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.
// ==========================================================================
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<StringResponse>(indexName, CreatePost(query), ctx: 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);
return client.CreateIndexAsync(indexName, query, ct);
}
}
}

48
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<object>();
@ -59,15 +55,10 @@ namespace Squidex.Extensions.Text.ElasticSearch
if (args.Count == 0)
{
return;
return Task.CompletedTask;
}
var result = await client.BulkAsync<StringResponse>(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<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,
CancellationToken ct)
{
var result = await client.SearchAsync<DynamicResponse>(indexName, CreatePost(query), ctx: ct);
if (!result.Success)
{
throw result.OriginalException;
}
var hits = await elasticClient.SearchAsync(indexName, query, ct);
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;
@ -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>(T data)
{
return new SerializableData<T>(data);
return scope == SearchScope.Published ? "servePublished" : "serveAll";
}
}
}

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))
{
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");
@ -39,8 +39,23 @@ namespace Squidex.Extensions.Text.ElasticSearch
indexName = "squidex-index";
}
services.AddSingleton(
c => new ElasticSearchTextIndex(elasticConfiguration, indexName, c.GetRequiredService<IJsonSerializer>()));
var openSearch = config.GetValue<bool>("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<IJsonSerializer>());
});
services.AddSingleton<ITextIndex>(
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",
// The name of the index.
"indexName": "squidex"
"indexName": "squidex",
// True, to use the Open Search client.
"openSearch": false
},
"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
{
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);
}

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
{
[Trait("Category", "Dependencies")]
public class ElasticTextIndexTests : TextIndexerTestsBase, IClassFixture<ElasticTextIndexFixture>
public class OpenSearchTextIndexTests : TextIndexerTestsBase, IClassFixture<OpenSearchTextIndexFixture>
{
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;
}

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

@ -27,7 +27,7 @@
</ng-container>
<ng-container *ngSwitchCase="'Dropdown'">
<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>
</select>
</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.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);

Loading…
Cancel
Save