Browse Source

Feature/geo queries (#621)

* Support for geo queries.

* More tests

* Last fixes.

* Serialization fixes.

* Lat/lon fixes.

* Tests fixed
pull/624/head
Sebastian Stehle 5 years ago
committed by GitHub
parent
commit
41e7fc5f13
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/StringFormatter.cs
  2. 14
      backend/src/Squidex.Domain.Apps.Core.Operations/GenerateEdmSchema/EdmTypeVisitor.cs
  3. 3
      backend/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs
  4. 16
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs
  5. 9
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FirstPascalPathConverter.cs
  6. 9
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs
  7. 11
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs
  8. 9
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Adapt.cs
  9. 80
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/AdaptionVisitor.cs
  10. 52
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs
  11. 107
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/CommandFactory.cs
  12. 82
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndex.cs
  13. 15
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexEntity.cs
  14. 5
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/FullText/MongoTextIndexEntityText.cs
  15. 43
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryParser.cs
  16. 31
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/FilterTagTransformer.cs
  17. 8
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsSearchSource.cs
  18. 89
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs
  19. 71
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/FilterTagTransformer.cs
  20. 70
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/GeoQueryTransformer.cs
  21. 87
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/CommandFactory.cs
  22. 90
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Elastic/ElasticSearchTextIndex.cs
  23. 72
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Extensions.cs
  24. 17
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/GeoQuery.cs
  25. 4
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/ITextIndex.cs
  26. 45
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/SearchFilter.cs
  27. 22
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs
  28. 30
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextSearch.cs
  29. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/UpdateIndexEntry.cs
  30. 7
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/UpsertIndexEntry.cs
  31. 16
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/WriteonlyGeoJsonConverter.cs
  32. 16
      backend/src/Squidex.Infrastructure.MongoDb/MongoDb/Queries/FilterVisitor.cs
  33. 14
      backend/src/Squidex.Infrastructure/Json/GeoJson.cs
  34. 2
      backend/src/Squidex.Infrastructure/Json/IJsonSerializer.cs
  35. 4
      backend/src/Squidex.Infrastructure/Json/Newtonsoft/NewtonsoftJsonSerializer.cs
  36. 16
      backend/src/Squidex.Infrastructure/Json/Newtonsoft/WriteonlyGeoJsonConverter.cs
  37. 16
      backend/src/Squidex.Infrastructure/Json/Objects/JsonObject.cs
  38. 12
      backend/src/Squidex.Infrastructure/Queries/AsyncTransformVisitor.cs
  39. 17
      backend/src/Squidex.Infrastructure/Queries/ClrValue.cs
  40. 1
      backend/src/Squidex.Infrastructure/Queries/ClrValueType.cs
  41. 27
      backend/src/Squidex.Infrastructure/Queries/CompareFilter.cs
  42. 4
      backend/src/Squidex.Infrastructure/Queries/FilterNode.cs
  43. 8
      backend/src/Squidex.Infrastructure/Queries/FilterNodeVisitor.cs
  44. 19
      backend/src/Squidex.Infrastructure/Queries/FilterSphere.cs
  45. 53
      backend/src/Squidex.Infrastructure/Queries/Json/JsonFilterVisitor.cs
  46. 8
      backend/src/Squidex.Infrastructure/Queries/Json/OperatorValidator.cs
  47. 40
      backend/src/Squidex.Infrastructure/Queries/Json/ValueConverter.cs
  48. 22
      backend/src/Squidex.Infrastructure/Queries/LogicalFilter.cs
  49. 17
      backend/src/Squidex.Infrastructure/Queries/NegateFilter.cs
  50. 7
      backend/src/Squidex.Infrastructure/Queries/OData/EdmModelExtensions.cs
  51. 22
      backend/src/Squidex.Infrastructure/Queries/OData/FilterVisitor.cs
  52. 23
      backend/src/Squidex.Infrastructure/Queries/Optimizer.cs
  53. 6
      backend/src/Squidex.Infrastructure/Queries/PascalCasePathConverter.cs
  54. 12
      backend/src/Squidex.Infrastructure/Queries/TransformVisitor.cs
  55. 1
      backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  56. 1
      backend/src/Squidex/Areas/IdentityServer/Controllers/Extensions.cs
  57. 5
      backend/src/Squidex/Config/Domain/SerializationServices.cs
  58. 5
      backend/tests/Squidex.Domain.Apps.Core.Tests/TestHelpers/TestUtils.cs
  59. 1
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryFixture.cs
  60. 35
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryParserTests.cs
  61. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs
  62. 62
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/FilterTagTransformerTests.cs
  63. 8
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentsSearchSourceTests.cs
  64. 12
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs
  65. 13
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/MongoDbQueryTests.cs
  66. 143
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryParserTests.cs
  67. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs
  68. 104
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/FilterTagTransformerTests.cs
  69. 244
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTestsBase.cs
  70. 14
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests_Elastic.cs
  71. 16
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests_Mongo.cs
  72. 4
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/CosmosDbEventStoreFixture.cs
  73. 2
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/DefaultEventDataFormatterTests.cs
  74. 2
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/GetEventStoreFixture.cs
  75. 2
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/MongoEventStoreFixture.cs
  76. 2
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/MongoParallelInsertTests.cs
  77. 2
      backend/tests/Squidex.Infrastructure.Tests/Json/Objects/JsonValuesSerializationTests.cs
  78. 6
      backend/tests/Squidex.Infrastructure.Tests/NamedIdTests.cs
  79. 2
      backend/tests/Squidex.Infrastructure.Tests/Orleans/JsonExternalSerializerTests.cs
  80. 398
      backend/tests/Squidex.Infrastructure.Tests/Queries/JsonQueryConversionTests.cs
  81. 651
      backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromJsonTests.cs
  82. 19
      backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromODataTests.cs
  83. 489
      backend/tests/Squidex.Infrastructure.Tests/Queries/QueryJsonConversionTests.cs
  84. 14
      backend/tests/Squidex.Infrastructure.Tests/Queries/QueryJsonTests.cs
  85. 2
      backend/tests/Squidex.Infrastructure.Tests/TestHelpers/TestUtils.cs
  86. 211
      backend/tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs
  87. 1
      backend/tools/TestSuite/TestSuite.ApiTests/ContentReferencesTests.cs
  88. 10
      backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs
  89. 1
      backend/tools/TestSuite/TestSuite.ApiTests/GraphQLTests.cs
  90. 2
      backend/tools/TestSuite/TestSuite.LoadTests/ReadingFixture.cs
  91. 19
      backend/tools/TestSuite/TestSuite.Shared/Fixtures/ContentQueryFixture1to10.cs
  92. 17
      backend/tools/TestSuite/TestSuite.Shared/Model/TestEntity.cs
  93. 1
      backend/tools/TestSuite/TestSuite.Shared/Model/TestEntityWithReferences.cs

4
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<JsonNumber>("latitude", out var lat) &&
jsonObject.TryGetValue<JsonNumber>("longitude", out var lon))
{
return $"{lat}, {lon}";
}

14
backend/src/Squidex.Domain.Apps.Core.Operations/GenerateEdmSchema/EdmTypeVisitor.cs

