diff --git a/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchClient.cs b/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchClient.cs new file mode 100644 index 000000000..55692f38c --- /dev/null +++ b/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchClient.cs @@ -0,0 +1,78 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Elasticsearch.Net; + +namespace Squidex.Extensions.Text.ElasticSearch +{ + internal class ElasticSearchClient : IElasticSearchClient + { + private readonly IElasticLowLevelClient elasticSearch; + + public ElasticSearchClient(string configurationString) + { + var config = new ConnectionConfiguration(new Uri(configurationString)); + + elasticSearch = new ElasticLowLevelClient(config); + } + + public async Task CreateIndexAsync(string indexName, T request, + CancellationToken ct) + { + var result = await elasticSearch.Indices.PutMappingAsync(indexName, CreatePost(request), ctx: ct); + + if (!result.Success) + { + throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException); + } + } + + public async Task BulkAsync(List requests, + CancellationToken ct) + { + var result = await elasticSearch.BulkAsync(CreatePost(requests), ctx: ct); + + if (!result.Success) + { + throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException); + } + } + + public async Task> SearchAsync(string indexName, T request, + CancellationToken ct) + { + var result = await elasticSearch.SearchAsync(indexName, CreatePost(request), ctx: ct); + + if (!result.Success) + { + throw result.OriginalException; + } + + var hits = new List(); + + foreach (var item in result.Body.hits.hits) + { + if (item != null) + { + hits.Add(item); + } + } + + return hits; + } + + private static PostData CreatePost(List requests) + { + return PostData.MultiJson(requests.OfType()); + } + + private static PostData CreatePost(T data) + { + return new SerializableData(data); + } + } +} diff --git a/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchIndexDefinition.cs b/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchIndexDefinition.cs index 020c373c0..2f204c7fc 100644 --- a/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchIndexDefinition.cs +++ b/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchIndexDefinition.cs @@ -5,8 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Elasticsearch.Net; - namespace Squidex.Extensions.Text.ElasticSearch { public static class ElasticSearchIndexDefinition @@ -96,7 +94,7 @@ namespace Squidex.Extensions.Text.ElasticSearch return "texts.iv"; } - public static async Task ApplyAsync(IElasticLowLevelClient elastic, string indexName, + public static Task ApplyAsync(IElasticSearchClient client, string indexName, CancellationToken ct = default) { var query = new @@ -119,17 +117,7 @@ namespace Squidex.Extensions.Text.ElasticSearch }; } - var result = await elastic.Indices.PutMappingAsync(indexName, CreatePost(query), ctx: ct); - - if (!result.Success) - { - throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException); - } - } - - private static PostData CreatePost(T data) - { - return new SerializableData(data); + return client.CreateIndexAsync(indexName, query, ct); } } } diff --git a/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextIndex.cs b/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextIndex.cs index 0ac7ff32c..c54f23db6 100644 --- a/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextIndex.cs +++ b/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextIndex.cs @@ -6,7 +6,6 @@ // ========================================================================== using System.Text.RegularExpressions; -using Elasticsearch.Net; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents.Text; @@ -20,17 +19,14 @@ namespace Squidex.Extensions.Text.ElasticSearch { private static readonly Regex LanguageRegex = new Regex(@"[^\w]+([a-z\-_]{2,}):", RegexOptions.Compiled | RegexOptions.ExplicitCapture); private static readonly Regex LanguageRegexStart = new Regex(@"$^([a-z\-_]{2,}):", RegexOptions.Compiled | RegexOptions.ExplicitCapture); - private readonly ElasticLowLevelClient client; + private readonly IJsonSerializer jsonSerializer; + private readonly IElasticSearchClient elasticClient; private readonly QueryParser queryParser = new QueryParser(ElasticSearchIndexDefinition.GetFieldPath); private readonly string indexName; - private readonly IJsonSerializer jsonSerializer; - public ElasticSearchTextIndex(string configurationString, string indexName, IJsonSerializer jsonSerializer) + public ElasticSearchTextIndex(IElasticSearchClient elasticClient, string indexName, IJsonSerializer jsonSerializer) { - var config = new ConnectionConfiguration(new Uri(configurationString)); - - client = new ElasticLowLevelClient(config); - + this.elasticClient = elasticClient; this.indexName = indexName; this.jsonSerializer = jsonSerializer; } @@ -38,7 +34,7 @@ namespace Squidex.Extensions.Text.ElasticSearch public Task InitializeAsync( CancellationToken ct) { - return ElasticSearchIndexDefinition.ApplyAsync(client, indexName, ct); + return ElasticSearchIndexDefinition.ApplyAsync(elasticClient, indexName, ct); } public Task ClearAsync( @@ -47,7 +43,7 @@ namespace Squidex.Extensions.Text.ElasticSearch return Task.CompletedTask; } - public async Task ExecuteAsync(IndexCommand[] commands, + public Task ExecuteAsync(IndexCommand[] commands, CancellationToken ct = default) { var args = new List(); @@ -59,15 +55,10 @@ namespace Squidex.Extensions.Text.ElasticSearch if (args.Count == 0) { - return; + return Task.CompletedTask; } - var result = await client.BulkAsync(PostData.MultiJson(args), ctx: ct); - - if (!result.Success) - { - throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException); - } + return elasticClient.BulkAsync(args, ct); } public async Task> SearchAsync(IAppEntity app, GeoQuery query, SearchScope scope, @@ -220,21 +211,13 @@ namespace Squidex.Extensions.Text.ElasticSearch private async Task> SearchAsync(object query, CancellationToken ct) { - var result = await client.SearchAsync(indexName, CreatePost(query), ctx: ct); - - if (!result.Success) - { - throw result.OriginalException; - } + var hits = await elasticClient.SearchAsync(indexName, query, ct); var ids = new List(); - foreach (var item in result.Body.hits.hits) + foreach (var item in hits) { - if (item != null) - { - ids.Add(DomainId.Create(item["_source"]["contentId"])); - } + ids.Add(DomainId.Create(item["_source"]["contentId"])); } return ids; @@ -242,14 +225,7 @@ namespace Squidex.Extensions.Text.ElasticSearch private static string GetServeField(SearchScope scope) { - return scope == SearchScope.Published ? - "servePublished" : - "serveAll"; - } - - private static PostData CreatePost(T data) - { - return new SerializableData(data); + return scope == SearchScope.Published ? "servePublished" : "serveAll"; } } } diff --git a/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextPlugin.cs b/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextPlugin.cs index 04fcdd164..813b52698 100644 --- a/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextPlugin.cs +++ b/backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextPlugin.cs @@ -23,9 +23,9 @@ namespace Squidex.Extensions.Text.ElasticSearch if (string.Equals(fullTextType, "elastic", StringComparison.OrdinalIgnoreCase)) { - var elasticConfiguration = config.GetValue("fullText:elastic:configuration"); + var configuration = config.GetValue("fullText:elastic:configuration"); - if (string.IsNullOrWhiteSpace(elasticConfiguration)) + if (string.IsNullOrWhiteSpace(configuration)) { var error = new ConfigurationError("Value is required.", "fullText:elastic:configuration"); @@ -39,8 +39,23 @@ namespace Squidex.Extensions.Text.ElasticSearch indexName = "squidex-index"; } - services.AddSingleton( - c => new ElasticSearchTextIndex(elasticConfiguration, indexName, c.GetRequiredService())); + var openSearch = config.GetValue("fullText:elastic:openSearch"); + + services.AddSingleton(c => + { + IElasticSearchClient elasticSearchClient; + + if (openSearch) + { + elasticSearchClient = new OpenSearchClient(configuration); + } + else + { + elasticSearchClient = new ElasticSearchClient(configuration); + } + + return new ElasticSearchTextIndex(elasticSearchClient, indexName, c.GetRequiredService()); + }); services.AddSingleton( c => c.GetRequiredService()); diff --git a/backend/extensions/Squidex.Extensions/Text/ElasticSearch/IElasticSearchClient.cs b/backend/extensions/Squidex.Extensions/Text/ElasticSearch/IElasticSearchClient.cs new file mode 100644 index 000000000..4d27540e1 --- /dev/null +++ b/backend/extensions/Squidex.Extensions/Text/ElasticSearch/IElasticSearchClient.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Extensions.Text.ElasticSearch +{ + public interface IElasticSearchClient + { + Task CreateIndexAsync(string indexName, T request, + CancellationToken ct); + + Task BulkAsync(List requests, + CancellationToken ct); + + Task> SearchAsync(string indexName, T request, + CancellationToken ct); + } +} diff --git a/backend/extensions/Squidex.Extensions/Text/ElasticSearch/OpenSearchClient.cs b/backend/extensions/Squidex.Extensions/Text/ElasticSearch/OpenSearchClient.cs new file mode 100644 index 000000000..9796e048e --- /dev/null +++ b/backend/extensions/Squidex.Extensions/Text/ElasticSearch/OpenSearchClient.cs @@ -0,0 +1,78 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using OpenSearch.Net; + +namespace Squidex.Extensions.Text.ElasticSearch +{ + public sealed class OpenSearchClient : IElasticSearchClient + { + private readonly IOpenSearchLowLevelClient openSearch; + + public OpenSearchClient(string configurationString) + { + var config = new ConnectionConfiguration(new Uri(configurationString)); + + openSearch = new OpenSearchLowLevelClient(config); + } + + public async Task CreateIndexAsync(string indexName, T request, + CancellationToken ct) + { + var result = await openSearch.Indices.PutMappingAsync(indexName, CreatePost(request), ctx: ct); + + if (!result.Success) + { + throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException); + } + } + + public async Task BulkAsync(List requests, + CancellationToken ct) + { + var result = await openSearch.BulkAsync(CreatePost(requests), ctx: ct); + + if (!result.Success) + { + throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException); + } + } + + public async Task> SearchAsync(string indexName, T request, + CancellationToken ct) + { + var result = await openSearch.SearchAsync(indexName, CreatePost(request), ctx: ct); + + if (!result.Success) + { + throw result.OriginalException; + } + + var hits = new List(); + + foreach (var item in result.Body.hits.hits) + { + if (item != null) + { + hits.Add(item); + } + } + + return hits; + } + + private static PostData CreatePost(List requests) + { + return PostData.MultiJson(requests.OfType()); + } + + private static PostData CreatePost(T data) + { + return new SerializableData(data); + } + } +} diff --git a/backend/src/Squidex/appsettings.json b/backend/src/Squidex/appsettings.json index f716eca8d..87378abba 100644 --- a/backend/src/Squidex/appsettings.json +++ b/backend/src/Squidex/appsettings.json @@ -40,7 +40,10 @@ "configuration": "http://localhost:9200", // The name of the index. - "indexName": "squidex" + "indexName": "squidex", + + // True, to use the Open Search client. + "openSearch": false }, "azure": { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/ElasticSearchTextIndexFixture.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/ElasticSearchTextIndexFixture.cs new file mode 100644 index 000000000..780c5cd0d --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/ElasticSearchTextIndexFixture.cs @@ -0,0 +1,37 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.TestHelpers; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Extensions.Text.ElasticSearch; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Contents.Text +{ + public sealed class ElasticSearchTextIndexFixture : IAsyncLifetime + { + public ElasticSearchTextIndex Index { get; } + + public ElasticSearchTextIndexFixture() + { + Index = new ElasticSearchTextIndex( + new ElasticSearchClient(TestConfig.Configuration["elastic:configuration"]), + TestConfig.Configuration["elastic:indexName"], + TestUtils.DefaultSerializer); + } + + public Task InitializeAsync() + { + return Index.InitializeAsync(default); + } + + public Task DisposeAsync() + { + return Task.CompletedTask; + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/ElasticSearchTextIndexTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/ElasticSearchTextIndexTests.cs new file mode 100644 index 000000000..6aae342d1 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/ElasticSearchTextIndexTests.cs @@ -0,0 +1,59 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Xunit; + +#pragma warning disable SA1300 // Element should begin with upper-case letter + +namespace Squidex.Domain.Apps.Entities.Contents.Text +{ + [Trait("Category", "Dependencies")] + public class ElasticSearchTextIndexTests : TextIndexerTestsBase, IClassFixture + { + public override bool SupportsGeo => true; + + public override int WaitAfterUpdate => 2000; + + public ElasticSearchTextIndexFixture _ { get; } + + public ElasticSearchTextIndexTests(ElasticSearchTextIndexFixture fixture) + { + _ = fixture; + } + + public override ITextIndex CreateIndex() + { + return _.Index; + } + + [Fact] + public async Task Should_retrieve_english_stopword_only_for_german_query() + { + await CreateTextAsync(ids1[0], "de", "and y"); + await CreateTextAsync(ids2[0], "en", "and y"); + + await SearchText(expected: ids2, text: "und"); + } + + [Fact] + public async Task Should_retrieve_german_stopword_only_for_english_query() + { + await CreateTextAsync(ids1[0], "de", "and und"); + await CreateTextAsync(ids2[0], "en", "and und"); + + await SearchText(expected: ids1, text: "and"); + } + + [Fact] + public async Task Should_index_cjk_content_and_retrieve() + { + await CreateTextAsync(ids1[0], "zh", "東京大学"); + + await SearchText(expected: ids1, text: "東京"); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/ElasticTextIndexFixture.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/OpenSearchTextIndexFixture.cs similarity index 83% rename from backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/ElasticTextIndexFixture.cs rename to backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/OpenSearchTextIndexFixture.cs index 6aa818ad8..fc3941074 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/ElasticTextIndexFixture.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/OpenSearchTextIndexFixture.cs @@ -12,14 +12,14 @@ using Xunit; namespace Squidex.Domain.Apps.Entities.Contents.Text { - public sealed class ElasticTextIndexFixture : IAsyncLifetime + public sealed class OpenSearchTextIndexFixture : IAsyncLifetime { public ElasticSearchTextIndex Index { get; } - public ElasticTextIndexFixture() + public OpenSearchTextIndexFixture() { Index = new ElasticSearchTextIndex( - TestConfig.Configuration["elastic:configuration"], + new OpenSearchClient(TestConfig.Configuration["elastic:configuration"]), TestConfig.Configuration["elastic:indexName"], TestUtils.DefaultSerializer); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/ElasticTextIndexTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/OpenSearchTextIndexTests.cs similarity index 87% rename from backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/ElasticTextIndexTests.cs rename to backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/OpenSearchTextIndexTests.cs index 90ef4416b..cdc693ac2 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/ElasticTextIndexTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/OpenSearchTextIndexTests.cs @@ -12,15 +12,15 @@ using Xunit; namespace Squidex.Domain.Apps.Entities.Contents.Text { [Trait("Category", "Dependencies")] - public class ElasticTextIndexTests : TextIndexerTestsBase, IClassFixture + public class OpenSearchTextIndexTests : TextIndexerTestsBase, IClassFixture { public override bool SupportsGeo => true; public override int WaitAfterUpdate => 2000; - public ElasticTextIndexFixture _ { get; } + public ElasticSearchTextIndexFixture _ { get; } - public ElasticTextIndexTests(ElasticTextIndexFixture fixture) + public OpenSearchTextIndexTests(ElasticSearchTextIndexFixture fixture) { _ = fixture; } diff --git a/frontend/src/app/features/rules/shared/actions/generic-action.component.html b/frontend/src/app/features/rules/shared/actions/generic-action.component.html index a1a570c69..0375628de 100644 --- a/frontend/src/app/features/rules/shared/actions/generic-action.component.html +++ b/frontend/src/app/features/rules/shared/actions/generic-action.component.html @@ -27,7 +27,7 @@ diff --git a/frontend/src/app/shared/state/rules.forms.ts b/frontend/src/app/shared/state/rules.forms.ts index a68de081c..4514653eb 100644 --- a/frontend/src/app/shared/state/rules.forms.ts +++ b/frontend/src/app/shared/state/rules.forms.ts @@ -25,7 +25,13 @@ export class ActionForm extends Form { Validators.required : Validators.nullValidator; - controls[property.name] = new FormControl(undefined, validator); + let defaultValue = undefined; + + if (property.isRequired && property.options) { + defaultValue = property.options[0]; + } + + controls[property.name] = new FormControl(defaultValue, validator); } return new ExtendedFormGroup(controls);