diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs index b59365144..1f730705d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs @@ -81,7 +81,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents private async Task UpsertOrDeletePublishedAsync(ContentState value, long oldVersion, long newVersion, ISchemaEntity schema) { - if (value.Status == Status.Published) + if (value.Status == Status.Published && !value.IsDeleted) { await UpsertPublishedContentAsync(value, oldVersion, newVersion, schema); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateCommandMiddleware.cs new file mode 100644 index 000000000..bfe482ea0 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateCommandMiddleware.cs @@ -0,0 +1,174 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Linq; +using System.Threading.Tasks; +using System.Threading.Tasks.Dataflow; +using Microsoft.Extensions.DependencyInjection; +using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class BulkUpdateCommandMiddleware : ICommandMiddleware + { + private readonly IServiceProvider serviceProvider; + private readonly IContentQueryService contentQuery; + private readonly IContextProvider contextProvider; + + public BulkUpdateCommandMiddleware(IServiceProvider serviceProvider, IContentQueryService contentQuery, IContextProvider contextProvider) + { + Guard.NotNull(serviceProvider); + Guard.NotNull(contentQuery); + Guard.NotNull(contextProvider); + + this.serviceProvider = serviceProvider; + this.contentQuery = contentQuery; + this.contextProvider = contextProvider; + } + + public async Task HandleAsync(CommandContext context, NextDelegate next) + { + if (context.Command is BulkUpdateContents bulkUpdates) + { + if (bulkUpdates.Jobs?.Count > 0) + { + var requestContext = contextProvider.Context.WithoutContentEnrichment().WithUnpublished(true); + var requestedSchema = bulkUpdates.SchemaId.Name; + + var results = new BulkUpdateResultItem[bulkUpdates.Jobs.Count]; + + var actionBlock = new ActionBlock(async index => + { + var job = bulkUpdates.Jobs[index]; + + var result = new BulkUpdateResultItem(); + + try + { + var id = await FindIdAsync(requestContext, requestedSchema, job); + + result.ContentId = id; + + switch (job.Type) + { + case BulkUpdateType.Upsert: + { + if (id.HasValue) + { + var command = SimpleMapper.Map(bulkUpdates, new UpdateContent { Data = job.Data, ContentId = id.Value }); + + await context.CommandBus.PublishAsync(command); + + results[index] = new BulkUpdateResultItem { ContentId = id }; + } + else + { + var command = SimpleMapper.Map(bulkUpdates, new CreateContent { Data = job.Data }); + + var content = serviceProvider.GetRequiredService(); + + content.Setup(command.ContentId); + + await content.ExecuteAsync(command); + + result.ContentId = command.ContentId; + } + + break; + } + + case BulkUpdateType.ChangeStatus: + { + if (id == null || id == default) + { + throw new DomainObjectNotFoundException("NOT DEFINED", typeof(IContentEntity)); + } + + var command = SimpleMapper.Map(bulkUpdates, new ChangeContentStatus { ContentId = id.Value }); + + if (job.Status != null) + { + command.Status = job.Status.Value; + } + + await context.CommandBus.PublishAsync(command); + break; + } + + case BulkUpdateType.Delete: + { + if (id == null || id == default) + { + throw new DomainObjectNotFoundException("NOT DEFINED", typeof(IContentEntity)); + } + + var command = SimpleMapper.Map(bulkUpdates, new DeleteContent { ContentId = id.Value }); + + await context.CommandBus.PublishAsync(command); + break; + } + } + } + catch (Exception ex) + { + result.Exception = ex; + } + + results[index] = result; + }, new ExecutionDataflowBlockOptions + { + MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount / 2) + }); + + for (var i = 0; i < bulkUpdates.Jobs.Count; i++) + { + await actionBlock.SendAsync(i); + } + + actionBlock.Complete(); + + await actionBlock.Completion; + + context.Complete(new BulkUpdateResult(results)); + } + else + { + context.Complete(new BulkUpdateResult()); + } + } + else + { + await next(context); + } + } + + private async Task FindIdAsync(Context context, string schema, BulkUpdateJob job) + { + var id = job.Id; + + if (id == null && job.Query != null) + { + job.Query.Take = 1; + + var existing = await contentQuery.QueryAsync(context, schema, Q.Empty.WithJsonQuery(job.Query)); + + if (existing.Total > 1) + { + throw new DomainException("More than one content matches to the query."); + } + + id = existing.FirstOrDefault()?.Id; + } + + return id; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateResult.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateResult.cs new file mode 100644 index 000000000..d09faf539 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateResult.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// 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.Contents +{ + public sealed class BulkUpdateResult : List + { + public BulkUpdateResult() + { + } + + public BulkUpdateResult(IEnumerable source) + : base(source) + { + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ImportResultItem.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateResultItem.cs similarity index 92% rename from backend/src/Squidex.Domain.Apps.Entities/Contents/ImportResultItem.cs rename to backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateResultItem.cs index 1e6d2254f..b93e58f33 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ImportResultItem.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateResultItem.cs @@ -9,7 +9,7 @@ using System; namespace Squidex.Domain.Apps.Entities.Contents { - public sealed class ImportResultItem + public sealed class BulkUpdateResultItem { public Guid? ContentId { get; set; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/CreateContents.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateContents.cs similarity index 81% rename from backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/CreateContents.cs rename to backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateContents.cs index d3cfcef3f..6ebcfecdd 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/CreateContents.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateContents.cs @@ -7,12 +7,11 @@ using System; using System.Collections.Generic; -using Squidex.Domain.Apps.Core.Contents; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents.Commands { - public sealed class CreateContents : SquidexCommand, ISchemaCommand, IAppCommand + public sealed class BulkUpdateContents : SquidexCommand, ISchemaCommand, IAppCommand { public NamedId AppId { get; set; } @@ -26,6 +25,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands public bool OptimizeValidation { get; set; } - public List Datas { get; set; } + public List? Jobs { get; set; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateJob.cs new file mode 100644 index 000000000..80c751e02 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateJob.cs @@ -0,0 +1,27 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure.Json.Objects; +using Squidex.Infrastructure.Queries; + +namespace Squidex.Domain.Apps.Entities.Contents.Commands +{ + public sealed class BulkUpdateJob + { + public Query? Query { get; set; } + + public Guid? Id { get; set; } + + public NamedContentData Data { get; set; } + + public Status? Status { get; set; } + + public BulkUpdateType Type { get; set; } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ImportResult.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateType.cs similarity index 72% rename from backend/src/Squidex.Domain.Apps.Entities/Contents/ImportResult.cs rename to backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateType.cs index 7128381a4..eadfbd212 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ImportResult.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateType.cs @@ -5,11 +5,12 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Collections.Generic; - -namespace Squidex.Domain.Apps.Entities.Contents +namespace Squidex.Domain.Apps.Entities.Contents.Commands { - public sealed class ImportResult : List + public enum BulkUpdateType { + Upsert, + ChangeStatus, + Delete } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs index 8e15d2a7b..fdf122c55 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs @@ -14,6 +14,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands { public Guid ContentId { get; set; } + public bool DoNotScript { get; set; } + Guid IAggregateCommand.AggregateId { get { return ContentId; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs index f2eea4643..c03f5ca5f 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs @@ -11,6 +11,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands { public abstract class ContentDataCommand : ContentCommand { + public bool DoNotValidate { get; set; } + + public bool OptimizeValidation { get; set; } + public NamedContentData Data { get; set; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/CreateContent.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/CreateContent.cs index 077036d0c..2eec65f73 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/CreateContent.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/CreateContent.cs @@ -18,12 +18,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands public bool Publish { get; set; } - public bool DoNotValidate { get; set; } - - public bool DoNotScript { get; set; } - - public bool OptimizeValidation { get; set; } - public CreateContent() { ContentId = Guid.NewGuid(); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs index 34442d68b..0d943aa95 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs @@ -78,7 +78,7 @@ namespace Squidex.Domain.Apps.Entities.Contents await context.ValidateContentAsync(c.Data); } - if (c.Publish) + if (!c.DoNotScript && c.Publish) { await context.ExecuteScriptAsync(s => s.Change, new ScriptContext @@ -154,14 +154,17 @@ namespace Squidex.Domain.Apps.Entities.Contents { var change = GetChange(c); - await context.ExecuteScriptAsync(s => s.Change, - new ScriptContext - { - Operation = change.ToString(), - Data = Snapshot.Data, - Status = c.Status, - StatusOld = Snapshot.EditingStatus - }); + if (!c.DoNotScript) + { + await context.ExecuteScriptAsync(s => s.Change, + new ScriptContext + { + Operation = change.ToString(), + Data = Snapshot.Data, + Status = c.Status, + StatusOld = Snapshot.EditingStatus + }); + } ChangeStatus(c, change); } @@ -188,14 +191,17 @@ namespace Squidex.Domain.Apps.Entities.Contents GuardContent.CanDelete(context.Schema, c); - await context.ExecuteScriptAsync(s => s.Delete, - new ScriptContext - { - Operation = "Delete", - Data = Snapshot.Data, - Status = Snapshot.EditingStatus, - StatusOld = default - }); + if (!c.DoNotScript) + { + await context.ExecuteScriptAsync(s => s.Delete, + new ScriptContext + { + Operation = "Delete", + Data = Snapshot.Data, + Status = Snapshot.EditingStatus, + StatusOld = default + }); + } Delete(c); }); @@ -213,28 +219,37 @@ namespace Squidex.Domain.Apps.Entities.Contents if (!currentData!.Equals(newData)) { - await LoadContext(Snapshot.AppId, Snapshot.SchemaId, command, () => "Failed to update content."); + await LoadContext(Snapshot.AppId, Snapshot.SchemaId, command, () => "Failed to update content.", command.OptimizeValidation); - if (partial) + if (!command.DoNotValidate) { - await context.ValidateInputPartialAsync(command.Data); + if (partial) + { + await context.ValidateInputPartialAsync(command.Data); + } + else + { + await context.ValidateInputAsync(command.Data); + } } - else + + if (!command.DoNotScript) { - await context.ValidateInputAsync(command.Data); + newData = await context.ExecuteScriptAndTransformAsync(s => s.Update, + new ScriptContext + { + Operation = "Create", + Data = newData, + DataOld = currentData, + Status = Snapshot.EditingStatus, + StatusOld = default + }); } - newData = await context.ExecuteScriptAndTransformAsync(s => s.Update, - new ScriptContext - { - Operation = "Create", - Data = newData, - DataOld = currentData, - Status = Snapshot.EditingStatus, - StatusOld = default - }); - - await context.ValidateContentAsync(newData); + if (!command.DoNotValidate) + { + await context.ValidateContentAsync(newData); + } Update(command, newData); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentImporterCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentImporterCommandMiddleware.cs deleted file mode 100644 index ab452990a..000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentImporterCommandMiddleware.cs +++ /dev/null @@ -1,69 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -using Squidex.Domain.Apps.Entities.Contents.Commands; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Reflection; - -namespace Squidex.Domain.Apps.Entities.Contents -{ - public sealed class ContentImporterCommandMiddleware : ICommandMiddleware - { - private readonly IServiceProvider serviceProvider; - - public ContentImporterCommandMiddleware(IServiceProvider serviceProvider) - { - Guard.NotNull(serviceProvider); - - this.serviceProvider = serviceProvider; - } - - public async Task HandleAsync(CommandContext context, NextDelegate next) - { - if (context.Command is CreateContents createContents) - { - var result = new ImportResult(); - - if (createContents.Datas != null && createContents.Datas.Count > 0) - { - var command = SimpleMapper.Map(createContents, new CreateContent()); - - foreach (var data in createContents.Datas) - { - try - { - command.ContentId = Guid.NewGuid(); - command.Data = data; - - var content = serviceProvider.GetRequiredService(); - - content.Setup(command.ContentId); - - await content.ExecuteAsync(command); - - result.Add(new ImportResultItem { ContentId = command.ContentId }); - } - catch (Exception ex) - { - result.Add(new ImportResultItem { Exception = ex }); - } - } - } - - context.Complete(result); - } - else - { - await next(context); - } - } - } -} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs index c32385adf..2b4bff6a8 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs @@ -21,6 +21,7 @@ using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.Json; +using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Queries; using Squidex.Infrastructure.Queries.Json; @@ -61,6 +62,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { result = ParseJson(context, schema, q.JsonQuery); } + else if (q?.ParsedJsonQuery != null) + { + result = ParseJson(context, schema, q.ParsedJsonQuery); + } else if (!string.IsNullOrWhiteSpace(q?.ODataQuery)) { result = ParseOData(context, schema, q.ODataQuery); @@ -89,6 +94,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries } } + private ClrQuery ParseJson(Context context, ISchemaEntity schema, Query query) + { + var jsonSchema = BuildJsonSchema(context, schema); + + return jsonSchema.Convert(query); + } + private ClrQuery ParseJson(Context context, ISchemaEntity schema, string json) { var jsonSchema = BuildJsonSchema(context, schema); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Q.cs b/backend/src/Squidex.Domain.Apps.Entities/Q.cs index 6b840293a..6737a3c84 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Q.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Q.cs @@ -9,6 +9,7 @@ using System; using System.Collections.Generic; using System.Linq; using Squidex.Infrastructure; +using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Queries; namespace Squidex.Domain.Apps.Entities @@ -23,6 +24,8 @@ namespace Squidex.Domain.Apps.Entities public string? JsonQuery { get; private set; } + public Query? ParsedJsonQuery { get; private set; } + public ClrQuery? Query { get; private set; } public Q WithQuery(ClrQuery? query) @@ -40,6 +43,11 @@ namespace Squidex.Domain.Apps.Entities return Clone(c => c.JsonQuery = jsonQuery); } + public Q WithJsonQuery(Query? jsonQuery) + { + return Clone(c => c.ParsedJsonQuery = jsonQuery); + } + public Q WithIds(params Guid[] ids) { return Clone(c => c.Ids = ids.ToList()); diff --git a/backend/src/Squidex.Infrastructure/Queries/Json/QueryParser.cs b/backend/src/Squidex.Infrastructure/Queries/Json/QueryParser.cs index cca0e0b06..8544b89cc 100644 --- a/backend/src/Squidex.Infrastructure/Queries/Json/QueryParser.cs +++ b/backend/src/Squidex.Infrastructure/Queries/Json/QueryParser.cs @@ -27,6 +27,11 @@ namespace Squidex.Infrastructure.Queries.Json var query = ParseFromJson(json, jsonSerializer); + return Convert(schema, query); + } + + public static ClrQuery Convert(this JsonSchema schema, Query query) + { if (query == null) { return new ClrQuery(); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index 54c3e8bf5..5e2e86b65 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -326,10 +326,10 @@ namespace Squidex.Areas.Api.Controllers.Contents /// [HttpPost] [Route("content/{app}/{name}/import")] - [ProducesResponseType(typeof(ImportResultDto[]), 200)] + [ProducesResponseType(typeof(BulkResultDto[]), 200)] [ApiPermission(Permissions.AppContentsCreate)] [ApiCosts(5)] - public async Task PostContent(string app, string name, [FromBody] ImportContentsDto request) + public async Task PostContents(string app, string name, [FromBody] ImportContentsDto request) { await contentQuery.GetSchemaOrThrowAsync(Context, name); @@ -337,8 +337,41 @@ namespace Squidex.Areas.Api.Controllers.Contents var context = await CommandBus.PublishAsync(command); - var result = context.Result(); - var response = result.Select(x => ImportResultDto.FromImportResult(x, HttpContext)).ToArray(); + var result = context.Result(); + var response = result.Select(x => BulkResultDto.FromImportResult(x, HttpContext)).ToArray(); + + return Ok(response); + } + + /// + /// Bulk update content items. + /// + /// The name of the app. + /// The name of the schema. + /// The bulk update request. + /// + /// 201 => Contents created. + /// 404 => Content references, schema or app not found. + /// 400 => Content data is not valid. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs. + /// + [HttpPost] + [Route("content/{app}/{name}/bulk")] + [ProducesResponseType(typeof(BulkResultDto[]), 200)] + [ApiPermission(Permissions.AppContents)] + [ApiCosts(5)] + public async Task BulkContents(string app, string name, [FromBody] BulkUpdateDto request) + { + await contentQuery.GetSchemaOrThrowAsync(Context, name); + + var command = request.ToCommand(); + + var context = await CommandBus.PublishAsync(command); + + var result = context.Result(); + var response = result.Select(x => BulkResultDto.FromImportResult(x, HttpContext)).ToArray(); return Ok(response); } @@ -423,7 +456,7 @@ namespace Squidex.Areas.Api.Controllers.Contents [HttpPut] [Route("content/{app}/{name}/{id}/status/")] [ProducesResponseType(typeof(ContentsDto), 200)] - [ApiPermission] + [ApiPermission(Permissions.AppContentsUpdate)] [ApiCosts(1)] public async Task PutContentStatus(string app, string name, Guid id, ChangeStatusDto request) { diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ImportResultDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkResultDto.cs similarity index 80% rename from backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ImportResultDto.cs rename to backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkResultDto.cs index 7ecc1eeba..5790d8c81 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ImportResultDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkResultDto.cs @@ -12,7 +12,7 @@ using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Contents.Models { - public sealed class ImportResultDto + public sealed class BulkResultDto { /// /// The error when the import failed. @@ -24,11 +24,11 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models /// public Guid? ContentId { get; set; } - public static ImportResultDto FromImportResult(ImportResultItem result, HttpContext httpContext) + public static BulkResultDto FromImportResult(BulkUpdateResultItem result, HttpContext httpContext) { var error = result.Exception?.ToErrorDto(httpContext).Error; - return new ImportResultDto { ContentId = result.ContentId, Error = error }; + return new BulkResultDto { ContentId = result.ContentId, Error = error }; } } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateDto.cs new file mode 100644 index 000000000..57aefa206 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateDto.cs @@ -0,0 +1,48 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Contents.Models +{ + public sealed class BulkUpdateDto + { + /// + /// The contents to update or insert. + /// + [Required] + public List Jobs { get; set; } + + /// + /// True to automatically publish the content. + /// + public bool Publish { get; set; } + + /// + /// True to turn off scripting for faster inserts. Default: true. + /// + public bool DoNotScript { get; set; } = true; + + /// + /// True to turn off costly validation: Unique checks, asset checks and reference checks. Default: true. + /// + public bool OptimizeValidation { get; set; } = true; + + public BulkUpdateContents ToCommand() + { + var result = SimpleMapper.Map(this, new BulkUpdateContents()); + + result.Jobs = Jobs?.Select(x => x.ToJob())?.ToList(); + + return result; + } + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateJobDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateJobDto.cs new file mode 100644 index 000000000..d8b021b55 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateJobDto.cs @@ -0,0 +1,49 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Infrastructure.Json.Objects; +using Squidex.Infrastructure.Queries; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Contents.Models +{ + public class BulkUpdateJobDto + { + /// + /// An optional query to identify the content to update. + /// + public Query? Query { get; set; } + + /// + /// An optional id of the content to update. + /// + public Guid? Id { get; set; } + + /// + /// The data of the content when type is set to 'Upsert'. + /// + public NamedContentData? Data { get; set; } + + /// + /// The new status when the type is set to 'ChangeStatus'. + /// + public Status? Status { get; set; } + + /// + /// The update type. + /// + public BulkUpdateType Type { get; set; } + + public BulkUpdateJob ToJob() + { + return SimpleMapper.Map(this, new BulkUpdateJob()); + } + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs index 88e3d3c3b..9a5db5c51 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs @@ -165,7 +165,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models } } - if (content.NextStatuses != null) + if (controller.HasPermission(Permissions.AppContentsUpdate, app, schema) && content.NextStatuses != null) { foreach (var next in content.NextStatuses) { diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ImportContentsDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ImportContentsDto.cs index 2b5d15290..a4c25fa54 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ImportContentsDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ImportContentsDto.cs @@ -36,9 +36,9 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models /// public bool OptimizeValidation { get; set; } = true; - public CreateContents ToCommand() + public BulkUpdateContents ToCommand() { - return SimpleMapper.Map(this, new CreateContents()); + return SimpleMapper.Map(this, new BulkUpdateContents()); } } } diff --git a/backend/src/Squidex/Config/Domain/CommandsServices.cs b/backend/src/Squidex/Config/Domain/CommandsServices.cs index eb838beef..2ec0f662c 100644 --- a/backend/src/Squidex/Config/Domain/CommandsServices.cs +++ b/backend/src/Squidex/Config/Domain/CommandsServices.cs @@ -77,7 +77,7 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); - services.AddSingletonAs() + services.AddSingletonAs() .As(); services.AddSingletonAs() diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BulkUpdateCommandMiddlewareTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BulkUpdateCommandMiddlewareTests.cs new file mode 100644 index 000000000..0d87c2fb2 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BulkUpdateCommandMiddlewareTests.cs @@ -0,0 +1,394 @@ +// ========================================================================== +// 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 System.Threading.Tasks; +using FakeItEasy; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Json.Objects; +using Squidex.Infrastructure.Queries; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public class BulkUpdateCommandMiddlewareTests + { + private readonly IServiceProvider serviceProvider = A.Fake(); + private readonly IContentQueryService contentQuery = A.Fake(); + private readonly IContextProvider contextProvider = A.Fake(); + private readonly ICommandBus commandBus = A.Dummy(); + private readonly Context requestContext = Context.Anonymous(); + private readonly NamedId schemaId = NamedId.Of(Guid.NewGuid(), "my-schema"); + private readonly BulkUpdateCommandMiddleware sut; + + public BulkUpdateCommandMiddlewareTests() + { + A.CallTo(() => contextProvider.Context) + .Returns(requestContext); + + sut = new BulkUpdateCommandMiddleware(serviceProvider, contentQuery, contextProvider); + } + + [Fact] + public async Task Should_do_nothing_if_jobs_is_null() + { + var command = new BulkUpdateContents(); + + var context = new CommandContext(command, commandBus); + + await sut.HandleAsync(context); + + Assert.True(context.PlainResult is BulkUpdateResult); + + A.CallTo(() => serviceProvider.GetService(A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_do_nothing_if_jobs_is_empty() + { + var command = new BulkUpdateContents { Jobs = new List() }; + + var context = new CommandContext(command, commandBus); + + await sut.HandleAsync(context); + + Assert.True(context.PlainResult is BulkUpdateResult); + + A.CallTo(() => serviceProvider.GetService(A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_import_contents_when_no_query_defined() + { + var (_, data, _) = CreateTestData(false); + + var domainObject = A.Fake(); + + A.CallTo(() => serviceProvider.GetService(typeof(ContentDomainObject))) + .Returns(domainObject); + + var command = new BulkUpdateContents + { + Jobs = new List + { + new BulkUpdateJob + { + Type = BulkUpdateType.Upsert, + Data = data + } + }, + SchemaId = schemaId + }; + + var context = new CommandContext(command, commandBus); + + await sut.HandleAsync(context); + + var result = context.Result(); + + Assert.Single(result); + Assert.Equal(1, result.Count(x => x.ContentId != default && x.Exception == null)); + + A.CallTo(() => domainObject.ExecuteAsync(A.That.Matches(x => x.Data == data))) + .MustHaveHappenedOnceExactly(); + + A.CallTo(() => domainObject.Setup(A._)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task Should_import_contents_when_query_returns_no_result() + { + var (_, data, query) = CreateTestData(false); + + var domainObject = A.Fake(); + + A.CallTo(() => serviceProvider.GetService(typeof(ContentDomainObject))) + .Returns(domainObject); + + var command = new BulkUpdateContents + { + Jobs = new List + { + new BulkUpdateJob + { + Type = BulkUpdateType.Upsert, + Data = data, + Query = query + } + }, + SchemaId = schemaId + }; + + var context = new CommandContext(command, commandBus); + + await sut.HandleAsync(context); + + var result = context.Result(); + + Assert.Single(result); + Assert.Equal(1, result.Count(x => x.ContentId != default && x.Exception == null)); + + A.CallTo(() => domainObject.ExecuteAsync(A.That.Matches(x => x.Data == data))) + .MustHaveHappenedOnceExactly(); + + A.CallTo(() => domainObject.Setup(A._)) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task Should_update_content_when_id_defined() + { + var (id, data, _) = CreateTestData(false); + + var command = new BulkUpdateContents + { + Jobs = new List + { + new BulkUpdateJob + { + Type = BulkUpdateType.Upsert, + Data = data, + Id = id + } + }, + SchemaId = schemaId + }; + + var context = new CommandContext(command, commandBus); + + await sut.HandleAsync(context); + + var result = context.Result(); + + Assert.Single(result); + Assert.Equal(1, result.Count(x => x.ContentId != default && x.Exception == null)); + + A.CallTo(() => commandBus.PublishAsync(A.That.Matches(x => x.ContentId == id && x.Data == data))) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task Should_update_content_when_query_defined() + { + var (id, data, query) = CreateTestData(true); + + A.CallTo(() => contentQuery.QueryAsync(requestContext, A._, A.That.Matches(x => x.ParsedJsonQuery == query))) + .Returns(ResultList.CreateFrom(1, CreateContent(id))); + + var command = new BulkUpdateContents + { + Jobs = new List + { + new BulkUpdateJob + { + Type = BulkUpdateType.Upsert, + Data = data, + Query = query + } + }, + SchemaId = schemaId + }; + + var context = new CommandContext(command, commandBus); + + await sut.HandleAsync(context); + + var result = context.Result(); + + Assert.Single(result); + Assert.Equal(1, result.Count(x => x.ContentId != default && x.Exception == null)); + + A.CallTo(() => commandBus.PublishAsync(A.That.Matches(x => x.ContentId == id && x.Data == data))) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task Should_throw_exception_when_query_resolves_multiple_contents() + { + var (id, data, query) = CreateTestData(true); + + A.CallTo(() => contentQuery.QueryAsync(requestContext, A._, A.That.Matches(x => x.ParsedJsonQuery == query))) + .Returns(ResultList.CreateFrom(2, CreateContent(id), CreateContent(id))); + + var command = new BulkUpdateContents + { + Jobs = new List + { + new BulkUpdateJob + { + Type = BulkUpdateType.Upsert, + Data = data, + Query = query + } + }, + SchemaId = schemaId + }; + + var context = new CommandContext(command, commandBus); + + await sut.HandleAsync(context); + + var result = context.Result(); + + Assert.Single(result); + Assert.Equal(1, result.Count(x => x.ContentId == null && x.Exception is DomainException)); + + A.CallTo(() => commandBus.PublishAsync(A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_change_content_status() + { + var (id, _, _) = CreateTestData(false); + + var command = new BulkUpdateContents + { + Jobs = new List + { + new BulkUpdateJob + { + Type = BulkUpdateType.ChangeStatus, + Id = id + } + }, + SchemaId = schemaId + }; + + var context = new CommandContext(command, commandBus); + + await sut.HandleAsync(context); + + var result = context.Result(); + + Assert.Single(result); + Assert.Equal(1, result.Count(x => x.ContentId == id)); + + A.CallTo(() => commandBus.PublishAsync(A.That.Matches(x => x.ContentId == id))) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_throw_exception_when_content_id_to_change_cannot_be_resolved() + { + var (_, _, query) = CreateTestData(true); + + var command = new BulkUpdateContents + { + Jobs = new List + { + new BulkUpdateJob + { + Type = BulkUpdateType.ChangeStatus, + Query = query + } + }, + SchemaId = schemaId + }; + + var context = new CommandContext(command, commandBus); + + await sut.HandleAsync(context); + + var result = context.Result(); + + Assert.Single(result); + Assert.Equal(1, result.Count(x => x.ContentId == null && x.Exception is DomainObjectNotFoundException)); + + A.CallTo(() => commandBus.PublishAsync(A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_delete_content() + { + var (id, _, _) = CreateTestData(false); + + var command = new BulkUpdateContents + { + Jobs = new List + { + new BulkUpdateJob + { + Type = BulkUpdateType.Delete, + Id = id + } + }, + SchemaId = schemaId + }; + + var context = new CommandContext(command, commandBus); + + await sut.HandleAsync(context); + + var result = context.Result(); + + Assert.Single(result); + Assert.Equal(1, result.Count(x => x.ContentId == id)); + + A.CallTo(() => commandBus.PublishAsync(A.That.Matches(x => x.ContentId == id))) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_throw_exception_when_content_id_to_delete_cannot_be_resolved() + { + var (_, _, query) = CreateTestData(true); + + var command = new BulkUpdateContents + { + Jobs = new List + { + new BulkUpdateJob + { + Type = BulkUpdateType.Delete, + Query = query + } + }, + SchemaId = schemaId + }; + + var context = new CommandContext(command, commandBus); + + await sut.HandleAsync(context); + + var result = context.Result(); + + Assert.Single(result); + Assert.Equal(1, result.Count(x => x.ContentId == null && x.Exception is DomainObjectNotFoundException)); + + A.CallTo(() => commandBus.PublishAsync(A._)) + .MustNotHaveHappened(); + } + + private static (Guid Id, NamedContentData Data, Query? Query) CreateTestData(bool withQuery) + { + Query? query = withQuery ? new Query() : null; + + var data = + new NamedContentData() + .AddField("value", + new ContentFieldData() + .AddJsonValue("iv", JsonValue.Create(1))); + + return (Guid.NewGuid(), data, query); + } + + private static IEnrichedContentEntity CreateContent(Guid id) + { + return new ContentEntity { Id = id }; + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentImporterCommandMiddlewareTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentImporterCommandMiddlewareTests.cs deleted file mode 100644 index 04af97d34..000000000 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentImporterCommandMiddlewareTests.cs +++ /dev/null @@ -1,142 +0,0 @@ -// ========================================================================== -// 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 System.Threading.Tasks; -using FakeItEasy; -using Squidex.Domain.Apps.Core.Contents; -using Squidex.Domain.Apps.Entities.Contents.Commands; -using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Json.Objects; -using Xunit; - -namespace Squidex.Domain.Apps.Entities.Contents -{ - public class ContentImporterCommandMiddlewareTests - { - private readonly IServiceProvider serviceProvider = A.Fake(); - private readonly ICommandBus commandBus = A.Dummy(); - private readonly ContentImporterCommandMiddleware sut; - - public ContentImporterCommandMiddlewareTests() - { - sut = new ContentImporterCommandMiddleware(serviceProvider); - } - - [Fact] - public async Task Should_do_nothing_if_datas_is_null() - { - var command = new CreateContents(); - - var context = new CommandContext(command, commandBus); - - await sut.HandleAsync(context); - - Assert.True(context.PlainResult is ImportResult); - - A.CallTo(() => serviceProvider.GetService(A._)) - .MustNotHaveHappened(); - } - - [Fact] - public async Task Should_do_nothing_if_datas_is_empty() - { - var command = new CreateContents { Datas = new List() }; - - var context = new CommandContext(command, commandBus); - - await sut.HandleAsync(context); - - Assert.True(context.PlainResult is ImportResult); - - A.CallTo(() => serviceProvider.GetService(A._)) - .MustNotHaveHappened(); - } - - [Fact] - public async Task Should_import_data() - { - var data1 = CreateData(1); - var data2 = CreateData(2); - - var domainObject = A.Fake(); - - A.CallTo(() => serviceProvider.GetService(typeof(ContentDomainObject))) - .Returns(domainObject); - - var command = new CreateContents - { - Datas = new List - { - data1, - data2 - } - }; - - var context = new CommandContext(command, commandBus); - - await sut.HandleAsync(context); - - var result = context.Result(); - - Assert.Equal(2, result.Count); - Assert.Equal(2, result.Count(x => x.ContentId.HasValue && x.Exception == null)); - - A.CallTo(() => domainObject.Setup(A._)) - .MustHaveHappenedTwiceExactly(); - - A.CallTo(() => domainObject.ExecuteAsync(A._)) - .MustHaveHappenedTwiceExactly(); - } - - [Fact] - public async Task Should_skip_exception() - { - var data1 = CreateData(1); - var data2 = CreateData(2); - - var domainObject = A.Fake(); - - var exception = new InvalidOperationException(); - - A.CallTo(() => serviceProvider.GetService(typeof(ContentDomainObject))) - .Returns(domainObject); - - A.CallTo(() => domainObject.ExecuteAsync(A.That.Matches(x => x.Data == data1))) - .Throws(exception); - - var command = new CreateContents - { - Datas = new List - { - data1, - data2 - } - }; - - var context = new CommandContext(command, commandBus); - - await sut.HandleAsync(context); - - var result = context.Result(); - - Assert.Equal(2, result.Count); - Assert.Equal(1, result.Count(x => x.ContentId.HasValue && x.Exception == null)); - Assert.Equal(1, result.Count(x => !x.ContentId.HasValue && x.Exception == exception)); - } - - private static NamedContentData CreateData(int value) - { - return new NamedContentData() - .AddField("value", - new ContentFieldData() - .AddJsonValue("iv", JsonValue.Create(value))); - } - } -} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryParserTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryParserTests.cs index e5dd3d8ea..e9d714c73 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryParserTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryParserTests.cs @@ -13,6 +13,7 @@ using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure; +using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Queries; using Squidex.Infrastructure.Validation; using Xunit; @@ -100,6 +101,20 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries Assert.Equal("Filter: data.firstName.iv == 'ABC'; Take: 30; Sort: lastModified Descending", parsed.ToString()); } + [Fact] + public void Should_convert_json_query_and_enrich_with_defaults() + { + var query = Q.Empty.WithJsonQuery( + new Query + { + Filter = new CompareFilter("data.firstName.iv", CompareOperator.Equals, JsonValue.Create("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() { @@ -110,6 +125,20 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries Assert.Equal("FullText: 'Hello'; Take: 30; Sort: lastModified Descending", parsed.ToString()); } + [Fact] + public void Should_convert_json_full_text_query_and_enrich_with_defaults() + { + var query = Q.Empty.WithJsonQuery( + new Query + { + 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() {