@ -79,12 +79,12 @@ namespace Squidex.Domain.Apps.Core.GenerateEdmSchema
public IEdmTypeReference? Visit(IField<GeolocationFieldProperties> field, Args args)
{
return null;
return CreateGeographyPoint(field);
}
public IEdmTypeReference? Visit(IField<JsonFieldProperties> field, Args args)
{
return new EdmComplexTypeReference(JsonType, !field.RawProperties.IsRequired);
return CreateJson(field);
}
public IEdmTypeReference? Visit(IField<NumberFieldProperties> 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<GeolocationFieldProperties> field)
{
return EdmCoreModel.Instance.GetSpatial(EdmPrimitiveTypeKind.GeographyPoint, !field.RawProperties.IsRequired);
}
private static IEdmTypeReference CreateJson(IField<JsonFieldProperties> field)
{
return new EdmComplexTypeReference(JsonType, !field.RawProperties.IsRequired);
}
}
}

3
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,

16
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<IGeoJSONObject>(stream);
var geoJson = args.JsonSerializer.Deserialize<GeoJSONObject>(stream);
return (geoJson, null);
}
}
catch
{
if (geoObject.TryGetValue("latitude", out var latValue) && latValue is JsonNumber latNumber)
if (geoObject.TryGetValue<JsonNumber>("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<JsonNumber>("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);
}

9
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<TValue> : TransformVisitor<TValue>
public sealed class FirstPascalPathConverter<TValue> : TransformVisitor<TValue, None>
{
private static readonly FirstPascalPathConverter<TValue> Instance = new FirstPascalPathConverter<TValue>();
@ -19,12 +20,12 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors
public static FilterNode<TValue>? Transform(FilterNode<TValue> node)
{
return node.Accept(Instance);
return node.Accept(Instance, None.Value);
}
public override FilterNode<TValue>? Visit(CompareFilter<TValue> nodeIn)
public override FilterNode<TValue>? Visit(CompareFilter<TValue> nodeIn, None args)
{
return new CompareFilter<TValue>(nodeIn.Path.ToFirstPascalCase(), nodeIn.Operator, nodeIn.Value);
return nodeIn with { Path = nodeIn.Path.ToFirstPascalCase() };
}
}
}

9
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<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, Q q, SearchScope scope)
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, Q q)
{
using (Profiler.TraceMethod<MongoContentRepository>())
{
@ -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<IContentEntity>(0);

11
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);
}
}

9
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<ClrValue>? AdjustToModel(this FilterNode<ClrValue> filterNode, Schema schema)
public static FilterNode<ClrValue>? AdjustToModel(this FilterNode<ClrValue> filter, DomainId appId, Schema schema)
{
var pathConverter = Path(schema);
return filterNode.Accept(new AdaptionVisitor(pathConverter));
return AdaptionVisitor.Adapt(filter, pathConverter, appId);
}
}
}

80
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<ClrValue>
internal sealed class AdaptionVisitor : TransformVisitor<ClrValue, AdaptionVisitor.Args>
{
private readonly Func<PropertyPath, PropertyPath> pathConverter;
private static readonly AdaptionVisitor Instance = new AdaptionVisitor();
public AdaptionVisitor(Func<PropertyPath, PropertyPath> pathConverter)
public struct Args
{
this.pathConverter = pathConverter;
public readonly Func<PropertyPath, PropertyPath> PathConverter;
public readonly DomainId AppId;
public Args(Func<PropertyPath, PropertyPath> pathConverter, DomainId appId)
{
PathConverter = pathConverter;
AppId = appId;
}
}
public override FilterNode<ClrValue> Visit(CompareFilter<ClrValue> nodeIn)
private AdaptionVisitor()
{
CompareFilter<ClrValue> result;
}
var path = pathConverter(nodeIn.Path);
public static FilterNode<ClrValue>? Adapt(FilterNode<ClrValue> filter, Func<PropertyPath, PropertyPath> 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<ClrValue> Visit(CompareFilter<ClrValue> nodeIn, Args args)
{
result = new CompareFilter<ClrValue>(path, nodeIn.Operator, value.ToString());
var result = nodeIn;
var (path, op, value) = nodeIn;
var clrValue = value.Value;
if (string.Equals(path[0], "id", StringComparison.OrdinalIgnoreCase))
{
path = "_id";
if (clrValue is List<string> idList)
{
value = idList.Select(x => DomainId.Combine(args.AppId, DomainId.Create(x)).ToString()).ToList();
}
else
else if (clrValue is string id)
{
value = DomainId.Combine(args.AppId, DomainId.Create(id)).ToString();
}
else if (clrValue is List<Guid> guidIdList)
{
result = new CompareFilter<ClrValue>(path, nodeIn.Operator, nodeIn.Value);
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
{
path = args.PathConverter(path);
if (value is List<Guid> guidList)
if (clrValue is List<Guid> guidList)
{
result = new CompareFilter<ClrValue>(path, nodeIn.Operator, guidList.Select(x => x.ToString()).ToList());
value = guidList.Select(x => x.ToString()).ToList();
}
else if (value is Guid guid)
else if (clrValue is Guid guid)
{
result = new CompareFilter<ClrValue>(path, nodeIn.Operator, guid.ToString());
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 };
}
}
}

52
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<DomainId>? 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<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, Q q, SearchScope scope)
public async Task<IResultList<IContentEntity>> 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<DomainId>? 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<IContentEntity>(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<MongoContentEntity> CreateFilter(DomainId appId, IEnumerable<DomainId> schemaIds, ICollection<DomainId>? ids, ClrQuery? query, DomainId referenced)
private static FilterDefinition<MongoContentEntity> CreateFilter(DomainId appId, IEnumerable<DomainId> schemaIds, ClrQuery? query, DomainId referenced)
{
var filters = new List<FilterDefinition<MongoContentEntity>>
{
@ -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<MongoContentEntity>());

107
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<MongoTextIndexEntity> Filter = Builders<MongoTextIndexEntity>.Filter;
private static readonly UpdateDefinitionBuilder<MongoTextIndexEntity> Update = Builders<MongoTextIndexEntity>.Update;
public static void CreateCommands(IndexCommand command, List<WriteModel<MongoTextIndexEntity>> 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<WriteModel<MongoTextIndexEntity>> writes)
{
writes.Add(
new UpdateOneModel<MongoTextIndexEntity>(
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<MongoTextIndexEntity>(
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<MongoTextIndexEntity>(
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<WriteModel<MongoTextIndexEntity>> writes)
{
writes.Add(
new UpdateOneModel<MongoTextIndexEntity>(
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<WriteModel<MongoTextIndexEntity>> writes)
{
writes.Add(
new DeleteOneModel<MongoTextIndexEntity>(
Filter.Eq(x => x.DocId, delete.DocId)));
}
}
}

82
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<MongoTextIndexEntity>, ITextIndex
{
private const int Limit = 2000;
private const int LimitHalf = 1000;
private static readonly List<DomainId> EmptyResults = new List<DomainId>();
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<MongoTextIndexEntity>(
Index.Ascending(x => x.DocId)),
new CreateIndexModel<MongoTextIndexEntity>(
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<MongoTextIndexEntity>(
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<MongoTextIndexEntity>(
Filter.Eq(x => x.DocId, command.DocId)));
break;
case UpdateIndexEntry update:
writes.Add(
new UpdateOneModel<MongoTextIndexEntity>(
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<MongoTextIndexEntity>(
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<List<DomainId>?> SearchAsync(string? queryText, IAppEntity app, SearchFilter? filter, SearchScope scope)
public async Task<List<DomainId>?> 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<MongoTextIndexEntity>(x => nameof(x.ContentId));
return byGeo.Select(x => DomainId.Create(x[field].AsString)).Distinct().ToList();
}
public async Task<List<DomainId>?> 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<List<DomainId>> SearchBySchemaAsync(string queryText, IAppEntity app, SearchFilter filter, SearchScope scope, int limit)
private async Task<List<DomainId>> SearchBySchemaAsync(string queryText, IAppEntity app, TextFilter filter, SearchScope scope, int limit)
{
var bySchema =
await Collection.Find(

15
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<MongoTextIndexEntityText> Texts { get; set; }
[BsonIgnoreIfNull]
[BsonElement("gf")]
public string GeoField { get; set; }
[BsonIgnoreIfNull]
[BsonElement("go")]
[BsonJson]
public GeoJSONObject GeoObject { get; set; }
}
}

5
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 };
}
}
}

43
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryParser.cs

@ -46,12 +46,25 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
this.options = options.Value;
}
public virtual async ValueTask<Q> ParseQueryAsync(Context context, Q q)
public virtual async Task<Q> ParseQueryAsync(Context context, Q q)
{
Guard.NotNull(context, nameof(context));
Guard.NotNull(q, nameof(q));
using (Profiler.TraceMethod<AssetQueryParser>())
{
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;
@ -64,11 +77,23 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
query = ParseOData(q.ODataQuery);
}
if (query.Filter != null)
return query;
}
private void WithPaging(ClrQuery query)
{
query.Filter = await FilterTagTransformer.TransformAsync(query.Filter, context.App.Id, tagService);
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<string> { "lastModified" }, SortOrder.Descending));
@ -78,17 +103,13 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
{
query.Sort.Add(new SortNode(new List<string> { "id" }, SortOrder.Ascending));
}
}
if (query.Take == long.MaxValue)
private async Task TransformTagAsync(Context context, ClrQuery query)
{
query.Take = options.DefaultPageSize;
}
else if (query.Take > options.MaxResults)
if (query.Filter != null)
{
query.Take = options.MaxResults;
}
return q!.WithQuery(query);
query.Filter = await FilterTagTransformer.TransformAsync(query.Filter, context.App.Id, tagService);
}
}

31
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<ClrValue>
internal sealed class FilterTagTransformer : AsyncTransformVisitor<ClrValue, FilterTagTransformer.Args>
{
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<FilterNode<ClrValue>?> TransformAsync(FilterNode<ClrValue> 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<FilterNode<ClrValue>?> Visit(CompareFilter<ClrValue> nodeIn)
public override async ValueTask<FilterNode<ClrValue>?> Visit(CompareFilter<ClrValue> 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))
{

8
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<SearchFilter?> CreateSearchFilterAsync(Context context)
private async Task<TextFilter?> CreateSearchFilterAsync(Context context)
{
var allowedSchemas = new List<DomainId>();
@ -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)

89
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,25 +40,80 @@ 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<ContentOptions> options)
public ContentQueryParser(IMemoryCache cache, IJsonSerializer jsonSerializer, ITextIndex textIndex, IOptions<ContentOptions> 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<Q> ParseAsync(Context context, Q q, ISchemaEntity? schema = null)
public virtual async Task<Q> ParseAsync(Context context, Q q, ISchemaEntity? schema = null)
{
Guard.NotNull(context, nameof(context));
Guard.NotNull(q, nameof(q));
using (Profiler.TraceMethod<ContentQueryParser>())
{
var query = ParseQuery(context, q, schema);
await TransformFilterAsync(query, context, schema);
WithSorting(query);
WithPaging(query);
q = q!.WithQuery(query);
return q;
}
}
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)
{
throw new InvalidOperationException();
}
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)
{
fullTextFilter = ClrFilter.In("id", fullTextIds.Select(x => x.ToString()).ToList());
}
if (query.Filter != null)
{
query.Filter = ClrFilter.And(query.Filter, fullTextFilter);
}
else
{
query.Filter = fullTextFilter;
}
query.FullText = null;
}
}
private ClrQuery ParseQuery(Context context, Q q, ISchemaEntity? schema)
{
var query = q.Query;
@ -74,6 +130,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
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<string> { "lastModified" }, SortOrder.Descending));
@ -83,7 +144,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
{
query.Sort.Add(new SortNode(new List<string> { "id" }, SortOrder.Ascending));
}
}
private void WithPaging(ClrQuery query)
{
if (query.Take == long.MaxValue)
{
query.Take = options.DefaultPageSize;
@ -92,11 +156,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
{
query.Take = options.MaxResults;
}
q = q!.WithQuery(query);
return new ValueTask<Q>(q);
}
}
private ClrQuery ParseJson(Context context, ISchemaEntity? schema, Query<IJsonValue> query)
@ -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
};

71
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/FilterTagTransformer.cs

@ -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<ClrValue>
{
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<FilterNode<ClrValue>?> TransformAsync(FilterNode<ClrValue> 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<FilterNode<ClrValue>?> Visit(CompareFilter<ClrValue> 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<ClrValue>(nodeIn.Path, nodeIn.Operator, normalized);
}
}
return nodeIn;
}
private bool IsTagField(IReadOnlyList<string> path)
{
return schema.SchemaDef.FieldsByName.TryGetValue(path[1], out var field) && IsTagField(field);
}
private static bool IsTagField(IField field)
{
return field is IField<TagsFieldProperties> tags && tags.Properties.Normalization == TagsFieldNormalization.Schema;
}
private static bool IsDataPath(IReadOnlyList<string> path)
{
return path.Count == 3 && string.Equals(path[0], nameof(IContentEntity.Data), StringComparison.OrdinalIgnoreCase);
}
}
}

70
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<ClrValue, GeoQueryTransformer.Args>
{
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<FilterNode<ClrValue>?> TransformAsync(FilterNode<ClrValue> filter, Context context, ISchemaEntity schema, ITextIndex textIndex)
{
var args = new Args(context, schema, textIndex);
return await filter.Accept(Instance, args);
}
public override async ValueTask<FilterNode<ClrValue>?> Visit(CompareFilter<ClrValue> 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;
}
}
}

87
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<object> 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<object> 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<object> 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<object> args, string indexName)
{
args.Add(new
{
delete = new
{
_id = delete.DocId,
_index = indexName,
}
});
}
}
}

