Browse Source

OpenSearch support for text index.

pull/927/head
Sebastian 4 years ago
parent
commit
a8879eb5be
  1. 78
      backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchClient.cs
  2. 16
      backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchIndexDefinition.cs
  3. 48
      backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextIndex.cs
  4. 23
      backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextPlugin.cs
  5. 21
      backend/extensions/Squidex.Extensions/Text/ElasticSearch/IElasticSearchClient.cs
  6. 78
      backend/extensions/Squidex.Extensions/Text/ElasticSearch/OpenSearchClient.cs
  7. 5
      backend/src/Squidex/appsettings.json
  8. 37
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/ElasticSearchTextIndexFixture.cs
  9. 59
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/ElasticSearchTextIndexTests.cs
  10. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/OpenSearchTextIndexFixture.cs
  11. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/OpenSearchTextIndexTests.cs
  12. 2
      frontend/src/app/features/rules/shared/actions/generic-action.component.html
  13. 8
      frontend/src/app/shared/state/rules.forms.ts

78
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<T>(string indexName, T request,
CancellationToken ct)
{
var result = await elasticSearch.Indices.PutMappingAsync<StringResponse>(indexName, CreatePost(request), ctx: ct);
if (!result.Success)
{
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException);
}
}
public async Task BulkAsync<T>(List<T> requests,
CancellationToken ct)
{
var result = await elasticSearch.BulkAsync<StringResponse>(CreatePost(requests), ctx: ct);
if (!result.Success)
{
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException);
}
}
public async Task<List<dynamic>> SearchAsync<T>(string indexName, T request,
CancellationToken ct)
{
var result = await elasticSearch.SearchAsync<DynamicResponse>(indexName, CreatePost(request), ctx: ct);
if (!result.Success)
{
throw result.OriginalException;
}
var hits = new List<dynamic>();
foreach (var item in result.Body.hits.hits)
{
if (item != null)
{
hits.Add(item);
}
}
return hits;
}
private static PostData CreatePost<T>(List<T> requests)
{
return PostData.MultiJson(requests.OfType<object>());
}
private static PostData CreatePost<T>(T data)
{
return new SerializableData<T>(data);
}
}
}

16
backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchIndexDefinition.cs

@ -5,8 +5,6 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Elasticsearch.Net;
namespace Squidex.Extensions.Text.ElasticSearch namespace Squidex.Extensions.Text.ElasticSearch
{ {
public static class ElasticSearchIndexDefinition public static class ElasticSearchIndexDefinition
@ -96,7 +94,7 @@ namespace Squidex.Extensions.Text.ElasticSearch
return "texts.iv"; return "texts.iv";
} }
public static async Task ApplyAsync(IElasticLowLevelClient elastic, string indexName, public static Task ApplyAsync(IElasticSearchClient client, string indexName,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var query = new var query = new
@ -119,17 +117,7 @@ namespace Squidex.Extensions.Text.ElasticSearch
}; };
} }
var result = await elastic.Indices.PutMappingAsync<StringResponse>(indexName, CreatePost(query), ctx: ct); return client.CreateIndexAsync(indexName, query, ct);
if (!result.Success)
{
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException);
}
}
private static PostData CreatePost<T>(T data)
{
return new SerializableData<T>(data);
} }
} }
} }

48
backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextIndex.cs

