Browse Source

Merge branch 'master' of github.com:Squidex/squidex

pull/405/head
Sebastian Stehle 7 years ago
parent
commit
294f32567d
  1. 8
      src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonSchemaExtensions.cs
  2. 7
      src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs
  3. 2
      src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs
  4. 2
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FilterFactory.cs
  5. 2
      src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs
  6. 2
      src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetEnricher.cs
  7. 173
      src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryParser.cs
  8. 65
      src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs
  9. 1
      src/Squidex.Domain.Apps.Entities/Assets/Queries/FilterTagTransformer.cs
  10. 3
      src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs
  11. 104
      src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelBuilder.cs
  12. 15
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs
  13. 1
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs
  14. 2
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Middlewares.cs
  15. 2
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs
  16. 2
      src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs
  17. 2
      src/Squidex.Domain.Apps.Entities/Contents/IEnrichedContentEntity.cs
  18. 33
      src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentEnricher.cs
  19. 203
      src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs
  20. 71
      src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs
  21. 2
      src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentVersionLoader.cs
  22. 10
      src/Squidex.Domain.Apps.Entities/Contents/Queries/FilterTagTransformer.cs
  23. 2
      src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs
  24. 16
      src/Squidex.Domain.Apps.Entities/IEntityWithCacheDependencies.cs
  25. 7
      src/Squidex.Domain.Apps.Entities/Q.cs
  26. 2
      src/Squidex.Infrastructure.MongoDb/MongoDb/Queries/SortBuilder.cs
  27. 84
      src/Squidex.Infrastructure/Queries/Json/JsonFilterVisitor.cs
  28. 71
      src/Squidex.Infrastructure/Queries/Json/OperatorValidator.cs
  29. 16
      src/Squidex.Infrastructure/Queries/Json/PropertyPathValidator.cs
  30. 72
      src/Squidex.Infrastructure/Queries/Json/QueryParser.cs
  31. 228
      src/Squidex.Infrastructure/Queries/Json/ValueConverter.cs
  32. 1
      src/Squidex.Infrastructure/Queries/LogicalFilter.cs
  33. 18
      src/Squidex.Infrastructure/Queries/OData/FilterBuilder.cs
  34. 67
      src/Squidex.Infrastructure/Queries/Optimizer.cs
  35. 10
      src/Squidex.Infrastructure/Queries/SortNode.cs
  36. 53
      src/Squidex.Web/ETagExtensions.cs
  37. 1
      src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs
  38. 9
      src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
  39. 17
      src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  40. 12
      src/Squidex/Config/Domain/EntitiesServices.cs
  41. 3
      src/Squidex/Config/Domain/SerializationServices.cs
  42. 4
      src/Squidex/app/features/content/shared/references-dropdown.component.ts
  43. 3
      tests/Squidex.Domain.Apps.Core.Tests/TestUtils.cs
  44. 2
      tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetEnricherTests.cs
  45. 129
      tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryParserTests.cs
  46. 65
      tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs
  47. 3
      tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/FilterTagTransformerTests.cs
  48. 3
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs
  49. 31
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherReferencesTests.cs
  50. 31
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherTests.cs
  51. 126
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryParserTests.cs
  52. 66
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs
  53. 2
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentVersionLoaderTests.cs
  54. 5
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/FilterTagTransformerTests.cs
  55. 68
      tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/JsonHelper.cs
  56. 12
      tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/Mocks.cs
  57. 373
      tests/Squidex.Infrastructure.Tests/Queries/QueryJsonConversionTests.cs
  58. 6
      tests/Squidex.Infrastructure.Tests/Queries/QueryJsonTests.cs
  59. 94
      tests/Squidex.Infrastructure.Tests/Queries/QueryOptimizationTests.cs

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

7
src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs

@ -16,17 +16,20 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema
public sealed class JsonTypeVisitor : IFieldVisitor<JsonSchemaProperty>
{
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);

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

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

