Skip to content

Commit

Permalink
Add a way to provide a custom Elasticsearch query
Browse files Browse the repository at this point in the history
Fix #14842
  • Loading branch information
MikeAlhayek committed Dec 5, 2023
1 parent a79e40f commit 55f241a
Show file tree
Hide file tree
Showing 8 changed files with 224 additions and 47 deletions.
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
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.Services;
using OrchardCore.Search.Elasticsearch.ViewModels;
using OrchardCore.Settings;

Expand All @@ -16,19 +23,33 @@ namespace OrchardCore.Search.Elasticsearch.Drivers
public class ElasticSettingsDisplayDriver : SectionDisplayDriver<ISite, ElasticSettings>
{
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
IAuthorizationService authorizationService,
IElasticClient elasticClient,
IStringLocalizer<ElasticSettingsDisplayDriver> stringLocalizer

)
{
_elasticIndexSettingsService = elasticIndexSettingsService;
_httpContextAccessor = httpContextAccessor;
_authorizationService = authorizationService;
_elasticClient = elasticClient;
S = stringLocalizer;
}

public override async Task<IDisplayResult> EditAsync(ElasticSettings settings, BuildEditorContext context)
Expand All @@ -41,12 +62,30 @@ public override async Task<IDisplayResult> EditAsync(ElasticSettings settings, B
}

return Initialize<ElasticSettingsViewModel>("ElasticSettings_Edit", async model =>
{
model.SearchIndex = settings.SearchIndex;
model.SearchFields = string.Join(", ", settings.DefaultSearchFields ?? []);
model.SearchIndexes = (await _elasticIndexSettingsService.GetSettingsAsync()).Select(x => x.IndexName);
model.DefaultQuery = settings.DefaultQuery;
#pragma warning disable CS0618 // Type or member is obsolete
if (settings.SearchType == null && settings.AllowElasticQueryStringQueryInSearch)
{
model.SearchIndex = settings.SearchIndex;
model.SearchFields = string.Join(", ", settings.DefaultSearchFields ?? Array.Empty<string>());
model.SearchIndexes = (await _elasticIndexSettingsService.GetSettingsAsync()).Select(x => x.IndexName);
model.AllowElasticQueryStringQueryInSearch = settings.AllowElasticQueryStringQueryInSearch;
}).Location("Content:2").OnGroup(GroupId);
model.SearchType = "query_string";
}
else
{
model.SearchType = settings.SearchType;
}
#pragma warning restore CS0618 // Type or member is obsolete
model.SearchTypes = [
new(S["Multi-match query"], string.Empty),
new(S["String query"], ElasticsearchService.QueryStringSearchType),
new(S["Raw query"], ElasticsearchService.RawSearchType),
];
}).Location("Content:2")
.OnGroup(GroupId);
}

public override async Task<IDisplayResult> UpdateAsync(ElasticSettings section, BuildEditorContext context)
Expand All @@ -64,9 +103,37 @@ public override async Task<IDisplayResult> UpdateAsync(ElasticSettings section,

await context.Updater.TryUpdateModelAsync(model, Prefix);

section.DefaultQuery = null;
section.SearchIndex = model.SearchIndex;
section.DefaultSearchFields = model.SearchFields?.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries);
section.AllowElasticQueryStringQueryInSearch = model.AllowElasticQueryStringQueryInSearch;
section.DefaultSearchFields = model.SearchFields?.Split(_separator, StringSplitOptions.RemoveEmptyEntries);
section.SearchType = model.SearchType ?? string.Empty;

if (model.SearchType == ElasticsearchService.RawSearchType)
{
if (string.IsNullOrWhiteSpace(model.DefaultQuery))
{
context.Updater.ModelState.AddModelError(Prefix, nameof(model.DefaultQuery), S["Please provide the default query."]);
}
else if (!JsonHelpers.TryParse(model.DefaultQuery, out var document))
{
context.Updater.ModelState.AddModelError(Prefix, nameof(model.DefaultQuery), S["The provided query is not formatted correctly."]);
}
else
{
section.DefaultQuery = JsonSerializer.Serialize(document, _jsonSerializerOptions);

try
{
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(model.DefaultQuery));

var searchRequest = await _elasticClient.RequestResponseSerializer.DeserializeAsync<SearchRequest>(stream);
}
catch
{
context.Updater.ModelState.AddModelError(Prefix, nameof(model.DefaultQuery), S["Invalid query provided."]);
}
}
}
}

return await EditAsync(section, context);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -12,24 +17,36 @@ namespace OrchardCore.Search.Elasticsearch.Services;

