diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 40a396a72..d38ebe862 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -100,6 +100,11 @@ jobs: with: cmd: yq e '.services.squidex2.image = "squidex-tmp"' -i backend/tests/docker-compose.yml + - name: Replace Image Name3 + uses: mikefarah/yq@v4.9.1 + with: + cmd: yq e '.services.squidex3.image = "squidex-tmp"' -i backend/tests/docker-compose.yml + - name: Start Test run: docker-compose up -d working-directory: backend/tests @@ -131,6 +136,20 @@ jobs: options: --name test2 volumes: ${{ github.workspace }}:/src run: dotnet test /src/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated + + - name: RUN TEST with dedicated collections + uses: kohlerdominik/docker-run-action@v1.0.0 + with: + image: squidex/build + environment: | + CONFIG__BACKUPURL=http://localhost:5000 + CONFIG__WAIT=60 + CONFIG__SERVER__URL=http://localhost:8082 + WEBHOOKCATCHER__HOST__ENDPOINT=webhookcatcher + default_network: host + options: --name test3 + volumes: ${{ github.workspace }}:/src + run: dotnet test /src/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated - name: Dump docker logs on failure if: failure() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b088e38e..b226810e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,6 +83,11 @@ jobs: with: cmd: yq e '.services.squidex2.image = "squidex-tmp"' -i backend/tests/docker-compose.yml + - name: Replace Image Name3 + uses: mikefarah/yq@v4.9.1 + with: + cmd: yq e '.services.squidex3.image = "squidex-tmp"' -i backend/tests/docker-compose.yml + - name: Start Test run: docker-compose up -d working-directory: backend/tests @@ -114,6 +119,20 @@ jobs: options: --name test2 volumes: ${{ github.workspace }}:/src run: dotnet test /src/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated + + - name: RUN TEST with dedicated collections + uses: kohlerdominik/docker-run-action@v1.0.0 + with: + image: squidex/build + environment: | + CONFIG__BACKUPURL=http://localhost:5000 + CONFIG__WAIT=60 + CONFIG__SERVER__URL=http://localhost:8082 + WEBHOOKCATCHER__HOST__ENDPOINT=webhookcatcher + default_network: host + options: --name test3 + volumes: ${{ github.workspace }}:/src + run: dotnet test /src/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated - name: Dump docker logs on failure if: failure() diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/ContentIdStatus.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/ContentIdStatus.cs new file mode 100644 index 000000000..042a6132f --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/ContentIdStatus.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure; + +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + +namespace Squidex.Domain.Apps.Core.Contents +{ + public record struct ContentIdStatus(DomainId SchemaId, DomainId Id, Status Status) + { + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ReferencesValidator.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ReferencesValidator.cs index d36db17a4..3380e11e1 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ReferencesValidator.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ReferencesValidator.cs @@ -14,7 +14,7 @@ using Squidex.Infrastructure.Translations; namespace Squidex.Domain.Apps.Core.ValidateContent.Validators { - public delegate Task> CheckContentsByIds(HashSet ids); + public delegate Task> CheckContentsByIds(HashSet ids); public sealed class ReferencesValidator : IValidator { diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/UniqueValidator.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/UniqueValidator.cs index 2b4058644..097b8e7a8 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/UniqueValidator.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/UniqueValidator.cs @@ -14,7 +14,7 @@ using Squidex.Infrastructure.Translations; namespace Squidex.Domain.Apps.Core.ValidateContent.Validators { - public delegate Task> CheckUniqueness(FilterNode filter); + public delegate Task> CheckUniqueness(FilterNode filter); public sealed class UniqueValidator : IValidator { diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs index 33b1e06c0..6145576bc 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs @@ -118,6 +118,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets { var query = q.Query.AdjustToModel(appId); + // Default means that no other filters are applied and we only query by app. var (filter, isDefault) = query.BuildFilter(appId, parentId); var assetEntities = @@ -138,7 +139,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets } else if (isDefaultQuery) { - // Cache total count by app and asset folder. + // Cache total count by app and asset folder because no other filters are applied (aka default). var totalKey = $"{appId}_{parentId}"; assetTotal = await countCollection.GetOrAddAsync(totalKey, ct => Collection.Find(filter).CountDocumentsAsync(ct), ct); diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs index 822f28965..b56d0b0e0 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs @@ -16,6 +16,9 @@ using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.MongoDb; using Squidex.Infrastructure.Queries; +using Squidex.Infrastructure.Translations; + +#pragma warning disable IDE0060 // Remove unused parameter namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { @@ -25,13 +28,15 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents private readonly QueryById queryBdId; private readonly QueryByIds queryByIds; private readonly QueryByQuery queryByQuery; + private readonly QueryInDedicatedCollection? queryInDedicatedCollection; private readonly QueryReferences queryReferences; private readonly QueryReferrers queryReferrers; private readonly QueryScheduled queryScheduled; private readonly ReadPreference readPreference; private readonly string name; - public MongoContentCollection(string name, IMongoDatabase database, IAppProvider appProvider, ReadPreference readPreference) + public MongoContentCollection(string name, IMongoDatabase database, ReadPreference readPreference, + bool dedicatedCollections) : base(database) { this.name = name; @@ -39,17 +44,21 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents queryAsStream = new QueryAsStream(); queryBdId = new QueryById(); queryByIds = new QueryByIds(); - queryByQuery = new QueryByQuery(appProvider, new MongoCountCollection(database, name)); queryReferences = new QueryReferences(queryByIds); queryReferrers = new QueryReferrers(); queryScheduled = new QueryScheduled(); + queryByQuery = new QueryByQuery(new MongoCountCollection(database, name)); - this.readPreference = readPreference; - } + if (dedicatedCollections) + { + queryInDedicatedCollection = + new QueryInDedicatedCollection( + database.Client, + database.DatabaseNamespace.DatabaseName, + name); + } - public IMongoCollection GetInternalCollection() - { - return Collection; + this.readPreference = readPreference; } protected override string CollectionName() @@ -119,27 +128,38 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { using (Telemetry.Activities.StartActivity("MongoContentCollection/QueryAsync")) { - if (q.Ids is { Count: > 0 }) - { - return await queryByIds.QueryAsync(app.Id, schemas, q, ct); - } - - if (q.ScheduledFrom != null && q.ScheduledTo != null) + try { - return await queryScheduled.QueryAsync(app.Id, schemas, q, ct); + if (q.Ids is { Count: > 0 } && schemas.Count > 0) + { + return await queryByIds.QueryAsync(app, schemas, q, ct); + } + + if (q.ScheduledFrom != null && q.ScheduledTo != null && schemas.Count > 0) + { + return await queryScheduled.QueryAsync(app, schemas, q, ct); + } + + if (q.Referencing != default && schemas.Count > 0) + { + return await queryReferences.QueryAsync(app, schemas, q, ct); + } + + if (q.Reference != default && schemas.Count > 0) + { + return await queryByQuery.QueryAsync(app, schemas, q, ct); + } + + return ResultList.Empty(); } - - if (q.Referencing != default) + catch (MongoCommandException ex) when (ex.Code == 96) { - return await queryReferences.QueryAsync(app.Id, schemas, q, ct); + throw new DomainException(T.Get("common.resultTooLarge")); } - - if (q.Reference != default) + catch (MongoQueryException ex) when (ex.Message.Contains("17406", StringComparison.Ordinal)) { - return await queryByQuery.QueryAsync(app, schemas, q, ct); + throw new DomainException(T.Get("common.resultTooLarge")); } - - return ResultList.CreateFrom(0); } } @@ -148,22 +168,38 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { using (Telemetry.Activities.StartActivity("MongoContentCollection/QueryAsync")) { - if (q.Ids is { Count: > 0 }) + try { - return await queryByIds.QueryAsync(app.Id, new List { schema }, q, ct); + if (q.Ids is { Count: > 0 }) + { + return await queryByIds.QueryAsync(app, new List { schema }, q, ct); + } + + if (q.ScheduledFrom != null && q.ScheduledTo != null) + { + return await queryScheduled.QueryAsync(app, new List { schema }, q, ct); + } + + if (q.Referencing == default) + { + if (queryInDedicatedCollection != null) + { + return await queryInDedicatedCollection.QueryAsync(schema, q, ct); + } + + return await queryByQuery.QueryAsync(schema, q, ct); + } + + return ResultList.Empty(); } - - if (q.ScheduledFrom != null && q.ScheduledTo != null) + catch (MongoCommandException ex) when (ex.Code == 96) { - return await queryScheduled.QueryAsync(app.Id, new List { schema }, q, ct); + throw new DomainException(T.Get("common.resultTooLarge")); } - - if (q.Referencing == default) + catch (MongoQueryException ex) when (ex.Message.Contains("17406", StringComparison.Ordinal)) { - return await queryByQuery.QueryAsync(app, schema, q, ct); + throw new DomainException(T.Get("common.resultTooLarge")); } - - return ResultList.CreateFrom(0); } } @@ -176,7 +212,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents } } - public async Task> QueryIdsAsync(DomainId appId, HashSet ids, + public async Task> QueryIdsAsync(DomainId appId, HashSet ids, CancellationToken ct) { using (Telemetry.Activities.StartActivity("MongoContentCollection/QueryIdsAsync")) @@ -185,7 +221,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents } } - public async Task> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode filterNode, + public async Task> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode filterNode, CancellationToken ct) { using (Telemetry.Activities.StartActivity("MongoContentCollection/QueryIdsAsync")) @@ -215,27 +251,37 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents return Collection.Find(new BsonDocument()).ToAsyncEnumerable(ct); } - public Task UpsertVersionedAsync(DomainId documentId, long oldVersion, MongoContentEntity value, + public async Task UpsertVersionedAsync(DomainId documentId, long oldVersion, MongoContentEntity value, CancellationToken ct = default) { - return Collection.UpsertVersionedAsync(documentId, oldVersion, value.Version, value, ct); + if (queryInDedicatedCollection != null) + { + await queryInDedicatedCollection.UpsertVersionedAsync(documentId, oldVersion, value, default); + } + + await Collection.UpsertVersionedAsync(documentId, oldVersion, value.Version, value, default); } - public Task RemoveAsync(DomainId key, + public async Task RemoveAsync(DomainId key, CancellationToken ct = default) { - return Collection.DeleteOneAsync(x => x.DocumentId == key, null, ct); + var previous = await Collection.FindOneAndDeleteAsync(x => x.DocumentId == key, null, default); + + if (queryInDedicatedCollection != null && previous != null) + { + await queryInDedicatedCollection.RemoveAsync(previous, default); + } } - public Task InsertManyAsync(IReadOnlyList snapshots, - CancellationToken ct = default) + public async Task AddCollectionsAsync(MongoContentEntity entity, Action, MongoContentEntity> add, + CancellationToken ct) { - if (snapshots.Count == 0) + if (queryInDedicatedCollection != null) { - return Task.CompletedTask; + add(await queryInDedicatedCollection.GetCollectionAsync(entity.AppId.Id, entity.SchemaId.Id), entity); } - return Collection.InsertManyAsync(snapshots, InsertUnordered, ct); + add(Collection, entity); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs index 5bd38e06a..e787cf867 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs @@ -137,7 +137,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents return entity; } - public static async Task CreateDraftAsync(SnapshotWriteJob job, IAppProvider appProvider) + public static async Task CreateAsync(SnapshotWriteJob job, IAppProvider appProvider) { var entity = await CreateContentAsync(job.Value.Data, job, appProvider); diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs index 4d9c02e79..3dec39767 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Microsoft.Extensions.Options; using MongoDB.Driver; using NodaTime; using Squidex.Domain.Apps.Core.Contents; @@ -19,9 +20,9 @@ using Squidex.Infrastructure.Queries; namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { - public partial class MongoContentRepository : IContentRepository, IInitializable + public partial class MongoContentRepository : MongoBase, IContentRepository, IInitializable { - private readonly MongoContentCollection collectionFrontend; + private readonly MongoContentCollection collectionComplete; private readonly MongoContentCollection collectionPublished; private readonly IAppProvider appProvider; @@ -30,15 +31,16 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents TypeConverterStringSerializer.Register(); } - public MongoContentRepository(IMongoDatabase database, IAppProvider appProvider) + public MongoContentRepository(IMongoDatabase database, IAppProvider appProvider, + IOptions options) { - collectionFrontend = - new MongoContentCollection("States_Contents_All3", database, appProvider, - ReadPreference.Primary); + collectionComplete = + new MongoContentCollection("States_Contents_All3", database, + ReadPreference.Primary, options.Value.OptimizeForSelfHosting); collectionPublished = - new MongoContentCollection("States_Contents_Published3", database, appProvider, - ReadPreference.Secondary); + new MongoContentCollection("States_Contents_Published3", database, + ReadPreference.Secondary, options.Value.OptimizeForSelfHosting); this.appProvider = appProvider; } @@ -46,20 +48,20 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents public async Task InitializeAsync( CancellationToken ct) { - await collectionFrontend.InitializeAsync(ct); + await collectionComplete.InitializeAsync(ct); await collectionPublished.InitializeAsync(ct); } public IAsyncEnumerable StreamAll(DomainId appId, HashSet? schemaIds, CancellationToken ct = default) { - return collectionFrontend.StreamAll(appId, schemaIds, ct); + return collectionComplete.StreamAll(appId, schemaIds, ct); } public IAsyncEnumerable QueryScheduledWithoutDataAsync(Instant now, CancellationToken ct = default) { - return collectionFrontend.QueryScheduledWithoutDataAsync(now, ct); + return collectionComplete.QueryScheduledWithoutDataAsync(now, ct); } public Task> QueryAsync(IAppEntity app, List schemas, Q q, SearchScope scope, @@ -67,7 +69,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { if (scope == SearchScope.All) { - return collectionFrontend.QueryAsync(app, schemas, q, ct); + return collectionComplete.QueryAsync(app, schemas, q, ct); } else { @@ -80,7 +82,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { if (scope == SearchScope.All) { - return collectionFrontend.QueryAsync(app, schema, q, ct); + return collectionComplete.QueryAsync(app, schema, q, ct); } else { @@ -93,7 +95,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { if (scope == SearchScope.All) { - return collectionFrontend.FindContentAsync(schema, id, ct); + return collectionComplete.FindContentAsync(schema, id, ct); } else { @@ -101,12 +103,12 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents } } - public Task> QueryIdsAsync(DomainId appId, HashSet ids, SearchScope scope, + public Task> QueryIdsAsync(DomainId appId, HashSet ids, SearchScope scope, CancellationToken ct = default) { if (scope == SearchScope.All) { - return collectionFrontend.QueryIdsAsync(appId, ids, ct); + return collectionComplete.QueryIdsAsync(appId, ids, ct); } else { @@ -119,7 +121,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { if (scope == SearchScope.All) { - return collectionFrontend.HasReferrersAsync(appId, contentId, ct); + return collectionComplete.HasReferrersAsync(appId, contentId, ct); } else { @@ -130,19 +132,13 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents public Task ResetScheduledAsync(DomainId documentId, CancellationToken ct = default) { - return collectionFrontend.ResetScheduledAsync(documentId, ct); + return collectionComplete.ResetScheduledAsync(documentId, ct); } - public Task> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode filterNode, + public Task> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode filterNode, CancellationToken ct = default) { - return collectionFrontend.QueryIdsAsync(appId, schemaId, filterNode, ct); - } - - public IEnumerable> GetInternalCollections() - { - yield return collectionFrontend.GetInternalCollection(); - yield return collectionPublished.GetInternalCollection(); + return collectionComplete.QueryIdsAsync(appId, schemaId, filterNode, ct); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs index ad632182f..c6ba30681 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using MongoDB.Driver; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Contents.DomainObject; @@ -20,7 +21,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents IAsyncEnumerable> ISnapshotStore.ReadAllAsync( CancellationToken ct) { - return collectionFrontend.StreamAll(ct) + return collectionComplete.StreamAll(ct) .Select(x => new SnapshotResult(x.DocumentId, x.ToState(), x.Version, true)); } @@ -30,7 +31,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents using (Telemetry.Activities.StartActivity("MongoContentRepository/ReadAsync")) { var existing = - await collectionFrontend.FindAsync(key, ct); + await collectionComplete.FindAsync(key, ct); if (existing?.IsSnapshot == true) { @@ -46,7 +47,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { using (Telemetry.Activities.StartActivity("MongoContentRepository/DeleteAppAsync")) { - await collectionFrontend.DeleteAppAsync(app.Id, ct); + await collectionComplete.DeleteAppAsync(app.Id, ct); await collectionPublished.DeleteAppAsync(app.Id, ct); } } @@ -56,7 +57,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { using (Telemetry.Activities.StartActivity("MongoContentRepository/ClearAsync")) { - await collectionFrontend.ClearAsync(ct); + await collectionComplete.ClearAsync(ct); await collectionPublished.ClearAsync(ct); } } @@ -66,8 +67,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { using (Telemetry.Activities.StartActivity("MongoContentRepository/RemoveAsync")) { - await collectionFrontend.RemoveAsync(key, ct); - await collectionPublished.RemoveAsync(key, ct); + if (key == DomainId.Empty) + { + return; + } + + await Task.WhenAll( + collectionComplete.RemoveAsync(key, ct), + collectionPublished.RemoveAsync(key, ct)); } } @@ -76,7 +83,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { using (Telemetry.Activities.StartActivity("MongoContentRepository/WriteAsync")) { - if (job.Value.SchemaId.Id == DomainId.Empty) + if (!IsValid(job.Value)) { return; } @@ -92,22 +99,34 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { using (Telemetry.Activities.StartActivity("MongoContentRepository/WriteManyAsync")) { - var entitiesPublished = new List(); - var entitiesFrontend = new List(); + var updates = new Dictionary, List>(); + + var add = new Action, MongoContentEntity>((collection, entity) => + { + updates.GetOrAddNew(collection).Add(entity); + }); - foreach (var job in jobs.Where(IsValid)) + foreach (var job in jobs) { - if (ShouldWritePublished(job.Value)) + var isValid = IsValid(job.Value); + + if (isValid && ShouldWritePublished(job.Value)) { - entitiesPublished.Add(await MongoContentEntity.CreatePublishedAsync(job, appProvider)); + await collectionPublished.AddCollectionsAsync( + await MongoContentEntity.CreatePublishedAsync(job, appProvider), add, ct); } - entitiesFrontend.Add(await MongoContentEntity.CreateDraftAsync(job, appProvider)); + if (isValid) + { + await collectionComplete.AddCollectionsAsync( + await MongoContentEntity.CreateAsync(job, appProvider), add, ct); + } } - await Task.WhenAll( - collectionFrontend.InsertManyAsync(entitiesFrontend, ct), - collectionPublished.InsertManyAsync(entitiesPublished, ct)); + await Parallel.ForEachAsync(updates, ct, (update, ct) => + { + return new ValueTask(update.Key.InsertManyAsync(update.Value, InsertUnordered, ct)); + }); } } @@ -120,24 +139,22 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents } else { - await DeletePublishedContentAsync(job.Value.AppId.Id, job.Value.Id, ct); + await DeletePublishedContentAsync(job.Value.UniqueId, ct); } } - private Task DeletePublishedContentAsync(DomainId appId, DomainId id, + private Task DeletePublishedContentAsync(DomainId key, CancellationToken ct = default) { - var documentId = DomainId.Combine(appId, id); - - return collectionPublished.RemoveAsync(documentId, ct); + return collectionPublished.RemoveAsync(key, ct); } private async Task UpsertFrontendAsync(SnapshotWriteJob job, CancellationToken ct = default) { - var entity = await MongoContentEntity.CreateDraftAsync(job, appProvider); + var entity = await MongoContentEntity.CreateAsync(job, appProvider); - await collectionFrontend.UpsertVersionedAsync(entity.DocumentId, job.OldVersion, entity, ct); + await collectionComplete.UpsertVersionedAsync(entity.DocumentId, job.OldVersion, entity, ct); } private async Task UpsertPublishedContentAsync(SnapshotWriteJob job, @@ -154,10 +171,15 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents return value.Status == Status.Published && !value.IsDeleted; } - private static bool IsValid(SnapshotWriteJob job) + private static bool IsValid(ContentDomainObject.State state) { // Some data is corrupt and might throw an exception during migration if we do not skip them. - return job.Value.AppId != null || job.Value.CurrentVersion != null; + return + state.AppId != null && + state.AppId.Id != DomainId.Empty && + state.CurrentVersion != null && + state.SchemaId != null && + state.SchemaId.Id != DomainId.Empty; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Extensions.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Extensions.cs index 055b19d9f..2b8d43e36 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Extensions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Extensions.cs @@ -9,12 +9,15 @@ using MongoDB.Bson.Serialization.Attributes; using MongoDB.Driver; using Squidex.Domain.Apps.Core.Contents; using Squidex.Infrastructure; +using Squidex.Infrastructure.MongoDb.Queries; +using Squidex.Infrastructure.Queries; namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations { public static class Extensions { - public sealed class StatusModel + [BsonIgnoreExtraElements] + public sealed class StatusOnly { [BsonId] [BsonElement("_id")] @@ -33,13 +36,72 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations public Status Status { get; set; } } - public static Task> FindStatusAsync(this IMongoCollection collection, FilterDefinition filter, + [BsonIgnoreExtraElements] + public sealed class IdOnly + { + [BsonId] + [BsonElement("_id")] + public DomainId Id { get; set; } + + public MongoContentEntity[] Joined { get; set; } + } + + public static bool IsSatisfiedByIndex(this ClrQuery query) + { + return + query.Sort is { Count: 2 } && + query.Sort[0].Path.ToString() == "mt" && + query.Sort[0].Order == SortOrder.Descending && + query.Sort[1].Path.ToString() == "id" && + query.Sort[1].Order == SortOrder.Ascending; + } + + public static async Task> QueryContentsAsync(this IMongoCollection collection, + FilterDefinition filter, ClrQuery query, + CancellationToken ct) + { + if (query.Skip > 0 && !query.IsSatisfiedByIndex()) + { + // If we have to skip over items, we could reach the limit of the sort buffer, therefore get the ids and all filter fields only + // in a first iteration and get the actual content in the a second query. + var projection = Builders.Projection.Include("_id"); + + foreach (var field in query.GetAllFields()) + { + projection = projection.Include(field); + } + + var joined = + await collection.Aggregate() + .Match(filter) + .Project(projection) + .QuerySort(query) + .QuerySkip(query) + .QueryLimit(query) + .Lookup(collection, x => x.Id, x => x.DocumentId, x => x.Joined) + .ToListAsync(ct); + + return joined.Select(x => x.Joined[0]).ToList(); + } + + var result = + collection.Find(filter) + .QuerySort(query) + .QueryLimit(query) + .QuerySkip(query) + .ToListAsync(ct); + + return await result; + } + + public static Task> FindStatusAsync(this IMongoCollection collection, + FilterDefinition filter, CancellationToken ct) { var projections = Builders.Projection; return collection.Find(filter) - .Project(projections + .Project(projections .Include(x => x.Id) .Include(x => x.IndexedSchemaId) .Include(x => x.Status)) diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/OperationBase.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/OperationBase.cs index 7c5321a40..d1e366e7c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/OperationBase.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/OperationBase.cs @@ -6,17 +6,12 @@ // ========================================================================== using MongoDB.Driver; +using Squidex.Infrastructure.MongoDb; namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations { - public abstract class OperationBase + public abstract class OperationBase : MongoBase { - protected static readonly SortDefinitionBuilder Sort = Builders.Sort; - protected static readonly UpdateDefinitionBuilder Update = Builders.Update; - protected static readonly FilterDefinitionBuilder Filter = Builders.Filter; - protected static readonly IndexKeysDefinitionBuilder Index = Builders.IndexKeys; - protected static readonly ProjectionDefinitionBuilder Projection = Builders.Projection; - public IMongoCollection Collection { get; private set; } public void Setup(IMongoCollection collection) diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs index 15c5c6f0f..f3a4cf0ec 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs @@ -7,6 +7,7 @@ using System.Runtime.CompilerServices; using MongoDB.Driver; +using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Contents; using Squidex.Infrastructure; @@ -25,12 +26,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations public async IAsyncEnumerable StreamAll(DomainId appId, HashSet? schemaIds, [EnumeratorCancellation] CancellationToken ct) { - var find = - schemaIds != null ? - Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted && schemaIds.Contains(x.IndexedSchemaId)) : - Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted); + var filter = CreateFilter(appId, schemaIds); - using (var cursor = await find.ToCursorAsync(ct)) + using (var cursor = await Collection.Find(filter).ToCursorAsync(ct)) { while (await cursor.MoveNextAsync(ct)) { @@ -41,5 +39,22 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations } } } + + private static FilterDefinition CreateFilter(DomainId appId, HashSet? schemaIds) + { + var filters = new List> + { + Filter.Gt(x => x.LastModified, default), + Filter.Gt(x => x.Id, default), + Filter.Eq(x => x.IndexedAppId, appId) + }; + + if (schemaIds != null) + { + filters.Add(Filter.In(x => x.IndexedSchemaId, schemaIds)); + } + + return Filter.And(filters); + } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryById.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryById.cs index 9d02698ad..96dfc5aba 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryById.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryById.cs @@ -17,20 +17,13 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations public async Task QueryAsync(ISchemaEntity schema, DomainId id, CancellationToken ct) { - Guard.NotNull(schema); + var filter = Filter.Eq(x => x.DocumentId, DomainId.Combine(schema.AppId, id)); - var documentId = DomainId.Combine(schema.AppId, id); + var contentEntity = await Collection.Find(filter).FirstOrDefaultAsync(ct); - var find = Collection.Find(x => x.DocumentId == documentId); - - var contentEntity = await find.FirstOrDefaultAsync(ct); - - if (contentEntity != null) + if (contentEntity == null || contentEntity.IndexedSchemaId != schema.Id) { - if (contentEntity.IndexedSchemaId != schema.Id) - { - return null; - } + return null; } return contentEntity; diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByIds.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByIds.cs index 2d17f8c88..9af0a212e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByIds.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByIds.cs @@ -7,9 +7,11 @@ 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.Schemas; using Squidex.Infrastructure; +using Squidex.Infrastructure.Collections; using Squidex.Infrastructure.MongoDb.Queries; using Squidex.Infrastructure.Queries; @@ -17,32 +19,30 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations { internal sealed class QueryByIds : OperationBase { - public async Task> QueryIdsAsync(DomainId appId, HashSet ids, + public async Task> QueryIdsAsync(DomainId appId, HashSet ids, CancellationToken ct) { if (ids == null || ids.Count == 0) { - return new List<(DomainId SchemaId, DomainId Id, Status Status)>(); + return ReadonlyList.Empty(); } var filter = CreateFilter(appId, null, ids); - var contentItems = await Collection.FindStatusAsync(filter, ct); + var contentEntities = await Collection.FindStatusAsync(filter, ct); - return contentItems.Select(x => (x.IndexedSchemaId, x.Id, x.Status)).ToList(); + return contentEntities.Select(x => new ContentIdStatus(x.IndexedSchemaId, x.Id, x.Status)).ToList(); } - public async Task> QueryAsync(DomainId appId, List schemas, Q q, + public async Task> QueryAsync(IAppEntity app, List schemas, Q q, CancellationToken ct) { - Guard.NotNull(q); - if (q.Ids == null || q.Ids.Count == 0) { - return ResultList.CreateFrom(0); + return ResultList.Empty(); } - var filter = CreateFilter(appId, schemas.Select(x => x.Id), q.Ids.ToHashSet()); + var filter = CreateFilter(app.Id, schemas.Select(x => x.Id), q.Ids.ToHashSet()); var contentEntities = await FindContentsAsync(q.Query, filter); var contentTotal = (long)contentEntities.Count; @@ -87,11 +87,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations } else { - var first = documentIds[0]; - filters.Add( Filter.Or( - Filter.Eq(x => x.DocumentId, first))); + Filter.Eq(x => x.DocumentId, documentIds[0]))); } if (schemaIds != null) diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs index 94f1aab9a..d7100a455 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using MongoDB.Bson.Serialization.Attributes; using MongoDB.Driver; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Apps; @@ -14,28 +13,15 @@ using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.MongoDb.Queries; using Squidex.Infrastructure.Queries; -using Squidex.Infrastructure.Translations; namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations { internal sealed class QueryByQuery : OperationBase { - private readonly IAppProvider appProvider; private readonly MongoCountCollection countCollection; - [BsonIgnoreExtraElements] - internal sealed class IdOnly + public QueryByQuery(MongoCountCollection countCollection) { - [BsonId] - [BsonElement("_id")] - public DomainId Id { get; set; } - - public MongoContentEntity[] Joined { get; set; } - } - - public QueryByQuery(IAppProvider appProvider, MongoCountCollection countCollection) - { - this.appProvider = appProvider; this.countCollection = countCollection; } @@ -55,171 +41,92 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations .Descending(x => x.LastModified)); } - public async Task> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode filterNode, + public async Task> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode filterNode, CancellationToken ct) { - Guard.NotNull(filterNode); + // We need to translate the query names to the document field names in MongoDB. + var adjustedFilter = filterNode.AdjustToModel(appId); - try - { - var schema = await appProvider.GetSchemaAsync(appId, schemaId, ct: ct); + var filter = BuildFilter(appId, schemaId, adjustedFilter); - if (schema == null) - { - return new List<(DomainId SchemaId, DomainId Id, Status Status)>(); - } + var contentEntities = await Collection.FindStatusAsync(filter, ct); + var contentResults = contentEntities.Select(x => new ContentIdStatus(x.IndexedSchemaId, x.Id, x.Status)).ToList(); - var filter = BuildFilter(appId, schemaId, filterNode.AdjustToModel(appId)); - - var contentItems = await Collection.FindStatusAsync(filter, ct); - - return contentItems.Select(x => (x.IndexedSchemaId, x.Id, x.Status)).ToList(); - } - catch (MongoCommandException ex) when (ex.Code == 96) - { - throw new DomainException(T.Get("common.resultTooLarge")); - } - catch (MongoQueryException ex) when (ex.Message.Contains("17406", StringComparison.Ordinal)) - { - throw new DomainException(T.Get("common.resultTooLarge")); - } + return contentResults; } public async Task> QueryAsync(IAppEntity app, List schemas, Q q, CancellationToken ct) { - Guard.NotNull(app); - Guard.NotNull(q); - - try - { - var query = q.Query.AdjustToModel(app.Id); + // We need to translate the query names to the document field names in MongoDB. + var query = q.Query.AdjustToModel(app.Id); - var (filter, isDefault) = CreateFilter(app.Id, schemas.Select(x => x.Id), query, q.Reference, q.CreatedBy); + var (filter, isDefault) = CreateFilter(app.Id, schemas.Select(x => x.Id), query, q.Reference, q.CreatedBy); - var contentEntities = await FindContentsAsync(query, filter, ct); - var contentTotal = (long)contentEntities.Count; + var contentEntities = await Collection.QueryContentsAsync(filter, query, ct); + var contentTotal = (long)contentEntities.Count; - if (contentTotal >= q.Query.Take || q.Query.Skip > 0) + if (contentTotal >= q.Query.Take || q.Query.Skip > 0) + { + if (q.NoTotal || (q.NoSlowTotal && q.Query.Filter != null)) { - if (q.NoTotal || (q.NoSlowTotal && q.Query.Filter != null)) - { - contentTotal = -1; - } - else if (IsSatisfiedByIndex(query)) - { - contentTotal = await Collection.Find(filter).QuerySort(query).CountDocumentsAsync(ct); - } - else - { - contentTotal = await Collection.Find(filter).CountDocumentsAsync(ct); - } + contentTotal = -1; + } + else if (query.IsSatisfiedByIndex()) + { + // It is faster to filter with sorting when there is an index, because it forces the index to be used. + contentTotal = await Collection.Find(filter).QuerySort(query).CountDocumentsAsync(ct); + } + else + { + contentTotal = await Collection.Find(filter).CountDocumentsAsync(ct); } - - return ResultList.Create(contentTotal, contentEntities); - } - catch (MongoCommandException ex) when (ex.Code == 96) - { - throw new DomainException(T.Get("common.resultTooLarge")); - } - catch (MongoQueryException ex) when (ex.Message.Contains("17406", StringComparison.Ordinal)) - { - throw new DomainException(T.Get("common.resultTooLarge")); } + + return ResultList.Create(contentTotal, contentEntities); } - public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Q q, + public async Task> QueryAsync(ISchemaEntity schema, Q q, CancellationToken ct) { - Guard.NotNull(app); - Guard.NotNull(schema); - Guard.NotNull(q); - - try - { - var query = q.Query.AdjustToModel(app.Id); + // We need to translate the query names to the document field names in MongoDB. + var query = q.Query.AdjustToModel(schema.AppId.Id); - var (filter, isDefault) = CreateFilter(schema.AppId.Id, Enumerable.Repeat(schema.Id, 1), query, q.Reference, q.CreatedBy); + // Default means that no other filters are applied and we only query by app and schema. + var (filter, isDefault) = CreateFilter(schema.AppId.Id, Enumerable.Repeat(schema.Id, 1), query, q.Reference, q.CreatedBy); - var contentEntities = await FindContentsAsync(query, filter, ct); - var contentTotal = (long)contentEntities.Count; + var contentEntities = await Collection.QueryContentsAsync(filter, query, ct); + var contentTotal = (long)contentEntities.Count; - if (contentTotal >= q.Query.Take || q.Query.Skip > 0) + if (contentTotal >= q.Query.Take || q.Query.Skip > 0) + { + if (q.NoTotal || (q.NoSlowTotal && q.Query.Filter != null)) { - if (q.NoTotal || (q.NoSlowTotal && q.Query.Filter != null)) - { - contentTotal = -1; - } - else if (isDefault) - { - // Cache total count by app and schema. - var totalKey = $"{app.Id}_{schema.Id}"; - - contentTotal = await countCollection.GetOrAddAsync(totalKey, ct => Collection.Find(filter).CountDocumentsAsync(ct), ct); - } - else if (IsSatisfiedByIndex(query)) - { - contentTotal = await Collection.Find(filter).QuerySort(query).CountDocumentsAsync(ct); - } - else - { - contentTotal = await Collection.Find(filter).CountDocumentsAsync(ct); - } + contentTotal = -1; } + else if (isDefault) + { + // Cache total count by app and schema because no other filters are applied (aka default). + var totalKey = $"{schema.AppId.Id}_{schema.Id}"; - return ResultList.Create(contentTotal, contentEntities); - } - catch (MongoCommandException ex) when (ex.Code == 96) - { - throw new DomainException(T.Get("common.resultTooLarge")); - } - catch (MongoQueryException ex) when (ex.Message.Contains("17406", StringComparison.Ordinal)) - { - throw new DomainException(T.Get("common.resultTooLarge")); - } - } - - private async Task> FindContentsAsync(ClrQuery query, FilterDefinition filter, - CancellationToken ct) - { - if (query.Skip > 0 && !IsSatisfiedByIndex(query)) - { - var projection = Projection.Include("_id"); - - foreach (var field in query.GetAllFields()) + contentTotal = await countCollection.GetOrAddAsync(totalKey, ct => Collection.Find(filter).CountDocumentsAsync(ct), ct); + } + else if (query.IsSatisfiedByIndex()) { - projection = projection.Include(field); + // It is faster to filter with sorting when there is an index, because it forces the index to be used. + contentTotal = await Collection.Find(filter).QuerySort(query).CountDocumentsAsync(ct); + } + else + { + contentTotal = await Collection.Find(filter).CountDocumentsAsync(ct); } - - var joined = - await Collection.Aggregate() - .Match(filter) - .Project(projection) - .QuerySort(query) - .QuerySkip(query) - .QueryLimit(query) - .Lookup(Collection, x => x.Id, x => x.DocumentId, x => x.Joined) - .ToListAsync(ct); - - return joined.Select(x => x.Joined[0]).ToList(); } - var result = - Collection.Find(filter) - .QuerySort(query) - .QueryLimit(query) - .QuerySkip(query) - .ToListAsync(ct); - - return await result; - } - - private static bool IsSatisfiedByIndex(ClrQuery query) - { - return query.Sort is { Count: 2 } && query.Sort[0].Path.ToString() == "mt" && query.Sort[0].Order == SortOrder.Descending && query.Sort[1].Path.ToString() == "id" && query.Sort[1].Order == SortOrder.Ascending; + return ResultList.Create(contentTotal, contentEntities); } - private static FilterDefinition BuildFilter(DomainId appId, DomainId schemaId, FilterNode? filter) + private static FilterDefinition BuildFilter(DomainId appId, DomainId schemaId, + FilterNode? filter) { var filters = new List> { @@ -242,7 +149,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations return Filter.And(filters); } - private static (FilterDefinition, bool) CreateFilter(DomainId appId, IEnumerable schemaIds, ClrQuery? query, + private static (FilterDefinition, bool) CreateFilter(DomainId appId, IEnumerable schemaIds, ClrQuery? query, DomainId referenced, RefToken? createdBy) { var filters = new List> diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryInDedicatedCollection.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryInDedicatedCollection.cs new file mode 100644 index 000000000..998b110b2 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryInDedicatedCollection.cs @@ -0,0 +1,182 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Concurrent; +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.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.MongoDb; +using Squidex.Infrastructure.MongoDb.Queries; +using Squidex.Infrastructure.Queries; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations +{ + internal sealed class QueryInDedicatedCollection : MongoBase + { + private readonly ConcurrentDictionary<(DomainId, DomainId), Task>> collections = + new ConcurrentDictionary<(DomainId, DomainId), Task>>(); + + private readonly IMongoClient mongoClient; + private readonly string prefixDatabase; + private readonly string prefixCollection; + + public QueryInDedicatedCollection(IMongoClient mongoClient, string prefixDatabase, string prefixCollection) + { + this.mongoClient = mongoClient; + this.prefixDatabase = prefixDatabase; + this.prefixCollection = prefixCollection; + } + + public Task> GetCollectionAsync(DomainId appId, DomainId schemaId) + { +#pragma warning disable MA0106 // Avoid closure by using an overload with the 'factoryArgument' parameter + return collections.GetOrAdd((appId, schemaId), async key => + { + var (appId, schemaId) = key; + + var schemaDatabase = mongoClient.GetDatabase($"{prefixDatabase}_{appId}"); + var schemaCollection = schemaDatabase.GetCollection($"{prefixCollection}_{schemaId}"); + + await schemaCollection.Indexes.CreateManyAsync( + new[] + { + new CreateIndexModel(Index + .Descending(x => x.LastModified) + .Ascending(x => x.Id) + .Ascending(x => x.IsDeleted) + .Ascending(x => x.ReferencedIds)), + new CreateIndexModel(Index + .Ascending(x => x.IndexedSchemaId) + .Ascending(x => x.IsDeleted) + .Descending(x => x.LastModified)) + }); + + return schemaCollection; + }); +#pragma warning restore MA0106 // Avoid closure by using an overload with the 'factoryArgument' parameter + } + + public async Task> QueryIdsAsync(IAppEntity app, ISchemaEntity schema, FilterNode filterNode, + CancellationToken ct) + { + // We need to translate the filter names to the document field names in MongoDB. + var adjustedFilter = filterNode.AdjustToModel(app.Id); + + var filter = BuildFilter(adjustedFilter); + + var contentCollection = await GetCollectionAsync(schema.AppId.Id, schema.Id); + var contentEntities = await contentCollection.FindStatusAsync(filter, ct); + var contentResults = contentEntities.Select(x => new ContentIdStatus(x.IndexedSchemaId, x.Id, x.Status)).ToList(); + + return contentResults; + } + + public async Task> QueryAsync(ISchemaEntity schema, Q q, + CancellationToken ct) + { + // We need to translate the query names to the document field names in MongoDB. + var query = q.Query.AdjustToModel(schema.AppId.Id); + + var filter = CreateFilter(query, q.Reference, q.CreatedBy); + + var contentCollection = await GetCollectionAsync(schema.AppId.Id, schema.Id); + var contentEntities = await contentCollection.QueryContentsAsync(filter, query, ct); + var contentTotal = (long)contentEntities.Count; + + if (contentTotal >= q.Query.Take || q.Query.Skip > 0) + { + if (q.NoTotal || (q.NoSlowTotal && q.Query.Filter != null)) + { + contentTotal = -1; + } + else if (query.IsSatisfiedByIndex()) + { + // It is faster to filter with sorting when there is an index, because it forces the index to be used. + contentTotal = await contentCollection.Find(filter).QuerySort(query).CountDocumentsAsync(ct); + } + else + { + contentTotal = await contentCollection.Find(filter).CountDocumentsAsync(ct); + } + } + + return ResultList.Create(contentTotal, contentEntities); + } + + public async Task UpsertVersionedAsync(DomainId documentId, long oldVersion, MongoContentEntity value, + CancellationToken ct = default) + { + var collection = await GetCollectionAsync(value.AppId.Id, value.SchemaId.Id); + + await collection.UpsertVersionedAsync(documentId, oldVersion, value.Version, value, ct); + } + + public async Task RemoveAsync(MongoContentEntity value, + CancellationToken ct = default) + { + var collection = await GetCollectionAsync(value.AppId.Id, value.SchemaId.Id); + + await collection.DeleteOneAsync(x => x.DocumentId == value.DocumentId, null, ct); + } + + private static FilterDefinition BuildFilter(FilterNode? filter) + { + var filters = new List> + { + Filter.Exists(x => x.LastModified), + Filter.Exists(x => x.Id) + }; + + if (filter?.HasField("dl") != true) + { + filters.Add(Filter.Ne(x => x.IsDeleted, true)); + } + + if (filter != null) + { + filters.Add(filter.BuildFilter()); + } + + return Filter.And(filters); + } + + private static FilterDefinition CreateFilter(ClrQuery? query, + DomainId referenced, RefToken? createdBy) + { + var filters = new List> + { + Filter.Gt(x => x.LastModified, default), + Filter.Gt(x => x.Id, default) + }; + + if (query?.HasFilterField("dl") != true) + { + filters.Add(Filter.Ne(x => x.IsDeleted, true)); + } + + if (query?.Filter != null) + { + filters.Add(query.Filter.BuildFilter()); + } + + if (referenced != default) + { + filters.Add(Filter.AnyEq(x => x.ReferencedIds, referenced)); + } + + if (createdBy != null) + { + filters.Add(Filter.Eq(x => x.CreatedBy, createdBy)); + } + + return Filter.And(filters); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryReferences.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryReferences.cs index 825f72475..1973b0054 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryReferences.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryReferences.cs @@ -7,6 +7,7 @@ using MongoDB.Bson.Serialization.Attributes; using MongoDB.Driver; +using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; @@ -15,7 +16,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations { internal sealed class QueryReferences : OperationBase { - private static readonly IResultList EmptyIds = ResultList.CreateFrom(0); private readonly QueryByIds queryByIds; public sealed class ReferencedIdsOnly @@ -34,14 +34,12 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations this.queryByIds = queryByIds; } - public async Task> QueryAsync(DomainId appId, List schemas, Q q, + public async Task> QueryAsync(IAppEntity app, List schemas, Q q, CancellationToken ct) { - var documentId = DomainId.Combine(appId, q.Referencing); - var find = Collection - .Find(x => x.DocumentId == documentId) + .Find(Filter.Eq(x => x.DocumentId, DomainId.Combine(app.Id, q.Referencing))) .Project(Projection.Include(x => x.ReferencedIds)); var contentEntity = await find.FirstOrDefaultAsync(ct); @@ -53,12 +51,12 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations if (contentEntity.ReferencedIds == null || contentEntity.ReferencedIds.Count == 0) { - return EmptyIds; + return ResultList.Empty(); } q = q.WithReferencing(default).WithIds(contentEntity.ReferencedIds!); - return await queryByIds.QueryAsync(appId, schemas, q, ct); + return await queryByIds.QueryAsync(app, schemas, q, ct); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryScheduled.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryScheduled.cs index bf57c1b2b..ab764edc8 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryScheduled.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryScheduled.cs @@ -7,11 +7,14 @@ using MongoDB.Driver; using NodaTime; +using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.MongoDb; +#pragma warning disable MA0073 // Avoid comparison with bool constant + namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations { internal sealed class QueryScheduled : OperationBase @@ -25,17 +28,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations .Ascending(x => x.IndexedSchemaId)); } - public async Task> QueryAsync(DomainId appId, List schemas, Q q, + public async Task> QueryAsync(IAppEntity app, List schemas, Q q, CancellationToken ct) { - Guard.NotNull(q); - - if (q.ScheduledFrom == null || q.ScheduledTo == null) - { - return ResultList.CreateFrom(0); - } - - var filter = CreateFilter(appId, schemas.Select(x => x.Id), q.ScheduledFrom.Value, q.ScheduledTo.Value); + var filter = CreateFilter(app.Id, schemas.Select(x => x.Id), q.ScheduledFrom!.Value, q.ScheduledTo!.Value); var contentEntities = await Collection.Find(filter).Limit(100).ToListAsync(ct); var contentTotal = (long)contentEntities.Count; @@ -46,12 +42,15 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations public IAsyncEnumerable QueryAsync(Instant now, CancellationToken ct) { -#pragma warning disable MA0073 // Avoid comparison with bool constant - return Collection.Find(x => x.ScheduledAt < now && x.IsDeleted != true).Not(x => x.Data).ToAsyncEnumerable(ct); -#pragma warning restore MA0073 // Avoid comparison with bool constant + var find = Collection.Find(x => x.ScheduledAt < now && x.IsDeleted != true).Not(x => x.Data); + + return find.ToAsyncEnumerable(ct); } - private static FilterDefinition CreateFilter(DomainId appId, IEnumerable schemaIds, Instant scheduledFrom, Instant scheduledTo) + private static FilterDefinition CreateFilter(DomainId appId, + IEnumerable schemaIds, + Instant scheduledFrom, + Instant scheduledTo) { return Filter.And( Filter.Gte(x => x.ScheduledAt, scheduledFrom), diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs index 8479e502e..4a44fd6ed 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs @@ -13,7 +13,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries { public sealed class AssetQueryService : IAssetQueryService { - private static readonly IResultList EmptyAssets = ResultList.CreateFrom(0); private readonly IAssetEnricher assetEnricher; private readonly IAssetRepository assetRepository; private readonly IAssetLoader assetLoader; @@ -173,7 +172,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries if (q == null) { - return EmptyAssets; + return ResultList.Empty(); } using (Telemetry.Activities.StartActivity("AssetQueryService/QueryAsync")) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOptions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOptions.cs index 581f5f74b..ac93f714c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOptions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOptions.cs @@ -11,7 +11,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { public bool CanCache { get; set; } - public bool OptimizeTotal { get; set; } = true; + public bool OptimizeForSelfHosting { get; set; } public int DefaultPageSize { get; set; } = 200; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs index 0d1efae5d..0bf917144 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs @@ -18,7 +18,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries public sealed class ContentQueryService : IContentQueryService { private const string SingletonId = "_schemaId_"; - private static readonly IResultList EmptyContents = ResultList.CreateFrom(0); private readonly IAppProvider appProvider; private readonly IContentEnricher contentEnricher; private readonly IContentRepository contentRepository; @@ -85,7 +84,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { if (q == null) { - return EmptyContents; + return ResultList.Empty(); } var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName, ct); @@ -117,14 +116,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { if (q == null) { - return EmptyContents; + return ResultList.Empty(); } var schemas = await GetSchemasAsync(context, ct); if (schemas.Count == 0) { - return EmptyContents; + return ResultList.Empty(); } q = await queryParser.ParseAsync(context, q); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs index 4ae7418a9..d2b5dde5d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs @@ -25,10 +25,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.Repositories Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Q q, SearchScope scope, CancellationToken ct = default); - Task> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode filterNode, + Task> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode filterNode, CancellationToken ct = default); - Task> QueryIdsAsync(DomainId appId, HashSet ids, SearchScope scope, + Task> QueryIdsAsync(DomainId appId, HashSet ids, SearchScope scope, CancellationToken ct = default); Task FindContentAsync(IAppEntity app, ISchemaEntity schema, DomainId id, SearchScope scope, diff --git a/backend/src/Squidex.Domain.Users/DefaultUserService.cs b/backend/src/Squidex.Domain.Users/DefaultUserService.cs index a7c93956e..6dafaa94c 100644 --- a/backend/src/Squidex.Domain.Users/DefaultUserService.cs +++ b/backend/src/Squidex.Domain.Users/DefaultUserService.cs @@ -58,7 +58,7 @@ namespace Squidex.Domain.Users if (!ids.Any()) { - return ResultList.CreateFrom(0); + return ResultList.Empty(); } var users = userManager.Users.Where(x => ids.Contains(x.Id)).ToList(); diff --git a/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoBase.cs b/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoBase.cs new file mode 100644 index 000000000..c85845b02 --- /dev/null +++ b/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoBase.cs @@ -0,0 +1,55 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using MongoDB.Driver; + +#pragma warning disable RECS0108 // Warns about static fields in generic types + +namespace Squidex.Infrastructure.MongoDb +{ + public abstract class MongoBase + { + protected static readonly FieldDefinitionBuilder FieldBuilder = + FieldDefinitionBuilder.Instance; + + protected static readonly FilterDefinitionBuilder Filter = + Builders.Filter; + + protected static readonly IndexKeysDefinitionBuilder Index = + Builders.IndexKeys; + + protected static readonly ProjectionDefinitionBuilder Projection = + Builders.Projection; + + protected static readonly SortDefinitionBuilder Sort = + Builders.Sort; + + protected static readonly UpdateDefinitionBuilder Update = + Builders.Update; + + protected static readonly BulkWriteOptions BulkUnordered = + new BulkWriteOptions { IsOrdered = true }; + + protected static readonly InsertManyOptions InsertUnordered = + new InsertManyOptions { IsOrdered = true }; + + protected static readonly ReplaceOptions UpsertReplace = + new ReplaceOptions { IsUpsert = true }; + + protected static readonly UpdateOptions Upsert = + new UpdateOptions { IsUpsert = true }; + + static MongoBase() + { + TypeConverterStringSerializer.Register(); + + InstantSerializer.Register(); + + DomainIdSerializer.Register(); + } + } +} diff --git a/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoRepositoryBase.cs b/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoRepositoryBase.cs index b0e7f6b7e..4fb7552bb 100644 --- a/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoRepositoryBase.cs +++ b/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoRepositoryBase.cs @@ -5,30 +5,14 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Globalization; using MongoDB.Driver; using Squidex.Hosting; using Squidex.Hosting.Configuration; -#pragma warning disable RECS0108 // Warns about static fields in generic types - namespace Squidex.Infrastructure.MongoDb { - public abstract class MongoRepositoryBase : IInitializable + public abstract class MongoRepositoryBase : MongoBase, IInitializable { - private const string CollectionFormat = "{0}Set"; - - protected static readonly BulkWriteOptions BulkUnordered = new BulkWriteOptions { IsOrdered = true }; - protected static readonly FieldDefinitionBuilder FieldBuilder = FieldDefinitionBuilder.Instance; - protected static readonly FilterDefinitionBuilder Filter = Builders.Filter; - protected static readonly IndexKeysDefinitionBuilder Index = Builders.IndexKeys; - protected static readonly InsertManyOptions InsertUnordered = new InsertManyOptions { IsOrdered = true }; - protected static readonly ProjectionDefinitionBuilder Projection = Builders.Projection; - protected static readonly ReplaceOptions UpsertReplace = new ReplaceOptions { IsUpsert = true }; - protected static readonly SortDefinitionBuilder Sort = Builders.Sort; - protected static readonly UpdateDefinitionBuilder Update = Builders.Update; - protected static readonly UpdateOptions Upsert = new UpdateOptions { IsUpsert = true }; - private readonly IMongoDatabase mongoDatabase; private IMongoCollection mongoCollection; @@ -51,15 +35,6 @@ namespace Squidex.Infrastructure.MongoDb get => mongoDatabase; } - static MongoRepositoryBase() - { - TypeConverterStringSerializer.Register(); - - InstantSerializer.Register(); - - DomainIdSerializer.Register(); - } - protected MongoRepositoryBase(IMongoDatabase database, bool setup = false) { Guard.NotNull(database); @@ -77,10 +52,7 @@ namespace Squidex.Infrastructure.MongoDb return new MongoCollectionSettings(); } - protected virtual string CollectionName() - { - return string.Format(CultureInfo.InvariantCulture, CollectionFormat, typeof(TEntity).Name); - } + protected abstract string CollectionName(); protected virtual Task SetupCollectionAsync(IMongoCollection collection, CancellationToken ct) diff --git a/backend/src/Squidex.Infrastructure/ResultList.cs b/backend/src/Squidex.Infrastructure/ResultList.cs index 2585d9fe4..2db025857 100644 --- a/backend/src/Squidex.Infrastructure/ResultList.cs +++ b/backend/src/Squidex.Infrastructure/ResultList.cs @@ -5,29 +5,48 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Squidex.Infrastructure.Collections; + namespace Squidex.Infrastructure { public static class ResultList { - private sealed class Impl : List, IResultList + private sealed class Impl : ReadonlyList, IResultList { public long Total { get; } - public Impl(IEnumerable items, long total) + public Impl(List items, long total) : base(items) { Total = total; } } - public static IResultList Create(long total, IEnumerable items) + private static class Empties + { +#pragma warning disable SA1401 // Fields should be private + public static Impl Instance = new Impl(new List(), 0); +#pragma warning restore SA1401 // Fields should be private + } + + public static IResultList Empty() + { + return Empties.Instance; + } + + public static IResultList Create(long total, List items) { return new Impl(items, total); } + public static IResultList Create(long total, IEnumerable items) + { + return new Impl(items.ToList(), total); + } + public static IResultList CreateFrom(long total, params T[] items) { - return new Impl(items, total); + return new Impl(items.ToList(), total); } } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs index f5b51f6b8..5515df1df 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs @@ -187,7 +187,7 @@ namespace Squidex.Areas.Api.Controllers.Assets { if (scope == AssetFolderScope.Path) { - return Task.FromResult(ResultList.CreateFrom(0)); + return Task.FromResult(ResultList.Empty()); } return assetQuery.QueryAssetFoldersAsync(Context, parentId, HttpContext.RequestAborted); diff --git a/backend/src/Squidex/appsettings.json b/backend/src/Squidex/appsettings.json index edc1e214d..39a74d2e4 100644 --- a/backend/src/Squidex/appsettings.json +++ b/backend/src/Squidex/appsettings.json @@ -229,7 +229,14 @@ // This is only supported in GraphQL with the @cache(duration: 1000) directive. "canCache": true, + // True to enable an optimization for self hosting. + // + // Creates one database per app and one collection per schema. Slows down inserts, but you can create custom indexes. + "optimizeForSelfHosting": false, + // The default page size if not specified by a query. + // + // Warning: Can slow down queries if increased. "defaultPageSize": 200, // The maximum number of items to return for each query. @@ -251,6 +258,8 @@ "canCache": true, // The default page size if not specified by a query. + // + // Warning: Can slow down queries if increased. "defaultPageSize": 200, // The maximum number of items to return for each query. diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ReferencesFieldTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ReferencesFieldTests.cs index e4bd0c182..7bebcbc78 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ReferencesFieldTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ReferencesFieldTests.cs @@ -40,9 +40,9 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent { yield return new ReferencesValidator(references.Properties.IsRequired, references.Properties, ids => { - var result = ids.Select(x => (schemaId, x, Status.Published)).ToList(); + var result = ids.Select(x => new ContentIdStatus(schemaId, x, Status.Published)).ToList(); - return Task.FromResult>(result); + return Task.FromResult>(result); }); } } diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/ReferencesValidatorTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/ReferencesValidatorTests.cs index 929afddf6..1229a7585 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/ReferencesValidatorTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/ReferencesValidatorTests.cs @@ -219,9 +219,9 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent.Validators { return x => { - var result = references.Select(x => (schemaId, x.Id, x.Status)).ToList(); + var result = references.Select(x => new ContentIdStatus(schemaId, x.Id, x.Status)).ToList(); - return Task.FromResult>(result); + return Task.FromResult>(result); }; } } diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/UniqueValidatorTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/UniqueValidatorTests.cs index 8af103677..db8fd1f9e 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/UniqueValidatorTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/UniqueValidatorTests.cs @@ -101,12 +101,12 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent.Validators { filter?.Invoke(filterNode.ToString()); - var foundIds = new List<(DomainId, DomainId, Status)> + var foundIds = new List { - (id, id, Status.Draft) + new ContentIdStatus(id, id, Status.Draft) }; - return Task.FromResult>(foundIds); + return Task.FromResult>(foundIds); }; } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryFixture.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryFixture.cs index 0110c5c0d..1dfedd20c 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryFixture.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryFixture.cs @@ -9,17 +9,21 @@ using System.Globalization; using MongoDB.Bson; using MongoDB.Driver; using Newtonsoft.Json; +using NodaTime; using Squidex.Domain.Apps.Core.Assets; using Squidex.Domain.Apps.Core.TestHelpers; +using Squidex.Domain.Apps.Entities.Assets.DomainObject; using Squidex.Domain.Apps.Entities.MongoDb.Assets; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure; using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.MongoDb; +using Squidex.Infrastructure.States; +using Xunit; namespace Squidex.Domain.Apps.Entities.Assets.MongoDb { - public sealed class AssetsQueryFixture + public sealed class AssetsQueryFixture : IAsyncLifetime { private readonly Random random = new Random(); private readonly int numValues = 250; @@ -41,78 +45,97 @@ namespace Squidex.Domain.Apps.Entities.Assets.MongoDb mongoClient = new MongoClient(TestConfig.Configuration["mongodb:configuration"]); mongoDatabase = mongoClient.GetDatabase(TestConfig.Configuration["mongodb:database"]); - var assetRepository = new MongoAssetRepository(mongoDatabase); + AssetRepository = new MongoAssetRepository(mongoDatabase); + } - Task.Run(async () => - { - await assetRepository.InitializeAsync(default); + public Task DisposeAsync() + { + return Task.CompletedTask; + } - await mongoDatabase.RunCommandAsync("{ profile : 0 }"); - await mongoDatabase.DropCollectionAsync("system.profile"); + public async Task InitializeAsync() + { + await AssetRepository.InitializeAsync(default); + + await CreateDataAsync(default); + await ClearProfileAsync(default); + } - var collection = assetRepository.GetInternalCollection(); + private async Task CreateDataAsync( + CancellationToken ct) + { + if (await AssetRepository.StreamAll(AppIds[0].Id, ct).AnyAsync(ct)) + { + return; + } - var assetCount = await collection.Find(new BsonDocument()).CountDocumentsAsync(); + var batch = new List>(); - if (assetCount == 0) + async Task ExecuteBatchAsync(AssetDomainObject.State? entity) + { + if (entity != null) { - var batch = new List(); + batch.Add(new SnapshotWriteJob(entity.UniqueId, entity, 0)); + } - async Task ExecuteBatchAsync(MongoAssetEntity? entity) - { - if (entity != null) - { - batch.Add(entity); - } + if ((entity == null || batch.Count >= 1000) && batch.Count > 0) + { + var store = (ISnapshotStore)AssetRepository; - if ((entity == null || batch.Count >= 1000) && batch.Count > 0) - { - await collection.InsertManyAsync(batch); + await store.WriteManyAsync(batch, ct); - batch.Clear(); - } - } + batch.Clear(); + } + } + + var now = SystemClock.Instance.GetCurrentInstant(); + + var user = new RefToken(RefTokenType.Subject, "1"); + + foreach (var appId in AppIds) + { + for (var i = 0; i < numValues; i++) + { + var fileName = i.ToString(CultureInfo.InvariantCulture); - foreach (var appId in AppIds) + for (var j = 0; j < numValues; j++) { - for (var i = 0; i < numValues; i++) - { - var fileName = i.ToString(CultureInfo.InvariantCulture); + var tag = j.ToString(CultureInfo.InvariantCulture); - for (var j = 0; j < numValues; j++) + var asset = new AssetDomainObject.State + { + Tags = new HashSet { tag }, + Id = DomainId.NewGuid(), + Created = now, + CreatedBy = user, + FileHash = fileName, + FileName = fileName, + FileSize = 1024, + LastModified = now, + LastModifiedBy = user, + IsDeleted = false, + IsProtected = false, + Metadata = new AssetMetadata { - var tag = j.ToString(CultureInfo.InvariantCulture); - - var asset = new MongoAssetEntity - { - DocumentId = DomainId.NewGuid(), - Tags = new HashSet { tag }, - Id = DomainId.NewGuid(), - FileHash = fileName, - FileName = fileName, - FileSize = 1024, - IndexedAppId = appId.Id, - IsDeleted = false, - IsProtected = false, - Metadata = new AssetMetadata - { - ["value"] = JsonValue.Create(tag) - }, - Slug = fileName - }; - - await ExecuteBatchAsync(asset); - } - } - } + ["value"] = JsonValue.Create(tag) + }, + Slug = fileName + }; - await ExecuteBatchAsync(null); + await ExecuteBatchAsync(asset); + } } + } - await mongoDatabase.RunCommandAsync("{ profile : 2 }"); - }).Wait(); + await ExecuteBatchAsync(null); + } - AssetRepository = assetRepository; + private async Task ClearProfileAsync( + CancellationToken ct) + { + await mongoDatabase.RunCommandAsync("{ profile : 0 }", cancellationToken: ct); + await mongoDatabase.DropCollectionAsync("system.profile", ct); + await mongoDatabase.RunCommandAsync("{ profile : 2 }", cancellationToken: ct); } private static void SetupJson() diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentMappingTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentMappingTests.cs index 2134887c0..4964288e8 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentMappingTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentMappingTests.cs @@ -27,7 +27,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb var source = CreateContentWithoutNewVersion(); var snapshotJob = new SnapshotWriteJob(source.UniqueId, source, source.Version); - var snapshot = await MongoContentEntity.CreateDraftAsync(snapshotJob, appProvider); + var snapshot = await MongoContentEntity.CreateAsync(snapshotJob, appProvider); Assert.Equal(source.CurrentVersion.Data, snapshot.Data); Assert.Null(snapshot.DraftData); @@ -61,7 +61,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb var source = CreateContentWithNewVersion(); var snapshotJob = new SnapshotWriteJob(source.UniqueId, source, source.Version); - var snapshot = await MongoContentEntity.CreateDraftAsync(snapshotJob, appProvider); + var snapshot = await MongoContentEntity.CreateAsync(snapshotJob, appProvider); Assert.Equal(source.NewVersion?.Data, snapshot.Data); Assert.Equal(source.CurrentVersion.Data, snapshot.DraftData); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs index 1cb95c85d..78dbfed90 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs @@ -8,24 +8,47 @@ using System.Globalization; using FakeItEasy; using LoremNET; +using Microsoft.Extensions.Options; using MongoDB.Bson; using MongoDB.Driver; using Newtonsoft.Json; +using NodaTime; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Contents.DomainObject; using Squidex.Domain.Apps.Entities.MongoDb.Contents; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure; using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.MongoDb; +using Squidex.Infrastructure.States; +using Xunit; + +#pragma warning disable MA0048 // File name must match type name namespace Squidex.Domain.Apps.Entities.Contents.MongoDb { - public sealed class ContentsQueryFixture + public sealed class ContentsQueryFixture : ContentsQueryFixtureBase + { + public ContentsQueryFixture() + : base(false) + { + } + } + + public sealed class ContentsQueryDedicatedFixture : ContentsQueryFixtureBase + { + public ContentsQueryDedicatedFixture() + : base(true) + { + } + } + + public abstract class ContentsQueryFixtureBase : IAsyncLifetime { private readonly Random random = new Random(); private readonly int numValues = 10000; @@ -49,95 +72,125 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb NamedId.Of(DomainId.Create("741e902c-fdfa-41ad-8e5a-b7cb9d6e3d94"), "my-schema5") }; - public ContentsQueryFixture() + protected ContentsQueryFixtureBase(bool dedicatedCollections) { + SetupJson(); + mongoClient = new MongoClient(TestConfig.Configuration["mongodb:configuration"]); mongoDatabase = mongoClient.GetDatabase(TestConfig.Configuration["mongodb:database"]); - SetupJson(); - var appProvider = CreateAppProvider(); + var options = Options.Create(new ContentOptions + { + OptimizeForSelfHosting = dedicatedCollections + }); + ContentRepository = new MongoContentRepository( mongoDatabase, - appProvider); + appProvider, + options); + } - Task.Run(async () => - { - await SetupAsync(ContentRepository, mongoDatabase); - }).Wait(); + public Task DisposeAsync() + { + return Task.CompletedTask; } - private async Task SetupAsync(MongoContentRepository contentRepository, IMongoDatabase database) + public async Task InitializeAsync() { - await contentRepository.InitializeAsync(default); + await ContentRepository.InitializeAsync(default); - await database.RunCommandAsync("{ profile : 0 }"); - await database.DropCollectionAsync("system.profile"); + await CreateDataAsync(default); + await ClearProfilerAsync(default); + } - var collections = contentRepository.GetInternalCollections(); + private async Task CreateDataAsync( + CancellationToken ct) + { + if (await ContentRepository.StreamAll(AppIds[0].Id, null, ct).AnyAsync(ct)) + { + return; + } + + var batch = new List>(); - foreach (var collection in collections) + async Task ExecuteBatchAsync(ContentDomainObject.State? state) { - var contentCount = await collection.Find(new BsonDocument()).CountDocumentsAsync(); + if (state != null) + { + batch.Add(new SnapshotWriteJob(state.UniqueId, state, 0)); + } - if (contentCount == 0) + if ((state == null || batch.Count >= 1000) && batch.Count > 0) { - var batch = new List(); + var store = (ISnapshotStore)ContentRepository; - async Task ExecuteBatchAsync(MongoContentEntity? entity) - { - if (entity != null) - { - batch.Add(entity); - } + await store.WriteManyAsync(batch, ct); - if ((entity == null || batch.Count >= 1000) && batch.Count > 0) - { - await collection.InsertManyAsync(batch); + batch.Clear(); + } + } - batch.Clear(); - } - } + var now = SystemClock.Instance.GetCurrentInstant(); + + var user = new RefToken(RefTokenType.Subject, "1"); - foreach (var appId in AppIds) + foreach (var appId in AppIds) + { + foreach (var schemaId in SchemaIds) + { + for (var i = 0; i < numValues; i++) { - foreach (var schemaId in SchemaIds) + var data = + new ContentData() + .AddField("field1", + new ContentFieldData() + .AddInvariant(JsonValue.Create(i))) + .AddField("field2", + new ContentFieldData() + .AddInvariant(JsonValue.Create(Lorem.Paragraph(200, 20)))); + + var content = new ContentDomainObject.State { - for (var i = 0; i < numValues; i++) - { - var data = - new ContentData() - .AddField("field1", - new ContentFieldData() - .AddInvariant(JsonValue.Create(i))) - .AddField("field2", - new ContentFieldData() - .AddInvariant(JsonValue.Create(Lorem.Paragraph(200, 20)))); - - var content = new MongoContentEntity - { - DocumentId = DomainId.NewGuid(), - AppId = appId, - Data = data, - IndexedAppId = appId.Id, - IndexedSchemaId = schemaId.Id, - IsDeleted = false, - SchemaId = schemaId, - Status = Status.Published - }; - - await ExecuteBatchAsync(content); - } - } + Id = DomainId.NewGuid(), + AppId = appId, + Created = now, + CreatedBy = user, + CurrentVersion = new ContentVersion(Status.Published, data), + IsDeleted = false, + LastModified = now, + LastModifiedBy = user, + SchemaId = schemaId + }; + + await ExecuteBatchAsync(content); } - - await ExecuteBatchAsync(null); } } - await database.RunCommandAsync("{ profile : 2 }"); + await ExecuteBatchAsync(null); + } + + private async Task ClearProfilerAsync( + CancellationToken ct) + { + var prefix = mongoDatabase.DatabaseNamespace.DatabaseName; + + foreach (var databaseName in await (await mongoClient.ListDatabaseNamesAsync(ct)).ToListAsync(ct)) + { + if (!databaseName.StartsWith(prefix, StringComparison.Ordinal)) + { + continue; + } + + var database = mongoClient.GetDatabase(databaseName); + + await database.RunCommandAsync("{ profile : 0 }", cancellationToken: ct); + await database.DropCollectionAsync("system.profile", ct); + await database.RunCommandAsync("{ profile : 2 }", cancellationToken: ct); + } } private static IAppProvider CreateAppProvider() diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryIntegrationTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryIntegrationTests.cs index 69a71c6d6..13d79cf16 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryIntegrationTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryIntegrationTests.cs @@ -14,15 +14,33 @@ using Xunit; using F = Squidex.Infrastructure.Queries.ClrFilter; #pragma warning disable SA1300 // Element should begin with upper-case letter +#pragma warning disable MA0048 // File name must match type name namespace Squidex.Domain.Apps.Entities.Contents.MongoDb { [Trait("Category", "Dependencies")] - public class ContentsQueryIntegrationTests : IClassFixture + public class ContentsQueryIntegrationTests : ContentsQueryTestsBase, IClassFixture { - public ContentsQueryFixture _ { get; } - public ContentsQueryIntegrationTests(ContentsQueryFixture fixture) + : base(fixture) + { + } + } + + [Trait("Category", "Dependencies")] + public class ContentsQueryDedicatedIntegrationTests : ContentsQueryTestsBase, IClassFixture + { + public ContentsQueryDedicatedIntegrationTests(ContentsQueryDedicatedFixture fixture) + : base(fixture) + { + } + } + + public abstract class ContentsQueryTestsBase + { + public ContentsQueryFixtureBase _ { get; } + + protected ContentsQueryTestsBase(ContentsQueryFixtureBase fixture) { _ = fixture; } @@ -34,6 +52,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb var contents = await _.ContentRepository.QueryIdsAsync(_.RandomAppId(), ids, SearchScope.Published); + // The IDs are random here, as it does not really matter. Assert.NotNull(contents); } @@ -49,6 +68,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb var contents = await _.ContentRepository.QueryAsync(_.RandomApp(), schemas, Q.Empty.WithIds(ids), SearchScope.All); + // The IDs are random here, as it does not really matter. Assert.NotNull(contents); } @@ -59,6 +79,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb var contents = await _.ContentRepository.QueryAsync(_.RandomApp(), _.RandomSchema(), Q.Empty.WithIds(ids), SearchScope.All); + // The IDs are random here, as it does not really matter. Assert.NotNull(contents); } @@ -69,6 +90,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb var contents = await _.ContentRepository.QueryIdsAsync(_.RandomAppId(), _.RandomSchemaId(), filter); + // We have a concrete query, so we expect an result. Assert.NotEmpty(contents); } @@ -82,6 +104,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb var contents = await QueryAsync(_.ContentRepository, query, 1000, 0); + // We have a concrete query, so we expect an result. Assert.NotEmpty(contents); } @@ -90,7 +113,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb { var time = SystemClock.Instance.GetCurrentInstant(); - await _.ContentRepository.QueryScheduledWithoutDataAsync(time).ToListAsync(); + var contents = await _.ContentRepository.QueryScheduledWithoutDataAsync(time).ToListAsync(); + + // The IDs are random here, as it does not really matter. + Assert.NotNull(contents); } [Fact] @@ -100,6 +126,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb var contents = await QueryAsync(_.ContentRepository, query); + // We have a concrete query, so we expect an result. Assert.NotEmpty(contents); } @@ -110,7 +137,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb var contents = await QueryAsync(_.ContentRepository, query, reference: DomainId.NewGuid()); - Assert.Empty(contents); + // The IDs are random here, as it does not really matter. + Assert.NotNull(contents); } [Fact] @@ -126,6 +154,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb var contents = await QueryAsync(_.ContentRepository, query, 1000, 9000); + // We have a concrete query, so we expect an result. Assert.NotEmpty(contents); } @@ -139,6 +168,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb var contents = await QueryAsync(_.ContentRepository, query); + // The full text is resolved by another system, so we cannot verify the result. Assert.NotNull(contents); } @@ -152,6 +182,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb var contents = await QueryAsync(_.ContentRepository, query, 1000, 0); + // We have a concrete query, so we expect an result. Assert.NotEmpty(contents); } @@ -165,6 +196,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb var contents = await QueryAsync(_.ContentRepository, query, 1000, 0, reference: DomainId.NewGuid()); + // We do not insert test entities with references, so we cannot verify the result. Assert.Empty(contents); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ConvertDataTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ConvertDataTests.cs index 432b16d34..146a8fb2c 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ConvertDataTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ConvertDataTests.cs @@ -89,7 +89,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries .Returns(new List { id2 }); A.CallTo(() => contentRepository.QueryIdsAsync(appId.Id, A>.That.Is(id1, id2), SearchScope.All, A._)) - .Returns(new List<(DomainId, DomainId, Status)> { (id2, id2, Status.Published) }); + .Returns(new List { new ContentIdStatus(id2, id2, Status.Published) }); var ctx = new Context(Mocks.FrontendUser(), Mocks.App(appId)); @@ -127,7 +127,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries .Returns(new List()); A.CallTo(() => contentRepository.QueryIdsAsync(appId.Id, A>.That.Is(id1, id2), SearchScope.All, A._)) - .Returns(new List<(DomainId, DomainId, Status)>()); + .Returns(new List()); var ctx = new Context(Mocks.FrontendUser(), Mocks.App(appId)); diff --git a/backend/tests/docker-compose.yml b/backend/tests/docker-compose.yml index 8209ea489..24d96bf6c 100644 --- a/backend/tests/docker-compose.yml +++ b/backend/tests/docker-compose.yml @@ -57,6 +57,32 @@ services: depends_on: - mongo + squidex3: + image: squidex + environment: + - URLS__BASEURL=http://localhost:8082 + - ASSETS__RESIZERURL=http://resizer + - CONTENTS__OPTIMIZEFORSELFHOSTING=true + - EVENTSTORE__MONGODB__CONFIGURATION=mongodb://mongo + - EVENTSTORE__MONGODB__DATABASE=squidex3 + - GRAPHQL__CACHEDURATION=0 + - IDENTITY__ADMINCLIENTID=root + - IDENTITY__ADMINCLIENTSECRET=xeLd6jFxqbXJrfmNLlO2j1apagGGGSyZJhFnIuHp4I0= + - IDENTITY__MULTIPLEDOMAINS=true + - RULES__RULESCACHEDURATION=00:00:00 + - SCRIPTING__TIMEOUTEXECUTION=00:00:10 + - SCRIPTING__TIMEOUTSCRIPT=00:00:10 + - STORE__MONGODB__CONFIGURATION=mongodb://mongo + - STORE__MONGODB__DATABASE=squidex3 + - STORE__MONGODB__CONTENTDATABASE=squidex3_content + - STORE__TYPE=MongoDB + - TEMPLATES__LOCALURL=http://localhost:5000 + - ASPNETCORE_URLS=http://+:5000 + networks: + - internal + depends_on: + - mongo + resizer: image: squidex/resizer:dev-40 networks: @@ -99,6 +125,20 @@ services: networks: - internal restart: unless-stopped + + squidex_proxy3: + image: squidex/caddy-proxy-path + ports: + - "8082:8082" + environment: + - SITE_ADDRESS=http://localhost:8082 + - SITE_PATH=* + - SITE_SERVER="squidex3:5000" + depends_on: + - squidex2 + networks: + - internal + restart: unless-stopped networks: internal: