diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs index 2f2729858..0691fb391 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs @@ -23,8 +23,8 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets IUpdateableEntityWithLastModifiedBy { [BsonRequired] - [BsonElement] - public Guid AppIdId { get; set; } + [BsonElement("AppIdId")] + public Guid IndexedAppId { get; set; } [BsonRequired] [BsonElement] diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs index 92633415d..49492c3bf 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs @@ -41,7 +41,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets var entity = SimpleMapper.Map(value, new MongoAssetEntity()); entity.Version = newVersion; - entity.AppIdId = value.AppId.Id; + entity.IndexedAppId = value.AppId.Id; await Collection.ReplaceOneAsync(x => x.Id == key && x.Version == oldVersion, entity, Upsert); } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs index af3a75764..914c1b1f2 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs @@ -51,7 +51,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors { var filters = new List> { - Filter.Eq(x => x.AppIdId, appId), + Filter.Eq(x => x.IndexedAppId, appId), Filter.Eq(x => x.IsDeleted, false) }; diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Extensions.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Extensions.cs index 0d30a2e6b..d3d48a048 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Extensions.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Extensions.cs @@ -43,7 +43,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents if (value.Length < 1000) { - stringBuilder.Append(" "); + if (stringBuilder.Length > 0) + { + stringBuilder.Append(" "); + } + stringBuilder.Append(text); } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs new file mode 100644 index 000000000..f536807ad --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs @@ -0,0 +1,131 @@ +// ========================================================================== +// 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 Microsoft.OData.UriParser; +using MongoDB.Driver; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Contents; +using Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.MongoDb; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Contents +{ + internal class MongoContentCollection : MongoRepositoryBase + { + private readonly string collectionName; + + public MongoContentCollection(IMongoDatabase database, string collectionName) + : base(database) + { + this.collectionName = collectionName; + } + + protected override async Task SetupCollectionAsync(IMongoCollection collection) + { + await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.ReferencedIds)); + } + + protected override string CollectionName() + { + return collectionName; + } + + public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, ODataUriParser odataQuery, Status[] status = null, bool useDraft = false) + { + try + { + var propertyCalculator = FindExtensions.CreatePropertyCalculator(schema.SchemaDef, useDraft); + + var filter = FindExtensions.BuildQuery(odataQuery, schema.Id, status, propertyCalculator); + + var contentCount = Collection.Find(filter).CountAsync(); + var contentItems = + Collection.Find(filter) + .ContentTake(odataQuery) + .ContentSkip(odataQuery) + .ContentSort(odataQuery, propertyCalculator) + .Not(x => x.DataText) + .ToListAsync(); + + await Task.WhenAll(contentItems, contentCount); + + foreach (var entity in contentItems.Result) + { + entity.ParseData(schema.SchemaDef); + } + + return ResultList.Create(contentItems.Result, contentCount.Result); + } + catch (NotSupportedException) + { + throw new ValidationException("This odata operation is not supported."); + } + catch (NotImplementedException) + { + throw new ValidationException("This odata operation is not supported."); + } + catch (MongoQueryException ex) + { + if (ex.Message.Contains("17406")) + { + throw new DomainException("Result set is too large to be retrieved. Use $top parameter to reduce the number of items."); + } + else + { + throw; + } + } + } + + public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, HashSet ids, Status[] status = null) + { + var find = + status != null && status.Length > 0 ? + Collection.Find(x => x.IndexedSchemaId == schema.Id && ids.Contains(x.Id) && x.IsDeleted != true && status.Contains(x.Status)) : + Collection.Find(x => x.IndexedSchemaId == schema.Id && ids.Contains(x.Id)); + + var contentItems = find.Not(x => x.DataText).ToListAsync(); + var contentCount = find.CountAsync(); + + await Task.WhenAll(contentItems, contentCount); + + foreach (var entity in contentItems.Result) + { + entity.ParseData(schema.SchemaDef); + } + + return ResultList.Create(contentItems.Result, contentCount.Result); + } + + public async Task FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id) + { + var contentEntity = + await Collection.Find(x => x.IndexedSchemaId == schema.Id && x.Id == id && x.IsDeleted != true).Not(x => x.DataText) + .FirstOrDefaultAsync(); + + contentEntity?.ParseData(schema.SchemaDef); + + return contentEntity; + } + + public Task CleanupAsync(Guid id) + { + return Collection.UpdateManyAsync( + Filter.And( + Filter.AnyEq(x => x.ReferencedIds, id), + Filter.AnyNe(x => x.ReferencedIdsDeleted, id)), + Update.AddToSet(x => x.ReferencedIdsDeleted, id)); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentDraftCollection.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentDraftCollection.cs new file mode 100644 index 000000000..6921fbdd2 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentDraftCollection.cs @@ -0,0 +1,116 @@ +// ========================================================================== +// 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 MongoDB.Driver; +using NodaTime; +using Squidex.Domain.Apps.Entities.Contents; +using Squidex.Domain.Apps.Entities.Contents.State; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.MongoDb; +using Squidex.Infrastructure.Reflection; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Contents +{ + internal sealed class MongoContentDraftCollection : MongoContentCollection + { + public MongoContentDraftCollection(IMongoDatabase database) + : base(database, "State_Content_Draft") + { + } + + protected override async Task SetupCollectionAsync(IMongoCollection collection) + { + await collection.Indexes.CreateOneAsync( + Index + .Ascending(x => x.IndexedSchemaId) + .Ascending(x => x.Id) + .Ascending(x => x.IsDeleted)); + + await collection.Indexes.CreateOneAsync( + Index + .Text(x => x.DataText) + .Ascending(x => x.IndexedSchemaId) + .Ascending(x => x.IsDeleted) + .Ascending(x => x.Status)); + + await base.SetupCollectionAsync(collection); + } + + public async Task> QueryNotFoundAsync(Guid appId, Guid schemaId, IList ids) + { + var contentEntities = + await Collection.Find(x => x.IndexedSchemaId == schemaId && ids.Contains(x.Id) && x.IsDeleted != true).Only(x => x.Id) + .ToListAsync(); + + return ids.Except(contentEntities.Select(x => Guid.Parse(x["_id"].AsString))).ToList(); + } + + public Task QueryScheduledWithoutDataAsync(Instant now, Func callback) + { + return Collection.Find(x => x.ScheduledAt < now && x.IsDeleted != true) + .Not(x => x.DataByIds) + .Not(x => x.DataDraftByIds) + .Not(x => x.DataText) + .ForEachAsync(c => + { + callback(c); + }); + } + + public async Task<(ContentState Value, long Version)> ReadAsync(Guid key, Func> getSchema) + { + var contentEntity = + await Collection.Find(x => x.Id == key).Not(x => x.DataText) + .FirstOrDefaultAsync(); + + if (contentEntity != null) + { + var schema = await getSchema(contentEntity.IndexedAppId, contentEntity.IndexedSchemaId); + + contentEntity?.ParseData(schema.SchemaDef); + + return (SimpleMapper.Map(contentEntity, new ContentState()), contentEntity.Version); + } + + return (null, EtagVersion.NotFound); + } + + public async Task UpsertAsync(MongoContentEntity content, long oldVersion) + { + try + { + content.DataText = content.DataDraftByIds.ToFullText(); + + await Collection.ReplaceOneAsync(x => x.Id == content.Id && x.Version == oldVersion, content, Upsert); + } + catch (MongoWriteException ex) + { + if (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) + { + var existingVersion = + await Collection.Find(x => x.Id == content.Id).Only(x => x.Id, x => x.Version) + .FirstOrDefaultAsync(); + + if (existingVersion != null) + { + throw new InconsistentStateException(existingVersion["vs"].AsInt64, oldVersion, ex); + } + } + else + { + throw; + } + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs index 9ce18c582..c4f7c2bcc 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs @@ -21,22 +21,22 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents public sealed class MongoContentEntity : IContentEntity { private NamedContentData data; - private NamedContentData pendingData; + private NamedContentData dataDraft; [BsonId] - [BsonRequired] + [BsonElement] [BsonRepresentation(BsonType.String)] public Guid Id { get; set; } [BsonRequired] - [BsonElement("ai")] + [BsonElement("_ai")] [BsonRepresentation(BsonType.String)] - public Guid AppIdId { get; set; } + public Guid IndexedAppId { get; set; } [BsonRequired] - [BsonElement("si")] + [BsonElement("_si")] [BsonRepresentation(BsonType.String)] - public Guid SchemaIdId { get; set; } + public Guid IndexedSchemaId { get; set; } [BsonRequired] [BsonElement("rf")] @@ -49,38 +49,42 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents public List ReferencedIdsDeleted { get; set; } = new List(); [BsonRequired] - [BsonElement("st")] + [BsonElement("ss")] [BsonRepresentation(BsonType.String)] public Status Status { get; set; } - [BsonRequired] + [BsonIgnoreIfDefault] [BsonElement("do")] [BsonJson] public IdContentData DataByIds { get; set; } - [BsonIgnoreIfNull] - [BsonElement("dop")] + [BsonIgnoreIfDefault] + [BsonElement("dd")] [BsonJson] - public IdContentData PendingDataByIds { get; set; } + public IdContentData DataDraftByIds { get; set; } + + [BsonIgnoreIfDefault] + [BsonElement("dt")] + public string DataText { get; set; } [BsonRequired] - [BsonElement("ai2")] + [BsonElement("ai")] public NamedId AppId { get; set; } [BsonRequired] - [BsonElement("si2")] + [BsonElement("si")] public NamedId SchemaId { get; set; } [BsonIgnoreIfNull] - [BsonElement("sdt")] + [BsonElement("st")] public Status? ScheduledTo { get; set; } [BsonIgnoreIfNull] - [BsonElement("sda")] + [BsonElement("sa")] public Instant? ScheduledAt { get; set; } [BsonIgnoreIfNull] - [BsonElement("sdb")] + [BsonElement("sb")] public RefToken ScheduledBy { get; set; } [BsonRequired] @@ -91,18 +95,18 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents [BsonElement("mt")] public Instant LastModified { get; set; } - [BsonRequired] - [BsonElement("dt")] - public string DataText { get; set; } - [BsonRequired] [BsonElement("vs")] public long Version { get; set; } - [BsonRequired] + [BsonIgnoreIfDefault] [BsonElement("dl")] public bool IsDeleted { get; set; } + [BsonIgnoreIfDefault] + [BsonElement("pd")] + public bool IsPending { get; set; } + [BsonRequired] [BsonElement("cb")] public RefToken CreatedBy { get; set; } @@ -118,18 +122,18 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents } [BsonIgnore] - public NamedContentData PendingData + public NamedContentData DataDraft { - get { return pendingData; } + get { return dataDraft; } } public void ParseData(Schema schema) { data = DataByIds.ToData(schema, ReferencedIdsDeleted); - if (PendingDataByIds != null) + if (DataDraftByIds != null) { - pendingData = PendingDataByIds.ToData(schema, ReferencedIdsDeleted); + dataDraft = DataDraftByIds.ToData(schema, ReferencedIdsDeleted); } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentPublishedCollection.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentPublishedCollection.cs new file mode 100644 index 000000000..48c54d04a --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentPublishedCollection.cs @@ -0,0 +1,46 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using MongoDB.Driver; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Contents +{ + internal sealed class MongoContentPublishedCollection : MongoContentCollection + { + public MongoContentPublishedCollection(IMongoDatabase database) + : base(database, "State_Content_Published") + { + } + + protected override async Task SetupCollectionAsync(IMongoCollection collection) + { + await collection.Indexes.CreateOneAsync(Index.Text(x => x.DataText).Ascending(x => x.IndexedSchemaId)); + + await collection.Indexes.CreateOneAsync( + Index + .Ascending(x => x.IndexedSchemaId) + .Ascending(x => x.Id)); + + await base.SetupCollectionAsync(collection); + } + + public Task UpsertAsync(MongoContentEntity content) + { + content.DataText = content.DataByIds.ToFullText(); + content.DataDraftByIds = null; + + return Collection.ReplaceOneAsync(x => x.Id == content.Id, content, new UpdateOptions { IsUpsert = true }); + } + + public Task RemoveAsync(Guid id) + { + return Collection.DeleteOneAsync(x => x.Id == id); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs index 1e2065096..537ff3331 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs @@ -7,7 +7,6 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using Microsoft.OData.UriParser; using MongoDB.Driver; @@ -16,146 +15,97 @@ using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents.Repositories; -using Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; -using Squidex.Infrastructure.MongoDb; namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { - public partial class MongoContentRepository : MongoRepositoryBase, IContentRepository + public partial class MongoContentRepository : IContentRepository, IInitializable { + private readonly IMongoDatabase database; private readonly IAppProvider appProvider; + private readonly MongoContentDraftCollection contentsDraft; + private readonly MongoContentPublishedCollection contentsPublished; public MongoContentRepository(IMongoDatabase database, IAppProvider appProvider) - : base(database) { Guard.NotNull(appProvider, nameof(appProvider)); this.appProvider = appProvider; - } - protected override string CollectionName() - { - return "States_Contents"; + contentsDraft = new MongoContentDraftCollection(database); + contentsPublished = new MongoContentPublishedCollection(database); + + this.database = database; } - protected override async Task SetupCollectionAsync(IMongoCollection collection) + public void Initialize() { - await collection.Indexes.TryDropOneAsync("si_1_st_1_dl_1_dt_text"); - - await collection.Indexes.CreateOneAsync( - Index - .Text(x => x.DataText) - .Ascending(x => x.SchemaIdId) - .Ascending(x => x.Status) - .Ascending(x => x.IsDeleted)); - - await collection.Indexes.CreateOneAsync( - Index - .Ascending(x => x.SchemaIdId) - .Ascending(x => x.Id) - .Ascending(x => x.IsDeleted) - .Ascending(x => x.Status)); - - await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.ReferencedIds)); + contentsDraft.Initialize(); + contentsPublished.Initialize(); } - public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, ODataUriParser odataQuery) + public Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, ODataUriParser odataQuery) { - try + if (RequiresPublished(status)) { - var propertyCalculator = FindExtensions.CreatePropertyCalculator(schema.SchemaDef); - - var filter = FindExtensions.BuildQuery(odataQuery, schema.Id, status, propertyCalculator); - - var contentCount = Collection.Find(filter).CountAsync(); - var contentItems = - Collection.Find(filter) - .ContentTake(odataQuery) - .ContentSkip(odataQuery) - .ContentSort(odataQuery, propertyCalculator) - .ToListAsync(); - - await Task.WhenAll(contentItems, contentCount); - - foreach (var entity in contentItems.Result) - { - entity.ParseData(schema.SchemaDef); - } - - return ResultList.Create(contentItems.Result, contentCount.Result); + return contentsPublished.QueryAsync(app, schema, odataQuery); } - catch (NotSupportedException) + else { - throw new ValidationException("This odata operation is not supported."); + return contentsDraft.QueryAsync(app, schema, odataQuery, status, true); } - catch (NotImplementedException) + } + + public Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, HashSet ids) + { + if (RequiresPublished(status)) { - throw new ValidationException("This odata operation is not supported."); + return contentsPublished.QueryAsync(app, schema, ids); } - catch (MongoQueryException ex) + else { - if (ex.Message.Contains("17406")) - { - throw new DomainException("Result set is too large to be retrieved. Use $top parameter to reduce the number of items."); - } - else - { - throw; - } + return contentsDraft.QueryAsync(app, schema, ids, status); } } - public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, HashSet ids) + public Task FindContentAsync(IAppEntity app, ISchemaEntity schema, Status[] status, Guid id) { - var find = Collection.Find(x => x.SchemaIdId == schema.Id && ids.Contains(x.Id) && x.IsDeleted == false && status.Contains(x.Status)); - - var contentItems = find.ToListAsync(); - var contentCount = find.CountAsync(); - - await Task.WhenAll(contentItems, contentCount); - - foreach (var entity in contentItems.Result) + if (RequiresPublished(status)) { - entity.ParseData(schema.SchemaDef); + return contentsPublished.FindContentAsync(app, schema, id); + } + else + { + return contentsPublished.FindContentAsync(app, schema, id); } - - return ResultList.Create(contentItems.Result, contentCount.Result); } - public async Task> QueryNotFoundAsync(Guid appId, Guid schemaId, IList ids) + public Task> QueryNotFoundAsync(Guid appId, Guid schemaId, IList ids) { - var contentEntities = - await Collection.Find(x => x.SchemaIdId == schemaId && ids.Contains(x.Id) && x.IsDeleted == false).Only(x => x.Id) - .ToListAsync(); - - return ids.Except(contentEntities.Select(x => Guid.Parse(x["_id"].AsString))).ToList(); + return contentsDraft.QueryNotFoundAsync(appId, schemaId, ids); } - public async Task FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id) + public Task QueryScheduledWithoutDataAsync(Instant now, Func callback) { - var contentEntity = - await Collection.Find(x => x.SchemaIdId == schema.Id && x.Id == id && x.IsDeleted == false) - .FirstOrDefaultAsync(); - - contentEntity?.ParseData(schema.SchemaDef); - - return contentEntity; + return contentsDraft.QueryScheduledWithoutDataAsync(now, callback); } - public Task QueryScheduledWithoutDataAsync(Instant now, Func callback) + public Task ClearAsync() { - return Collection.Find(x => x.ScheduledAt < now && x.IsDeleted == false) - .ForEachAsync(c => - { - callback(c); - }); + return Task.WhenAll( + contentsDraft.ClearAsync(), + contentsPublished.ClearAsync()); } public Task DeleteArchiveAsync() { - return Database.DropCollectionAsync("States_Contents_Archive"); + return database.DropCollectionAsync("States_Contents_Archive"); + } + + private static bool RequiresPublished(Status[] status) + { + return status?.Length == 1 && status[0] == Status.Published; } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_EventHandling.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_EventHandling.cs index 8a923d361..427629d1d 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_EventHandling.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_EventHandling.cs @@ -33,20 +33,16 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents protected Task On(AssetDeleted @event) { - return Collection.UpdateManyAsync( - Filter.And( - Filter.AnyEq(x => x.ReferencedIds, @event.AssetId), - Filter.AnyNe(x => x.ReferencedIdsDeleted, @event.AssetId)), - Update.AddToSet(x => x.ReferencedIdsDeleted, @event.AssetId)); + return Task.WhenAll( + contentsDraft.CleanupAsync(@event.AssetId), + contentsPublished.CleanupAsync(@event.AssetId)); } protected Task On(ContentDeleted @event) { - return Collection.UpdateManyAsync( - Filter.And( - Filter.AnyEq(x => x.ReferencedIds, @event.ContentId), - Filter.AnyNe(x => x.ReferencedIdsDeleted, @event.ContentId)), - Update.AddToSet(x => x.ReferencedIdsDeleted, @event.ContentId)); + return Task.WhenAll( + contentsDraft.CleanupAsync(@event.ContentId), + contentsPublished.CleanupAsync(@event.ContentId)); } Task IEventConsumer.ClearAsync() diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs index 7a6a48be7..ea1bd0903 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs @@ -7,12 +7,11 @@ using System; using System.Threading.Tasks; -using MongoDB.Driver; +using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.ConvertContent; using Squidex.Domain.Apps.Entities.Contents.State; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; -using Squidex.Infrastructure.MongoDb; using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.States; @@ -20,27 +19,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { public partial class MongoContentRepository : ISnapshotStore { - Task ISnapshotStore.ReadAllAsync(Func callback) + public Task<(ContentState Value, long Version)> ReadAsync(Guid key) { - throw new NotSupportedException(); - } - - public async Task<(ContentState Value, long Version)> ReadAsync(Guid key) - { - var contentEntity = - await Collection.Find(x => x.Id == key).SortByDescending(x => x.Version) - .FirstOrDefaultAsync(); - - if (contentEntity != null) - { - var schema = await GetSchemaAsync(contentEntity.AppIdId, contentEntity.SchemaIdId); - - contentEntity?.ParseData(schema.SchemaDef); - - return (SimpleMapper.Map(contentEntity, new ContentState()), contentEntity.Version); - } - - return (null, EtagVersion.NotFound); + return contentsDraft.ReadAsync(key, GetSchemaAsync); } public async Task WriteAsync(Guid key, ContentState value, long oldVersion, long newVersion) @@ -52,42 +33,28 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents var schema = await GetSchemaAsync(value.AppId.Id, value.SchemaId.Id); - var idData = value.Data?.ToIdModel(schema.SchemaDef, true); + var idData = value.Data.ToIdModel(schema.SchemaDef, true); - var document = SimpleMapper.Map(value, new MongoContentEntity + var content = SimpleMapper.Map(value, new MongoContentEntity { - AppIdId = value.AppId.Id, - SchemaIdId = value.SchemaId.Id, - IsDeleted = value.IsDeleted, - DataText = idData?.ToFullText(), DataByIds = idData, - PendingDataByIds = value.PendingData?.ToIdModel(schema.SchemaDef, true), - ReferencedIds = idData?.ToReferencedIds(schema.SchemaDef), + DataDraftByIds = value.DataDraft?.ToIdModel(schema.SchemaDef, true), + IsDeleted = value.IsDeleted, + IndexedAppId = value.AppId.Id, + IndexedSchemaId = value.SchemaId.Id, + ReferencedIds = idData.ToReferencedIds(schema.SchemaDef), + Version = newVersion }); - document.Version = newVersion; + await contentsDraft.UpsertAsync(content, oldVersion); - try + if (value.Status == Status.Published && !value.IsDeleted) { - await Collection.ReplaceOneAsync(x => x.Id == key && x.Version == oldVersion, document, Upsert); + await contentsPublished.UpsertAsync(content); } - catch (MongoWriteException ex) + else { - if (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) - { - var existingVersion = - await Collection.Find(x => x.Id == key).Only(x => x.Id, x => x.Version) - .FirstOrDefaultAsync(); - - if (existingVersion != null) - { - throw new InconsistentStateException(existingVersion["vs"].AsInt64, oldVersion, ex); - } - } - else - { - throw; - } + await contentsPublished.RemoveAsync(content.Id); } } @@ -102,5 +69,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents return schema; } + + Task ISnapshotStore.ReadAllAsync(Func callback) + { + throw new NotSupportedException(); + } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs index cdfaff9b8..35f218538 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs @@ -27,12 +27,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors typeof(MongoContentEntity).GetProperties() .ToDictionary(x => x.Name, x => x.GetCustomAttribute()?.ElementName ?? x.Name, StringComparer.OrdinalIgnoreCase); - static FindExtensions() - { - PropertyMap["Data"] = "do"; - } - - public static PropertyCalculator CreatePropertyCalculator(Schema schema) + public static PropertyCalculator CreatePropertyCalculator(Schema schema, bool useDraft) { return propertyNames => { @@ -50,7 +45,21 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors if (propertyNames.Length > 0) { - propertyNames[0] = PropertyMap[propertyNames[0]]; + if (propertyNames[0].Equals("Data", StringComparison.CurrentCultureIgnoreCase)) + { + if (useDraft) + { + propertyNames[0] = "dd"; + } + else + { + propertyNames[0] = "do"; + } + } + else + { + propertyNames[0] = PropertyMap[propertyNames[0]]; + } } var propertyName = string.Join(".", propertyNames); @@ -80,11 +89,15 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors { var filters = new List> { - Filter.Eq(x => x.SchemaIdId, schemaId), - Filter.In(x => x.Status, status), - Filter.Eq(x => x.IsDeleted, false) + Filter.Eq(x => x.IndexedSchemaId, schemaId), }; + if (status != null) + { + filters.Add(Filter.Ne(x => x.IsDeleted, true)); + filters.Add(Filter.In(x => x.Status, status)); + } + var filter = query.BuildFilter(propertyCalculator); if (filter.Filter != null) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs index f2eea4643..7f0842c16 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs @@ -12,5 +12,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands public abstract class ContentDataCommand : ContentCommand { public NamedContentData Data { get; set; } + + public bool AsDraft { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs index 65cbde251..80206cebd 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs @@ -9,6 +9,5 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands { public sealed class PatchContent : ContentDataCommand { - public bool AsProposal { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs index 1762385e8..01f642d5c 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs @@ -9,6 +9,5 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands { public sealed class UpdateContent : ContentDataCommand { - public bool AsProposal { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs index 7bd935591..72c2158ee 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs @@ -42,7 +42,9 @@ namespace Squidex.Domain.Apps.Entities.Contents public NamedContentData Data { get; set; } - public NamedContentData PendingData { get; set; } + public NamedContentData DataDraft { get; set; } + + public bool IsPending { get; set; } public static ContentEntity Create(CreateContent command, EntityCreatedResult result) { diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs index c10c501b2..1758b1e98 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs @@ -84,7 +84,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { GuardContent.CanUpdate(c); - return UpdateContentAsync(c, c.Data, "Update", c.AsProposal); + return UpdateAsync(c, x => c.Data, false); }); case PatchContent patchContent: @@ -92,21 +92,32 @@ namespace Squidex.Domain.Apps.Entities.Contents { GuardContent.CanPatch(c); - return UpdateContentAsync(c, c.Data.MergeInto(Snapshot.PendingData ?? Snapshot.Data), "Patch", c.AsProposal); + return UpdateAsync(c, c.Data.MergeInto, true); }); case ChangeContentStatus changeContentStatus: - return UpdateReturnAsync(changeContentStatus, c => + return UpdateAsync(changeContentStatus, async c => { - GuardContent.CanChangeContentStatus(Snapshot.PendingData, Snapshot.Status, c); + GuardContent.CanChangeContentStatus(Snapshot.IsPending, Snapshot.Status, c); - if (Snapshot.PendingData != null) + if (Snapshot.IsPending && Snapshot.Status == Status.Published && c.Status == Status.Published) { - return UpdateContentAsync(c, Snapshot.PendingData, "Update", false); + var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to update content."); + + await ctx.ExecuteScriptAndTransformAsync(x => x.ScriptUpdate, "Update", c, Snapshot.DataDraft, Snapshot.Data); + + ConfirmChanges(c); } else { - return ChangeStatusAsync(c); + if (!c.DueTime.HasValue) + { + var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to change content."); + + await ctx.ExecuteScriptAsync(x => x.ScriptChange, c.Status, c, Snapshot.Data); + } + + ChangeStatus(c); } }); @@ -125,7 +136,7 @@ namespace Squidex.Domain.Apps.Entities.Contents case DiscardChanges discardChanges: return UpdateAsync(discardChanges, c => { - GuardContent.CanDiscardChanges(Snapshot.PendingData, c); + GuardContent.CanDiscardChanges(Snapshot.IsPending, c); DiscardChanges(c); }); @@ -135,41 +146,43 @@ namespace Squidex.Domain.Apps.Entities.Contents } } - private async Task ChangeStatusAsync(ChangeContentStatus c) + private async Task UpdateAsync(ContentDataCommand c, Func newDataFunc, bool partial) { - if (!c.DueTime.HasValue) - { - var operationContext = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to change content."); + var isProposal = c.AsDraft && Snapshot.Status == Status.Published; - await operationContext.ExecuteScriptAsync(x => x.ScriptChange, c.Status, c, Snapshot.Data); - } - - ChangeStatus(c); + var currentData = + isProposal ? + Snapshot.DataDraft : + Snapshot.Data; - return new EntitySavedResult(NewVersion); - } - - private async Task UpdateContentAsync(ContentCommand command, NamedContentData data, string operation, bool asProposal) - { - var operationContext = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to update content."); + var newData = newDataFunc(currentData); - if (!Snapshot.Data.Equals(data)) + if (!currentData.Equals(newData)) { - await operationContext.ValidateAsync(data); + var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to update content."); - if (asProposal) + if (partial) { - ProposeUpdate(command, data); + await ctx.ValidatePartialAsync(c.Data); } else { - await operationContext.ExecuteScriptAndTransformAsync(x => x.ScriptUpdate, "Update", command, data, Snapshot.Data); + await ctx.ValidateAsync(c.Data); + } - Update(command, data); + if (isProposal) + { + ProposeUpdate(c, newData); + } + else + { + newData = await ctx.ExecuteScriptAndTransformAsync(x => x.ScriptUpdate, "Update", c, newData, Snapshot.Data); + + Update(c, newData); } } - return new ContentDataChangedResult(data, NewVersion); + return new ContentDataChangedResult(newData, NewVersion); } public void Create(CreateContent command) @@ -182,6 +195,11 @@ namespace Squidex.Domain.Apps.Entities.Contents } } + public void ConfirmChanges(ChangeContentStatus command) + { + RaiseEvent(SimpleMapper.Map(command, new ContentChangesConfirmed())); + } + public void DiscardChanges(DiscardChanges command) { RaiseEvent(SimpleMapper.Map(command, new ContentChangesDiscarded())); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs index 6c0c621d1..5b1bc9474 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs @@ -69,6 +69,13 @@ namespace Squidex.Domain.Apps.Entities.Contents return data.ValidateAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message); } + public Task ValidatePartialAsync(NamedContentData data) + { + var ctx = CreateValidationContext(); + + return data.ValidatePartialAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message); + } + public Task ExecuteScriptAndTransformAsync(Func script, object operation, ContentCommand command, NamedContentData data, NamedContentData oldData = null) { var ctx = CreateScriptContext(operation, command, data, oldData); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs index fc8d77f94..11795ca8f 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs @@ -70,10 +70,12 @@ namespace Squidex.Domain.Apps.Entities.Contents var isFrontendClient = IsFrontendClient(user); var isVersioned = version > EtagVersion.Empty; + var parsedStatus = isFrontendClient ? new[] { Status.Published } : null; + var content = isVersioned ? await FindContentByVersionAsync(id, version) : - await FindContentAsync(app, id, schema); + await FindContentAsync(app, id, parsedStatus, schema); if (content == null || (content.Status != Status.Published && !isFrontendClient) || content.SchemaId.Id != schema.Id) { @@ -144,12 +146,17 @@ namespace Squidex.Domain.Apps.Entities.Contents { var result = SimpleMapper.Map(content, new ContentEntity()); - if (!isFrontendClient && isScripting) + if (result.Data != null) { - result.Data = scriptEngine.Transform(new ScriptContext { User = user, Data = content.Data, ContentId = content.Id }, scriptText); + if (!isFrontendClient && isScripting) + { + result.Data = scriptEngine.Transform(new ScriptContext { User = user, Data = content.Data, ContentId = content.Id }, scriptText); + } + + result.Data = result.Data.ToApiModel(schema.SchemaDef, app.LanguagesConfig, isFrontendClient, isTypeChecking); } - result.Data = result.Data.ToApiModel(schema.SchemaDef, app.LanguagesConfig, isFrontendClient, isTypeChecking); + result.DataDraft = result.DataDraft.ToApiModel(schema.SchemaDef, app.LanguagesConfig, isFrontendClient, isTypeChecking); yield return result; } @@ -222,9 +229,9 @@ namespace Squidex.Domain.Apps.Entities.Contents return contentVersionLoader.LoadAsync(id, version); } - private Task FindContentAsync(IAppEntity app, Guid id, ISchemaEntity schema) + private Task FindContentAsync(IAppEntity app, Guid id, Status[] status, ISchemaEntity schema) { - return contentRepository.FindContentAsync(app, schema, id); + return contentRepository.FindContentAsync(app, schema, status, id); } private static bool IsFrontendClient(ClaimsPrincipal user) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs b/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs index cb8cbe3df..64d8f8a39 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs @@ -53,30 +53,29 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards }); } - public static void CanDiscardChanges(NamedContentData pendingData, DiscardChanges command) + public static void CanDiscardChanges(bool isPending, DiscardChanges command) { Guard.NotNull(command, nameof(command)); Validate.It(() => "Cannot discard pending changes.", error => { - if (pendingData == null) + if (!isPending) { error(new ValidationError("The content has no pending changes.")); } }); } - public static void CanChangeContentStatus(NamedContentData pendingData, Status status, ChangeContentStatus command) + public static void CanChangeContentStatus(bool isPending, Status status, ChangeContentStatus command) { Guard.NotNull(command, nameof(command)); Validate.It(() => "Cannot change status.", error => { var isAllowedPendingUpdate = - command.DueTime == null && status == command.Status && status == Status.Published && - pendingData != null; + isPending; if (!StatusFlow.Exists(command.Status) || (!StatusFlow.CanChange(status, command.Status) && !isAllowedPendingUpdate)) { diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs b/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs index 677cee347..cf908dd4d 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs @@ -33,6 +33,8 @@ namespace Squidex.Domain.Apps.Entities.Contents NamedContentData Data { get; } - NamedContentData PendingData { get; } + NamedContentData DataDraft { get; } + + bool IsPending { get; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs b/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs index 0c2e687b7..36da4d974 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs @@ -25,7 +25,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Repositories Task> QueryNotFoundAsync(Guid appId, Guid schemaId, IList ids); - Task FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id); + Task FindContentAsync(IAppEntity app, ISchemaEntity schema, Status[] status, Guid id); Task QueryScheduledWithoutDataAsync(Instant now, Func callback); } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs b/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs index e218b6c44..ceb88ba75 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs @@ -30,7 +30,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.State public NamedContentData Data { get; set; } [JsonProperty] - public NamedContentData PendingData { get; set; } + public NamedContentData DataDraft { get; set; } [JsonProperty] public Status Status { get; set; } @@ -44,29 +44,44 @@ namespace Squidex.Domain.Apps.Entities.Contents.State [JsonProperty] public RefToken ScheduledBy { get; set; } + [JsonProperty] + public bool IsPending { get; set; } + [JsonProperty] public bool IsDeleted { get; set; } protected void On(ContentCreated @event) { SimpleMapper.Map(@event, this); + + DataDraft = @event.Data; + } + + protected void On(ContentUpdated @event) + { + Data = @event.Data; + DataDraft = @event.Data; } protected void On(ContentUpdateProposed @event) { - PendingData = @event.Data; + DataDraft = @event.Data; + + IsPending = true; } protected void On(ContentChangesDiscarded @event) { - PendingData = null; + DataDraft = Data; + + IsPending = false; } - protected void On(ContentUpdated @event) + protected void On(ContentChangesConfirmed @event) { - PendingData = null; + Data = DataDraft; - Data = @event.Data; + IsPending = false; } protected void On(ContentStatusScheduled @event) @@ -83,6 +98,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.State ScheduledAt = null; ScheduledBy = null; ScheduledTo = null; + + if (@event.Status == Status.Published) + { + Data = DataDraft; + + IsPending = false; + } } protected void On(ContentDeleted @event) diff --git a/src/Squidex.Domain.Apps.Events/Contents/ContentChangesConfirmed.cs b/src/Squidex.Domain.Apps.Events/Contents/ContentChangesConfirmed.cs new file mode 100644 index 000000000..6e5554cb9 --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Contents/ContentChangesConfirmed.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Contents +{ + [EventType(nameof(ContentChangesConfirmed))] + public sealed class ContentChangesConfirmed : ContentEvent + { + } +} diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs index 96ff8de11..90240e3fe 100644 --- a/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs @@ -74,6 +74,27 @@ namespace Squidex.Infrastructure.MongoDb return find.Project(Builders.Projection.Include(include1).Include(include2).Include(include3)); } + public static IFindFluent Not(this IFindFluent find, + Expression> exclude) + { + return find.Project(Builders.Projection.Exclude(exclude)); + } + + public static IFindFluent Not(this IFindFluent find, + Expression> exclude1, + Expression> exclude2) + { + return find.Project(Builders.Projection.Exclude(exclude1).Exclude(exclude2)); + } + + public static IFindFluent Not(this IFindFluent find, + Expression> exclude1, + Expression> exclude2, + Expression> exclude3) + { + return find.Project(Builders.Projection.Exclude(exclude1).Exclude(exclude2).Exclude(exclude3)); + } + public static async Task UpsertVersionedAsync(this IMongoCollection collection, TKey key, long oldVersion, long newVersion, Func, UpdateDefinition> updater) where T : IVersionedEntity { try diff --git a/src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs b/src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs index f4667e9cd..3905530ea 100644 --- a/src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs @@ -119,7 +119,7 @@ namespace Squidex.Areas.Api.Controllers.Contents var response = new ContentsDto { Total = result.Total, - Items = result.Take(200).Select(item => SimpleMapper.Map(item, new ContentDto { Data = item.Data })).ToArray() + Items = result.Take(200).Select(item => SimpleMapper.Map(item, new ContentDto { Data = item.Data, DataDraft = item.DataDraft })).ToArray() }; Response.Headers["Surrogate-Key"] = string.Join(" ", response.Items.Select(x => x.Id)); @@ -148,10 +148,10 @@ namespace Squidex.Areas.Api.Controllers.Contents { var content = await contentQuery.FindContentAsync(App, name, User, id); - var response = SimpleMapper.Map(content, new ContentDto { Data = content.Data }); + var response = SimpleMapper.Map(content, new ContentDto { Data = content.Data, DataDraft = content.DataDraft }); - Response.Headers["ETag"] = entity.Version.ToString(); - Response.Headers["Surrogate-Key"] = entity.Id.ToString(); + Response.Headers["ETag"] = content.Version.ToString(); + Response.Headers["Surrogate-Key"] = content.Id.ToString(); return Ok(response); } @@ -177,9 +177,9 @@ namespace Squidex.Areas.Api.Controllers.Contents [ApiCosts(1)] public async Task GetContentVersion(string app, string name, Guid id, int version) { - var (schema, entity) = await contentQuery.FindContentAsync(App, name, User, id, version); - - var response = SimpleMapper.Map(content, new ContentDto { Data = content.Data }); + var content = await contentQuery.FindContentAsync(App, name, User, id, version); + + var response = SimpleMapper.Map(content, new ContentDto { Data = content.Data, DataDraft = content.DataDraft }); Response.Headers["ETag"] = content.Version.ToString(); Response.Headers["Surrogate-Key"] = content.Id.ToString(); @@ -227,7 +227,7 @@ namespace Squidex.Areas.Api.Controllers.Contents /// The name of the schema. /// The id of the content item to update. /// The full data for the content item. - /// Indicates whether the update is a proposal. + /// Indicates whether the update is a proposal. /// /// 200 => Content updated. /// 404 => Content, schema or app not found. @@ -240,11 +240,11 @@ namespace Squidex.Areas.Api.Controllers.Contents [HttpPut] [Route("content/{app}/{name}/{id}/")] [ApiCosts(1)] - public async Task PutContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asProposal = false) + public async Task PutContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asDraft = false) { await contentQuery.ThrowIfSchemaNotExistsAsync(App, name); - var command = new UpdateContent { ContentId = id, Data = request.ToCleaned(), AsProposal = asProposal }; + var command = new UpdateContent { ContentId = id, Data = request.ToCleaned(), AsDraft = asDraft }; var context = await CommandBus.PublishAsync(command); var result = context.Result(); @@ -260,7 +260,7 @@ namespace Squidex.Areas.Api.Controllers.Contents /// The name of the schema. /// The id of the content item to patch. /// The patch for the content item. - /// Indicates whether the patch is a proposal. + /// Indicates whether the patch is a proposal. /// /// 200 => Content patched. /// 404 => Content, schema or app not found. @@ -273,11 +273,11 @@ namespace Squidex.Areas.Api.Controllers.Contents [HttpPatch] [Route("content/{app}/{name}/{id}/")] [ApiCosts(1)] - public async Task PatchContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asProposal = false) + public async Task PatchContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asDraft = false) { await contentQuery.ThrowIfSchemaNotExistsAsync(App, name); - var command = new PatchContent { ContentId = id, Data = request.ToCleaned(), AsProposal = asProposal }; + var command = new PatchContent { ContentId = id, Data = request.ToCleaned(), AsDraft = asDraft }; var context = await CommandBus.PublishAsync(command); var result = context.Result(); @@ -426,7 +426,7 @@ namespace Squidex.Areas.Api.Controllers.Contents [ApiCosts(1)] public async Task DiscardChanges(string app, string name, Guid id) { - await contentQuery.FindSchemaAsync(App, name); + await contentQuery.ThrowIfSchemaNotExistsAsync(App, name); var command = new DiscardChanges { ContentId = id }; diff --git a/src/Squidex/Areas/Api/Controllers/Content/Models/ContentDto.cs b/src/Squidex/Areas/Api/Controllers/Content/Models/ContentDto.cs index 30516a07f..cd5340a26 100644 --- a/src/Squidex/Areas/Api/Controllers/Content/Models/ContentDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Content/Models/ContentDto.cs @@ -43,7 +43,12 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models /// /// The pending changes of the content item. /// - public object PendingData { get; set; } + public object DataDraft { get; set; } + + /// + /// Indicates if the draft data is pending. + /// + public bool IsPending { get; set; } /// /// The scheduled status. diff --git a/src/Squidex/app/features/content/pages/content/content-page.component.html b/src/Squidex/app/features/content/pages/content/content-page.component.html index 8aa3b56ae..45133754a 100644 --- a/src/Squidex/app/features/content/pages/content/content-page.component.html +++ b/src/Squidex/app/features/content/pages/content/content-page.component.html @@ -20,18 +20,31 @@ - - + + + + + + + diff --git a/src/Squidex/app/features/content/pages/content/content-page.component.ts b/src/Squidex/app/features/content/pages/content/content-page.component.ts index 1228ee551..52271a6bf 100644 --- a/src/Squidex/app/features/content/pages/content/content-page.component.ts +++ b/src/Squidex/app/features/content/pages/content/content-page.component.ts @@ -87,7 +87,7 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy, .subscribe(content => { this.content = content; - this.loadContent(content.data); + this.loadContent(content.dataDraft); }); this.contentVersionSelectedSubscription = @@ -106,14 +106,18 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy, } public saveAndPublish() { - this.saveContent(true); + this.saveContent(true, false); } - public saveAsDraft() { - this.saveContent(false); + public saveAsProposal() { + this.saveContent(false, true); } - private saveContent(publish: boolean) { + public save() { + this.saveContent(false, false); + } + + private saveContent(publish: boolean, asProposal: boolean) { if (this.content && this.content.status === 'Archived') { return; } @@ -122,12 +126,21 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy, if (value) { if (this.content) { - this.contentsState.update(this.content, value) - .subscribe(dto => { - this.contentForm.submitCompleted(); - }, error => { - this.contentForm.submitFailed(error); - }); + if (asProposal) { + this.contentsState.proposeUpdate(this.content, value) + .subscribe(dto => { + this.contentForm.submitCompleted(); + }, error => { + this.contentForm.submitFailed(error); + }); + } else { + this.contentsState.update(this.content, value) + .subscribe(dto => { + this.contentForm.submitCompleted(); + }, error => { + this.contentForm.submitFailed(error); + }); + } } else { this.contentsState.create(value, publish) .subscribe(dto => { @@ -168,7 +181,7 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy, if (this.contentVersion) { this.contentVersion = null; - this.loadContent(this.content.data); + this.loadContent(this.content.dataDraft); } } } \ No newline at end of file diff --git a/src/Squidex/app/features/content/shared/content-item.component.html b/src/Squidex/app/features/content/shared/content-item.component.html index fe060b63a..956a06d21 100644 --- a/src/Squidex/app/features/content/shared/content-item.component.html +++ b/src/Squidex/app/features/content/shared/content-item.component.html @@ -60,7 +60,7 @@ [status]="content.status" [scheduledTo]="content.scheduledTo" [scheduledAt]="content.scheduledAt" - [hasPendingChanges]="content.pendingData"> + [isPending]="content.isPending"> {{content.lastModified | sqxFromNow}} diff --git a/src/Squidex/app/features/content/shared/content-item.component.ts b/src/Squidex/app/features/content/shared/content-item.component.ts index ad96cd40c..99893ea9b 100644 --- a/src/Squidex/app/features/content/shared/content-item.component.ts +++ b/src/Squidex/app/features/content/shared/content-item.component.ts @@ -137,7 +137,7 @@ export class ContentItemComponent implements OnChanges { } private getRawValue(field: FieldDto): any { - const contentField = this.content.displayData[field.name]; + const contentField = this.content.dataDraft[field.name]; if (contentField) { if (field.isLocalizable) { diff --git a/src/Squidex/app/features/content/shared/content-status.component.ts b/src/Squidex/app/features/content/shared/content-status.component.ts index 7592ccc16..1f7c463a5 100644 --- a/src/Squidex/app/features/content/shared/content-status.component.ts +++ b/src/Squidex/app/features/content/shared/content-status.component.ts @@ -5,9 +5,9 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Component, ChangeDetectionStrategy, Input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; -import { DateTime } from 'shared'; +import { DateTime } from '@app/shared'; @Component({ selector: 'sqx-content-status', @@ -26,13 +26,13 @@ export class ContentStatusComponent { public scheduledAt?: DateTime; @Input() - public hasPendingChanges: any; + public isPending: any; @Input() public showLabel = false; public get displayStatus() { - return !!this.hasPendingChanges ? 'Pending' : this.status; + return !!this.isPending ? 'Pending' : this.status; } } diff --git a/src/Squidex/app/features/schemas/pages/schema/field.component.html b/src/Squidex/app/features/schemas/pages/schema/field.component.html index 5c6e2827a..fe5f7c15e 100644 --- a/src/Squidex/app/features/schemas/pages/schema/field.component.html +++ b/src/Squidex/app/features/schemas/pages/schema/field.component.html @@ -28,22 +28,22 @@