2
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<IReadOnlyList<IEnrichedAssetEntity>> QueryByHashAsync(Guid appId, string hash);
Task<IResultList<IEnrichedAssetEntity>> QueryAsync(Context context, Q query);

2
src/Squidex.Domain.Apps.Entities/Assets/AssetEnricher.cs → 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
{

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

65
src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs → 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<AssetOptions> 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<IEnrichedAssetEntity> FindAssetAsync( Guid id)
@ -93,7 +77,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
private async Task<IResultList<IAssetEntity>> 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<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;
}
catch (NotSupportedException)
{
throw new ValidationException("OData operation is not supported.");
}
catch (ODataException ex)
{
throw new ValidationException($"Failed to parse query: {ex.Message}", ex);
}
}
}
}

1
src/Squidex.Domain.Apps.Entities/Assets/Queries/FilterTagTransformer.cs

@ -27,6 +27,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
public static FilterNode<ClrValue> Transform(FilterNode<ClrValue> nodeIn, Guid appId, ITagService tagService)
{
Guard.NotNull(nodeIn, nameof(nodeIn));
Guard.NotNull(tagService, nameof(tagService));
return nodeIn.Accept(new FilterTagTransformer(appId, tagService));

3
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<string> CacheDependencies { get; } = new HashSet<string>();
}
}

104
src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelBuilder.cs

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

15
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<IContentQueryService>().DefaultPageSizeGraphQl,
resolver.Resolve<IAssetQueryService>().DefaultPageSizeGraphQl,
GetPageSizeForContents(),
GetPageSizeForAssets(),
resolver.Resolve<IGraphQLUrlGenerator>());
});
}
private int GetPageSizeForContents()
{
return resolver.Resolve<IOptions<ContentOptions>>().Value.DefaultPageSizeGraphQl;
}
private int GetPageSizeForAssets()
{
return resolver.Resolve<IOptions<AssetOptions>>().Value.DefaultPageSizeGraphQl;
}
private static object CreateCacheKey(Guid appId, string etag)
{
return $"GraphQLModel_{appId}_{etag}";

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

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

2
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<IReadOnlyList<T>> LoadManyAsync<TKey, T>(this IDataLoader<TKey, T> dataLoader, ICollection<TKey> 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();
}

2
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<IResultList<IEnrichedContentEntity>> QueryAsync(Context context, IReadOnlyList<Guid> ids);
Task<IResultList<IEnrichedContentEntity>> QueryAsync(Context context, string schemaIdOrName, Q query);

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

33
src/Squidex.Domain.Apps.Entities/Contents/ContentEnricher.cs → 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<ContentEntity> contents, Context context)
private async Task ResolveReferencesAsync(ISchemaEntity schema, IEnumerable<ContentEntity> contents, Context context)
{
var schema = await ContentQuery.GetSchemaOrThrowAsync(context, schemaId.ToString());
var references = await GetReferencesAsync(schema, contents, context);
var formatted = new Dictionary<IContentEntity, JsonObject>();
@ -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)

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

71
src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs → 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<ContentOptions> 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<IEnrichedContentEntity> 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<ContentQueryService>())
{
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<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;
}
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<ISchemaEntity> GetSchemaOrThrowAsync(Context context, string schemaIdOrName)
{
ISchemaEntity schema = null;
@ -343,14 +294,14 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
private async Task<IResultList<IContentEntity>> QueryByQueryAsync(Context context, Q query, ISchemaEntity schema)
private async Task<IResultList<IContentEntity>> 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<IResultList<IContentEntity>> QueryByIdsAsync(Context context, Q query, ISchemaEntity schema)
private async Task<IResultList<IContentEntity>> QueryByIdsAsync(Context context, ISchemaEntity schema, Q query)
{
var contents = await QueryCoreAsync(context, schema, query.Ids.ToHashSet());

2
src/Squidex.Domain.Apps.Entities/Contents/ContentVersionLoader.cs → 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
{

10
src/Squidex.Domain.Apps.Entities/Contents/Queries/FilterTagTransformer.cs

@ -31,6 +31,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
public static FilterNode<ClrValue> Transform(FilterNode<ClrValue> 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<string> path)
{
return schema.SchemaDef.FieldsByName.TryGetValue(path[1], out var field) &&
field is IField<TagsFieldProperties> 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<TagsFieldProperties> tags && tags.Properties.Normalization == TagsFieldNormalization.Schema;
}
}
}

2
src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs → 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
{

16
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<string> CacheDependencies { get; }
}
}

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