90
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<object> args)
{
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 void Update(UpdateIndexEntry update, List<object> args)
{
args.Add(new
{
update = new
public Task<List<DomainId>?> SearchAsync(IAppEntity app, GeoQuery query, SearchScope scope)
{
_id = update.DocId,
_index = indexName,
return Task.FromResult<List<DomainId>?>(null);
}
});
args.Add(new
public async Task<List<DomainId>?> SearchAsync(IAppEntity app, TextQuery query, SearchScope scope)
{
doc = new
{
serveAll = update.ServeAll,
servePublished = update.ServePublished
}
});
}
Guard.NotNull(app, nameof(app));
Guard.NotNull(query, nameof(query));
private void Delete(DeleteIndexEntry delete, List<object> args)
{
args.Add(new
{
delete = new
{
_id = delete.DocId,
_index = indexName,
}
});
}
var queryText = query.Text;
public async Task<List<DomainId>?> SearchAsync(string? queryText, IAppEntity app, SearchFilter? filter, SearchScope scope)
{
if (string.IsNullOrWhiteSpace(queryText))
{
return new List<DomainId>();
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<string, object>
{
["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<DynamicResponse>(indexName, CreatePost(query));
var result = await client.SearchAsync<DynamicResponse>(indexName, CreatePost(elasticQuery));
if (!result.Success)
{

72
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<StringBuilder> StringBuilderPool =
new DefaultObjectPool<StringBuilder>(new StringBuilderPooledObjectPolicy());
public static Dictionary<string, string> ToTexts(this NamedContentData data)
private static readonly ObjectPool<MemoryStream> MemoryStreamPool =
new DefaultObjectPool<MemoryStream>(new DefaultPooledObjectPolicy<MemoryStream>());
public static Dictionary<string, GeoJSONObject>? ToGeo(this NamedContentData data, IJsonSerializer jsonSerializer)
{
var result = new Dictionary<string, string>();
Dictionary<string, GeoJSONObject>? 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<string, GeoJSONObject>();
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<GeoJSONObject>(stream, null, true);
}
catch
{
if (geoObject.TryGetValue<JsonNumber>("latitude", out var lat) &&
geoObject.TryGetValue<JsonNumber>("longitude", out var lon))
{
return new Point(new Position(lat.Value, lon.Value));
}
}
finally
{
MemoryStreamPool.Return(stream);
}
}
return null;
}
public static Dictionary<string, string>? ToTexts(this NamedContentData data)
{
Dictionary<string, string>? result = null;
if (data != null)
{
@ -40,9 +104,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
foreach (var (key, sb) in languages)
{
if (sb.Length > 0)
{
result ??= new Dictionary<string, string>();
result[key] = sb.ToString();
}
}
}
finally
{
foreach (var (_, sb) in languages)

17
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)
{
}
}

4
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<List<DomainId>?> SearchAsync(string? queryText, IAppEntity app, SearchFilter? filter, SearchScope scope);
Task<List<DomainId>?> SearchAsync(IAppEntity app, TextQuery query, SearchScope scope);
Task<List<DomainId>?> SearchAsync(IAppEntity app, GeoQuery query, SearchScope scope);
Task ClearAsync();

45
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/SearchFilter.cs

@ -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<DomainId> SchemaIds { get; }
public bool Must { get; }
public SearchFilter(IReadOnlyList<DomainId> schemaIds, bool must)
{
Guard.NotNull(schemaIds, nameof(schemaIds));
SchemaIds = schemaIds;
Must = must;
}
public static SearchFilter MustHaveSchemas(List<DomainId> 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);
}
}
}

22
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<DomainId, TextContentState> states;
private readonly IJsonSerializer jsonSerializer;
private readonly Dictionary<DomainId, TextContentState> updates = new Dictionary<DomainId, TextContentState>();
private readonly Dictionary<string, IndexCommand> commands = new Dictionary<string, IndexCommand>();
public Updates(Dictionary<DomainId, TextContentState> states)
public Updates(Dictionary<DomainId, TextContentState> 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)
{

30
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);
}
}
}

2
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; }

7
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<string, string> Texts { get; set; }
public Dictionary<string, GeoJSONObject>? GeoObjects { get; set; }
public Dictionary<string, string>? Texts { get; set; }
public bool ServeAll { get; set; }
public bool ServePublished { get; set; }
public DomainId ContentId { get; set; }
public bool IsNew { get; set; }
}
}

16
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;
}
}

16
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<T> : FilterNodeVisitor<FilterDefinition<T>, ClrValue>
public sealed class FilterVisitor<T> : FilterNodeVisitor<FilterDefinition<T>, ClrValue, None>
{
private static readonly FilterDefinitionBuilder<T> Filter = Builders<T>.Filter;
private static readonly FilterVisitor<T> Instance = new FilterVisitor<T>();
@ -25,27 +25,27 @@ namespace Squidex.Infrastructure.MongoDb.Queries
public static FilterDefinition<T> Visit(FilterNode<ClrValue> node)
{
return node.Accept(Instance);
return node.Accept(Instance, None.Value);
}
public override FilterDefinition<T> Visit(NegateFilter<ClrValue> nodeIn)
public override FilterDefinition<T> Visit(NegateFilter<ClrValue> nodeIn, None args)
{
return Filter.Not(nodeIn.Filter.Accept(this));
return Filter.Not(nodeIn.Filter.Accept(this, args));
}
public override FilterDefinition<T> Visit(LogicalFilter<ClrValue> nodeIn)
public override FilterDefinition<T> Visit(LogicalFilter<ClrValue> 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<T> Visit(CompareFilter<ClrValue> nodeIn)
public override FilterDefinition<T> Visit(CompareFilter<ClrValue> nodeIn, None args)
{
var propertyName = nodeIn.Path.ToString();

14
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";
}
}

2
backend/src/Squidex.Infrastructure/Json/IJsonSerializer.cs

@ -18,6 +18,6 @@ namespace Squidex.Infrastructure.Json
T Deserialize<T>(string value, Type? actualType = null);
T Deserialize<T>(Stream stream, Type? actualType = null);
T Deserialize<T>(Stream stream, Type? actualType = null, bool leaveOpen = false);
}
}

4
backend/src/Squidex.Infrastructure/Json/Newtonsoft/NewtonsoftJsonSerializer.cs

@ -53,9 +53,9 @@ namespace Squidex.Infrastructure.Json.Newtonsoft
}
}
public T Deserialize<T>(Stream stream, Type? actualType = null)
public T Deserialize<T>(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);

16
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;
}
}

