Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a way to provide a custom Elasticsearch query #14843

Merged
merged 9 commits into from
Dec 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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