From 5468a7836c5d904330c2fc44c8ea123071202eb5 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Thu, 7 Dec 2023 11:57:48 -0800 Subject: [PATCH] Add a way to provide a custom Elasticsearch query (#14843) --- .../Drivers/ElasticSettingsDisplayDriver.cs | 128 ++++++++++++------ .../Services/ElasticSearchService.cs | 49 +++++-- .../ViewModels/ElasticSettingsViewModel.cs | 13 +- .../Views/ElasticSettings.Edit.cshtml | 74 ++++++---- .../Recipes/blog.elastic.search.recipe.json | 5 +- .../OrchardCore.Abstractions/JsonHelpers.cs | 33 +++++ .../Models/ElasticSettings.cs | 38 ++++-- .../Services/ElasticIndexManager.cs | 2 +- .../Services/ElasticQueryService.cs | 8 +- .../reference/modules/Elasticsearch/README.md | 16 ++- src/docs/releases/1.8.0.md | 34 ++++- 11 files changed, 296 insertions(+), 104 deletions(-) create mode 100644 src/OrchardCore/OrchardCore.Abstractions/JsonHelpers.cs diff --git a/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Drivers/ElasticSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Drivers/ElasticSettingsDisplayDriver.cs index 869bea6bdf0..dac5b29ebe9 100644 --- a/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Drivers/ElasticSettingsDisplayDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Drivers/ElasticSettingsDisplayDriver.cs @@ -1,75 +1,119 @@ using System; +using System.IO; using System.Linq; +using System.Text; +using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Localization; +using Nest; using OrchardCore.DisplayManagement.Entities; using OrchardCore.DisplayManagement.Handlers; using OrchardCore.DisplayManagement.Views; +using OrchardCore.Mvc.ModelBinding; using OrchardCore.Search.Elasticsearch.Core.Models; using OrchardCore.Search.Elasticsearch.Core.Services; using OrchardCore.Search.Elasticsearch.ViewModels; using OrchardCore.Settings; -namespace OrchardCore.Search.Elasticsearch.Drivers +namespace OrchardCore.Search.Elasticsearch.Drivers; + +public class ElasticSettingsDisplayDriver : SectionDisplayDriver { - public class ElasticSettingsDisplayDriver : SectionDisplayDriver + public const string GroupId = "elasticsearch"; + + private static readonly char[] _separator = [',', ' ']; + private static readonly JsonSerializerOptions _jsonSerializerOptions = new() + { + WriteIndented = true, + }; + private readonly ElasticIndexSettingsService _elasticIndexSettingsService; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IAuthorizationService _authorizationService; + private readonly IElasticClient _elasticClient; + protected readonly IStringLocalizer S; + + public ElasticSettingsDisplayDriver( + ElasticIndexSettingsService elasticIndexSettingsService, + IHttpContextAccessor httpContextAccessor, + IAuthorizationService authorizationService, + IElasticClient elasticClient, + IStringLocalizer stringLocalizer + ) { - public const string GroupId = "elasticsearch"; - private readonly ElasticIndexSettingsService _elasticIndexSettingsService; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly IAuthorizationService _authorizationService; + _elasticIndexSettingsService = elasticIndexSettingsService; + _httpContextAccessor = httpContextAccessor; + _authorizationService = authorizationService; + _elasticClient = elasticClient; + S = stringLocalizer; + } - public ElasticSettingsDisplayDriver( - ElasticIndexSettingsService elasticIndexSettingsService, - IHttpContextAccessor httpContextAccessor, - IAuthorizationService authorizationService - ) + public override IDisplayResult Edit(ElasticSettings settings) + => Initialize("ElasticSettings_Edit", async model => { - _elasticIndexSettingsService = elasticIndexSettingsService; - _httpContextAccessor = httpContextAccessor; - _authorizationService = authorizationService; + model.SearchIndex = settings.SearchIndex; + model.SearchFields = string.Join(", ", settings.DefaultSearchFields ?? []); + model.SearchIndexes = (await _elasticIndexSettingsService.GetSettingsAsync()).Select(x => x.IndexName); + model.DefaultQuery = settings.DefaultQuery; + model.SearchType = settings.GetSearchType(); + model.SearchTypes = [ + new(S["Multi-Match Query (Default)"], string.Empty), + new(S["Query String Query"], ElasticSettings.QueryStringSearchType), + new(S["Custom Query"], ElasticSettings.CustomSearchType), + ]; + }).Location("Content:2") + .RenderWhen(() => _authorizationService.AuthorizeAsync(_httpContextAccessor.HttpContext.User, Permissions.ManageElasticIndexes)) + .OnGroup(GroupId); + + public override async Task UpdateAsync(ElasticSettings section, BuildEditorContext context) + { + if (!string.Equals(GroupId, context.GroupId, StringComparison.OrdinalIgnoreCase)) + { + return null; } - public override async Task EditAsync(ElasticSettings settings, BuildEditorContext context) + if (!await _authorizationService.AuthorizeAsync(_httpContextAccessor.HttpContext?.User, Permissions.ManageElasticIndexes)) { - var user = _httpContextAccessor.HttpContext?.User; + return null; + } - if (!await _authorizationService.AuthorizeAsync(user, Permissions.ManageElasticIndexes)) - { - return null; - } + var model = new ElasticSettingsViewModel(); - return Initialize("ElasticSettings_Edit", async model => - { - model.SearchIndex = settings.SearchIndex; - model.SearchFields = string.Join(", ", settings.DefaultSearchFields ?? Array.Empty()); - model.SearchIndexes = (await _elasticIndexSettingsService.GetSettingsAsync()).Select(x => x.IndexName); - model.AllowElasticQueryStringQueryInSearch = settings.AllowElasticQueryStringQueryInSearch; - }).Location("Content:2").OnGroup(GroupId); - } + await context.Updater.TryUpdateModelAsync(model, Prefix); - public override async Task UpdateAsync(ElasticSettings section, BuildEditorContext context) - { - var user = _httpContextAccessor.HttpContext?.User; + section.DefaultQuery = null; + section.SearchIndex = model.SearchIndex; + section.DefaultSearchFields = model.SearchFields?.Split(_separator, StringSplitOptions.RemoveEmptyEntries); + section.SearchType = model.SearchType ?? string.Empty; - if (!await _authorizationService.AuthorizeAsync(user, Permissions.ManageElasticIndexes)) + if (model.SearchType == ElasticSettings.CustomSearchType) + { + if (string.IsNullOrWhiteSpace(model.DefaultQuery)) { - return null; + context.Updater.ModelState.AddModelError(Prefix, nameof(model.DefaultQuery), S["Please provide the default query."]); } - - if (context.GroupId.Equals(GroupId, StringComparison.OrdinalIgnoreCase)) + else if (!JsonHelpers.TryParse(model.DefaultQuery, out var document)) { - var model = new ElasticSettingsViewModel(); + context.Updater.ModelState.AddModelError(Prefix, nameof(model.DefaultQuery), S["The provided query is not formatted correctly."]); + } + else + { + section.DefaultQuery = JsonSerializer.Serialize(document, _jsonSerializerOptions); - await context.Updater.TryUpdateModelAsync(model, Prefix); + try + { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(model.DefaultQuery)); - section.SearchIndex = model.SearchIndex; - section.DefaultSearchFields = model.SearchFields?.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries); - section.AllowElasticQueryStringQueryInSearch = model.AllowElasticQueryStringQueryInSearch; + var searchRequest = await _elasticClient.RequestResponseSerializer.DeserializeAsync(stream); + } + catch + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.DefaultQuery), S["Invalid query provided."]); + } } - - return await EditAsync(section, context); } + + return await EditAsync(section, context); } } diff --git a/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Services/ElasticSearchService.cs b/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Services/ElasticSearchService.cs index 32a03be3032..403281a17fe 100644 --- a/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Services/ElasticSearchService.cs +++ b/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Services/ElasticSearchService.cs @@ -1,8 +1,13 @@ using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Encodings.Web; using System.Threading.Tasks; +using Fluid.Values; using Microsoft.Extensions.Logging; using Nest; -using OrchardCore.Entities; +using OrchardCore.Liquid; using OrchardCore.Search.Abstractions; using OrchardCore.Search.Elasticsearch.Core.Models; using OrchardCore.Search.Elasticsearch.Core.Services; @@ -16,6 +21,9 @@ public class ElasticsearchService : ISearchService private readonly ElasticIndexManager _elasticIndexManager; private readonly ElasticIndexSettingsService _elasticIndexSettingsService; private readonly IElasticSearchQueryService _elasticsearchQueryService; + private readonly IElasticClient _elasticClient; + private readonly JavaScriptEncoder _javaScriptEncoder; + private readonly ILiquidTemplateManager _liquidTemplateManager; private readonly ILogger _logger; public ElasticsearchService( @@ -23,6 +31,9 @@ public ElasticsearchService( ElasticIndexManager elasticIndexManager, ElasticIndexSettingsService elasticIndexSettingsService, IElasticSearchQueryService elasticsearchQueryService, + IElasticClient elasticClient, + JavaScriptEncoder javaScriptEncoder, + ILiquidTemplateManager liquidTemplateManager, ILogger logger ) { @@ -30,6 +41,9 @@ ILogger logger _elasticIndexManager = elasticIndexManager; _elasticIndexSettingsService = elasticIndexSettingsService; _elasticsearchQueryService = elasticsearchQueryService; + _elasticClient = elasticClient; + _javaScriptEncoder = javaScriptEncoder; + _liquidTemplateManager = liquidTemplateManager; _logger = logger; } @@ -63,20 +77,30 @@ public async Task SearchAsync(string indexName, string term, int s try { + var searchType = searchSettings.GetSearchType(); QueryContainer query = null; - if (searchSettings.AllowElasticQueryStringQueryInSearch) + if (searchType == ElasticSettings.CustomSearchType && !string.IsNullOrWhiteSpace(searchSettings.DefaultQuery)) { - query = new QueryStringQuery + var tokenizedContent = await _liquidTemplateManager.RenderStringAsync(searchSettings.DefaultQuery, _javaScriptEncoder, + new Dictionary() + { + ["term"] = new StringValue(term) + }); + + try { - Fields = searchSettings.DefaultSearchFields, - Analyzer = await _elasticIndexSettingsService.GetQueryAnalyzerAsync(index), - Query = term - }; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(tokenizedContent)); + + var searchRequest = await _elasticClient.RequestResponseSerializer.DeserializeAsync(stream); + + query = searchRequest.Query; + } + catch { } } - else + else if (searchType == ElasticSettings.QueryStringSearchType) { - query = new MultiMatchQuery + query = new QueryStringQuery { Fields = searchSettings.DefaultSearchFields, Analyzer = await _elasticIndexSettingsService.GetQueryAnalyzerAsync(index), @@ -84,6 +108,13 @@ public async Task SearchAsync(string indexName, string term, int s }; } + query ??= new MultiMatchQuery + { + Fields = searchSettings.DefaultSearchFields, + Analyzer = await _elasticIndexSettingsService.GetQueryAnalyzerAsync(index), + Query = term + }; + result.ContentItemIds = await _elasticsearchQueryService.ExecuteQueryAsync(index, query, null, start, pageSize); result.Success = true; } diff --git a/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/ViewModels/ElasticSettingsViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/ViewModels/ElasticSettingsViewModel.cs index 5e6383c8951..c06bc3f4ff1 100644 --- a/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/ViewModels/ElasticSettingsViewModel.cs +++ b/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/ViewModels/ElasticSettingsViewModel.cs @@ -1,13 +1,24 @@ using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; namespace OrchardCore.Search.Elasticsearch.ViewModels { public class ElasticSettingsViewModel { public string Analyzer { get; set; } + public string SearchIndex { get; set; } + public IEnumerable SearchIndexes { get; set; } + public string SearchFields { get; set; } - public bool AllowElasticQueryStringQueryInSearch { get; set; } + + public string DefaultQuery { get; set; } + + public string SearchType { get; set; } + + [BindNever] + public IEnumerable SearchTypes { get; set; } } } diff --git a/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Views/ElasticSettings.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Views/ElasticSettings.Edit.cshtml index 7dc6c6c7974..85e1e6801bf 100644 --- a/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Views/ElasticSettings.Edit.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Search.Elasticsearch/Views/ElasticSettings.Edit.cshtml @@ -1,35 +1,63 @@ +@using OrchardCore.Search.Elasticsearch.Core.Models + @model ElasticSettingsViewModel -@if (Model.SearchIndexes.Any()) -{ -
- - - - @T["The default index to use for the search page."] -
-} -else +@if (!Model.SearchIndexes.Any()) {
@T["You need to create at least an index to set as the Search index."]
+ + return; } -
+
+ + + + @T["The default index to use for the search page."] +
+ +
+ + + +
+ +
+ + + + @T["Create a custom Elasticsearch query to be utilized for each search request. Liquid is supported, so use {0} template as a substitute for the user-provided search term.", "{{ term }}"] +
+ +
@T["A comma separated list of fields to use for search pages. The default value is Content.ContentItem.FullText."]
-
-
- - - @T["Whether search queries should be allowed to use Elasticsearch \"query string query\" syntax."] @T["See documentation"] -
-
+ diff --git a/src/OrchardCore.Themes/TheBlogTheme/Recipes/blog.elastic.search.recipe.json b/src/OrchardCore.Themes/TheBlogTheme/Recipes/blog.elastic.search.recipe.json index b80d5883180..b7957a0bed5 100644 --- a/src/OrchardCore.Themes/TheBlogTheme/Recipes/blog.elastic.search.recipe.json +++ b/src/OrchardCore.Themes/TheBlogTheme/Recipes/blog.elastic.search.recipe.json @@ -59,8 +59,9 @@ "DefaultSearchFields": [ "Content.ContentItem.FullText" ], - "AllowElasticQueryStringQueryInSearch": false, - "SyncWithLucene": true + "SearchType": "", + "DefaultQuery": null, + "SyncWithLucene": true } }, { diff --git a/src/OrchardCore/OrchardCore.Abstractions/JsonHelpers.cs b/src/OrchardCore/OrchardCore.Abstractions/JsonHelpers.cs new file mode 100644 index 00000000000..371b1778ad3 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Abstractions/JsonHelpers.cs @@ -0,0 +1,33 @@ +using System.Text.Json; + +namespace OrchardCore; + +public class JsonHelpers +{ + public static bool IsValid(string json, JsonDocumentOptions options = default) + { + try + { + JsonDocument.Parse(json, options); + + return true; + } + catch { } + + return false; + } + + public static bool TryParse(string json, out JsonDocument document, JsonDocumentOptions options = default) + { + try + { + document = JsonDocument.Parse(json, options); + + return true; + } + catch { } + + document = null; + return false; + } +} diff --git a/src/OrchardCore/OrchardCore.Search.Elasticsearch.Core/Models/ElasticSettings.cs b/src/OrchardCore/OrchardCore.Search.Elasticsearch.Core/Models/ElasticSettings.cs index acd5500dd1f..7fce5c796a0 100644 --- a/src/OrchardCore/OrchardCore.Search.Elasticsearch.Core/Models/ElasticSettings.cs +++ b/src/OrchardCore/OrchardCore.Search.Elasticsearch.Core/Models/ElasticSettings.cs @@ -1,19 +1,39 @@ using System; using OrchardCore.Contents.Indexing; -namespace OrchardCore.Search.Elasticsearch.Core.Models +namespace OrchardCore.Search.Elasticsearch.Core.Models; + +public class ElasticSettings { - public class ElasticSettings - { - public static readonly string[] FullTextField = new string[] { IndexingConstants.FullTextKey }; + public const string CustomSearchType = "custom"; + + public const string QueryStringSearchType = "query_string"; + + public static readonly string[] FullTextField = [IndexingConstants.FullTextKey]; + + public string SearchIndex { get; set; } - [Obsolete("This property will be removed in future releases.")] - public const string StandardAnalyzer = "standardanalyzer"; + public string DefaultQuery { get; set; } - public string SearchIndex { get; set; } + public string[] DefaultSearchFields { get; set; } = FullTextField; - public string[] DefaultSearchFields { get; set; } = FullTextField; + public string SearchType { get; set; } + + [Obsolete("This property will be removed in future releases.")] + public const string StandardAnalyzer = "standardanalyzer"; + + [Obsolete($"This property will be removed in future releases. Instead use {nameof(SearchType)} property.")] + public bool AllowElasticQueryStringQueryInSearch { get; set; } = false; + + public string GetSearchType() + { +#pragma warning disable CS0618 // Type or member is obsolete + if (SearchType == null && AllowElasticQueryStringQueryInSearch) + { + return QueryStringSearchType; + } +#pragma warning restore CS0618 // Type or member is obsolete - public bool AllowElasticQueryStringQueryInSearch { get; set; } = false; + return SearchType; } } diff --git a/src/OrchardCore/OrchardCore.Search.Elasticsearch.Core/Services/ElasticIndexManager.cs b/src/OrchardCore/OrchardCore.Search.Elasticsearch.Core/Services/ElasticIndexManager.cs index d14732d9295..881717f898d 100644 --- a/src/OrchardCore/OrchardCore.Search.Elasticsearch.Core/Services/ElasticIndexManager.cs +++ b/src/OrchardCore/OrchardCore.Search.Elasticsearch.Core/Services/ElasticIndexManager.cs @@ -102,7 +102,7 @@ public async Task CreateIndexAsync(ElasticIndexSettings elasticIndexSettin var indexSettingsDescriptor = new IndexSettingsDescriptor(); // The name "standardanalyzer" is a legacy used prior OC 1.6 release. It can be removed in future releases. - var analyzerName = elasticIndexSettings.AnalyzerName == "standardanalyzer" ? "standard" : elasticIndexSettings.AnalyzerName; + var analyzerName = (elasticIndexSettings.AnalyzerName == "standardanalyzer" ? null : elasticIndexSettings.AnalyzerName) ?? "standard"; if (_elasticsearchOptions.Analyzers.TryGetValue(analyzerName, out var analyzerProperties)) { diff --git a/src/OrchardCore/OrchardCore.Search.Elasticsearch.Core/Services/ElasticQueryService.cs b/src/OrchardCore/OrchardCore.Search.Elasticsearch.Core/Services/ElasticQueryService.cs index ea3817561a7..12243eee9e9 100644 --- a/src/OrchardCore/OrchardCore.Search.Elasticsearch.Core/Services/ElasticQueryService.cs +++ b/src/OrchardCore/OrchardCore.Search.Elasticsearch.Core/Services/ElasticQueryService.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Text; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -38,7 +37,8 @@ public async Task SearchAsync(string indexName, string query) try { - var deserializedSearchRequest = _elasticClient.RequestResponseSerializer.Deserialize(new MemoryStream(Encoding.UTF8.GetBytes(query))); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(query)); + var deserializedSearchRequest = _elasticClient.RequestResponseSerializer.Deserialize(stream); var searchRequest = new SearchRequest(_indexPrefix + indexName) { @@ -47,7 +47,7 @@ public async Task SearchAsync(string indexName, string query) Size = deserializedSearchRequest.Size, Fields = deserializedSearchRequest.Fields, Sort = deserializedSearchRequest.Sort, - Source = deserializedSearchRequest.Source + Source = deserializedSearchRequest.Source, }; var searchResponse = await _elasticClient.SearchAsync>(searchRequest); @@ -71,7 +71,7 @@ public async Task SearchAsync(string indexName, string query) if (searchResponse.IsValid) { elasticTopDocs.Count = searchResponse.Total; - elasticTopDocs.TopDocs = searchResponse.Documents.ToList(); + elasticTopDocs.TopDocs = [.. searchResponse.Documents]; elasticTopDocs.Fields = hits; } else diff --git a/src/docs/reference/modules/Elasticsearch/README.md b/src/docs/reference/modules/Elasticsearch/README.md index 3807f793ab7..4f5ec84dedb 100644 --- a/src/docs/reference/modules/Elasticsearch/README.md +++ b/src/docs/reference/modules/Elasticsearch/README.md @@ -90,7 +90,8 @@ Here is an example for setting default search settings: "DefaultSearchFields":[ "Content.ContentItem.FullText" ], - "AllowElasticQueryStringQueryInSearch":false, + "SearchType": "", // Use 'custom' for a custom query in DefaultQuery and 'query_string' for a Query String Query search. Leave it blank for the default, which is a Multi-Match Query search. + "DefaultQuery": null, "SyncWithLucene":true // Allows to sync content index settings. } } @@ -169,11 +170,14 @@ Here is an example for creating a Elasticsearch query from a Queries recipe step ```json { - "Source": "Elasticsearch", - "Name": "RecentBlogPosts", - "Index": "Search", - "Template": "...", // json encoded query template - "ReturnContentItems": true + "steps":[ + { + "Source": "Elasticsearch", + "Name": "RecentBlogPosts", + "Index": "Search", + "Template": "...", // json encoded query template + "ReturnContentItems": true + } } ``` diff --git a/src/docs/releases/1.8.0.md b/src/docs/releases/1.8.0.md index 86cd0443b10..e102f4d75e6 100644 --- a/src/docs/releases/1.8.0.md +++ b/src/docs/releases/1.8.0.md @@ -48,13 +48,13 @@ Additionally, if you've customized the `Error.cshtml` view, it should be renamed The following interfaces have been updated to incorporate new asynchronous methods, corresponding to each synchronous method. All synchronous methods have been deprecated and should no longer be utilized. -- IStereotypeService -- IStereotypesProvider -- IRouteableContentTypeProvider -- IRouteableContentTypeCoordinator -- IContentDefinitionService -- IContentDefinitionManager -- IContentDefinitionService +- `IStereotypeService` +- `IStereotypesProvider` +- `IRouteableContentTypeProvider` +- `IRouteableContentTypeCoordinator` +- `IContentDefinitionService` +- `IContentDefinitionManager` +- `IContentDefinitionService` ## Change Logs @@ -104,3 +104,23 @@ To add the `Navbar` shape into your own back-end theme, add the following code i @await DisplayAsync(await DisplayManager.BuildDisplayAsync(UpdateModelAccessor.ModelUpdater, "DetailAdmin")) ``` + +### Elasticsearch Module + +Introduced a new option in `ElasticSettings` that permits the definition of a custom query for the default search. For instance, the following is an example of a search query supporting the [fuzziness](https://www.elastic.co/guide/en/elasticsearch/reference/7.17/common-options.html#fuzziness) option: + +``` +{ + "query": { + "match": { + "Content.ContentItem.FullText": { + "query": "{{ term }}", + "fuzziness": "AUTO", + "analyzer": "whitespace" + } + } + } +} +``` + +![elastic-search-ex](https://github.com/OrchardCMS/OrchardCore/assets/24724371/15aae13e-0fc0-4df6-98be-352a441618c0)