Browse Source

Bulk endpoint. (#516)

* Bulk endpoint.

* Fix in bulk middleware to always query for unpublished content.

* Permission fixed.
pull/519/head
Sebastian Stehle 6 years ago
committed by GitHub
parent
commit
b048283848
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs
  2. 174
      backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateCommandMiddleware.cs
  3. 23
      backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateResult.cs
  4. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateResultItem.cs
  5. 5
      backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateContents.cs
  6. 27
      backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateJob.cs
  7. 9
      backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateType.cs
  8. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs
  9. 4
      backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs
  10. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/CreateContent.cs
  11. 81
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs
  12. 69
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentImporterCommandMiddleware.cs
  13. 12
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs
  14. 8
      backend/src/Squidex.Domain.Apps.Entities/Q.cs
  15. 5
      backend/src/Squidex.Infrastructure/Queries/Json/QueryParser.cs
  16. 43
      backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  17. 6
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkResultDto.cs
  18. 48
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateDto.cs
  19. 49
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateJobDto.cs
  20. 2
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs
  21. 4
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ImportContentsDto.cs
  22. 2
      backend/src/Squidex/Config/Domain/CommandsServices.cs
  23. 394
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BulkUpdateCommandMiddlewareTests.cs
  24. 142
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentImporterCommandMiddlewareTests.cs
  25. 29
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryParserTests.cs

2
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) 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); await UpsertPublishedContentAsync(value, oldVersion, newVersion, schema);
} }

174
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<int>(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<ContentDomainObject>();
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<Guid?> 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;
}
}
}