16
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<T>(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<KeyValuePair<string, IJsonValue>> GetEnumerator()
{
return inner.GetEnumerator();

12
backend/src/Squidex.Infrastructure/Queries/AsyncTransformVisitor.cs

@ -10,20 +10,20 @@ using System.Threading.Tasks;
namespace Squidex.Infrastructure.Queries
{
public abstract class AsyncTransformVisitor<TValue> : FilterNodeVisitor<ValueTask<FilterNode<TValue>?>, TValue>
public abstract class AsyncTransformVisitor<TValue, TArgs> : FilterNodeVisitor<ValueTask<FilterNode<TValue>?>, TValue, TArgs>
{
public override ValueTask<FilterNode<TValue>?> Visit(CompareFilter<TValue> nodeIn)
public override ValueTask<FilterNode<TValue>?> Visit(CompareFilter<TValue> nodeIn, TArgs args)
{
return new ValueTask<FilterNode<TValue>?>(nodeIn);
}
public override async ValueTask<FilterNode<TValue>?> Visit(LogicalFilter<TValue> nodeIn)
public override async ValueTask<FilterNode<TValue>?> Visit(LogicalFilter<TValue> nodeIn, TArgs args)
{
var pruned = new List<FilterNode<TValue>>(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<TValue>(nodeIn.Type, pruned);
}
public override async ValueTask<FilterNode<TValue>?> Visit(NegateFilter<TValue> nodeIn)
public override async ValueTask<FilterNode<TValue>?> Visit(NegateFilter<TValue> nodeIn, TArgs args)
{
var inner = await nodeIn.Filter.Accept(this);
var inner = await nodeIn.Filter.Accept(this, args);
if (inner == null)
{

17
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<object?, string> 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)

1
backend/src/Squidex.Infrastructure/Queries/ClrValueType.cs

@ -17,6 +17,7 @@ namespace Squidex.Infrastructure.Queries
Int32,
Int64,
Single,
Sphere,
String,
Null
}

27
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<TValue> : FilterNode<TValue>
{
public PropertyPath Path { get; }
public CompareOperator Operator { get; }
public TValue Value { get; }
public CompareFilter(PropertyPath path, CompareOperator @operator, TValue value)
public sealed record CompareFilter<TValue>(PropertyPath Path, CompareOperator Operator, TValue Value) : FilterNode<TValue>
{
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<string> fields)
{
fields.Add(Path.ToString());
}
public override T Accept<T>(FilterNodeVisitor<T, TValue> visitor)
public override T Accept<T, TArgs>(FilterNodeVisitor<T, TValue, TArgs> visitor, TArgs args)
{
return visitor.Visit(this);
return visitor.Visit(this, args);
}
public override string ToString()

4
backend/src/Squidex.Infrastructure/Queries/FilterNode.cs

@ -9,9 +9,9 @@ using System.Collections.Generic;
namespace Squidex.Infrastructure.Queries
{
public abstract class FilterNode<TValue>
public abstract record FilterNode<TValue>
{
public abstract T Accept<T>(FilterNodeVisitor<T, TValue> visitor);
public abstract T Accept<T, TArgs>(FilterNodeVisitor<T, TValue, TArgs> visitor, TArgs args);
public abstract void AddFields(HashSet<string> fields);

8
backend/src/Squidex.Infrastructure/Queries/FilterNodeVisitor.cs

@ -11,19 +11,19 @@ using System;
namespace Squidex.Infrastructure.Queries
{
public abstract class FilterNodeVisitor<T, TValue>
public abstract class FilterNodeVisitor<T, TValue, TArgs>
{
public virtual T Visit(CompareFilter<TValue> nodeIn)
public virtual T Visit(CompareFilter<TValue> nodeIn, TArgs args)
{
throw new NotImplementedException();
}
public virtual T Visit(LogicalFilter<TValue> nodeIn)
public virtual T Visit(LogicalFilter<TValue> nodeIn, TArgs args)
{
throw new NotImplementedException();
}
public virtual T Visit(NegateFilter<TValue> nodeIn)
public virtual T Visit(NegateFilter<TValue> nodeIn, TArgs args)
{
throw new NotImplementedException();
}

19
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})";
}
}
}

53
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<FilterNode<ClrValue>, IJsonValue>
public sealed class JsonFilterVisitor : FilterNodeVisitor<FilterNode<ClrValue>, IJsonValue, JsonFilterVisitor.Args>
{
private readonly List<string> errors;
private readonly JsonSchema schema;
private static readonly JsonFilterVisitor Instance = new JsonFilterVisitor();
private JsonFilterVisitor(JsonSchema schema, List<string> errors)
public struct Args
{
this.schema = schema;
public readonly List<string> Errors;
this.errors = errors;
public JsonSchema Schema;
public Args(JsonSchema schema, List<string> errors)
{
Schema = schema;
Errors = errors;
}
}
private JsonFilterVisitor()
{
}
public static FilterNode<ClrValue>? Parse(FilterNode<IJsonValue> filter, JsonSchema schema, List<string> 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<ClrValue> Visit(NegateFilter<IJsonValue> nodeIn)
public override FilterNode<ClrValue> Visit(NegateFilter<IJsonValue> nodeIn, Args args)
{
return new NegateFilter<ClrValue>(nodeIn.Accept(this));
return new NegateFilter<ClrValue>(nodeIn.Accept(this, args));
}
public override FilterNode<ClrValue> Visit(LogicalFilter<IJsonValue> nodeIn)
public override FilterNode<ClrValue> Visit(LogicalFilter<IJsonValue> nodeIn, Args args)
{
return new LogicalFilter<ClrValue>(nodeIn.Type, nodeIn.Filters.Select(x => x.Accept(this)).ToList());
return new LogicalFilter<ClrValue>(nodeIn.Type, nodeIn.Filters.Select(x => x.Accept(this, args)).ToList());
}
public override FilterNode<ClrValue> Visit(CompareFilter<IJsonValue> nodeIn)
public override FilterNode<ClrValue> Visit(CompareFilter<IJsonValue> nodeIn, Args args)
{
CompareFilter<ClrValue>? 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<ClrValue>(nodeIn.Path, nodeIn.Operator, value);

8
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;

40
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<string> errors, PropertyPath path, IJsonValue value, out FilterSphere result)
{
result = default!;
if (value is JsonObject geoObject &&
geoObject.TryGetValue<JsonNumber>("latitude", out var lat) &&
geoObject.TryGetValue<JsonNumber>("longitude", out var lon) &&
geoObject.TryGetValue<JsonNumber>("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<string> errors, PropertyPath path, IJsonValue value, out bool result)
{
result = default;

22
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<TValue> : FilterNode<TValue>
{
public IReadOnlyList<FilterNode<TValue>> Filters { get; }
public LogicalFilterType Type { get; }
public LogicalFilter(LogicalFilterType type, IReadOnlyList<FilterNode<TValue>> filters)
public sealed record LogicalFilter<TValue>(LogicalFilterType Type, IReadOnlyList<FilterNode<TValue>> Filters) : FilterNode<TValue>
{
Guard.NotNull(filters, nameof(filters));
Guard.Enum(type, nameof(type));
Filters = filters;
Type = type;
}
public override void AddFields(HashSet<string> fields)
{
foreach (var filter in Filters)
@ -33,9 +21,9 @@ namespace Squidex.Infrastructure.Queries
}
}
public override T Accept<T>(FilterNodeVisitor<T, TValue> visitor)
public override T Accept<T, TArgs>(FilterNodeVisitor<T, TValue, TArgs> visitor, TArgs args)
{
return visitor.Visit(this);
return visitor.Visit(this, args);
}
public override string ToString()

17
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<TValue> : FilterNode<TValue>
{
public FilterNode<TValue> Filter { get; }
public NegateFilter(FilterNode<TValue> filter)
public sealed record NegateFilter<TValue>(FilterNode<TValue> Filter) : FilterNode<TValue>
{
Guard.NotNull(filter, nameof(filter));
Filter = filter;
}
public override void AddFields(HashSet<string> fields)
{
Filter.AddFields(fields);
}
public override T Accept<T>(FilterNodeVisitor<T, TValue> visitor)
public override T Accept<T, TArgs>(FilterNodeVisitor<T, TValue, TArgs> visitor, TArgs args)
{
return visitor.Visit(this);
return visitor.Visit(this, args);
}
public override string ToString()

7
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)

22
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
{
@ -94,6 +95,26 @@ namespace Squidex.Infrastructure.Queries.OData
}
if (nodeIn.Left is SingleValueFunctionCallNode functionNode)
{
if (string.Equals(functionNode.Name, "geo.distance", StringComparison.OrdinalIgnoreCase) && nodeIn.OperatorKind == BinaryOperatorKind.LessThan)
{
var valueDistance = (double)ConstantWithTypeVisitor.Visit(nodeIn.Right).Value!;
if (functionNode.Parameters.ElementAt(1) is not ConstantNode constantNode)
{
throw new NotSupportedException();
}
if (constantNode.Value is not GeographyPoint geographyPoint)
{
throw new NotSupportedException();
}
var property = PropertyPathVisitor.Visit(functionNode.Parameters.ElementAt(0));
return ClrFilter.Lt(property, new FilterSphere(geographyPoint.Longitude, geographyPoint.Latitude, valueDistance));
}
else
{
var regexFilter = Visit(functionNode);
@ -110,6 +131,7 @@ namespace Squidex.Infrastructure.Queries.OData
return regexFilter;
}
}
}
else
{
if (nodeIn.OperatorKind == BinaryOperatorKind.NotEqual)

23
backend/src/Squidex.Infrastructure/Queries/Optimizer.cs

@ -9,7 +9,7 @@ using System.Collections.Generic;
namespace Squidex.Infrastructure.Queries
{
public sealed class Optimizer<TValue> : TransformVisitor<TValue>
public sealed class Optimizer<TValue> : TransformVisitor<TValue, None>
{
private static readonly Optimizer<TValue> Instance = new Optimizer<TValue>();
@ -19,16 +19,16 @@ namespace Squidex.Infrastructure.Queries
public static FilterNode<TValue>? Optimize(FilterNode<TValue> source)
{
return source?.Accept(Instance);
return source?.Accept(Instance, None.Value);
}
public override FilterNode<TValue>? Visit(LogicalFilter<TValue> nodeIn)
public override FilterNode<TValue>? Visit(LogicalFilter<TValue> nodeIn, None args)
{
var pruned = new List<FilterNode<TValue>>(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<TValue>(nodeIn.Type, pruned);
return nodeIn with { Filters = pruned };
}
public override FilterNode<TValue>? Visit(NegateFilter<TValue> nodeIn)
public override FilterNode<TValue>? Visit(NegateFilter<TValue> 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<TValue>(comparison.Path, CompareOperator.NotEquals, comparison.Value);
return comparison with { Operator = CompareOperator.NotEquals };
}
if (comparison.Operator == CompareOperator.NotEquals)
{
return new CompareFilter<TValue>(comparison.Path, CompareOperator.Equals, comparison.Value);
return comparison with { Operator = CompareOperator.Equals };
}
}
if (ReferenceEquals(pruned, nodeIn.Filter))
{
return nodeIn;
}
return new NegateFilter<TValue>(pruned);
}
}

6
backend/src/Squidex.Infrastructure/Queries/PascalCasePathConverter.cs

@ -10,7 +10,7 @@ using Squidex.Text;
namespace Squidex.Infrastructure.Queries
{
public sealed class PascalCasePathConverter<TValue> : TransformVisitor<TValue>
public sealed class PascalCasePathConverter<TValue> : TransformVisitor<TValue, None>
{
private static readonly PascalCasePathConverter<TValue> Instance = new PascalCasePathConverter<TValue>();
@ -20,10 +20,10 @@ namespace Squidex.Infrastructure.Queries
public static FilterNode<TValue>? Transform(FilterNode<TValue> node)
{
return node.Accept(Instance);
return node.Accept(Instance, None.Value);
}
public override FilterNode<TValue>? Visit(CompareFilter<TValue> nodeIn)
public override FilterNode<TValue>? Visit(CompareFilter<TValue> nodeIn, None args)
{
return new CompareFilter<TValue>(nodeIn.Path.Select(x => x.ToPascalCase()).ToList(), nodeIn.Operator, nodeIn.Value);
}

12
backend/src/Squidex.Infrastructure/Queries/TransformVisitor.cs

@ -9,20 +9,20 @@ using System.Collections.Generic;
namespace Squidex.Infrastructure.Queries
{
public abstract class TransformVisitor<TValue> : FilterNodeVisitor<FilterNode<TValue>?, TValue>
public abstract class TransformVisitor<TValue, TArgs> : FilterNodeVisitor<FilterNode<TValue>?, TValue, TArgs>
{
public override FilterNode<TValue>? Visit(CompareFilter<TValue> nodeIn)
public override FilterNode<TValue>? Visit(CompareFilter<TValue> nodeIn, TArgs args)
{
return nodeIn;
}
public override FilterNode<TValue>? Visit(LogicalFilter<TValue> nodeIn)
public override FilterNode<TValue>? Visit(LogicalFilter<TValue> nodeIn, TArgs args)
{
var pruned = new List<FilterNode<TValue>>(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<TValue>(nodeIn.Type, pruned);
}
public override FilterNode<TValue>? Visit(NegateFilter<TValue> nodeIn)
public override FilterNode<TValue>? Visit(NegateFilter<TValue> nodeIn, TArgs args)
{
var inner = nodeIn.Filter.Accept(this);
var inner = nodeIn.Filter.Accept(this, args);
if (inner == null)
{

1
backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -14,6 +14,7 @@
<None Remove="NewFolder\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="GeoJSON.Net" Version="1.2.19" />
<PackageReference Include="MailKit" Version="2.10.1" />
<PackageReference Include="McMaster.NETCore.Plugins" Version="1.3.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" />

1
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;

5
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;

5
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
};

1
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;

35
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<ValidationException>(() => sut.ParseQueryAsync(requestContext, query).AsTask());
await Assert.ThrowsAsync<ValidationException>(() => 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<ValidationException>(() => sut.ParseQueryAsync(requestContext, query).AsTask());
await Assert.ThrowsAsync<ValidationException>(() => 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<HashSet<string>>.That.Contains("name1")))
.Returns(new Dictionary<string, string>());
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<string>._, A<HashSet<string>>._))
.MustNotHaveHappened();
}
}
}

2
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<Q>._))
.ReturnsLazily(c => new ValueTask<Q>(c.GetArgument<Q>(1)!));
.ReturnsLazily(c => Task.FromResult(c.GetArgument<Q>(1)!));
sut = new AssetQueryService(assetEnricher, assetRepository, assetFolderRepository, queryParser);
}

62
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/FilterTagTransformerTests.cs

@ -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<ITagService>();
private readonly DomainId appId = DomainId.NewGuid();
[Fact]
public async Task Should_normalize_tags()
{
A.CallTo(() => tagService.GetTagIdsAsync(appId, TagGroups.Assets, A<HashSet<string>>.That.Contains("name1")))
.Returns(new Dictionary<string, string> { ["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<HashSet<string>>.That.Contains("name1")))
.Returns(new Dictionary<string, string>());
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<string>._, A<HashSet<string>>._))
.MustNotHaveHappened();
}
}
}

8
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<string>._, ctx.App, A<SearchFilter>._, A<SearchScope>._))
A.CallTo(() => contentIndex.SearchAsync(ctx.App, A<TextQuery>._, A<SearchScope>._))
.MustNotHaveHappened();
}
@ -169,7 +169,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
var ctx = ContextWithPermissions(schemaId1, schemaId2);
A.CallTo(() => contentIndex.SearchAsync("query~", ctx.App, A<SearchFilter>._, ctx.Scope()))
A.CallTo(() => contentIndex.SearchAsync(ctx.App, A<TextQuery>.That.Matches(x => x.Text == "query~"), ctx.Scope()))
.Returns(new List<DomainId>());
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<DomainId> { content.Id };
A.CallTo(() => contentIndex.SearchAsync("query~", ctx.App, A<SearchFilter>.That.IsEqualTo(searchFilter), ctx.Scope()))
A.CallTo(() => contentIndex.SearchAsync(ctx.App, A<TextQuery>.That.Matches(x => x.Text == "query~" && x.Filter != null), ctx.Scope()))
.Returns(ids);
A.CallTo(() => contentQuery.QueryAsync(ctx, A<Q>.That.HasIds(ids)))

12
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<ITextIndex>();
A.CallTo(() => textIndexer.SearchAsync(A<string>._, A<IAppEntity>._, A<SearchFilter>._, A<SearchScope>._))
.Returns(new List<DomainId> { DomainId.NewGuid() });
return textIndexer;
}
private static void SetupJson()
{
var jsonSerializer = JsonSerializer.Create(TestUtils.DefaultSerializerSettings);

13
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<MongoContentEntity> Serializer = BsonSerializer.SerializerRegistry.GetSerializer<MongoContentEntity>();
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<Guid> { 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<string> { 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<MongoContentEntity>().Filter!
query.AdjustToModel(appId, schemaDef).BuildFilter<MongoContentEntity>().Filter!
.Render(Serializer, Registry).ToString();
return rendered;

143
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<ITextIndex>();
private readonly ISchemaEntity schema;
private readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app");
private readonly NamedId<DomainId> 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<ValidationException>(() => sut.ParseAsync(requestContext, query, schema).AsTask());
await Assert.ThrowsAsync<ValidationException>(() => 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<ValidationException>(() => sut.ParseAsync(requestContext, query, schema).AsTask());
await Assert.ThrowsAsync<ValidationException>(() => 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<IJsonValue>
A.CallTo(() => textIndex.SearchAsync(requestContext.App, A<TextQuery>.That.Matches(x => x.Text == "Hello"), requestContext.Scope()))
.Returns(new List<DomainId> { 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' && id in ['1', '2']); Take: 30; Sort: lastModified Descending, id Ascending", q.Query.ToString());
}
[Fact]
public async Task Should_convert_full_text_query_to_filter()
{
Filter = new CompareFilter<IJsonValue>("data.firstName.iv", CompareOperator.Equals, JsonValue.Create("ABC"))
});
A.CallTo(() => textIndex.SearchAsync(requestContext.App, A<TextQuery>.That.Matches(x => x.Text == "Hello"), requestContext.Scope()))
.Returns(new List<DomainId> { DomainId.Create("1"), DomainId.Create("2") });
var query = Q.Empty.WithODataQuery("$search=Hello");
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: 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_when_single_id_found()
{
var query = Q.Empty.WithJsonQuery(Json("{ 'fullText': 'Hello' }"));
A.CallTo(() => textIndex.SearchAsync(requestContext.App, A<TextQuery>.That.Matches(x => x.Text == "Hello"), requestContext.Scope()))
.Returns(new List<DomainId> { 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_json_full_text_query_and_enrich_with_defaults()
public async Task Should_convert_full_text_query_to_filter_when_index_returns_null()
{
var query = Q.Empty.WithJsonQuery(
new Query<IJsonValue>
A.CallTo(() => textIndex.SearchAsync(requestContext.App, A<TextQuery>.That.Matches(x => x.Text == "Hello"), requestContext.Scope()))
.Returns(Task.FromResult<List<DomainId>?>(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()
{
FullText = "Hello"
});
A.CallTo(() => textIndex.SearchAsync(requestContext.App, A<TextQuery>.That.Matches(x => x.Text == "Hello"), requestContext.Scope()))
.Returns(new List<DomainId>());
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 == '__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> { 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> { 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<List<DomainId>?>(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<DomainId>());
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<IJsonValue>
{
Filter = new CompareFilter<IJsonValue>("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());
}
}
}

2
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<ISchemaEntity> { schema });
A.CallTo(() => queryParser.ParseAsync(A<Context>._, A<Q>._, A<ISchemaEntity?>._))
.ReturnsLazily(c => new ValueTask<Q>(c.GetArgument<Q>(1)!));
.ReturnsLazily(c => Task.FromResult(c.GetArgument<Q>(1)!));
sut = new ContentQueryService(
appProvider,

104
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/FilterTagTransformerTests.cs

@ -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<ITagService>();
private readonly ISchemaEntity schema;
private readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app");
private readonly NamedId<DomainId> 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<HashSet<string>>.That.Contains("name1")))
.Returns(new Dictionary<string, string> { ["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<HashSet<string>>.That.Contains("name1")))
.Returns(new Dictionary<string, string>());
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<string>._, A<HashSet<string>>._))
.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<string>._, A<HashSet<string>>._))
.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<string>._, A<HashSet<string>>._))
.MustNotHaveHappened();
}
}
}

244
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,41 +297,48 @@ 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",
@ -321,6 +346,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
.AddValue(language, text));
}
private static NamedContentData GeoData(string field, double latitude, double longitude)
{
return new NamedContentData()
.AddField(field,
new ContentFieldData()
.AddValue("iv", JsonValue.Object().Add("latitude", latitude).Add("longitude", longitude)));
}
protected IndexOperation CreateDraft(DomainId id)
{
return Op(id, new ContentDraftCreated());
@ -355,13 +388,32 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
return p => p.On(Enumerable.Repeat(Envelope.Create<IEvent>(contentEvent), 1));
}
protected IndexOperation Search(List<DomainId>? expected, string text, SearchScope target = SearchScope.All)
protected IndexOperation SearchGeo(List<DomainId>? 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<DomainId>? 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);
}

14
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: "東京")
);
}
}