2
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<T>.Sort.Ascending(propertyName);
}

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

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

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

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

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

1
src/Squidex.Infrastructure/Queries/LogicalFilter.cs

@ -18,7 +18,6 @@ namespace Squidex.Infrastructure.Queries
public LogicalFilter(LogicalFilterType type, IReadOnlyList<FilterNode<TValue>> filters)
{
Guard.NotNull(filters, nameof(filters));
Guard.GreaterEquals(filters.Count, 2, nameof(filters.Count));
Guard.Enum(type, nameof(type));
Filters = filters;

18
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<ClrValue>.Optimize(filter);
}
}
}

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

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

53
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<T>(this IReadOnlyList<T> items, params IEntityWithVersion[] dependencies) where T : IEntity, IEntityWithVersion
public static string ToEtag<T>(this IReadOnlyList<T> 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<T>(this IResultList<T> items, params IEntityWithVersion[] dependencies) where T : IEntity, IEntityWithVersion
public static string ToEtag<T>(this IResultList<T> 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<T>(IReadOnlyList<T> items, long total, params IEntityWithVersion[] dependencies) where T : IEntity, IEntityWithVersion
private static string Unhashed<T>(IReadOnlyList<T> 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<T>(this T item, IEntityWithVersion app = null) where T : IEntity, IEntityWithVersion
public static string ToEtag<T>(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>(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;
}
}
}

1
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

9
src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs

@ -89,6 +89,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="ids">The optional asset ids.</param>
/// <param name="q">The optional json query.</param>
/// <returns>
/// 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<IActionResult> GetAssets(string app, [FromQuery] string ids = null)
public async Task<IActionResult> 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(() =>
{

17
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
/// <param name="app">The name of the app.</param>
/// <param name="name">The name of the schema.</param>
/// <param name="ids">The optional ids of the content to fetch.</param>
/// <param name="q">The optional json query.</param>
/// <returns>
/// 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<IActionResult> GetContents(string app, string name, [FromQuery] string ids = null)
public async Task<IActionResult> 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);
}

12
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<ContentEnricher>()
.As<IContentEnricher>();
services.AddSingletonAs<AssetQueryParser>()
.AsSelf();
services.AddSingletonAs<AssetQueryService>()
.As<IAssetQueryService>();
services.AddSingletonAs(c => new Lazy<IContentQueryService>(() => c.GetRequiredService<IContentQueryService>()))
.AsSelf();
services.AddSingletonAs<ContentQueryParser>()
.AsSelf();
services.AddSingletonAs<ContentQueryService>()
.As<IContentQueryService>();
@ -132,9 +139,6 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<RolePermissionsProvider>()
.AsSelf();
services.AddSingletonAs<EdmModelBuilder>()
.AsSelf();
services.AddSingletonAs<GrainTagService>()
.As<ITagService>();

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

4
src/Squidex/app/features/content/shared/references-dropdown.component.ts

@ -47,7 +47,7 @@ type ContentName = { name: string, id?: string };
</ng-template>
</sqx-dropdown>`,
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<State,
public trackByContent(content: ContentDto) {
return content.id;
}
}
}

3
tests/Squidex.Domain.Apps.Core.Tests/TestUtils.cs

@ -20,6 +20,7 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Json.Newtonsoft;
using Squidex.Infrastructure.Queries.Json;
using Xunit;
namespace Squidex.Domain.Apps.Core
@ -46,6 +47,7 @@ namespace Squidex.Domain.Apps.Core
new AppPatternsConverter(),
new ClaimsPrincipalConverter(),
new EnvelopeHeadersConverter(),
new FilterConverter(),
new InstantConverter(),
new JsonValueConverter(),
new LanguageConverter(),
@ -53,6 +55,7 @@ namespace Squidex.Domain.Apps.Core
new NamedGuidIdConverter(),
new NamedLongIdConverter(),
new NamedStringIdConverter(),
new PropertyPathConverter(),
new RefTokenConverter(),
new RolesConverter(),
new RuleConverter(),

2
tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetEnricherTests.cs → tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetEnricherTests.cs

@ -13,7 +13,7 @@ using Squidex.Domain.Apps.Core.Tags;
using Squidex.Infrastructure;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Assets
namespace Squidex.Domain.Apps.Entities.Assets.Queries
{
public class AssetEnricherTests
{

129
tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryParserTests.cs

@ -0,0 +1,129 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using FakeItEasy;
using Microsoft.Extensions.Options;
using Squidex.Domain.Apps.Core.Tags;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Assets.Queries
{
public class AssetQueryParserTests
{
private readonly ITagService tagService = A.Fake<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('\'', '"');
}
}
}

65
tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetQueryServiceTests.cs → 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<IAssetEnricher>();
private readonly IAssetRepository assetRepository = A.Fake<IAssetRepository>();
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 queryParser = A.Fake<AssetQueryParser>();
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<Q>.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<ClrQuery>.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<ClrQuery>.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<ClrQuery>.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<ClrQuery>.That.Is("Skip: 20; Take: 200; Sort: lastModified Descending")))
.MustHaveHappened();
}
}
}

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

@ -26,6 +26,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
.Returns(new Dictionary<string, string> { ["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<string, string>());
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());

3
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<IAssetQueryService>();
protected readonly IContentQueryService contentQuery = A.Fake<IContentQueryService>();
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<AssetOptions>)] = Options.Create(new AssetOptions()),
[typeof(IOptions<ContentOptions>)] = Options.Create(new ContentOptions()),
[typeof(ISemanticLog)] = A.Fake<ISemanticLog>(),
[typeof(DataLoaderDocumentListener)] = new DataLoaderDocumentListener(dataLoaderContext)
};

31
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentEnricherReferencesTests.cs → 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<IContentQueryService>(() => 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<Context>.Ignored, A<IReadOnlyList<Guid>>.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()
{

31
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentEnricherTests.cs → 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<IContentWorkflow>();
private readonly IContentQueryService contentQuery = A.Fake<IContentQueryService>();
private readonly Context requestContext = new Context();
private readonly ISchemaEntity schema;
private readonly Context requestContext;
private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app");
private readonly NamedId<Guid> 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<IContentQueryService>(() => 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()
{

126
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<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('\'', '"');
}
}
}

66
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs → 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<ContentQueryParser>();
private readonly ContentQueryService sut;
public static IEnumerable<object[]> 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<Q>.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<DomainObjectNotFoundException>(() => 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<Status[]>.That.Is(Status.Published), false,
A<ClrQuery>.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<Status[]>.That.Is(status), false,
A<ClrQuery>.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<ValidationException>(() => 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<Guid> ids, bool includeDraft)
{
A.CallTo(() => contentRepository.QueryAsync(app, schema, A<Status[]>.That.Is(status), A<HashSet<Guid>>.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<Guid> ids, bool includeDraft)

2
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentVersionLoaderTests.cs → 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
{

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

@ -44,6 +44,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
.Returns(new Dictionary<string, string> { ["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<string, string>());
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());

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

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

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

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

94
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<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…
Cancel
Save