diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/StringFormatter.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/StringFormatter.cs index 4f44a534c..b3f3c5c8e 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/StringFormatter.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/StringFormatter.cs @@ -73,8 +73,8 @@ namespace Squidex.Domain.Apps.Core.ConvertContent public string Visit(GeolocationFieldProperties properties, Args args) { if (args.Value is JsonObject jsonObject && - jsonObject.TryGetValue("latitude", out var lat) && - jsonObject.TryGetValue("longitude", out var lon)) + jsonObject.TryGetValue("latitude", out var lat) && + jsonObject.TryGetValue("longitude", out var lon)) { return $"{lat}, {lon}"; } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateEdmSchema/EdmTypeVisitor.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateEdmSchema/EdmTypeVisitor.cs index f2b9f1544..82066d719 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateEdmSchema/EdmTypeVisitor.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateEdmSchema/EdmTypeVisitor.cs @@ -79,12 +79,12 @@ namespace Squidex.Domain.Apps.Core.GenerateEdmSchema public IEdmTypeReference? Visit(IField field, Args args) { - return null; + return CreateGeographyPoint(field); } public IEdmTypeReference? Visit(IField field, Args args) { - return new EdmComplexTypeReference(JsonType, !field.RawProperties.IsRequired); + return CreateJson(field); } public IEdmTypeReference? Visit(IField field, Args args) @@ -116,5 +116,15 @@ namespace Squidex.Domain.Apps.Core.GenerateEdmSchema { return EdmCoreModel.Instance.GetPrimitive(kind, !field.RawProperties.IsRequired); } + + private static IEdmTypeReference CreateGeographyPoint(IField field) + { + return EdmCoreModel.Instance.GetSpatial(EdmPrimitiveTypeKind.GeographyPoint, !field.RawProperties.IsRequired); + } + + private static IEdmTypeReference CreateJson(IField field) + { + return new EdmComplexTypeReference(JsonType, !field.RawProperties.IsRequired); + } } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs index 8edb94e13..99a03edc2 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs @@ -8,6 +8,7 @@ using System.Collections.ObjectModel; using NJsonSchema; using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Infrastructure.Json; namespace Squidex.Domain.Apps.Core.GenerateJsonSchema { @@ -83,6 +84,8 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema { var geolocationSchema = SchemaBuilder.Object(); + geolocationSchema.Format = GeoJson.Format; + geolocationSchema.Properties.Add("latitude", new JsonSchemaProperty { Type = JsonObjectType.Number, diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs index 0983e722f..4ac7e0ce9 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs @@ -134,18 +134,16 @@ namespace Squidex.Domain.Apps.Core.ValidateContent stream.Position = 0; - var geoJson = args.JsonSerializer.Deserialize(stream); + var geoJson = args.JsonSerializer.Deserialize(stream); return (geoJson, null); } } catch { - if (geoObject.TryGetValue("latitude", out var latValue) && latValue is JsonNumber latNumber) + if (geoObject.TryGetValue("latitude", out var lat)) { - var lat = latNumber.Value; - - if (!lat.IsBetween(-90, 90)) + if (!lat.Value.IsBetween(-90, 90)) { return (null, new JsonError(T.Get("contents.invalidGeolocationLatitude"))); } @@ -155,11 +153,9 @@ namespace Squidex.Domain.Apps.Core.ValidateContent return (null, new JsonError(T.Get("contents.invalidGeolocation"))); } - if (geoObject.TryGetValue("longitude", out var lonValue) && lonValue is JsonNumber lonNumber) + if (geoObject.TryGetValue("longitude", out var lon)) { - var lon = lonNumber.Value; - - if (!lon.IsBetween(-180, 180)) + if (!lon.Value.IsBetween(-180, 180)) { return (null, new JsonError(T.Get("contents.invalidGeolocationLongitude"))); } @@ -169,7 +165,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent return (null, new JsonError(T.Get("contents.invalidGeolocation"))); } - var position = new Position(latNumber.Value, lonNumber.Value); + var position = new Point(new Position(lat.Value, lon.Value)); return (position, null); } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FirstPascalPathConverter.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FirstPascalPathConverter.cs index 5c055e501..2fb1c90ef 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FirstPascalPathConverter.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FirstPascalPathConverter.cs @@ -5,11 +5,12 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Squidex.Infrastructure; using Squidex.Infrastructure.Queries; namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors { - public sealed class FirstPascalPathConverter : TransformVisitor + public sealed class FirstPascalPathConverter : TransformVisitor { private static readonly FirstPascalPathConverter Instance = new FirstPascalPathConverter(); @@ -19,12 +20,12 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors public static FilterNode? Transform(FilterNode node) { - return node.Accept(Instance); + return node.Accept(Instance, None.Value); } - public override FilterNode? Visit(CompareFilter nodeIn) + public override FilterNode? Visit(CompareFilter nodeIn, None args) { - return new CompareFilter(nodeIn.Path.ToFirstPascalCase(), nodeIn.Operator, nodeIn.Value); + return nodeIn with { Path = nodeIn.Path.ToFirstPascalCase() }; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs index 39c0a07bc..742ad134e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs @@ -14,7 +14,6 @@ using NodaTime; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Contents; -using Squidex.Domain.Apps.Entities.Contents.Text; using Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; @@ -35,7 +34,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents private readonly QueryScheduled queryScheduled; private readonly string name; - public MongoContentCollection(string name, IMongoDatabase database, IAppProvider appProvider, ITextIndex indexer, DataConverter dataConverter) + public MongoContentCollection(string name, IMongoDatabase database, IAppProvider appProvider, DataConverter dataConverter) : base(database) { this.name = name; @@ -43,7 +42,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents queryAsStream = new QueryAsStream(dataConverter, appProvider); queryBdId = new QueryById(dataConverter); queryByIds = new QueryByIds(dataConverter); - queryByQuery = new QueryByQuery(dataConverter, indexer, appProvider); + queryByQuery = new QueryByQuery(dataConverter, appProvider); queryReferences = new QueryReferences(dataConverter, queryByIds); queryReferrers = new QueryReferrers(dataConverter); queryScheduled = new QueryScheduled(dataConverter); @@ -98,7 +97,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents } } - public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Q q, SearchScope scope) + public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Q q) { using (Profiler.TraceMethod()) { @@ -109,7 +108,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents if (q.Referencing == default) { - return await queryByQuery.QueryAsync(app, schema, q, scope); + return await queryByQuery.QueryAsync(app, schema, q); } return ResultList.CreateFrom(0); diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs index 913a1e66d..180d7eb18 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs @@ -15,7 +15,6 @@ using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents.Repositories; -using Squidex.Domain.Apps.Entities.Contents.Text; using Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Hosting; @@ -37,7 +36,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents StatusSerializer.Register(); } - public MongoContentRepository(IMongoDatabase database, IAppProvider appProvider, ITextIndex indexer, IJsonSerializer serializer) + public MongoContentRepository(IMongoDatabase database, IAppProvider appProvider, IJsonSerializer serializer) { Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(serializer, nameof(serializer)); @@ -48,11 +47,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents collectionAll = new MongoContentCollection( - "States_Contents_All2", database, appProvider, indexer, converter); + "States_Contents_All2", database, appProvider, converter); collectionPublished = new MongoContentCollection( - "States_Contents_Published2", database, appProvider, indexer, converter); + "States_Contents_Published2", database, appProvider, converter); } public async Task InitializeAsync(CancellationToken ct = default) @@ -82,11 +81,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { if (scope == SearchScope.All) { - return collectionAll.QueryAsync(app, schema, q, scope); + return collectionAll.QueryAsync(app, schema, q); } else { - return collectionPublished.QueryAsync(app, schema, q, scope); + return collectionPublished.QueryAsync(app, schema, q); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Adapt.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Adapt.cs index 076e27400..71939448c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Adapt.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Adapt.cs @@ -12,6 +12,7 @@ using System.Reflection; using MongoDB.Bson.Serialization.Attributes; using Squidex.Domain.Apps.Core.GenerateEdmSchema; using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Infrastructure; using Squidex.Infrastructure.Queries; namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations @@ -78,13 +79,13 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations }; } - public static ClrQuery AdjustToModel(this ClrQuery query, Schema? schema) + public static ClrQuery AdjustToModel(this ClrQuery query, DomainId appId, Schema? schema) { var pathConverter = Path(schema); if (query.Filter != null) { - query.Filter = query.Filter.Accept(new AdaptionVisitor(pathConverter)); + query.Filter = AdaptionVisitor.Adapt(query.Filter, pathConverter, appId); } query.Sort = query.Sort.Select(x => new SortNode(pathConverter(x.Path), x.Order)).ToList(); @@ -92,11 +93,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations return query; } - public static FilterNode? AdjustToModel(this FilterNode filterNode, Schema schema) + public static FilterNode? AdjustToModel(this FilterNode filter, DomainId appId, Schema schema) { var pathConverter = Path(schema); - return filterNode.Accept(new AdaptionVisitor(pathConverter)); + return AdaptionVisitor.Adapt(filter, pathConverter, appId); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/AdaptionVisitor.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/AdaptionVisitor.cs index 88b785f18..762e63788 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/AdaptionVisitor.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/AdaptionVisitor.cs @@ -9,48 +9,90 @@ using System; using System.Collections.Generic; using System.Linq; using NodaTime; +using Squidex.Infrastructure; using Squidex.Infrastructure.Queries; namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations { - internal sealed class AdaptionVisitor : TransformVisitor + internal sealed class AdaptionVisitor : TransformVisitor { - private readonly Func pathConverter; + private static readonly AdaptionVisitor Instance = new AdaptionVisitor(); - public AdaptionVisitor(Func pathConverter) + public struct Args { - this.pathConverter = pathConverter; + public readonly Func PathConverter; + + public readonly DomainId AppId; + + public Args(Func pathConverter, DomainId appId) + { + PathConverter = pathConverter; + + AppId = appId; + } } - public override FilterNode Visit(CompareFilter nodeIn) + private AdaptionVisitor() { - CompareFilter result; + } - var path = pathConverter(nodeIn.Path); + public static FilterNode? Adapt(FilterNode filter, Func pathConverter, DomainId appId) + { + var args = new Args(pathConverter, appId); - var value = nodeIn.Value.Value; + return filter.Accept(Instance, args); + } - if (value is Instant && - !string.Equals(path[0], "mt", StringComparison.OrdinalIgnoreCase) && - !string.Equals(path[0], "ct", StringComparison.OrdinalIgnoreCase)) + public override FilterNode Visit(CompareFilter nodeIn, Args args) + { + var result = nodeIn; + + var (path, op, value) = nodeIn; + + var clrValue = value.Value; + + if (string.Equals(path[0], "id", StringComparison.OrdinalIgnoreCase)) { - result = new CompareFilter(path, nodeIn.Operator, value.ToString()); + path = "_id"; + + if (clrValue is List idList) + { + value = idList.Select(x => DomainId.Combine(args.AppId, DomainId.Create(x)).ToString()).ToList(); + } + else if (clrValue is string id) + { + value = DomainId.Combine(args.AppId, DomainId.Create(id)).ToString(); + } + else if (clrValue is List guidIdList) + { + value = guidIdList.Select(x => DomainId.Combine(args.AppId, DomainId.Create(x)).ToString()).ToList(); + } + else if (clrValue is Guid guidId) + { + value = DomainId.Combine(args.AppId, DomainId.Create(guidId)).ToString(); + } } else { - result = new CompareFilter(path, nodeIn.Operator, nodeIn.Value); - } + path = args.PathConverter(path); - if (value is List guidList) - { - result = new CompareFilter(path, nodeIn.Operator, guidList.Select(x => x.ToString()).ToList()); - } - else if (value is Guid guid) - { - result = new CompareFilter(path, nodeIn.Operator, guid.ToString()); + if (clrValue is List guidList) + { + value = guidList.Select(x => x.ToString()).ToList(); + } + else if (clrValue is Guid guid) + { + value = guid.ToString(); + } + else if (clrValue is Instant && + !string.Equals(path[0], "mt", StringComparison.OrdinalIgnoreCase) && + !string.Equals(path[0], "ct", StringComparison.OrdinalIgnoreCase)) + { + value = clrValue.ToString(); + } } - return result; + return result with { Path = path, Value = value }; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs index a81e539da..aac1a4e26 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -15,7 +14,6 @@ using MongoDB.Driver; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Contents; -using Squidex.Domain.Apps.Entities.Contents.Text; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.MongoDb.Queries; @@ -26,7 +24,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations { internal sealed class QueryByQuery : OperationBase { - private readonly ITextIndex indexer; private readonly IAppProvider appProvider; [BsonIgnoreExtraElements] @@ -39,11 +36,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations public MongoContentEntity[] Joined { get; set; } } - public QueryByQuery(DataConverter dataConverter, ITextIndex indexer, IAppProvider appProvider) + public QueryByQuery(DataConverter dataConverter, IAppProvider appProvider) : base(dataConverter) { - this.indexer = indexer; - this.appProvider = appProvider; } @@ -81,7 +76,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations return new List<(DomainId SchemaId, DomainId Id, Status Status)>(); } - var filter = BuildFilter(appId, schemaId, filterNode.AdjustToModel(schema.SchemaDef)); + var filter = BuildFilter(appId, schemaId, filterNode.AdjustToModel(appId, schema.SchemaDef)); var contentItems = await Collection.FindStatusAsync(filter); @@ -104,16 +99,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations try { - var query = q.Query.AdjustToModel(null); - - List? fullTextIds = null; - - if (!string.IsNullOrWhiteSpace(query.FullText)) - { - throw new NotSupportedException(); - } + var query = q.Query.AdjustToModel(app.Id, null); - var filter = CreateFilter(app.Id, schemas.Select(x => x.Id), fullTextIds, query, q.Reference); + var filter = CreateFilter(app.Id, schemas.Select(x => x.Id), query, q.Reference); var contentEntities = await FindContentsAsync(query, filter); var contentTotal = (long)contentEntities.Count; @@ -145,7 +133,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations } } - public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Q q, SearchScope scope) + public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Q q) { Guard.NotNull(app, nameof(app)); Guard.NotNull(schema, nameof(schema)); @@ -153,23 +141,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations try { - var query = q.Query.AdjustToModel(schema.SchemaDef); + var query = q.Query.AdjustToModel(app.Id, schema.SchemaDef); - List? fullTextIds = null; - - if (!string.IsNullOrWhiteSpace(query.FullText)) - { - var searchFilter = SearchFilter.ShouldHaveSchemas(schema.Id); - - fullTextIds = await indexer.SearchAsync(query.FullText, app, searchFilter, scope); - - if (fullTextIds?.Count == 0) - { - return ResultList.CreateFrom(0); - } - } - - var filter = CreateFilter(schema.AppId.Id, Enumerable.Repeat(schema.Id, 1), fullTextIds, query, q.Reference); + var filter = CreateFilter(schema.AppId.Id, Enumerable.Repeat(schema.Id, 1), query, q.Reference); var contentEntities = await FindContentsAsync(query, filter); var contentTotal = (long)contentEntities.Count; @@ -255,7 +229,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations return Filter.And(filters); } - private static FilterDefinition CreateFilter(DomainId appId, IEnumerable schemaIds, ICollection? ids, ClrQuery? query, DomainId referenced) + private static FilterDefinition CreateFilter(DomainId appId, IEnumerable schemaIds, ClrQuery? query, DomainId referenced) { var filters = new List> { @@ -264,16 +238,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations Filter.Ne(x => x.IsDeleted, true) }; - if (ids != null && ids.Count > 0) - { - var documentIds = ids.Select(x => DomainId.Combine(appId, x)).ToList(); - - filters.Add( - Filter.Or( - Filter.AnyIn(x => x.ReferencedIds, documentIds), - Filter.In(x => x.DocumentId, documentIds))); - } - if (query?.Filter != null) { filters.Add(query.Filter.BuildFilter()); diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/CommandFactory.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/CommandFactory.cs new file mode 100644 index 000000000..b0f2f8415 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/CommandFactory.cs @@ -0,0 +1,107 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using MongoDB.Driver; +using Squidex.Domain.Apps.Entities.Contents.Text; + +namespace Squidex.Domain.Apps.Entities.MongoDb.FullText +{ + public static class CommandFactory + { + private static readonly FilterDefinitionBuilder Filter = Builders.Filter; + private static readonly UpdateDefinitionBuilder Update = Builders.Update; + + public static void CreateCommands(IndexCommand command, List> writes) + { + switch (command) + { + case DeleteIndexEntry delete: + DeleteEntry(delete, writes); + break; + case UpsertIndexEntry upsert: + UpsertEntry(upsert, writes); + break; + case UpdateIndexEntry update: + UpdateEntry(update, writes); + break; + } + } + + private static void UpsertEntry(UpsertIndexEntry upsert, List> writes) + { + writes.Add( + new UpdateOneModel( + Filter.And( + Filter.Eq(x => x.DocId, upsert.DocId), + Filter.Exists(x => x.GeoField, false), + Filter.Exists(x => x.GeoObject, false)), + Update + .Set(x => x.ServeAll, upsert.ServeAll) + .Set(x => x.ServePublished, upsert.ServePublished) + .Set(x => x.Texts, upsert.Texts?.Values.Select(MongoTextIndexEntityText.FromText).ToList()) + .SetOnInsert(x => x.Id, Guid.NewGuid().ToString()) + .SetOnInsert(x => x.DocId, upsert.DocId) + .SetOnInsert(x => x.AppId, upsert.AppId.Id) + .SetOnInsert(x => x.ContentId, upsert.ContentId) + .SetOnInsert(x => x.SchemaId, upsert.SchemaId.Id)) + { + IsUpsert = true + }); + + if (upsert.GeoObjects?.Any() == true) + { + if (!upsert.IsNew) + { + writes.Add( + new DeleteOneModel( + Filter.And( + Filter.Eq(x => x.DocId, upsert.DocId), + Filter.Exists(x => x.GeoField), + Filter.Exists(x => x.GeoObject)))); + } + + foreach (var (field, geoObject) in upsert.GeoObjects) + { + writes.Add( + new InsertOneModel( + new MongoTextIndexEntity + { + Id = Guid.NewGuid().ToString(), + AppId = upsert.AppId.Id, + DocId = upsert.DocId, + ContentId = upsert.ContentId, + GeoField = field, + GeoObject = geoObject, + SchemaId = upsert.SchemaId.Id, + ServeAll = upsert.ServeAll, + ServePublished = upsert.ServePublished + })); + } + } + } + + private static void UpdateEntry(UpdateIndexEntry update, List> writes) + { + writes.Add( + new UpdateOneModel( + Filter.Eq(x => x.DocId, update.DocId), + Update + .Set(x => x.ServeAll, update.ServeAll) + .Set(x => x.ServePublished, update.ServePublished))); + } + + private static void DeleteEntry(DeleteIndexEntry delete, List> writes) + { + writes.Add( + new DeleteOneModel( + Filter.Eq(x => x.DocId, delete.DocId))); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndex.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndex.cs index 796b2627c..1f7c5312a 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndex.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndex.cs @@ -21,6 +21,8 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.FullText { public sealed class MongoTextIndex : MongoRepositoryBase, ITextIndex { + private const int Limit = 2000; + private const int LimitHalf = 1000; private static readonly List EmptyResults = new List(); public MongoTextIndex(IMongoDatabase database, bool setup = false) @@ -32,13 +34,25 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.FullText { return collection.Indexes.CreateManyAsync(new[] { + new CreateIndexModel( + Index.Ascending(x => x.DocId)), + new CreateIndexModel( Index .Text("t.t") .Ascending(x => x.AppId) .Ascending(x => x.ServeAll) .Ascending(x => x.ServePublished) - .Ascending(x => x.SchemaId)) + .Ascending(x => x.SchemaId)), + + new CreateIndexModel( + Index + .Ascending(x => x.AppId) + .Ascending(x => x.ServeAll) + .Ascending(x => x.ServePublished) + .Ascending(x => x.SchemaId) + .Ascending(x => x.GeoField) + .Geo2DSphere(x => x.GeoObject)) }, ct); } @@ -53,40 +67,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.FullText foreach (var command in commands) { - switch (command) - { - case DeleteIndexEntry _: - writes.Add( - new DeleteOneModel( - Filter.Eq(x => x.DocId, command.DocId))); - break; - case UpdateIndexEntry update: - writes.Add( - new UpdateOneModel( - Filter.Eq(x => x.DocId, command.DocId), - Update - .Set(x => x.ServeAll, update.ServeAll) - .Set(x => x.ServePublished, update.ServePublished))); - break; - case UpsertIndexEntry upsert when upsert.Texts.Count > 0: - writes.Add( - new ReplaceOneModel( - Filter.Eq(x => x.DocId, command.DocId), - new MongoTextIndexEntity - { - DocId = upsert.DocId, - ContentId = upsert.ContentId, - SchemaId = upsert.SchemaId.Id, - ServeAll = upsert.ServeAll, - ServePublished = upsert.ServePublished, - Texts = upsert.Texts.Select(x => new MongoTextIndexEntityText { Text = x.Value }).ToList(), - AppId = upsert.AppId.Id - }) - { - IsUpsert = true - }); - break; - } + CommandFactory.CreateCommands(command, writes); } if (writes.Count == 0) @@ -97,8 +78,27 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.FullText return Collection.BulkWriteAsync(writes); } - public async Task?> SearchAsync(string? queryText, IAppEntity app, SearchFilter? filter, SearchScope scope) + public async Task?> SearchAsync(IAppEntity app, GeoQuery query, SearchScope scope) + { + var byGeo = + await Collection.Find( + Filter.And( + Filter.Eq(x => x.AppId, app.Id), + Filter.Eq(x => x.SchemaId, query.SchemaId), + Filter_ByScope(scope), + Filter.GeoWithinCenterSphere(x => x.GeoObject, query.Longitude, query.Latitude, query.Radius / 6378100))) + .Limit(Limit).Only(x => x.ContentId) + .ToListAsync(); + + var field = Field.Of(x => nameof(x.ContentId)); + + return byGeo.Select(x => DomainId.Create(x[field].AsString)).Distinct().ToList(); + } + + public async Task?> SearchAsync(IAppEntity app, TextQuery query, SearchScope scope) { + var (queryText, filter) = query; + if (string.IsNullOrWhiteSpace(queryText)) { return EmptyResults; @@ -106,24 +106,24 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.FullText if (filter == null) { - return await SearchByAppAsync(queryText, app, scope, 2000); + return await SearchByAppAsync(queryText, app, scope, Limit); } else if (filter.Must) { - return await SearchBySchemaAsync(queryText, app, filter, scope, 2000); + return await SearchBySchemaAsync(queryText, app, filter, scope, Limit); } else { var (bySchema, byApp) = await AsyncHelper.WhenAll( - SearchBySchemaAsync(queryText, app, filter, scope, 1000), - SearchByAppAsync(queryText, app, scope, 1000)); + SearchBySchemaAsync(queryText, app, filter, scope, LimitHalf), + SearchByAppAsync(queryText, app, scope, LimitHalf)); return bySchema.Union(byApp).Distinct().ToList(); } } - private async Task> SearchBySchemaAsync(string queryText, IAppEntity app, SearchFilter filter, SearchScope scope, int limit) + private async Task> SearchBySchemaAsync(string queryText, IAppEntity app, TextFilter filter, SearchScope scope, int limit) { var bySchema = await Collection.Find( diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexEntity.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexEntity.cs index a34e1133e..ed3e7aa93 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexEntity.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexEntity.cs @@ -6,9 +6,11 @@ // ========================================================================== using System.Collections.Generic; +using GeoJSON.Net; using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; using Squidex.Infrastructure; +using Squidex.Infrastructure.MongoDb; namespace Squidex.Domain.Apps.Entities.MongoDb.FullText { @@ -17,6 +19,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.FullText [BsonId] [BsonElement] [BsonRepresentation(BsonType.String)] + public string Id { get; set; } + + [BsonRequired] + [BsonElement] public string DocId { get; set; } [BsonRequired] @@ -45,5 +51,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.FullText [BsonIgnoreIfNull] [BsonElement("t")] public List Texts { get; set; } + + [BsonIgnoreIfNull] + [BsonElement("gf")] + public string GeoField { get; set; } + + [BsonIgnoreIfNull] + [BsonElement("go")] + [BsonJson] + public GeoJSONObject GeoObject { get; set; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexEntityText.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexEntityText.cs index 64216b08d..5a61bc54a 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexEntityText.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexEntityText.cs @@ -18,5 +18,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.FullText [BsonIgnoreIfNull] [BsonElement("language")] public string Language { get; set; } = "none"; + + public static MongoTextIndexEntityText FromText(string text) + { + return new MongoTextIndexEntityText { Text = text }; + } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryParser.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryParser.cs index 047b49bfb..cbeeb93e8 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryParser.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryParser.cs @@ -46,52 +46,73 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries this.options = options.Value; } - public virtual async ValueTask ParseQueryAsync(Context context, Q q) + public virtual async Task ParseQueryAsync(Context context, Q q) { Guard.NotNull(context, nameof(context)); Guard.NotNull(q, nameof(q)); using (Profiler.TraceMethod()) { - var query = q.Query; - - if (!string.IsNullOrWhiteSpace(q?.JsonQueryString)) - { - query = ParseJson(q.JsonQueryString); - } - else if (!string.IsNullOrWhiteSpace(q?.ODataQuery)) - { - query = ParseOData(q.ODataQuery); - } - - if (query.Filter != null) - { - query.Filter = await FilterTagTransformer.TransformAsync(query.Filter, context.App.Id, tagService); - } - - if (query.Sort.Count == 0) - { - query.Sort.Add(new SortNode(new List { "lastModified" }, SortOrder.Descending)); - } - - if (!query.Sort.Any(x => string.Equals(x.Path.ToString(), "id", StringComparison.OrdinalIgnoreCase))) - { - query.Sort.Add(new SortNode(new List { "id" }, SortOrder.Ascending)); - } - - if (query.Take == long.MaxValue) - { - query.Take = options.DefaultPageSize; - } - else if (query.Take > options.MaxResults) - { - query.Take = options.MaxResults; - } + var query = ParseQuery(q); + + await TransformTagAsync(context, query); + + WithSorting(query); + WithPaging(query); return q!.WithQuery(query); } } + private ClrQuery ParseQuery(Q q) + { + var query = q.Query; + + if (!string.IsNullOrWhiteSpace(q?.JsonQueryString)) + { + query = ParseJson(q.JsonQueryString); + } + else if (!string.IsNullOrWhiteSpace(q?.ODataQuery)) + { + query = ParseOData(q.ODataQuery); + } + + return query; + } + + private void WithPaging(ClrQuery query) + { + if (query.Take == long.MaxValue) + { + query.Take = options.DefaultPageSize; + } + else if (query.Take > options.MaxResults) + { + query.Take = options.MaxResults; + } + } + + private static void WithSorting(ClrQuery query) + { + if (query.Sort.Count == 0) + { + query.Sort.Add(new SortNode(new List { "lastModified" }, SortOrder.Descending)); + } + + if (!query.Sort.Any(x => string.Equals(x.Path.ToString(), "id", StringComparison.OrdinalIgnoreCase))) + { + query.Sort.Add(new SortNode(new List { "id" }, SortOrder.Ascending)); + } + } + + private async Task TransformTagAsync(Context context, ClrQuery query) + { + if (query.Filter != null) + { + query.Filter = await FilterTagTransformer.TransformAsync(query.Filter, context.App.Id, tagService); + } + } + private ClrQuery ParseJson(string json) { return jsonSchema.Parse(json, jsonSerializer); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/FilterTagTransformer.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/FilterTagTransformer.cs index 1eac8482f..22710e7d1 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/FilterTagTransformer.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/FilterTagTransformer.cs @@ -13,31 +13,40 @@ using Squidex.Infrastructure.Queries; namespace Squidex.Domain.Apps.Entities.Assets.Queries { - public sealed class FilterTagTransformer : AsyncTransformVisitor + internal sealed class FilterTagTransformer : AsyncTransformVisitor { - private readonly ITagService tagService; - private readonly DomainId appId; + private static readonly FilterTagTransformer Instance = new FilterTagTransformer(); - private FilterTagTransformer(DomainId appId, ITagService tagService) + public struct Args { - this.appId = appId; + public readonly DomainId AppId; - this.tagService = tagService; + public readonly ITagService TagService; + + public Args(DomainId appId, ITagService tagService) + { + AppId = appId; + + TagService = tagService; + } + } + + private FilterTagTransformer() + { } public static ValueTask?> TransformAsync(FilterNode nodeIn, DomainId appId, ITagService tagService) { - Guard.NotNull(nodeIn, nameof(nodeIn)); - Guard.NotNull(tagService, nameof(tagService)); + var args = new Args(appId, tagService); - return nodeIn.Accept(new FilterTagTransformer(appId, tagService)); + return nodeIn.Accept(Instance, args); } - public override async ValueTask?> Visit(CompareFilter nodeIn) + public override async ValueTask?> Visit(CompareFilter nodeIn, Args args) { if (string.Equals(nodeIn.Path[0], nameof(IAssetEntity.Tags), StringComparison.OrdinalIgnoreCase) && nodeIn.Value.Value is string stringValue) { - var tagNames = await tagService.GetTagIdsAsync(appId, TagGroups.Assets, HashSet.Of(stringValue)); + var tagNames = await args.TagService.GetTagIdsAsync(args.AppId, TagGroups.Assets, HashSet.Of(stringValue)); if (tagNames.TryGetValue(stringValue, out var normalized)) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsSearchSource.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsSearchSource.cs index 915345c47..c263cac59 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsSearchSource.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsSearchSource.cs @@ -56,7 +56,9 @@ namespace Squidex.Domain.Apps.Entities.Contents return result; } - var ids = await contentTextIndexer.SearchAsync($"{query}~", context.App, searchFilter, context.Scope()); + var textQuery = new TextQuery($"{query}~", searchFilter); + + var ids = await contentTextIndexer.SearchAsync(context.App, textQuery, context.Scope()); if (ids == null || ids.Count == 0) { @@ -79,7 +81,7 @@ namespace Squidex.Domain.Apps.Entities.Contents return result; } - private async Task CreateSearchFilterAsync(Context context) + private async Task CreateSearchFilterAsync(Context context) { var allowedSchemas = new List(); @@ -98,7 +100,7 @@ namespace Squidex.Domain.Apps.Entities.Contents return null; } - return SearchFilter.MustHaveSchemas(allowedSchemas.ToArray()); + return TextFilter.MustHaveSchemas(allowedSchemas.ToArray()); } private static bool HasPermission(Context context, string schemaName) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs index ac5c8ea13..c4fcb08b4 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs @@ -18,6 +18,7 @@ using Squidex.Domain.Apps.Core.GenerateEdmSchema; using Squidex.Domain.Apps.Core.GenerateJsonSchema; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Contents.Text; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.Json; @@ -39,63 +40,121 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries private readonly JsonSchema genericJsonSchema = BuildJsonSchema("Content", null); private readonly IMemoryCache cache; private readonly IJsonSerializer jsonSerializer; + private readonly ITextIndex textIndex; private readonly ContentOptions options; - public ContentQueryParser(IMemoryCache cache, IJsonSerializer jsonSerializer, IOptions options) + public ContentQueryParser(IMemoryCache cache, IJsonSerializer jsonSerializer, ITextIndex textIndex, IOptions options) { Guard.NotNull(jsonSerializer, nameof(jsonSerializer)); + Guard.NotNull(textIndex, nameof(textIndex)); Guard.NotNull(cache, nameof(cache)); Guard.NotNull(options, nameof(options)); this.jsonSerializer = jsonSerializer; + this.textIndex = textIndex; this.cache = cache; this.options = options.Value; } - public virtual ValueTask ParseAsync(Context context, Q q, ISchemaEntity? schema = null) + public virtual async Task ParseAsync(Context context, Q q, ISchemaEntity? schema = null) { Guard.NotNull(context, nameof(context)); Guard.NotNull(q, nameof(q)); using (Profiler.TraceMethod()) { - var query = q.Query; + var query = ParseQuery(context, q, schema); - if (!string.IsNullOrWhiteSpace(q.JsonQueryString)) - { - query = ParseJson(context, schema, q.JsonQueryString); - } - else if (q?.JsonQuery != null) - { - query = ParseJson(context, schema, q.JsonQuery); - } - else if (!string.IsNullOrWhiteSpace(q?.ODataQuery)) - { - query = ParseOData(context, schema, q.ODataQuery); - } + await TransformFilterAsync(query, context, schema); + + WithSorting(query); + WithPaging(query); + + q = q!.WithQuery(query); + + return q; + } + } - if (query.Sort.Count == 0) + private async Task TransformFilterAsync(ClrQuery query, Context context, ISchemaEntity? schema) + { + if (query.Filter != null && schema != null) + { + query.Filter = await GeoQueryTransformer.TransformAsync(query.Filter, context, schema, textIndex); + } + + if (!string.IsNullOrWhiteSpace(query.FullText)) + { + if (schema == null) { - query.Sort.Add(new SortNode(new List { "lastModified" }, SortOrder.Descending)); + throw new InvalidOperationException(); } - if (!query.Sort.Any(x => string.Equals(x.Path.ToString(), "id", StringComparison.OrdinalIgnoreCase))) + var textQuery = new TextQuery(query.FullText, TextFilter.ShouldHaveSchemas(schema.Id)); + + var fullTextIds = await textIndex.SearchAsync(context.App, textQuery, context.Scope()); + var fullTextFilter = ClrFilter.Eq("id", "__notfound__"); + + if (fullTextIds?.Any() == true) { - query.Sort.Add(new SortNode(new List { "id" }, SortOrder.Ascending)); + fullTextFilter = ClrFilter.In("id", fullTextIds.Select(x => x.ToString()).ToList()); } - if (query.Take == long.MaxValue) + if (query.Filter != null) { - query.Take = options.DefaultPageSize; + query.Filter = ClrFilter.And(query.Filter, fullTextFilter); } - else if (query.Take > options.MaxResults) + else { - query.Take = options.MaxResults; + query.Filter = fullTextFilter; } - q = q!.WithQuery(query); + query.FullText = null; + } + } - return new ValueTask(q); + private ClrQuery ParseQuery(Context context, Q q, ISchemaEntity? schema) + { + var query = q.Query; + + if (!string.IsNullOrWhiteSpace(q.JsonQueryString)) + { + query = ParseJson(context, schema, q.JsonQueryString); + } + else if (q?.JsonQuery != null) + { + query = ParseJson(context, schema, q.JsonQuery); + } + else if (!string.IsNullOrWhiteSpace(q?.ODataQuery)) + { + query = ParseOData(context, schema, q.ODataQuery); + } + + return query; + } + + private static void WithSorting(ClrQuery query) + { + if (query.Sort.Count == 0) + { + query.Sort.Add(new SortNode(new List { "lastModified" }, SortOrder.Descending)); + } + + if (!query.Sort.Any(x => string.Equals(x.Path.ToString(), "id", StringComparison.OrdinalIgnoreCase))) + { + query.Sort.Add(new SortNode(new List { "id" }, SortOrder.Ascending)); + } + } + + private void WithPaging(ClrQuery query) + { + if (query.Take == long.MaxValue) + { + query.Take = options.DefaultPageSize; + } + else if (query.Take > options.MaxResults) + { + query.Take = options.MaxResults; } } @@ -182,14 +241,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { Properties = { - ["id"] = SchemaBuilder.StringProperty($"The id of the {name} content.", true), - ["version"] = SchemaBuilder.NumberProperty($"The version of the {name}.", true), - ["created"] = SchemaBuilder.DateTimeProperty($"The date and time when the {name} content has been created.", true), - ["createdBy"] = SchemaBuilder.StringProperty($"The user that has created the {name} content.", true), - ["lastModified"] = SchemaBuilder.DateTimeProperty($"The date and time when the {name} content has been modified last.", true), - ["lastModifiedBy"] = SchemaBuilder.StringProperty($"The user that has updated the {name} content last.", true), - ["newStatus"] = SchemaBuilder.StringProperty($"The new status of the content.", false), - ["status"] = SchemaBuilder.StringProperty($"The status of the content.", true) + [nameof(IContentEntity.Id).ToCamelCase()] = SchemaBuilder.StringProperty($"The id of the {name} content.", true), + [nameof(IContentEntity.Version).ToCamelCase()] = SchemaBuilder.NumberProperty($"The version of the {name}.", true), + [nameof(IContentEntity.Created).ToCamelCase()] = SchemaBuilder.DateTimeProperty($"The date and time when the {name} content has been created.", true), + [nameof(IContentEntity.CreatedBy).ToCamelCase()] = SchemaBuilder.StringProperty($"The user that has created the {name} content.", true), + [nameof(IContentEntity.LastModified).ToCamelCase()] = SchemaBuilder.DateTimeProperty($"The date and time when the {name} content has been modified last.", true), + [nameof(IContentEntity.LastModifiedBy).ToCamelCase()] = SchemaBuilder.StringProperty($"The user that has updated the {name} content last.", true), + [nameof(IContentEntity.NewStatus).ToCamelCase()] = SchemaBuilder.StringProperty($"The new status of the content.", false), + [nameof(IContentEntity.Status).ToCamelCase()] = SchemaBuilder.StringProperty($"The status of the content.", true) }, Type = JsonObjectType.Object }; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/FilterTagTransformer.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/FilterTagTransformer.cs deleted file mode 100644 index 72738bfa5..000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/FilterTagTransformer.cs +++ /dev/null @@ -1,71 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Domain.Apps.Core.Tags; -using Squidex.Domain.Apps.Entities.Schemas; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Queries; - -namespace Squidex.Domain.Apps.Entities.Contents.Queries -{ - public sealed class FilterTagTransformer : AsyncTransformVisitor - { - private readonly ITagService tagService; - private readonly ISchemaEntity schema; - private readonly DomainId appId; - - private FilterTagTransformer(DomainId appId, ISchemaEntity schema, ITagService tagService) - { - this.appId = appId; - this.schema = schema; - this.tagService = tagService; - } - - public static ValueTask?> TransformAsync(FilterNode nodeIn, DomainId appId, ISchemaEntity schema, ITagService tagService) - { - Guard.NotNull(nodeIn, nameof(nodeIn)); - Guard.NotNull(tagService, nameof(tagService)); - Guard.NotNull(schema, nameof(schema)); - - return nodeIn.Accept(new FilterTagTransformer(appId, schema, tagService)); - } - - public override async ValueTask?> Visit(CompareFilter nodeIn) - { - if (nodeIn.Value.Value is string stringValue && IsDataPath(nodeIn.Path) && IsTagField(nodeIn.Path)) - { - var tagNames = await tagService.GetTagIdsAsync(appId, TagGroups.Schemas(schema.Id), HashSet.Of(stringValue)); - - if (tagNames.TryGetValue(stringValue, out var normalized)) - { - return new CompareFilter(nodeIn.Path, nodeIn.Operator, normalized); - } - } - - return nodeIn; - } - - private bool IsTagField(IReadOnlyList path) - { - return schema.SchemaDef.FieldsByName.TryGetValue(path[1], out var field) && IsTagField(field); - } - - private static bool IsTagField(IField field) - { - return field is IField tags && tags.Properties.Normalization == TagsFieldNormalization.Schema; - } - - private static bool IsDataPath(IReadOnlyList path) - { - return path.Count == 3 && string.Equals(path[0], nameof(IContentEntity.Data), StringComparison.OrdinalIgnoreCase); - } - } -} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/GeoQueryTransformer.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/GeoQueryTransformer.cs new file mode 100644 index 000000000..741d87d8b --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/GeoQueryTransformer.cs @@ -0,0 +1,70 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Contents.Text; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure.Queries; + +namespace Squidex.Domain.Apps.Entities.Contents.Queries +{ + internal sealed class GeoQueryTransformer : AsyncTransformVisitor + { + public static readonly GeoQueryTransformer Instance = new GeoQueryTransformer(); + + public struct Args + { + public readonly ITextIndex TextIndex; + + public readonly ISchemaEntity Schema; + + public readonly Context Context; + + public Args(Context context, ISchemaEntity schema, ITextIndex textIndex) + { + Context = context; + Schema = schema; + TextIndex = textIndex; + } + } + + private GeoQueryTransformer() + { + } + + public static async Task?> TransformAsync(FilterNode filter, Context context, ISchemaEntity schema, ITextIndex textIndex) + { + var args = new Args(context, schema, textIndex); + + return await filter.Accept(Instance, args); + } + + public override async ValueTask?> Visit(CompareFilter nodeIn, Args args) + { + if (nodeIn.Value.Value is FilterSphere sphere) + { + var field = string.Join(".", nodeIn.Path.Skip(1)); + + var searchQuery = new GeoQuery(args.Schema.Id, field, sphere.Latitude, sphere.Longitude, sphere.Radius); + var searchScope = args.Context.Scope(); + + var ids = await args.TextIndex.SearchAsync(args.Context.App, searchQuery, searchScope); + + if (ids == null || ids.Count == 0) + { + return ClrFilter.Eq("id", "__notfound__"); + } + + return ClrFilter.In("id", ids.Select(x => x.ToString()).ToList()); + } + + return nodeIn; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/CommandFactory.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/CommandFactory.cs new file mode 100644 index 000000000..e4d5b0c7f --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/CommandFactory.cs @@ -0,0 +1,87 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic +{ + public static class CommandFactory + { + public static void CreateCommands(IndexCommand command, List args, string indexName) + { + switch (command) + { + case UpsertIndexEntry upsert: + UpsertEntry(upsert, args, indexName); + break; + case UpdateIndexEntry update: + UpdateEntry(update, args, indexName); + break; + case DeleteIndexEntry delete: + DeleteEntry(delete, args, indexName); + break; + } + } + + private static void UpsertEntry(UpsertIndexEntry upsert, List args, string indexName) + { + args.Add(new + { + index = new + { + _id = upsert.DocId, + _index = indexName, + } + }); + + args.Add(new + { + appId = upsert.AppId.Id.ToString(), + appName = upsert.AppId.Name, + contentId = upsert.ContentId.ToString(), + schemaId = upsert.SchemaId.Id.ToString(), + schemaName = upsert.SchemaId.Name, + serveAll = upsert.ServeAll, + servePublished = upsert.ServePublished, + texts = upsert.Texts + }); + } + + private static void UpdateEntry(UpdateIndexEntry update, List args, string indexName) + { + args.Add(new + { + update = new + { + _id = update.DocId, + _index = indexName, + } + }); + + args.Add(new + { + doc = new + { + serveAll = update.ServeAll, + servePublished = update.ServePublished + } + }); + } + + private static void DeleteEntry(DeleteIndexEntry delete, List args, string indexName) + { + args.Add(new + { + delete = new + { + _id = delete.DocId, + _index = indexName, + } + }); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/ElasticSearchTextIndex.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/ElasticSearchTextIndex.cs index 9cbea39e1..ee057d813 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/ElasticSearchTextIndex.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/ElasticSearchTextIndex.cs @@ -55,18 +55,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic foreach (var command in commands) { - switch (command) - { - case UpsertIndexEntry upsert: - Upsert(upsert, args); - break; - case UpdateIndexEntry update: - Update(update, args); - break; - case DeleteIndexEntry delete: - Delete(delete, args); - break; - } + CommandFactory.CreateCommands(command, args, indexName); } if (args.Count > 0) @@ -85,68 +74,21 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic } } - private void Upsert(UpsertIndexEntry upsert, List args) + public Task?> SearchAsync(IAppEntity app, GeoQuery query, SearchScope scope) { - args.Add(new - { - index = new - { - _id = upsert.DocId, - _index = indexName, - } - }); - - args.Add(new - { - appId = upsert.AppId.Id.ToString(), - appName = upsert.AppId.Name, - contentId = upsert.ContentId.ToString(), - schemaId = upsert.SchemaId.Id.ToString(), - schemaName = upsert.SchemaId.Name, - serveAll = upsert.ServeAll, - servePublished = upsert.ServePublished, - texts = upsert.Texts - }); + return Task.FromResult?>(null); } - private void Update(UpdateIndexEntry update, List args) + public async Task?> SearchAsync(IAppEntity app, TextQuery query, SearchScope scope) { - args.Add(new - { - update = new - { - _id = update.DocId, - _index = indexName, - } - }); + Guard.NotNull(app, nameof(app)); + Guard.NotNull(query, nameof(query)); - args.Add(new - { - doc = new - { - serveAll = update.ServeAll, - servePublished = update.ServePublished - } - }); - } - - private void Delete(DeleteIndexEntry delete, List args) - { - args.Add(new - { - delete = new - { - _id = delete.DocId, - _index = indexName, - } - }); - } + var queryText = query.Text; - public async Task?> SearchAsync(string? queryText, IAppEntity app, SearchFilter? filter, SearchScope scope) - { if (string.IsNullOrWhiteSpace(queryText)) { - return new List(); + return null; } var isFuzzy = queryText.EndsWith("~", StringComparison.OrdinalIgnoreCase); @@ -172,7 +114,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic var serveField = GetServeField(scope); - var query = new + var elasticQuery = new { query = new { @@ -203,7 +145,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic { field }, - query = queryText + query = query.Text } } }, @@ -217,27 +159,27 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text.Elastic size = 2000 }; - if (filter?.SchemaIds.Count > 0) + if (query.Filter?.SchemaIds?.Length > 0) { var bySchema = new { terms = new Dictionary { - ["schemaId.keyword"] = filter.SchemaIds.Select(x => x.ToString()).ToArray() + ["schemaId.keyword"] = query.Filter.SchemaIds.Select(x => x.ToString()).ToArray() } }; - if (filter.Must) + if (query.Filter.Must) { - query.query.@bool.must.Add(bySchema); + elasticQuery.query.@bool.must.Add(bySchema); } else { - query.query.@bool.should.Add(bySchema); + elasticQuery.query.@bool.should.Add(bySchema); } } - var result = await client.SearchAsync(indexName, CreatePost(query)); + var result = await client.SearchAsync(indexName, CreatePost(elasticQuery)); if (!result.Success) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Extensions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Extensions.cs index 815b26430..885255917 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Extensions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Extensions.cs @@ -6,9 +6,13 @@ // ========================================================================== using System.Collections.Generic; +using System.IO; using System.Text; +using GeoJSON.Net; +using GeoJSON.Net.Geometry; using Microsoft.Extensions.ObjectPool; using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Json.Objects; namespace Squidex.Domain.Apps.Entities.Contents.Text @@ -18,9 +22,69 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text private static readonly ObjectPool StringBuilderPool = new DefaultObjectPool(new StringBuilderPooledObjectPolicy()); - public static Dictionary ToTexts(this NamedContentData data) + private static readonly ObjectPool MemoryStreamPool = + new DefaultObjectPool(new DefaultPooledObjectPolicy()); + + public static Dictionary? ToGeo(this NamedContentData data, IJsonSerializer jsonSerializer) + { + Dictionary? result = null; + + foreach (var (field, value) in data) + { + if (value != null) + { + foreach (var (key, jsonValue) in value) + { + var geoJson = GetGeoJson(jsonSerializer, jsonValue); + + if (geoJson != null) + { + result ??= new Dictionary(); + result[$"{field}.{key}"] = geoJson; + } + } + } + } + + return result; + } + + private static GeoJSONObject? GetGeoJson(IJsonSerializer jsonSerializer, IJsonValue value) + { + if (value is JsonObject geoObject) + { + var stream = MemoryStreamPool.Get(); + + try + { + stream.Position = 0; + + jsonSerializer.Serialize(geoObject, stream, true); + + stream.Position = 0; + + return jsonSerializer.Deserialize(stream, null, true); + } + catch + { + if (geoObject.TryGetValue("latitude", out var lat) && + geoObject.TryGetValue("longitude", out var lon)) + { + return new Point(new Position(lat.Value, lon.Value)); + } + } + finally + { + MemoryStreamPool.Return(stream); + } + } + + return null; + } + + public static Dictionary? ToTexts(this NamedContentData data) { - var result = new Dictionary(); + Dictionary? result = null; if (data != null) { @@ -40,7 +104,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text foreach (var (key, sb) in languages) { - result[key] = sb.ToString(); + if (sb.Length > 0) + { + result ??= new Dictionary(); + result[key] = sb.ToString(); + } } } finally diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/GeoQuery.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/GeoQuery.cs new file mode 100644 index 000000000..f6f155640 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/GeoQuery.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure; + +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + +namespace Squidex.Domain.Apps.Entities.Contents.Text +{ + public sealed record GeoQuery(DomainId SchemaId, string Field, double Latitude, double Longitude, double Radius) + { + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/ITextIndex.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/ITextIndex.cs index 58f656d43..507b5988e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/ITextIndex.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/ITextIndex.cs @@ -14,7 +14,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text { public interface ITextIndex { - Task?> SearchAsync(string? queryText, IAppEntity app, SearchFilter? filter, SearchScope scope); + Task?> SearchAsync(IAppEntity app, TextQuery query, SearchScope scope); + + Task?> SearchAsync(IAppEntity app, GeoQuery query, SearchScope scope); Task ClearAsync(); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/SearchFilter.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/SearchFilter.cs deleted file mode 100644 index a434c69bd..000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/SearchFilter.cs +++ /dev/null @@ -1,45 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; -using System.Linq; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Entities.Contents.Text -{ - [Equals(DoNotAddEqualityOperators = true)] - public sealed class SearchFilter - { - public IReadOnlyList SchemaIds { get; } - - public bool Must { get; } - - public SearchFilter(IReadOnlyList schemaIds, bool must) - { - Guard.NotNull(schemaIds, nameof(schemaIds)); - - SchemaIds = schemaIds; - - Must = must; - } - - public static SearchFilter MustHaveSchemas(List schemaIds) - { - return new SearchFilter(schemaIds, true); - } - - public static SearchFilter MustHaveSchemas(params DomainId[] schemaIds) - { - return new SearchFilter(schemaIds?.ToList()!, true); - } - - public static SearchFilter ShouldHaveSchemas(params DomainId[] schemaIds) - { - return new SearchFilter(schemaIds?.ToList()!, false); - } - } -} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs index f25f31ada..1253a2f0d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs @@ -13,12 +13,14 @@ using Squidex.Domain.Apps.Entities.Contents.Text.State; using Squidex.Domain.Apps.Events.Contents; using Squidex.Infrastructure; using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Json; namespace Squidex.Domain.Apps.Entities.Contents.Text { public sealed class TextIndexingProcess : IEventConsumer { private const string NotFound = "<404>"; + private readonly IJsonSerializer jsonSerializer; private readonly ITextIndex textIndex; private readonly ITextIndexerState textIndexerState; @@ -50,12 +52,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text private sealed class Updates { private readonly Dictionary states; + private readonly IJsonSerializer jsonSerializer; private readonly Dictionary updates = new Dictionary(); private readonly Dictionary commands = new Dictionary(); - public Updates(Dictionary states) + public Updates(Dictionary states, IJsonSerializer jsonSerializer) { this.states = states; + + this.jsonSerializer = jsonSerializer; } public async Task WriteAsync(ITextIndex textIndex, ITextIndexerState textIndexerState) @@ -123,9 +128,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text { ContentId = @event.ContentId, DocId = state.DocIdCurrent, + GeoObjects = data.ToGeo(jsonSerializer), ServeAll = true, ServePublished = false, - Texts = data.ToTexts() + Texts = data.ToTexts(), + IsNew = true }); states[state.UniqueContentId] = state; @@ -178,6 +185,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text { ContentId = @event.ContentId, DocId = state.DocIdNew, + GeoObjects = data.ToGeo(jsonSerializer), ServeAll = true, ServePublished = false, Texts = data.ToTexts() @@ -200,6 +208,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text { ContentId = @event.ContentId, DocId = state.DocIdCurrent, + GeoObjects = data.ToGeo(jsonSerializer), ServeAll = true, ServePublished = isPublished, Texts = data.ToTexts() @@ -321,11 +330,16 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text } } - public TextIndexingProcess(ITextIndex textIndex, ITextIndexerState textIndexerState) + public TextIndexingProcess( + IJsonSerializer jsonSerializer, + ITextIndex textIndex, + ITextIndexerState textIndexerState) { + Guard.NotNull(jsonSerializer, nameof(jsonSerializer)); Guard.NotNull(textIndex, nameof(textIndex)); Guard.NotNull(textIndexerState, nameof(textIndexerState)); + this.jsonSerializer = jsonSerializer; this.textIndex = textIndex; this.textIndexerState = textIndexerState; } @@ -340,7 +354,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text { var states = await QueryStatesAsync(events); - var updates = new Updates(states); + var updates = new Updates(states, jsonSerializer); foreach (var @event in events) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextSearch.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextSearch.cs new file mode 100644 index 000000000..fea13f4f1 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextSearch.cs @@ -0,0 +1,30 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure; + +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + +namespace Squidex.Domain.Apps.Entities.Contents.Text +{ + public sealed record TextQuery(string? Text, TextFilter? Filter) + { + } + + public sealed record TextFilter(DomainId[]? SchemaIds, bool Must) + { + public static TextFilter MustHaveSchemas(params DomainId[] schemaIds) + { + return new TextFilter(schemaIds, true); + } + + public static TextFilter ShouldHaveSchemas(params DomainId[] schemaIds) + { + return new TextFilter(schemaIds, false); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/UpdateIndexEntry.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/UpdateIndexEntry.cs index d172d52a8..df0672ad9 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/UpdateIndexEntry.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/UpdateIndexEntry.cs @@ -7,7 +7,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text { - public sealed class UpdateIndexEntry : IndexCommand + public class UpdateIndexEntry : IndexCommand { public bool ServeAll { get; set; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/UpsertIndexEntry.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/UpsertIndexEntry.cs index 5e2dc914f..f2932aeff 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/UpsertIndexEntry.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/UpsertIndexEntry.cs @@ -6,18 +6,23 @@ // ========================================================================== using System.Collections.Generic; +using GeoJSON.Net; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents.Text { public sealed class UpsertIndexEntry : IndexCommand { - public Dictionary Texts { get; set; } + public Dictionary? GeoObjects { get; set; } + + public Dictionary? Texts { get; set; } public bool ServeAll { get; set; } public bool ServePublished { get; set; } public DomainId ContentId { get; set; } + + public bool IsNew { get; set; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/WriteonlyGeoJsonConverter.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/WriteonlyGeoJsonConverter.cs new file mode 100644 index 000000000..e4822df9c --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/WriteonlyGeoJsonConverter.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using GeoJSON.Net.Converters; + +namespace Squidex.Domain.Apps.Entities.Contents.Text +{ + public sealed class WriteonlyGeoJsonConverter : GeoJsonConverter + { + public override bool CanWrite => false; + } +} diff --git a/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/Queries/FilterVisitor.cs b/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/Queries/FilterVisitor.cs index 793f0fcf8..3bae8a4f4 100644 --- a/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/Queries/FilterVisitor.cs +++ b/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/Queries/FilterVisitor.cs @@ -14,7 +14,7 @@ using Squidex.Infrastructure.Queries; namespace Squidex.Infrastructure.MongoDb.Queries { - public sealed class FilterVisitor : FilterNodeVisitor, ClrValue> + public sealed class FilterVisitor : FilterNodeVisitor, ClrValue, None> { private static readonly FilterDefinitionBuilder Filter = Builders.Filter; private static readonly FilterVisitor Instance = new FilterVisitor(); @@ -25,27 +25,27 @@ namespace Squidex.Infrastructure.MongoDb.Queries public static FilterDefinition Visit(FilterNode node) { - return node.Accept(Instance); + return node.Accept(Instance, None.Value); } - public override FilterDefinition Visit(NegateFilter nodeIn) + public override FilterDefinition Visit(NegateFilter nodeIn, None args) { - return Filter.Not(nodeIn.Filter.Accept(this)); + return Filter.Not(nodeIn.Filter.Accept(this, args)); } - public override FilterDefinition Visit(LogicalFilter nodeIn) + public override FilterDefinition Visit(LogicalFilter nodeIn, None args) { if (nodeIn.Type == LogicalFilterType.And) { - return Filter.And(nodeIn.Filters.Select(x => x.Accept(this))); + return Filter.And(nodeIn.Filters.Select(x => x.Accept(this, args))); } else { - return Filter.Or(nodeIn.Filters.Select(x => x.Accept(this))); + return Filter.Or(nodeIn.Filters.Select(x => x.Accept(this, args))); } } - public override FilterDefinition Visit(CompareFilter nodeIn) + public override FilterDefinition Visit(CompareFilter nodeIn, None args) { var propertyName = nodeIn.Path.ToString(); diff --git a/backend/src/Squidex.Infrastructure/Json/GeoJson.cs b/backend/src/Squidex.Infrastructure/Json/GeoJson.cs new file mode 100644 index 000000000..d2f3bb225 --- /dev/null +++ b/backend/src/Squidex.Infrastructure/Json/GeoJson.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Infrastructure.Json +{ + public static class GeoJson + { + public const string Format = "geo-json"; + } +} diff --git a/backend/src/Squidex.Infrastructure/Json/IJsonSerializer.cs b/backend/src/Squidex.Infrastructure/Json/IJsonSerializer.cs index 15586f24a..ba831271d 100644 --- a/backend/src/Squidex.Infrastructure/Json/IJsonSerializer.cs +++ b/backend/src/Squidex.Infrastructure/Json/IJsonSerializer.cs @@ -18,6 +18,6 @@ namespace Squidex.Infrastructure.Json T Deserialize(string value, Type? actualType = null); - T Deserialize(Stream stream, Type? actualType = null); + T Deserialize(Stream stream, Type? actualType = null, bool leaveOpen = false); } } diff --git a/backend/src/Squidex.Infrastructure/Json/Newtonsoft/NewtonsoftJsonSerializer.cs b/backend/src/Squidex.Infrastructure/Json/Newtonsoft/NewtonsoftJsonSerializer.cs index 055dea798..aa34a585d 100644 --- a/backend/src/Squidex.Infrastructure/Json/Newtonsoft/NewtonsoftJsonSerializer.cs +++ b/backend/src/Squidex.Infrastructure/Json/Newtonsoft/NewtonsoftJsonSerializer.cs @@ -53,9 +53,9 @@ namespace Squidex.Infrastructure.Json.Newtonsoft } } - public T Deserialize(Stream stream, Type? actualType = null) + public T Deserialize(Stream stream, Type? actualType = null, bool leaveOpen = false) { - using (var textReader = new StreamReader(stream)) + using (var textReader = new StreamReader(stream, leaveOpen: leaveOpen)) { actualType ??= typeof(T); diff --git a/backend/src/Squidex.Infrastructure/Json/Newtonsoft/WriteonlyGeoJsonConverter.cs b/backend/src/Squidex.Infrastructure/Json/Newtonsoft/WriteonlyGeoJsonConverter.cs new file mode 100644 index 000000000..5a70e2f92 --- /dev/null +++ b/backend/src/Squidex.Infrastructure/Json/Newtonsoft/WriteonlyGeoJsonConverter.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using GeoJSON.Net.Converters; + +namespace Squidex.Infrastructure.Json.Newtonsoft +{ + public sealed class WriteonlyGeoJsonConverter : GeoJsonConverter + { + public override bool CanWrite => false; + } +} diff --git a/backend/src/Squidex.Infrastructure/Json/Objects/JsonObject.cs b/backend/src/Squidex.Infrastructure/Json/Objects/JsonObject.cs index 081f6d4c0..3dda169e5 100644 --- a/backend/src/Squidex.Infrastructure/Json/Objects/JsonObject.cs +++ b/backend/src/Squidex.Infrastructure/Json/Objects/JsonObject.cs @@ -48,7 +48,7 @@ namespace Squidex.Infrastructure.Json.Objects public JsonValueType Type { - get { return JsonValueType.Array; } + get { return JsonValueType.Object; } } public JsonObject() @@ -98,6 +98,20 @@ namespace Squidex.Infrastructure.Json.Objects return inner.TryGetValue(key, out value!); } + public bool TryGetValue(string key, [MaybeNullWhen(false)] out T value) where T : class, IJsonValue + { + if (inner.TryGetValue(key, out var temp) && temp is T typed) + { + value = typed; + return true; + } + else + { + value = null!; + return false; + } + } + public IEnumerator> GetEnumerator() { return inner.GetEnumerator(); diff --git a/backend/src/Squidex.Infrastructure/Queries/AsyncTransformVisitor.cs b/backend/src/Squidex.Infrastructure/Queries/AsyncTransformVisitor.cs index d635ba447..22d5fd540 100644 --- a/backend/src/Squidex.Infrastructure/Queries/AsyncTransformVisitor.cs +++ b/backend/src/Squidex.Infrastructure/Queries/AsyncTransformVisitor.cs @@ -10,20 +10,20 @@ using System.Threading.Tasks; namespace Squidex.Infrastructure.Queries { - public abstract class AsyncTransformVisitor : FilterNodeVisitor?>, TValue> + public abstract class AsyncTransformVisitor : FilterNodeVisitor?>, TValue, TArgs> { - public override ValueTask?> Visit(CompareFilter nodeIn) + public override ValueTask?> Visit(CompareFilter nodeIn, TArgs args) { return new ValueTask?>(nodeIn); } - public override async ValueTask?> Visit(LogicalFilter nodeIn) + public override async ValueTask?> Visit(LogicalFilter nodeIn, TArgs args) { var pruned = new List>(nodeIn.Filters.Count); foreach (var inner in nodeIn.Filters) { - var transformed = await inner.Accept(this); + var transformed = await inner.Accept(this, args); if (transformed != null) { @@ -34,9 +34,9 @@ namespace Squidex.Infrastructure.Queries return new LogicalFilter(nodeIn.Type, pruned); } - public override async ValueTask?> Visit(NegateFilter nodeIn) + public override async ValueTask?> Visit(NegateFilter nodeIn, TArgs args) { - var inner = await nodeIn.Filter.Accept(this); + var inner = await nodeIn.Filter.Accept(this, args); if (inner == null) { diff --git a/backend/src/Squidex.Infrastructure/Queries/ClrValue.cs b/backend/src/Squidex.Infrastructure/Queries/ClrValue.cs index 631792380..ca5aa8def 100644 --- a/backend/src/Squidex.Infrastructure/Queries/ClrValue.cs +++ b/backend/src/Squidex.Infrastructure/Queries/ClrValue.cs @@ -12,26 +12,19 @@ using System.Globalization; using System.Linq; using NodaTime; +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + namespace Squidex.Infrastructure.Queries { - public sealed class ClrValue + public sealed record ClrValue(object? Value, ClrValueType ValueType, bool IsList) { private static readonly Func ToStringDelegate = ToString; public static readonly ClrValue Null = new ClrValue(null, ClrValueType.Null, false); - public object? Value { get; } - - public ClrValueType ValueType { get; } - - public bool IsList { get; } - - private ClrValue(object? value, ClrValueType valueType, bool isList) + public static implicit operator ClrValue(FilterSphere value) { - Value = value; - ValueType = valueType; - - IsList = isList; + return new ClrValue(value, ClrValueType.Sphere, false); } public static implicit operator ClrValue(Instant value) diff --git a/backend/src/Squidex.Infrastructure/Queries/ClrValueType.cs b/backend/src/Squidex.Infrastructure/Queries/ClrValueType.cs index d205d5c1a..79dba9560 100644 --- a/backend/src/Squidex.Infrastructure/Queries/ClrValueType.cs +++ b/backend/src/Squidex.Infrastructure/Queries/ClrValueType.cs @@ -17,6 +17,7 @@ namespace Squidex.Infrastructure.Queries Int32, Int64, Single, + Sphere, String, Null } diff --git a/backend/src/Squidex.Infrastructure/Queries/CompareFilter.cs b/backend/src/Squidex.Infrastructure/Queries/CompareFilter.cs index c33d039f2..baf62edc2 100644 --- a/backend/src/Squidex.Infrastructure/Queries/CompareFilter.cs +++ b/backend/src/Squidex.Infrastructure/Queries/CompareFilter.cs @@ -7,37 +7,20 @@ using System.Collections.Generic; +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + namespace Squidex.Infrastructure.Queries { - public sealed class CompareFilter : FilterNode + public sealed record CompareFilter(PropertyPath Path, CompareOperator Operator, TValue Value) : FilterNode { - public PropertyPath Path { get; } - - public CompareOperator Operator { get; } - - public TValue Value { get; } - - public CompareFilter(PropertyPath path, CompareOperator @operator, TValue value) - { - Guard.NotNull(path, nameof(path)); - Guard.NotNull(value, nameof(value)); - Guard.Enum(@operator, nameof(@operator)); - - Path = path; - - Operator = @operator; - - Value = value; - } - public override void AddFields(HashSet fields) { fields.Add(Path.ToString()); } - public override T Accept(FilterNodeVisitor visitor) + public override T Accept(FilterNodeVisitor visitor, TArgs args) { - return visitor.Visit(this); + return visitor.Visit(this, args); } public override string ToString() diff --git a/backend/src/Squidex.Infrastructure/Queries/FilterNode.cs b/backend/src/Squidex.Infrastructure/Queries/FilterNode.cs index 0ca210b6e..ffb1db252 100644 --- a/backend/src/Squidex.Infrastructure/Queries/FilterNode.cs +++ b/backend/src/Squidex.Infrastructure/Queries/FilterNode.cs @@ -9,9 +9,9 @@ using System.Collections.Generic; namespace Squidex.Infrastructure.Queries { - public abstract class FilterNode + public abstract record FilterNode { - public abstract T Accept(FilterNodeVisitor visitor); + public abstract T Accept(FilterNodeVisitor visitor, TArgs args); public abstract void AddFields(HashSet fields); diff --git a/backend/src/Squidex.Infrastructure/Queries/FilterNodeVisitor.cs b/backend/src/Squidex.Infrastructure/Queries/FilterNodeVisitor.cs index a18f9ea62..0cf3d2b4d 100644 --- a/backend/src/Squidex.Infrastructure/Queries/FilterNodeVisitor.cs +++ b/backend/src/Squidex.Infrastructure/Queries/FilterNodeVisitor.cs @@ -11,19 +11,19 @@ using System; namespace Squidex.Infrastructure.Queries { - public abstract class FilterNodeVisitor + public abstract class FilterNodeVisitor { - public virtual T Visit(CompareFilter nodeIn) + public virtual T Visit(CompareFilter nodeIn, TArgs args) { throw new NotImplementedException(); } - public virtual T Visit(LogicalFilter nodeIn) + public virtual T Visit(LogicalFilter nodeIn, TArgs args) { throw new NotImplementedException(); } - public virtual T Visit(NegateFilter nodeIn) + public virtual T Visit(NegateFilter nodeIn, TArgs args) { throw new NotImplementedException(); } diff --git a/backend/src/Squidex.Infrastructure/Queries/FilterSphere.cs b/backend/src/Squidex.Infrastructure/Queries/FilterSphere.cs new file mode 100644 index 000000000..236ce2821 --- /dev/null +++ b/backend/src/Squidex.Infrastructure/Queries/FilterSphere.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + +namespace Squidex.Infrastructure.Queries +{ + public sealed record FilterSphere(double Longitude, double Latitude, double Radius) + { + public override string ToString() + { + return $"Radius({Longitude}, {Latitude}, {Radius})"; + } + } +} diff --git a/backend/src/Squidex.Infrastructure/Queries/Json/JsonFilterVisitor.cs b/backend/src/Squidex.Infrastructure/Queries/Json/JsonFilterVisitor.cs index 2e3192719..e985981c8 100644 --- a/backend/src/Squidex.Infrastructure/Queries/Json/JsonFilterVisitor.cs +++ b/backend/src/Squidex.Infrastructure/Queries/Json/JsonFilterVisitor.cs @@ -12,25 +12,35 @@ using Squidex.Infrastructure.Json.Objects; namespace Squidex.Infrastructure.Queries.Json { - public sealed class JsonFilterVisitor : FilterNodeVisitor, IJsonValue> + public sealed class JsonFilterVisitor : FilterNodeVisitor, IJsonValue, JsonFilterVisitor.Args> { - private readonly List errors; - private readonly JsonSchema schema; + private static readonly JsonFilterVisitor Instance = new JsonFilterVisitor(); - private JsonFilterVisitor(JsonSchema schema, List errors) + public struct Args { - this.schema = schema; + public readonly List Errors; - this.errors = errors; + public JsonSchema Schema; + + public Args(JsonSchema schema, List errors) + { + Schema = schema; + + Errors = errors; + } + } + + private JsonFilterVisitor() + { } public static FilterNode? Parse(FilterNode filter, JsonSchema schema, List errors) { - var visitor = new JsonFilterVisitor(schema, errors); + var args = new Args(schema, errors); - var parsed = filter.Accept(visitor); + var parsed = filter.Accept(Instance, args); - if (visitor.errors.Count > 0) + if (errors.Count > 0) { return null; } @@ -40,36 +50,43 @@ namespace Squidex.Infrastructure.Queries.Json } } - public override FilterNode Visit(NegateFilter nodeIn) + public override FilterNode Visit(NegateFilter nodeIn, Args args) { - return new NegateFilter(nodeIn.Accept(this)); + return new NegateFilter(nodeIn.Accept(this, args)); } - public override FilterNode Visit(LogicalFilter nodeIn) + public override FilterNode Visit(LogicalFilter nodeIn, Args args) { - return new LogicalFilter(nodeIn.Type, nodeIn.Filters.Select(x => x.Accept(this)).ToList()); + return new LogicalFilter(nodeIn.Type, nodeIn.Filters.Select(x => x.Accept(this, args)).ToList()); } - public override FilterNode Visit(CompareFilter nodeIn) + public override FilterNode Visit(CompareFilter nodeIn, Args args) { CompareFilter? result = null; - if (nodeIn.Path.TryGetProperty(schema, errors, out var property)) + if (nodeIn.Path.TryGetProperty(args.Schema, args.Errors, out var property)) { var isValidOperator = OperatorValidator.IsAllowedOperator(property, nodeIn.Operator); if (!isValidOperator) { - errors.Add($"{nodeIn.Operator} is not a valid operator for type {property.Type} at {nodeIn.Path}."); + var name = property.Type.ToString(); + + if (!string.IsNullOrWhiteSpace(property.Format)) + { + name = $"{name}({property.Format})"; + } + + args.Errors.Add($"'{nodeIn.Operator}' is not a valid operator for type {name} at '{nodeIn.Path}'."); } - var value = ValueConverter.Convert(property, nodeIn.Value, nodeIn.Path, errors); + var value = ValueConverter.Convert(property, nodeIn.Value, nodeIn.Path, args.Errors); if (value != null && isValidOperator) { if (value.IsList && nodeIn.Operator != CompareOperator.In) { - errors.Add($"Array value is not allowed for '{nodeIn.Operator}' operator and path '{nodeIn.Path}'."); + args.Errors.Add($"Array value is not allowed for '{nodeIn.Operator}' operator and path '{nodeIn.Path}'."); } result = new CompareFilter(nodeIn.Path, nodeIn.Operator, value); diff --git a/backend/src/Squidex.Infrastructure/Queries/Json/OperatorValidator.cs b/backend/src/Squidex.Infrastructure/Queries/Json/OperatorValidator.cs index 2e127c665..e99c64859 100644 --- a/backend/src/Squidex.Infrastructure/Queries/Json/OperatorValidator.cs +++ b/backend/src/Squidex.Infrastructure/Queries/Json/OperatorValidator.cs @@ -7,6 +7,7 @@ using System.Linq; using NJsonSchema; +using Squidex.Infrastructure.Json; namespace Squidex.Infrastructure.Queries.Json { @@ -49,6 +50,10 @@ namespace Squidex.Infrastructure.Queries.Json CompareOperator.In, CompareOperator.NotEquals }; + private static readonly CompareOperator[] GeoOperators = + { + CompareOperator.LessThan + }; public static bool IsAllowedOperator(JsonSchema schema, CompareOperator compareOperator) { @@ -59,12 +64,15 @@ namespace Squidex.Infrastructure.Queries.Json case JsonObjectType.Boolean: return BooleanOperators.Contains(compareOperator); case JsonObjectType.Integer: + return NumberOperators.Contains(compareOperator); case JsonObjectType.Number: return NumberOperators.Contains(compareOperator); case JsonObjectType.String: return StringOperators.Contains(compareOperator); case JsonObjectType.Array: return ArrayOperators.Contains(compareOperator); + case JsonObjectType.Object when schema.Format == GeoJson.Format: + return GeoOperators.Contains(compareOperator); } return false; diff --git a/backend/src/Squidex.Infrastructure/Queries/Json/ValueConverter.cs b/backend/src/Squidex.Infrastructure/Queries/Json/ValueConverter.cs index 5c60da153..aa1245bf1 100644 --- a/backend/src/Squidex.Infrastructure/Queries/Json/ValueConverter.cs +++ b/backend/src/Squidex.Infrastructure/Queries/Json/ValueConverter.cs @@ -11,6 +11,7 @@ using System.Linq; using NJsonSchema; using NodaTime; using NodaTime.Text; +using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Json.Objects; namespace Squidex.Infrastructure.Queries.Json @@ -32,6 +33,16 @@ namespace Squidex.Infrastructure.Queries.Json switch (GetType(schema)) { + case JsonObjectType.None when schema.Reference?.Format == GeoJson.Format: + { + if (TryParseGeoJson(errors, path, value, out var temp)) + { + result = temp; + } + + break; + } + case JsonObjectType.None: { if (value is JsonArray jsonArray) @@ -116,6 +127,16 @@ namespace Squidex.Infrastructure.Queries.Json break; } + case JsonObjectType.Object when schema.Format == GeoJson.Format || schema.Reference?.Format == GeoJson.Format: + { + if (TryParseGeoJson(errors, path, value, out var temp)) + { + result = temp; + } + + break; + } + default: { errors.Add($"Unsupported type {schema.Type} for {path}."); @@ -141,6 +162,25 @@ namespace Squidex.Infrastructure.Queries.Json return items; } + private static bool TryParseGeoJson(List errors, PropertyPath path, IJsonValue value, out FilterSphere result) + { + result = default!; + + if (value is JsonObject geoObject && + geoObject.TryGetValue("latitude", out var lat) && + geoObject.TryGetValue("longitude", out var lon) && + geoObject.TryGetValue("distance", out var distance)) + { + result = new FilterSphere(lon.Value, lat.Value, distance.Value); + + return true; + } + + errors.Add($"Expected Object(geo-json) for path '{path}', but got {value.Type}."); + + return false; + } + private static bool TryParseBoolean(List errors, PropertyPath path, IJsonValue value, out bool result) { result = default; diff --git a/backend/src/Squidex.Infrastructure/Queries/LogicalFilter.cs b/backend/src/Squidex.Infrastructure/Queries/LogicalFilter.cs index f2a49a75e..314b3206c 100644 --- a/backend/src/Squidex.Infrastructure/Queries/LogicalFilter.cs +++ b/backend/src/Squidex.Infrastructure/Queries/LogicalFilter.cs @@ -7,24 +7,12 @@ using System.Collections.Generic; +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + namespace Squidex.Infrastructure.Queries { - public sealed class LogicalFilter : FilterNode + public sealed record LogicalFilter(LogicalFilterType Type, IReadOnlyList> Filters) : FilterNode { - public IReadOnlyList> Filters { get; } - - public LogicalFilterType Type { get; } - - public LogicalFilter(LogicalFilterType type, IReadOnlyList> filters) - { - Guard.NotNull(filters, nameof(filters)); - Guard.Enum(type, nameof(type)); - - Filters = filters; - - Type = type; - } - public override void AddFields(HashSet fields) { foreach (var filter in Filters) @@ -33,9 +21,9 @@ namespace Squidex.Infrastructure.Queries } } - public override T Accept(FilterNodeVisitor visitor) + public override T Accept(FilterNodeVisitor visitor, TArgs args) { - return visitor.Visit(this); + return visitor.Visit(this, args); } public override string ToString() diff --git a/backend/src/Squidex.Infrastructure/Queries/NegateFilter.cs b/backend/src/Squidex.Infrastructure/Queries/NegateFilter.cs index 83f04331a..69f969e6b 100644 --- a/backend/src/Squidex.Infrastructure/Queries/NegateFilter.cs +++ b/backend/src/Squidex.Infrastructure/Queries/NegateFilter.cs @@ -7,27 +7,20 @@ using System.Collections.Generic; +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + namespace Squidex.Infrastructure.Queries { - public sealed class NegateFilter : FilterNode + public sealed record NegateFilter(FilterNode Filter) : FilterNode { - public FilterNode Filter { get; } - - public NegateFilter(FilterNode filter) - { - Guard.NotNull(filter, nameof(filter)); - - Filter = filter; - } - public override void AddFields(HashSet fields) { Filter.AddFields(fields); } - public override T Accept(FilterNodeVisitor visitor) + public override T Accept(FilterNodeVisitor visitor, TArgs args) { - return visitor.Visit(this); + return visitor.Visit(this, args); } public override string ToString() diff --git a/backend/src/Squidex.Infrastructure/Queries/OData/EdmModelExtensions.cs b/backend/src/Squidex.Infrastructure/Queries/OData/EdmModelExtensions.cs index f360c996e..78c7b7579 100644 --- a/backend/src/Squidex.Infrastructure/Queries/OData/EdmModelExtensions.cs +++ b/backend/src/Squidex.Infrastructure/Queries/OData/EdmModelExtensions.cs @@ -20,6 +20,13 @@ namespace Squidex.Infrastructure.Queries.OData new FunctionSignatureWithReturnType( EdmCoreModel.Instance.GetBoolean(false), EdmCoreModel.Instance.GetString(true))); + + CustomUriFunctions.AddCustomUriFunction("distanceto", + new FunctionSignatureWithReturnType( + EdmCoreModel.Instance.GetDouble(false), + EdmCoreModel.Instance.GetString(true), + EdmCoreModel.Instance.GetInt32(true), + EdmCoreModel.Instance.GetInt32(true))); } public static ODataUriParser? ParseQuery(this IEdmModel model, string query) diff --git a/backend/src/Squidex.Infrastructure/Queries/OData/FilterVisitor.cs b/backend/src/Squidex.Infrastructure/Queries/OData/FilterVisitor.cs index 9d366ce54..7aa1cfa0a 100644 --- a/backend/src/Squidex.Infrastructure/Queries/OData/FilterVisitor.cs +++ b/backend/src/Squidex.Infrastructure/Queries/OData/FilterVisitor.cs @@ -8,6 +8,7 @@ using System; using System.Linq; using Microsoft.OData.UriParser; +using Microsoft.Spatial; namespace Squidex.Infrastructure.Queries.OData { @@ -95,19 +96,40 @@ namespace Squidex.Infrastructure.Queries.OData if (nodeIn.Left is SingleValueFunctionCallNode functionNode) { - var regexFilter = Visit(functionNode); + if (string.Equals(functionNode.Name, "geo.distance", StringComparison.OrdinalIgnoreCase) && nodeIn.OperatorKind == BinaryOperatorKind.LessThan) + { + var valueDistance = (double)ConstantWithTypeVisitor.Visit(nodeIn.Right).Value!; - var value = ConstantWithTypeVisitor.Visit(nodeIn.Right); + if (functionNode.Parameters.ElementAt(1) is not ConstantNode constantNode) + { + throw new NotSupportedException(); + } - if (value.ValueType == ClrValueType.Boolean && value.Value is bool booleanRight) - { - if ((nodeIn.OperatorKind == BinaryOperatorKind.Equal && !booleanRight) || - (nodeIn.OperatorKind == BinaryOperatorKind.NotEqual && booleanRight)) + if (constantNode.Value is not GeographyPoint geographyPoint) { - regexFilter = ClrFilter.Not(regexFilter); + throw new NotSupportedException(); } - return regexFilter; + var property = PropertyPathVisitor.Visit(functionNode.Parameters.ElementAt(0)); + + return ClrFilter.Lt(property, new FilterSphere(geographyPoint.Longitude, geographyPoint.Latitude, valueDistance)); + } + else + { + var regexFilter = Visit(functionNode); + + var value = ConstantWithTypeVisitor.Visit(nodeIn.Right); + + if (value.ValueType == ClrValueType.Boolean && value.Value is bool booleanRight) + { + if ((nodeIn.OperatorKind == BinaryOperatorKind.Equal && !booleanRight) || + (nodeIn.OperatorKind == BinaryOperatorKind.NotEqual && booleanRight)) + { + regexFilter = ClrFilter.Not(regexFilter); + } + + return regexFilter; + } } } else diff --git a/backend/src/Squidex.Infrastructure/Queries/Optimizer.cs b/backend/src/Squidex.Infrastructure/Queries/Optimizer.cs index 396857d11..4d3024082 100644 --- a/backend/src/Squidex.Infrastructure/Queries/Optimizer.cs +++ b/backend/src/Squidex.Infrastructure/Queries/Optimizer.cs @@ -9,7 +9,7 @@ using System.Collections.Generic; namespace Squidex.Infrastructure.Queries { - public sealed class Optimizer : TransformVisitor + public sealed class Optimizer : TransformVisitor { private static readonly Optimizer Instance = new Optimizer(); @@ -19,16 +19,16 @@ namespace Squidex.Infrastructure.Queries public static FilterNode? Optimize(FilterNode source) { - return source?.Accept(Instance); + return source?.Accept(Instance, None.Value); } - public override FilterNode? Visit(LogicalFilter nodeIn) + public override FilterNode? Visit(LogicalFilter nodeIn, None args) { var pruned = new List>(nodeIn.Filters.Count); foreach (var filter in nodeIn.Filters) { - var transformed = filter.Accept(this); + var transformed = filter.Accept(this, None.Value); if (transformed != null) { @@ -46,12 +46,12 @@ namespace Squidex.Infrastructure.Queries return null; } - return new LogicalFilter(nodeIn.Type, pruned); + return nodeIn with { Filters = pruned }; } - public override FilterNode? Visit(NegateFilter nodeIn) + public override FilterNode? Visit(NegateFilter nodeIn, None args) { - var pruned = nodeIn.Filter.Accept(this); + var pruned = nodeIn.Filter.Accept(this, None.Value); if (pruned == null) { @@ -62,15 +62,20 @@ namespace Squidex.Infrastructure.Queries { if (comparison.Operator == CompareOperator.Equals) { - return new CompareFilter(comparison.Path, CompareOperator.NotEquals, comparison.Value); + return comparison with { Operator = CompareOperator.NotEquals }; } if (comparison.Operator == CompareOperator.NotEquals) { - return new CompareFilter(comparison.Path, CompareOperator.Equals, comparison.Value); + return comparison with { Operator = CompareOperator.Equals }; } } + if (ReferenceEquals(pruned, nodeIn.Filter)) + { + return nodeIn; + } + return new NegateFilter(pruned); } } diff --git a/backend/src/Squidex.Infrastructure/Queries/PascalCasePathConverter.cs b/backend/src/Squidex.Infrastructure/Queries/PascalCasePathConverter.cs index 4fae9488b..8fca25cc7 100644 --- a/backend/src/Squidex.Infrastructure/Queries/PascalCasePathConverter.cs +++ b/backend/src/Squidex.Infrastructure/Queries/PascalCasePathConverter.cs @@ -10,7 +10,7 @@ using Squidex.Text; namespace Squidex.Infrastructure.Queries { - public sealed class PascalCasePathConverter : TransformVisitor + public sealed class PascalCasePathConverter : TransformVisitor { private static readonly PascalCasePathConverter Instance = new PascalCasePathConverter(); @@ -20,10 +20,10 @@ namespace Squidex.Infrastructure.Queries public static FilterNode? Transform(FilterNode node) { - return node.Accept(Instance); + return node.Accept(Instance, None.Value); } - public override FilterNode? Visit(CompareFilter nodeIn) + public override FilterNode? Visit(CompareFilter nodeIn, None args) { return new CompareFilter(nodeIn.Path.Select(x => x.ToPascalCase()).ToList(), nodeIn.Operator, nodeIn.Value); } diff --git a/backend/src/Squidex.Infrastructure/Queries/TransformVisitor.cs b/backend/src/Squidex.Infrastructure/Queries/TransformVisitor.cs index c215374f1..5f72ccf27 100644 --- a/backend/src/Squidex.Infrastructure/Queries/TransformVisitor.cs +++ b/backend/src/Squidex.Infrastructure/Queries/TransformVisitor.cs @@ -9,20 +9,20 @@ using System.Collections.Generic; namespace Squidex.Infrastructure.Queries { - public abstract class TransformVisitor : FilterNodeVisitor?, TValue> + public abstract class TransformVisitor : FilterNodeVisitor?, TValue, TArgs> { - public override FilterNode? Visit(CompareFilter nodeIn) + public override FilterNode? Visit(CompareFilter nodeIn, TArgs args) { return nodeIn; } - public override FilterNode? Visit(LogicalFilter nodeIn) + public override FilterNode? Visit(LogicalFilter nodeIn, TArgs args) { var pruned = new List>(nodeIn.Filters.Count); foreach (var inner in nodeIn.Filters) { - var transformed = inner.Accept(this); + var transformed = inner.Accept(this, args); if (transformed != null) { @@ -33,9 +33,9 @@ namespace Squidex.Infrastructure.Queries return new LogicalFilter(nodeIn.Type, pruned); } - public override FilterNode? Visit(NegateFilter nodeIn) + public override FilterNode? Visit(NegateFilter nodeIn, TArgs args) { - var inner = nodeIn.Filter.Accept(this); + var inner = nodeIn.Filter.Accept(this, args); if (inner == null) { diff --git a/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj b/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj index 0fa8191c4..62e9af3aa 100644 --- a/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj +++ b/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj @@ -14,6 +14,7 @@ + diff --git a/backend/src/Squidex/Areas/IdentityServer/Controllers/Extensions.cs b/backend/src/Squidex/Areas/IdentityServer/Controllers/Extensions.cs index 2df67a991..b0bc1b89e 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Controllers/Extensions.cs +++ b/backend/src/Squidex/Areas/IdentityServer/Controllers/Extensions.cs @@ -8,7 +8,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Identity; diff --git a/backend/src/Squidex/Config/Domain/SerializationServices.cs b/backend/src/Squidex/Config/Domain/SerializationServices.cs index 1e907087c..3f44c2199 100644 --- a/backend/src/Squidex/Config/Domain/SerializationServices.cs +++ b/backend/src/Squidex/Config/Domain/SerializationServices.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using GeoJSON.Net.Converters; using Microsoft.Extensions.DependencyInjection; using Migrations; using Newtonsoft.Json; @@ -41,7 +40,6 @@ namespace Squidex.Config.Domain new EnvelopeHeadersConverter(), new FilterConverter(), new InstantConverter(), - new GeoJsonConverter(), new JsonValueConverter(), new LanguageConverter(), new LanguagesConfigConverter(), @@ -58,7 +56,8 @@ namespace Squidex.Config.Domain new StatusConverter(), new StringEnumConverter(), new WorkflowsConverter(), - new WorkflowStepConverter()); + new WorkflowStepConverter(), + new WriteonlyGeoJsonConverter()); settings.NullValueHandling = NullValueHandling.Ignore; diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/TestHelpers/TestUtils.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/TestHelpers/TestUtils.cs index 309818efc..7c69d94da 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/TestHelpers/TestUtils.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/TestHelpers/TestUtils.cs @@ -8,7 +8,6 @@ using System; using System.Linq; using System.Reflection; -using GeoJSON.Net.Converters; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Squidex.Domain.Apps.Core.Apps.Json; @@ -54,7 +53,6 @@ namespace Squidex.Domain.Apps.Core.TestHelpers new DomainIdConverter(), new EnvelopeHeadersConverter(), new FilterConverter(), - new GeoJsonConverter(), new InstantConverter(), new JsonValueConverter(), new LanguageConverter(), @@ -72,7 +70,8 @@ namespace Squidex.Domain.Apps.Core.TestHelpers new StatusConverter(), new StringEnumConverter(), new WorkflowsConverter(), - new WorkflowStepConverter()), + new WorkflowStepConverter(), + new WriteonlyGeoJsonConverter()), TypeNameHandling = typeNameHandling }; diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryFixture.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryFixture.cs index 8605ec960..57d09ce34 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryFixture.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryFixture.cs @@ -15,7 +15,6 @@ using Squidex.Domain.Apps.Core.Assets; using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Domain.Apps.Entities.MongoDb.Assets; -using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure; using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.MongoDb; diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryParserTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryParserTests.cs index b7b92e172..a8c2999bb 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryParserTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryParserTests.cs @@ -39,7 +39,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries { var query = Q.Empty.WithODataQuery("$filter=invalid"); - await Assert.ThrowsAsync(() => sut.ParseQueryAsync(requestContext, query).AsTask()); + await Assert.ThrowsAsync(() => sut.ParseQueryAsync(requestContext, query)); } [Fact] @@ -47,7 +47,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries { var query = Q.Empty.WithJsonQuery("invalid"); - await Assert.ThrowsAsync(() => sut.ParseQueryAsync(requestContext, query).AsTask()); + await Assert.ThrowsAsync(() => sut.ParseQueryAsync(requestContext, query)); } [Fact] @@ -73,7 +73,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries [Fact] public async Task Should_parse_json_query_and_enrich_with_defaults() { - var query = Q.Empty.WithJsonQuery(Json("{ 'filter': { 'path': 'fileName', 'op': 'eq', 'value': 'ABC' } }")); + var query = Q.Empty.WithJsonQuery("{ \"filter\": { \"path\": \"fileName\", \"op\": \"eq\", \"value\": \"ABC\" } }"); var q = await sut.ParseQueryAsync(requestContext, query); @@ -83,7 +83,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries [Fact] public async Task Should_parse_json_full_text_query_and_enrich_with_defaults() { - var query = Q.Empty.WithJsonQuery(Json("{ 'fullText': 'Hello' }")); + var query = Q.Empty.WithJsonQuery("{ \"fullText\": \"Hello\" }"); var q = await sut.ParseQueryAsync(requestContext, query); @@ -101,7 +101,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries } [Fact] - public async Task Should_limit_number_of_assets() + public async Task Should_apply_default_limit() { var query = Q.Empty.WithODataQuery("$top=300&$skip=20"); @@ -133,9 +133,30 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries Assert.Equal("Filter: tags == 'id1'; Take: 30; Sort: lastModified Descending, id Ascending", q.Query.ToString()); } - private static string Json(string text) + [Fact] + public async Task Should_not_fail_when_tags_not_found() { - return text.Replace('\'', '"'); + A.CallTo(() => tagService.GetTagIdsAsync(appId.Id, TagGroups.Assets, A>.That.Contains("name1"))) + .Returns(new Dictionary()); + + var query = Q.Empty.WithODataQuery("$filter=tags eq 'name1'"); + + var q = await sut.ParseQueryAsync(requestContext, query); + + Assert.Equal("Filter: tags == 'name1'; Take: 30; Sort: lastModified Descending, id Ascending", q.Query.ToString()); + } + + [Fact] + public async Task Should_not_normalize_other_field() + { + var query = Q.Empty.WithODataQuery("$filter=fileSize eq 123"); + + var q = await sut.ParseQueryAsync(requestContext, query); + + Assert.Equal("Filter: fileSize == 123; Take: 30; Sort: lastModified Descending, id Ascending", q.Query.ToString()); + + A.CallTo(() => tagService.GetTagIdsAsync(appId.Id, A._, A>._)) + .MustNotHaveHappened(); } } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs index 6fc0bd182..ec32c9dc2 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs @@ -31,7 +31,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries requestContext = new Context(Mocks.FrontendUser(), Mocks.App(appId)); A.CallTo(() => queryParser.ParseQueryAsync(requestContext, A._)) - .ReturnsLazily(c => new ValueTask(c.GetArgument(1)!)); + .ReturnsLazily(c => Task.FromResult(c.GetArgument(1)!)); sut = new AssetQueryService(assetEnricher, assetRepository, assetFolderRepository, queryParser); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/FilterTagTransformerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/FilterTagTransformerTests.cs deleted file mode 100644 index 386b34ce3..000000000 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/FilterTagTransformerTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; -using System.Threading.Tasks; -using FakeItEasy; -using Squidex.Domain.Apps.Core.Tags; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Queries; -using Xunit; - -namespace Squidex.Domain.Apps.Entities.Assets.Queries -{ - public class FilterTagTransformerTests - { - private readonly ITagService tagService = A.Fake(); - private readonly DomainId appId = DomainId.NewGuid(); - - [Fact] - public async Task Should_normalize_tags() - { - A.CallTo(() => tagService.GetTagIdsAsync(appId, TagGroups.Assets, A>.That.Contains("name1"))) - .Returns(new Dictionary { ["name1"] = "id1" }); - - var source = ClrFilter.Eq("tags", "name1"); - - var result = await FilterTagTransformer.TransformAsync(source, appId, tagService); - - Assert.Equal("tags == 'id1'", result!.ToString()); - } - - [Fact] - public async Task Should_not_fail_when_tags_not_found() - { - A.CallTo(() => tagService.GetTagIdsAsync(appId, TagGroups.Assets, A>.That.Contains("name1"))) - .Returns(new Dictionary()); - - var source = ClrFilter.Eq("tags", "name1"); - - var result = await FilterTagTransformer.TransformAsync(source, appId, tagService); - - Assert.Equal("tags == 'name1'", result!.ToString()); - } - - [Fact] - public async Task Should_not_normalize_other_field() - { - var source = ClrFilter.Eq("other", "value"); - - var result = await FilterTagTransformer.TransformAsync(source, appId, tagService); - - Assert.Equal("other == 'value'", result!.ToString()); - - A.CallTo(() => tagService.GetTagIdsAsync(appId, A._, A>._)) - .MustNotHaveHappened(); - } - } -} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentsSearchSourceTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentsSearchSourceTests.cs index 74f5a8dce..6bc022208 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentsSearchSourceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentsSearchSourceTests.cs @@ -160,7 +160,7 @@ namespace Squidex.Domain.Apps.Entities.Contents Assert.Empty(result); - A.CallTo(() => contentIndex.SearchAsync(A._, ctx.App, A._, A._)) + A.CallTo(() => contentIndex.SearchAsync(ctx.App, A._, A._)) .MustNotHaveHappened(); } @@ -169,7 +169,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { var ctx = ContextWithPermissions(schemaId1, schemaId2); - A.CallTo(() => contentIndex.SearchAsync("query~", ctx.App, A._, ctx.Scope())) + A.CallTo(() => contentIndex.SearchAsync(ctx.App, A.That.Matches(x => x.Text == "query~"), ctx.Scope())) .Returns(new List()); var result = await sut.SearchAsync("query", ctx); @@ -186,11 +186,9 @@ namespace Squidex.Domain.Apps.Entities.Contents var ctx = ContextWithPermissions(schemaId1, schemaId2); - var searchFilter = SearchFilter.MustHaveSchemas(schemaId1.Id, schemaId2.Id); - var ids = new List { content.Id }; - A.CallTo(() => contentIndex.SearchAsync("query~", ctx.App, A.That.IsEqualTo(searchFilter), ctx.Scope())) + A.CallTo(() => contentIndex.SearchAsync(ctx.App, A.That.Matches(x => x.Text == "query~" && x.Filter != null), ctx.Scope())) .Returns(ids); A.CallTo(() => contentQuery.QueryAsync(ctx, A.That.HasIds(ids))) diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs index 31d321f37..72b75d71b 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs @@ -19,7 +19,6 @@ using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Contents.Repositories; -using Squidex.Domain.Apps.Entities.Contents.Text; using Squidex.Domain.Apps.Entities.MongoDb.Contents; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.TestHelpers; @@ -63,7 +62,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb new MongoContentRepository( mongoDatabase, CreateAppProvider(), - CreateTextIndexer(), TestUtils.DefaultSerializer); Task.Run(async () => @@ -150,16 +148,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb return appProvider; } - private static ITextIndex CreateTextIndexer() - { - var textIndexer = A.Fake(); - - A.CallTo(() => textIndexer.SearchAsync(A._, A._, A._, A._)) - .Returns(new List { DomainId.NewGuid() }); - - return textIndexer; - } - private static void SetupJson() { var jsonSerializer = JsonSerializer.Create(TestUtils.DefaultSerializerSettings); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/MongoDbQueryTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/MongoDbQueryTests.cs index a2bcdb457..365162fc6 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/MongoDbQueryTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/MongoDbQueryTests.cs @@ -36,6 +36,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb { private static readonly IBsonSerializerRegistry Registry = BsonSerializer.SerializerRegistry; private static readonly IBsonSerializer Serializer = BsonSerializer.SerializerRegistry.GetSerializer(); + private readonly DomainId appId = DomainId.NewGuid(); private readonly Schema schemaDef; private readonly LanguagesConfig languagesConfig = LanguagesConfig.English.Set(Language.DE); @@ -91,7 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb var id = Guid.NewGuid(); var i = _F(ClrFilter.Eq("id", id)); - var o = _C($"{{ 'id' : '{id}' }}"); + var o = _C($"{{ '_id' : '{appId}--{id}' }}"); Assert.Equal(o, i); } @@ -102,7 +103,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb var id = DomainId.NewGuid().ToString(); var i = _F(ClrFilter.Eq("id", id)); - var o = _C($"{{ 'id' : '{id}' }}"); + var o = _C($"{{ '_id' : '{appId}--{id}' }}"); Assert.Equal(o, i); } @@ -113,7 +114,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb var id = Guid.NewGuid(); var i = _F(ClrFilter.In("id", new List { id })); - var o = _C($"{{ 'id' : {{ '$in' : ['{id}'] }} }}"); + var o = _C($"{{ '_id' : {{ '$in' : ['{appId}--{id}'] }} }}"); Assert.Equal(o, i); } @@ -124,7 +125,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb var id = DomainId.NewGuid().ToString(); var i = _F(ClrFilter.In("id", new List { id })); - var o = _C($"{{ 'id' : {{ '$in' : ['{id}'] }} }}"); + var o = _C($"{{ '_id' : {{ '$in' : ['{appId}--{id}'] }} }}"); Assert.Equal(o, i); } @@ -319,7 +320,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb i = sortDefinition.Render(Serializer, Registry).ToString(); }); - cursor.QuerySort(new ClrQuery { Sort = sorts.ToList() }.AdjustToModel(schemaDef)); + cursor.QuerySort(new ClrQuery { Sort = sorts.ToList() }.AdjustToModel(appId, schemaDef)); return i; } @@ -327,7 +328,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb private string _Q(ClrQuery query) { var rendered = - query.AdjustToModel(schemaDef).BuildFilter().Filter! + query.AdjustToModel(appId, schemaDef).BuildFilter().Filter! .Render(Serializer, Registry).ToString(); return rendered; diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryParserTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryParserTests.cs index be1ec0690..dacad43dd 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryParserTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryParserTests.cs @@ -5,12 +5,15 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using System.Threading.Tasks; +using FakeItEasy; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.TestHelpers; +using Squidex.Domain.Apps.Entities.Contents.Text; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure; @@ -23,6 +26,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { public class ContentQueryParserTests { + private readonly ITextIndex textIndex = A.Fake(); private readonly ISchemaEntity schema; private readonly NamedId appId = NamedId.Of(DomainId.NewGuid(), "my-app"); private readonly NamedId schemaId = NamedId.Of(DomainId.NewGuid(), "my-app"); @@ -37,13 +41,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries var schemaDef = new Schema(schemaId.Name) - .AddString(1, "firstName", Partitioning.Invariant); + .AddString(1, "firstName", Partitioning.Invariant) + .AddGeolocation(2, "geo", Partitioning.Invariant); schema = Mocks.Schema(appId, schemaId, schemaDef); var cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); - sut = new ContentQueryParser(cache, TestUtils.DefaultSerializer, options); + sut = new ContentQueryParser(cache, TestUtils.DefaultSerializer, textIndex, options); } [Fact] @@ -51,7 +56,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { var query = Q.Empty.WithODataQuery("$filter=invalid"); - await Assert.ThrowsAsync(() => sut.ParseAsync(requestContext, query, schema).AsTask()); + await Assert.ThrowsAsync(() => sut.ParseAsync(requestContext, query, schema)); } [Fact] @@ -59,7 +64,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { var query = Q.Empty.WithJsonQuery("invalid"); - await Assert.ThrowsAsync(() => sut.ParseAsync(requestContext, query, schema).AsTask()); + await Assert.ThrowsAsync(() => sut.ParseAsync(requestContext, query, schema)); } [Fact] @@ -85,11 +90,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries [Fact] public async Task Should_parse_odata_query() { - var query = Q.Empty.WithODataQuery("$top=100&$orderby=data/firstName/iv asc&$search=Hello World"); + var query = Q.Empty.WithODataQuery("$top=100&$orderby=data/firstName/iv asc&$filter=status eq 'Draft'"); var q = await sut.ParseAsync(requestContext, query, schema); - Assert.Equal("FullText: 'Hello World'; Take: 100; Sort: data.firstName.iv Ascending, id Ascending", q.Query.ToString()); + Assert.Equal("Filter: status == 'Draft'; Take: 100; Sort: data.firstName.iv Ascending, id Ascending", q.Query.ToString()); } [Fact] @@ -105,7 +110,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries [Fact] public async Task Should_parse_json_query_and_enrich_with_defaults() { - var query = Q.Empty.WithJsonQuery(Json("{ 'filter': { 'path': 'data.firstName.iv', 'op': 'eq', 'value': 'ABC' } }")); + var query = Q.Empty.WithJsonQuery("{ \"filter\": { \"path\": \"data.firstName.iv\", \"op\": \"eq\", \"value\": \"ABC\" } }"); var q = await sut.ParseAsync(requestContext, query, schema); @@ -113,41 +118,120 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries } [Fact] - public async Task Should_convert_json_query_and_enrich_with_defaults() + public async Task Should_convert_full_text_query_to_filter_with_other_filter() { - var query = Q.Empty.WithJsonQuery( - new Query - { - Filter = new CompareFilter("data.firstName.iv", CompareOperator.Equals, JsonValue.Create("ABC")) - }); + A.CallTo(() => textIndex.SearchAsync(requestContext.App, A.That.Matches(x => x.Text == "Hello"), requestContext.Scope())) + .Returns(new List { DomainId.Create("1"), DomainId.Create("2") }); + + var query = Q.Empty.WithODataQuery("$search=Hello&$filter=data/firstName/iv eq 'ABC'"); var q = await sut.ParseAsync(requestContext, query, schema); - Assert.Equal("Filter: data.firstName.iv == 'ABC'; Take: 30; Sort: lastModified Descending, id Ascending", q.Query.ToString()); + Assert.Equal("Filter: (data.firstName.iv == 'ABC' && id in ['1', '2']); Take: 30; Sort: lastModified Descending, id Ascending", q.Query.ToString()); } [Fact] - public async Task Should_parse_json_full_text_query_and_enrich_with_defaults() + public async Task Should_convert_full_text_query_to_filter() { - var query = Q.Empty.WithJsonQuery(Json("{ 'fullText': 'Hello' }")); + A.CallTo(() => textIndex.SearchAsync(requestContext.App, A.That.Matches(x => x.Text == "Hello"), requestContext.Scope())) + .Returns(new List { DomainId.Create("1"), DomainId.Create("2") }); + + var query = Q.Empty.WithODataQuery("$search=Hello"); var q = await sut.ParseAsync(requestContext, query, schema); - Assert.Equal("FullText: 'Hello'; Take: 30; Sort: lastModified Descending, id Ascending", q.Query.ToString()); + Assert.Equal("Filter: id in ['1', '2']; Take: 30; Sort: lastModified Descending, id Ascending", q.Query.ToString()); } [Fact] - public async Task Should_convert_json_full_text_query_and_enrich_with_defaults() + public async Task Should_convert_full_text_query_to_filter_when_single_id_found() { - var query = Q.Empty.WithJsonQuery( - new Query - { - FullText = "Hello" - }); + A.CallTo(() => textIndex.SearchAsync(requestContext.App, A.That.Matches(x => x.Text == "Hello"), requestContext.Scope())) + .Returns(new List { DomainId.Create("1") }); + + var query = Q.Empty.WithODataQuery("$search=Hello"); var q = await sut.ParseAsync(requestContext, query, schema); - Assert.Equal("FullText: 'Hello'; Take: 30; Sort: lastModified Descending, id Ascending", q.Query.ToString()); + Assert.Equal("Filter: id in ['1']; Take: 30; Sort: lastModified Descending, id Ascending", q.Query.ToString()); + } + + [Fact] + public async Task Should_convert_full_text_query_to_filter_when_index_returns_null() + { + A.CallTo(() => textIndex.SearchAsync(requestContext.App, A.That.Matches(x => x.Text == "Hello"), requestContext.Scope())) + .Returns(Task.FromResult?>(null)); + + var query = Q.Empty.WithODataQuery("$search=Hello"); + + var q = await sut.ParseAsync(requestContext, query, schema); + + Assert.Equal("Filter: id == '__notfound__'; Take: 30; Sort: lastModified Descending, id Ascending", q.Query.ToString()); + } + + [Fact] + public async Task Should_convert_full_text_query_to_filter_when_index_returns_empty() + { + A.CallTo(() => textIndex.SearchAsync(requestContext.App, A.That.Matches(x => x.Text == "Hello"), requestContext.Scope())) + .Returns(new List()); + + var query = Q.Empty.WithODataQuery("$search=Hello"); + + var q = await sut.ParseAsync(requestContext, query, schema); + + Assert.Equal("Filter: id == '__notfound__'; Take: 30; Sort: lastModified Descending, id Ascending", q.Query.ToString()); + } + + [Fact] + public async Task Should_convert_geo_query_to_filter() + { + A.CallTo(() => textIndex.SearchAsync(requestContext.App, new GeoQuery(schemaId.Id, "geo.iv", 10, 20, 30), requestContext.Scope())) + .Returns(new List { DomainId.Create("1"), DomainId.Create("2") }); + + var query = Q.Empty.WithODataQuery("$filter=geo.distance(data/geo/iv, geography'POINT(20 10)') lt 30.0"); + + var q = await sut.ParseAsync(requestContext, query, schema); + + Assert.Equal("Filter: id in ['1', '2']; Take: 30; Sort: lastModified Descending, id Ascending", q.Query.ToString()); + } + + [Fact] + public async Task Should_convert_geo_query_to_filter_when_single_id_found() + { + A.CallTo(() => textIndex.SearchAsync(requestContext.App, new GeoQuery(schemaId.Id, "geo.iv", 10, 20, 30), requestContext.Scope())) + .Returns(new List { DomainId.Create("1") }); + + var query = Q.Empty.WithODataQuery("$filter=geo.distance(data/geo/iv, geography'POINT(20 10)') lt 30.0"); + + var q = await sut.ParseAsync(requestContext, query, schema); + + Assert.Equal("Filter: id in ['1']; Take: 30; Sort: lastModified Descending, id Ascending", q.Query.ToString()); + } + + [Fact] + public async Task Should_convert_geo_query_to_filter_when_index_returns_null() + { + A.CallTo(() => textIndex.SearchAsync(requestContext.App, new GeoQuery(schemaId.Id, "geo.iv", 10, 20, 30), requestContext.Scope())) + .Returns(Task.FromResult?>(null)); + + var query = Q.Empty.WithODataQuery("$filter=geo.distance(data/geo/iv, geography'POINT(20 10)') lt 30.0"); + + var q = await sut.ParseAsync(requestContext, query, schema); + + Assert.Equal("Filter: id == '__notfound__'; Take: 30; Sort: lastModified Descending, id Ascending", q.Query.ToString()); + } + + [Fact] + public async Task Should_convert_geo_query_to_filter_when_index_returns_empty() + { + A.CallTo(() => textIndex.SearchAsync(requestContext.App, new GeoQuery(schemaId.Id, "geo.iv", 10, 20, 30), requestContext.Scope())) + .Returns(new List()); + + var query = Q.Empty.WithODataQuery("$filter=geo.distance(data/geo/iv, geography'POINT(20 10)') lt 30.0"); + + var q = await sut.ParseAsync(requestContext, query, schema); + + Assert.Equal("Filter: id == '__notfound__'; Take: 30; Sort: lastModified Descending, id Ascending", q.Query.ToString()); } [Fact] @@ -161,7 +245,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries } [Fact] - public async Task Should_limit_number_of_contents() + public async Task Should_apply_default_limit() { var query = Q.Empty.WithODataQuery("$top=300&$skip=20"); @@ -180,9 +264,18 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries Assert.Equal("Skip: 20; Take: 200; Sort: id Descending", q.Query.ToString()); } - private static string Json(string text) + [Fact] + public async Task Should_convert_json_query_and_enrich_with_defaults() { - return text.Replace('\'', '"'); + var query = Q.Empty.WithJsonQuery( + new Query + { + Filter = new CompareFilter("data.firstName.iv", CompareOperator.Equals, JsonValue.Create("ABC")) + }); + + var q = await sut.ParseAsync(requestContext, query, schema); + + Assert.Equal("Filter: data.firstName.iv == 'ABC'; Take: 30; Sort: lastModified Descending, id Ascending", q.Query.ToString()); } } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs index db7d5742d..1da50dc68 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs @@ -56,7 +56,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries .Returns(new List { schema }); A.CallTo(() => queryParser.ParseAsync(A._, A._, A._)) - .ReturnsLazily(c => new ValueTask(c.GetArgument(1)!)); + .ReturnsLazily(c => Task.FromResult(c.GetArgument(1)!)); sut = new ContentQueryService( appProvider, diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/FilterTagTransformerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/FilterTagTransformerTests.cs deleted file mode 100644 index c912cfd35..000000000 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/FilterTagTransformerTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; -using FakeItEasy; -using Squidex.Domain.Apps.Core; -using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Domain.Apps.Core.Tags; -using Squidex.Domain.Apps.Entities.Schemas; -using Squidex.Domain.Apps.Entities.TestHelpers; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Queries; -using Xunit; - -namespace Squidex.Domain.Apps.Entities.Contents.Queries -{ - public class FilterTagTransformerTests - { - private readonly ITagService tagService = A.Fake(); - private readonly ISchemaEntity schema; - private readonly NamedId appId = NamedId.Of(DomainId.NewGuid(), "my-app"); - private readonly NamedId schemaId = NamedId.Of(DomainId.NewGuid(), "my-schema"); - - public FilterTagTransformerTests() - { - var schemaDef = - new Schema("schema") - .AddTags(1, "tags1", Partitioning.Invariant) - .AddTags(2, "tags2", Partitioning.Invariant, new TagsFieldProperties { Normalization = TagsFieldNormalization.Schema }) - .AddString(3, "string", Partitioning.Invariant); - - schema = Mocks.Schema(appId, schemaId, schemaDef); - } - - [Fact] - public void Should_normalize_tags() - { - A.CallTo(() => tagService.GetTagIdsAsync(appId.Id, TagGroups.Schemas(schemaId.Id), A>.That.Contains("name1"))) - .Returns(new Dictionary { ["name1"] = "id1" }); - - var source = ClrFilter.Eq("data.tags2.iv", "name1"); - - var result = FilterTagTransformer.TransformAsync(source, appId.Id, schema, tagService); - - Assert.Equal("data.tags2.iv == 'id1'", result!.ToString()); - } - - [Fact] - public void Should_not_fail_when_tags_not_found() - { - A.CallTo(() => tagService.GetTagIdsAsync(appId.Id, TagGroups.Assets, A>.That.Contains("name1"))) - .Returns(new Dictionary()); - - var source = ClrFilter.Eq("data.tags2.iv", "name1"); - - var result = FilterTagTransformer.TransformAsync(source, appId.Id, schema, tagService); - - Assert.Equal("data.tags2.iv == 'name1'", result!.ToString()); - } - - [Fact] - public void Should_not_normalize_other_tags_field() - { - var source = ClrFilter.Eq("data.tags1.iv", "value"); - - var result = FilterTagTransformer.TransformAsync(source, appId.Id, schema, tagService); - - Assert.Equal("data.tags1.iv == 'value'", result!.ToString()); - - A.CallTo(() => tagService.GetTagIdsAsync(appId.Id, A._, A>._)) - .MustNotHaveHappened(); - } - - [Fact] - public void Should_not_normalize_other_typed_field() - { - var source = ClrFilter.Eq("data.string.iv", "value"); - - var result = FilterTagTransformer.TransformAsync(source, appId.Id, schema, tagService); - - Assert.Equal("data.string.iv == 'value'", result!.ToString()); - - A.CallTo(() => tagService.GetTagIdsAsync(appId.Id, A._, A>._)) - .MustNotHaveHappened(); - } - - [Fact] - public void Should_not_normalize_non_data_field() - { - var source = ClrFilter.Eq("no.data", "value"); - - var result = FilterTagTransformer.TransformAsync(source, appId.Id, schema, tagService); - - Assert.Equal("no.data == 'value'", result!.ToString()); - - A.CallTo(() => tagService.GetTagIdsAsync(appId.Id, A._, A>._)) - .MustNotHaveHappened(); - } - } -} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTestsBase.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTestsBase.cs index 2b7867045..52f5f2b34 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTestsBase.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTestsBase.cs @@ -10,12 +10,14 @@ using System.Linq; using System.Threading.Tasks; using FluentAssertions; using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Contents.Text.State; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Events.Contents; using Squidex.Infrastructure; using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Json.Objects; using Xunit; #pragma warning disable SA1401 // Fields should be private @@ -38,7 +40,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text public virtual bool SupportsCleanup { get; set; } = false; - public virtual bool SupportssQuerySyntax { get; set; } = true; + public virtual bool SupportsQuerySyntax { get; set; } = true; + + public virtual bool SupportsGeo { get; set; } = false; public virtual InMemoryTextIndexerState State { get; } = new InMemoryTextIndexerState(); @@ -53,28 +57,42 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text [SkippableFact] public async Task Should_index_invariant_content_and_retrieve_with_fuzzy() { - Skip.IfNot(SupportssQuerySyntax); + Skip.IfNot(SupportsQuerySyntax); await TestCombinations( - Create(ids1[0], "iv", "Hello"), - Create(ids2[0], "iv", "World"), + CreateText(ids1[0], "iv", "Hello"), + CreateText(ids2[0], "iv", "World"), - Search(expected: ids1, text: "helo~"), - Search(expected: ids2, text: "wold~", SearchScope.All) + SearchText(expected: ids1, text: "helo~"), + SearchText(expected: ids2, text: "wold~", SearchScope.All) ); } [SkippableFact] public async Task Should_search_by_field() { - Skip.IfNot(SupportssQuerySyntax); + Skip.IfNot(SupportsQuerySyntax); + + await TestCombinations( + CreateText(ids1[0], "en", "City"), + CreateText(ids2[0], "de", "Stadt"), + + SearchText(expected: ids1, text: "en:city"), + SearchText(expected: ids2, text: "de:Stadt") + ); + } + + [SkippableFact] + public async Task Should_search_by_geo() + { + Skip.IfNot(SupportsGeo); await TestCombinations( - Create(ids1[0], "en", "City"), - Create(ids2[0], "de", "Stadt"), + CreateGeo(ids1[0], "geo", 51.343391192211506, 12.401476788622826), // Within radius + CreateGeo(ids2[0], "geo", 51.30765141427311, 12.379631713912486), // Not in radius - Search(expected: ids1, text: "en:city"), - Search(expected: ids2, text: "de:Stadt") + SearchGeo(expected: ids1, "geo.iv", 51.34641682574934, 12.401965298137707), + SearchGeo(expected: null, "abc.iv", 51.48596429889613, 12.102629469505713) // Wrong field ); } @@ -82,14 +100,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text public async Task Should_index_invariant_content_and_retrieve() { await TestCombinations( - Create(ids1[0], "iv", "Hello"), - Create(ids2[0], "iv", "World"), + CreateText(ids1[0], "iv", "Hello"), + CreateText(ids2[0], "iv", "World"), - Search(expected: ids1, text: "Hello"), - Search(expected: ids2, text: "World"), + SearchText(expected: ids1, text: "Hello"), + SearchText(expected: ids2, text: "World"), - Search(expected: null, text: "Hello", SearchScope.Published), - Search(expected: null, text: "World", SearchScope.Published) + SearchText(expected: null, text: "Hello", SearchScope.Published), + SearchText(expected: null, text: "World", SearchScope.Published) ); } @@ -97,15 +115,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text public async Task Should_update_draft_only() { await TestCombinations( - Create(ids1[0], "iv", "V1"), + CreateText(ids1[0], "iv", "V1"), - Update(ids1[0], "iv", "V2"), + UpdateText(ids1[0], "iv", "V2"), - Search(expected: null, text: "V1", target: SearchScope.All), - Search(expected: null, text: "V1", target: SearchScope.Published), + SearchText(expected: null, text: "V1", target: SearchScope.All), + SearchText(expected: null, text: "V1", target: SearchScope.Published), - Search(expected: ids1, text: "V2", target: SearchScope.All), - Search(expected: null, text: "V2", target: SearchScope.Published) + SearchText(expected: ids1, text: "V2", target: SearchScope.All), + SearchText(expected: null, text: "V2", target: SearchScope.Published) ); } @@ -113,16 +131,16 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text public async Task Should_update_draft_only_multiple_times() { await TestCombinations( - Create(ids1[0], "iv", "V1"), + CreateText(ids1[0], "iv", "V1"), - Update(ids1[0], "iv", "V2"), - Update(ids1[0], "iv", "V3"), + UpdateText(ids1[0], "iv", "V2"), + UpdateText(ids1[0], "iv", "V3"), - Search(expected: null, text: "V2", target: SearchScope.All), - Search(expected: null, text: "V2", target: SearchScope.Published), + SearchText(expected: null, text: "V2", target: SearchScope.All), + SearchText(expected: null, text: "V2", target: SearchScope.Published), - Search(expected: ids1, text: "V3", target: SearchScope.All), - Search(expected: null, text: "V3", target: SearchScope.Published) + SearchText(expected: ids1, text: "V3", target: SearchScope.All), + SearchText(expected: null, text: "V3", target: SearchScope.Published) ); } @@ -130,12 +148,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text public async Task Should_also_serve_published_after_publish() { await TestCombinations( - Create(ids1[0], "iv", "V1"), + CreateText(ids1[0], "iv", "V1"), Publish(ids1[0]), - Search(expected: ids1, text: "V1", target: SearchScope.All), - Search(expected: ids1, text: "V1", target: SearchScope.Published) + SearchText(expected: ids1, text: "V1", target: SearchScope.All), + SearchText(expected: ids1, text: "V1", target: SearchScope.Published) ); } @@ -143,17 +161,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text public async Task Should_also_update_published_content() { await TestCombinations( - Create(ids1[0], "iv", "V1"), + CreateText(ids1[0], "iv", "V1"), Publish(ids1[0]), - Update(ids1[0], "iv", "V2"), + UpdateText(ids1[0], "iv", "V2"), - Search(expected: null, text: "V1", target: SearchScope.All), - Search(expected: null, text: "V1", target: SearchScope.Published), + SearchText(expected: null, text: "V1", target: SearchScope.All), + SearchText(expected: null, text: "V1", target: SearchScope.Published), - Search(expected: ids1, text: "V2", target: SearchScope.All), - Search(expected: ids1, text: "V2", target: SearchScope.Published) + SearchText(expected: ids1, text: "V2", target: SearchScope.All), + SearchText(expected: ids1, text: "V2", target: SearchScope.Published) ); } @@ -161,18 +179,18 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text public async Task Should_also_update_published_content_multiple_times() { await TestCombinations( - Create(ids1[0], "iv", "V1"), + CreateText(ids1[0], "iv", "V1"), Publish(ids1[0]), - Update(ids1[0], "iv", "V2"), - Update(ids1[0], "iv", "V3"), + UpdateText(ids1[0], "iv", "V2"), + UpdateText(ids1[0], "iv", "V3"), - Search(expected: null, text: "V2", target: SearchScope.All), - Search(expected: null, text: "V2", target: SearchScope.Published), + SearchText(expected: null, text: "V2", target: SearchScope.All), + SearchText(expected: null, text: "V2", target: SearchScope.Published), - Search(expected: ids1, text: "V3", target: SearchScope.All), - Search(expected: ids1, text: "V3", target: SearchScope.Published) + SearchText(expected: ids1, text: "V3", target: SearchScope.All), + SearchText(expected: ids1, text: "V3", target: SearchScope.Published) ); } @@ -180,43 +198,43 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text public async Task Should_simulate_new_version() { await TestCombinations(0, - Create(ids1[0], "iv", "V1"), + CreateText(ids1[0], "iv", "V1"), // Publish the content. Publish(ids1[0]), - Search(expected: ids1, text: "V1", target: SearchScope.All), - Search(expected: ids1, text: "V1", target: SearchScope.Published), + SearchText(expected: ids1, text: "V1", target: SearchScope.All), + SearchText(expected: ids1, text: "V1", target: SearchScope.Published), // Create a new version, the value is still the same as old version. CreateDraft(ids1[0]), - Search(expected: ids1, text: "V1", target: SearchScope.All), - Search(expected: ids1, text: "V1", target: SearchScope.Published), + SearchText(expected: ids1, text: "V1", target: SearchScope.All), + SearchText(expected: ids1, text: "V1", target: SearchScope.Published), // Make an update, this updates the new version only. - Update(ids1[0], "iv", "V2"), + UpdateText(ids1[0], "iv", "V2"), - Search(expected: null, text: "V1", target: SearchScope.All), - Search(expected: ids1, text: "V1", target: SearchScope.Published), + SearchText(expected: null, text: "V1", target: SearchScope.All), + SearchText(expected: ids1, text: "V1", target: SearchScope.Published), - Search(expected: ids1, text: "V2", target: SearchScope.All), - Search(expected: null, text: "V2", target: SearchScope.Published), + SearchText(expected: ids1, text: "V2", target: SearchScope.All), + SearchText(expected: null, text: "V2", target: SearchScope.Published), // Publish the new version to get rid of the "V1" version. Publish(ids1[0]), - Search(expected: null, text: "V1", target: SearchScope.All), - Search(expected: null, text: "V1", target: SearchScope.Published), + SearchText(expected: null, text: "V1", target: SearchScope.All), + SearchText(expected: null, text: "V1", target: SearchScope.Published), - Search(expected: ids1, text: "V2", target: SearchScope.All), - Search(expected: ids1, text: "V2", target: SearchScope.Published), + SearchText(expected: ids1, text: "V2", target: SearchScope.All), + SearchText(expected: ids1, text: "V2", target: SearchScope.Published), // Unpublish the version Unpublish(ids1[0]), - Search(expected: ids1, text: "V2", target: SearchScope.All), - Search(expected: null, text: "V2", target: SearchScope.Published) + SearchText(expected: ids1, text: "V2", target: SearchScope.All), + SearchText(expected: null, text: "V2", target: SearchScope.Published) ); } @@ -224,22 +242,22 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text public async Task Should_simulate_new_version_with_migration() { await TestCombinations(0, - Create(ids1[0], "iv", "V1"), + CreateText(ids1[0], "iv", "V1"), // Publish the content. Publish(ids1[0]), - Search(expected: ids1, text: "V1", target: SearchScope.All), - Search(expected: ids1, text: "V1", target: SearchScope.Published), + SearchText(expected: ids1, text: "V1", target: SearchScope.All), + SearchText(expected: ids1, text: "V1", target: SearchScope.Published), // Create a new version, his updates the new version also. - CreateDraftWithData(ids1[0], "iv", "V2"), + CreateDraftWithText(ids1[0], "iv", "V2"), - Search(expected: null, text: "V1", target: SearchScope.All), - Search(expected: ids1, text: "V1", target: SearchScope.Published), + SearchText(expected: null, text: "V1", target: SearchScope.All), + SearchText(expected: ids1, text: "V1", target: SearchScope.Published), - Search(expected: ids1, text: "V2", target: SearchScope.All), - Search(expected: null, text: "V2", target: SearchScope.Published) + SearchText(expected: ids1, text: "V2", target: SearchScope.All), + SearchText(expected: null, text: "V2", target: SearchScope.Published) ); } @@ -247,7 +265,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text public async Task Should_simulate_content_reversion() { await TestCombinations( - Create(ids1[0], "iv", "V1"), + CreateText(ids1[0], "iv", "V1"), // Publish the content. Publish(ids1[0]), @@ -256,22 +274,22 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text CreateDraft(ids1[0]), // Make an update, this updates the new version only. - Update(ids1[0], "iv", "V2"), + UpdateText(ids1[0], "iv", "V2"), // Make an update, this updates the new version only. DeleteDraft(ids1[0]), - Search(expected: ids1, text: "V1", target: SearchScope.All), - Search(expected: ids1, text: "V1", target: SearchScope.Published), + SearchText(expected: ids1, text: "V1", target: SearchScope.All), + SearchText(expected: ids1, text: "V1", target: SearchScope.Published), - Search(expected: null, text: "V2", target: SearchScope.All), - Search(expected: null, text: "V2", target: SearchScope.Published), + SearchText(expected: null, text: "V2", target: SearchScope.All), + SearchText(expected: null, text: "V2", target: SearchScope.Published), // Make an update, this updates the current version only. - Update(ids1[0], "iv", "V3"), + UpdateText(ids1[0], "iv", "V3"), - Search(expected: ids1, text: "V3", target: SearchScope.All), - Search(expected: ids1, text: "V3", target: SearchScope.Published) + SearchText(expected: ids1, text: "V3", target: SearchScope.All), + SearchText(expected: ids1, text: "V3", target: SearchScope.Published) ); } @@ -279,46 +297,61 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text public async Task Should_delete_documents_from_index() { await TestCombinations( - Create(ids1[0], "iv", "V1_1"), - Create(ids2[0], "iv", "V2_1"), + CreateText(ids1[0], "iv", "V1_1"), + CreateText(ids2[0], "iv", "V2_1"), - Search(expected: ids1, text: "V1_1"), - Search(expected: ids2, text: "V2_1"), + SearchText(expected: ids1, text: "V1_1"), + SearchText(expected: ids2, text: "V2_1"), Delete(ids1[0]), - Search(expected: null, text: "V1_1"), - Search(expected: ids2, text: "V2_1") + SearchText(expected: null, text: "V1_1"), + SearchText(expected: ids2, text: "V2_1") ); } - protected IndexOperation Create(DomainId id, string language, string text) + protected IndexOperation CreateText(DomainId id, string language, string text) { - var data = Data(language, text); + var data = TextData(language, text); return Op(id, new ContentCreated { Data = data }); } - protected IndexOperation Update(DomainId id, string language, string text) + protected IndexOperation CreateGeo(DomainId id, string field, double latitude, double longitude) { - var data = Data(language, text); + var data = GeoData(field, latitude, longitude); + + return Op(id, new ContentCreated { Data = data }); + } + + protected IndexOperation UpdateText(DomainId id, string language, string text) + { + var data = TextData(language, text); return Op(id, new ContentUpdated { Data = data }); } - protected IndexOperation CreateDraftWithData(DomainId id, string language, string text) + protected IndexOperation CreateDraftWithText(DomainId id, string language, string text) { - var data = Data(language, text); + var data = TextData(language, text); return Op(id, new ContentDraftCreated { MigratedData = data }); } - private static NamedContentData Data(string language, string text) + private static NamedContentData TextData(string language, string text) + { + return new NamedContentData() + .AddField("text", + new ContentFieldData() + .AddValue(language, text)); + } + + private static NamedContentData GeoData(string field, double latitude, double longitude) { return new NamedContentData() - .AddField("text", - new ContentFieldData() - .AddValue(language, text)); + .AddField(field, + new ContentFieldData() + .AddValue("iv", JsonValue.Object().Add("latitude", latitude).Add("longitude", longitude))); } protected IndexOperation CreateDraft(DomainId id) @@ -355,13 +388,32 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text return p => p.On(Enumerable.Repeat(Envelope.Create(contentEvent), 1)); } - protected IndexOperation Search(List? expected, string text, SearchScope target = SearchScope.All) + protected IndexOperation SearchGeo(List? expected, string field, double latitude, double longitude, SearchScope target = SearchScope.All) + { + return async p => + { + var query = new GeoQuery(schemaId.Id, field, latitude, longitude, 1000); + + var result = await p.TextIndex.SearchAsync(app, query, target); + + if (expected != null) + { + result.Should().BeEquivalentTo(expected.ToHashSet()); + } + else + { + result.Should().BeEmpty(); + } + }; + } + + protected IndexOperation SearchText(List? expected, string text, SearchScope target = SearchScope.All) { return async p => { - var searchFilter = SearchFilter.ShouldHaveSchemas(schemaId.Id); + var query = new TextQuery(text, TextFilter.ShouldHaveSchemas(schemaId.Id)); - var result = await p.TextIndex.SearchAsync(text, app, searchFilter, target); + var result = await p.TextIndex.SearchAsync(app, query, target); if (expected != null) { @@ -413,7 +465,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text var indexer = await Factory.CreateAsync(schemaId.Id); try { - var sut = new TextIndexingProcess(indexer, State); + var sut = new TextIndexingProcess(TestUtils.DefaultSerializer, indexer, State); await action(sut); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests_Elastic.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests_Elastic.cs index 0f413b857..3042627db 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests_Elastic.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests_Elastic.cs @@ -38,18 +38,18 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text public TextIndexerTests_Elastic() { - SupportssQuerySyntax = true; + SupportsQuerySyntax = true; } [Fact] public async Task Should_index_localized_content_without_stop_words_and_retrieve() { await TestCombinations( - Create(ids1[0], "de", "and und"), - Create(ids2[0], "en", "and und"), + CreateText(ids1[0], "de", "and und"), + CreateText(ids2[0], "en", "and und"), - Search(expected: ids1, text: "and"), - Search(expected: ids2, text: "und") + SearchText(expected: ids1, text: "and"), + SearchText(expected: ids2, text: "und") ); } @@ -57,9 +57,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text public async Task Should_index_cjk_content_and_retrieve() { await TestCombinations( - Create(ids1[0], "zh", "東京大学"), + CreateText(ids1[0], "zh", "東京大学"), - Search(expected: ids1, text: "東京") + SearchText(expected: ids1, text: "東京") ); } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests_Mongo.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests_Mongo.cs index 5b5e1bc36..827344f92 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests_Mongo.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests_Mongo.cs @@ -8,8 +8,11 @@ using System.Linq; using System.Threading.Tasks; using MongoDB.Driver; +using Newtonsoft.Json; +using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Entities.MongoDb.FullText; using Squidex.Infrastructure; +using Squidex.Infrastructure.MongoDb; using Xunit; #pragma warning disable SA1115 // Parameter should follow comma @@ -44,7 +47,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text public TextIndexerTests_Mongo() { - SupportssQuerySyntax = false; + BsonJsonConvention.Register(JsonSerializer.Create(TestUtils.CreateSerializerSettings())); + + SupportsQuerySyntax = false; + SupportsGeo = true; } [Fact] @@ -53,11 +59,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text var both = ids2.Union(ids1).ToList(); await TestCombinations( - Create(ids1[0], "de", "and und"), - Create(ids2[0], "en", "and und"), + CreateText(ids1[0], "de", "and und"), + CreateText(ids2[0], "en", "and und"), - Search(expected: both, text: "and"), - Search(expected: both, text: "und") + SearchText(expected: both, text: "and"), + SearchText(expected: both, text: "und") ); } } diff --git a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/CosmosDbEventStoreFixture.cs b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/CosmosDbEventStoreFixture.cs index 817947031..039b1faeb 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/CosmosDbEventStoreFixture.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/CosmosDbEventStoreFixture.cs @@ -21,9 +21,9 @@ namespace Squidex.Infrastructure.EventSourcing public CosmosDbEventStoreFixture() { - client = new DocumentClient(new Uri(EmulatorUri), EmulatorKey, JsonHelper.DefaultSettings()); + client = new DocumentClient(new Uri(EmulatorUri), EmulatorKey, TestUtils.DefaultSettings()); - EventStore = new CosmosDbEventStore(client, EmulatorKey, "Test", JsonHelper.DefaultSettings()); + EventStore = new CosmosDbEventStore(client, EmulatorKey, "Test", TestUtils.DefaultSettings()); EventStore.InitializeAsync().Wait(); } diff --git a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/DefaultEventDataFormatterTests.cs b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/DefaultEventDataFormatterTests.cs index e994f8015..72fc1f484 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/DefaultEventDataFormatterTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/DefaultEventDataFormatterTests.cs @@ -36,7 +36,7 @@ namespace Squidex.Infrastructure.EventSourcing .Map(typeof(MyEvent), "Event") .Map(typeof(MyOldEvent), "OldEvent"); - sut = new DefaultEventDataFormatter(typeNameRegistry, JsonHelper.CreateSerializer(typeNameRegistry)); + sut = new DefaultEventDataFormatter(typeNameRegistry, TestUtils.CreateSerializer(typeNameRegistry)); } [Fact] diff --git a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/GetEventStoreFixture.cs b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/GetEventStoreFixture.cs index a27b850a7..1ebbf01b5 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/GetEventStoreFixture.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/GetEventStoreFixture.cs @@ -26,7 +26,7 @@ namespace Squidex.Infrastructure.EventSourcing { connection = EventStoreConnection.Create("ConnectTo=tcp://admin:changeit@localhost:1113; HeartBeatTimeout=500; MaxReconnections=-1"); - EventStore = new GetEventStore(connection, JsonHelper.DefaultSerializer, "test", "localhost"); + EventStore = new GetEventStore(connection, TestUtils.DefaultSerializer, "test", "localhost"); EventStore.InitializeAsync().Wait(); } diff --git a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/MongoEventStoreFixture.cs b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/MongoEventStoreFixture.cs index 65d655dc1..7749b5f2a 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/MongoEventStoreFixture.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/MongoEventStoreFixture.cs @@ -27,7 +27,7 @@ namespace Squidex.Infrastructure.EventSourcing mongoClient = new MongoClient(connectionString); mongoDatabase = mongoClient.GetDatabase($"EventStoreTest"); - BsonJsonConvention.Register(JsonSerializer.Create(JsonHelper.DefaultSettings())); + BsonJsonConvention.Register(JsonSerializer.Create(TestUtils.DefaultSettings())); EventStore = new MongoEventStore(mongoDatabase, notifier); EventStore.InitializeAsync().Wait(); diff --git a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/MongoParallelInsertTests.cs b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/MongoParallelInsertTests.cs index 6bbd9420a..c62685bb0 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/MongoParallelInsertTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/MongoParallelInsertTests.cs @@ -216,7 +216,7 @@ namespace Squidex.Infrastructure.EventSourcing var typeNameRegistry = new TypeNameRegistry().Map(typeof(MyEvent), "My"); - eventDataFormatter = new DefaultEventDataFormatter(typeNameRegistry, JsonHelper.DefaultSerializer); + eventDataFormatter = new DefaultEventDataFormatter(typeNameRegistry, TestUtils.DefaultSerializer); } [Fact] diff --git a/backend/tests/Squidex.Infrastructure.Tests/Json/Objects/JsonValuesSerializationTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Json/Objects/JsonValuesSerializationTests.cs index 5eaac3d03..491d2fd1b 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Json/Objects/JsonValuesSerializationTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Json/Objects/JsonValuesSerializationTests.cs @@ -15,7 +15,7 @@ namespace Squidex.Infrastructure.Json.Objects [Fact] public void Should_deserialize_integer() { - var serialized = JsonHelper.Deserialize(123); + var serialized = TestUtils.Deserialize(123); Assert.Equal(JsonValue.Create(123), serialized); } diff --git a/backend/tests/Squidex.Infrastructure.Tests/NamedIdTests.cs b/backend/tests/Squidex.Infrastructure.Tests/NamedIdTests.cs index 2f48922c2..a1bcc610c 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/NamedIdTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/NamedIdTests.cs @@ -117,19 +117,19 @@ namespace Squidex.Infrastructure [Fact] public void Should_throw_exception_if_string_id_is_not_valid() { - Assert.ThrowsAny(() => JsonHelper.Deserialize>("123")); + Assert.ThrowsAny(() => TestUtils.Deserialize>("123")); } [Fact] public void Should_throw_exception_if_long_id_is_not_valid() { - Assert.ThrowsAny(() => JsonHelper.Deserialize>("invalid-long,name")); + Assert.ThrowsAny(() => TestUtils.Deserialize>("invalid-long,name")); } [Fact] public void Should_throw_exception_if_guid_id_is_not_valid() { - Assert.ThrowsAny(() => JsonHelper.Deserialize>("invalid-guid,name")); + Assert.ThrowsAny(() => TestUtils.Deserialize>("invalid-guid,name")); } } } diff --git a/backend/tests/Squidex.Infrastructure.Tests/Orleans/JsonExternalSerializerTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Orleans/JsonExternalSerializerTests.cs index e0b21bb40..c46c2df1b 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Orleans/JsonExternalSerializerTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Orleans/JsonExternalSerializerTests.cs @@ -20,7 +20,7 @@ namespace Squidex.Infrastructure.Orleans { public JsonExternalSerializerTests() { - J.DefaultSerializer = JsonHelper.DefaultSerializer; + J.DefaultSerializer = TestUtils.DefaultSerializer; } [Fact] diff --git a/backend/tests/Squidex.Infrastructure.Tests/Queries/JsonQueryConversionTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Queries/JsonQueryConversionTests.cs deleted file mode 100644 index e2c9670e7..000000000 --- a/backend/tests/Squidex.Infrastructure.Tests/Queries/JsonQueryConversionTests.cs +++ /dev/null @@ -1,398 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; -using System.Linq; -using NJsonSchema; -using Squidex.Infrastructure.Json.Objects; -using Squidex.Infrastructure.Queries.Json; -using Squidex.Infrastructure.TestHelpers; -using Squidex.Infrastructure.Validation; -using Xunit; - -namespace Squidex.Infrastructure.Queries -{ - public sealed class JsonQueryConversionTests - { - private readonly List errors = new List(); - private readonly JsonSchema schema = new JsonSchema(); - - public JsonQueryConversionTests() - { - var nested = new JsonSchemaProperty { Title = "nested", Type = JsonObjectType.Object }; - - nested.Properties["property"] = new JsonSchemaProperty - { - Type = JsonObjectType.String - }; - - schema.Properties["boolean"] = new JsonSchemaProperty - { - Type = JsonObjectType.Boolean - }; - - schema.Properties["datetime"] = new JsonSchemaProperty - { - Type = JsonObjectType.String, Format = JsonFormatStrings.DateTime - }; - - schema.Properties["guid"] = new JsonSchemaProperty - { - Type = JsonObjectType.String, Format = JsonFormatStrings.Guid - }; - - schema.Properties["integer"] = new JsonSchemaProperty - { - Type = JsonObjectType.Integer - }; - - schema.Properties["number"] = new JsonSchemaProperty - { - Type = JsonObjectType.Number - }; - - schema.Properties["string"] = new JsonSchemaProperty - { - Type = JsonObjectType.String - }; - - schema.Properties["stringArray"] = new JsonSchemaProperty - { - Item = new JsonSchema - { - Type = JsonObjectType.String - }, - Type = JsonObjectType.Array - }; - - schema.Properties["object"] = nested; - - schema.Properties["reference"] = new JsonSchemaProperty - { - Reference = nested - }; - } - - [Fact] - public void Should_add_error_if_property_does_not_exist() - { - var json = new { path = "notfound", op = "eq", value = 1 }; - - AssertErrors(json, "Path 'notfound' does not point to a valid property in the model."); - } - - [Fact] - public void Should_add_error_if_nested_property_does_not_exist() - { - var json = new { path = "object.notfound", op = "eq", value = 1 }; - - AssertErrors(json, "'notfound' is not a property of 'nested'."); - } - - [Theory] - [InlineData("contains", "contains(datetime, 2012-11-10T09:08:07Z)")] - [InlineData("empty", "empty(datetime)")] - [InlineData("endswith", "endsWith(datetime, 2012-11-10T09:08:07Z)")] - [InlineData("eq", "datetime == 2012-11-10T09:08:07Z")] - [InlineData("ge", "datetime >= 2012-11-10T09:08:07Z")] - [InlineData("gt", "datetime > 2012-11-10T09:08:07Z")] - [InlineData("le", "datetime <= 2012-11-10T09:08:07Z")] - [InlineData("lt", "datetime < 2012-11-10T09:08:07Z")] - [InlineData("ne", "datetime != 2012-11-10T09:08:07Z")] - [InlineData("startswith", "startsWith(datetime, 2012-11-10T09:08:07Z)")] - public void Should_parse_datetime_string_filter(string op, string expected) - { - var json = new { path = "datetime", op, value = "2012-11-10T09:08:07Z" }; - - AssertFilter(json, expected); - } - - [Fact] - public void Should_parse_date_string_filter() - { - var json = new { path = "datetime", op = "eq", value = "2012-11-10" }; - - AssertFilter(json, "datetime == 2012-11-10T00:00:00Z"); - } - - [Fact] - public void Should_add_error_if_datetime_string_property_got_invalid_string_value() - { - var json = new { path = "datetime", op = "eq", value = "invalid" }; - - AssertErrors(json, "Expected ISO8601 DateTime String for path 'datetime', but got invalid String."); - } - - [Fact] - public void Should_add_error_if_datetime_string_property_got_invalid_value() - { - var json = new { path = "datetime", op = "eq", value = 1 }; - - AssertErrors(json, "Expected ISO8601 DateTime String for path 'datetime', but got Number."); - } - - [Theory] - [InlineData("contains", "contains(guid, bf57d32c-d4dd-4217-8c16-6dcb16975cf3)")] - [InlineData("empty", "empty(guid)")] - [InlineData("endswith", "endsWith(guid, bf57d32c-d4dd-4217-8c16-6dcb16975cf3)")] - [InlineData("eq", "guid == bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] - [InlineData("ge", "guid >= bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] - [InlineData("gt", "guid > bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] - [InlineData("le", "guid <= bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] - [InlineData("lt", "guid < bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] - [InlineData("ne", "guid != bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] - [InlineData("startswith", "startsWith(guid, bf57d32c-d4dd-4217-8c16-6dcb16975cf3)")] - public void Should_parse_guid_string_filter(string op, string expected) - { - var json = new { path = "guid", op, value = "bf57d32c-d4dd-4217-8c16-6dcb16975cf3" }; - - AssertFilter(json, expected); - } - - [Fact] - public void Should_add_error_if_guid_string_property_got_invalid_string_value() - { - var json = new { path = "guid", op = "eq", value = "invalid" }; - - AssertErrors(json, "Expected Guid String for path 'guid', but got invalid String."); - } - - [Fact] - public void Should_add_error_if_guid_string_property_got_invalid_value() - { - var json = new { path = "guid", op = "eq", value = 1 }; - - AssertErrors(json, "Expected Guid String for path 'guid', but got Number."); - } - - [Theory] - [InlineData("contains", "contains(string, 'Hello')")] - [InlineData("empty", "empty(string)")] - [InlineData("endswith", "endsWith(string, 'Hello')")] - [InlineData("eq", "string == 'Hello'")] - [InlineData("ge", "string >= 'Hello'")] - [InlineData("gt", "string > 'Hello'")] - [InlineData("le", "string <= 'Hello'")] - [InlineData("lt", "string < 'Hello'")] - [InlineData("ne", "string != 'Hello'")] - [InlineData("startswith", "startsWith(string, 'Hello')")] - public void Should_parse_string_filter(string op, string expected) - { - var json = new { path = "string", op, value = "Hello" }; - - AssertFilter(json, expected); - } - - [Fact] - public void Should_add_error_if_string_property_got_invalid_value() - { - var json = new { path = "string", op = "eq", value = 1 }; - - AssertErrors(json, "Expected String for path 'string', but got Number."); - } - - [Fact] - public void Should_parse_string_in_filter() - { - var json = new { path = "string", op = "in", value = new[] { "Hello" } }; - - AssertFilter(json, "string in ['Hello']"); - } - - [Fact] - public void Should_parse_nested_string_filter() - { - var json = new { path = "object.property", op = "in", value = new[] { "Hello" } }; - - AssertFilter(json, "object.property in ['Hello']"); - } - - [Fact] - public void Should_parse_referenced_string_filter() - { - var json = new { path = "reference.property", op = "in", value = new[] { "Hello" } }; - - AssertFilter(json, "reference.property in ['Hello']"); - } - - [Theory] - [InlineData("eq", "number == 12")] - [InlineData("ge", "number >= 12")] - [InlineData("gt", "number > 12")] - [InlineData("le", "number <= 12")] - [InlineData("lt", "number < 12")] - [InlineData("ne", "number != 12")] - public void Should_parse_number_filter(string op, string expected) - { - var json = new { path = "number", op, value = 12 }; - - AssertFilter(json, expected); - } - - [Fact] - public void Should_add_error_if_number_property_got_invalid_value() - { - var json = new { path = "number", op = "eq", value = true }; - - AssertErrors(json, "Expected Number for path 'number', but got Boolean."); - } - - [Fact] - public void Should_parse_number_in_filter() - { - var json = new { path = "number", op = "in", value = new[] { 12 } }; - - AssertFilter(json, "number in [12]"); - } - - [Theory] - [InlineData("eq", "boolean == True")] - [InlineData("ne", "boolean != True")] - public void Should_parse_boolean_filter(string op, string expected) - { - var json = new { path = "boolean", op, value = true }; - - AssertFilter(json, expected); - } - - [Fact] - public void Should_add_error_if_boolean_property_got_invalid_value() - { - var json = new { path = "boolean", op = "eq", value = 1 }; - - AssertErrors(json, "Expected Boolean for path 'boolean', but got Number."); - } - - [Fact] - public void Should_parse_boolean_in_filter() - { - var json = new { path = "boolean", op = "in", value = new[] { true } }; - - AssertFilter(json, "boolean in [True]"); - } - - [Theory] - [InlineData("empty", "empty(stringArray)")] - [InlineData("eq", "stringArray == 'Hello'")] - [InlineData("ne", "stringArray != 'Hello'")] - public void Should_parse_array_filter(string op, string expected) - { - var json = new { path = "stringArray", op, value = "Hello" }; - - AssertFilter(json, expected); - } - - [Fact] - public void Should_parse_array_in_filter() - { - var json = new { path = "stringArray", op = "in", value = new[] { "Hello" } }; - - AssertFilter(json, "stringArray in ['Hello']"); - } - - [Fact] - public void Should_add_error_when_using_array_value_for_non_allowed_operator() - { - var json = new { path = "string", op = "eq", value = new[] { "Hello" } }; - - AssertErrors(json, "Array value is not allowed for 'Equals' operator and path 'string'."); - } - - [Fact] - public void Should_parse_query() - { - var json = new { skip = 10, take = 20, FullText = "Hello", Filter = new { path = "string", op = "eq", value = "Hello" } }; - - AssertQuery(json, "Filter: string == 'Hello'; FullText: 'Hello'; Skip: 10; Take: 20"); - } - - [Fact] - public void Should_parse_query_with_sorting() - { - var json = new { sort = new[] { new { path = "string", order = "ascending" } } }; - - AssertQuery(json, "Sort: string Ascending"); - } - - [Fact] - public void Should_throw_exception_for_invalid_query() - { - var json = new { sort = new[] { new { path = "invalid", order = "ascending" } } }; - - Assert.Throws(() => AssertQuery(json, null)); - } - - [Fact] - public void Should_throw_exception_when_parsing_invalid_json() - { - var json = "invalid"; - - Assert.Throws(() => AssertQuery(json, null)); - } - - [Fact] - public void Should_not_throw_exception_when_parsing_null_string() - { - string? json = null; - - Assert.NotNull(schema.Parse(json!, JsonHelper.DefaultSerializer)); - } - - [Fact] - public void Should_not_throw_exception_when_parsing_null_json() - { - var json = "null"; - - Assert.NotNull(schema.Parse(json, JsonHelper.DefaultSerializer)); - } - - private void AssertQuery(object json, string? expectedFilter) - { - var filter = ConvertQuery(json); - - Assert.Empty(errors); - - Assert.Equal(expectedFilter, filter); - } - - private void AssertFilter(object json, string? expectedFilter) - { - var filter = ConvertFilter(json); - - Assert.Empty(errors); - - Assert.Equal(expectedFilter, filter); - } - - private void AssertErrors(object json, params string[] expectedErrors) - { - var filter = ConvertFilter(json); - - Assert.Equal(expectedErrors.ToList(), errors); - - Assert.Null(filter); - } - - private string? ConvertFilter(T value) - { - var json = JsonHelper.DefaultSerializer.Serialize(value, true); - - var jsonFilter = JsonHelper.DefaultSerializer.Deserialize>(json); - - return JsonFilterVisitor.Parse(jsonFilter, schema, errors)?.ToString(); - } - - private string? ConvertQuery(T value) - { - var json = JsonHelper.DefaultSerializer.Serialize(value, true); - - var jsonQuery = schema.Parse(json, JsonHelper.DefaultSerializer); - - return jsonQuery.ToString(); - } - } -} diff --git a/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromJsonTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromJsonTests.cs new file mode 100644 index 000000000..a294593ea --- /dev/null +++ b/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromJsonTests.cs @@ -0,0 +1,651 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using NJsonSchema; +using Squidex.Infrastructure.Json; +using Squidex.Infrastructure.Json.Objects; +using Squidex.Infrastructure.Queries.Json; +using Squidex.Infrastructure.TestHelpers; +using Squidex.Infrastructure.Validation; +using Xunit; + +namespace Squidex.Infrastructure.Queries +{ + public sealed class QueryFromJsonTests + { + private static readonly (string Name, string Operator, string Output)[] AllOps = + { + ("Contains", "contains", "contains($FIELD, $VALUE)"), + ("Empty", "empty", "empty($FIELD)"), + ("EndsWith", "endswith", "endsWith($FIELD, $VALUE)"), + ("Equals", "eq", "$FIELD == $VALUE"), + ("GreaterThanOrEqual", "ge", "$FIELD >= $VALUE"), + ("GreaterThan", "gt", "$FIELD > $VALUE"), + ("LessThanOrEqual", "le", "$FIELD <= $VALUE"), + ("LessThan", "lt", "$FIELD < $VALUE"), + ("NotEquals", "ne", "$FIELD != $VALUE"), + ("StartsWith", "startswith", "startsWith($FIELD, $VALUE)") + }; + + private static readonly JsonSchema Schema = new JsonSchema(); + + static QueryFromJsonTests() + { + var nested = new JsonSchemaProperty { Title = "nested", Type = JsonObjectType.Object }; + + nested.Properties["property"] = new JsonSchemaProperty + { + Type = JsonObjectType.String + }; + + Schema.Properties["boolean"] = new JsonSchemaProperty + { + Type = JsonObjectType.Boolean + }; + + Schema.Properties["datetime"] = new JsonSchemaProperty + { + Type = JsonObjectType.String, Format = JsonFormatStrings.DateTime + }; + + Schema.Properties["guid"] = new JsonSchemaProperty + { + Type = JsonObjectType.String, Format = JsonFormatStrings.Guid + }; + + Schema.Properties["integer"] = new JsonSchemaProperty + { + Type = JsonObjectType.Integer + }; + + Schema.Properties["number"] = new JsonSchemaProperty + { + Type = JsonObjectType.Number + }; + + Schema.Properties["json"] = new JsonSchemaProperty + { + Type = JsonObjectType.None + }; + + Schema.Properties["geo"] = new JsonSchemaProperty + { + Type = JsonObjectType.Object, Format = GeoJson.Format + }; + + Schema.Properties["reference"] = new JsonSchemaProperty + { + Reference = nested + }; + + Schema.Properties["string"] = new JsonSchemaProperty + { + Type = JsonObjectType.String + }; + + Schema.Properties["geoRef"] = new JsonSchemaProperty + { + Reference = new JsonSchema + { + Format = GeoJson.Format + }, + }; + + Schema.Properties["stringArray"] = new JsonSchemaProperty + { + Item = new JsonSchema + { + Type = JsonObjectType.String + }, + Type = JsonObjectType.Array + }; + + Schema.Properties["object"] = nested; + } + + public class DateTime + { + public static IEnumerable ValidTests() + { + const string value = "2012-11-10T09:08:07Z"; + + return BuildTests("datetime", x => true, value, value); + } + + [Theory] + [MemberData(nameof(ValidTests))] + public void Should_parse_filter(string field, string op, string value, string expected) + { + var json = new { path = field, op, value }; + + AssertFilter(json, expected); + } + + [Fact] + public void Should_add_error_if_value_is_invalid() + { + var json = new { path = "datetime", op = "eq", value = "invalid" }; + + AssertErrors(json, "Expected ISO8601 DateTime String for path 'datetime', but got invalid String."); + } + + [Fact] + public void Should_add_error_if_value_type_is_invalid() + { + var json = new { path = "datetime", op = "eq", value = 1 }; + + AssertErrors(json, "Expected ISO8601 DateTime String for path 'datetime', but got Number."); + } + } + + public class Guid + { + public static IEnumerable ValidTests() + { + const string value = "bf57d32c-d4dd-4217-8c16-6dcb16975cf3"; + + return BuildTests("guid", x => true, value, value); + } + + [Theory] + [MemberData(nameof(ValidTests))] + public void Should_parse_filter(string field, string op, string value, string expected) + { + var json = new { path = field, op, value }; + + AssertFilter(json, expected); + } + + [Fact] + public void Should_add_error_if_value_is_invalid() + { + var json = new { path = "guid", op = "eq", value = "invalid" }; + + AssertErrors(json, "Expected Guid String for path 'guid', but got invalid String."); + } + + [Fact] + public void Should_add_error_if_value_type_is_invalid() + { + var json = new { path = "guid", op = "eq", value = 1 }; + + AssertErrors(json, "Expected Guid String for path 'guid', but got Number."); + } + } + + public class String + { + public static IEnumerable ValidTests() + { + const string value = "Hello"; + + return BuildTests("string", x => true, value, $"'{value}'"); + } + + public static IEnumerable ValidInTests() + { + const string value = "Hello"; + + return BuildInTests("string", value, $"'{value}'"); + } + + [Theory] + [MemberData(nameof(ValidTests))] + public void Should_parse_filter(string field, string op, string value, string expected) + { + var json = new { path = field, op, value }; + + AssertFilter(json, expected); + } + + [Theory] + [MemberData(nameof(ValidInTests))] + public void Should_parse_in_filter(string field, string value, string expected) + { + var json = new { path = field, op = "in", value = new[] { value } }; + + AssertFilter(json, expected); + } + + [Fact] + public void Should_add_error_if_value_type_is_invalid() + { + var json = new { path = "string", op = "eq", value = 1 }; + + AssertErrors(json, "Expected String for path 'string', but got Number."); + } + + [Fact] + public void Should_parse_nested_string_filter() + { + var json = new { path = "object.property", op = "in", value = new[] { "Hello" } }; + + AssertFilter(json, "object.property in ['Hello']"); + } + + [Fact] + public void Should_parse_referenced_string_filter() + { + var json = new { path = "reference.property", op = "in", value = new[] { "Hello" } }; + + AssertFilter(json, "reference.property in ['Hello']"); + } + } + + public class Geo + { + public static IEnumerable ValidTests() + { + var value = new { longitude = 10, latitude = 20, distance = 30 }; + + return BuildFlatTests("geo", x => x == "lt", value, $"Radius({value.longitude}, {value.latitude}, {value.distance})"); + } + + public static IEnumerable ValidRefTests() + { + var value = new { longitude = 10, latitude = 20, distance = 30 }; + + return BuildFlatTests("geoRef", x => x == "lt", value, $"Radius({value.longitude}, {value.latitude}, {value.distance})"); + } + + public static IEnumerable InvalidTests() + { + var value = new { longitude = 10, latitude = 20, distance = 30 }; + + return BuildInvalidOperatorTests("geo", x => x == "lt", value); + } + + [Theory] + [MemberData(nameof(ValidTests))] + public void Should_parse_filter(string field, string op, object value, string expected) + { + var json = new { path = field, op, value }; + + AssertFilter(json, expected); + } + + [Theory] + [MemberData(nameof(ValidRefTests))] + public void Should_parse_filter_with_reference(string field, string op, object value, string expected) + { + var json = new { path = field, op, value }; + + AssertFilter(json, expected); + } + + [Theory] + [MemberData(nameof(InvalidTests))] + public void Should_add_error_if_operator_is_invalid(string field, string op, object value, string expected) + { + var json = new { path = field, op, value }; + + AssertErrors(json, $"'{expected}' is not a valid operator for type Object(geo-json) at '{field}'."); + } + + [Fact] + public void Should_add_error_if_value_is_invalid() + { + var json = new { path = "geo", op = "lt", value = new { latitude = 10, longitude = 20 } }; + + AssertErrors(json, "Expected Object(geo-json) for path 'geo', but got Object."); + } + + [Fact] + public void Should_add_error_if_value_type_is_invalid() + { + var json = new { path = "geo", op = "lt", value = 1 }; + + AssertErrors(json, "Expected Object(geo-json) for path 'geo', but got Number."); + } + } + + public class Number + { + public static IEnumerable ValidTests() + { + const int value = 12; + + return BuildTests("number", x => x.Length == 2, value, $"{value}"); + } + + public static IEnumerable InvalidTests() + { + const int value = 12; + + return BuildInvalidOperatorTests("number", x => x.Length == 2, $"{value}"); + } + + public static IEnumerable ValidInTests() + { + const int value = 12; + + return BuildInTests("number", value, $"{value}"); + } + + [Theory] + [MemberData(nameof(ValidTests))] + public void Should_parse_filter(string field, string op, int value, string expected) + { + var json = new { path = field, op, value }; + + AssertFilter(json, expected); + } + + [Theory] + [MemberData(nameof(InvalidTests))] + public void Should_add_error_if_operator_is_invalid(string field, string op, int value, string expected) + { + var json = new { path = field, op, value }; + + AssertErrors(json, $"'{expected}' is not a valid operator for type Number at '{field}'."); + } + + [Theory] + [MemberData(nameof(ValidInTests))] + public void Should_parse_in_filter(string field, int value, string expected) + { + var json = new { path = field, op = "in", value = new[] { value } }; + + AssertFilter(json, expected); + } + + [Fact] + public void Should_add_error_if_value_type_is_invalid() + { + var json = new { path = "number", op = "eq", value = true }; + + AssertErrors(json, "Expected Number for path 'number', but got Boolean."); + } + } + + public class Boolean + { + public static IEnumerable ValidTests() + { + const bool value = true; + + return BuildTests("boolean", x => x == "eq" || x == "ne", value, $"{value}"); + } + + public static IEnumerable InvalidTests() + { + const bool value = true; + + return BuildInvalidOperatorTests("boolean", x => x == "eq" || x == "ne", value); + } + + public static IEnumerable ValidInTests() + { + const bool value = true; + + return BuildInTests("boolean", value, $"{value}"); + } + + [Theory] + [MemberData(nameof(ValidTests))] + public void Should_parse_filter(string field, string op, bool value, string expected) + { + var json = new { path = field, op, value }; + + AssertFilter(json, expected); + } + + [Theory] + [MemberData(nameof(InvalidTests))] + public void Should_add_error_if_operator_is_invalid(string field, string op, bool value, string expected) + { + var json = new { path = field, op, value }; + + AssertErrors(json, $"'{expected}' is not a valid operator for type Boolean at '{field}'."); + } + + [Theory] + [MemberData(nameof(ValidInTests))] + public void Should_parse_in_filter(string field, bool value, string expected) + { + var json = new { path = field, op = "in", value = new[] { value } }; + + AssertFilter(json, expected); + } + + [Fact] + public void Should_add_error_if_boolean_property_got_invalid_value() + { + var json = new { path = "boolean", op = "eq", value = 1 }; + + AssertErrors(json, "Expected Boolean for path 'boolean', but got Number."); + } + } + + public class Array + { + public static IEnumerable ValiedTests() + { + const string value = "Hello"; + + return BuildTests("stringArray", x => x == "eq" || x == "ne" || x == "empty", value, $"'{value}'"); + } + + public static IEnumerable ValidInTests() + { + const string value = "Hello"; + + return BuildInTests("stringArray", value, $"'{value}'"); + } + + [Theory] + [MemberData(nameof(ValiedTests))] + public void Should_parse_array_filter(string field, string op, string value, string expected) + { + var json = new { path = field, op, value }; + + AssertFilter(json, expected); + } + + [Theory] + [MemberData(nameof(ValidInTests))] + public void Should_parse_array_in_filter(string field, string value, string expected) + { + var json = new { path = field, op = "in", value = new[] { value } }; + + AssertFilter(json, expected); + } + + [Fact] + public void Should_add_error_when_using_array_value_for_non_allowed_operator() + { + var json = new { path = "string", op = "eq", value = new[] { "Hello" } }; + + AssertErrors(json, "Array value is not allowed for 'Equals' operator and path 'string'."); + } + } + + [Fact] + public void Should_add_error_if_property_does_not_exist() + { + var json = new { path = "notfound", op = "eq", value = 1 }; + + AssertErrors(json, "Path 'notfound' does not point to a valid property in the model."); + } + + [Fact] + public void Should_add_error_if_nested_property_does_not_exist() + { + var json = new { path = "object.notfound", op = "eq", value = 1 }; + + AssertErrors(json, "'notfound' is not a property of 'nested'."); + } + + [Fact] + public void Should_add_error_if_nested_reference_property_does_not_exist() + { + var json = new { path = "reference.notfound", op = "eq", value = 1 }; + + AssertErrors(json, "'notfound' is not a property of 'nested'."); + } + + [Fact] + public void Should_parse_query() + { + var json = new { skip = 10, take = 20, FullText = "Hello", Filter = new { path = "string", op = "eq", value = "Hello" } }; + + AssertQuery(json, "Filter: string == 'Hello'; FullText: 'Hello'; Skip: 10; Take: 20"); + } + + [Fact] + public void Should_parse_query_with_top() + { + var json = new { skip = 10, top = 20, FullText = "Hello", Filter = new { path = "string", op = "eq", value = "Hello" } }; + + AssertQuery(json, "Filter: string == 'Hello'; FullText: 'Hello'; Skip: 10; Take: 20"); + } + + [Fact] + public void Should_parse_query_with_sorting() + { + var json = new { sort = new[] { new { path = "string", order = "ascending" } } }; + + AssertQuery(json, "Sort: string Ascending"); + } + + [Fact] + public void Should_throw_exception_for_invalid_query() + { + var json = new { sort = new[] { new { path = "invalid", order = "ascending" } } }; + + Assert.Throws(() => AssertQuery(json, null)); + } + + [Fact] + public void Should_throw_exception_when_parsing_invalid_json() + { + var json = "invalid"; + + Assert.Throws(() => AssertQuery(json, null)); + } + + private static void AssertQuery(object json, string? expectedFilter) + { + var errors = new List(); + + var filter = ConvertQuery(json); + + Assert.Empty(errors); + Assert.Equal(expectedFilter, filter); + } + + private static void AssertFilter(object json, string? expectedFilter) + { + var errors = new List(); + + var filter = ConvertFilter(json, errors); + + Assert.Empty(errors); + Assert.Equal(expectedFilter, filter); + } + + private static void AssertErrors(object json, string expectedError) + { + var errors = new List(); + + var filter = ConvertFilter(json, errors); + + Assert.Equal(expectedError, errors.FirstOrDefault()); + Assert.Null(filter); + } + + private static string? ConvertFilter(T value, List errors) + { + var json = TestUtils.DefaultSerializer.Serialize(value, true); + + var jsonFilter = TestUtils.DefaultSerializer.Deserialize>(json); + + return JsonFilterVisitor.Parse(jsonFilter, Schema, errors)?.ToString(); + } + + private static string? ConvertQuery(T value) + { + var json = TestUtils.DefaultSerializer.Serialize(value, true); + + var jsonFilter = Schema.Parse(json, TestUtils.DefaultSerializer); + + return jsonFilter.ToString(); + } + + public static IEnumerable BuildFlatTests(string field, Predicate opFilter, object value, string valueString) + { + var fields = new[] + { + $"{field}" + }; + + foreach (var f in fields) + { + foreach (var (_, op, output) in AllOps.Where(x => opFilter(x.Operator))) + { + var expected = + output + .Replace("$FIELD", f) + .Replace("$VALUE", valueString); + + yield return new[] { f, op, value, expected }; + } + } + } + + public static IEnumerable BuildTests(string field, Predicate opFilter, object value, string valueString) + { + var fields = new[] + { + $"{field}", + $"json.{field}", + $"json.nested.{field}" + }; + + foreach (var f in fields) + { + foreach (var (_, op, output) in AllOps.Where(x => opFilter(x.Operator))) + { + var expected = + output + .Replace("$FIELD", f) + .Replace("$VALUE", valueString); + + yield return new[] { f, op, value, expected }; + } + } + } + + public static IEnumerable BuildInTests(string field, object value, string valueString) + { + var fields = new[] + { + $"{field}", + $"json.{field}", + $"json.nested.{field}" + }; + + foreach (var f in fields) + { + var expected = $"{f} in [{valueString}]"; + + yield return new[] { f, value, expected }; + } + } + + public static IEnumerable BuildInvalidOperatorTests(string field, Predicate opFilter, object value) + { + foreach (var (name, op, _) in AllOps.Where(x => !opFilter(x.Operator))) + { + yield return new[] { field, op, value, name }; + } + } + } +} diff --git a/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryODataConversionTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromODataTests.cs similarity index 95% rename from backend/tests/Squidex.Infrastructure.Tests/Queries/QueryODataConversionTests.cs rename to backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromODataTests.cs index a0683d19f..1fd6c1911 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryODataConversionTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromODataTests.cs @@ -14,11 +14,11 @@ using Xunit; namespace Squidex.Infrastructure.Queries { - public class QueryODataConversionTests + public class QueryFromODataTests { private static readonly IEdmModel EdmModel; - static QueryODataConversionTests() + static QueryFromODataTests() { var entityType = new EdmEntityType("Squidex", "Users"); @@ -37,6 +37,8 @@ namespace Squidex.Infrastructure.Queries entityType.AddStructuralProperty("incomeCentsNullable", EdmPrimitiveTypeKind.Int64, true); entityType.AddStructuralProperty("incomeMio", EdmPrimitiveTypeKind.Double, false); entityType.AddStructuralProperty("incomeMioNullable", EdmPrimitiveTypeKind.Double, true); + entityType.AddStructuralProperty("geo", EdmPrimitiveTypeKind.GeographyPoint, false); + entityType.AddStructuralProperty("geoNullable", EdmPrimitiveTypeKind.GeographyPoint, true); entityType.AddStructuralProperty("age", EdmPrimitiveTypeKind.Int32, false); entityType.AddStructuralProperty("ageNullable", EdmPrimitiveTypeKind.Int32, true); entityType.AddStructuralProperty("properties", new EdmComplexTypeReference(new EdmComplexType("Squidex", "Properties", null, false, true), true)); @@ -246,6 +248,19 @@ namespace Squidex.Infrastructure.Queries Assert.Equal(o, i); } + [Theory] + [InlineData("geo")] + [InlineData("geoNullable")] + [InlineData("properties/geo")] + [InlineData("properties/nested/geo")] + public void Should_parse_filter_when_type_is_geograph(string field) + { + var i = _Q($"$filter=geo.distance({field}, geography'POINT(10 20)') lt 30.0"); + var o = _C($"Filter: {field} < Radius(10, 20, 30)"); + + Assert.Equal(o, i); + } + [Fact] public void Should_parse_filter_when_type_is_double_list() { diff --git a/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryJsonConversionTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryJsonConversionTests.cs deleted file mode 100644 index cfd339b42..000000000 --- a/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryJsonConversionTests.cs +++ /dev/null @@ -1,489 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using System.Linq; -using NJsonSchema; -using Squidex.Infrastructure.Json.Objects; -using Squidex.Infrastructure.Queries.Json; -using Squidex.Infrastructure.TestHelpers; -using Squidex.Infrastructure.Validation; -using Xunit; - -namespace Squidex.Infrastructure.Queries -{ - public sealed class QueryJsonConversionTests - { - private static readonly (string Operator, string Output)[] AllOps = - { - ("contains", "contains($FIELD, $VALUE)"), - ("empty", "empty($FIELD)"), - ("endswith", "endsWith($FIELD, $VALUE)"), - ("eq", "$FIELD == $VALUE"), - ("ge", "$FIELD >= $VALUE"), - ("gt", "$FIELD > $VALUE"), - ("le", "$FIELD <= $VALUE"), - ("lt", "$FIELD < $VALUE"), - ("ne", "$FIELD != $VALUE"), - ("startswith", "startsWith($FIELD, $VALUE)") - }; - - private readonly List errors = new List(); - private readonly JsonSchema schema = new JsonSchema(); - - public QueryJsonConversionTests() - { - var nested = new JsonSchemaProperty { Title = "nested", Type = JsonObjectType.Object }; - - nested.Properties["property"] = new JsonSchemaProperty - { - Type = JsonObjectType.String - }; - - schema.Properties["boolean"] = new JsonSchemaProperty - { - Type = JsonObjectType.Boolean - }; - - schema.Properties["datetime"] = new JsonSchemaProperty - { - Type = JsonObjectType.String, Format = JsonFormatStrings.DateTime - }; - - schema.Properties["guid"] = new JsonSchemaProperty - { - Type = JsonObjectType.String, Format = JsonFormatStrings.Guid - }; - - schema.Properties["integer"] = new JsonSchemaProperty - { - Type = JsonObjectType.Integer - }; - - schema.Properties["number"] = new JsonSchemaProperty - { - Type = JsonObjectType.Number - }; - - schema.Properties["json"] = new JsonSchemaProperty - { - Type = JsonObjectType.None - }; - - schema.Properties["string"] = new JsonSchemaProperty - { - Type = JsonObjectType.String - }; - - schema.Properties["stringArray"] = new JsonSchemaProperty - { - Item = new JsonSchema - { - Type = JsonObjectType.String - }, - Type = JsonObjectType.Array - }; - - schema.Properties["object"] = nested; - - schema.Properties["reference"] = new JsonSchemaProperty - { - Reference = nested - }; - } - - [Fact] - public void Should_add_error_if_property_does_not_exist() - { - var json = new { path = "notfound", op = "eq", value = 1 }; - - AssertErrors(json, "Path 'notfound' does not point to a valid property in the model."); - } - - [Fact] - public void Should_add_error_if_nested_property_does_not_exist() - { - var json = new { path = "object.notfound", op = "eq", value = 1 }; - - AssertErrors(json, "'notfound' is not a property of 'nested'."); - } - - [Fact] - public void Should_add_error_if_nested_reference_property_does_not_exist() - { - var json = new { path = "reference.notfound", op = "eq", value = 1 }; - - AssertErrors(json, "'notfound' is not a property of 'nested'."); - } - - public static IEnumerable DateTimeTests() - { - const string value = "2012-11-10T09:08:07Z"; - - return BuildTests("datetime", x => true, value, value); - } - - [Theory] - [MemberData(nameof(DateTimeTests))] - public void Should_parse_datetime_string_filter(string field, string op, string value, string expected) - { - var json = new { path = field, op, value }; - - AssertFilter(json, expected); - } - - [Fact] - public void Should_add_error_if_datetime_string_property_got_invalid_string_value() - { - var json = new { path = "datetime", op = "eq", value = "invalid" }; - - AssertErrors(json, "Expected ISO8601 DateTime String for path 'datetime', but got invalid String."); - } - - [Fact] - public void Should_add_error_if_datetime_string_property_got_invalid_value() - { - var json = new { path = "datetime", op = "eq", value = 1 }; - - AssertErrors(json, "Expected ISO8601 DateTime String for path 'datetime', but got Number."); - } - - public static IEnumerable GuidTests() - { - const string value = "bf57d32c-d4dd-4217-8c16-6dcb16975cf3"; - - return BuildTests("guid", x => true, value, value); - } - - [Theory] - [MemberData(nameof(GuidTests))] - public void Should_parse_guid_string_filter(string field, string op, string value, string expected) - { - var json = new { path = field, op, value }; - - AssertFilter(json, expected); - } - - [Fact] - public void Should_add_error_if_guid_string_property_got_invalid_string_value() - { - var json = new { path = "guid", op = "eq", value = "invalid" }; - - AssertErrors(json, "Expected Guid String for path 'guid', but got invalid String."); - } - - [Fact] - public void Should_add_error_if_guid_string_property_got_invalid_value() - { - var json = new { path = "guid", op = "eq", value = 1 }; - - AssertErrors(json, "Expected Guid String for path 'guid', but got Number."); - } - - public static IEnumerable StringTests() - { - const string value = "Hello"; - - return BuildTests("string", x => true, value, $"'{value}'"); - } - - [Theory] - [MemberData(nameof(StringTests))] - public void Should_parse_string_filter(string field, string op, string value, string expected) - { - var json = new { path = field, op, value }; - - AssertFilter(json, expected); - } - - public static IEnumerable StringInTests() - { - const string value = "Hello"; - - return BuildInTests("string", value, $"'{value}'"); - } - - [Theory] - [MemberData(nameof(StringInTests))] - public void Should_parse_string_in_filter(string field, string value, string expected) - { - var json = new { path = field, op = "in", value = new[] { value } }; - - AssertFilter(json, expected); - } - - [Fact] - public void Should_add_error_if_string_property_got_invalid_value() - { - var json = new { path = "string", op = "eq", value = 1 }; - - AssertErrors(json, "Expected String for path 'string', but got Number."); - } - - [Fact] - public void Should_parse_nested_string_filter() - { - var json = new { path = "object.property", op = "in", value = new[] { "Hello" } }; - - AssertFilter(json, "object.property in ['Hello']"); - } - - [Fact] - public void Should_parse_referenced_string_filter() - { - var json = new { path = "reference.property", op = "in", value = new[] { "Hello" } }; - - AssertFilter(json, "reference.property in ['Hello']"); - } - - public static IEnumerable NumberTests() - { - const int value = 12; - - return BuildTests("number", x => x.Length == 2, value, $"{value}"); - } - - [Theory] - [MemberData(nameof(NumberTests))] - public void Should_parse_number_filter(string field, string op, int value, string expected) - { - var json = new { path = field, op, value }; - - AssertFilter(json, expected); - } - - public static IEnumerable NumberInTests() - { - const int value = 12; - - return BuildInTests("number", value, $"{value}"); - } - - [Theory] - [MemberData(nameof(NumberInTests))] - public void Should_parse_number_in_filter(string field, int value, string expected) - { - var json = new { path = field, op = "in", value = new[] { value } }; - - AssertFilter(json, expected); - } - - [Fact] - public void Should_add_error_if_number_property_got_invalid_value() - { - var json = new { path = "number", op = "eq", value = true }; - - AssertErrors(json, "Expected Number for path 'number', but got Boolean."); - } - - public static IEnumerable BooleanTests() - { - const bool value = true; - - return BuildTests("boolean", x => x == "eq" || x == "ne", value, $"{value}"); - } - - [Theory] - [MemberData(nameof(BooleanTests))] - public void Should_parse_boolean_filter(string field, string op, bool value, string expected) - { - var json = new { path = field, op, value }; - - AssertFilter(json, expected); - } - - public static IEnumerable BooleanInTests() - { - const bool value = true; - - return BuildInTests("boolean", value, $"{value}"); - } - - [Theory] - [MemberData(nameof(BooleanInTests))] - public void Should_parse_boolean_in_filter(string field, bool value, string expected) - { - var json = new { path = field, op = "in", value = new[] { value } }; - - AssertFilter(json, expected); - } - - [Fact] - public void Should_add_error_if_boolean_property_got_invalid_value() - { - var json = new { path = "boolean", op = "eq", value = 1 }; - - AssertErrors(json, "Expected Boolean for path 'boolean', but got Number."); - } - - public static IEnumerable ArrayTests() - { - const string value = "Hello"; - - return BuildTests("stringArray", x => x == "eq" || x == "ne" || x == "empty", value, $"'{value}'"); - } - - [Theory] - [MemberData(nameof(ArrayTests))] - public void Should_parse_array_filter(string field, string op, string value, string expected) - { - var json = new { path = field, op, value }; - - AssertFilter(json, expected); - } - - public static IEnumerable ArrayInTests() - { - const string value = "Hello"; - - return BuildInTests("stringArray", value, $"'{value}'"); - } - - [Theory] - [MemberData(nameof(ArrayInTests))] - public void Should_parse_array_in_filter(string field, string value, string expected) - { - var json = new { path = field, op = "in", value = new[] { value } }; - - AssertFilter(json, expected); - } - - [Fact] - public void Should_add_error_when_using_array_value_for_non_allowed_operator() - { - var json = new { path = "string", op = "eq", value = new[] { "Hello" } }; - - AssertErrors(json, "Array value is not allowed for 'Equals' operator and path 'string'."); - } - - [Fact] - public void Should_parse_query() - { - var json = new { skip = 10, take = 20, FullText = "Hello", Filter = new { path = "string", op = "eq", value = "Hello" } }; - - AssertQuery(json, "Filter: string == 'Hello'; FullText: 'Hello'; Skip: 10; Take: 20"); - } - - [Fact] - public void Should_parse_query_with_top() - { - var json = new { skip = 10, top = 20, FullText = "Hello", Filter = new { path = "string", op = "eq", value = "Hello" } }; - - AssertQuery(json, "Filter: string == 'Hello'; FullText: 'Hello'; Skip: 10; Take: 20"); - } - - [Fact] - public void Should_parse_query_with_sorting() - { - var json = new { sort = new[] { new { path = "string", order = "ascending" } } }; - - AssertQuery(json, "Sort: string Ascending"); - } - - [Fact] - public void Should_throw_exception_for_invalid_query() - { - var json = new { sort = new[] { new { path = "invalid", order = "ascending" } } }; - - Assert.Throws(() => AssertQuery(json, null)); - } - - [Fact] - public void Should_throw_exception_when_parsing_invalid_json() - { - var json = "invalid"; - - Assert.Throws(() => AssertQuery(json, null)); - } - - private void AssertQuery(object json, string? expectedFilter) - { - var filter = ConvertQuery(json); - - Assert.Empty(errors); - - Assert.Equal(expectedFilter, filter); - } - - private void AssertFilter(object json, string? expectedFilter) - { - var filter = ConvertFilter(json); - - Assert.Empty(errors); - - Assert.Equal(expectedFilter, filter); - } - - private void AssertErrors(object json, params string[] expectedErrors) - { - var filter = ConvertFilter(json); - - Assert.Equal(expectedErrors.ToList(), errors); - - Assert.Null(filter); - } - - private string? ConvertFilter(T value) - { - var json = JsonHelper.DefaultSerializer.Serialize(value, true); - - var jsonFilter = JsonHelper.DefaultSerializer.Deserialize>(json); - - return JsonFilterVisitor.Parse(jsonFilter, schema, errors)?.ToString(); - } - - private string? ConvertQuery(T value) - { - var json = JsonHelper.DefaultSerializer.Serialize(value, true); - - var jsonFilter = schema.Parse(json, JsonHelper.DefaultSerializer); - - return jsonFilter.ToString(); - } - - public static IEnumerable BuildInTests(string field, object value, string valueString) - { - var fields = new[] - { - $"{field}", - $"json.{field}", - $"json.nested.{field}" - }; - - foreach (var f in fields) - { - var expected = $"{f} in [{valueString}]"; - - yield return new[] { f, value, expected }; - } - } - - public static IEnumerable BuildTests(string field, Predicate opFilter, object value, string valueString) - { - var fields = new[] - { - $"{field}", - $"json.{field}", - $"json.nested.{field}" - }; - - foreach (var f in fields) - { - foreach (var op in AllOps.Where(x => opFilter(x.Operator))) - { - var expected = - op.Output - .Replace("$FIELD", f) - .Replace("$VALUE", valueString); - - yield return new[] { f, op.Operator, value, expected }; - } - } - } - } -} diff --git a/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryJsonTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryJsonTests.cs index 50173b4f2..15be28a3b 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryJsonTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryJsonTests.cs @@ -48,6 +48,16 @@ namespace Squidex.Infrastructure.Queries Assert.Equal("empty(property)", filter.ToString()); } + [Fact] + public void Should_convert_comparison_with_radius() + { + var json = new { path = "property", op = "lt", value = new { latitude = 10, longitude = 20 } }; + + var filter = SerializeAndDeserialize(json); + + Assert.Equal("property < {\"latitude\":10, \"longitude\":20}", filter.ToString()); + } + [Fact] public void Should_convert_comparison_in() { @@ -173,9 +183,9 @@ namespace Squidex.Infrastructure.Queries private static FilterNode SerializeAndDeserialize(T value) { - var json = JsonHelper.DefaultSerializer.Serialize(value, true); + var json = TestUtils.DefaultSerializer.Serialize(value, true); - return JsonHelper.DefaultSerializer.Deserialize>(json); + return TestUtils.DefaultSerializer.Deserialize>(json); } } } diff --git a/backend/tests/Squidex.Infrastructure.Tests/TestHelpers/JsonHelper.cs b/backend/tests/Squidex.Infrastructure.Tests/TestHelpers/TestUtils.cs similarity index 98% rename from backend/tests/Squidex.Infrastructure.Tests/TestHelpers/JsonHelper.cs rename to backend/tests/Squidex.Infrastructure.Tests/TestHelpers/TestUtils.cs index 371965abd..a3e4a09bd 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/TestHelpers/JsonHelper.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/TestHelpers/TestUtils.cs @@ -15,7 +15,7 @@ using Squidex.Infrastructure.Reflection; namespace Squidex.Infrastructure.TestHelpers { - public static class JsonHelper + public static class TestUtils { public static readonly IJsonSerializer DefaultSerializer = CreateSerializer(); diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs index c9f27f7c6..ae5f24bb2 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs @@ -16,15 +16,16 @@ using TestSuite.Fixtures; using TestSuite.Model; using Xunit; +#pragma warning disable SA1507 // Code should not contain multiple blank lines in a row #pragma warning disable SA1300 // Element should begin with upper-case letter namespace TestSuite.ApiTests { - public class ContentQueryTests : IClassFixture + public class ContentQueryTests : IClassFixture { - public ContentQueryFixture _ { get; } + public ContentQueryFixture1to10 _ { get; } - public ContentQueryTests(ContentQueryFixture fixture) + public ContentQueryTests(ContentQueryFixture1to10 fixture) { _ = fixture; } @@ -48,7 +49,10 @@ namespace TestSuite.ApiTests [Fact] public async Task Should_query_by_ids() { - var items = await _.Contents.GetAsync(new ContentQuery { OrderBy = "data/number/iv asc" }); + var items = await _.Contents.GetAsync(new ContentQuery + { + OrderBy = "data/number/iv asc" + }); var itemsById = await _.Contents.GetAsync(new HashSet(items.Items.Take(3).Select(x => x.Id))); @@ -65,7 +69,10 @@ namespace TestSuite.ApiTests [Fact] public async Task Should_return_all_with_odata() { - var items = await _.Contents.GetAsync(new ContentQuery { OrderBy = "data/number/iv asc" }); + var items = await _.Contents.GetAsync(new ContentQuery + { + OrderBy = "data/number/iv asc" + }); AssertItems(items, 10, new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }); } @@ -91,15 +98,18 @@ namespace TestSuite.ApiTests } [Fact] - public async Task Should_return_items_with_skip_with_odata() + public async Task Should_return_items_by_skip_with_odata() { - var items = await _.Contents.GetAsync(new ContentQuery { Skip = 5, OrderBy = "data/number/iv asc" }); + var items = await _.Contents.GetAsync(new ContentQuery + { + Skip = 5, OrderBy = "data/number/iv asc" + }); AssertItems(items, 10, new[] { 6, 7, 8, 9, 10 }); } [Fact] - public async Task Should_return_items_with_skip_with_json() + public async Task Should_return_items_by_skip_with_json() { var items = await _.Contents.GetAsync(new ContentQuery { @@ -120,15 +130,18 @@ namespace TestSuite.ApiTests } [Fact] - public async Task Should_return_items_with_skip_and_top_with_odata() + public async Task Should_return_items_by_skip_and_top_with_odata() { - var items = await _.Contents.GetAsync(new ContentQuery { Skip = 2, Top = 5, OrderBy = "data/number/iv asc" }); + var items = await _.Contents.GetAsync(new ContentQuery + { + Skip = 2, Top = 5, OrderBy = "data/number/iv asc" + }); AssertItems(items, 10, new[] { 3, 4, 5, 6, 7 }); } [Fact] - public async Task Should_return_items_with_skip_and_top_with_json() + public async Task Should_return_items_by_skip_and_top_with_json() { var items = await _.Contents.GetAsync(new ContentQuery { @@ -149,15 +162,18 @@ namespace TestSuite.ApiTests } [Fact] - public async Task Should_return_items_with_filter_with_odata() + public async Task Should_return_items_by_filter_with_odata() { - var items = await _.Contents.GetAsync(new ContentQuery { Filter = "data/number/iv gt 3 and data/number/iv lt 7", OrderBy = "data/number/iv asc" }); + var items = await _.Contents.GetAsync(new ContentQuery + { + Filter = "data/number/iv gt 3 and data/number/iv lt 7", OrderBy = "data/number/iv asc" + }); AssertItems(items, 3, new[] { 4, 5, 6 }); } [Fact] - public async Task Should_return_items_with_filter_with_json() + public async Task Should_return_items_by_filter_with_json() { var items = await _.Contents.GetAsync(new ContentQuery { @@ -194,6 +210,173 @@ namespace TestSuite.ApiTests AssertItems(items, 3, new[] { 4, 5, 6 }); } + [Fact] + public async Task Should_return_items_by_full_text_with_odata() + { + // Query multiple times to wait for async text indexer. + for (var i = 0; i < 10; i++) + { + await Task.Delay(500); + + var items = await _.Contents.GetAsync(new ContentQuery + { + Search = "1" + }); + + if (items.Items.Any()) + { + AssertItems(items, 1, new[] { 1 }); + return; + } + } + + Assert.False(true); + } + + [Fact] + public async Task Should_return_items_by_full_text_with_json() + { + // Query multiple times to wait for async text indexer. + for (var i = 0; i < 10; i++) + { + await Task.Delay(500); + + var items = await _.Contents.GetAsync(new ContentQuery + { + JsonQuery = new + { + fullText = "2" + } + }); + + if (items.Items.Any()) + { + AssertItems(items, 1, new[] { 2 }); + return; + } + } + + Assert.False(true); + } + + [Fact] + public async Task Should_return_items_by_near_location_with_odata() + { + // Query multiple times to wait for async text indexer. + for (var i = 0; i < 10; i++) + { + await Task.Delay(500); + + var items = await _.Contents.GetAsync(new ContentQuery + { + Filter = "geo.distance(data/geo/iv, geography'POINT(3 3)') lt 1000" + }); + + if (items.Items.Any()) + { + AssertItems(items, 1, new[] { 3 }); + return; + } + } + + Assert.False(true); + } + + [Fact] + public async Task Should_return_items_by_near_location_with_json() + { + // Query multiple times to wait for async text indexer. + for (var i = 0; i < 10; i++) + { + await Task.Delay(500); + + var items = await _.Contents.GetAsync(new ContentQuery + { + JsonQuery = new + { + filter = new + { + path = "data.geo.iv", + op = "lt", + value = new + { + longitude = 3, + latitude = 3, + distance = 1000 + } + } + } + }); + + if (items.Items.Any()) + { + AssertItems(items, 1, new[] { 3 }); + return; + } + } + + Assert.False(true); + } + + [Fact] + public async Task Should_return_items_by_near_geoson_location_with_odata() + { + // Query multiple times to wait for async text indexer. + for (var i = 0; i < 10; i++) + { + await Task.Delay(500); + + var items = await _.Contents.GetAsync(new ContentQuery + { + Filter = "geo.distance(data/geo/iv, geography'POINT(4 4)') lt 1000" + }); + + if (items.Items.Any()) + { + AssertItems(items, 1, new[] { 4 }); + return; + } + } + + Assert.False(true); + } + + [Fact] + public async Task Should_return_items_by_near_geoson_location_with_json() + { + // Query multiple times to wait for async text indexer. + for (var i = 0; i < 10; i++) + { + await Task.Delay(500); + + var items = await _.Contents.GetAsync(new ContentQuery + { + JsonQuery = new + { + filter = new + { + path = "data.geo.iv", + op = "lt", + value = new + { + longitude = 4, + latitude = 4, + distance = 1000 + } + } + } + }); + + if (items.Items.Any()) + { + AssertItems(items, 1, new[] { 4 }); + return; + } + } + + Assert.False(true); + } + [Fact] public async Task Should_create_and_query_with_inline_graphql() { diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/ContentReferencesTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/ContentReferencesTests.cs index cc185a90a..7739bc8fa 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/ContentReferencesTests.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/ContentReferencesTests.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Threading.Tasks; using TestSuite.Fixtures; using TestSuite.Model; diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs index 3947405d0..c5e767613 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs @@ -7,10 +7,8 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Threading.Tasks; using Squidex.ClientLibrary; -using Squidex.ClientLibrary.Management; using TestSuite.Fixtures; using TestSuite.Model; using Xunit; @@ -168,12 +166,12 @@ namespace TestSuite.ApiTests { new BulkUpdateJob { - Type = Squidex.ClientLibrary.BulkUpdateType.Upsert, + Type = BulkUpdateType.Upsert, Data = new { number = new { - iv = -99 + iv = TestEntity.ScriptTrigger } } } @@ -209,12 +207,12 @@ namespace TestSuite.ApiTests { new BulkUpdateJob { - Type = Squidex.ClientLibrary.BulkUpdateType.Upsert, + Type = BulkUpdateType.Upsert, Data = new { number = new { - iv = -99 + iv = TestEntity.ScriptTrigger } } } diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/GraphQLTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/GraphQLTests.cs index 1d87f2e1f..701f6383c 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/GraphQLTests.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/GraphQLTests.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Collections.Generic; using System.Threading.Tasks; using Newtonsoft.Json.Linq; diff --git a/backend/tools/TestSuite/TestSuite.LoadTests/ReadingFixture.cs b/backend/tools/TestSuite/TestSuite.LoadTests/ReadingFixture.cs index 2e97d0b52..afa2b2c0d 100644 --- a/backend/tools/TestSuite/TestSuite.LoadTests/ReadingFixture.cs +++ b/backend/tools/TestSuite/TestSuite.LoadTests/ReadingFixture.cs @@ -9,7 +9,7 @@ using TestSuite.Fixtures; namespace TestSuite.LoadTests { - public sealed class ReadingFixture : ContentQueryFixture + public sealed class ReadingFixture : ContentQueryFixture1to10 { public ReadingFixture() : base("benchmark-reading") diff --git a/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ContentQueryFixture.cs b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ContentQueryFixture1to10.cs similarity index 64% rename from backend/tools/TestSuite/TestSuite.Shared/Fixtures/ContentQueryFixture.cs rename to backend/tools/TestSuite/TestSuite.Shared/Fixtures/ContentQueryFixture1to10.cs index 1e3c9cd51..87c15a36e 100644 --- a/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ContentQueryFixture.cs +++ b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ContentQueryFixture1to10.cs @@ -11,14 +11,14 @@ using TestSuite.Model; namespace TestSuite.Fixtures { - public class ContentQueryFixture : ContentFixture + public class ContentQueryFixture1to10 : ContentFixture { - public ContentQueryFixture() + public ContentQueryFixture1to10() : this("my-reads") { } - protected ContentQueryFixture(string schemaName = "my-schema") + protected ContentQueryFixture1to10(string schemaName = "my-schema") : base(schemaName) { Task.Run(async () => @@ -27,7 +27,18 @@ namespace TestSuite.Fixtures for (var i = 10; i > 0; i--) { - await Contents.CreateAsync(new TestEntityData { Number = i }, true); + var data = new TestEntityData { Number = i, String = i.ToString() }; + + if (i % 2 == 0) + { + data.Geo = new { type = "Point", coordinates = new[] { i, i } }; + } + else + { + data.Geo = new { longitude = i, latitude = i }; + } + + await Contents.CreateAsync(data, true); } }).Wait(); } diff --git a/backend/tools/TestSuite/TestSuite.Shared/Model/TestEntity.cs b/backend/tools/TestSuite/TestSuite.Shared/Model/TestEntity.cs index 52ca23503..1a2391a4b 100644 --- a/backend/tools/TestSuite/TestSuite.Shared/Model/TestEntity.cs +++ b/backend/tools/TestSuite/TestSuite.Shared/Model/TestEntity.cs @@ -15,6 +15,8 @@ namespace TestSuite.Model { public sealed class TestEntity : Content { + public const int ScriptTrigger = -99; + public static async Task CreateSchemaAsync(ISchemasClient schemas, string appName, string name) { var schema = await schemas.PostSchemaAsync(appName, new CreateSchemaDto @@ -37,12 +39,20 @@ namespace TestSuite.Model { IsRequired = false } + }, + new UpsertSchemaFieldDto + { + Name = TestEntityData.GeoField, + Properties = new GeolocationFieldPropertiesDto + { + IsRequired = false + } } }, Scripts = new SchemaScriptsDto { Create = $@" - if (ctx.data.{TestEntityData.NumberField}.iv === -99) {{ + if (ctx.data.{TestEntityData.NumberField}.iv === {ScriptTrigger}) {{ ctx.data.{TestEntityData.NumberField}.iv = incrementCounter('my'); replace(); }}" @@ -60,10 +70,15 @@ namespace TestSuite.Model public static readonly string NumberField = nameof(Number).ToLowerInvariant(); + public static readonly string GeoField = nameof(Geo).ToLowerInvariant(); + [JsonConverter(typeof(InvariantConverter))] public int Number { get; set; } [JsonConverter(typeof(InvariantConverter))] public string String { get; set; } + + [JsonConverter(typeof(InvariantConverter))] + public object Geo { get; set; } } } diff --git a/backend/tools/TestSuite/TestSuite.Shared/Model/TestEntityWithReferences.cs b/backend/tools/TestSuite/TestSuite.Shared/Model/TestEntityWithReferences.cs index e442e6520..27727ebba 100644 --- a/backend/tools/TestSuite/TestSuite.Shared/Model/TestEntityWithReferences.cs +++ b/backend/tools/TestSuite/TestSuite.Shared/Model/TestEntityWithReferences.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Collections.Generic; using System.Threading.Tasks; using Newtonsoft.Json;