Skip to content

Commit

Permalink
Add a way to provide a custom Elasticsearch query (OrchardCMS#14843)
Browse files Browse the repository at this point in the history
  • Loading branch information
MikeAlhayek committed Dec 7, 2023
1 parent bb80a80 commit 5468a78
Show file tree
Hide file tree
Showing 11 changed files with 296 additions and 104 deletions.
Original file line number Diff line number Diff line change
@@ -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<ISite, ElasticSettings>
{
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,
IElasticClient elasticClient,
IStringLocalizer<ElasticSettingsDisplayDriver> 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<ElasticSettingsViewModel>("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<IDisplayResult> UpdateAsync(ElasticSettings section, BuildEditorContext context)
{
if (!string.Equals(GroupId, context.GroupId, StringComparison.OrdinalIgnoreCase))
{
return null;
}

public override async Task<IDisplayResult> 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<ElasticSettingsViewModel>("ElasticSettings_Edit", async model =>
{
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);
}
await context.Updater.TryUpdateModelAsync(model, Prefix);

public override async Task<IDisplayResult> 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<SearchRequest>(stream);
}
catch
{
context.Updater.ModelState.AddModelError(Prefix, nameof(model.DefaultQuery), S["Invalid query provided."]);
}
}

return await EditAsync(section, context);
}

return await EditAsync(section, context);
}
}
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 @@ -16,20 +21,29 @@ 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(
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 @@ -63,27 +77,44 @@ public async Task<SearchResult> 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<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 (searchType == ElasticSettings.QueryStringSearchType)
{
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.Core.Models

@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 type"]</label>
<select asp-for="SearchType" class="form-select" asp-items="Model.SearchTypes" data-raw-type="@ElasticSettings.CustomSearchType"></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>
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@
"DefaultSearchFields": [
"Content.ContentItem.FullText"
],
"AllowElasticQueryStringQueryInSearch": false,
"SyncWithLucene": true
"SearchType": "",
"DefaultQuery": null,
"SyncWithLucene": true
}
},
{
Expand Down
Loading

0 comments on commit 5468a78

Please sign in to comment.