16
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")
);
}
}

4
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();
}

2
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]

2
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();
}

2
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();

2
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]

2
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<IJsonValue>(123);
var serialized = TestUtils.Deserialize<IJsonValue>(123);
Assert.Equal(JsonValue.Create(123), serialized);
}

6
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<Exception>(() => JsonHelper.Deserialize<NamedId<string>>("123"));
Assert.ThrowsAny<Exception>(() => TestUtils.Deserialize<NamedId<string>>("123"));
}
[Fact]
public void Should_throw_exception_if_long_id_is_not_valid()
{
Assert.ThrowsAny<Exception>(() => JsonHelper.Deserialize<NamedId<long>>("invalid-long,name"));
Assert.ThrowsAny<Exception>(() => TestUtils.Deserialize<NamedId<long>>("invalid-long,name"));
}
[Fact]
public void Should_throw_exception_if_guid_id_is_not_valid()
{
Assert.ThrowsAny<Exception>(() => JsonHelper.Deserialize<NamedId<Guid>>("invalid-guid,name"));
Assert.ThrowsAny<Exception>(() => TestUtils.Deserialize<NamedId<Guid>>("invalid-guid,name"));
}
}
}

2
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]

398
backend/tests/Squidex.Infrastructure.Tests/Queries/JsonQueryConversionTests.cs

