diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs index 7a39e0329..468bd2110 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs @@ -8,24 +8,71 @@ using System; using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; -using Squidex.Domain.Apps.Entities.Assets.State; +using Squidex.Domain.Apps.Core.ValidateContent; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Infrastructure; using Squidex.Infrastructure.MongoDb; namespace Squidex.Domain.Apps.Entities.MongoDb.Assets { - public sealed class MongoAssetEntity : IVersionedEntity + public sealed class MongoAssetEntity : + MongoEntity, + IAssetEntity, + IUpdateableEntityWithVersion, + IUpdateableEntityWithCreatedBy, + IUpdateableEntityWithLastModifiedBy, + IUpdateableEntityWithAppRef { - [BsonId] + [BsonRequired] [BsonElement] - [BsonRepresentation(BsonType.String)] - public Guid Id { get; set; } + public string MimeType { get; set; } + [BsonRequired] [BsonElement] + public string FileName { get; set; } + [BsonRequired] - public AssetState State { get; set; } + [BsonElement] + public long FileSize { get; set; } + + [BsonRequired] + [BsonElement] + public long FileVersion { get; set; } + [BsonRequired] [BsonElement] + public bool IsImage { get; set; } + [BsonRequired] + [BsonElement] public long Version { get; set; } + + [BsonRequired] + [BsonElement] + public int? PixelWidth { get; set; } + + [BsonRequired] + [BsonElement] + public int? PixelHeight { get; set; } + + [BsonRequired] + [BsonElement] + public Guid AppId { get; set; } + + [BsonRequired] + [BsonElement] + public RefToken CreatedBy { get; set; } + + [BsonRequired] + [BsonElement] + public RefToken LastModifiedBy { get; set; } + + [BsonElement] + public bool IsDeleted { get; set; } + + Guid IAssetInfo.AssetId + { + get { return Id; } + } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs index 1b3abce9c..b5f371186 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs @@ -9,10 +9,13 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using MongoDB.Bson; +using Microsoft.OData; +using Microsoft.OData.UriParser; using MongoDB.Driver; using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Domain.Apps.Entities.Assets.Edm; using Squidex.Domain.Apps.Entities.Assets.Repositories; +using Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors; using Squidex.Infrastructure; using Squidex.Infrastructure.MongoDb; @@ -20,9 +23,12 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets { public sealed partial class MongoAssetRepository : MongoRepositoryBase, IAssetRepository { - public MongoAssetRepository(IMongoDatabase database) + private readonly EdmModelBuilder modelBuilder; + + public MongoAssetRepository(IMongoDatabase database, EdmModelBuilder modelBuilder) : base(database) { + this.modelBuilder = modelBuilder; } protected override string CollectionName() @@ -34,44 +40,40 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets { return collection.Indexes.CreateOneAsync( Index - .Ascending(x => x.State.AppId) - .Ascending(x => x.State.IsDeleted) - .Ascending(x => x.State.FileName) - .Ascending(x => x.State.MimeType) - .Descending(x => x.State.LastModified)); + .Ascending(x => x.AppId) + .Ascending(x => x.IsDeleted) + .Ascending(x => x.FileName) + .Descending(x => x.LastModified)); } - public async Task> QueryAsync(Guid appId, HashSet mimeTypes = null, HashSet ids = null, string query = null, int take = 10, int skip = 0) + public async Task> QueryAsync(Guid appId, string query = null) { - var filters = new List> - { - Filter.Eq(x => x.State.AppId, appId), - Filter.Eq(x => x.State.IsDeleted, false) - }; - - if (ids != null && ids.Count > 0) - { - filters.Add(Filter.In(x => x.Id, ids)); - } + var parsedQuery = ParseQuery(query); - if (mimeTypes != null && mimeTypes.Count > 0) - { - filters.Add(Filter.In(x => x.State.MimeType, mimeTypes)); - } + var assetEntities = + await Collection + .Find(parsedQuery, appId) + .Skip(parsedQuery) + .Take(parsedQuery) + .SortByDescending(x => x.LastModified) + .ToListAsync(); - if (!string.IsNullOrWhiteSpace(query)) - { - filters.Add(Filter.Regex(x => x.State.FileName, new BsonRegularExpression(query, "i"))); - } + var assetCount = await Collection.Find(parsedQuery, appId).CountAsync(); - var filter = Filter.And(filters); + return ResultList.Create(assetEntities.OfType().ToList(), assetCount); + } - var assetItems = Collection.Find(filter).Skip(skip).Limit(take).SortByDescending(x => x.State.LastModified).ToListAsync(); - var assetCount = Collection.Find(filter).CountAsync(); + public async Task> QueryAsync(Guid appId, HashSet ids) + { + var find = Collection + .Find(Filter.In(x => x.Id, ids)) + .SortByDescending(x => x.LastModified); - await Task.WhenAll(assetItems, assetCount); + var assetEntities = find.ToListAsync(); + var assetCount = find.CountAsync(); + await Task.WhenAll(assetEntities, assetCount); - return ResultList.Create(assetItems.Result.Select(x => x.State), assetCount.Result); + return ResultList.Create(assetEntities.Result.OfType().ToList(), assetCount.Result); } public async Task FindAssetAsync(Guid id) @@ -80,7 +82,21 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets await Collection.Find(x => x.Id == id) .FirstOrDefaultAsync(); - return assetEntity?.State; + return assetEntity; + } + + private ODataUriParser ParseQuery(string query) + { + try + { + var model = modelBuilder.EdmModel; + + return model.ParseQuery(query); + } + catch (ODataException ex) + { + throw new ValidationException($"Failed to parse query: {ex.Message}", ex); + } } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs index d181e97ca..89bdc2c09 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs @@ -10,7 +10,7 @@ using System.Threading.Tasks; using MongoDB.Driver; using Squidex.Domain.Apps.Entities.Assets.State; using Squidex.Infrastructure; -using Squidex.Infrastructure.MongoDb; +using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.MongoDb.Assets @@ -25,15 +25,18 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets if (existing != null) { - return (existing.State, existing.Version); + return (SimpleMapper.Map(existing, new AssetState()), existing.Version); } return (null, EtagVersion.NotFound); } - public Task WriteAsync(Guid key, AssetState value, long oldVersion, long newVersion) + public async Task WriteAsync(Guid key, AssetState value, long oldVersion, long newVersion) { - return Collection.UpsertVersionedAsync(key, oldVersion, newVersion, u => u.Set(x => x.State, value)); + var entity = SimpleMapper.Map(value, new MongoAssetEntity()); + entity.Version = newVersion; + + await Collection.ReplaceOneAsync(x => x.Id == key && x.Version == oldVersion, entity, Upsert); } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/ConstantVisitor.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/ConstantVisitor.cs new file mode 100644 index 000000000..3984c456e --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/ConstantVisitor.cs @@ -0,0 +1,67 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; +using NodaTime; +using NodaTime.Text; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors +{ + public sealed class ConstantVisitor : QueryNodeVisitor + { + private static readonly ConstantVisitor Instance = new ConstantVisitor(); + + private ConstantVisitor() + { + } + + public static object Visit(QueryNode node) + { + return node.Accept(Instance); + } + + public override object Visit(ConvertNode nodeIn) + { + var booleanType = EdmCoreModel.Instance.GetPrimitiveType(EdmPrimitiveTypeKind.Boolean); + + if (nodeIn.TypeReference.Definition == booleanType) + { + return bool.Parse(Visit(nodeIn.Source).ToString()); + } + + var dateTimeType = EdmCoreModel.Instance.GetPrimitiveType(EdmPrimitiveTypeKind.DateTimeOffset); + + if (nodeIn.TypeReference.Definition == dateTimeType) + { + var value = Visit(nodeIn.Source); + + if (value is DateTimeOffset dateTimeOffset) + { + return Instant.FromDateTimeOffset(dateTimeOffset); + } + + return InstantPattern.General.Parse(Visit(nodeIn.Source).ToString()).Value; + } + + var guidType = EdmCoreModel.Instance.GetPrimitiveType(EdmPrimitiveTypeKind.Guid); + + if (nodeIn.TypeReference.Definition == guidType) + { + return Guid.Parse(Visit(nodeIn.Source).ToString()); + } + + return base.Visit(nodeIn); + } + + public override object Visit(ConstantNode nodeIn) + { + return nodeIn.Value; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FilterBuilder.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FilterBuilder.cs new file mode 100644 index 000000000..00e192f2e --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FilterBuilder.cs @@ -0,0 +1,57 @@ +// ========================================================================== +// FilterBuilder.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== +using System.Collections.Generic; +using Microsoft.OData; +using Microsoft.OData.UriParser; +using MongoDB.Driver; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors +{ + public static class FilterBuilder + { + private static readonly FilterDefinitionBuilder Filter = Builders.Filter; + + public static List> Build(ODataUriParser query) + { + List> filters = new List>(); + + SearchClause search; + try + { + search = query.ParseSearch(); + } + catch (ODataException ex) + { + throw new ValidationException("Query $search clause not valid.", new ValidationError(ex.Message)); + } + + if (search != null) + { + filters.Add(Filter.Text(SearchTermVisitor.Visit(search.Expression).ToString())); + } + + FilterClause filter; + try + { + filter = query.ParseFilter(); + } + catch (ODataException ex) + { + throw new ValidationException("Query $filter clause not valid.", new ValidationError(ex.Message)); + } + + if (filter != null) + { + filters.Add(FilterVisitor.Visit(filter.Expression)); + } + + return filters; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FilterVisitor.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FilterVisitor.cs new file mode 100644 index 000000000..2ef2e94eb --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FilterVisitor.cs @@ -0,0 +1,155 @@ +// ========================================================================== +// FilterVisitor.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== +using System; +using System.Linq; +using Microsoft.OData.UriParser; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors +{ + public class FilterVisitor : QueryNodeVisitor> + { + private static readonly FilterDefinitionBuilder Filter = Builders.Filter; + + public static FilterDefinition Visit(QueryNode node) + { + var visitor = new FilterVisitor(); + + return node.Accept(visitor); + } + + public override FilterDefinition Visit(ConvertNode nodeIn) + { + return nodeIn.Source.Accept(this); + } + + public override FilterDefinition Visit(UnaryOperatorNode nodeIn) + { + if (nodeIn.OperatorKind == UnaryOperatorKind.Not) + { + return Filter.Not(nodeIn.Operand.Accept(this)); + } + + throw new NotSupportedException(); + } + + public override FilterDefinition Visit(SingleValueFunctionCallNode nodeIn) + { + var fieldNode = nodeIn.Parameters.ElementAt(0); + var valueNode = nodeIn.Parameters.ElementAt(1); + + if (string.Equals(nodeIn.Name, "endswith", StringComparison.OrdinalIgnoreCase)) + { + var value = BuildRegex(valueNode, v => v + "$"); + + return Filter.Regex(BuildFieldDefinition(fieldNode), value); + } + + if (string.Equals(nodeIn.Name, "startswith", StringComparison.OrdinalIgnoreCase)) + { + var value = BuildRegex(valueNode, v => "^" + v); + + return Filter.Regex(BuildFieldDefinition(fieldNode), value); + } + + if (string.Equals(nodeIn.Name, "contains", StringComparison.OrdinalIgnoreCase)) + { + var value = BuildRegex(valueNode, v => v); + + return Filter.Regex(BuildFieldDefinition(fieldNode), value); + } + + throw new NotSupportedException(); + } + + public override FilterDefinition Visit(BinaryOperatorNode nodeIn) + { + if (nodeIn.OperatorKind == BinaryOperatorKind.And) + { + return Filter.And(nodeIn.Left.Accept(this), nodeIn.Right.Accept(this)); + } + + if (nodeIn.OperatorKind == BinaryOperatorKind.Or) + { + return Filter.Or(nodeIn.Left.Accept(this), nodeIn.Right.Accept(this)); + } + + if (nodeIn.Left is SingleValueFunctionCallNode functionNode) + { + var regexFilter = Visit(functionNode); + + var value = BuildValue(nodeIn.Right); + + if (value is bool booleanRight) + { + if ((nodeIn.OperatorKind == BinaryOperatorKind.Equal && !booleanRight) || + (nodeIn.OperatorKind == BinaryOperatorKind.NotEqual && booleanRight)) + { + regexFilter = Filter.Not(regexFilter); + } + + return regexFilter; + } + } + else + { + if (nodeIn.OperatorKind == BinaryOperatorKind.NotEqual) + { + var field = BuildFieldDefinition(nodeIn.Left); + + return Filter.Or( + Filter.Not(Filter.Exists(field)), + Filter.Ne(field, BuildValue(nodeIn.Right))); + } + + if (nodeIn.OperatorKind == BinaryOperatorKind.Equal) + { + return Filter.Eq(BuildFieldDefinition(nodeIn.Left), BuildValue(nodeIn.Right)); + } + + if (nodeIn.OperatorKind == BinaryOperatorKind.LessThan) + { + return Filter.Lt(BuildFieldDefinition(nodeIn.Left), BuildValue(nodeIn.Right)); + } + + if (nodeIn.OperatorKind == BinaryOperatorKind.LessThanOrEqual) + { + return Filter.Lte(BuildFieldDefinition(nodeIn.Left), BuildValue(nodeIn.Right)); + } + + if (nodeIn.OperatorKind == BinaryOperatorKind.GreaterThan) + { + return Filter.Gt(BuildFieldDefinition(nodeIn.Left), BuildValue(nodeIn.Right)); + } + + if (nodeIn.OperatorKind == BinaryOperatorKind.GreaterThanOrEqual) + { + return Filter.Gte(BuildFieldDefinition(nodeIn.Left), BuildValue(nodeIn.Right)); + } + } + + throw new NotSupportedException(); + } + + private static BsonRegularExpression BuildRegex(QueryNode node, Func formatter) + { + return new BsonRegularExpression(formatter(BuildValue(node).ToString()), "i"); + } + + private FieldDefinition BuildFieldDefinition(QueryNode nodeIn) + { + return PropertyVisitor.Visit(nodeIn); + } + + private static object BuildValue(QueryNode nodeIn) + { + return ConstantVisitor.Visit(nodeIn); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs new file mode 100644 index 000000000..711a7f58f --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs @@ -0,0 +1,88 @@ +// ========================================================================== +// FindExtensions.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== +using System; +using System.Collections.Generic; +using Microsoft.OData.UriParser; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors +{ + public static class FindExtensions + { + private static readonly FilterDefinitionBuilder Filter = Builders.Filter; + + public static IFindFluent Sort(this IFindFluent cursor, ODataUriParser query) + { + return cursor.Sort(SortBuilder.BuildSort(query)); + } + + public static IFindFluent Take(this IFindFluent cursor, ODataUriParser query) + { + var top = query.ParseTop(); + + if (top.HasValue) + { + cursor = cursor.Limit(Math.Min((int)top.Value, 200)); + } + else + { + cursor = cursor.Limit(20); + } + + return cursor; + } + + public static IFindFluent Skip(this IFindFluent cursor, ODataUriParser query) + { + var skip = query.ParseSkip(); + + if (skip.HasValue) + { + cursor = cursor.Skip((int)skip.Value); + } + else + { + cursor = cursor.Skip(null); + } + + return cursor; + } + + public static IFindFluent Find(this IMongoCollection cursor, ODataUriParser query, Guid appId) + { + var filter = BuildQuery(query, appId); + + return cursor.Find(filter); + } + + public static FilterDefinition BuildQuery(ODataUriParser query, Guid appId) + { + var filters = new List> + { + Filter.Eq(x => x.AppId, appId), + Filter.Eq(x => x.IsDeleted, false) + }; + + filters.AddRange(FilterBuilder.Build(query)); + + if (filters.Count > 1) + { + return Filter.And(filters); + } + else if (filters.Count == 1) + { + return filters[0]; + } + else + { + return new BsonDocument(); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/PropertyVisitor.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/PropertyVisitor.cs new file mode 100644 index 000000000..723d03c38 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/PropertyVisitor.cs @@ -0,0 +1,55 @@ +// ========================================================================== +// PropertyVisitor.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== +using System.Collections.Immutable; +using System.Linq; +using Microsoft.OData.UriParser; +using MongoDB.Driver; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors +{ + public sealed class PropertyVisitor : QueryNodeVisitor> + { + private static readonly PropertyVisitor Instance = new PropertyVisitor(); + + public static StringFieldDefinition Visit(QueryNode node) + { + var propertyNames = node.Accept(Instance).ToArray(); + + return new StringFieldDefinition(propertyNames.First()); + } + + public override ImmutableList Visit(ConvertNode nodeIn) + { + return nodeIn.Source.Accept(this); + } + + public override ImmutableList Visit(SingleComplexNode nodeIn) + { + if (nodeIn.Source is SingleComplexNode) + { + return nodeIn.Source.Accept(this).Add(nodeIn.Property.Name); + } + else + { + return ImmutableList.Create(nodeIn.Property.Name); + } + } + + public override ImmutableList Visit(SingleValuePropertyAccessNode nodeIn) + { + if (nodeIn.Source is SingleComplexNode) + { + return nodeIn.Source.Accept(this).Add(nodeIn.Property.Name); + } + else + { + return ImmutableList.Create(nodeIn.Property.Name); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/SearchTermVisitor.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/SearchTermVisitor.cs new file mode 100644 index 000000000..23366fde2 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/SearchTermVisitor.cs @@ -0,0 +1,41 @@ +// ========================================================================== +// SearchTermVisitor.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== +using System; +using Microsoft.OData.UriParser; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors +{ + public class SearchTermVisitor : QueryNodeVisitor + { + private static readonly SearchTermVisitor Instance = new SearchTermVisitor(); + + private SearchTermVisitor() + { + } + + public static object Visit(QueryNode node) + { + return node.Accept(Instance); + } + + public override string Visit(BinaryOperatorNode nodeIn) + { + if (nodeIn.OperatorKind == BinaryOperatorKind.And) + { + return nodeIn.Left.Accept(this) + " " + nodeIn.Right.Accept(this); + } + + throw new NotSupportedException(); + } + + public override string Visit(SearchTermNode nodeIn) + { + return nodeIn.Text; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/SortBuilder.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/SortBuilder.cs new file mode 100644 index 000000000..cf1cef451 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/SortBuilder.cs @@ -0,0 +1,60 @@ +// ========================================================================== +// SortBuilder.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== +using System.Collections.Generic; +using Microsoft.OData.UriParser; +using MongoDB.Driver; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors +{ + public static class SortBuilder + { + private static readonly SortDefinitionBuilder Sort = Builders.Sort; + + public static SortDefinition BuildSort(ODataUriParser query) + { + var orderBy = query.ParseOrderBy(); + + if (orderBy != null) + { + var sorts = new List>(); + + while (orderBy != null) + { + sorts.Add(OrderBy(orderBy)); + + orderBy = orderBy.ThenBy; + } + + if (sorts.Count > 1) + { + return Sort.Combine(sorts); + } + else + { + return sorts[0]; + } + } + else + { + return Sort.Descending(x => x.LastModified); + } + } + + public static SortDefinition OrderBy(OrderByClause clause) + { + if (clause.Direction == OrderByDirection.Ascending) + { + return Sort.Ascending(PropertyVisitor.Visit(clause.Expression)); + } + else + { + return Sort.Descending(PropertyVisitor.Visit(clause.Expression)); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmModelBuilder.cs b/src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmModelBuilder.cs new file mode 100644 index 000000000..61516c68e --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmModelBuilder.cs @@ -0,0 +1,55 @@ +// ========================================================================== +// EdmModelBuilder.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== +using Microsoft.OData.Edm; +using Squidex.Domain.Apps.Entities.Assets.State; + +namespace Squidex.Domain.Apps.Entities.Assets.Edm +{ + public class EdmModelBuilder + { + private readonly IEdmModel edmModel; + public EdmModelBuilder() + { + edmModel = BuildEdmModel(); + } + + public virtual IEdmModel EdmModel + { + get { return edmModel; } + } + + private IEdmModel BuildEdmModel() + { + var model = new EdmModel(); + var container = new EdmEntityContainer("Squidex", "Container"); + var entityType = new EdmEntityType("Squidex", "Asset"); + + entityType.AddStructuralProperty(nameof(AssetState.Id), EdmPrimitiveTypeKind.Guid); + entityType.AddStructuralProperty(nameof(AssetState.AppId), EdmPrimitiveTypeKind.Guid); + entityType.AddStructuralProperty(nameof(AssetState.Created), EdmPrimitiveTypeKind.DateTimeOffset); + entityType.AddStructuralProperty(nameof(AssetState.CreatedBy), EdmPrimitiveTypeKind.String); + entityType.AddStructuralProperty(nameof(AssetState.LastModified), EdmPrimitiveTypeKind.DateTimeOffset); + entityType.AddStructuralProperty(nameof(AssetState.LastModifiedBy), EdmPrimitiveTypeKind.String); + entityType.AddStructuralProperty(nameof(AssetState.Version), EdmPrimitiveTypeKind.Int64); + entityType.AddStructuralProperty(nameof(AssetState.FileName), EdmPrimitiveTypeKind.String); + entityType.AddStructuralProperty(nameof(AssetState.FileSize), EdmPrimitiveTypeKind.Int64); + entityType.AddStructuralProperty(nameof(AssetState.FileVersion), EdmPrimitiveTypeKind.Int64); + entityType.AddStructuralProperty(nameof(AssetState.IsImage), EdmPrimitiveTypeKind.Boolean); + entityType.AddStructuralProperty(nameof(AssetState.MimeType), EdmPrimitiveTypeKind.String); + entityType.AddStructuralProperty(nameof(AssetState.PixelHeight), EdmPrimitiveTypeKind.Int32); + entityType.AddStructuralProperty(nameof(AssetState.PixelWidth), EdmPrimitiveTypeKind.Int32); + + model.AddElement(container); + model.AddElement(entityType); + + container.AddEntitySet("AssetSet", entityType); + + return model; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmModelExtensions.cs b/src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmModelExtensions.cs new file mode 100644 index 000000000..6bb691335 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmModelExtensions.cs @@ -0,0 +1,38 @@ +// ========================================================================== +// EdmModelExtensions.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== +using System; +using System.Linq; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; + +namespace Squidex.Domain.Apps.Entities.Assets.Edm +{ + public static class EdmModelExtensions + { + public static ODataUriParser ParseQuery(this IEdmModel model, string query) + { + if (!model.EntityContainer.EntitySets().Any()) + { + return null; + } + + query = query ?? string.Empty; + + var path = model.EntityContainer.EntitySets().First().Path.Path.Split('.').Last(); + + if (query.StartsWith("?", StringComparison.Ordinal)) + { + query = query.Substring(1); + } + + var parser = new ODataUriParser(model, new Uri($"{path}?{query}", UriKind.Relative)); + + return parser; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs b/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs index c2bd7eca1..f748b4936 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs @@ -14,7 +14,9 @@ namespace Squidex.Domain.Apps.Entities.Assets.Repositories { public interface IAssetRepository { - Task> QueryAsync(Guid appId, HashSet mimeTypes = null, HashSet ids = null, string query = null, int take = 10, int skip = 0); + Task> QueryAsync(Guid appId, string query = null); + + Task> QueryAsync(Guid appId, HashSet ids); Task FindAssetAsync(Guid id); } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs index d06df5e7c..c0bccc485 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text; using System.Threading.Tasks; using Squidex.Domain.Apps.Core.EnrichContent; using Squidex.Domain.Apps.Core.Scripting; @@ -106,7 +107,7 @@ namespace Squidex.Domain.Apps.Entities.Contents private async Task> QueryAssetsAsync(Guid appId, IEnumerable assetIds) { - return await assetRepository.QueryAsync(appId, null, new HashSet(assetIds), null, int.MaxValue, 0); + return await assetRepository.QueryAsync(appId, new HashSet(assetIds)); } private async Task> QueryContentsAsync(Guid appId, Guid schemaId, IEnumerable contentIds) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs index 40c64fdbf..fcc0f6110 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs @@ -82,11 +82,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types ResolvedType = new ListGraphType(new NonNullGraphType(assetType)), Resolver = ResolveAsync((c, e) => { - var argTake = c.GetArgument("take", 20); - var argSkip = c.GetArgument("skip", 0); - var argQuery = c.GetArgument("search", string.Empty); + var assetQuery = BuildODataQuery(c); - return e.QueryAssetsAsync(argQuery, argSkip, argTake); + return e.QueryAssetsAsync(assetQuery); }), Description = "Get assets." }); @@ -98,11 +96,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types ResolvedType = new AssetsResultGraphType(assetType), Resolver = ResolveAsync((c, e) => { - var argTake = c.GetArgument("take", 20); - var argSkip = c.GetArgument("skip", 0); - var argQuery = c.GetArgument("search", string.Empty); + var assetQuery = BuildODataQuery(c); - return e.QueryAssetsAsync(argQuery, argSkip, argTake); + return e.QueryAssetsAsync(assetQuery); }), Description = "Get assets and total count." }); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/QueryContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/QueryContext.cs index 8ed70d159..bf5dbd7ab 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/QueryContext.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/QueryContext.cs @@ -80,9 +80,9 @@ namespace Squidex.Domain.Apps.Entities.Contents return content; } - public async Task> QueryAssetsAsync(string query, int skip = 0, int take = 10) + public async Task> QueryAssetsAsync(string query) { - var assets = await assetRepository.QueryAsync(app.Id, null, null, query, take, skip); + var assets = await assetRepository.QueryAsync(app.Id, query); foreach (var asset in assets) { @@ -112,7 +112,7 @@ namespace Squidex.Domain.Apps.Entities.Contents if (notLoadedAssets.Count > 0) { - var assets = await assetRepository.QueryAsync(app.Id, null, notLoadedAssets, null, int.MaxValue); + var assets = await assetRepository.QueryAsync(app.Id, notLoadedAssets); foreach (var asset in assets) { diff --git a/src/Squidex/Areas/Api/Config/Swagger/SwaggerServices.cs b/src/Squidex/Areas/Api/Config/Swagger/SwaggerServices.cs index 7658b8011..cc8bd4334 100644 --- a/src/Squidex/Areas/Api/Config/Swagger/SwaggerServices.cs +++ b/src/Squidex/Areas/Api/Config/Swagger/SwaggerServices.cs @@ -29,7 +29,16 @@ namespace Squidex.Areas.Api.Config.Swagger var urlOptions = s.GetService>().Value; var settings = - new SwaggerSettings { Title = "Squidex API", Version = "1.0", IsAspNetCore = false } + new SwaggerSettings + { + Title = "Squidex API", + Version = "1.0", + IsAspNetCore = false, + OperationProcessors = + { + new Docs.AddODataQueryParams() + } + } .ConfigurePaths(urlOptions) .ConfigureSchemaSettings() .ConfigureIdentity(urlOptions); diff --git a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs index 8a1d43c1f..8938907c3 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs @@ -59,11 +59,6 @@ namespace Squidex.Areas.Api.Controllers.Assets /// Get assets. /// /// The name of the app. - /// The optional asset ids. - /// Optional number of assets to skip. - /// Optional number of assets to take (Default: 20). - /// Optional query to limit the files by name. - /// Comma separated list of mime types to get. /// /// 200 => Assets returned. /// 404 => App not found. @@ -76,32 +71,9 @@ namespace Squidex.Areas.Api.Controllers.Assets [Route("apps/{app}/assets/")] [ProducesResponseType(typeof(AssetsDto), 200)] [ApiCosts(1)] - public async Task GetAssets(string app, [FromQuery] string query = null, [FromQuery] string mimeTypes = null, [FromQuery] string ids = null, [FromQuery] int skip = 0, [FromQuery] int take = 20) + public async Task GetAssets(string app) { - var mimeTypeList = new HashSet(); - - if (!string.IsNullOrWhiteSpace(mimeTypes)) - { - foreach (var mimeType in mimeTypes.Split(',')) - { - mimeTypeList.Add(mimeType.Trim()); - } - } - - var idsList = new HashSet(); - - if (!string.IsNullOrWhiteSpace(ids)) - { - foreach (var id in ids.Split(',')) - { - if (Guid.TryParse(id, out var guid)) - { - idsList.Add(guid); - } - } - } - - var assets = await assetRepository.QueryAsync(App.Id, mimeTypeList, idsList, query, take, skip); + var assets = await assetRepository.QueryAsync(App.Id, Request.QueryString.ToString()); var response = new AssetsDto { diff --git a/src/Squidex/Config/Domain/ReadServices.cs b/src/Squidex/Config/Domain/ReadServices.cs index 1a24fedd5..abdd6b4bc 100644 --- a/src/Squidex/Config/Domain/ReadServices.cs +++ b/src/Squidex/Config/Domain/ReadServices.cs @@ -120,6 +120,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .AsSelf(); + services.AddSingletonAs() + .AsSelf(); + services.AddSingletonAs() .AsSelf(); } diff --git a/src/Squidex/Config/Domain/StoreServices.cs b/src/Squidex/Config/Domain/StoreServices.cs index f9b849af6..eb84e6d97 100644 --- a/src/Squidex/Config/Domain/StoreServices.cs +++ b/src/Squidex/Config/Domain/StoreServices.cs @@ -16,6 +16,7 @@ using Newtonsoft.Json; using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Apps.Repositories; using Squidex.Domain.Apps.Entities.Apps.State; +using Squidex.Domain.Apps.Entities.Assets.Edm; using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Domain.Apps.Entities.Assets.State; using Squidex.Domain.Apps.Entities.Contents.Repositories; @@ -101,7 +102,7 @@ namespace Squidex.Config.Domain .As>() .As(); - services.AddSingletonAs(c => new MongoAssetRepository(mongoDatabase)) + services.AddSingletonAs(c => new MongoAssetRepository(mongoDatabase, c.GetRequiredService())) .As() .As>() .As(); diff --git a/src/Squidex/Docs/AddODataQueryParams.cs b/src/Squidex/Docs/AddODataQueryParams.cs new file mode 100644 index 000000000..f79e4b210 --- /dev/null +++ b/src/Squidex/Docs/AddODataQueryParams.cs @@ -0,0 +1,34 @@ +// ========================================================================== +// AddODataQueryParams.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Threading.Tasks; +using NJsonSchema; +using NSwag.SwaggerGeneration.Processors; +using NSwag.SwaggerGeneration.Processors.Contexts; +using Squidex.Infrastructure.Tasks; +using Squidex.Pipeline.Swagger; + +namespace Squidex.Docs +{ + public class AddODataQueryParams : IOperationProcessor + { + public Task ProcessAsync(OperationProcessorContext context) + { + if (context.OperationDescription.Path == "/apps/{app}/assets") + { + context.OperationDescription.Operation.AddQueryParameter("$top", JsonObjectType.Number, "Optional number of contents to take."); + context.OperationDescription.Operation.AddQueryParameter("$skip", JsonObjectType.Number, "Optional number of contents to skip."); + context.OperationDescription.Operation.AddQueryParameter("$search", JsonObjectType.String, "Optional OData full text search."); + context.OperationDescription.Operation.AddQueryParameter("$orderby", JsonObjectType.String, "Optional OData order definition."); + context.OperationDescription.Operation.AddQueryParameter("$filter", JsonObjectType.String, "Optional OData filter definition."); + } + + return TaskHelper.True; + } + } +} diff --git a/src/Squidex/app/shared/services/assets.service.spec.ts b/src/Squidex/app/shared/services/assets.service.spec.ts index 5e98293ae..00e5b575d 100644 --- a/src/Squidex/app/shared/services/assets.service.spec.ts +++ b/src/Squidex/app/shared/services/assets.service.spec.ts @@ -88,8 +88,8 @@ describe('AssetsService', () => { assets = result; }); - const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets?take=17&skip=13'); - + const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets?$top=17&$skip=13'); + expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); @@ -219,7 +219,7 @@ describe('AssetsService', () => { assetsService.getAssets('my-app', 17, 13, 'my-query').subscribe(); - const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets?query=my-query&take=17&skip=13'); + const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets?$search=my-query&$top=17&$skip=13'); expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); @@ -232,7 +232,7 @@ describe('AssetsService', () => { assetsService.getAssets('my-app', 17, 13, undefined, ['image/png', 'image/png']).subscribe(); - const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets?mimeTypes=image/png,image/png&take=17&skip=13'); + const req = httpMock.expectOne(`http://service/p/api/apps/my-app/assets?$filter=MimeType eq 'image/png' or MimeType eq 'image/png'&$top=17&$skip=13`); expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); @@ -245,7 +245,7 @@ describe('AssetsService', () => { assetsService.getAssets('my-app', 17, 13, undefined, undefined, ['12', '23']).subscribe(); - const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets?ids=12,23&take=17&skip=13'); + const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets?$filter=Id eq 12 or Id eq 23&$top=17&$skip=13'); expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); diff --git a/src/Squidex/app/shared/services/assets.service.ts b/src/Squidex/app/shared/services/assets.service.ts index 065103db8..c272a7c29 100644 --- a/src/Squidex/app/shared/services/assets.service.ts +++ b/src/Squidex/app/shared/services/assets.service.ts @@ -111,21 +111,26 @@ export class AssetsService { public getAssets(appName: string, take: number, skip: number, query?: string, mimeTypes?: string[], ids?: string[]): Observable { const queries: string[] = []; + const filters: string[] = []; if (mimeTypes && mimeTypes.length > 0) { - queries.push(`mimeTypes=${mimeTypes.join(',')}`); + filters.push(mimeTypes.map(mimeType => `MimeType eq '${mimeType}'`).join(' or ')); } if (ids && ids.length > 0) { - queries.push(`ids=${ids.join(',')}`); + filters.push(ids.map(id => `Id eq ${id}`).join(' or ')); + } + + if (filters.length > 0) { + queries.push(`$filter=${filters.join(' and ')}`); } if (query && query.length > 0) { - queries.push(`query=${query}`); + queries.push(`$search=${encodeURIComponent(query)}`); } - queries.push(`take=${take}`); - queries.push(`skip=${skip}`); + queries.push(`$top=${take}`); + queries.push(`$skip=${skip}`); const fullQuery = queries.join('&'); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs index 5b6d937a2..9cb71fc72 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs @@ -65,7 +65,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var assets = new List { asset }; - A.CallTo(() => assetRepository.QueryAsync(app.Id, null, null, "my-query", 30, 5)) + A.CallTo(() => assetRepository.QueryAsync(app.Id, "?$take=30&$skip=5&$search=my-query")) .Returns(ResultList.Create(assets, 0)); var result = await sut.QueryAsync(app, user, new GraphQLQuery { Query = query }); @@ -134,7 +134,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var assets = new List { asset }; - A.CallTo(() => assetRepository.QueryAsync(app.Id, null, null, "my-query", 30, 5)) + A.CallTo(() => assetRepository.QueryAsync(app.Id, "?$take=30&$skip=5&$search=my-query")) .Returns(ResultList.Create(assets, 10)); var result = await sut.QueryAsync(app, user, new GraphQLQuery { Query = query }); @@ -667,7 +667,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL A.CallTo(() => contentQuery.FindContentAsync(app, schema.Id.ToString(), user, contentId, EtagVersion.Any)) .Returns((schema, content)); - A.CallTo(() => assetRepository.QueryAsync(app.Id, null, A>.That.Matches(x => x.Contains(assetRefId)), null, int.MaxValue, 0)) + A.CallTo(() => assetRepository.QueryAsync(app.Id, A>.That.Matches(x => x.Contains(assetRefId)))) .Returns(ResultList.Create(refAssets, 0)); var result = await sut.QueryAsync(app, user, new GraphQLQuery { Query = query }); diff --git a/tools/Migrate_01/Migration04_FlattenAssetEntity.cs b/tools/Migrate_01/Migration04_FlattenAssetEntity.cs new file mode 100644 index 000000000..dc01bfbb8 --- /dev/null +++ b/tools/Migrate_01/Migration04_FlattenAssetEntity.cs @@ -0,0 +1,36 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Infrastructure.Migrations; + +namespace Migrate_01 +{ + public class Migration04_FlattenAssetEntity : IMigration + { + private readonly Rebuilder rebuilder; + + public int FromVersion { get; } = 3; + + public int ToVersion { get; } = 4; + + public Migration04_FlattenAssetEntity(Rebuilder rebuilder) + { + this.rebuilder = rebuilder; + } + + public async Task UpdateAsync(IEnumerable previousMigrations) + { + if (!previousMigrations.Any(x => x is Migration01_FromCqrs)) + { + await rebuilder.RebuildAssetsAsync(); + } + } + } +}