23
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<BulkUpdateResultItem>
{
public BulkUpdateResult()
{
}
public BulkUpdateResult(IEnumerable<BulkUpdateResultItem> source)
: base(source)
{
}
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Contents/ImportResultItem.cs → backend/src/Squidex.Domain.Apps.Entities/Contents/BulkUpdateResultItem.cs

@ -9,7 +9,7 @@ using System;
namespace Squidex.Domain.Apps.Entities.Contents namespace Squidex.Domain.Apps.Entities.Contents
{ {
public sealed class ImportResultItem public sealed class BulkUpdateResultItem
{ {
public Guid? ContentId { get; set; } public Guid? ContentId { get; set; }

5
backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/CreateContents.cs → backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateContents.cs

@ -7,12 +7,11 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure; using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Commands namespace Squidex.Domain.Apps.Entities.Contents.Commands
{ {
public sealed class CreateContents : SquidexCommand, ISchemaCommand, IAppCommand public sealed class BulkUpdateContents : SquidexCommand, ISchemaCommand, IAppCommand
{ {
public NamedId<Guid> AppId { get; set; } public NamedId<Guid> AppId { get; set; }
@ -26,6 +25,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands
public bool OptimizeValidation { get; set; } public bool OptimizeValidation { get; set; }
public List<NamedContentData> Datas { get; set; } public List<BulkUpdateJob>? Jobs { get; set; }
} }
} }

27
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<IJsonValue>? Query { get; set; }
public Guid? Id { get; set; }
public NamedContentData Data { get; set; }
public Status? Status { get; set; }
public BulkUpdateType Type { get; set; }
}
}

9
backend/src/Squidex.Domain.Apps.Entities/Contents/ImportResult.cs → backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateType.cs

@ -5,11 +5,12 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Generic; namespace Squidex.Domain.Apps.Entities.Contents.Commands
namespace Squidex.Domain.Apps.Entities.Contents
{ {
public sealed class ImportResult : List<ImportResultItem> public enum BulkUpdateType
{ {
Upsert,
ChangeStatus,
Delete
} }
} }

2
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 Guid ContentId { get; set; }
public bool DoNotScript { get; set; }
Guid IAggregateCommand.AggregateId Guid IAggregateCommand.AggregateId
{ {
get { return ContentId; } get { return ContentId; }

4
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 abstract class ContentDataCommand : ContentCommand
{ {
public bool DoNotValidate { get; set; }
public bool OptimizeValidation { get; set; }
public NamedContentData Data { get; set; } public NamedContentData Data { get; set; }
} }
} }

6
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 Publish { get; set; }
public bool DoNotValidate { get; set; }
public bool DoNotScript { get; set; }
public bool OptimizeValidation { get; set; }
public CreateContent() public CreateContent()
{ {
ContentId = Guid.NewGuid(); ContentId = Guid.NewGuid();

81
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs

@ -78,7 +78,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await context.ValidateContentAsync(c.Data); await context.ValidateContentAsync(c.Data);
} }
if (c.Publish) if (!c.DoNotScript && c.Publish)
{ {
await context.ExecuteScriptAsync(s => s.Change, await context.ExecuteScriptAsync(s => s.Change,
new ScriptContext new ScriptContext
@ -154,14 +154,17 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var change = GetChange(c); var change = GetChange(c);
await context.ExecuteScriptAsync(s => s.Change, if (!c.DoNotScript)
new ScriptContext {
{ await context.ExecuteScriptAsync(s => s.Change,
Operation = change.ToString(), new ScriptContext
Data = Snapshot.Data, {
Status = c.Status, Operation = change.ToString(),
StatusOld = Snapshot.EditingStatus Data = Snapshot.Data,
}); Status = c.Status,
StatusOld = Snapshot.EditingStatus
});
}
ChangeStatus(c, change); ChangeStatus(c, change);
} }
@ -188,14 +191,17 @@ namespace Squidex.Domain.Apps.Entities.Contents
GuardContent.CanDelete(context.Schema, c); GuardContent.CanDelete(context.Schema, c);
await context.ExecuteScriptAsync(s => s.Delete, if (!c.DoNotScript)
new ScriptContext {
{ await context.ExecuteScriptAsync(s => s.Delete,
Operation = "Delete", new ScriptContext
Data = Snapshot.Data, {
Status = Snapshot.EditingStatus, Operation = "Delete",
StatusOld = default Data = Snapshot.Data,
}); Status = Snapshot.EditingStatus,
StatusOld = default
});
}
Delete(c); Delete(c);
}); });
@ -213,28 +219,37 @@ namespace Squidex.Domain.Apps.Entities.Contents
if (!currentData!.Equals(newData)) 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, if (!command.DoNotValidate)
new ScriptContext {
{ await context.ValidateContentAsync(newData);
Operation = "Create", }
Data = newData,
DataOld = currentData,
Status = Snapshot.EditingStatus,
StatusOld = default
});
await context.ValidateContentAsync(newData);
Update(command, newData); Update(command, newData);
} }

69
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentImporterCommandMiddleware.cs

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

12
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;
using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Queries; using Squidex.Infrastructure.Queries;
using Squidex.Infrastructure.Queries.Json; using Squidex.Infrastructure.Queries.Json;
@ -61,6 +62,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
{ {
result = ParseJson(context, schema, q.JsonQuery); result = ParseJson(context, schema, q.JsonQuery);
} }
else if (q?.ParsedJsonQuery != null)
{
result = ParseJson(context, schema, q.ParsedJsonQuery);
}
else if (!string.IsNullOrWhiteSpace(q?.ODataQuery)) else if (!string.IsNullOrWhiteSpace(q?.ODataQuery))
{ {
result = ParseOData(context, schema, 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<IJsonValue> query)
{
var jsonSchema = BuildJsonSchema(context, schema);
return jsonSchema.Convert(query);
}
private ClrQuery ParseJson(Context context, ISchemaEntity schema, string json) private ClrQuery ParseJson(Context context, ISchemaEntity schema, string json)
{ {
var jsonSchema = BuildJsonSchema(context, schema); var jsonSchema = BuildJsonSchema(context, schema);

8
backend/src/Squidex.Domain.Apps.Entities/Q.cs

@ -9,6 +9,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Queries; using Squidex.Infrastructure.Queries;
namespace Squidex.Domain.Apps.Entities namespace Squidex.Domain.Apps.Entities
@ -23,6 +24,8 @@ namespace Squidex.Domain.Apps.Entities
public string? JsonQuery { get; private set; } public string? JsonQuery { get; private set; }
public Query<IJsonValue>? ParsedJsonQuery { get; private set; }
public ClrQuery? Query { get; private set; } public ClrQuery? Query { get; private set; }
public Q WithQuery(ClrQuery? query) public Q WithQuery(ClrQuery? query)
@ -40,6 +43,11 @@ namespace Squidex.Domain.Apps.Entities
return Clone(c => c.JsonQuery = jsonQuery); return Clone(c => c.JsonQuery = jsonQuery);
} }
public Q WithJsonQuery(Query<IJsonValue>? jsonQuery)
{
return Clone(c => c.ParsedJsonQuery = jsonQuery);
}
public Q WithIds(params Guid[] ids) public Q WithIds(params Guid[] ids)
{ {
return Clone(c => c.Ids = ids.ToList()); return Clone(c => c.Ids = ids.ToList());

5
backend/src/Squidex.Infrastructure/Queries/Json/QueryParser.cs

@ -27,6 +27,11 @@ namespace Squidex.Infrastructure.Queries.Json
var query = ParseFromJson(json, jsonSerializer); var query = ParseFromJson(json, jsonSerializer);
return Convert(schema, query);
}
public static ClrQuery Convert(this JsonSchema schema, Query<IJsonValue> query)
{
if (query == null) if (query == null)
{ {
return new ClrQuery(); return new ClrQuery();

43
backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs

@ -326,10 +326,10 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// </remarks> /// </remarks>
[HttpPost] [HttpPost]
[Route("content/{app}/{name}/import")] [Route("content/{app}/{name}/import")]
[ProducesResponseType(typeof(ImportResultDto[]), 200)] [ProducesResponseType(typeof(BulkResultDto[]), 200)]
[ApiPermission(Permissions.AppContentsCreate)] [ApiPermission(Permissions.AppContentsCreate)]
[ApiCosts(5)] [ApiCosts(5)]
public async Task<IActionResult> PostContent(string app, string name, [FromBody] ImportContentsDto request) public async Task<IActionResult> PostContents(string app, string name, [FromBody] ImportContentsDto request)
{ {
await contentQuery.GetSchemaOrThrowAsync(Context, name); await contentQuery.GetSchemaOrThrowAsync(Context, name);
@ -337,8 +337,41 @@ namespace Squidex.Areas.Api.Controllers.Contents
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<ImportResult>(); var result = context.Result<BulkUpdateResult>();
var response = result.Select(x => ImportResultDto.FromImportResult(x, HttpContext)).ToArray(); var response = result.Select(x => BulkResultDto.FromImportResult(x, HttpContext)).ToArray();
return Ok(response);
}
/// <summary>
/// Bulk update content items.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="name">The name of the schema.</param>
/// <param name="request">The bulk update request.</param>
/// <returns>
/// 201 => Contents created.
/// 404 => Content references, schema or app not found.
/// 400 => Content data is not valid.
/// </returns>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
[HttpPost]
[Route("content/{app}/{name}/bulk")]
[ProducesResponseType(typeof(BulkResultDto[]), 200)]
[ApiPermission(Permissions.AppContents)]
[ApiCosts(5)]
public async Task<IActionResult> 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<BulkUpdateResult>();
var response = result.Select(x => BulkResultDto.FromImportResult(x, HttpContext)).ToArray();
return Ok(response); return Ok(response);
} }
@ -423,7 +456,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[HttpPut] [HttpPut]
[Route("content/{app}/{name}/{id}/status/")] [Route("content/{app}/{name}/{id}/status/")]
[ProducesResponseType(typeof(ContentsDto), 200)] [ProducesResponseType(typeof(ContentsDto), 200)]
[ApiPermission] [ApiPermission(Permissions.AppContentsUpdate)]
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> PutContentStatus(string app, string name, Guid id, ChangeStatusDto request) public async Task<IActionResult> PutContentStatus(string app, string name, Guid id, ChangeStatusDto request)
{ {

6
backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ImportResultDto.cs → backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkResultDto.cs

@ -12,7 +12,7 @@ using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Contents.Models namespace Squidex.Areas.Api.Controllers.Contents.Models
{ {
public sealed class ImportResultDto public sealed class BulkResultDto
{ {
/// <summary> /// <summary>
/// The error when the import failed. /// The error when the import failed.
@ -24,11 +24,11 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
/// </summary> /// </summary>
public Guid? ContentId { get; set; } 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; var error = result.Exception?.ToErrorDto(httpContext).Error;
return new ImportResultDto { ContentId = result.ContentId, Error = error }; return new BulkResultDto { ContentId = result.ContentId, Error = error };
} }
} }
} }

48
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
{
/// <summary>
/// The contents to update or insert.
/// </summary>
[Required]
public List<BulkUpdateJobDto> Jobs { get; set; }
/// <summary>
/// True to automatically publish the content.
/// </summary>
public bool Publish { get; set; }
/// <summary>
/// True to turn off scripting for faster inserts. Default: true.
/// </summary>
public bool DoNotScript { get; set; } = true;
/// <summary>
/// True to turn off costly validation: Unique checks, asset checks and reference checks. Default: true.
/// </summary>
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;
}
}
}

49
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
{
/// <summary>
/// An optional query to identify the content to update.
/// </summary>
public Query<IJsonValue>? Query { get; set; }
/// <summary>
/// An optional id of the content to update.
/// </summary>
public Guid? Id { get; set; }
/// <summary>
/// The data of the content when type is set to 'Upsert'.
/// </summary>
public NamedContentData? Data { get; set; }
/// <summary>
/// The new status when the type is set to 'ChangeStatus'.
/// </summary>
public Status? Status { get; set; }
/// <summary>
/// The update type.
/// </summary>
public BulkUpdateType Type { get; set; }
public BulkUpdateJob ToJob()
{
return SimpleMapper.Map(this, new BulkUpdateJob());
}
}
}

2
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) foreach (var next in content.NextStatuses)
{ {

4
backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ImportContentsDto.cs

@ -36,9 +36,9 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
/// </summary> /// </summary>
public bool OptimizeValidation { get; set; } = true; public bool OptimizeValidation { get; set; } = true;
public CreateContents ToCommand() public BulkUpdateContents ToCommand()
{ {
return SimpleMapper.Map(this, new CreateContents()); return SimpleMapper.Map(this, new BulkUpdateContents());
} }
} }
} }

2
backend/src/Squidex/Config/Domain/CommandsServices.cs

@ -77,7 +77,7 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<AssetCommandMiddleware>() services.AddSingletonAs<AssetCommandMiddleware>()
.As<ICommandMiddleware>(); .As<ICommandMiddleware>();
services.AddSingletonAs<ContentImporterCommandMiddleware>() services.AddSingletonAs<BulkUpdateCommandMiddleware>()
.As<ICommandMiddleware>(); .As<ICommandMiddleware>();
services.AddSingletonAs<ContentCommandMiddleware>() services.AddSingletonAs<ContentCommandMiddleware>()

394
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<IServiceProvider>();
private readonly IContentQueryService contentQuery = A.Fake<IContentQueryService>();
private readonly IContextProvider contextProvider = A.Fake<IContextProvider>();
private readonly ICommandBus commandBus = A.Dummy<ICommandBus>();
private readonly Context requestContext = Context.Anonymous();
private readonly NamedId<Guid> 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<Type>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_do_nothing_if_jobs_is_empty()
{
var command = new BulkUpdateContents { Jobs = new List<BulkUpdateJob>() };
var context = new CommandContext(command, commandBus);
await sut.HandleAsync(context);
Assert.True(context.PlainResult is BulkUpdateResult);
A.CallTo(() => serviceProvider.GetService(A<Type>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_import_contents_when_no_query_defined()
{
var (_, data, _) = CreateTestData(false);
var domainObject = A.Fake<ContentDomainObject>();
A.CallTo(() => serviceProvider.GetService(typeof(ContentDomainObject)))
.Returns(domainObject);
var command = new BulkUpdateContents
{
Jobs = new List<BulkUpdateJob>
{
new BulkUpdateJob
{
Type = BulkUpdateType.Upsert,
Data = data
}
},
SchemaId = schemaId
};
var context = new CommandContext(command, commandBus);
await sut.HandleAsync(context);
var result = context.Result<BulkUpdateResult>();
Assert.Single(result);
Assert.Equal(1, result.Count(x => x.ContentId != default && x.Exception == null));
A.CallTo(() => domainObject.ExecuteAsync(A<CreateContent>.That.Matches(x => x.Data == data)))
.MustHaveHappenedOnceExactly();
A.CallTo(() => domainObject.Setup(A<Guid>._))
.MustHaveHappenedOnceExactly();
}
[Fact]
public async Task Should_import_contents_when_query_returns_no_result()
{
var (_, data, query) = CreateTestData(false);
var domainObject = A.Fake<ContentDomainObject>();
A.CallTo(() => serviceProvider.GetService(typeof(ContentDomainObject)))
.Returns(domainObject);
var command = new BulkUpdateContents
{
Jobs = new List<BulkUpdateJob>
{
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<BulkUpdateResult>();
Assert.Single(result);
Assert.Equal(1, result.Count(x => x.ContentId != default && x.Exception == null));
A.CallTo(() => domainObject.ExecuteAsync(A<CreateContent>.That.Matches(x => x.Data == data)))
.MustHaveHappenedOnceExactly();
A.CallTo(() => domainObject.Setup(A<Guid>._))
.MustHaveHappenedOnceExactly();
}
[Fact]
public async Task Should_update_content_when_id_defined()
{
var (id, data, _) = CreateTestData(false);
var command = new BulkUpdateContents
{
Jobs = new List<BulkUpdateJob>
{
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<BulkUpdateResult>();
Assert.Single(result);
Assert.Equal(1, result.Count(x => x.ContentId != default && x.Exception == null));
A.CallTo(() => commandBus.PublishAsync(A<UpdateContent>.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<string>._, A<Q>.That.Matches(x => x.ParsedJsonQuery == query)))
.Returns(ResultList.CreateFrom(1, CreateContent(id)));
var command = new BulkUpdateContents
{
Jobs = new List<BulkUpdateJob>
{
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<BulkUpdateResult>();
Assert.Single(result);
Assert.Equal(1, result.Count(x => x.ContentId != default && x.Exception == null));
A.CallTo(() => commandBus.PublishAsync(A<UpdateContent>.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<string>._, A<Q>.That.Matches(x => x.ParsedJsonQuery == query)))
.Returns(ResultList.CreateFrom(2, CreateContent(id), CreateContent(id)));
var command = new BulkUpdateContents
{
Jobs = new List<BulkUpdateJob>
{
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<BulkUpdateResult>();
Assert.Single(result);
Assert.Equal(1, result.Count(x => x.ContentId == null && x.Exception is DomainException));
A.CallTo(() => commandBus.PublishAsync(A<ICommand>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_change_content_status()
{
var (id, _, _) = CreateTestData(false);
var command = new BulkUpdateContents
{
Jobs = new List<BulkUpdateJob>
{
new BulkUpdateJob
{
Type = BulkUpdateType.ChangeStatus,
Id = id
}
},
SchemaId = schemaId
};
var context = new CommandContext(command, commandBus);
await sut.HandleAsync(context);
var result = context.Result<BulkUpdateResult>();
Assert.Single(result);
Assert.Equal(1, result.Count(x => x.ContentId == id));
A.CallTo(() => commandBus.PublishAsync(A<ChangeContentStatus>.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<BulkUpdateJob>
{
new BulkUpdateJob
{
Type = BulkUpdateType.ChangeStatus,
Query = query
}
},
SchemaId = schemaId
};
var context = new CommandContext(command, commandBus);
await sut.HandleAsync(context);
var result = context.Result<BulkUpdateResult>();
Assert.Single(result);
Assert.Equal(1, result.Count(x => x.ContentId == null && x.Exception is DomainObjectNotFoundException));
A.CallTo(() => commandBus.PublishAsync(A<ICommand>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_delete_content()
{
var (id, _, _) = CreateTestData(false);
var command = new BulkUpdateContents
{
Jobs = new List<BulkUpdateJob>
{
new BulkUpdateJob
{
Type = BulkUpdateType.Delete,
Id = id
}
},
SchemaId = schemaId
};
var context = new CommandContext(command, commandBus);
await sut.HandleAsync(context);
var result = context.Result<BulkUpdateResult>();
Assert.Single(result);
Assert.Equal(1, result.Count(x => x.ContentId == id));
A.CallTo(() => commandBus.PublishAsync(A<DeleteContent>.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<BulkUpdateJob>
{
new BulkUpdateJob
{
Type = BulkUpdateType.Delete,
Query = query
}
},
SchemaId = schemaId
};
var context = new CommandContext(command, commandBus);
await sut.HandleAsync(context);
var result = context.Result<BulkUpdateResult>();
Assert.Single(result);
Assert.Equal(1, result.Count(x => x.ContentId == null && x.Exception is DomainObjectNotFoundException));
A.CallTo(() => commandBus.PublishAsync(A<ICommand>._))
.MustNotHaveHappened();
}
private static (Guid Id, NamedContentData Data, Query<IJsonValue>? Query) CreateTestData(bool withQuery)
{
Query<IJsonValue>? query = withQuery ? new Query<IJsonValue>() : 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 };
}
}
}

142
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentImporterCommandMiddlewareTests.cs

@ -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<IServiceProvider>();
private readonly ICommandBus commandBus = A.Dummy<ICommandBus>();
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<Type>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_do_nothing_if_datas_is_empty()
{
var command = new CreateContents { Datas = new List<NamedContentData>() };
var context = new CommandContext(command, commandBus);
await sut.HandleAsync(context);
Assert.True(context.PlainResult is ImportResult);
A.CallTo(() => serviceProvider.GetService(A<Type>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_import_data()
{
var data1 = CreateData(1);
var data2 = CreateData(2);
var domainObject = A.Fake<ContentDomainObject>();
A.CallTo(() => serviceProvider.GetService(typeof(ContentDomainObject)))
.Returns(domainObject);
var command = new CreateContents
{
Datas = new List<NamedContentData>
{
data1,
data2
}
};
var context = new CommandContext(command, commandBus);
await sut.HandleAsync(context);
var result = context.Result<ImportResult>();
Assert.Equal(2, result.Count);
Assert.Equal(2, result.Count(x => x.ContentId.HasValue && x.Exception == null));
A.CallTo(() => domainObject.Setup(A<Guid>._))
.MustHaveHappenedTwiceExactly();
A.CallTo(() => domainObject.ExecuteAsync(A<CreateContent>._))
.MustHaveHappenedTwiceExactly();
}
[Fact]
public async Task Should_skip_exception()
{
var data1 = CreateData(1);
var data2 = CreateData(2);
var domainObject = A.Fake<ContentDomainObject>();
var exception = new InvalidOperationException();
A.CallTo(() => serviceProvider.GetService(typeof(ContentDomainObject)))
.Returns(domainObject);
A.CallTo(() => domainObject.ExecuteAsync(A<CreateContent>.That.Matches(x => x.Data == data1)))
.Throws(exception);
var command = new CreateContents
{
Datas = new List<NamedContentData>
{
data1,
data2
}
};
var context = new CommandContext(command, commandBus);
await sut.HandleAsync(context);
var result = context.Result<ImportResult>();
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)));
}
}
}

29
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.Schemas;
using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Queries; using Squidex.Infrastructure.Queries;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
using Xunit; 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()); 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<IJsonValue>
{
Filter = new CompareFilter<IJsonValue>("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] [Fact]
public void Should_parse_json_full_text_query_and_enrich_with_defaults() 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()); 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<IJsonValue>
{
FullText = "Hello"
});
var parsed = sut.ParseQuery(requestContext, schema, query);
Assert.Equal("FullText: 'Hello'; Take: 30; Sort: lastModified Descending", parsed.ToString());
}
[Fact] [Fact]
public void Should_apply_default_page_size() public void Should_apply_default_page_size()
{ {

Loading…
Cancel
Save