Browse Source

Storage (#901)

* Storage improvements.

* Temp

* Dedicated Mongo collections.

* Remove unused option.

* Replace image names.

* Use correct database.

* Add caddy
pull/908/head
Sebastian Stehle 4 years ago
committed by GitHub
parent
commit
b172612b4a
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 19
      .github/workflows/dev.yml
  2. 19
      .github/workflows/release.yml
  3. 17
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/ContentIdStatus.cs
  4. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ReferencesValidator.cs
  5. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/UniqueValidator.cs
  6. 3
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs
  7. 132
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs
  8. 2
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs
  9. 48
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs
  10. 72
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs
  11. 68
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Extensions.cs
  12. 9
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/OperationBase.cs
  13. 25
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs
  14. 15
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryById.cs
  15. 22
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByIds.cs
  16. 205
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs
  17. 182
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryInDedicatedCollection.cs
  18. 12
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryReferences.cs
  19. 25
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryScheduled.cs
  20. 3
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs
  21. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOptions.cs
  22. 7
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs
  23. 4
      backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs
  24. 2
      backend/src/Squidex.Domain.Users/DefaultUserService.cs
  25. 55
      backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoBase.cs
  26. 32
      backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoRepositoryBase.cs
  27. 27
      backend/src/Squidex.Infrastructure/ResultList.cs
  28. 2
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs
  29. 9
      backend/src/Squidex/appsettings.json
  30. 4
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ReferencesFieldTests.cs
  31. 4
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/ReferencesValidatorTests.cs
  32. 6
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/UniqueValidatorTests.cs
  33. 135
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryFixture.cs
  34. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentMappingTests.cs
  35. 175
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs
  36. 42
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryIntegrationTests.cs
  37. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ConvertDataTests.cs
  38. 40
      backend/tests/docker-compose.yml

19
.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()

19
.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()

17
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)
{
}
}

2
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<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>> CheckContentsByIds(HashSet<DomainId> ids);
public delegate Task<IReadOnlyList<ContentIdStatus>> CheckContentsByIds(HashSet<DomainId> ids);
public sealed class ReferencesValidator : IValidator
{

2
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<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>> CheckUniqueness(FilterNode<ClrValue> filter);
public delegate Task<IReadOnlyList<ContentIdStatus>> CheckUniqueness(FilterNode<ClrValue> filter);
public sealed class UniqueValidator : IValidator
{

3
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);

132
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<MongoContentEntity> 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<IContentEntity>();
}
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<IContentEntity>(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<ISchemaEntity> { schema }, q, ct);
if (q.Ids is { Count: > 0 })
{
return await queryByIds.QueryAsync(app, new List<ISchemaEntity> { schema }, q, ct);
}
if (q.ScheduledFrom != null && q.ScheduledTo != null)
{
return await queryScheduled.QueryAsync(app, new List<ISchemaEntity> { 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<IContentEntity>();
}
if (q.ScheduledFrom != null && q.ScheduledTo != null)
catch (MongoCommandException ex) when (ex.Code == 96)
{
return await queryScheduled.QueryAsync(app.Id, new List<ISchemaEntity> { 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<IContentEntity>(0);
}
}
@ -176,7 +212,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
}
}
public async Task<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>> QueryIdsAsync(DomainId appId, HashSet<DomainId> ids,
public async Task<IReadOnlyList<ContentIdStatus>> QueryIdsAsync(DomainId appId, HashSet<DomainId> ids,
CancellationToken ct)
{
using (Telemetry.Activities.StartActivity("MongoContentCollection/QueryIdsAsync"))
@ -185,7 +221,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
}
}
public async Task<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode<ClrValue> filterNode,
public async Task<IReadOnlyList<ContentIdStatus>> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode<ClrValue> 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<MongoContentEntity> snapshots,
CancellationToken ct = default)
public async Task AddCollectionsAsync(MongoContentEntity entity, Action<IMongoCollection<MongoContentEntity>, 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);
}
}
}

