mirror of https://github.com/Squidex/squidex.git
Browse Source
* Support for geo queries. * More tests * Last fixes. * Serialization fixes. * Lat/lon fixes. * Tests fixedpull/624/head
committed by
GitHub
93 changed files with 2300 additions and 1829 deletions
@ -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))); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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, |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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) |
||||
|
{ |
||||
|
} |
||||
|
} |
||||
@ -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); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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"; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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})"; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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(); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -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(); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -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(); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -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 }; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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 }; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
Loading…
Reference in new issue