@ -6,7 +6,6 @@
// ========================================================================== // ==========================================================================
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Elasticsearch.Net;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Text; 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 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 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 QueryParser queryParser = new QueryParser(ElasticSearchIndexDefinition.GetFieldPath);
private readonly string indexName; 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)); this.elasticClient = elasticClient;
client = new ElasticLowLevelClient(config);
this.indexName = indexName; this.indexName = indexName;
this.jsonSerializer = jsonSerializer; this.jsonSerializer = jsonSerializer;
} }
@ -38,7 +34,7 @@ namespace Squidex.Extensions.Text.ElasticSearch
public Task InitializeAsync( public Task InitializeAsync(
CancellationToken ct) CancellationToken ct)
{ {
return ElasticSearchIndexDefinition.ApplyAsync(client, indexName, ct); return ElasticSearchIndexDefinition.ApplyAsync(elasticClient, indexName, ct);
} }
public Task ClearAsync( public Task ClearAsync(
@ -47,7 +43,7 @@ namespace Squidex.Extensions.Text.ElasticSearch
return Task.CompletedTask; return Task.CompletedTask;
} }
public async Task ExecuteAsync(IndexCommand[] commands, public Task ExecuteAsync(IndexCommand[] commands,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var args = new List<object>(); var args = new List<object>();
@ -59,15 +55,10 @@ namespace Squidex.Extensions.Text.ElasticSearch
if (args.Count == 0) if (args.Count == 0)
{ {
return; return Task.CompletedTask;
} }
var result = await client.BulkAsync<StringResponse>(PostData.MultiJson(args), ctx: ct); return elasticClient.BulkAsync(args, ct);
if (!result.Success)
{
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException);
}
} }
public async Task<List<DomainId>> SearchAsync(IAppEntity app, GeoQuery query, SearchScope scope, public async Task<List<DomainId>> SearchAsync(IAppEntity app, GeoQuery query, SearchScope scope,
@ -220,21 +211,13 @@ namespace Squidex.Extensions.Text.ElasticSearch
private async Task<List<DomainId>> SearchAsync(object query, private async Task<List<DomainId>> SearchAsync(object query,
CancellationToken ct) CancellationToken ct)
{ {
var result = await client.SearchAsync<DynamicResponse>(indexName, CreatePost(query), ctx: ct); var hits = await elasticClient.SearchAsync(indexName, query, ct);
if (!result.Success)
{
throw result.OriginalException;
}
var ids = new List<DomainId>(); var ids = new List<DomainId>();
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; return ids;
@ -242,14 +225,7 @@ namespace Squidex.Extensions.Text.ElasticSearch
private static string GetServeField(SearchScope scope) private static string GetServeField(SearchScope scope)
{ {
return scope == SearchScope.Published ? return scope == SearchScope.Published ? "servePublished" : "serveAll";
"servePublished" :
"serveAll";
}
private static PostData CreatePost<T>(T data)
{
return new SerializableData<T>(data);
} }
} }
} }

23
backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextPlugin.cs

@ -23,9 +23,9 @@ namespace Squidex.Extensions.Text.ElasticSearch
if (string.Equals(fullTextType, "elastic", StringComparison.OrdinalIgnoreCase)) if (string.Equals(fullTextType, "elastic", StringComparison.OrdinalIgnoreCase))
{ {
var elasticConfiguration = config.GetValue<string>("fullText:elastic:configuration"); var configuration = config.GetValue<string>("fullText:elastic:configuration");
if (string.IsNullOrWhiteSpace(elasticConfiguration)) if (string.IsNullOrWhiteSpace(configuration))
{ {
var error = new ConfigurationError("Value is required.", "fullText:elastic:configuration"); var error = new ConfigurationError("Value is required.", "fullText:elastic:configuration");
@ -39,8 +39,23 @@ namespace Squidex.Extensions.Text.ElasticSearch
indexName = "squidex-index"; indexName = "squidex-index";
} }
services.AddSingleton( var openSearch = config.GetValue<bool>("fullText:elastic:openSearch");
c => new ElasticSearchTextIndex(elasticConfiguration, indexName, c.GetRequiredService<IJsonSerializer>()));
services.AddSingleton(c =>
{
IElasticSearchClient elasticSearchClient;
if (openSearch)
{
elasticSearchClient = new OpenSearchClient(configuration);
}
else
{
elasticSearchClient = new ElasticSearchClient(configuration);
}
return new ElasticSearchTextIndex(elasticSearchClient, indexName, c.GetRequiredService<IJsonSerializer>());
});
services.AddSingleton<ITextIndex>( services.AddSingleton<ITextIndex>(
c => c.GetRequiredService<ElasticSearchTextIndex>()); c => c.GetRequiredService<ElasticSearchTextIndex>());

21
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<T>(string indexName, T request,
CancellationToken ct);
Task BulkAsync<T>(List<T> requests,
CancellationToken ct);
Task<List<dynamic>> SearchAsync<T>(string indexName, T request,
CancellationToken ct);
}
}