public class ElasticsearchService : ISearchService
{
public const string RawSearchType = "raw";
public const string QueryStringSearchType = "query_string";

private readonly ISiteService _siteService;
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(
ISiteService siteService,
ElasticIndexManager elasticIndexManager,
ElasticIndexSettingsService elasticIndexSettingsService,
IElasticSearchQueryService elasticsearchQueryService,
IElasticClient elasticClient,
JavaScriptEncoder javaScriptEncoder,
ILiquidTemplateManager liquidTemplateManager,
ILogger<ElasticsearchService> logger
)
{
_siteService = siteService;
_elasticIndexManager = elasticIndexManager;
_elasticIndexSettingsService = elasticIndexSettingsService;
_elasticsearchQueryService = elasticsearchQueryService;
_elasticClient = elasticClient;
_javaScriptEncoder = javaScriptEncoder;
_liquidTemplateManager = liquidTemplateManager;
_logger = logger;
}

Expand Down Expand Up @@ -65,25 +82,41 @@ public async Task<SearchResult> SearchAsync(string indexName, string term, int s
{
QueryContainer query = null;

if (searchSettings.AllowElasticQueryStringQueryInSearch)
if (searchSettings.SearchType == RawSearchType)
{
query = new QueryStringQuery
var tokenizedContent = await _liquidTemplateManager.RenderStringAsync(searchSettings.DefaultQuery, _javaScriptEncoder,
new Dictionary<string, FluidValue>()
{
["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<SearchRequest>(stream);

query = searchRequest.Query;
}
catch { }
}
else
else if (searchSettings.SearchType == "query_string")
{
query = new MultiMatchQuery
query = new QueryStringQuery
{
Fields = searchSettings.DefaultSearchFields,
Analyzer = await _elasticIndexSettingsService.GetQueryAnalyzerAsync(index),
Query = term
};
}

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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string> 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<SelectListItem> SearchTypes { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -1,35 +1,63 @@
@using OrchardCore.Search.Elasticsearch.Services

@model ElasticSettingsViewModel

@if (Model.SearchIndexes.Any())
{
<div class="mb-3" asp-validation-class-for="SearchIndex">
<label asp-for="SearchIndex" class="form-label">@T["Default search index"]</label>
<select asp-for="SearchIndex" class="form-select">
@foreach (var index in Model.SearchIndexes)
{
<option value="@index" selected="@(Model.SearchIndex == index)">@index</option>
}
</select>
<span asp-validation-for="SearchIndex"></span>
<span class="hint">@T["The default index to use for the search page."]</span>
</div>
}
else
@if (!Model.SearchIndexes.Any())
{
<div class="alert alert-warning">@T["You need to create at least an index to set as the Search index."]</div>

return;
}

<div class="mb-3" asp-validation-class-for="SearchFields">
<div class="mb-3" asp-validation-class-for="SearchIndex">
<label asp-for="SearchIndex" class="form-label">@T["Default search index"]</label>
<select asp-for="SearchIndex" class="form-select">
@foreach (var index in Model.SearchIndexes)
{
<option value="@index" selected="@(Model.SearchIndex == index)">@index</option>
}
</select>
<span asp-validation-for="SearchIndex"></span>
<span class="hint">@T["The default index to use for the search page."]</span>
</div>

<div class="mb-3" asp-validation-class-for="SearchType">
<label asp-for="SearchType" class="form-label">@T["Default search index"]</label>
<select asp-for="SearchType" class="form-select" asp-items="Model.SearchTypes" data-raw-type="@ElasticsearchService.RawSearchType"></select>
<span asp-validation-for="SearchType"></span>
</div>

<div class="mb-3" asp-validation-class-for="DefaultQuery" id="DefaultQueryContainer">
<label asp-for="DefaultQuery" class="form-label">@T["Default query"]</label>
<textarea asp-for="DefaultQuery" class="form-control" rows="10"></textarea>
<span asp-validation-for="DefaultQuery"></span>
<span class="hint">@T["Create a custom Elasticsearch query to be utilized for each search request. Liquid is supported, so use <code>{0}</code> template as a substitute for the user-provided search term.", "{{ term }}"]</span>
</div>

<div class="mb-3" asp-validation-class-for="SearchFields" id="DefaultQueryFields">
<label asp-for="SearchFields" class="form-label">@T["Default searched fields"]</label>
<input asp-for="SearchFields" class="form-control" />
<span asp-validation-for="SearchFields"></span>
<span class="hint">@T["A comma separated list of fields to use for search pages. The default value is <code>Content.ContentItem.FullText</code>."]</span>
</div>

<div class="mb-3" asp-validation-class-for="AllowElasticQueryStringQueryInSearch">
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="AllowElasticQueryStringQueryInSearch" />
<label class="form-check-label" asp-for="AllowElasticQueryStringQueryInSearch">@T["Allow Elasticsearch \"query string query\" in search forms"]</label>
<span class="hint dashed">@T["Whether search queries should be allowed to use <a href=\"https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#query-dsl-query-string-query\">Elasticsearch \"query string query\" syntax</a>."] <a class="seedoc" href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#query-dsl-query-string-query" target="_blank">@T["See documentation"]</a></span>
</div>
</div>
<script at="Foot">
document.addEventListener('DOMContentLoaded', function () {
const menu = document.getElementById('@Html.IdFor(m => m.SearchType)');
const queryContainer = document.getElementById('DefaultQueryContainer');
const fieldsContainer = document.getElementById('DefaultQueryFields');
menu.addEventListener('change', function (e) {
if (e.target.value == e.target.getAttribute('data-raw-type')) {
queryContainer.classList.remove('d-none');
fieldsContainer.classList.add('d-none');
} else {
queryContainer.classList.add('d-none');
fieldsContainer.classList.remove('d-none');
}
});
menu.dispatchEvent(new Event('change'));
});
</script>
33 changes: 33 additions & 0 deletions src/OrchardCore/OrchardCore.Abstractions/JsonHelpers.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading

0 comments on commit 55f241a

Please sign in to comment.