mirror of https://github.com/Squidex/squidex.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
295 lines
8.6 KiB
295 lines
8.6 KiB
// ==========================================================================
|
|
// Squidex Headless CMS
|
|
// ==========================================================================
|
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|
// All rights reserved. Licensed under the MIT license.
|
|
// ==========================================================================
|
|
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Options;
|
|
using Microsoft.OData;
|
|
using Microsoft.OData.Edm;
|
|
using Squidex.Domain.Apps.Core.Apps;
|
|
using Squidex.Domain.Apps.Core.GenerateFilters;
|
|
using Squidex.Domain.Apps.Core.Schemas;
|
|
using Squidex.Domain.Apps.Entities.Contents.Text;
|
|
using Squidex.Infrastructure;
|
|
using Squidex.Infrastructure.Json;
|
|
using Squidex.Infrastructure.Json.Objects;
|
|
using Squidex.Infrastructure.Queries;
|
|
using Squidex.Infrastructure.Queries.Json;
|
|
using Squidex.Infrastructure.Queries.OData;
|
|
using Squidex.Infrastructure.Translations;
|
|
using Squidex.Infrastructure.Validation;
|
|
|
|
namespace Squidex.Domain.Apps.Entities.Contents.Queries;
|
|
|
|
public class ContentQueryParser(
|
|
IAppProvider appprovider,
|
|
ITextIndex textIndex,
|
|
IOptions<ContentsOptions> options,
|
|
IMemoryCache cache,
|
|
IJsonSerializer serializer)
|
|
{
|
|
private static readonly TimeSpan CacheTime = TimeSpan.FromMinutes(60);
|
|
private readonly ContentsOptions options = options.Value;
|
|
|
|
public virtual async Task<Q?> ParseAsync(Context context, Q q, Schema? schema = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
Guard.NotNull(context);
|
|
Guard.NotNull(q);
|
|
|
|
using (Telemetry.Activities.StartActivity("ContentQueryParser/ParseAsync"))
|
|
{
|
|
var query = await ParseClrQueryAsync(context, q, schema, ct);
|
|
|
|
var shouldCancel = await TransformFilterAsync(query, context, schema, ct);
|
|
if (shouldCancel)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
WithSorting(query);
|
|
WithPaging(query, q);
|
|
|
|
q = q.WithQuery(query);
|
|
q = q.WithFields(context.Fields());
|
|
|
|
if (context.NoTotal())
|
|
{
|
|
q = q.WithoutTotal();
|
|
}
|
|
else if (context.NoSlowTotal())
|
|
{
|
|
q = q.WithoutSlowTotal();
|
|
}
|
|
|
|
return q;
|
|
}
|
|
}
|
|
|
|
private async Task<bool> TransformFilterAsync(ClrQuery query, Context context, Schema? schema,
|
|
CancellationToken ct)
|
|
{
|
|
if (query.Filter != null && schema != null)
|
|
{
|
|
query.Filter = await GeoQueryVisitor.VisitAsync(query.Filter, context, schema, textIndex, ct);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(query.FullText))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (schema == null)
|
|
{
|
|
ThrowHelper.InvalidOperationException();
|
|
return false;
|
|
}
|
|
|
|
var searchFilters = new List<CompareFilter<ClrValue>>();
|
|
|
|
var textQuery = new TextQuery(query.FullText, 1000)
|
|
{
|
|
PreferredSchemaId = schema.Id,
|
|
};
|
|
|
|
var fullTextIds = await textIndex.SearchAsync(context.App, textQuery, context.Scope(), ct);
|
|
if (fullTextIds is not { Count: > 0 } && schema.Properties.SearchFields is not { Count: > 0 })
|
|
{
|
|
// Cancel the search. We would not any results.
|
|
return true;
|
|
}
|
|
|
|
if (fullTextIds?.Count > 0)
|
|
{
|
|
searchFilters.Add(ClrFilter.In("id", fullTextIds.Select(x => x.ToString()).ToList()));
|
|
}
|
|
|
|
foreach (var searchField in schema.Properties.SearchFields ?? FieldNames.Empty)
|
|
{
|
|
searchFilters.Add(ClrFilter.Contains(searchField, query.FullText));
|
|
}
|
|
|
|
if (searchFilters.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Just an mini optimization to flatten OR expressions with one element.
|
|
var searchFilter =
|
|
searchFilters.Count == 1 ?
|
|
searchFilters[0] :
|
|
ClrFilter.Or(searchFilters) as FilterNode<ClrValue>;
|
|
|
|
if (query.Filter != null)
|
|
{
|
|
query.Filter = ClrFilter.And(query.Filter, searchFilter);
|
|
}
|
|
else
|
|
{
|
|
query.Filter = searchFilter;
|
|
}
|
|
|
|
query.FullText = null;
|
|
return false;
|
|
}
|
|
|
|
private async Task<ClrQuery> ParseClrQueryAsync(Context context, Q q, Schema? schema,
|
|
CancellationToken ct)
|
|
{
|
|
var components = ResolvedComponents.Empty;
|
|
|
|
if (schema != null)
|
|
{
|
|
components = await appprovider.GetComponentsAsync(schema, ct);
|
|
}
|
|
|
|
var query = q.Query;
|
|
|
|
if (!string.IsNullOrWhiteSpace(q.QueryAsJson))
|
|
{
|
|
query = ParseJson(context, schema, q.QueryAsJson, components);
|
|
}
|
|
else if (q?.JsonQuery != null)
|
|
{
|
|
query = ParseJson(context, schema, q.JsonQuery, components);
|
|
}
|
|
else if (!string.IsNullOrWhiteSpace(q?.QueryAsOdata))
|
|
{
|
|
query = ParseOData(context, schema, q.QueryAsOdata, components);
|
|
}
|
|
|
|
return query;
|
|
}
|
|
|
|
private static void WithSorting(ClrQuery query)
|
|
{
|
|
query.Sort ??= [];
|
|
|
|
if (query.Sort.Count == 0)
|
|
{
|
|
query.Sort.Add(new SortNode("lastModified", SortOrder.Descending));
|
|
}
|
|
|
|
if (!query.Sort.Exists(x => x.Path.Equals("id")))
|
|
{
|
|
query.Sort.Add(new SortNode("id", SortOrder.Ascending));
|
|
}
|
|
}
|
|
|
|
private void WithPaging(ClrQuery query, Q q)
|
|
{
|
|
if (query.Take is <= 0 or long.MaxValue)
|
|
{
|
|
if (q.Ids is { Count: > 0 })
|
|
{
|
|
query.Take = q.Ids.Count;
|
|
}
|
|
else
|
|
{
|
|
query.Take = options.DefaultPageSize;
|
|
}
|
|
}
|
|
else if (query.Take > options.MaxResults)
|
|
{
|
|
query.Take = options.MaxResults;
|
|
}
|
|
}
|
|
|
|
private ClrQuery ParseJson(Context context, Schema? schema, Query<JsonValue> query,
|
|
ResolvedComponents components)
|
|
{
|
|
var queryModel = BuildQueryModel(context, schema, components);
|
|
|
|
return queryModel.Convert(query);
|
|
}
|
|
|
|
private ClrQuery ParseJson(Context context, Schema? schema, string json,
|
|
ResolvedComponents components)
|
|
{
|
|
var queryModel = BuildQueryModel(context, schema, components);
|
|
|
|
return queryModel.Parse(json, serializer);
|
|
}
|
|
|
|
private ClrQuery ParseOData(Context context, Schema? schema, string odata,
|
|
ResolvedComponents components)
|
|
{
|
|
try
|
|
{
|
|
var model = BuildEdmModel(context, schema, components);
|
|
|
|
return model.ParseQuery(odata).ToQuery();
|
|
}
|
|
catch (ValidationException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (NotSupportedException)
|
|
{
|
|
throw new ValidationException(T.Get("common.odataNotSupported", new { odata }));
|
|
}
|
|
catch (ODataException ex)
|
|
{
|
|
var message = ex.Message;
|
|
|
|
throw new ValidationException(T.Get("common.odataFailure", new { odata, message }), ex);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
throw new ValidationException(T.Get("common.odataNotSupported", new { odata }));
|
|
}
|
|
}
|
|
|
|
private QueryModel BuildQueryModel(Context context, Schema? schema,
|
|
ResolvedComponents components)
|
|
{
|
|
var cacheKey = BuildJsonCacheKey(context.App, schema, context.IsFrontendClient);
|
|
|
|
var result = cache.GetOrCreate(cacheKey, entry =>
|
|
{
|
|
entry.AbsoluteExpirationRelativeToNow = CacheTime;
|
|
|
|
return ContentQueryModel.Build(schema, context.App.PartitionResolver(), components);
|
|
})!;
|
|
|
|
return result;
|
|
}
|
|
|
|
private IEdmModel BuildEdmModel(Context context, Schema? schema,
|
|
ResolvedComponents components)
|
|
{
|
|
var cacheKey = BuildEmdCacheKey(context.App, schema, context.IsFrontendClient);
|
|
|
|
var result = cache.GetOrCreate<IEdmModel>(cacheKey, entry =>
|
|
{
|
|
entry.AbsoluteExpirationRelativeToNow = CacheTime;
|
|
|
|
return BuildQueryModel(context, schema, components).ConvertToEdm("Contents", schema?.Name ?? "Generic");
|
|
})!;
|
|
|
|
return result;
|
|
}
|
|
|
|
private static string BuildEmdCacheKey(App app, Schema? schema, bool withHidden)
|
|
{
|
|
if (schema == null)
|
|
{
|
|
return $"EDM/__generic";
|
|
}
|
|
|
|
return $"EDM/{app.Version}/{schema.Id}_{schema.Version}/{withHidden}";
|
|
}
|
|
|
|
private static string BuildJsonCacheKey(App app, Schema? schema, bool withHidden)
|
|
{
|
|
if (schema == null)
|
|
{
|
|
return $"JSON/__generic";
|
|
}
|
|
|
|
return $"JSON/{app.Version}/{schema.Id}_{schema.Version}/{withHidden}";
|
|
}
|
|
}
|
|
|