78
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<T>(string indexName, T request,
CancellationToken ct)
{
var result = await openSearch.Indices.PutMappingAsync<StringResponse>(indexName, CreatePost(request), ctx: ct);
if (!result.Success)
{
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException);
}
}
public async Task BulkAsync<T>(List<T> requests,
CancellationToken ct)
{
var result = await openSearch.BulkAsync<StringResponse>(CreatePost(requests), ctx: ct);
if (!result.Success)
{
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException);
}
}
public async Task<List<dynamic>> SearchAsync<T>(string indexName, T request,
CancellationToken ct)
{
var result = await openSearch.SearchAsync<DynamicResponse>(indexName, CreatePost(request), ctx: ct);
if (!result.Success)
{
throw result.OriginalException;
}
var hits = new List<dynamic>();
foreach (var item in result.Body.hits.hits)
{
if (item != null)
{
hits.Add(item);
}
}
return hits;
}
private static PostData CreatePost<T>(List<T> requests)
{
return PostData.MultiJson(requests.OfType<object>());
}
private static PostData CreatePost<T>(T data)
{
return new SerializableData<T>(data);
}
}
}

5
backend/src/Squidex/appsettings.json

@ -40,7 +40,10 @@
"configuration": "http://localhost:9200", "configuration": "http://localhost:9200",
// The name of the index. // The name of the index.
"indexName": "squidex" "indexName": "squidex",
// True, to use the Open Search client.
"openSearch": false
}, },
"azure": { "azure": {

37
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;
}
}
}

59
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<ElasticSearchTextIndexFixture>
{
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: "東京");
}
}
}

6
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/ElasticTextIndexFixture.cs → backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/OpenSearchTextIndexFixture.cs

@ -12,14 +12,14 @@ using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents.Text namespace Squidex.Domain.Apps.Entities.Contents.Text
{ {
public sealed class ElasticTextIndexFixture : IAsyncLifetime public sealed class OpenSearchTextIndexFixture : IAsyncLifetime
{ {
public ElasticSearchTextIndex Index { get; } public ElasticSearchTextIndex Index { get; }
public ElasticTextIndexFixture() public OpenSearchTextIndexFixture()
{ {
Index = new ElasticSearchTextIndex( Index = new ElasticSearchTextIndex(
TestConfig.Configuration["elastic:configuration"], new OpenSearchClient(TestConfig.Configuration["elastic:configuration"]),
TestConfig.Configuration["elastic:indexName"], TestConfig.Configuration["elastic:indexName"],
TestUtils.DefaultSerializer); TestUtils.DefaultSerializer);
} }

6
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/ElasticTextIndexTests.cs → backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/OpenSearchTextIndexTests.cs

@ -12,15 +12,15 @@ using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents.Text namespace Squidex.Domain.Apps.Entities.Contents.Text
{ {
[Trait("Category", "Dependencies")] [Trait("Category", "Dependencies")]
public class ElasticTextIndexTests : TextIndexerTestsBase, IClassFixture<ElasticTextIndexFixture> public class OpenSearchTextIndexTests : TextIndexerTestsBase, IClassFixture<OpenSearchTextIndexFixture>
{ {
public override bool SupportsGeo => true; public override bool SupportsGeo => true;
public override int WaitAfterUpdate => 2000; public override int WaitAfterUpdate => 2000;
public ElasticTextIndexFixture _ { get; } public ElasticSearchTextIndexFixture _ { get; }
public ElasticTextIndexTests(ElasticTextIndexFixture fixture) public OpenSearchTextIndexTests(ElasticSearchTextIndexFixture fixture)
{ {
_ = fixture; _ = fixture;
} }

2
frontend/src/app/features/rules/shared/actions/generic-action.component.html

@ -27,7 +27,7 @@
</ng-container> </ng-container>
<ng-container *ngSwitchCase="'Dropdown'"> <ng-container *ngSwitchCase="'Dropdown'">
<select class="form-select" [formControlName]="property.name"> <select class="form-select" [formControlName]="property.name">
<option></option> <option *ngIf="!property.isRequired"></option>
<option *ngFor="let option of property.options" [ngValue]="option">{{option}}</option> <option *ngFor="let option of property.options" [ngValue]="option">{{option}}</option>
</select> </select>
</ng-container> </ng-container>

8
frontend/src/app/shared/state/rules.forms.ts

@ -25,7 +25,13 @@ export class ActionForm extends Form<any, FormGroup> {
Validators.required : Validators.required :
Validators.nullValidator; 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); return new ExtendedFormGroup(controls);

Loading…
Cancel
Save