@ -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<string> errors = new List<string>();
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<ValidationException>(() => AssertQuery(json, null));
}
[Fact]
public void Should_throw_exception_when_parsing_invalid_json()
{
var json = "invalid";
Assert.Throws<ValidationException>(() => 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>(T value)
{
var json = JsonHelper.DefaultSerializer.Serialize(value, true);
var jsonFilter = JsonHelper.DefaultSerializer.Deserialize<FilterNode<IJsonValue>>(json);
return JsonFilterVisitor.Parse(jsonFilter, schema, errors)?.ToString();
}
private string? ConvertQuery<T>(T value)
{
var json = JsonHelper.DefaultSerializer.Serialize(value, true);
var jsonQuery = schema.Parse(json, JsonHelper.DefaultSerializer);
return jsonQuery.ToString();
}
}
}

651
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<object[]> 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<object[]> 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<object[]> ValidTests()
{
const string value = "Hello";
return BuildTests("string", x => true, value, $"'{value}'");
}
public static IEnumerable<object[]> 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<object[]> 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<object[]> 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<object[]> 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<object[]> ValidTests()
{
const int value = 12;
return BuildTests("number", x => x.Length == 2, value, $"{value}");
}
public static IEnumerable<object[]> InvalidTests()
{
const int value = 12;
return BuildInvalidOperatorTests("number", x => x.Length == 2, $"{value}");
}
public static IEnumerable<object[]> 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<object[]> ValidTests()
{
const bool value = true;
return BuildTests("boolean", x => x == "eq" || x == "ne", value, $"{value}");
}
public static IEnumerable<object[]> InvalidTests()
{
const bool value = true;
return BuildInvalidOperatorTests("boolean", x => x == "eq" || x == "ne", value);
}
public static IEnumerable<object[]> 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<object[]> ValiedTests()
{
const string value = "Hello";
return BuildTests("stringArray", x => x == "eq" || x == "ne" || x == "empty", value, $"'{value}'");
}
public static IEnumerable<object[]> 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<ValidationException>(() => AssertQuery(json, null));
}
[Fact]
public void Should_throw_exception_when_parsing_invalid_json()
{
var json = "invalid";
Assert.Throws<ValidationException>(() => AssertQuery(json, null));
}
private static void AssertQuery(object json, string? expectedFilter)
{
var errors = new List<string>();
var filter = ConvertQuery(json);
Assert.Empty(errors);
Assert.Equal(expectedFilter, filter);
}
private static void AssertFilter(object json, string? expectedFilter)
{
var errors = new List<string>();
var filter = ConvertFilter(json, errors);
Assert.Empty(errors);
Assert.Equal(expectedFilter, filter);
}
private static void AssertErrors(object json, string expectedError)
{
var errors = new List<string>();
var filter = ConvertFilter(json, errors);
Assert.Equal(expectedError, errors.FirstOrDefault());
Assert.Null(filter);
}
private static string? ConvertFilter<T>(T value, List<string> errors)
{
var json = TestUtils.DefaultSerializer.Serialize(value, true);
var jsonFilter = TestUtils.DefaultSerializer.Deserialize<FilterNode<IJsonValue>>(json);
return JsonFilterVisitor.Parse(jsonFilter, Schema, errors)?.ToString();
}
private static string? ConvertQuery<T>(T value)
{
var json = TestUtils.DefaultSerializer.Serialize(value, true);
var jsonFilter = Schema.Parse(json, TestUtils.DefaultSerializer);
return jsonFilter.ToString();
}
public static IEnumerable<object[]> BuildFlatTests(string field, Predicate<string> 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<object[]> BuildTests(string field, Predicate<string> 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<object[]> 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<object[]> BuildInvalidOperatorTests(string field, Predicate<string> opFilter, object value)
{
foreach (var (name, op, _) in AllOps.Where(x => !opFilter(x.Operator)))
{
yield return new[] { field, op, value, name };
}
}
}
}