2
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<MongoContentEntity> CreateDraftAsync(SnapshotWriteJob<ContentDomainObject.State> job, IAppProvider appProvider)
public static async Task<MongoContentEntity> CreateAsync(SnapshotWriteJob<ContentDomainObject.State> job, IAppProvider appProvider)
{
var entity = await CreateContentAsync(job.Value.Data, job, appProvider);

48
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<MongoContentEntity>, 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<Status>.Register();
}
public MongoContentRepository(IMongoDatabase database, IAppProvider appProvider)
public MongoContentRepository(IMongoDatabase database, IAppProvider appProvider,
IOptions<ContentOptions> 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<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? schemaIds,
CancellationToken ct = default)
{
return collectionFrontend.StreamAll(appId, schemaIds, ct);
return collectionComplete.StreamAll(appId, schemaIds, ct);
}
public IAsyncEnumerable<IContentEntity> QueryScheduledWithoutDataAsync(Instant now,
CancellationToken ct = default)
{
return collectionFrontend.QueryScheduledWithoutDataAsync(now, ct);
return collectionComplete.QueryScheduledWithoutDataAsync(now, ct);
}
public Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, List<ISchemaEntity> 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<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>> QueryIdsAsync(DomainId appId, HashSet<DomainId> ids, SearchScope scope,
public Task<IReadOnlyList<ContentIdStatus>> QueryIdsAsync(DomainId appId, HashSet<DomainId> 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<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode<ClrValue> filterNode,
public Task<IReadOnlyList<ContentIdStatus>> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode<ClrValue> filterNode,
CancellationToken ct = default)
{
return collectionFrontend.QueryIdsAsync(appId, schemaId, filterNode, ct);
}
public IEnumerable<IMongoCollection<MongoContentEntity>> GetInternalCollections()
{
yield return collectionFrontend.GetInternalCollection();
yield return collectionPublished.GetInternalCollection();
return collectionComplete.QueryIdsAsync(appId, schemaId, filterNode, ct);
}
}
}

72
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<SnapshotResult<ContentDomainObject.State>> ISnapshotStore<ContentDomainObject.State>.ReadAllAsync(
CancellationToken ct)
{
return collectionFrontend.StreamAll(ct)
return collectionComplete.StreamAll(ct)
.Select(x => new SnapshotResult<ContentDomainObject.State>(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<MongoContentEntity>();
var entitiesFrontend = new List<MongoContentEntity>();
var updates = new Dictionary<IMongoCollection<MongoContentEntity>, List<MongoContentEntity>>();
var add = new Action<IMongoCollection<MongoContentEntity>, 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<ContentDomainObject.State> 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<ContentDomainObject.State> job,
@ -154,10 +171,15 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
return value.Status == Status.Published && !value.IsDeleted;
}
private static bool IsValid(SnapshotWriteJob<ContentDomainObject.State> 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;
}
}
}

68
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<List<StatusModel>> FindStatusAsync(this IMongoCollection<MongoContentEntity> collection, FilterDefinition<MongoContentEntity> 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<List<MongoContentEntity>> QueryContentsAsync(this IMongoCollection<MongoContentEntity> collection,
FilterDefinition<MongoContentEntity> 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<MongoContentEntity>.Projection.Include("_id");
foreach (var field in query.GetAllFields())
{
projection = projection.Include(field);
}
var joined =
await collection.Aggregate()
.Match(filter)
.Project<IdOnly>(projection)
.QuerySort(query)
.QuerySkip(query)
.QueryLimit(query)
.Lookup<IdOnly, MongoContentEntity, IdOnly>(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<List<StatusOnly>> FindStatusAsync(this IMongoCollection<MongoContentEntity> collection,
FilterDefinition<MongoContentEntity> filter,
CancellationToken ct)
{
var projections = Builders<MongoContentEntity>.Projection;
return collection.Find(filter)
.Project<StatusModel>(projections
.Project<StatusOnly>(projections
.Include(x => x.Id)
.Include(x => x.IndexedSchemaId)
.Include(x => x.Status))

9
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<MongoContentEntity>
{
protected static readonly SortDefinitionBuilder<MongoContentEntity> Sort = Builders<MongoContentEntity>.Sort;
protected static readonly UpdateDefinitionBuilder<MongoContentEntity> Update = Builders<MongoContentEntity>.Update;
protected static readonly FilterDefinitionBuilder<MongoContentEntity> Filter = Builders<MongoContentEntity>.Filter;
protected static readonly IndexKeysDefinitionBuilder<MongoContentEntity> Index = Builders<MongoContentEntity>.IndexKeys;
protected static readonly ProjectionDefinitionBuilder<MongoContentEntity> Projection = Builders<MongoContentEntity>.Projection;
public IMongoCollection<MongoContentEntity> Collection { get; private set; }
public void Setup(IMongoCollection<MongoContentEntity> collection)

25
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<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? 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<MongoContentEntity> CreateFilter(DomainId appId, HashSet<DomainId>? schemaIds)
{
var filters = new List<FilterDefinition<MongoContentEntity>>
{
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);
}
}
}

15
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<IContentEntity?> 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;

22
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<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>> QueryIdsAsync(DomainId appId, HashSet<DomainId> ids,
public async Task<IReadOnlyList<ContentIdStatus>> QueryIdsAsync(DomainId appId, HashSet<DomainId> ids,
CancellationToken ct)
{
if (ids == null || ids.Count == 0)
{
return new List<(DomainId SchemaId, DomainId Id, Status Status)>();
return ReadonlyList.Empty<ContentIdStatus>();
}
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<IResultList<IContentEntity>> QueryAsync(DomainId appId, List<ISchemaEntity> schemas, Q q,
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, List<ISchemaEntity> schemas, Q q,
CancellationToken ct)
{
Guard.NotNull(q);
if (q.Ids == null || q.Ids.Count == 0)
{
return ResultList.CreateFrom<IContentEntity>(0);
return ResultList.Empty<IContentEntity>();
}
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)

205
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<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode<ClrValue> filterNode,
public async Task<IReadOnlyList<ContentIdStatus>> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode<ClrValue> 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<IResultList<IContentEntity>> QueryAsync(IAppEntity app, List<ISchemaEntity> 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<IContentEntity>(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<IContentEntity>(contentTotal, contentEntities);
}
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, Q q,
public async Task<IResultList<IContentEntity>> 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<IContentEntity>(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<List<MongoContentEntity>> FindContentsAsync(ClrQuery query, FilterDefinition<MongoContentEntity> 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<IdOnly>(projection)
.QuerySort(query)
.QuerySkip(query)
.QueryLimit(query)
.Lookup<IdOnly, MongoContentEntity, IdOnly>(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<IContentEntity>(contentTotal, contentEntities);
}
private static FilterDefinition<MongoContentEntity> BuildFilter(DomainId appId, DomainId schemaId, FilterNode<ClrValue>? filter)
private static FilterDefinition<MongoContentEntity> BuildFilter(DomainId appId, DomainId schemaId,
FilterNode<ClrValue>? filter)
{
var filters = new List<FilterDefinition<MongoContentEntity>>
{
@ -242,7 +149,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
return Filter.And(filters);
}
private static (FilterDefinition<MongoContentEntity>, bool) CreateFilter(DomainId appId, IEnumerable<DomainId> schemaIds, ClrQuery? query,
private static (FilterDefinition<MongoContentEntity>, bool) CreateFilter(DomainId appId, IEnumerable<DomainId> schemaIds, ClrQuery? query,
DomainId referenced, RefToken? createdBy)
{
var filters = new List<FilterDefinition<MongoContentEntity>>

182
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<MongoContentEntity>
{
private readonly ConcurrentDictionary<(DomainId, DomainId), Task<IMongoCollection<MongoContentEntity>>> collections =
new ConcurrentDictionary<(DomainId, DomainId), Task<IMongoCollection<MongoContentEntity>>>();
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<IMongoCollection<MongoContentEntity>> 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<MongoContentEntity>($"{prefixCollection}_{schemaId}");
await schemaCollection.Indexes.CreateManyAsync(
new[]
{
new CreateIndexModel<MongoContentEntity>(Index
.Descending(x => x.LastModified)
.Ascending(x => x.Id)
.Ascending(x => x.IsDeleted)
.Ascending(x => x.ReferencedIds)),
new CreateIndexModel<MongoContentEntity>(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<IReadOnlyList<ContentIdStatus>> QueryIdsAsync(IAppEntity app, ISchemaEntity schema, FilterNode<ClrValue> 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<IResultList<IContentEntity>> 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<IContentEntity>(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<MongoContentEntity> BuildFilter(FilterNode<ClrValue>? filter)
{
var filters = new List<FilterDefinition<MongoContentEntity>>
{
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<MongoContentEntity>());
}
return Filter.And(filters);
}
private static FilterDefinition<MongoContentEntity> CreateFilter(ClrQuery? query,
DomainId referenced, RefToken? createdBy)
{
var filters = new List<FilterDefinition<MongoContentEntity>>
{
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<MongoContentEntity>());
}
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);
}
}
}

12
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<IContentEntity> EmptyIds = ResultList.CreateFrom<IContentEntity>(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<IResultList<IContentEntity>> QueryAsync(DomainId appId, List<ISchemaEntity> schemas, Q q,
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, List<ISchemaEntity> 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<ReferencedIdsOnly>(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<IContentEntity>();
}
q = q.WithReferencing(default).WithIds(contentEntity.ReferencedIds!);
return await queryByIds.QueryAsync(appId, schemas, q, ct);
return await queryByIds.QueryAsync(app, schemas, q, ct);
}
}
}

25
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<IResultList<IContentEntity>> QueryAsync(DomainId appId, List<ISchemaEntity> schemas, Q q,
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, List<ISchemaEntity> schemas, Q q,
CancellationToken ct)
{
Guard.NotNull(q);
if (q.ScheduledFrom == null || q.ScheduledTo == null)
{
return ResultList.CreateFrom<IContentEntity>(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<IContentEntity> 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<MongoContentEntity> CreateFilter(DomainId appId, IEnumerable<DomainId> schemaIds, Instant scheduledFrom, Instant scheduledTo)
private static FilterDefinition<MongoContentEntity> CreateFilter(DomainId appId,
IEnumerable<DomainId> schemaIds,
Instant scheduledFrom,
Instant scheduledTo)
{
return Filter.And(
Filter.Gte(x => x.ScheduledAt, scheduledFrom),

3
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<IEnrichedAssetEntity> EmptyAssets = ResultList.CreateFrom<IEnrichedAssetEntity>(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<IEnrichedAssetEntity>();
}
using (Telemetry.Activities.StartActivity("AssetQueryService/QueryAsync"))

2
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;

7
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<IEnrichedContentEntity> EmptyContents = ResultList.CreateFrom<IEnrichedContentEntity>(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<IEnrichedContentEntity>();
}
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<IEnrichedContentEntity>();
}
var schemas = await GetSchemasAsync(context, ct);
if (schemas.Count == 0)
{
return EmptyContents;
return ResultList.Empty<IEnrichedContentEntity>();
}
q = await queryParser.ParseAsync(context, q);

4
backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs

@ -25,10 +25,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.Repositories
Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, Q q, SearchScope scope,
CancellationToken ct = default);
Task<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode<ClrValue> filterNode,
Task<IReadOnlyList<ContentIdStatus>> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode<ClrValue> filterNode,
CancellationToken ct = default);
Task<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>> QueryIdsAsync(DomainId appId, HashSet<DomainId> ids, SearchScope scope,
Task<IReadOnlyList<ContentIdStatus>> QueryIdsAsync(DomainId appId, HashSet<DomainId> ids, SearchScope scope,
CancellationToken ct = default);
Task<IContentEntity?> FindContentAsync(IAppEntity app, ISchemaEntity schema, DomainId id, SearchScope scope,

2
backend/src/Squidex.Domain.Users/DefaultUserService.cs

@ -58,7 +58,7 @@ namespace Squidex.Domain.Users
if (!ids.Any())
{
return ResultList.CreateFrom<IUser>(0);
return ResultList.Empty<IUser>();
}
var users = userManager.Users.Where(x => ids.Contains(x.Id)).ToList();

55
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<TEntity>
{
protected static readonly FieldDefinitionBuilder<TEntity> FieldBuilder =
FieldDefinitionBuilder<TEntity>.Instance;
protected static readonly FilterDefinitionBuilder<TEntity> Filter =
Builders<TEntity>.Filter;
protected static readonly IndexKeysDefinitionBuilder<TEntity> Index =
Builders<TEntity>.IndexKeys;
protected static readonly ProjectionDefinitionBuilder<TEntity> Projection =
Builders<TEntity>.Projection;
protected static readonly SortDefinitionBuilder<TEntity> Sort =
Builders<TEntity>.Sort;
protected static readonly UpdateDefinitionBuilder<TEntity> Update =
Builders<TEntity>.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<RefToken>.Register();
InstantSerializer.Register();
DomainIdSerializer.Register();
}
}
}

32
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<TEntity> : IInitializable
public abstract class MongoRepositoryBase<TEntity> : MongoBase<TEntity>, IInitializable
{
private const string CollectionFormat = "{0}Set";
protected static readonly BulkWriteOptions BulkUnordered = new BulkWriteOptions { IsOrdered = true };
protected static readonly FieldDefinitionBuilder<TEntity> FieldBuilder = FieldDefinitionBuilder<TEntity>.Instance;
protected static readonly FilterDefinitionBuilder<TEntity> Filter = Builders<TEntity>.Filter;
protected static readonly IndexKeysDefinitionBuilder<TEntity> Index = Builders<TEntity>.IndexKeys;
protected static readonly InsertManyOptions InsertUnordered = new InsertManyOptions { IsOrdered = true };
protected static readonly ProjectionDefinitionBuilder<TEntity> Projection = Builders<TEntity>.Projection;
protected static readonly ReplaceOptions UpsertReplace = new ReplaceOptions { IsUpsert = true };
protected static readonly SortDefinitionBuilder<TEntity> Sort = Builders<TEntity>.Sort;
protected static readonly UpdateDefinitionBuilder<TEntity> Update = Builders<TEntity>.Update;
protected static readonly UpdateOptions Upsert = new UpdateOptions { IsUpsert = true };
private readonly IMongoDatabase mongoDatabase;
private IMongoCollection<TEntity> mongoCollection;
@ -51,15 +35,6 @@ namespace Squidex.Infrastructure.MongoDb
get => mongoDatabase;
}
static MongoRepositoryBase()
{
TypeConverterStringSerializer<RefToken>.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<TEntity> collection,
CancellationToken ct)

27
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<T> : List<T>, IResultList<T>
private sealed class Impl<T> : ReadonlyList<T>, IResultList<T>
{
public long Total { get; }
public Impl(IEnumerable<T> items, long total)
public Impl(List<T> items, long total)
: base(items)
{
Total = total;
}
}
public static IResultList<T> Create<T>(long total, IEnumerable<T> items)
private static class Empties<T>
{
#pragma warning disable SA1401 // Fields should be private
public static Impl<T> Instance = new Impl<T>(new List<T>(), 0);
#pragma warning restore SA1401 // Fields should be private
}
public static IResultList<T> Empty<T>()
{
return Empties<T>.Instance;
}
public static IResultList<T> Create<T>(long total, List<T> items)
{
return new Impl<T>(items, total);
}
public static IResultList<T> Create<T>(long total, IEnumerable<T> items)
{
return new Impl<T>(items.ToList(), total);
}
public static IResultList<T> CreateFrom<T>(long total, params T[] items)
{
return new Impl<T>(items, total);
return new Impl<T>(items.ToList(), total);
}
}
}

2
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<IAssetFolderEntity>(0));
return Task.FromResult(ResultList.Empty<IAssetFolderEntity>());
}
return assetQuery.QueryAssetFoldersAsync(Context, parentId, HttpContext.RequestAborted);

9
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.

4
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<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>>(result);
return Task.FromResult<IReadOnlyList<ContentIdStatus>>(result);
});
}
}

4
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<IReadOnlyList<(DomainId SchemaId, DomainId Id, Status Status)>>(result);
return Task.FromResult<IReadOnlyList<ContentIdStatus>>(result);
};
}
}

6
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<ContentIdStatus>
{
(id, id, Status.Draft)
new ContentIdStatus(id, id, Status.Draft)
};
return Task.FromResult<IReadOnlyList<(DomainId, DomainId, Status)>>(foundIds);
return Task.FromResult<IReadOnlyList<ContentIdStatus>>(foundIds);
};
}
}

135
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<BsonDocument>("{ 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<SnapshotWriteJob<AssetDomainObject.State>>();
if (assetCount == 0)
async Task ExecuteBatchAsync(AssetDomainObject.State? entity)
{
if (entity != null)
{
var batch = new List<MongoAssetEntity>();
batch.Add(new SnapshotWriteJob<AssetDomainObject.State>(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<AssetDomainObject.State>)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<string> { 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<string> { 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<BsonDocument>("{ profile : 2 }");
}).Wait();
await ExecuteBatchAsync(null);
}
AssetRepository = assetRepository;
private async Task ClearProfileAsync(
CancellationToken ct)
{
await mongoDatabase.RunCommandAsync<BsonDocument>("{ profile : 0 }", cancellationToken: ct);
await mongoDatabase.DropCollectionAsync("system.profile", ct);
await mongoDatabase.RunCommandAsync<BsonDocument>("{ profile : 2 }", cancellationToken: ct);
}
private static void SetupJson()

4
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<ContentDomainObject.State>(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<ContentDomainObject.State>(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);

175
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<BsonDocument>("{ 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<SnapshotWriteJob<ContentDomainObject.State>>();
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<ContentDomainObject.State>(state.UniqueId, state, 0));
}
if (contentCount == 0)
if ((state == null || batch.Count >= 1000) && batch.Count > 0)
{
var batch = new List<MongoContentEntity>();
var store = (ISnapshotStore<ContentDomainObject.State>)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<BsonDocument>("{ 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<BsonDocument>("{ profile : 0 }", cancellationToken: ct);
await database.DropCollectionAsync("system.profile", ct);
await database.RunCommandAsync<BsonDocument>("{ profile : 2 }", cancellationToken: ct);
}
}
private static IAppProvider CreateAppProvider()

42
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<ContentsQueryFixture>
public class ContentsQueryIntegrationTests : ContentsQueryTestsBase, IClassFixture<ContentsQueryFixture>
{
public ContentsQueryFixture _ { get; }
public ContentsQueryIntegrationTests(ContentsQueryFixture fixture)
: base(fixture)
{
}
}
[Trait("Category", "Dependencies")]
public class ContentsQueryDedicatedIntegrationTests : ContentsQueryTestsBase, IClassFixture<ContentsQueryDedicatedFixture>
{
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);
}

4
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<DomainId> { id2 });
A.CallTo(() => contentRepository.QueryIdsAsync(appId.Id, A<HashSet<DomainId>>.That.Is(id1, id2), SearchScope.All, A<CancellationToken>._))
.Returns(new List<(DomainId, DomainId, Status)> { (id2, id2, Status.Published) });
.Returns(new List<ContentIdStatus> { 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<DomainId>());
A.CallTo(() => contentRepository.QueryIdsAsync(appId.Id, A<HashSet<DomainId>>.That.Is(id1, id2), SearchScope.All, A<CancellationToken>._))
.Returns(new List<(DomainId, DomainId, Status)>());
.Returns(new List<ContentIdStatus>());
var ctx = new Context(Mocks.FrontendUser(), Mocks.App(appId));

40
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:

Loading…
Cancel
Save