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/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/AssetEnricher.cs b/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetEnricher.cs similarity index 98% rename from src/Squidex.Domain.Apps.Entities/Assets/AssetEnricher.cs rename to src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetEnricher.cs index 3a1b802e8..342a2a218 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetEnricher.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetEnricher.cs @@ -13,7 +13,7 @@ using Squidex.Infrastructure; using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Reflection; -namespace Squidex.Domain.Apps.Entities.Assets +namespace Squidex.Domain.Apps.Entities.Assets.Queries { public sealed class AssetEnricher : IAssetEnricher { 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/AssetQueryService.cs b/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs similarity index 59% rename from src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs rename to src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs index 7d1fe6cfd..640e95817 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs @@ -8,45 +8,29 @@ 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 +namespace Squidex.Domain.Apps.Entities.Assets.Queries { 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 +77,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 +93,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/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/ContentEntity.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs index 31d6de3b8..40bec7049 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs @@ -6,6 +6,7 @@ // ========================================================================== using System; +using System.Collections.Generic; using NodaTime; using Squidex.Domain.Apps.Core.Contents; using Squidex.Infrastructure; @@ -47,5 +48,7 @@ namespace Squidex.Domain.Apps.Entities.Contents public bool CanUpdate { get; set; } public bool IsPending { get; set; } + + public HashSet CacheDependencies { get; } = new HashSet(); } } \ No newline at end of file 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/GraphQL/GraphQLExecutionContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs index d320f7f4f..b128aabae 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs @@ -13,6 +13,7 @@ using GraphQL; using GraphQL.DataLoader; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types; +using Squidex.Domain.Apps.Entities.Contents.Queries; using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Log; diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Middlewares.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Middlewares.cs index e5198342f..813e4367c 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Middlewares.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Middlewares.cs @@ -34,7 +34,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL .WriteProperty("status", "failed") .WriteProperty("field", context.FieldName)); - throw ex; + throw; } }; }); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs index 5b78b2728..ba0bd5d59 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs @@ -42,7 +42,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types public static async Task> LoadManyAsync(this IDataLoader dataLoader, ICollection keys) where T : class { - var contents = await Task.WhenAll(keys.Select(x => dataLoader.LoadAsync(x))); + var contents = await Task.WhenAll(keys.Select(dataLoader.LoadAsync)); return contents.Where(x => x != null).ToList(); } 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/IEnrichedContentEntity.cs b/src/Squidex.Domain.Apps.Entities/Contents/IEnrichedContentEntity.cs index c4f6580e8..66d125dd9 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/IEnrichedContentEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/IEnrichedContentEntity.cs @@ -9,7 +9,7 @@ using Squidex.Domain.Apps.Core.Contents; namespace Squidex.Domain.Apps.Entities.Contents { - public interface IEnrichedContentEntity : IContentEntity + public interface IEnrichedContentEntity : IContentEntity, IEntityWithCacheDependencies { bool CanUpdate { get; } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentEnricher.cs b/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentEnricher.cs similarity index 86% rename from src/Squidex.Domain.Apps.Entities/Contents/ContentEnricher.cs rename to src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentEnricher.cs index 841b06e3c..fe504e987 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentEnricher.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentEnricher.cs @@ -18,7 +18,7 @@ using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Reflection; -namespace Squidex.Domain.Apps.Entities.Contents +namespace Squidex.Domain.Apps.Entities.Contents.Queries { public sealed class ContentEnricher : IContentEnricher { @@ -61,6 +61,8 @@ namespace Squidex.Domain.Apps.Entities.Contents if (contents.Any()) { + var appVersion = context.App.Version.ToString(); + var cache = new Dictionary<(Guid, Status), StatusInfo>(); foreach (var content in contents) @@ -75,14 +77,27 @@ namespace Squidex.Domain.Apps.Entities.Contents await ResolveCanUpdateAsync(content, result); } + result.CacheDependencies.Add(appVersion); + results.Add(result); } - if (ShouldEnrichWithReferences(context)) + foreach (var group in results.GroupBy(x => x.SchemaId.Id)) { - foreach (var group in results.GroupBy(x => x.SchemaId.Id)) + var schema = await ContentQuery.GetSchemaOrThrowAsync(context, group.Key.ToString()); + + var schemaIdentity = schema.Id.ToString(); + var schemaVersion = schema.Version.ToString(); + + foreach (var content in group) + { + content.CacheDependencies.Add(schemaIdentity); + content.CacheDependencies.Add(schemaVersion); + } + + if (ShouldEnrichWithReferences(context)) { - await ResolveReferencesAsync(group.Key, group, context); + await ResolveReferencesAsync(schema, group, context); } } } @@ -91,10 +106,8 @@ namespace Squidex.Domain.Apps.Entities.Contents } } - private async Task ResolveReferencesAsync(Guid schemaId, IEnumerable contents, Context context) + private async Task ResolveReferencesAsync(ISchemaEntity schema, IEnumerable contents, Context context) { - var schema = await ContentQuery.GetSchemaOrThrowAsync(context, schemaId.ToString()); - var references = await GetReferencesAsync(schema, contents, context); var formatted = new Dictionary(); @@ -116,6 +129,9 @@ namespace Squidex.Domain.Apps.Entities.Contents var referencedSchemaId = field.Properties.SchemaId; var referencedSchema = await ContentQuery.GetSchemaOrThrowAsync(context, referencedSchemaId.ToString()); + var schemaIdentity = referencedSchema.Id.ToString(); + var schemaVersion = referencedSchema.Version.ToString(); + foreach (var content in contents) { var fieldReference = content.ReferenceData[field.Name]; @@ -146,6 +162,9 @@ namespace Squidex.Domain.Apps.Entities.Contents } } } + + content.CacheDependencies.Add(schemaIdentity); + content.CacheDependencies.Add(schemaVersion); } } catch (DomainObjectNotFoundException) 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..80b7e13ce --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs @@ -0,0 +1,203 @@ +// ========================================================================== +// 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.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.Contents.Queries +{ + 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/ContentQueryService.cs b/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs similarity index 84% rename from src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs rename to src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs index 098998709..ada073af3 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs @@ -9,24 +9,20 @@ 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; #pragma warning disable RECS0147 -namespace Squidex.Domain.Apps.Entities.Contents +namespace Squidex.Domain.Apps.Entities.Contents.Queries { public sealed class ContentQueryService : IContentQueryService { @@ -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/ContentVersionLoader.cs b/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentVersionLoader.cs similarity index 96% rename from src/Squidex.Domain.Apps.Entities/Contents/ContentVersionLoader.cs rename to src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentVersionLoader.cs index 7016766ef..c020703af 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentVersionLoader.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentVersionLoader.cs @@ -11,7 +11,7 @@ using Orleans; using Squidex.Infrastructure; using Squidex.Infrastructure.Log; -namespace Squidex.Domain.Apps.Entities.Contents +namespace Squidex.Domain.Apps.Entities.Contents.Queries { public sealed class ContentVersionLoader : IContentVersionLoader { 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/Contents/QueryExecutionContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs similarity index 98% rename from src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs rename to src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs index 2c50a0d1e..7e68202c9 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs @@ -13,7 +13,7 @@ using System.Threading.Tasks; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Infrastructure; -namespace Squidex.Domain.Apps.Entities.Contents +namespace Squidex.Domain.Apps.Entities.Contents.Queries { public class QueryExecutionContext { diff --git a/src/Squidex.Domain.Apps.Entities/IEntityWithCacheDependencies.cs b/src/Squidex.Domain.Apps.Entities/IEntityWithCacheDependencies.cs new file mode 100644 index 000000000..512a2f4c6 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/IEntityWithCacheDependencies.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Domain.Apps.Entities +{ + public interface IEntityWithCacheDependencies + { + HashSet CacheDependencies { get; } + } +} \ No newline at end of file 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.Web/ETagExtensions.cs b/src/Squidex.Web/ETagExtensions.cs index d83943222..bd6e74f58 100644 --- a/src/Squidex.Web/ETagExtensions.cs +++ b/src/Squidex.Web/ETagExtensions.cs @@ -18,45 +18,39 @@ namespace Squidex.Web { private static readonly int GuidLength = Guid.Empty.ToString().Length; - public static string ToEtag(this IReadOnlyList items, params IEntityWithVersion[] dependencies) where T : IEntity, IEntityWithVersion + public static string ToEtag(this IReadOnlyList items) where T : IEntity, IEntityWithVersion { using (Profiler.Trace("CalculateEtag")) { - var unhashed = Unhashed(items, 0, dependencies); + var unhashed = Unhashed(items, 0); return unhashed.Sha256Base64(); } } - public static string ToEtag(this IResultList items, params IEntityWithVersion[] dependencies) where T : IEntity, IEntityWithVersion + public static string ToEtag(this IResultList items) where T : IEntity, IEntityWithVersion { using (Profiler.Trace("CalculateEtag")) { - var unhashed = Unhashed(items, items.Total, dependencies); + var unhashed = Unhashed(items, items.Total); return unhashed.Sha256Base64(); } } - private static string Unhashed(IReadOnlyList items, long total, params IEntityWithVersion[] dependencies) where T : IEntity, IEntityWithVersion + private static string Unhashed(IReadOnlyList items, long total) where T : IEntity, IEntityWithVersion { - var sb = new StringBuilder((items.Count * (GuidLength + 8)) + 10); + var sb = new StringBuilder(); - for (var i = 0; i < items.Count; i++) + foreach (var item in items) { + AppendItem(item, sb); + sb.Append(";"); - sb.Append(items[i].ToEtag()); } - sb.Append("_"); sb.Append(total); - foreach (var dependency in dependencies) - { - sb.Append("_"); - sb.Append(dependency.Version); - } - return sb.ToString(); } @@ -85,17 +79,32 @@ namespace Squidex.Web return sb.ToString(); } - public static string ToEtag(this T item, IEntityWithVersion app = null) where T : IEntity, IEntityWithVersion + public static string ToEtag(this T item) where T : IEntity, IEntityWithVersion { - var result = $"{item.Id};{item.Version}"; + var sb = new StringBuilder(); + + AppendItem(item, sb); - if (app != null) + return sb.ToString(); + } + + private static void AppendItem(T item, StringBuilder sb) where T : IEntity, IEntityWithVersion + { + sb.Append(item.Id); + sb.Append(";"); + sb.Append(item.Version); + + if (item is IEntityWithCacheDependencies withDependencies) { - result += ";"; - result += app.Version; + if (withDependencies.CacheDependencies != null) + { + foreach (var dependency in withDependencies.CacheDependencies) + { + sb.Append(";"); + sb.Append(dependency); + } + } } - - return result; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs index 0c8c0d76c..ea4d0f4b6 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs @@ -17,7 +17,6 @@ using Squidex.Domain.Apps.Entities.Apps.Services; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Security; using Squidex.Shared; -using Squidex.Shared.Identity; using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Apps 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 3024549c2..a81d26318 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -137,7 +137,7 @@ namespace Squidex.Areas.Api.Controllers.Contents Response.Headers["Surrogate-Key"] = contents.ToSurrogateKeys(); } - Response.Headers[HeaderNames.ETag] = contents.ToEtag(App); + Response.Headers[HeaderNames.ETag] = contents.ToEtag(); return Ok(response); } @@ -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 () => { @@ -176,7 +181,7 @@ namespace Squidex.Areas.Api.Controllers.Contents Response.Headers["Surrogate-Key"] = contents.ToSurrogateKeys(); } - Response.Headers[HeaderNames.ETag] = contents.ToEtag(App, schema); + Response.Headers[HeaderNames.ETag] = contents.ToEtag(); return Ok(response); } @@ -210,7 +215,7 @@ namespace Squidex.Areas.Api.Controllers.Contents Response.Headers["Surrogate-Key"] = content.ToSurrogateKey(); } - Response.Headers[HeaderNames.ETag] = content.ToEtag(App); + Response.Headers[HeaderNames.ETag] = content.ToEtag(); return Ok(response); } @@ -245,7 +250,7 @@ namespace Squidex.Areas.Api.Controllers.Contents Response.Headers["Surrogate-Key"] = content.ToSurrogateKey(); } - Response.Headers[HeaderNames.ETag] = content.ToEtag(App); + Response.Headers[HeaderNames.ETag] = content.ToEtag(); return Ok(response.Data); } diff --git a/src/Squidex/Config/Domain/EntitiesServices.cs b/src/Squidex/Config/Domain/EntitiesServices.cs index c2693fffc..b95e8f9db 100644 --- a/src/Squidex/Config/Domain/EntitiesServices.cs +++ b/src/Squidex/Config/Domain/EntitiesServices.cs @@ -28,12 +28,13 @@ 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.Queries; using Squidex.Domain.Apps.Entities.Contents.Text; using Squidex.Domain.Apps.Entities.History; using Squidex.Domain.Apps.Entities.History.Notifications; @@ -102,12 +103,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 +139,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/src/Squidex/app/features/content/shared/references-dropdown.component.ts b/src/Squidex/app/features/content/shared/references-dropdown.component.ts index 92cee2009..5fcc00ee0 100644 --- a/src/Squidex/app/features/content/shared/references-dropdown.component.ts +++ b/src/Squidex/app/features/content/shared/references-dropdown.component.ts @@ -47,7 +47,7 @@ type ContentName = { name: string, id?: string }; `, styles: [ - '.truncate { min-height: 1.2rem; }' + '.truncate { min-height: 1.5rem; }' ], providers: [SQX_REFERENCES_DROPDOWN_CONTROL_VALUE_ACCESSOR], changeDetection: ChangeDetectionStrategy.OnPush @@ -166,4 +166,4 @@ export class ReferencesDropdownComponent extends StatefulControlComponent(); + 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/AssetQueryServiceTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs similarity index 65% rename from tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetQueryServiceTests.cs rename to tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs index 1e8471e88..434339896 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetQueryServiceTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs @@ -10,40 +10,31 @@ 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.Repositories; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure; using Squidex.Infrastructure.Queries; using Xunit; -namespace Squidex.Domain.Apps.Entities.Assets +namespace Squidex.Domain.Apps.Entities.Assets.Queries { public class AssetQueryServiceTests { 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 +118,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/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/GraphQL/GraphQLTestBase.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs index 3817ba7ec..51edc65c4 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs @@ -38,7 +38,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL protected readonly IAppEntity app; protected readonly IAssetQueryService assetQuery = A.Fake(); protected readonly IContentQueryService contentQuery = A.Fake(); - protected readonly IDependencyResolver dependencyResolver; protected readonly IJsonSerializer serializer = TestUtils.CreateSerializer(TypeNameHandling.None); protected readonly ISchemaEntity schema; protected readonly Context requestContext; @@ -219,6 +218,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/ContentEnricherReferencesTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherReferencesTests.cs similarity index 86% rename from tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentEnricherReferencesTests.cs rename to tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherReferencesTests.cs index 22ddb944d..6c89788e5 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentEnricherReferencesTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherReferencesTests.cs @@ -18,7 +18,7 @@ using Squidex.Infrastructure; using Squidex.Infrastructure.Json.Objects; using Xunit; -namespace Squidex.Domain.Apps.Entities.Contents +namespace Squidex.Domain.Apps.Entities.Contents.Queries { public class ContentEnricherReferencesTests { @@ -76,6 +76,35 @@ namespace Squidex.Domain.Apps.Entities.Contents sut = new ContentEnricher(new Lazy(() => contentQuery), contentWorkflow); } + [Fact] + public async Task Should_add_referenced_id_as_dependency() + { + var ref1_1 = CreateRefContent(Guid.NewGuid(), "ref1_1", 13); + var ref1_2 = CreateRefContent(Guid.NewGuid(), "ref1_2", 17); + var ref2_1 = CreateRefContent(Guid.NewGuid(), "ref2_1", 23); + var ref2_2 = CreateRefContent(Guid.NewGuid(), "ref2_2", 29); + + var source = new IContentEntity[] + { + CreateContent(new Guid[] { ref1_1.Id }, new Guid[] { ref2_1.Id }), + CreateContent(new Guid[] { ref1_2.Id }, new Guid[] { ref2_2.Id }) + }; + + A.CallTo(() => contentQuery.QueryAsync(A.Ignored, A>.That.Matches(x => x.Count == 4))) + .Returns(ResultList.CreateFrom(4, ref1_1, ref1_2, ref2_1, ref2_2)); + + var enriched = await sut.EnrichAsync(source, requestContext); + + var enriched1 = enriched.ElementAt(0); + var enriched2 = enriched.ElementAt(1); + + Assert.Contains(refSchemaId1.Id.ToString(), enriched1.CacheDependencies); + Assert.Contains(refSchemaId2.Id.ToString(), enriched1.CacheDependencies); + + Assert.Contains(refSchemaId1.Id.ToString(), enriched2.CacheDependencies); + Assert.Contains(refSchemaId2.Id.ToString(), enriched2.CacheDependencies); + } + [Fact] public async Task Should_enrich_with_reference_data() { diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentEnricherTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherTests.cs similarity index 75% rename from tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentEnricherTests.cs rename to tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherTests.cs index c8227ef60..0fd177204 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentEnricherTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherTests.cs @@ -9,24 +9,51 @@ using System; using System.Threading.Tasks; using FakeItEasy; using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure; using Xunit; -namespace Squidex.Domain.Apps.Entities.Contents +namespace Squidex.Domain.Apps.Entities.Contents.Queries { public class ContentEnricherTests { private readonly IContentWorkflow contentWorkflow = A.Fake(); private readonly IContentQueryService contentQuery = A.Fake(); - private readonly Context requestContext = new Context(); + private readonly ISchemaEntity schema; + private readonly Context requestContext; + private readonly NamedId appId = NamedId.Of(Guid.NewGuid(), "my-app"); private readonly NamedId schemaId = NamedId.Of(Guid.NewGuid(), "my-schema"); private readonly ContentEnricher sut; public ContentEnricherTests() { + requestContext = new Context(Mocks.ApiUser(), Mocks.App(appId)); + + schema = Mocks.Schema(appId, schemaId); + + A.CallTo(() => contentQuery.GetSchemaOrThrowAsync(requestContext, schemaId.Id.ToString())) + .Returns(schema); + sut = new ContentEnricher(new Lazy(() => contentQuery), contentWorkflow); } + [Fact] + public async Task Should_add_app_version_and_schema_as_dependency() + { + var source = new ContentEntity { Status = Status.Published, SchemaId = schemaId }; + + A.CallTo(() => contentWorkflow.GetInfoAsync(source)) + .Returns(new StatusInfo(Status.Published, StatusColors.Published)); + + var result = await sut.EnrichAsync(source, requestContext); + + Assert.Contains(requestContext.App.Version.ToString(), result.CacheDependencies); + + Assert.Contains(schema.Id.ToString(), result.CacheDependencies); + Assert.Contains(schema.Version.ToString(), result.CacheDependencies); + } + [Fact] public async Task Should_enrich_content_with_status_color() { 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/ContentQueryServiceTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs similarity index 88% rename from tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs rename to tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs index 9097bba36..458577e6e 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/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; @@ -32,7 +29,7 @@ using Xunit; #pragma warning disable SA1401 // Fields must be private -namespace Squidex.Domain.Apps.Entities.Contents +namespace Squidex.Domain.Apps.Entities.Contents.Queries { public class ContentQueryServiceTests { @@ -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() { @@ -489,7 +435,7 @@ namespace Squidex.Domain.Apps.Entities.Contents private void SetupContents(Status[] status, int total, List ids, bool includeDraft) { A.CallTo(() => contentRepository.QueryAsync(app, schema, A.That.Is(status), A>.Ignored, includeDraft)) - .Returns(ResultList.Create(total, ids.Select(x => CreateContent(x)).Shuffle())); + .Returns(ResultList.Create(total, ids.Select(CreateContent).Shuffle())); } private void SetupContents(Status[] status, List ids, bool includeDraft) diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentVersionLoaderTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentVersionLoaderTests.cs similarity index 97% rename from tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentVersionLoaderTests.cs rename to tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentVersionLoaderTests.cs index b6b1d43b6..1497bd2c7 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentVersionLoaderTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentVersionLoaderTests.cs @@ -13,7 +13,7 @@ using Squidex.Infrastructure; using Squidex.Infrastructure.Orleans; using Xunit; -namespace Squidex.Domain.Apps.Entities.Contents +namespace Squidex.Domain.Apps.Entities.Contents.Queries { public class ContentVersionLoaderTests { 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.Domain.Apps.Entities.Tests/TestHelpers/Mocks.cs b/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/Mocks.cs index 6ead64d13..3aa99dc28 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/Mocks.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/Mocks.cs @@ -49,12 +49,22 @@ namespace Squidex.Domain.Apps.Entities.TestHelpers return schema; } + public static ClaimsPrincipal ApiUser(string role = null) + { + return CreateUser(role, "api"); + } + public static ClaimsPrincipal FrontendUser(string role = null) + { + return CreateUser(role, DefaultClients.Frontend); + } + + private static ClaimsPrincipal CreateUser(string role, string client) { var claimsIdentity = new ClaimsIdentity(); var claimsPrincipal = new ClaimsPrincipal(claimsIdentity); - claimsIdentity.AddClaim(new Claim(OpenIdClaims.ClientId, DefaultClients.Frontend)); + claimsIdentity.AddClaim(new Claim(OpenIdClaims.ClientId, client)); if (role != null) { 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()); + } + } +}