mirror of https://github.com/Squidex/squidex.git
59 changed files with 1987 additions and 437 deletions
@ -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<AssetOptions> 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<AssetQueryParser>()) |
|||
{ |
|||
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<string> { "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; |
|||
} |
|||
} |
|||
} |
|||
@ -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<IEdmModel>(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<EdmComplexType>().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); |
|||
} |
|||
} |
|||
} |
|||
@ -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<ContentOptions> 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<ContentQueryParser>()) |
|||
{ |
|||
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<string> { "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<JsonSchema>(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<IEdmModel>(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<EdmComplexType>().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}"; |
|||
} |
|||
} |
|||
} |
|||
@ -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<string> CacheDependencies { get; } |
|||
} |
|||
} |
|||
@ -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<FilterNode<ClrValue>, IJsonValue> |
|||
{ |
|||
private readonly List<string> errors; |
|||
private readonly JsonSchema schema; |
|||
|
|||
private JsonFilterVisitor(JsonSchema schema, List<string> errors) |
|||
{ |
|||
this.schema = schema; |
|||
|
|||
this.errors = errors; |
|||
} |
|||
|
|||
public static FilterNode<ClrValue> Parse(FilterNode<IJsonValue> filter, JsonSchema schema, List<string> 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<ClrValue> Visit(NegateFilter<IJsonValue> nodeIn) |
|||
{ |
|||
return new NegateFilter<ClrValue>(nodeIn.Accept(this)); |
|||
} |
|||
|
|||
public override FilterNode<ClrValue> Visit(LogicalFilter<IJsonValue> nodeIn) |
|||
{ |
|||
return new LogicalFilter<ClrValue>(nodeIn.Type, nodeIn.Filters.Select(x => x.Accept(this)).ToList()); |
|||
} |
|||
|
|||
public override FilterNode<ClrValue> Visit(CompareFilter<IJsonValue> nodeIn) |
|||
{ |
|||
CompareFilter<ClrValue> 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<ClrValue>(nodeIn.Path, nodeIn.Operator, value); |
|||
} |
|||
} |
|||
|
|||
result = result ?? new CompareFilter<ClrValue>(nodeIn.Path, nodeIn.Operator, ClrValue.Null); |
|||
|
|||
return result; |
|||
} |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
} |
|||
@ -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<string>(); |
|||
|
|||
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<string> errors, Query<IJsonValue> query) |
|||
{ |
|||
if (query.Filter != null) |
|||
{ |
|||
var filter = JsonFilterVisitor.Parse(query.Filter, schema, errors); |
|||
|
|||
result.Filter = Optimizer<ClrValue>.Optimize(filter); |
|||
} |
|||
} |
|||
|
|||
private static void ConvertSorting(JsonSchema schema, ClrQuery result, List<string> errors) |
|||
{ |
|||
if (result.Sort != null) |
|||
{ |
|||
foreach (var sorting in result.Sort) |
|||
{ |
|||
sorting.Path.TryGetProperty(schema, errors, out _); |
|||
} |
|||
} |
|||
} |
|||
|
|||
private static Query<IJsonValue> ParseFromJson(string json, IJsonSerializer jsonSerializer) |
|||
{ |
|||
try |
|||
{ |
|||
return jsonSerializer.Deserialize<Query<IJsonValue>>(json); |
|||
} |
|||
catch (JsonException ex) |
|||
{ |
|||
throw new ValidationException("Failed to parse json query.", new ValidationError(ex.Message)); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -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<T>(List<string> errors, PropertyPath path, IJsonValue value, out T result); |
|||
|
|||
public static ClrValue Convert(JsonSchema schema, IJsonValue value, PropertyPath path, List<string> errors) |
|||
{ |
|||
ClrValue result = null; |
|||
|
|||
switch (GetType(schema)) |
|||
{ |
|||
case JsonObjectType.Boolean: |
|||
{ |
|||
if (value is JsonArray jsonArray) |
|||
{ |
|||
result = ParseArray<bool>(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<double>(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<Guid>(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<Instant>(errors, path, jsonArray, TryParseDateTime); |
|||
} |
|||
else if (TryParseDateTime(errors, path, value, out var temp)) |
|||
{ |
|||
result = temp; |
|||
} |
|||
} |
|||
else |
|||
{ |
|||
if (value is JsonArray jsonArray) |
|||
{ |
|||
result = ParseArray<string>(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<T> ParseArray<T>(List<string> errors, PropertyPath path, JsonArray array, Parser<T> parser) |
|||
{ |
|||
var items = new List<T>(); |
|||
|
|||
foreach (var item in array) |
|||
{ |
|||
if (parser(errors, path, item, out var temp)) |
|||
{ |
|||
items.Add(temp); |
|||
} |
|||
} |
|||
|
|||
return items; |
|||
} |
|||
|
|||
private static bool TryParseBoolean(List<string> 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<string> 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<string> 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<string> 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<string> 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; |
|||
} |
|||
} |
|||
} |
|||
@ -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<TValue> : TransformVisitor<TValue> |
|||
{ |
|||
private static readonly Optimizer<TValue> Instance = new Optimizer<TValue>(); |
|||
|
|||
private Optimizer() |
|||
{ |
|||
} |
|||
|
|||
public static FilterNode<TValue> Optimize(FilterNode<TValue> source) |
|||
{ |
|||
return source?.Accept(Instance); |
|||
} |
|||
|
|||
public override FilterNode<TValue> Visit(LogicalFilter<TValue> 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<TValue>(nodeIn.Type, pruned); |
|||
} |
|||
|
|||
public override FilterNode<TValue> Visit(NegateFilter<TValue> nodeIn) |
|||
{ |
|||
var pruned = nodeIn.Filter.Accept(this); |
|||
|
|||
if (pruned == null) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
if (pruned is CompareFilter<TValue> comparison) |
|||
{ |
|||
if (comparison.Operator == CompareOperator.Equals) |
|||
{ |
|||
return new CompareFilter<TValue>(comparison.Path, CompareOperator.NotEquals, comparison.Value); |
|||
} |
|||
|
|||
if (comparison.Operator == CompareOperator.NotEquals) |
|||
{ |
|||
return new CompareFilter<TValue>(comparison.Path, CompareOperator.Equals, comparison.Value); |
|||
} |
|||
} |
|||
|
|||
return new NegateFilter<TValue>(pruned); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,129 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using FakeItEasy; |
|||
using Microsoft.Extensions.Options; |
|||
using Squidex.Domain.Apps.Core.Tags; |
|||
using Squidex.Domain.Apps.Entities.TestHelpers; |
|||
using Squidex.Infrastructure; |
|||
using Xunit; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Assets.Queries |
|||
{ |
|||
public class AssetQueryParserTests |
|||
{ |
|||
private readonly ITagService tagService = A.Fake<ITagService>(); |
|||
private readonly NamedId<Guid> 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<ValidationException>(() => sut.ParseQuery(requestContext, query)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_throw_if_json_query_is_invalid() |
|||
{ |
|||
var query = Q.Empty.WithJsonQuery("invalid"); |
|||
|
|||
Assert.Throws<ValidationException>(() => 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<HashSet<string>>.That.Contains("name1"))) |
|||
.Returns(new Dictionary<string, string> { ["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('\'', '"'); |
|||
} |
|||
} |
|||
} |
|||
@ -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<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app"); |
|||
private readonly NamedId<Guid> 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<ValidationException>(() => sut.ParseQuery(requestContext, schema, query)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_throw_if_json_query_is_invalid() |
|||
{ |
|||
var query = Q.Empty.WithJsonQuery("invalid"); |
|||
|
|||
Assert.Throws<ValidationException>(() => 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('\'', '"'); |
|||
} |
|||
} |
|||
} |
|||
@ -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<T>(this T value) |
|||
{ |
|||
return DefaultSerializer.Deserialize<Tuple<T>>(DefaultSerializer.Serialize(Tuple.Create(value))).Item1; |
|||
} |
|||
|
|||
public static T Deserialize<T>(string value) |
|||
{ |
|||
return DefaultSerializer.Deserialize<Tuple<T>>($"{{ \"Item1\": \"{value}\" }}").Item1; |
|||
} |
|||
|
|||
public static T Deserialize<T>(object value) |
|||
{ |
|||
return DefaultSerializer.Deserialize<Tuple<T>>($"{{ \"Item1\": {value} }}").Item1; |
|||
} |
|||
} |
|||
} |
|||
@ -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<string> errors = new List<string>(); |
|||
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<ValidationException>(() => AssertQuery(json, null)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_throw_exception_when_parsing_invalid_json() |
|||
{ |
|||
var json = "invalid"; |
|||
|
|||
Assert.Throws<ValidationException>(() => AssertQuery(json, null)); |
|||
} |
|||
|
|||
private void AssertQuery(object json, string expectedFilter) |
|||
{ |
|||
var filter = ConvertQuery(json); |
|||
|
|||
Assert.Empty(errors); |
|||
|
|||
Assert.Equal(expectedFilter, filter); |
|||
} |
|||
|
|||
private void AssertFilter(object json, string expectedFilter) |
|||
{ |
|||
var filter = ConvertFilter(json); |
|||
|
|||
Assert.Empty(errors); |
|||
|
|||
Assert.Equal(expectedFilter, filter); |
|||
} |
|||
|
|||
private void AssertErrors(object json, params string[] expectedErrors) |
|||
{ |
|||
var filter = ConvertFilter(json); |
|||
|
|||
Assert.Equal(expectedErrors.ToList(), errors); |
|||
|
|||
Assert.Null(filter); |
|||
} |
|||
|
|||
private string ConvertFilter<T>(T value) |
|||
{ |
|||
var json = JsonHelper.DefaultSerializer.Serialize(value, true); |
|||
|
|||
var jsonFilter = JsonHelper.DefaultSerializer.Deserialize<FilterNode<IJsonValue>>(json); |
|||
|
|||
return JsonFilterVisitor.Parse(jsonFilter, schema, errors)?.ToString(); |
|||
} |
|||
|
|||
private string ConvertQuery<T>(T value) |
|||
{ |
|||
var json = JsonHelper.DefaultSerializer.Serialize(value, true); |
|||
|
|||
var jsonFilter = schema.Parse(json, JsonHelper.DefaultSerializer); |
|||
|
|||
return jsonFilter.ToString(); |
|||
} |
|||
} |
|||
} |
|||
@ -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<ClrValue>.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<ClrValue>.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<ClrValue>.Optimize(source); |
|||
|
|||
Assert.Null(result); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_return_null_when_logical_filter_has_no_filter() |
|||
{ |
|||
var source = ClrFilter.And(); |
|||
|
|||
var result = Optimizer<ClrValue>.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<ClrValue>.Optimize(source); |
|||
|
|||
Assert.Null(result); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_invert_equals_not_filter() |
|||
{ |
|||
var source = ClrFilter.Not(ClrFilter.Eq("path", 1)); |
|||
|
|||
var result = Optimizer<ClrValue>.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<ClrValue>.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<ClrValue>.Optimize(source); |
|||
|
|||
Assert.Equal("!(path < 1)", result.ToString()); |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue