diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs
index b87d6169e..3fae399d5 100644
--- a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs
+++ b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs
@@ -492,6 +492,15 @@ namespace Squidex.Domain.Apps.Core {
}
}
+ ///
+ /// Looks up a localized string similar to Makes the update as patch..
+ ///
+ public static string ContentRequestPatch {
+ get {
+ return ResourceManager.GetString("ContentRequestPatch", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Set to true to autopublish content on create..
///
diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx
index 783b3a224..9698e8835 100644
--- a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx
+++ b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx
@@ -261,6 +261,9 @@
The initial status.
+
+ Makes the update as patch.
+
Set to true to autopublish content on create.
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateJob.cs
index 1e9f621fd..b52748301 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateJob.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateJob.cs
@@ -29,6 +29,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands
public string? Schema { get; set; }
+ public bool Patch { get; set; }
+
public bool Permanent { get; set; }
public long ExpectedCount { get; set; } = 1;
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpsertContent.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpsertContent.cs
index 5db1f76d0..b6e3637ec 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpsertContent.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpsertContent.cs
@@ -17,6 +17,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands
public bool CheckReferrers { get; set; }
+ public bool Patch { get; set; }
+
public UpsertContent()
{
ContentId = DomainId.NewGuid();
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs
index 3f657aeb4..cb69bee00 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs
@@ -72,13 +72,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject
{
var operation = await ContentOperation.CreateAsync(serviceProvider, c, () => Snapshot);
- if (Version > EtagVersion.Empty && !IsDeleted(Snapshot))
+ if (Version <= EtagVersion.Empty || IsDeleted(Snapshot))
{
- await UpdateCore(c.AsUpdate(), operation);
+ await CreateCore(c.AsCreate(), operation);
+ }
+ else if (c.Patch)
+ {
+ await PatchCore(c.AsUpdate(), operation);
}
else
{
- await CreateCore(c.AsCreate(), operation);
+ await UpdateCore(c.AsUpdate(), operation);
}
if (Is.OptionalChange(operation.Snapshot.EditingStatus(), c.Status))
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs
index e8a18bd17..1b54fa289 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs
@@ -277,6 +277,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents
Description = FieldDescriptions.ContentRequestPublish,
DefaultValue = false
},
+ new QueryArgument(AllTypes.Boolean)
+ {
+ Name = "patch",
+ Description = FieldDescriptions.ContentRequestPatch,
+ DefaultValue = false
+ },
new QueryArgument(AllTypes.String)
{
Name = "status",
@@ -297,8 +303,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents
var contentId = c.GetArgument("id");
var contentData = c.GetArgument("data")!;
var contentStatus = c.GetArgument("status");
+ var patch = c.GetArgument("patch");
- var command = new UpsertContent { ContentId = contentId, Data = contentData };
+ var command = new UpsertContent { ContentId = contentId, Data = contentData, Patch = patch };
if (!string.IsNullOrWhiteSpace(contentStatus))
{
diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasOpenApiGenerator.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasOpenApiGenerator.cs
index f788eace3..e8cd15cf3 100644
--- a/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasOpenApiGenerator.cs
+++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasOpenApiGenerator.cs
@@ -9,13 +9,14 @@ using NJsonSchema;
using NSwag;
using NSwag.Generation;
using NSwag.Generation.Processors.Contexts;
+using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Schemas;
-using Squidex.Hosting;
using Squidex.Infrastructure.Caching;
using Squidex.Properties;
using Squidex.Shared;
+using IRequestUrlGenerator = Squidex.Hosting.IUrlGenerator;
using SchemaDefType = Squidex.Domain.Apps.Core.Schemas.SchemaType;
namespace Squidex.Areas.Api.Controllers.Contents.Generator
@@ -23,16 +24,16 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
public sealed class SchemasOpenApiGenerator
{
private readonly IAppProvider appProvider;
- private readonly IUrlGenerator urlGenerator;
private readonly OpenApiDocumentGeneratorSettings schemaSettings;
private readonly OpenApiSchemaGenerator schemaGenerator;
+ private readonly IRequestUrlGenerator urlGenerator;
private readonly IRequestCache requestCache;
public SchemasOpenApiGenerator(
IAppProvider appProvider,
- IUrlGenerator urlGenerator,
OpenApiDocumentGeneratorSettings schemaSettings,
OpenApiSchemaGenerator schemaGenerator,
+ IRequestUrlGenerator urlGenerator,
IRequestCache requestCache)
{
this.appProvider = appProvider;
@@ -123,7 +124,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
.RequirePermission(Permissions.AppContentsReadOwn)
.Operation("GetVersioned")
.OperationSummary("Get a [schema] content item by id and version.")
- .HasPath("version", JsonObjectType.Number, "The version of the content item.")
+ .HasPath("version", JsonObjectType.Number, FieldDescriptions.EntityVersion)
.HasId()
.Responds(200, "Content item returned.", builder.ContentSchema);
@@ -139,8 +140,8 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
.RequirePermission(Permissions.AppContentsCreate)
.Operation("Create")
.OperationSummary("Create a [schema] content item.")
- .HasQuery("publish", JsonObjectType.Boolean, "True to automatically publish the content.")
- .HasQuery("id", JsonObjectType.String, "The optional custom content id.")
+ .HasQuery("publish", JsonObjectType.Boolean, FieldDescriptions.ContentRequestPublish)
+ .HasQuery("id", JsonObjectType.String, FieldDescriptions.ContentRequestOptionalId)
.HasBody("data", builder.DataSchema, Resources.OpenApiSchemaBody)
.Responds(201, "Content item created", builder.ContentSchema)
.Responds(400, "Content data not valid.");
@@ -149,7 +150,8 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
.RequirePermission(Permissions.AppContentsUpsert)
.Operation("Upsert")
.OperationSummary("Upsert a [schema] content item.")
- .HasQuery("publish", JsonObjectType.Boolean, "True to automatically publish the content.")
+ .HasQuery("patch", JsonObjectType.Boolean, FieldDescriptions.ContentRequestPatch)
+ .HasQuery("publish", JsonObjectType.Boolean, FieldDescriptions.ContentRequestPublish)
.HasId()
.HasBody("data", builder.DataSchema, Resources.OpenApiSchemaBody)
.Responds(200, "Content item created or updated.", builder.ContentSchema)
diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateContentsJobDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateContentsJobDto.cs
index 93ff2716b..991a3e9b2 100644
--- a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateContentsJobDto.cs
+++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateContentsJobDto.cs
@@ -50,6 +50,11 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
///
public string? Schema { get; set; }
+ ///
+ /// Makes the update as patch.
+ ///
+ public bool Patch { get; set; }
+
///
/// True to delete the content permanently.
///
diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/UpsertContentDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/UpsertContentDto.cs
index 5926507e0..c4eed4cdf 100644
--- a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/UpsertContentDto.cs
+++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/UpsertContentDto.cs
@@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Mvc;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Infrastructure;
+using Squidex.Infrastructure.Reflection;
using StatusType = Squidex.Domain.Apps.Core.Contents.Status;
namespace Squidex.Areas.Api.Controllers.Contents.Models
@@ -27,6 +28,12 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
[FromQuery]
public StatusType? Status { get; set; }
+ ///
+ /// Makes the update as patch.
+ ///
+ [FromQuery]
+ public bool Patch { get; set; }
+
///
/// True to automatically publish the content.
///
@@ -36,14 +43,10 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
public UpsertContent ToCommand(DomainId id)
{
- var command = new UpsertContent { Data = Data!, ContentId = id };
+ var command = SimpleMapper.Map(this, new UpsertContent { ContentId = id });
- if (Status != null)
- {
- command.Status = Status;
- }
#pragma warning disable CS0618 // Type or member is obsolete
- else if (Publish)
+ if (command.Status == null && Publish)
{
command.Status = StatusType.Published;
}
diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentDomainObjectTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentDomainObjectTests.cs
index 401346096..24cacb6a3 100644
--- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentDomainObjectTests.cs
+++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentDomainObjectTests.cs
@@ -325,6 +325,28 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject
.MustHaveHappened();
}
+ [Fact]
+ public async Task Upsert_should_patch_content_if_found()
+ {
+ var command = new UpsertContent { Data = patch, Patch = true };
+
+ await ExecuteCreateAsync();
+
+ var result = await PublishAsync(CreateContentCommand(command));
+
+ result.ShouldBeEquivalent(sut.Snapshot);
+
+ Assert.Equal(patched, sut.Snapshot.CurrentVersion.Data);
+
+ LastEvents
+ .ShouldHaveSameEvents(
+ CreateContentEvent(new ContentUpdated { Data = patched })
+ );
+
+ A.CallTo(() => scriptEngine.TransformAsync(DataScriptVars(patched, data, Status.Draft), "", ScriptOptions(), default))
+ .MustHaveHappened();
+ }
+
[Fact]
public async Task Upsert_should_not_change_status_on_update_if_status_set_to_initial()
{
diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs
index 43f2b81c2..32e810709 100644
--- a/backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs
+++ b/backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs
@@ -465,6 +465,106 @@ namespace TestSuite.ApiTests
}
}
+ [Fact]
+ public async Task Should_patch_content_with_upsert()
+ {
+ TestEntity content = null;
+ try
+ {
+ // STEP 1: Create a new item.
+ content = await _.Contents.CreateAsync(new TestEntityData { String = "test" }, ContentCreateOptions.AsPublish);
+
+
+ // STEP 2: Path an item.
+ await _.Contents.UpsertAsync(content.Id, new TestEntityData { Number = 1 }, ContentUpsertOptions.AsPatch);
+
+
+ // STEP 3: Update the item and ensure that the data has changed.
+ await _.Contents.UpsertAsync(content.Id, new TestEntityData { Number = 2 }, ContentUpsertOptions.AsPatch);
+
+ var updated = await _.Contents.GetAsync(content.Id);
+
+ Assert.Equal(2, updated.Data.Number);
+
+ // Should not change other value with patch.
+ Assert.Equal("test", updated.Data.String);
+ }
+ finally
+ {
+ if (content != null)
+ {
+ await _.Contents.DeleteAsync(content.Id);
+ }
+ }
+ }
+
+ [Fact]
+ public async Task Should_patch_content_with_bulk()
+ {
+ TestEntity content = null;
+ try
+ {
+ // STEP 1: Create a new item.
+ content = await _.Contents.CreateAsync(new TestEntityData { String = "test" }, ContentCreateOptions.AsPublish);
+
+
+ // STEP 2: Path an item.
+ await _.Contents.BulkUpdateAsync(new BulkUpdate
+ {
+ Jobs = new List
+ {
+ new BulkUpdateJob
+ {
+ Id = content.Id,
+ Data = new
+ {
+ number = new
+ {
+ iv = 1
+ }
+ },
+ Patch = true
+ }
+ }
+ });
+
+
+ // STEP 3: Update the item and ensure that the data has changed.
+ await _.Contents.BulkUpdateAsync(new BulkUpdate
+ {
+ Jobs = new List
+ {
+ new BulkUpdateJob
+ {
+ Id = content.Id,
+ Data = new
+ {
+ number = new
+ {
+ iv = 2
+ }
+ },
+ Patch = true
+ }
+ }
+ });
+
+ var updated = await _.Contents.GetAsync(content.Id);
+
+ Assert.Equal(2, updated.Data.Number);
+
+ // Should not change other value with patch.
+ Assert.Equal("test", updated.Data.String);
+ }
+ finally
+ {
+ if (content != null)
+ {
+ await _.Contents.DeleteAsync(content.Id);
+ }
+ }
+ }
+
[Fact]
public async Task Should_create_draft_version()
{
diff --git a/backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj b/backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj
index bd203d027..7d3009b94 100644
--- a/backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj
+++ b/backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj
@@ -16,7 +16,7 @@
-
+