mirror of https://github.com/Squidex/squidex.git
committed by
GitHub
34 changed files with 612 additions and 124 deletions
@ -0,0 +1,130 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using System.Reflection; |
||||
|
using Azure.Search.Documents.Indexes.Models; |
||||
|
|
||||
|
namespace Squidex.Extensions.Text.Azure |
||||
|
{ |
||||
|
public static class AzureIndexDefinition |
||||
|
{ |
||||
|
private static readonly Dictionary<string, (string Field, string Analyzer)> AllowedLanguages = new Dictionary<string, (string Field, string Analyzer)>(StringComparer.OrdinalIgnoreCase) |
||||
|
{ |
||||
|
["iv"] = ("text_iv", LexicalAnalyzerName.StandardLucene.ToString()), |
||||
|
["zh"] = ("text_zh", LexicalAnalyzerName.ZhHansLucene.ToString()) |
||||
|
}; |
||||
|
|
||||
|
static AzureIndexDefinition() |
||||
|
{ |
||||
|
var analyzers = |
||||
|
typeof(LexicalAnalyzerName) |
||||
|
.GetProperties(BindingFlags.Public | BindingFlags.Static) |
||||
|
.Select(x => x.GetValue(null)) |
||||
|
.Select(x => x.ToString()) |
||||
|
.OrderBy(x => x) |
||||
|
.ToList(); |
||||
|
|
||||
|
var addedLanguage = new HashSet<string>(); |
||||
|
|
||||
|
foreach (var analyzer in analyzers) |
||||
|
{ |
||||
|
var indexOfDot = analyzer.IndexOf('.', StringComparison.Ordinal); |
||||
|
|
||||
|
if (indexOfDot > 0) |
||||
|
{ |
||||
|
var language = analyzer[0..indexOfDot]; |
||||
|
|
||||
|
var isValidLanguage = |
||||
|
language.Length == 2 || |
||||
|
language.StartsWith("zh-", StringComparison.Ordinal); |
||||
|
|
||||
|
if (isValidLanguage && addedLanguage.Add(language)) |
||||
|
{ |
||||
|
var fieldName = $"text_{language.Replace('-', '_')}"; |
||||
|
|
||||
|
AllowedLanguages[language] = (fieldName, analyzer); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public static string GetTextField(string key) |
||||
|
{ |
||||
|
if (AllowedLanguages.TryGetValue(key, out var field)) |
||||
|
{ |
||||
|
return field.Field; |
||||
|
} |
||||
|
|
||||
|
if (key.Length > 2 && AllowedLanguages.TryGetValue(key[2..], out field)) |
||||
|
{ |
||||
|
return field.Field; |
||||
|
} |
||||
|
|
||||
|
return AllowedLanguages["iv"].Field; |
||||
|
} |
||||
|
|
||||
|
public static SearchIndex Create(string indexName) |
||||
|
{ |
||||
|
var fields = new List<SearchField> |
||||
|
{ |
||||
|
new SimpleField("docId", SearchFieldDataType.String) |
||||
|
{ |
||||
|
IsKey = true, |
||||
|
}, |
||||
|
new SimpleField("appId", SearchFieldDataType.String) |
||||
|
{ |
||||
|
IsFilterable = true, |
||||
|
}, |
||||
|
new SimpleField("appName", SearchFieldDataType.String) |
||||
|
{ |
||||
|
IsFilterable = false |
||||
|
}, |
||||
|
new SimpleField("contentId", SearchFieldDataType.String) |
||||
|
{ |
||||
|
IsFilterable = false |
||||
|
}, |
||||
|
new SearchableField("schemaId") |
||||
|
{ |
||||
|
IsFilterable = true |
||||
|
}, |
||||
|
new SimpleField("schemaName", SearchFieldDataType.String) |
||||
|
{ |
||||
|
IsFilterable = false |
||||
|
}, |
||||
|
new SimpleField("serveAll", SearchFieldDataType.Boolean) |
||||
|
{ |
||||
|
IsFilterable = true |
||||
|
}, |
||||
|
new SimpleField("servePublished", SearchFieldDataType.Boolean) |
||||
|
{ |
||||
|
IsFilterable = true |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
foreach (var (field, analyzer) in AllowedLanguages.Values) |
||||
|
{ |
||||
|
fields.Add( |
||||
|
new SearchableField(field) |
||||
|
{ |
||||
|
IsFilterable = false, |
||||
|
IsFacetable = false, |
||||
|
AnalyzerName = analyzer |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
var index = new SearchIndex(indexName) |
||||
|
{ |
||||
|
Fields = fields |
||||
|
}; |
||||
|
|
||||
|
return index; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,147 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using System.Text; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using Azure; |
||||
|
using Azure.Search.Documents; |
||||
|
using Azure.Search.Documents.Indexes; |
||||
|
using Azure.Search.Documents.Models; |
||||
|
using Squidex.Domain.Apps.Entities.Apps; |
||||
|
using Squidex.Domain.Apps.Entities.Contents; |
||||
|
using Squidex.Domain.Apps.Entities.Contents.Text; |
||||
|
using Squidex.Hosting; |
||||
|
using Squidex.Infrastructure; |
||||
|
|
||||
|
namespace Squidex.Extensions.Text.Azure |
||||
|
{ |
||||
|
public sealed class AzureTextIndex : IInitializable, ITextIndex |
||||
|
{ |
||||
|
private readonly SearchIndexClient indexClient; |
||||
|
private readonly SearchClient searchClient; |
||||
|
|
||||
|
public AzureTextIndex( |
||||
|
string serviceEndpoint, |
||||
|
string serviceApiKey, |
||||
|
string indexName) |
||||
|
{ |
||||
|
indexClient = new SearchIndexClient(new Uri(serviceEndpoint), new AzureKeyCredential(serviceApiKey)); |
||||
|
|
||||
|
searchClient = indexClient.GetSearchClient(indexName); |
||||
|
} |
||||
|
|
||||
|
public async Task InitializeAsync( |
||||
|
CancellationToken ct) |
||||
|
{ |
||||
|
await CreateIndexAsync(ct); |
||||
|
} |
||||
|
|
||||
|
public async Task ClearAsync( |
||||
|
CancellationToken ct = default) |
||||
|
{ |
||||
|
await indexClient.DeleteIndexAsync(searchClient.IndexName, ct); |
||||
|
|
||||
|
await CreateIndexAsync(ct); |
||||
|
} |
||||
|
|
||||
|
private async Task CreateIndexAsync( |
||||
|
CancellationToken ct) |
||||
|
{ |
||||
|
var index = AzureIndexDefinition.Create(searchClient.IndexName); |
||||
|
|
||||
|
await indexClient.CreateOrUpdateIndexAsync(index, true, true, ct); |
||||
|
} |
||||
|
|
||||
|
public async Task ExecuteAsync(IndexCommand[] commands, |
||||
|
CancellationToken ct = default) |
||||
|
{ |
||||
|
var batch = IndexDocumentsBatch.Create<SearchDocument>(); |
||||
|
|
||||
|
commands.Foreach(x => CommandFactory.CreateCommands(x, batch.Actions)); |
||||
|
|
||||
|
await searchClient.IndexDocumentsAsync(batch, cancellationToken: ct); |
||||
|
} |
||||
|
|
||||
|
public Task<List<DomainId>> SearchAsync(IAppEntity app, GeoQuery query, SearchScope scope, |
||||
|
CancellationToken ct = default) |
||||
|
{ |
||||
|
Guard.NotNull(app, nameof(app)); |
||||
|
Guard.NotNull(query, nameof(query)); |
||||
|
|
||||
|
return Task.FromResult<List<DomainId>>(null); |
||||
|
} |
||||
|
|
||||
|
public async Task<List<DomainId>> SearchAsync(IAppEntity app, TextQuery query, SearchScope scope, |
||||
|
CancellationToken ct = default) |
||||
|
{ |
||||
|
Guard.NotNull(app, nameof(app)); |
||||
|
Guard.NotNull(query, nameof(query)); |
||||
|
|
||||
|
if (string.IsNullOrWhiteSpace(query.Text)) |
||||
|
{ |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
var searchOptions = new SearchOptions |
||||
|
{ |
||||
|
Filter = BuildFilter(app, query, scope) |
||||
|
}; |
||||
|
|
||||
|
searchOptions.Select.Add("contentId"); |
||||
|
searchOptions.Size = 2000; |
||||
|
|
||||
|
var results = await searchClient.SearchAsync<SearchDocument>("*", searchOptions, ct); |
||||
|
|
||||
|
var ids = new List<DomainId>(); |
||||
|
|
||||
|
await foreach (var item in results.Value.GetResultsAsync().WithCancellation(ct)) |
||||
|
{ |
||||
|
if (item != null) |
||||
|
{ |
||||
|
ids.Add(DomainId.Create(item.Document["contentId"].ToString())); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return ids; |
||||
|
} |
||||
|
|
||||
|
private static string BuildFilter(IAppEntity app, TextQuery query, SearchScope scope) |
||||
|
{ |
||||
|
var sb = new StringBuilder(); |
||||
|
|
||||
|
sb.Append($"appId eq '{app.Id}' and {GetServeField(scope)} eq true"); |
||||
|
|
||||
|
if (query.RequiredSchemaIds?.Count > 0) |
||||
|
{ |
||||
|
var schemaIds = string.Join(" or ", query.RequiredSchemaIds.Select(x => $"schemaId eq '{x}'")); |
||||
|
|
||||
|
sb.Append($" and ({schemaIds}) and search.ismatchscoring('{query.Text}')"); |
||||
|
} |
||||
|
else if (query.PreferredSchemaId.HasValue) |
||||
|
{ |
||||
|
sb.Append($" and ((search.ismatchscoring('{query.Text}') and search.ismatchscoring('{query.PreferredSchemaId}', 'schemaId')) or search.ismatchscoring('{query.Text}'))"); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
sb.Append($" and search.ismatchscoring('{query.Text}')"); |
||||
|
} |
||||
|
|
||||
|
return sb.ToString(); |
||||
|
} |
||||
|
|
||||
|
private static string GetServeField(SearchScope scope) |
||||
|
{ |
||||
|
return scope == SearchScope.Published ? |
||||
|
"servePublished" : |
||||
|
"serveAll"; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,62 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using Microsoft.Extensions.Configuration; |
||||
|
using Microsoft.Extensions.DependencyInjection; |
||||
|
using Squidex.Domain.Apps.Entities.Contents.Text; |
||||
|
using Squidex.Hosting; |
||||
|
using Squidex.Hosting.Configuration; |
||||
|
using Squidex.Infrastructure.Plugins; |
||||
|
|
||||
|
namespace Squidex.Extensions.Text.Azure |
||||
|
{ |
||||
|
public sealed class AzureTextPlugin : IPlugin |
||||
|
{ |
||||
|
public void ConfigureServices(IServiceCollection services, IConfiguration config) |
||||
|
{ |
||||
|
var fullTextType = config.GetValue<string>("fullText:type"); |
||||
|
|
||||
|
if (string.Equals(fullTextType, "Azure", StringComparison.OrdinalIgnoreCase)) |
||||
|
{ |
||||
|
var serviceEndpoint = config.GetValue<string>("fullText:azure:serviceEndpoint"); |
||||
|
|
||||
|
if (string.IsNullOrWhiteSpace(serviceEndpoint)) |
||||
|
{ |
||||
|
var error = new ConfigurationError("Value is required.", "fullText:azure:serviceEndpoint"); |
||||
|
|
||||
|
throw new ConfigurationException(error); |
||||
|
} |
||||
|
|
||||
|
var serviceApiKey = config.GetValue<string>("fullText:azure:apiKey"); |
||||
|
|
||||
|
if (string.IsNullOrWhiteSpace(serviceApiKey)) |
||||
|
{ |
||||
|
var error = new ConfigurationError("Value is required.", "fullText:azure:apiKey"); |
||||
|
|
||||
|
throw new ConfigurationException(error); |
||||
|
} |
||||
|
|
||||
|
var indexName = config.GetValue<string>("fullText:azure:indexName"); |
||||
|
|
||||
|
if (string.IsNullOrWhiteSpace(indexName)) |
||||
|
{ |
||||
|
indexName = "squidex-index"; |
||||
|
} |
||||
|
|
||||
|
services.AddSingleton( |
||||
|
c => new AzureTextIndex(serviceEndpoint, serviceApiKey, indexName)); |
||||
|
|
||||
|
services.AddSingleton<ITextIndex>( |
||||
|
c => c.GetRequiredService<AzureTextIndex>()); |
||||
|
|
||||
|
services.AddSingleton<IInitializable>( |
||||
|
c => c.GetRequiredService<AzureTextIndex>()); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,81 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Text; |
||||
|
using Azure.Search.Documents.Models; |
||||
|
using Squidex.Domain.Apps.Entities.Contents.Text; |
||||
|
|
||||
|
namespace Squidex.Extensions.Text.Azure |
||||
|
{ |
||||
|
public static class CommandFactory |
||||
|
{ |
||||
|
public static void CreateCommands(IndexCommand command, IList<IndexDocumentsAction<SearchDocument>> batch) |
||||
|
{ |
||||
|
switch (command) |
||||
|
{ |
||||
|
case UpsertIndexEntry upsert: |
||||
|
batch.Add(UpsertEntry(upsert)); |
||||
|
break; |
||||
|
case UpdateIndexEntry update: |
||||
|
batch.Add(UpdateEntry(update)); |
||||
|
break; |
||||
|
case DeleteIndexEntry delete: |
||||
|
batch.Add(DeleteEntry(delete)); |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static IndexDocumentsAction<SearchDocument> UpsertEntry(UpsertIndexEntry upsert) |
||||
|
{ |
||||
|
var searchDocument = new SearchDocument |
||||
|
{ |
||||
|
["docId"] = upsert.DocId.ToBase64(), |
||||
|
["appId"] = upsert.AppId.Id.ToString(), |
||||
|
["appName"] = upsert.AppId.Name, |
||||
|
["contentId"] = upsert.ContentId.ToString(), |
||||
|
["schemaId"] = upsert.SchemaId.Id.ToString(), |
||||
|
["schemaName"] = upsert.SchemaId.Name, |
||||
|
["serveAll"] = upsert.ServeAll, |
||||
|
["servePublished"] = upsert.ServePublished |
||||
|
}; |
||||
|
|
||||
|
if (upsert.Texts != null) |
||||
|
{ |
||||
|
foreach (var (key, value) in upsert.Texts) |
||||
|
{ |
||||
|
searchDocument[AzureIndexDefinition.GetTextField(key)] = value; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return IndexDocumentsAction.MergeOrUpload(searchDocument); |
||||
|
} |
||||
|
|
||||
|
private static IndexDocumentsAction<SearchDocument> UpdateEntry(UpdateIndexEntry update) |
||||
|
{ |
||||
|
var searchDocument = new SearchDocument |
||||
|
{ |
||||
|
["docId"] = update.DocId.ToBase64(), |
||||
|
["serveAll"] = update.ServeAll, |
||||
|
["servePublished"] = update.ServePublished, |
||||
|
}; |
||||
|
|
||||
|
return IndexDocumentsAction.MergeOrUpload(searchDocument); |
||||
|
} |
||||
|
|
||||
|
private static IndexDocumentsAction<SearchDocument> DeleteEntry(DeleteIndexEntry delete) |
||||
|
{ |
||||
|
return IndexDocumentsAction.Delete("docId", delete.DocId.ToBase64()); |
||||
|
} |
||||
|
|
||||
|
private static string ToBase64(this string value) |
||||
|
{ |
||||
|
return Convert.ToBase64String(Encoding.Default.GetBytes(value)); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,53 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using Microsoft.Extensions.Configuration; |
||||
|
using Microsoft.Extensions.DependencyInjection; |
||||
|
using Squidex.Domain.Apps.Entities.Contents.Text; |
||||
|
using Squidex.Hosting; |
||||
|
using Squidex.Hosting.Configuration; |
||||
|
using Squidex.Infrastructure.Plugins; |
||||
|
|
||||
|
namespace Squidex.Extensions.Text.ElasticSearch |
||||
|
{ |
||||
|
public sealed class ElasticSearchTextPlugin : IPlugin |
||||
|
{ |
||||
|
public void ConfigureServices(IServiceCollection services, IConfiguration config) |
||||
|
{ |
||||
|
var fullTextType = config.GetValue<string>("fullText:type"); |
||||
|
|
||||
|
if (string.Equals(fullTextType, "elastic", StringComparison.OrdinalIgnoreCase)) |
||||
|
{ |
||||
|
var elasticConfiguration = config.GetValue<string>("fullText:elastic:configuration"); |
||||
|
|
||||
|
if (string.IsNullOrWhiteSpace(elasticConfiguration)) |
||||
|
{ |
||||
|
var error = new ConfigurationError("Value is required.", "fullText:elastic:configuration"); |
||||
|
|
||||
|
throw new ConfigurationException(error); |
||||
|
} |
||||
|
|
||||
|
var indexName = config.GetValue<string>("fullText:elastic:indexName"); |
||||
|
|
||||
|
if (string.IsNullOrWhiteSpace(indexName)) |
||||
|
{ |
||||
|
indexName = "squidex-index"; |
||||
|
} |
||||
|
|
||||
|
services.AddSingleton( |
||||
|
c => new ElasticSearchTextIndex(elasticConfiguration, indexName)); |
||||
|
|
||||
|
services.AddSingleton<ITextIndex>( |
||||
|
c => c.GetRequiredService<ElasticSearchTextIndex>()); |
||||
|
|
||||
|
services.AddSingleton<IInitializable>( |
||||
|
c => c.GetRequiredService<ElasticSearchTextIndex>()); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue