diff --git a/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonSchemaExtensions.cs b/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonSchemaExtensions.cs index bde4b80ef..ef1243f3e 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonSchemaExtensions.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonSchemaExtensions.cs @@ -13,17 +13,17 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema { public static class JsonSchemaExtensions { - public static JsonSchema BuildJsonSchema(this Schema schema, PartitionResolver partitionResolver, SchemaResolver schemaResolver) + public static JsonSchema BuildJsonSchema(this Schema schema, PartitionResolver partitionResolver, SchemaResolver schemaResolver, bool withHidden = false) { Guard.NotNull(schemaResolver, nameof(schemaResolver)); Guard.NotNull(partitionResolver, nameof(partitionResolver)); var schemaName = schema.Name.ToPascalCase(); - var jsonTypeVisitor = new JsonTypeVisitor(schemaResolver); - var jsonSchema = new JsonSchema { Type = JsonObjectType.Object }; + var jsonTypeVisitor = new JsonTypeVisitor(schemaResolver, withHidden); + var jsonSchema = Builder.Object(); - foreach (var field in schema.Fields.ForApi()) + foreach (var field in schema.Fields.ForApi(withHidden)) { var partitionObject = Builder.Object(); var partitionSet = partitionResolver(field.Partitioning); diff --git a/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs b/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs index 4504f70fc..f83c3ceb1 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs @@ -16,17 +16,20 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema public sealed class JsonTypeVisitor : IFieldVisitor { private readonly SchemaResolver schemaResolver; + private readonly bool withHiddenFields; - public JsonTypeVisitor(SchemaResolver schemaResolver) + public JsonTypeVisitor(SchemaResolver schemaResolver, bool withHiddenFields) { this.schemaResolver = schemaResolver; + + this.withHiddenFields = withHiddenFields; } public JsonSchemaProperty Visit(IArrayField field) { var item = Builder.Object(); - foreach (var nestedField in field.Fields.ForApi()) + foreach (var nestedField in field.Fields.ForApi(withHiddenFields)) { var childProperty = nestedField.Accept(this); diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs index 32087f5a4..7ce6d9bfb 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs @@ -31,7 +31,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors .Select(x => new SortNode( x.Path.Select(p => p.ToPascalCase()).ToList(), - x.SortOrder)) + x.Order)) .ToList(); return query; diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FilterFactory.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FilterFactory.cs index f520635f8..0c56331cf 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FilterFactory.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FilterFactory.cs @@ -37,7 +37,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors query.Filter = query.Filter.Accept(new AdaptionVisitor(pathConverter)); } - query.Sort = query.Sort.Select(x => new SortNode(pathConverter(x.Path), x.SortOrder)).ToList(); + query.Sort = query.Sort.Select(x => new SortNode(pathConverter(x.Path), x.Order)).ToList(); return query; } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs index 7d1fe6cfd..fbdf3ab1c 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs @@ -8,45 +8,30 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using Microsoft.Extensions.Options; -using Microsoft.OData; -using Squidex.Domain.Apps.Core.Tags; -using Squidex.Domain.Apps.Entities.Assets.Edm; using Squidex.Domain.Apps.Entities.Assets.Queries; using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Infrastructure; -using Squidex.Infrastructure.Queries; -using Squidex.Infrastructure.Queries.OData; namespace Squidex.Domain.Apps.Entities.Assets { public sealed class AssetQueryService : IAssetQueryService { - private readonly ITagService tagService; private readonly IAssetEnricher assetEnricher; private readonly IAssetRepository assetRepository; - private readonly AssetOptions options; - - public int DefaultPageSizeGraphQl - { - get { return options.DefaultPageSizeGraphQl; } - } + private readonly AssetQueryParser queryParser; public AssetQueryService( - ITagService tagService, IAssetEnricher assetEnricher, IAssetRepository assetRepository, - IOptions options) + AssetQueryParser queryParser) { - Guard.NotNull(tagService, nameof(tagService)); Guard.NotNull(assetEnricher, nameof(assetEnricher)); Guard.NotNull(assetRepository, nameof(assetRepository)); - Guard.NotNull(options, nameof(options)); + Guard.NotNull(queryParser, nameof(queryParser)); - this.tagService = tagService; this.assetEnricher = assetEnricher; this.assetRepository = assetRepository; - this.options = options.Value; + this.queryParser = queryParser; } public async Task FindAssetAsync( Guid id) @@ -93,7 +78,7 @@ namespace Squidex.Domain.Apps.Entities.Assets private async Task> QueryByQueryAsync(Context context, Q query) { - var parsedQuery = ParseQuery(context, query.ODataQuery); + var parsedQuery = queryParser.ParseQuery(context, query); return await assetRepository.QueryAsync(context.App.Id, parsedQuery); } @@ -109,42 +94,5 @@ namespace Squidex.Domain.Apps.Entities.Assets { return assets.SortSet(x => x.Id, ids); } - - private ClrQuery ParseQuery(Context context, string query) - { - try - { - var result = EdmAssetModel.Edm.ParseQuery(query).ToQuery(); - - if (result.Filter != null) - { - result.Filter = FilterTagTransformer.Transform(result.Filter, context.App.Id, tagService); - } - - if (result.Sort.Count == 0) - { - result.Sort.Add(new SortNode(new List { "lastModified" }, SortOrder.Descending)); - } - - if (result.Take == long.MaxValue) - { - result.Take = options.DefaultPageSize; - } - else if (result.Take > options.MaxResults) - { - result.Take = options.MaxResults; - } - - return result; - } - catch (NotSupportedException) - { - throw new ValidationException("OData operation is not supported."); - } - catch (ODataException ex) - { - throw new ValidationException($"Failed to parse query: {ex.Message}", ex); - } - } } } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs b/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs index ed2f78da7..9d5f3d48b 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs @@ -14,8 +14,6 @@ namespace Squidex.Domain.Apps.Entities.Assets { public interface IAssetQueryService { - int DefaultPageSizeGraphQl { get; } - Task> QueryByHashAsync(Guid appId, string hash); Task> QueryAsync(Context context, Q query); diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryParser.cs b/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryParser.cs new file mode 100644 index 000000000..569b816de --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryParser.cs @@ -0,0 +1,173 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Options; +using Microsoft.OData; +using Microsoft.OData.Edm; +using NJsonSchema; +using Squidex.Domain.Apps.Core.Tags; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Json; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Queries; +using Squidex.Infrastructure.Queries.Json; +using Squidex.Infrastructure.Queries.OData; + +namespace Squidex.Domain.Apps.Entities.Assets.Queries +{ + public class AssetQueryParser + { + private readonly JsonSchema jsonSchema = BuildJsonSchema(); + private readonly IEdmModel edmModel = BuildEdmModel(); + private readonly IJsonSerializer jsonSerializer; + private readonly ITagService tagService; + private readonly AssetOptions options; + + public AssetQueryParser(IJsonSerializer jsonSerializer, ITagService tagService, IOptions options) + { + Guard.NotNull(jsonSerializer, nameof(jsonSerializer)); + Guard.NotNull(options, nameof(options)); + Guard.NotNull(tagService, nameof(tagService)); + + this.jsonSerializer = jsonSerializer; + this.options = options.Value; + this.tagService = tagService; + } + + public virtual ClrQuery ParseQuery(Context context, Q q) + { + Guard.NotNull(context, nameof(context)); + + using (Profiler.TraceMethod()) + { + var result = new ClrQuery(); + + if (!string.IsNullOrWhiteSpace(q?.JsonQuery)) + { + result = ParseJson(q?.JsonQuery); + } + else if (!string.IsNullOrWhiteSpace(q?.ODataQuery)) + { + result = ParseOData(q.ODataQuery); + } + + if (result.Filter != null) + { + result.Filter = FilterTagTransformer.Transform(result.Filter, context.App.Id, tagService); + } + + if (result.Sort.Count == 0) + { + result.Sort.Add(new SortNode(new List { "lastModified" }, SortOrder.Descending)); + } + + if (result.Take == long.MaxValue) + { + result.Take = options.DefaultPageSize; + } + else if (result.Take > options.MaxResults) + { + result.Take = options.MaxResults; + } + + return result; + } + } + + private ClrQuery ParseJson(string json) + { + return jsonSchema.Parse(json, jsonSerializer); + } + + private ClrQuery ParseOData(string odata) + { + try + { + return edmModel.ParseQuery(odata).ToQuery(); + } + catch (NotSupportedException) + { + throw new ValidationException("OData operation is not supported."); + } + catch (ODataException ex) + { + throw new ValidationException($"Failed to parse query: {ex.Message}", ex); + } + } + + private static JsonSchema BuildJsonSchema() + { + var schema = new JsonSchema { Title = "Asset", Type = JsonObjectType.Object }; + + void AddProperty(string name, JsonObjectType type, string format = null) + { + var property = new JsonSchemaProperty { Type = type, Format = format }; + + schema.Properties[name.ToCamelCase()] = property; + } + + AddProperty(nameof(IAssetEntity.Id), JsonObjectType.String, JsonFormatStrings.Guid); + AddProperty(nameof(IAssetEntity.Created), JsonObjectType.String, JsonFormatStrings.DateTime); + AddProperty(nameof(IAssetEntity.CreatedBy), JsonObjectType.String); + AddProperty(nameof(IAssetEntity.LastModified), JsonObjectType.String, JsonFormatStrings.DateTime); + AddProperty(nameof(IAssetEntity.LastModifiedBy), JsonObjectType.String); + AddProperty(nameof(IAssetEntity.Version), JsonObjectType.Integer); + AddProperty(nameof(IAssetEntity.FileName), JsonObjectType.String); + AddProperty(nameof(IAssetEntity.FileHash), JsonObjectType.String); + AddProperty(nameof(IAssetEntity.FileSize), JsonObjectType.Integer); + AddProperty(nameof(IAssetEntity.FileVersion), JsonObjectType.Integer); + AddProperty(nameof(IAssetEntity.IsImage), JsonObjectType.Boolean); + AddProperty(nameof(IAssetEntity.MimeType), JsonObjectType.String); + AddProperty(nameof(IAssetEntity.PixelHeight), JsonObjectType.Integer); + AddProperty(nameof(IAssetEntity.PixelWidth), JsonObjectType.Integer); + AddProperty(nameof(IAssetEntity.Slug), JsonObjectType.String); + AddProperty(nameof(IAssetEntity.Tags), JsonObjectType.String); + + return schema; + } + + private static IEdmModel BuildEdmModel() + { + var entityType = new EdmEntityType("Squidex", "Asset"); + + void AddProperty(string name, EdmPrimitiveTypeKind type) + { + entityType.AddStructuralProperty(name.ToCamelCase(), type); + } + + AddProperty(nameof(IAssetEntity.Id), EdmPrimitiveTypeKind.String); + AddProperty(nameof(IAssetEntity.Created), EdmPrimitiveTypeKind.DateTimeOffset); + AddProperty(nameof(IAssetEntity.CreatedBy), EdmPrimitiveTypeKind.String); + AddProperty(nameof(IAssetEntity.LastModified), EdmPrimitiveTypeKind.DateTimeOffset); + AddProperty(nameof(IAssetEntity.LastModifiedBy), EdmPrimitiveTypeKind.String); + AddProperty(nameof(IAssetEntity.Version), EdmPrimitiveTypeKind.Int64); + AddProperty(nameof(IAssetEntity.FileName), EdmPrimitiveTypeKind.String); + AddProperty(nameof(IAssetEntity.FileHash), EdmPrimitiveTypeKind.String); + AddProperty(nameof(IAssetEntity.FileSize), EdmPrimitiveTypeKind.Int64); + AddProperty(nameof(IAssetEntity.FileVersion), EdmPrimitiveTypeKind.Int64); + AddProperty(nameof(IAssetEntity.IsImage), EdmPrimitiveTypeKind.Boolean); + AddProperty(nameof(IAssetEntity.MimeType), EdmPrimitiveTypeKind.String); + AddProperty(nameof(IAssetEntity.PixelHeight), EdmPrimitiveTypeKind.Int32); + AddProperty(nameof(IAssetEntity.PixelWidth), EdmPrimitiveTypeKind.Int32); + AddProperty(nameof(IAssetEntity.Slug), EdmPrimitiveTypeKind.String); + AddProperty(nameof(IAssetEntity.Tags), EdmPrimitiveTypeKind.String); + + var container = new EdmEntityContainer("Squidex", "Container"); + + container.AddEntitySet("AssetSet", entityType); + + var model = new EdmModel(); + + model.AddElement(container); + model.AddElement(entityType); + + return model; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Queries/FilterTagTransformer.cs b/src/Squidex.Domain.Apps.Entities/Assets/Queries/FilterTagTransformer.cs index d17b620ca..af0f852b5 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Queries/FilterTagTransformer.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Queries/FilterTagTransformer.cs @@ -27,6 +27,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries public static FilterNode Transform(FilterNode nodeIn, Guid appId, ITagService tagService) { + Guard.NotNull(nodeIn, nameof(nodeIn)); Guard.NotNull(tagService, nameof(tagService)); return nodeIn.Accept(new FilterTagTransformer(appId, tagService)); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs index 098998709..a87da0c46 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs @@ -9,18 +9,14 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Microsoft.Extensions.Options; -using Microsoft.OData; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.ConvertContent; using Squidex.Domain.Apps.Core.Scripting; -using Squidex.Domain.Apps.Entities.Contents.Edm; using Squidex.Domain.Apps.Entities.Contents.Repositories; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Queries; -using Squidex.Infrastructure.Queries.OData; using Squidex.Infrastructure.Reflection; using Squidex.Shared; @@ -38,13 +34,7 @@ namespace Squidex.Domain.Apps.Entities.Contents private readonly IContentRepository contentRepository; private readonly IContentVersionLoader contentVersionLoader; private readonly IScriptEngine scriptEngine; - private readonly ContentOptions options; - private readonly EdmModelBuilder modelBuilder; - - public int DefaultPageSizeGraphQl - { - get { return options.DefaultPageSizeGraphQl; } - } + private readonly ContentQueryParser queryParser; public ContentQueryService( IAppProvider appProvider, @@ -53,16 +43,14 @@ namespace Squidex.Domain.Apps.Entities.Contents IContentRepository contentRepository, IContentVersionLoader contentVersionLoader, IScriptEngine scriptEngine, - IOptions options, - EdmModelBuilder modelBuilder) + ContentQueryParser queryParser) { Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(assetUrlGenerator, nameof(assetUrlGenerator)); Guard.NotNull(contentEnricher, nameof(contentEnricher)); Guard.NotNull(contentRepository, nameof(contentRepository)); Guard.NotNull(contentVersionLoader, nameof(contentVersionLoader)); - Guard.NotNull(modelBuilder, nameof(modelBuilder)); - Guard.NotNull(options, nameof(options)); + Guard.NotNull(queryParser, nameof(queryParser)); Guard.NotNull(scriptEngine, nameof(scriptEngine)); this.appProvider = appProvider; @@ -70,9 +58,9 @@ namespace Squidex.Domain.Apps.Entities.Contents this.contentEnricher = contentEnricher; this.contentRepository = contentRepository; this.contentVersionLoader = contentVersionLoader; - this.modelBuilder = modelBuilder; - this.options = options.Value; + this.queryParser = queryParser; this.scriptEngine = scriptEngine; + this.queryParser = queryParser; } public async Task FindContentAsync(Context context, string schemaIdOrName, Guid id, long version = -1) @@ -119,11 +107,11 @@ namespace Squidex.Domain.Apps.Entities.Contents if (query.Ids != null && query.Ids.Count > 0) { - contents = await QueryByIdsAsync(context, query, schema); + contents = await QueryByIdsAsync(context, schema, query); } else { - contents = await QueryByQueryAsync(context, query, schema); + contents = await QueryByQueryAsync(context, schema, query); } return await TransformAsync(context, schema, contents); @@ -254,43 +242,6 @@ namespace Squidex.Domain.Apps.Entities.Contents } } - private ClrQuery ParseQuery(Context context, string query, ISchemaEntity schema) - { - using (Profiler.TraceMethod()) - { - try - { - var model = modelBuilder.BuildEdmModel(context.App, schema, context.IsFrontendClient); - - var result = model.ParseQuery(query).ToQuery(); - - if (result.Sort.Count == 0) - { - result.Sort.Add(new SortNode(new List { "lastModified" }, SortOrder.Descending)); - } - - if (result.Take == long.MaxValue) - { - result.Take = options.DefaultPageSize; - } - else if (result.Take > options.MaxResults) - { - result.Take = options.MaxResults; - } - - return result; - } - catch (NotSupportedException) - { - throw new ValidationException("OData operation is not supported."); - } - catch (ODataException ex) - { - throw new ValidationException($"Failed to parse query: {ex.Message}", ex); - } - } - } - public async Task GetSchemaOrThrowAsync(Context context, string schemaIdOrName) { ISchemaEntity schema = null; @@ -343,14 +294,14 @@ namespace Squidex.Domain.Apps.Entities.Contents } } - private async Task> QueryByQueryAsync(Context context, Q query, ISchemaEntity schema) + private async Task> QueryByQueryAsync(Context context, ISchemaEntity schema, Q query) { - var parsedQuery = ParseQuery(context, query.ODataQuery, schema); + var parsedQuery = queryParser.ParseQuery(context, schema, query); return await QueryCoreAsync(context, schema, parsedQuery); } - private async Task> QueryByIdsAsync(Context context, Q query, ISchemaEntity schema) + private async Task> QueryByIdsAsync(Context context, ISchemaEntity schema, Q query) { var contents = await QueryCoreAsync(context, schema, query.Ids.ToHashSet()); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelBuilder.cs b/src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelBuilder.cs deleted file mode 100644 index c33bf0018..000000000 --- a/src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelBuilder.cs +++ /dev/null @@ -1,104 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Linq; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.OData.Edm; -using Squidex.Domain.Apps.Core.GenerateEdmSchema; -using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Domain.Apps.Entities.Apps; -using Squidex.Domain.Apps.Entities.Schemas; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Entities.Contents.Edm -{ - public class EdmModelBuilder : CachingProviderBase - { - private static readonly TimeSpan CacheTime = TimeSpan.FromMinutes(60); - - public EdmModelBuilder(IMemoryCache cache) - : base(cache) - { - } - - public virtual IEdmModel BuildEdmModel(IAppEntity app, ISchemaEntity schema, bool withHidden) - { - Guard.NotNull(schema, nameof(schema)); - - var cacheKey = BuildCacheKey(app, schema, withHidden); - - var result = Cache.GetOrCreate(cacheKey, entry => - { - entry.AbsoluteExpirationRelativeToNow = CacheTime; - - return BuildEdmModel(schema.SchemaDef, app, withHidden); - }); - - return result; - } - - private static EdmModel BuildEdmModel(Schema schema, IAppEntity app, bool withHidden) - { - var model = new EdmModel(); - - var pascalAppName = app.Name.ToPascalCase(); - var pascalSchemaName = schema.Name.ToPascalCase(); - - var typeFactory = new EdmTypeFactory(name => - { - var finalName = pascalSchemaName; - - if (!string.IsNullOrWhiteSpace(name)) - { - finalName += "."; - finalName += name; - } - - var result = model.SchemaElements.OfType().FirstOrDefault(x => x.Name == finalName); - - if (result != null) - { - return (result, false); - } - - result = new EdmComplexType(pascalAppName, finalName); - - model.AddElement(result); - - return (result, true); - }); - - var schemaType = schema.BuildEdmType(withHidden, app.PartitionResolver(), typeFactory); - - var entityType = new EdmEntityType(app.Name.ToPascalCase(), schema.Name); - entityType.AddStructuralProperty(nameof(IContentEntity.Id).ToCamelCase(), EdmPrimitiveTypeKind.String); - entityType.AddStructuralProperty(nameof(IContentEntity.Created).ToCamelCase(), EdmPrimitiveTypeKind.DateTimeOffset); - entityType.AddStructuralProperty(nameof(IContentEntity.CreatedBy).ToCamelCase(), EdmPrimitiveTypeKind.String); - entityType.AddStructuralProperty(nameof(IContentEntity.LastModified).ToCamelCase(), EdmPrimitiveTypeKind.DateTimeOffset); - entityType.AddStructuralProperty(nameof(IContentEntity.LastModifiedBy).ToCamelCase(), EdmPrimitiveTypeKind.String); - entityType.AddStructuralProperty(nameof(IContentEntity.Status).ToCamelCase(), EdmPrimitiveTypeKind.String); - entityType.AddStructuralProperty(nameof(IContentEntity.Version).ToCamelCase(), EdmPrimitiveTypeKind.Int32); - entityType.AddStructuralProperty(nameof(IContentEntity.Data).ToCamelCase(), new EdmComplexTypeReference(schemaType, false)); - - var container = new EdmEntityContainer("Squidex", "Container"); - - container.AddEntitySet("ContentSet", entityType); - - model.AddElement(container); - model.AddElement(schemaType); - model.AddElement(entityType); - - return model; - } - - private static string BuildCacheKey(IAppEntity app, ISchemaEntity schema, bool withHidden) - { - return string.Join("_", schema.Id, schema.Version, app.Id, app.Version, withHidden); - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs index 1fa48486c..a5742335e 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs @@ -10,6 +10,7 @@ using System.Linq; using System.Threading.Tasks; using GraphQL; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Infrastructure; @@ -88,12 +89,22 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL return new GraphQLModel(app, allSchemas, - resolver.Resolve().DefaultPageSizeGraphQl, - resolver.Resolve().DefaultPageSizeGraphQl, + GetPageSizeForContents(), + GetPageSizeForAssets(), resolver.Resolve()); }); } + private int GetPageSizeForContents() + { + return resolver.Resolve>().Value.DefaultPageSizeGraphQl; + } + + private int GetPageSizeForAssets() + { + return resolver.Resolve>().Value.DefaultPageSizeGraphQl; + } + private static object CreateCacheKey(Guid appId, string etag) { return $"GraphQLModel_{appId}_{etag}"; diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs b/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs index de784f55d..62607cd06 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs @@ -15,8 +15,6 @@ namespace Squidex.Domain.Apps.Entities.Contents { public interface IContentQueryService { - int DefaultPageSizeGraphQl { get; } - Task> QueryAsync(Context context, IReadOnlyList ids); Task> QueryAsync(Context context, string schemaIdOrName, Q query); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs b/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs new file mode 100644 index 000000000..b39d4bad0 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs @@ -0,0 +1,204 @@ +// ========================================================================== +// 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 Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using Microsoft.OData; +using Microsoft.OData.Edm; +using NJsonSchema; +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; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Json; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Queries; +using Squidex.Infrastructure.Queries.Json; +using Squidex.Infrastructure.Queries.OData; + +namespace Squidex.Domain.Apps.Entities +{ + public class ContentQueryParser : CachingProviderBase + { + private static readonly TimeSpan CacheTime = TimeSpan.FromMinutes(60); + private readonly IJsonSerializer jsonSerializer; + private readonly ContentOptions options; + + public ContentQueryParser(IMemoryCache cache, IJsonSerializer jsonSerializer, IOptions options) + : base(cache) + { + this.jsonSerializer = jsonSerializer; + this.options = options.Value; + } + + public virtual ClrQuery ParseQuery(Context context, ISchemaEntity schema, Q q) + { + Guard.NotNull(context, nameof(context)); + Guard.NotNull(schema, nameof(schema)); + + using (Profiler.TraceMethod()) + { + var result = new ClrQuery(); + + if (!string.IsNullOrWhiteSpace(q?.JsonQuery)) + { + result = ParseJson(context, schema, q.JsonQuery); + } + else if (!string.IsNullOrWhiteSpace(q?.ODataQuery)) + { + result = ParseOData(context, schema, q.ODataQuery); + } + + if (result.Sort.Count == 0) + { + result.Sort.Add(new SortNode(new List { "lastModified" }, SortOrder.Descending)); + } + + if (result.Take == long.MaxValue) + { + result.Take = options.DefaultPageSize; + } + else if (result.Take > options.MaxResults) + { + result.Take = options.MaxResults; + } + + return result; + } + } + + private ClrQuery ParseJson(Context context, ISchemaEntity schema, string json) + { + var jsonSchema = BuildJsonSchema(context, schema); + + return jsonSchema.Parse(json, jsonSerializer); + } + + private ClrQuery ParseOData(Context context, ISchemaEntity schema, string odata) + { + try + { + var model = BuildEdmModel(context, schema); + + return model.ParseQuery(odata).ToQuery(); + } + catch (NotSupportedException) + { + throw new ValidationException("OData operation is not supported."); + } + catch (ODataException ex) + { + throw new ValidationException($"Failed to parse query: {ex.Message}", ex); + } + } + + private JsonSchema BuildJsonSchema(Context context, ISchemaEntity schema) + { + var cacheKey = BuildJsonCacheKey(context.App, schema, context.IsFrontendClient); + + var result = Cache.GetOrCreate(cacheKey, entry => + { + entry.AbsoluteExpirationRelativeToNow = CacheTime; + + return BuildJsonSchema(schema.SchemaDef, context.App, context.IsFrontendClient); + }); + + return result; + } + + private IEdmModel BuildEdmModel(Context context, ISchemaEntity schema) + { + var cacheKey = BuildEmdCacheKey(context.App, schema, context.IsFrontendClient); + + var result = Cache.GetOrCreate(cacheKey, entry => + { + entry.AbsoluteExpirationRelativeToNow = CacheTime; + + return BuildEdmModel(schema.SchemaDef, context.App, context.IsFrontendClient); + }); + + return result; + } + + private static JsonSchema BuildJsonSchema(Schema schema, IAppEntity app, bool withHiddenFields) + { + var dataSchema = schema.BuildJsonSchema(app.PartitionResolver(), (n, s) => s, withHiddenFields); + + return new ContentSchemaBuilder().CreateContentSchema(schema, dataSchema); + } + + private static EdmModel BuildEdmModel(Schema schema, IAppEntity app, bool withHiddenFields) + { + var model = new EdmModel(); + + var pascalAppName = app.Name.ToPascalCase(); + var pascalSchemaName = schema.Name.ToPascalCase(); + + var typeFactory = new EdmTypeFactory(name => + { + var finalName = pascalSchemaName; + + if (!string.IsNullOrWhiteSpace(name)) + { + finalName += "."; + finalName += name; + } + + var result = model.SchemaElements.OfType().FirstOrDefault(x => x.Name == finalName); + + if (result != null) + { + return (result, false); + } + + result = new EdmComplexType(pascalAppName, finalName); + + model.AddElement(result); + + return (result, true); + }); + + var schemaType = schema.BuildEdmType(withHiddenFields, app.PartitionResolver(), typeFactory); + + var entityType = new EdmEntityType(app.Name.ToPascalCase(), schema.Name); + entityType.AddStructuralProperty(nameof(IContentEntity.Id).ToCamelCase(), EdmPrimitiveTypeKind.String); + entityType.AddStructuralProperty(nameof(IContentEntity.Created).ToCamelCase(), EdmPrimitiveTypeKind.DateTimeOffset); + entityType.AddStructuralProperty(nameof(IContentEntity.CreatedBy).ToCamelCase(), EdmPrimitiveTypeKind.String); + entityType.AddStructuralProperty(nameof(IContentEntity.LastModified).ToCamelCase(), EdmPrimitiveTypeKind.DateTimeOffset); + entityType.AddStructuralProperty(nameof(IContentEntity.LastModifiedBy).ToCamelCase(), EdmPrimitiveTypeKind.String); + entityType.AddStructuralProperty(nameof(IContentEntity.Status).ToCamelCase(), EdmPrimitiveTypeKind.String); + entityType.AddStructuralProperty(nameof(IContentEntity.Version).ToCamelCase(), EdmPrimitiveTypeKind.Int32); + entityType.AddStructuralProperty(nameof(IContentEntity.Data).ToCamelCase(), new EdmComplexTypeReference(schemaType, false)); + + var container = new EdmEntityContainer("Squidex", "Container"); + + container.AddEntitySet("ContentSet", entityType); + + model.AddElement(container); + model.AddElement(schemaType); + model.AddElement(entityType); + + return model; + } + + private static string BuildEmdCacheKey(IAppEntity app, ISchemaEntity schema, bool withHidden) + { + return $"EDM/{app.Version}/{schema.Id}_{schema.Version}/{withHidden}"; + } + + private static string BuildJsonCacheKey(IAppEntity app, ISchemaEntity schema, bool withHidden) + { + return $"EDM/{app.Version}/{schema.Id}_{schema.Version}/{withHidden}"; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Queries/FilterTagTransformer.cs b/src/Squidex.Domain.Apps.Entities/Contents/Queries/FilterTagTransformer.cs index ed2ca631a..e0f812403 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Queries/FilterTagTransformer.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Queries/FilterTagTransformer.cs @@ -31,6 +31,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries public static FilterNode Transform(FilterNode nodeIn, Guid appId, ISchemaEntity schema, ITagService tagService) { + Guard.NotNull(nodeIn, nameof(nodeIn)); Guard.NotNull(tagService, nameof(tagService)); Guard.NotNull(schema, nameof(schema)); @@ -59,9 +60,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries private bool IsTagField(IReadOnlyList path) { - return schema.SchemaDef.FieldsByName.TryGetValue(path[1], out var field) && - field is IField fieldTags && - fieldTags.Properties.Normalization == TagsFieldNormalization.Schema; + return schema.SchemaDef.FieldsByName.TryGetValue(path[1], out var field) && IsTagField(field); + } + + private bool IsTagField(IField field) + { + return field is IField tags && tags.Properties.Normalization == TagsFieldNormalization.Schema; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Q.cs b/src/Squidex.Domain.Apps.Entities/Q.cs index 966bdd279..9fce3c394 100644 --- a/src/Squidex.Domain.Apps.Entities/Q.cs +++ b/src/Squidex.Domain.Apps.Entities/Q.cs @@ -20,11 +20,18 @@ namespace Squidex.Domain.Apps.Entities public string ODataQuery { get; private set; } + public string JsonQuery { get; private set; } + public Q WithODataQuery(string odataQuery) { return Clone(c => c.ODataQuery = odataQuery); } + public Q WithJsonQuery(string jsonQuery) + { + return Clone(c => c.JsonQuery = jsonQuery); + } + public Q WithIds(params Guid[] ids) { return Clone(c => c.Ids = ids.ToList()); diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/Queries/SortBuilder.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/Queries/SortBuilder.cs index 717b2299f..68b087166 100644 --- a/src/Squidex.Infrastructure.MongoDb/MongoDb/Queries/SortBuilder.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/Queries/SortBuilder.cs @@ -41,7 +41,7 @@ namespace Squidex.Infrastructure.MongoDb.Queries { var propertyName = string.Join(".", sort.Path); - if (sort.SortOrder == SortOrder.Ascending) + if (sort.Order == SortOrder.Ascending) { return Builders.Sort.Ascending(propertyName); } diff --git a/src/Squidex.Infrastructure/Queries/Json/JsonFilterVisitor.cs b/src/Squidex.Infrastructure/Queries/Json/JsonFilterVisitor.cs new file mode 100644 index 000000000..c6a506e08 --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/Json/JsonFilterVisitor.cs @@ -0,0 +1,84 @@ +// ========================================================================== +// 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; + +namespace Squidex.Infrastructure.Queries.Json +{ + public sealed class JsonFilterVisitor : FilterNodeVisitor, IJsonValue> + { + private readonly List errors; + private readonly JsonSchema schema; + + private JsonFilterVisitor(JsonSchema schema, List errors) + { + this.schema = schema; + + this.errors = errors; + } + + public static FilterNode Parse(FilterNode filter, JsonSchema schema, List errors) + { + var visitor = new JsonFilterVisitor(schema, errors); + + var parsed = filter.Accept(visitor); + + if (visitor.errors.Count > 0) + { + return null; + } + else + { + return parsed; + } + } + + public override FilterNode Visit(NegateFilter nodeIn) + { + return new NegateFilter(nodeIn.Accept(this)); + } + + public override FilterNode Visit(LogicalFilter nodeIn) + { + return new LogicalFilter(nodeIn.Type, nodeIn.Filters.Select(x => x.Accept(this)).ToList()); + } + + public override FilterNode Visit(CompareFilter nodeIn) + { + CompareFilter result = null; + + if (nodeIn.Path.TryGetProperty(schema, 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 value = ValueConverter.Convert(property, nodeIn.Value, nodeIn.Path, 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}'."); + } + + result = new CompareFilter(nodeIn.Path, nodeIn.Operator, value); + } + } + + result = result ?? new CompareFilter(nodeIn.Path, nodeIn.Operator, ClrValue.Null); + + return result; + } + } +} diff --git a/src/Squidex.Infrastructure/Queries/Json/OperatorValidator.cs b/src/Squidex.Infrastructure/Queries/Json/OperatorValidator.cs new file mode 100644 index 000000000..e7f864cc2 --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/Json/OperatorValidator.cs @@ -0,0 +1,71 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Linq; +using NJsonSchema; + +namespace Squidex.Infrastructure.Queries.Json +{ + public static class OperatorValidator + { + private static readonly CompareOperator[] BooleanOperators = + { + CompareOperator.Equals, + CompareOperator.In, + CompareOperator.NotEquals + }; + private static readonly CompareOperator[] NumberOperators = + { + CompareOperator.Equals, + CompareOperator.LessThan, + CompareOperator.LessThanOrEqual, + CompareOperator.GreaterThan, + CompareOperator.GreaterThanOrEqual, + CompareOperator.In, + CompareOperator.NotEquals + }; + private static readonly CompareOperator[] StringOperators = + { + CompareOperator.Contains, + CompareOperator.Empty, + CompareOperator.EndsWith, + CompareOperator.Equals, + CompareOperator.GreaterThan, + CompareOperator.GreaterThanOrEqual, + CompareOperator.In, + CompareOperator.LessThan, + CompareOperator.LessThanOrEqual, + CompareOperator.NotEquals, + CompareOperator.StartsWith + }; + private static readonly CompareOperator[] ArrayOperators = + { + CompareOperator.Empty, + CompareOperator.Equals, + CompareOperator.In, + CompareOperator.NotEquals + }; + + public static bool IsAllowedOperator(JsonSchema schema, CompareOperator compareOperator) + { + switch (schema.Type) + { + case JsonObjectType.Boolean: + return BooleanOperators.Contains(compareOperator); + case JsonObjectType.Integer: + case JsonObjectType.Number: + return NumberOperators.Contains(compareOperator); + case JsonObjectType.String: + return StringOperators.Contains(compareOperator); + case JsonObjectType.Array: + return ArrayOperators.Contains(compareOperator); + } + + return false; + } + } +} diff --git a/src/Squidex.Infrastructure/Queries/Json/PropertyPathValidator.cs b/src/Squidex.Infrastructure/Queries/Json/PropertyPathValidator.cs index 5e40b74e2..2062f310d 100644 --- a/src/Squidex.Infrastructure/Queries/Json/PropertyPathValidator.cs +++ b/src/Squidex.Infrastructure/Queries/Json/PropertyPathValidator.cs @@ -5,22 +5,34 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using NJsonSchema; namespace Squidex.Infrastructure.Queries.Json { public static class PropertyPathValidator { - public static bool TryGetProperty(this PropertyPath path, JsonSchema schema, out JsonSchema property) + public static bool TryGetProperty(this PropertyPath path, JsonSchema schema, List errors, out JsonSchema property) { foreach (var element in path) { - if (schema.Properties.TryGetValue(element, out var p)) + var parent = schema.Reference ?? schema; + + if (parent.Properties.TryGetValue(element, out var p)) { schema = p; } else { + if (!string.IsNullOrWhiteSpace(parent.Title)) + { + errors.Add($"'{element}' is not a property of '{parent.Title}'."); + } + else + { + errors.Add($"Path '{path}' does not point to a valid property in the model."); + } + property = null; return false; diff --git a/src/Squidex.Infrastructure/Queries/Json/QueryParser.cs b/src/Squidex.Infrastructure/Queries/Json/QueryParser.cs new file mode 100644 index 000000000..24f2e7848 --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/Json/QueryParser.cs @@ -0,0 +1,72 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using NJsonSchema; +using Squidex.Infrastructure.Json; +using Squidex.Infrastructure.Json.Objects; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Infrastructure.Queries.Json +{ + public static class QueryParser + { + public static ClrQuery Parse(this JsonSchema schema, string json, IJsonSerializer jsonSerializer) + { + var query = ParseFromJson(json, jsonSerializer); + + var result = SimpleMapper.Map(query, new ClrQuery()); + + var errors = new List(); + + ConvertSorting(schema, result, errors); + ConvertFilters(schema, result, errors, query); + + if (errors.Count > 0) + { + throw new ValidationException("Failed to parse json query", errors.Select(x => new ValidationError(x)).ToArray()); + } + + return result; + } + + private static void ConvertFilters(JsonSchema schema, ClrQuery result, List errors, Query query) + { + if (query.Filter != null) + { + var filter = JsonFilterVisitor.Parse(query.Filter, schema, errors); + + result.Filter = Optimizer.Optimize(filter); + } + } + + private static void ConvertSorting(JsonSchema schema, ClrQuery result, List errors) + { + if (result.Sort != null) + { + foreach (var sorting in result.Sort) + { + sorting.Path.TryGetProperty(schema, errors, out _); + } + } + } + + private static Query ParseFromJson(string json, IJsonSerializer jsonSerializer) + { + try + { + return jsonSerializer.Deserialize>(json); + } + catch (JsonException ex) + { + throw new ValidationException("Failed to parse json query.", new ValidationError(ex.Message)); + } + } + } +} diff --git a/src/Squidex.Infrastructure/Queries/Json/ValueConverter.cs b/src/Squidex.Infrastructure/Queries/Json/ValueConverter.cs new file mode 100644 index 000000000..38c801b15 --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/Json/ValueConverter.cs @@ -0,0 +1,228 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using NJsonSchema; +using NodaTime; +using NodaTime.Text; +using Squidex.Infrastructure.Json.Objects; + +namespace Squidex.Infrastructure.Queries.Json +{ + public static class ValueConverter + { + private delegate bool Parser(List errors, PropertyPath path, IJsonValue value, out T result); + + public static ClrValue Convert(JsonSchema schema, IJsonValue value, PropertyPath path, List errors) + { + ClrValue result = null; + + switch (GetType(schema)) + { + case JsonObjectType.Boolean: + { + if (value is JsonArray jsonArray) + { + result = ParseArray(errors, path, jsonArray, TryParseBoolean); + } + else if (TryParseBoolean(errors, path, value, out var temp)) + { + result = temp; + } + + break; + } + + case JsonObjectType.Integer: + case JsonObjectType.Number: + { + if (value is JsonArray jsonArray) + { + result = ParseArray(errors, path, jsonArray, TryParseNumber); + } + else if (TryParseNumber(errors, path, value, out var temp)) + { + result = temp; + } + + break; + } + + case JsonObjectType.String: + { + if (schema.Format == JsonFormatStrings.Guid) + { + if (value is JsonArray jsonArray) + { + result = ParseArray(errors, path, jsonArray, TryParseGuid); + } + else if (TryParseGuid(errors, path, value, out var temp)) + { + result = temp; + } + } + else if (schema.Format == JsonFormatStrings.DateTime) + { + if (value is JsonArray jsonArray) + { + result = ParseArray(errors, path, jsonArray, TryParseDateTime); + } + else if (TryParseDateTime(errors, path, value, out var temp)) + { + result = temp; + } + } + else + { + if (value is JsonArray jsonArray) + { + result = ParseArray(errors, path, jsonArray, TryParseString); + } + else if (TryParseString(errors, path, value, out var temp)) + { + result = temp; + } + } + + break; + } + + default: + { + errors.Add($"Unsupported type {schema.Type} for {path}."); + break; + } + } + + return result; + } + + private static List ParseArray(List errors, PropertyPath path, JsonArray array, Parser parser) + { + var items = new List(); + + foreach (var item in array) + { + if (parser(errors, path, item, out var temp)) + { + items.Add(temp); + } + } + + return items; + } + + private static bool TryParseBoolean(List errors, PropertyPath path, IJsonValue value, out bool result) + { + result = default; + + if (value is JsonBoolean jsonBoolean) + { + result = jsonBoolean.Value; + + return true; + } + + errors.Add($"Expected Boolean for path '{path}', but got {value.Type}."); + + return false; + } + + private static bool TryParseNumber(List errors, PropertyPath path, IJsonValue value, out double result) + { + result = default; + + if (value is JsonNumber jsonNumber) + { + result = jsonNumber.Value; + + return true; + } + + errors.Add($"Expected Number for path '{path}', but got {value.Type}."); + + return false; + } + + private static bool TryParseString(List errors, PropertyPath path, IJsonValue value, out string result) + { + result = default; + + if (value is JsonString jsonString) + { + result = jsonString.Value; + + return true; + } + else if (value is JsonNull) + { + return true; + } + + errors.Add($"Expected String for path '{path}', but got {value.Type}."); + + return false; + } + + private static bool TryParseGuid(List errors, PropertyPath path, IJsonValue value, out Guid result) + { + result = default; + + if (value is JsonString jsonString) + { + if (Guid.TryParse(jsonString.Value, out result)) + { + return true; + } + + errors.Add($"Expected Guid String for path '{path}', but got invalid String."); + } + else + { + errors.Add($"Expected Guid String for path '{path}', but got {value.Type}."); + } + + return false; + } + + private static bool TryParseDateTime(List errors, PropertyPath path, IJsonValue value, out Instant result) + { + result = default; + + if (value is JsonString jsonString) + { + var parsed = InstantPattern.General.Parse(jsonString.Value); + + if (parsed.Success) + { + result = parsed.Value; + + return true; + } + + errors.Add($"Expected ISO8601 DateTime String for path '{path}', but got invalid String."); + } + else + { + errors.Add($"Expected ISO8601 DateTime String for path '{path}', but got {value.Type}."); + } + + return false; + } + + private static JsonObjectType GetType(JsonSchema schema) + { + if (schema.Item != null) + { + return schema.Item.Type; + } + + return schema.Type; + } + } +} diff --git a/src/Squidex.Infrastructure/Queries/LogicalFilter.cs b/src/Squidex.Infrastructure/Queries/LogicalFilter.cs index 27456177f..1fc2a1416 100644 --- a/src/Squidex.Infrastructure/Queries/LogicalFilter.cs +++ b/src/Squidex.Infrastructure/Queries/LogicalFilter.cs @@ -18,7 +18,6 @@ namespace Squidex.Infrastructure.Queries public LogicalFilter(LogicalFilterType type, IReadOnlyList> filters) { Guard.NotNull(filters, nameof(filters)); - Guard.GreaterEquals(filters.Count, 2, nameof(filters.Count)); Guard.Enum(type, nameof(type)); Filters = filters; diff --git a/src/Squidex.Infrastructure/Queries/OData/FilterBuilder.cs b/src/Squidex.Infrastructure/Queries/OData/FilterBuilder.cs index 237a0141c..1c4b8137f 100644 --- a/src/Squidex.Infrastructure/Queries/OData/FilterBuilder.cs +++ b/src/Squidex.Infrastructure/Queries/OData/FilterBuilder.cs @@ -14,34 +14,36 @@ namespace Squidex.Infrastructure.Queries.OData { public static void ParseFilter(this ODataUriParser query, ClrQuery result) { - SearchClause search; + SearchClause searchClause; try { - search = query.ParseSearch(); + searchClause = query.ParseSearch(); } catch (ODataException ex) { throw new ValidationException("Query $search clause not valid.", new ValidationError(ex.Message)); } - if (search != null) + if (searchClause != null) { - result.FullText = SearchTermVisitor.Visit(search.Expression).ToString(); + result.FullText = SearchTermVisitor.Visit(searchClause.Expression).ToString(); } - FilterClause filter; + FilterClause filterClause; try { - filter = query.ParseFilter(); + filterClause = query.ParseFilter(); } catch (ODataException ex) { throw new ValidationException("Query $filter clause not valid.", new ValidationError(ex.Message)); } - if (filter != null) + if (filterClause != null) { - result.Filter = FilterVisitor.Visit(filter.Expression); + var filter = FilterVisitor.Visit(filterClause.Expression); + + result.Filter = Optimizer.Optimize(filter); } } } diff --git a/src/Squidex.Infrastructure/Queries/Optimizer.cs b/src/Squidex.Infrastructure/Queries/Optimizer.cs new file mode 100644 index 000000000..7a8cb170d --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/Optimizer.cs @@ -0,0 +1,67 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Linq; + +namespace Squidex.Infrastructure.Queries +{ + public sealed class Optimizer : TransformVisitor + { + private static readonly Optimizer Instance = new Optimizer(); + + private Optimizer() + { + } + + public static FilterNode Optimize(FilterNode source) + { + return source?.Accept(Instance); + } + + public override FilterNode Visit(LogicalFilter nodeIn) + { + var pruned = nodeIn.Filters.Select(x => x.Accept(this)).Where(x => x != null).ToList(); + + if (pruned.Count == 1) + { + return pruned[0]; + } + + if (pruned.Count == 0) + { + return null; + } + + return new LogicalFilter(nodeIn.Type, pruned); + } + + public override FilterNode Visit(NegateFilter nodeIn) + { + var pruned = nodeIn.Filter.Accept(this); + + if (pruned == null) + { + return null; + } + + if (pruned is CompareFilter comparison) + { + if (comparison.Operator == CompareOperator.Equals) + { + return new CompareFilter(comparison.Path, CompareOperator.NotEquals, comparison.Value); + } + + if (comparison.Operator == CompareOperator.NotEquals) + { + return new CompareFilter(comparison.Path, CompareOperator.Equals, comparison.Value); + } + } + + return new NegateFilter(pruned); + } + } +} diff --git a/src/Squidex.Infrastructure/Queries/SortNode.cs b/src/Squidex.Infrastructure/Queries/SortNode.cs index 31e717f27..fa4e47919 100644 --- a/src/Squidex.Infrastructure/Queries/SortNode.cs +++ b/src/Squidex.Infrastructure/Queries/SortNode.cs @@ -11,23 +11,23 @@ namespace Squidex.Infrastructure.Queries { public PropertyPath Path { get; } - public SortOrder SortOrder { get; set; } + public SortOrder Order { get; set; } - public SortNode(PropertyPath path, SortOrder sortOrder) + public SortNode(PropertyPath path, SortOrder order) { Guard.NotNull(path, nameof(path)); - Guard.Enum(sortOrder, nameof(sortOrder)); + Guard.Enum(order, nameof(order)); Path = path; - SortOrder = sortOrder; + Order = order; } public override string ToString() { var path = string.Join(".", Path); - return $"{path} {SortOrder}"; + return $"{path} {Order}"; } } } \ No newline at end of file diff --git a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs index 95a63d71f..7469bc195 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs @@ -89,6 +89,7 @@ namespace Squidex.Areas.Api.Controllers.Assets /// /// The name of the app. /// The optional asset ids. + /// The optional json query. /// /// 200 => Assets returned. /// 404 => App not found. @@ -101,9 +102,13 @@ namespace Squidex.Areas.Api.Controllers.Assets [ProducesResponseType(typeof(AssetsDto), 200)] [ApiPermission(Permissions.AppAssetsRead)] [ApiCosts(1)] - public async Task GetAssets(string app, [FromQuery] string ids = null) + public async Task GetAssets(string app, [FromQuery] string ids = null, [FromQuery] string q = null) { - var assets = await assetQuery.QueryAsync(Context, Q.Empty.WithODataQuery(Request.QueryString.ToString()).WithIds(ids)); + var assets = await assetQuery.QueryAsync(Context, + Q.Empty + .WithIds(ids) + .WithJsonQuery(q) + .WithODataQuery(Request.QueryString.ToString())); var response = Deferred.Response(() => { diff --git a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index efbfe5454..a81d26318 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -148,6 +148,7 @@ namespace Squidex.Areas.Api.Controllers.Contents /// The name of the app. /// The name of the schema. /// The optional ids of the content to fetch. + /// The optional json query. /// /// 200 => Contents retrieved. /// 404 => Schema or app not found. @@ -160,11 +161,15 @@ namespace Squidex.Areas.Api.Controllers.Contents [ProducesResponseType(typeof(ContentsDto), 200)] [ApiPermission] [ApiCosts(1)] - public async Task GetContents(string app, string name, [FromQuery] string ids = null) + public async Task GetContents(string app, string name, [FromQuery] string ids = null, [FromQuery] string q = null) { var schema = await contentQuery.GetSchemaOrThrowAsync(Context, name); - var contents = await contentQuery.QueryAsync(Context, name, Q.Empty.WithIds(ids).WithODataQuery(Request.QueryString.ToString())); + var contents = await contentQuery.QueryAsync(Context, name, + Q.Empty + .WithIds(ids) + .WithJsonQuery(q) + .WithODataQuery(Request.QueryString.ToString())); var response = Deferred.AsyncResponse(async () => { diff --git a/src/Squidex/Config/Domain/EntitiesServices.cs b/src/Squidex/Config/Domain/EntitiesServices.cs index c2693fffc..dec4b85a2 100644 --- a/src/Squidex/Config/Domain/EntitiesServices.cs +++ b/src/Squidex/Config/Domain/EntitiesServices.cs @@ -28,11 +28,11 @@ using Squidex.Domain.Apps.Entities.Apps.Invitation; using Squidex.Domain.Apps.Entities.Apps.Templates; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets.Commands; +using Squidex.Domain.Apps.Entities.Assets.Queries; using Squidex.Domain.Apps.Entities.Backup; using Squidex.Domain.Apps.Entities.Comments; using Squidex.Domain.Apps.Entities.Comments.Commands; using Squidex.Domain.Apps.Entities.Contents; -using Squidex.Domain.Apps.Entities.Contents.Edm; using Squidex.Domain.Apps.Entities.Contents.GraphQL; using Squidex.Domain.Apps.Entities.Contents.Text; using Squidex.Domain.Apps.Entities.History; @@ -102,12 +102,18 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + services.AddSingletonAs() + .AsSelf(); + services.AddSingletonAs() .As(); services.AddSingletonAs(c => new Lazy(() => c.GetRequiredService())) .AsSelf(); + services.AddSingletonAs() + .AsSelf(); + services.AddSingletonAs() .As(); @@ -132,9 +138,6 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .AsSelf(); - services.AddSingletonAs() - .AsSelf(); - services.AddSingletonAs() .As(); diff --git a/src/Squidex/Config/Domain/SerializationServices.cs b/src/Squidex/Config/Domain/SerializationServices.cs index 9217ff2fb..006f6c984 100644 --- a/src/Squidex/Config/Domain/SerializationServices.cs +++ b/src/Squidex/Config/Domain/SerializationServices.cs @@ -19,6 +19,7 @@ using Squidex.Domain.Apps.Events; using Squidex.Infrastructure; using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Json.Newtonsoft; +using Squidex.Infrastructure.Queries.Json; namespace Squidex.Config.Domain { @@ -34,6 +35,7 @@ namespace Squidex.Config.Domain new AppPatternsConverter(), new ClaimsPrincipalConverter(), new EnvelopeHeadersConverter(), + new FilterConverter(), new InstantConverter(), new JsonValueConverter(), new LanguageConverter(), @@ -41,6 +43,7 @@ namespace Squidex.Config.Domain new NamedGuidIdConverter(), new NamedLongIdConverter(), new NamedStringIdConverter(), + new PropertyPathConverter(), new RefTokenConverter(), new RolesConverter(), new RuleConverter(), diff --git a/tests/Squidex.Domain.Apps.Core.Tests/TestUtils.cs b/tests/Squidex.Domain.Apps.Core.Tests/TestUtils.cs index e6e9b36d5..91f97d5a1 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/TestUtils.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/TestUtils.cs @@ -20,6 +20,7 @@ using Squidex.Infrastructure; using Squidex.Infrastructure.Collections; using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Json.Newtonsoft; +using Squidex.Infrastructure.Queries.Json; using Xunit; namespace Squidex.Domain.Apps.Core @@ -46,6 +47,7 @@ namespace Squidex.Domain.Apps.Core new AppPatternsConverter(), new ClaimsPrincipalConverter(), new EnvelopeHeadersConverter(), + new FilterConverter(), new InstantConverter(), new JsonValueConverter(), new LanguageConverter(), @@ -53,6 +55,7 @@ namespace Squidex.Domain.Apps.Core new NamedGuidIdConverter(), new NamedLongIdConverter(), new NamedStringIdConverter(), + new PropertyPathConverter(), new RefTokenConverter(), new RolesConverter(), new RuleConverter(), diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetQueryServiceTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetQueryServiceTests.cs index 1e8471e88..23e696f8d 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetQueryServiceTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetQueryServiceTests.cs @@ -10,8 +10,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using FakeItEasy; -using Microsoft.Extensions.Options; -using Squidex.Domain.Apps.Core.Tags; +using Squidex.Domain.Apps.Entities.Assets.Queries; using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure; @@ -24,26 +23,19 @@ namespace Squidex.Domain.Apps.Entities.Assets { private readonly IAssetEnricher assetEnricher = A.Fake(); private readonly IAssetRepository assetRepository = A.Fake(); - private readonly ITagService tagService = A.Fake(); private readonly NamedId appId = NamedId.Of(Guid.NewGuid(), "my-app"); private readonly Context requestContext; + private readonly AssetQueryParser queryParser = A.Fake(); private readonly AssetQueryService sut; public AssetQueryServiceTests() { requestContext = new Context(Mocks.FrontendUser(), Mocks.App(appId)); - var options = Options.Create(new AssetOptions { DefaultPageSize = 30 }); + A.CallTo(() => queryParser.ParseQuery(requestContext, A.Ignored)) + .Returns(new ClrQuery()); - sut = new AssetQueryService(tagService, assetEnricher, assetRepository, options); - } - - [Fact] - public void Should_provide_default_page_size() - { - var result = sut.DefaultPageSizeGraphQl; - - Assert.Equal(20, result); + sut = new AssetQueryService(assetEnricher, assetRepository, queryParser); } [Fact] @@ -127,51 +119,5 @@ namespace Squidex.Domain.Apps.Entities.Assets Assert.Equal(new[] { enriched1, enriched2 }, result.ToArray()); } - - [Fact] - public async Task Should_transform_odata_query() - { - var query = Q.Empty.WithODataQuery("$top=100&$orderby=fileName asc&$search=Hello World"); - - await sut.QueryAsync(requestContext, query); - - A.CallTo(() => assetRepository.QueryAsync(appId.Id, A.That.Is("FullText: 'Hello World'; Take: 100; Sort: fileName Ascending"))) - .MustHaveHappened(); - } - - [Fact] - public async Task Should_transform_odata_query_and_enrich_with_defaults() - { - var query = Q.Empty.WithODataQuery("$top=200&$filter=fileName eq 'ABC'"); - - await sut.QueryAsync(requestContext, query); - - A.CallTo(() => assetRepository.QueryAsync(appId.Id, A.That.Is("Filter: fileName == 'ABC'; Take: 200; Sort: lastModified Descending"))) - .MustHaveHappened(); - } - - [Fact] - public async Task Should_apply_default_page_size() - { - var query = Q.Empty; - - await sut.QueryAsync(requestContext, query); - - A.CallTo(() => assetRepository.QueryAsync(appId.Id, - A.That.Is("Take: 30; Sort: lastModified Descending"))) - .MustHaveHappened(); - } - - [Fact] - public async Task Should_limit_number_of_assets() - { - var query = Q.Empty.WithODataQuery("$top=300&$skip=20"); - - await sut.QueryAsync(requestContext, query); - - A.CallTo(() => assetRepository.QueryAsync(appId.Id, - A.That.Is("Skip: 20; Take: 200; Sort: lastModified Descending"))) - .MustHaveHappened(); - } } } \ No newline at end of file diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryParserTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryParserTests.cs new file mode 100644 index 000000000..0f00124d1 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryParserTests.cs @@ -0,0 +1,129 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using FakeItEasy; +using Microsoft.Extensions.Options; +using Squidex.Domain.Apps.Core.Tags; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Assets.Queries +{ + public class AssetQueryParserTests + { + private readonly ITagService tagService = A.Fake(); + private readonly NamedId appId = NamedId.Of(Guid.NewGuid(), "my-app"); + private readonly Context requestContext; + private readonly AssetQueryParser sut; + + public AssetQueryParserTests() + { + requestContext = new Context(Mocks.FrontendUser(), Mocks.App(appId)); + + var options = Options.Create(new AssetOptions { DefaultPageSize = 30 }); + + sut = new AssetQueryParser(JsonHelper.DefaultSerializer, tagService, options); + } + + [Fact] + public void Should_throw_if_odata_query_is_invalid() + { + var query = Q.Empty.WithODataQuery("$filter=invalid"); + + Assert.Throws(() => sut.ParseQuery(requestContext, query)); + } + + [Fact] + public void Should_throw_if_json_query_is_invalid() + { + var query = Q.Empty.WithJsonQuery("invalid"); + + Assert.Throws(() => sut.ParseQuery(requestContext, query)); + } + + [Fact] + public void Should_parse_odata_query() + { + var query = Q.Empty.WithODataQuery("$top=100&$orderby=fileName asc&$search=Hello World"); + + var parsed = sut.ParseQuery(requestContext, query); + + Assert.Equal("FullText: 'Hello World'; Take: 100; Sort: fileName Ascending", parsed.ToString()); + } + + [Fact] + public void Should_parse_odata_query_and_enrich_with_defaults() + { + var query = Q.Empty.WithODataQuery("$top=200&$filter=fileName eq 'ABC'"); + + var parsed = sut.ParseQuery(requestContext, query); + + Assert.Equal("Filter: fileName == 'ABC'; Take: 200; Sort: lastModified Descending", parsed.ToString()); + } + + [Fact] + public void Should_parse_json_query_and_enrich_with_defaults() + { + var query = Q.Empty.WithJsonQuery(Json("{ 'filter': { 'path': 'fileName', 'op': 'eq', 'value': 'ABC' } }")); + + var parsed = sut.ParseQuery(requestContext, query); + + Assert.Equal("Filter: fileName == 'ABC'; Take: 30; Sort: lastModified Descending", parsed.ToString()); + } + + [Fact] + public void Should_parse_json_full_text_query_and_enrich_with_defaults() + { + var query = Q.Empty.WithJsonQuery(Json("{ 'fullText': 'Hello' }")); + + var parsed = sut.ParseQuery(requestContext, query); + + Assert.Equal("FullText: 'Hello'; Take: 30; Sort: lastModified Descending", parsed.ToString()); + } + + [Fact] + public void Should_apply_default_page_size() + { + var query = Q.Empty; + + var parsed = sut.ParseQuery(requestContext, query); + + Assert.Equal("Take: 30; Sort: lastModified Descending", parsed.ToString()); + } + + [Fact] + public void Should_limit_number_of_assets() + { + var query = Q.Empty.WithODataQuery("$top=300&$skip=20"); + + var parsed = sut.ParseQuery(requestContext, query); + + Assert.Equal("Skip: 20; Take: 200; Sort: lastModified Descending", parsed.ToString()); + } + + [Fact] + public void Should_denormalize_tags() + { + A.CallTo(() => tagService.GetTagIdsAsync(appId.Id, TagGroups.Assets, A>.That.Contains("name1"))) + .Returns(new Dictionary { ["name1"] = "id1" }); + + var query = Q.Empty.WithODataQuery("$filter=tags eq 'name1'"); + + var parsed = sut.ParseQuery(requestContext, query); + + Assert.Equal("Filter: tags == 'id1'; Take: 30; Sort: lastModified Descending", parsed.ToString()); + } + + private static string Json(string text) + { + return text.Replace('\'', '"'); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/FilterTagTransformerTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/FilterTagTransformerTests.cs index c5fc77ec1..f0da996fd 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/FilterTagTransformerTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/FilterTagTransformerTests.cs @@ -26,6 +26,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries .Returns(new Dictionary { ["name1"] = "id1" }); var source = ClrFilter.Eq("tags", "name1"); + var result = FilterTagTransformer.Transform(source, appId, tagService); Assert.Equal("tags == 'id1'", result.ToString()); @@ -38,6 +39,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries .Returns(new Dictionary()); var source = ClrFilter.Eq("tags", "name1"); + var result = FilterTagTransformer.Transform(source, appId, tagService); Assert.Equal("tags == 'name1'", result.ToString()); @@ -47,6 +49,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries public void Should_not_normalize_other_field() { var source = ClrFilter.Eq("other", "value"); + var result = FilterTagTransformer.Transform(source, appId, tagService); Assert.Equal("other == 'value'", result.ToString()); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs index 9097bba36..3d4f9f91b 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs @@ -11,14 +11,11 @@ using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using FakeItEasy; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.ConvertContent; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Entities.Apps; -using Squidex.Domain.Apps.Entities.Contents.Edm; using Squidex.Domain.Apps.Entities.Contents.Repositories; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.TestHelpers; @@ -51,8 +48,8 @@ namespace Squidex.Domain.Apps.Entities.Contents private readonly NamedContentData contentTransformed = new NamedContentData(); private readonly ClaimsPrincipal user; private readonly ClaimsIdentity identity = new ClaimsIdentity(); - private readonly EdmModelBuilder modelBuilder = new EdmModelBuilder(new MemoryCache(Options.Create(new MemoryCacheOptions()))); private readonly Context requestContext; + private readonly ContentQueryParser queryParser = A.Fake(); private readonly ContentQueryService sut; public static IEnumerable ApiStatusTests = new[] @@ -77,7 +74,8 @@ namespace Squidex.Domain.Apps.Entities.Contents SetupEnricher(); - var options = Options.Create(new ContentOptions { DefaultPageSize = 30 }); + A.CallTo(() => queryParser.ParseQuery(requestContext, schema, A.Ignored)) + .Returns(new ClrQuery()); sut = new ContentQueryService( appProvider, @@ -86,16 +84,7 @@ namespace Squidex.Domain.Apps.Entities.Contents contentRepository, contentVersionLoader, scriptEngine, - options, - modelBuilder); - } - - [Fact] - public void Should_provide_default_page_size() - { - var result = sut.DefaultPageSizeGraphQl; - - Assert.Equal(20, result); + queryParser); } [Fact] @@ -138,38 +127,6 @@ namespace Squidex.Domain.Apps.Entities.Contents await Assert.ThrowsAsync(() => sut.GetSchemaOrThrowAsync(ctx, schemaId.Name)); } - [Fact] - public async Task Should_apply_default_page_size() - { - SetupUser(isFrontend: false); - SetupSchemaFound(); - - var query = Q.Empty; - - await sut.QueryAsync(requestContext, schemaId.Name, query); - - A.CallTo(() => contentRepository.QueryAsync(app, schema, A.That.Is(Status.Published), false, - A.That.Is("Take: 30; Sort: lastModified Descending"), false)) - .MustHaveHappened(); - } - - [Fact] - public async Task Should_limit_number_of_contents() - { - var status = new[] { Status.Published }; - - SetupUser(isFrontend: false); - SetupSchemaFound(); - - var query = Q.Empty.WithODataQuery("$top=300&$skip=20"); - - await sut.QueryAsync(requestContext, schemaId.Name, query); - - A.CallTo(() => contentRepository.QueryAsync(app, schema, A.That.Is(status), false, - A.That.Is("Skip: 20; Take: 200; Sort: lastModified Descending"), false)) - .MustHaveHappened(); - } - [Fact] public async Task Should_throw_for_single_content_if_no_permission() { @@ -320,17 +277,6 @@ namespace Squidex.Domain.Apps.Entities.Contents .MustHaveHappened(count, Times.Exactly); } - [Fact] - public async Task Should_throw_if_query_is_invalid() - { - SetupUser(isFrontend: false); - SetupSchemaFound(); - - var query = Q.Empty.WithODataQuery("$filter=invalid"); - - await Assert.ThrowsAsync(() => sut.QueryAsync(requestContext, schemaId.Name, query)); - } - [Fact] public async Task Should_query_contents_by_id_for_frontend_and_transform() { diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs index 3817ba7ec..d083fc3f3 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs @@ -219,6 +219,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL [typeof(IContentQueryService)] = contentQuery, [typeof(IDataLoaderContextAccessor)] = dataLoaderContext, [typeof(IGraphQLUrlGenerator)] = new FakeUrlGenerator(), + [typeof(IOptions)] = Options.Create(new AssetOptions()), + [typeof(IOptions)] = Options.Create(new ContentOptions()), [typeof(ISemanticLog)] = A.Fake(), [typeof(DataLoaderDocumentListener)] = new DataLoaderDocumentListener(dataLoaderContext) }; diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryParserTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryParserTests.cs new file mode 100644 index 000000000..aa4b7a440 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryParserTests.cs @@ -0,0 +1,126 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Contents.Queries +{ + public class ContentQueryParserTests + { + private readonly ISchemaEntity schema; + private readonly NamedId appId = NamedId.Of(Guid.NewGuid(), "my-app"); + private readonly NamedId schemaId = NamedId.Of(Guid.NewGuid(), "my-app"); + private readonly Context requestContext; + private readonly ContentQueryParser sut; + + public ContentQueryParserTests() + { + var options = Options.Create(new ContentOptions { DefaultPageSize = 30 }); + + requestContext = new Context(Mocks.FrontendUser(), Mocks.App(appId)); + + var schemaDef = + new Schema(schemaId.Name) + .AddString(1, "firstName", Partitioning.Invariant); + + schema = Mocks.Schema(appId, schemaId, schemaDef); + + var cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); + + sut = new ContentQueryParser(cache, JsonHelper.DefaultSerializer, options); + } + + [Fact] + public void Should_throw_if_odata_query_is_invalid() + { + var query = Q.Empty.WithODataQuery("$filter=invalid"); + + Assert.Throws(() => sut.ParseQuery(requestContext, schema, query)); + } + + [Fact] + public void Should_throw_if_json_query_is_invalid() + { + var query = Q.Empty.WithJsonQuery("invalid"); + + Assert.Throws(() => sut.ParseQuery(requestContext, schema, query)); + } + + [Fact] + public void Should_parse_odata_query() + { + var query = Q.Empty.WithODataQuery("$top=100&$orderby=data/firstName/iv asc&$search=Hello World"); + + var parsed = sut.ParseQuery(requestContext, schema, query); + + Assert.Equal("FullText: 'Hello World'; Take: 100; Sort: data.firstName.iv Ascending", parsed.ToString()); + } + + [Fact] + public void Should_parse_odata_query_and_enrich_with_defaults() + { + var query = Q.Empty.WithODataQuery("$top=200&$filter=data/firstName/iv eq 'ABC'"); + + var parsed = sut.ParseQuery(requestContext, schema, query); + + Assert.Equal("Filter: data.firstName.iv == 'ABC'; Take: 200; Sort: lastModified Descending", parsed.ToString()); + } + + [Fact] + public void Should_parse_json_query_and_enrich_with_defaults() + { + var query = Q.Empty.WithJsonQuery(Json("{ 'filter': { 'path': 'data.firstName.iv', 'op': 'eq', 'value': 'ABC' } }")); + + var parsed = sut.ParseQuery(requestContext, schema, query); + + Assert.Equal("Filter: data.firstName.iv == 'ABC'; Take: 30; Sort: lastModified Descending", parsed.ToString()); + } + + [Fact] + public void Should_parse_json_full_text_query_and_enrich_with_defaults() + { + var query = Q.Empty.WithJsonQuery(Json("{ 'fullText': 'Hello' }")); + + var parsed = sut.ParseQuery(requestContext, schema, query); + + Assert.Equal("FullText: 'Hello'; Take: 30; Sort: lastModified Descending", parsed.ToString()); + } + + [Fact] + public void Should_apply_default_page_size() + { + var query = Q.Empty; + + var parsed = sut.ParseQuery(requestContext, schema, query); + + Assert.Equal("Take: 30; Sort: lastModified Descending", parsed.ToString()); + } + + [Fact] + public void Should_limit_number_of_contents() + { + var query = Q.Empty.WithODataQuery("$top=300&$skip=20"); + + var parsed = sut.ParseQuery(requestContext, schema, query); + + Assert.Equal("Skip: 20; Take: 200; Sort: lastModified Descending", parsed.ToString()); + } + + private static string Json(string text) + { + return text.Replace('\'', '"'); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/FilterTagTransformerTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/FilterTagTransformerTests.cs index a23603668..a566fbeec 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/FilterTagTransformerTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/FilterTagTransformerTests.cs @@ -44,6 +44,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries .Returns(new Dictionary { ["name1"] = "id1" }); var source = ClrFilter.Eq("data.tags2.iv", "name1"); + var result = FilterTagTransformer.Transform(source, appId.Id, schema, tagService); Assert.Equal("data.tags2.iv == 'id1'", result.ToString()); @@ -56,6 +57,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries .Returns(new Dictionary()); var source = ClrFilter.Eq("data.tags2.iv", "name1"); + var result = FilterTagTransformer.Transform(source, appId.Id, schema, tagService); Assert.Equal("data.tags2.iv == 'name1'", result.ToString()); @@ -65,6 +67,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries public void Should_not_normalize_other_tags_field() { var source = ClrFilter.Eq("data.tags1.iv", "value"); + var result = FilterTagTransformer.Transform(source, appId.Id, schema, tagService); Assert.Equal("data.tags1.iv == 'value'", result.ToString()); @@ -77,6 +80,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries public void Should_not_normalize_other_typed_field() { var source = ClrFilter.Eq("data.string.iv", "value"); + var result = FilterTagTransformer.Transform(source, appId.Id, schema, tagService); Assert.Equal("data.string.iv == 'value'", result.ToString()); @@ -89,6 +93,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries public void Should_not_normalize_non_data_field() { var source = ClrFilter.Eq("no.data", "value"); + var result = FilterTagTransformer.Transform(source, appId.Id, schema, tagService); Assert.Equal("no.data == 'value'", result.ToString()); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/JsonHelper.cs b/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/JsonHelper.cs new file mode 100644 index 000000000..84875b4f4 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/JsonHelper.cs @@ -0,0 +1,68 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Json; +using Squidex.Infrastructure.Json.Newtonsoft; +using Squidex.Infrastructure.Queries.Json; + +namespace Squidex.Domain.Apps.Entities.TestHelpers +{ + public static class JsonHelper + { + public static readonly IJsonSerializer DefaultSerializer = CreateSerializer(); + + public static IJsonSerializer CreateSerializer(TypeNameRegistry typeNameRegistry = null) + { + var serializerSettings = DefaultSettings(typeNameRegistry); + + return new NewtonsoftJsonSerializer(serializerSettings); + } + + public static JsonSerializerSettings DefaultSettings(TypeNameRegistry typeNameRegistry = null) + { + return new JsonSerializerSettings + { + SerializationBinder = new TypeNameSerializationBinder(typeNameRegistry ?? new TypeNameRegistry()), + + ContractResolver = new ConverterContractResolver( + new ClaimsPrincipalConverter(), + new InstantConverter(), + new EnvelopeHeadersConverter(), + new FilterConverter(), + new JsonValueConverter(), + new LanguageConverter(), + new NamedGuidIdConverter(), + new NamedLongIdConverter(), + new NamedStringIdConverter(), + new PropertyPathConverter(), + new RefTokenConverter(), + new StringEnumConverter()), + + TypeNameHandling = TypeNameHandling.Auto + }; + } + + public static T SerializeAndDeserialize(this T value) + { + return DefaultSerializer.Deserialize>(DefaultSerializer.Serialize(Tuple.Create(value))).Item1; + } + + public static T Deserialize(string value) + { + return DefaultSerializer.Deserialize>($"{{ \"Item1\": \"{value}\" }}").Item1; + } + + public static T Deserialize(object value) + { + return DefaultSerializer.Deserialize>($"{{ \"Item1\": {value} }}").Item1; + } + } +} diff --git a/tests/Squidex.Infrastructure.Tests/Queries/QueryJsonConversionTests.cs b/tests/Squidex.Infrastructure.Tests/Queries/QueryJsonConversionTests.cs new file mode 100644 index 000000000..8e0843325 --- /dev/null +++ b/tests/Squidex.Infrastructure.Tests/Queries/QueryJsonConversionTests.cs @@ -0,0 +1,373 @@ +// ========================================================================== +// 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 Xunit; + +namespace Squidex.Infrastructure.Queries +{ + public sealed class QueryJsonConversionTests + { + private readonly List errors = new List(); + private readonly JsonSchema schema = new JsonSchema(); + + public QueryJsonConversionTests() + { + var nested = new JsonSchemaProperty { Title = "nested" }; + + 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_add_error_if_datetime_string_property_got_invalid_string_value() + { + var json = new { path = "datetime", op = "eq", value = "invalid" }; + + AssertErrors(json, "Expected ISO8601 DateTime String for path 'datetime', but got invalid String."); + } + + [Fact] + public void Should_add_error_if_datetime_string_property_got_invalid_value() + { + var json = new { path = "datetime", op = "eq", value = 1 }; + + AssertErrors(json, "Expected ISO8601 DateTime String for path 'datetime', but got Number."); + } + + [Theory] + [InlineData("contains", "contains(guid, bf57d32c-d4dd-4217-8c16-6dcb16975cf3)")] + [InlineData("empty", "empty(guid)")] + [InlineData("endswith", "endsWith(guid, bf57d32c-d4dd-4217-8c16-6dcb16975cf3)")] + [InlineData("eq", "guid == bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] + [InlineData("ge", "guid >= bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] + [InlineData("gt", "guid > bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] + [InlineData("le", "guid <= bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] + [InlineData("lt", "guid < bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] + [InlineData("ne", "guid != bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] + [InlineData("startswith", "startsWith(guid, bf57d32c-d4dd-4217-8c16-6dcb16975cf3)")] + public void Should_parse_guid_string_filter(string op, string expected) + { + var json = new { path = "guid", op, value = "bf57d32c-d4dd-4217-8c16-6dcb16975cf3" }; + + AssertFilter(json, expected); + } + + [Fact] + public void Should_add_error_if_guid_string_property_got_invalid_string_value() + { + var json = new { path = "guid", op = "eq", value = "invalid" }; + + AssertErrors(json, "Expected Guid String for path 'guid', but got invalid String."); + } + + [Fact] + public void Should_add_error_if_guid_string_property_got_invalid_value() + { + var json = new { path = "guid", op = "eq", value = 1 }; + + AssertErrors(json, "Expected Guid String for path 'guid', but got Number."); + } + + [Theory] + [InlineData("contains", "contains(string, 'Hello')")] + [InlineData("empty", "empty(string)")] + [InlineData("endswith", "endsWith(string, 'Hello')")] + [InlineData("eq", "string == 'Hello'")] + [InlineData("ge", "string >= 'Hello'")] + [InlineData("gt", "string > 'Hello'")] + [InlineData("le", "string <= 'Hello'")] + [InlineData("lt", "string < 'Hello'")] + [InlineData("ne", "string != 'Hello'")] + [InlineData("startswith", "startsWith(string, 'Hello')")] + public void Should_parse_string_filter(string op, string expected) + { + var json = new { path = "string", op, value = "Hello" }; + + AssertFilter(json, expected); + } + + [Fact] + public void Should_add_error_if_string_property_got_invalid_value() + { + var json = new { path = "string", op = "eq", value = 1 }; + + AssertErrors(json, "Expected String for path 'string', but got Number."); + } + + [Fact] + public void Should_parse_string_in_filter() + { + var json = new { path = "string", op = "in", value = new[] { "Hello" } }; + + AssertFilter(json, "string in ['Hello']"); + } + + [Fact] + public void Should_parse_nested_string_filter() + { + var json = new { path = "object.property", op = "in", value = new[] { "Hello" } }; + + AssertFilter(json, "object.property in ['Hello']"); + } + + [Fact] + public void Should_parse_referenced_string_filter() + { + var json = new { path = "reference.property", op = "in", value = new[] { "Hello" } }; + + AssertFilter(json, "reference.property in ['Hello']"); + } + + [Theory] + [InlineData("eq", "number == 12")] + [InlineData("ge", "number >= 12")] + [InlineData("gt", "number > 12")] + [InlineData("le", "number <= 12")] + [InlineData("lt", "number < 12")] + [InlineData("ne", "number != 12")] + public void Should_parse_number_filter(string op, string expected) + { + var json = new { path = "number", op, value = 12 }; + + AssertFilter(json, expected); + } + + [Fact] + public void Should_add_error_if_number_property_got_invalid_value() + { + var json = new { path = "number", op = "eq", value = true }; + + AssertErrors(json, "Expected Number for path 'number', but got Boolean."); + } + + [Fact] + public void Should_parse_number_in_filter() + { + var json = new { path = "number", op = "in", value = new[] { 12 } }; + + AssertFilter(json, "number in [12]"); + } + + [Theory] + [InlineData("eq", "boolean == True")] + [InlineData("ne", "boolean != True")] + public void Should_parse_boolean_filter(string op, string expected) + { + var json = new { path = "boolean", op, value = true }; + + AssertFilter(json, expected); + } + + [Fact] + public void Should_add_error_if_boolean_property_got_invalid_value() + { + var json = new { path = "boolean", op = "eq", value = 1 }; + + AssertErrors(json, "Expected Boolean for path 'boolean', but got Number."); + } + + [Fact] + public void Should_parse_boolean_in_filter() + { + var json = new { path = "boolean", op = "in", value = new[] { true } }; + + AssertFilter(json, "boolean in [True]"); + } + + [Theory] + [InlineData("empty", "empty(stringArray)")] + [InlineData("eq", "stringArray == 'Hello'")] + [InlineData("ne", "stringArray != 'Hello'")] + public void Should_parse_array_filter(string op, string expected) + { + var json = new { path = "stringArray", op, value = "Hello" }; + + AssertFilter(json, expected); + } + + [Fact] + public void Should_parse_array_in_filter() + { + var json = new { path = "stringArray", op = "in", value = new[] { "Hello" } }; + + AssertFilter(json, "stringArray in ['Hello']"); + } + + [Fact] + public void Should_add_error_when_using_array_value_for_non_allowed_operator() + { + var json = new { path = "string", op = "eq", value = new[] { "Hello" } }; + + AssertErrors(json, "Array value is not allowed for 'Equals' operator and path 'string'."); + } + + [Fact] + public void Should_parse_query() + { + var json = new { skip = 10, take = 20, FullText = "Hello", Filter = new { path = "string", op = "eq", value = "Hello" } }; + + AssertQuery(json, "Filter: string == 'Hello'; FullText: 'Hello'; Skip: 10; Take: 20"); + } + + [Fact] + public void Should_parse_query_with_sorting() + { + var json = new { sort = new[] { new { path = "string", order = "ascending" } } }; + + AssertQuery(json, "Sort: string Ascending"); + } + + [Fact] + public void Should_throw_exception_for_invalid_query() + { + var json = new { sort = new[] { new { path = "invalid", order = "ascending" } } }; + + Assert.Throws(() => AssertQuery(json, null)); + } + + [Fact] + public void Should_throw_exception_when_parsing_invalid_json() + { + var json = "invalid"; + + Assert.Throws(() => AssertQuery(json, null)); + } + + private void AssertQuery(object json, string expectedFilter) + { + var filter = ConvertQuery(json); + + Assert.Empty(errors); + + Assert.Equal(expectedFilter, filter); + } + + private void AssertFilter(object json, string expectedFilter) + { + var filter = ConvertFilter(json); + + Assert.Empty(errors); + + Assert.Equal(expectedFilter, filter); + } + + private void AssertErrors(object json, params string[] expectedErrors) + { + var filter = ConvertFilter(json); + + Assert.Equal(expectedErrors.ToList(), errors); + + Assert.Null(filter); + } + + private string ConvertFilter(T value) + { + var json = JsonHelper.DefaultSerializer.Serialize(value, true); + + var jsonFilter = JsonHelper.DefaultSerializer.Deserialize>(json); + + return JsonFilterVisitor.Parse(jsonFilter, schema, errors)?.ToString(); + } + + private string ConvertQuery(T value) + { + var json = JsonHelper.DefaultSerializer.Serialize(value, true); + + var jsonFilter = schema.Parse(json, JsonHelper.DefaultSerializer); + + return jsonFilter.ToString(); + } + } +} diff --git a/tests/Squidex.Infrastructure.Tests/Queries/QueryJsonTests.cs b/tests/Squidex.Infrastructure.Tests/Queries/QueryJsonTests.cs index 508c3b676..50173b4f2 100644 --- a/tests/Squidex.Infrastructure.Tests/Queries/QueryJsonTests.cs +++ b/tests/Squidex.Infrastructure.Tests/Queries/QueryJsonTests.cs @@ -15,14 +15,14 @@ namespace Squidex.Infrastructure.Queries public class QueryJsonTests { [Theory] + [InlineData("contains", "contains(property, 12)")] + [InlineData("endswith", "endsWith(property, 12)")] [InlineData("eq", "property == 12")] - [InlineData("ne", "property != 12")] [InlineData("le", "property <= 12")] [InlineData("lt", "property < 12")] [InlineData("ge", "property >= 12")] [InlineData("gt", "property > 12")] - [InlineData("contains", "contains(property, 12)")] - [InlineData("endswith", "endsWith(property, 12)")] + [InlineData("ne", "property != 12")] [InlineData("startswith", "startsWith(property, 12)")] public void Should_convert_comparison(string op, string expected) { diff --git a/tests/Squidex.Infrastructure.Tests/Queries/QueryOptimizationTests.cs b/tests/Squidex.Infrastructure.Tests/Queries/QueryOptimizationTests.cs new file mode 100644 index 000000000..0ebd75b3a --- /dev/null +++ b/tests/Squidex.Infrastructure.Tests/Queries/QueryOptimizationTests.cs @@ -0,0 +1,94 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Xunit; + +namespace Squidex.Infrastructure.Queries +{ + public class QueryOptimizationTests + { + [Fact] + public void Should_not_convert_optimize_valid_logical_filter() + { + var source = ClrFilter.Or(ClrFilter.Eq("path", 2), ClrFilter.Eq("path", 3)); + + var result = Optimizer.Optimize(source); + + Assert.Equal("(path == 2 || path == 3)", result.ToString()); + } + + [Fact] + public void Should_return_filter_When_logical_filter_has_one_child() + { + var source = ClrFilter.And(ClrFilter.Eq("path", 1), ClrFilter.Or()); + + var result = Optimizer.Optimize(source); + + Assert.Equal("path == 1", result.ToString()); + } + + [Fact] + public void Should_return_null_when_filters_of_logical_filter_get_optimized_away() + { + var source = ClrFilter.And(ClrFilter.And()); + + var result = Optimizer.Optimize(source); + + Assert.Null(result); + } + + [Fact] + public void Should_return_null_when_logical_filter_has_no_filter() + { + var source = ClrFilter.And(); + + var result = Optimizer.Optimize(source); + + Assert.Null(result); + } + + [Fact] + public void Should_return_null_when_filter_of_negation_get_optimized_away() + { + var source = ClrFilter.Not(ClrFilter.And()); + + var result = Optimizer.Optimize(source); + + Assert.Null(result); + } + + [Fact] + public void Should_invert_equals_not_filter() + { + var source = ClrFilter.Not(ClrFilter.Eq("path", 1)); + + var result = Optimizer.Optimize(source); + + Assert.Equal("path != 1", result.ToString()); + } + + [Fact] + public void Should_invert_notequals_not_filter() + { + var source = ClrFilter.Not(ClrFilter.Ne("path", 1)); + + var result = Optimizer.Optimize(source); + + Assert.Equal("path == 1", result.ToString()); + } + + [Fact] + public void Should_not_convert_number_operator() + { + var source = ClrFilter.Not(ClrFilter.Lt("path", 1)); + + var result = Optimizer.Optimize(source); + + Assert.Equal("!(path < 1)", result.ToString()); + } + } +}