19
backend/tests/Squidex.Infrastructure.Tests/Queries/QueryODataConversionTests.cs → 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()
{

489
backend/tests/Squidex.Infrastructure.Tests/Queries/QueryJsonConversionTests.cs

@ -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<string> errors = new List<string>();
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<object[]> 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<object[]> 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<object[]> 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<object[]> 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<object[]> 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<object[]> 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<object[]> 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<object[]> 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<object[]> 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<object[]> 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<ValidationException>(() => AssertQuery(json, null));
}
[Fact]
public void Should_throw_exception_when_parsing_invalid_json()
{
var json = "invalid";
Assert.Throws<ValidationException>(() => 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>(T value)
{
var json = JsonHelper.DefaultSerializer.Serialize(value, true);
var jsonFilter = JsonHelper.DefaultSerializer.Deserialize<FilterNode<IJsonValue>>(json);
return JsonFilterVisitor.Parse(jsonFilter, schema, errors)?.ToString();
}
private string? ConvertQuery<T>(T value)
{
var json = JsonHelper.DefaultSerializer.Serialize(value, true);
var jsonFilter = schema.Parse(json, JsonHelper.DefaultSerializer);
return jsonFilter.ToString();
}
public static IEnumerable<object[]> 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<object[]> BuildTests(string field, Predicate<string> 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 };
}
}
}
}
}

14
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<IJsonValue> SerializeAndDeserialize<T>(T value)
{
var json = JsonHelper.DefaultSerializer.Serialize(value, true);
var json = TestUtils.DefaultSerializer.Serialize(value, true);
return JsonHelper.DefaultSerializer.Deserialize<FilterNode<IJsonValue>>(json);
return TestUtils.DefaultSerializer.Deserialize<FilterNode<IJsonValue>>(json);
}
}
}

2
backend/tests/Squidex.Infrastructure.Tests/TestHelpers/JsonHelper.cs → 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();

211
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<ContentQueryFixture>
public class ContentQueryTests : IClassFixture<ContentQueryFixture1to10>
{
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<string>(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
{
var items = await _.Contents.GetAsync(new ContentQuery { Skip = 5, OrderBy = "data/number/iv asc" });
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
{
var items = await _.Contents.GetAsync(new ContentQuery { Skip = 2, Top = 5, OrderBy = "data/number/iv asc" });
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()
{

1
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;

10
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
}
}
}

1
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;

2
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")

19
backend/tools/TestSuite/TestSuite.Shared/Fixtures/ContentQueryFixture.cs → 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();
}

17
backend/tools/TestSuite/TestSuite.Shared/Model/TestEntity.cs

@ -15,6 +15,8 @@ namespace TestSuite.Model
{
public sealed class TestEntity : Content<TestEntityData>
{
public const int ScriptTrigger = -99;
public static async Task<SchemaDetailsDto> 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; }
}
}

1
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;

Loading…
Cancel
Save