Browse Source

Merge branch 'master' of github.com:Squidex/squidex

pull/892/head
Sebastian 4 years ago
parent
commit
123727e32e
  1. 2
      backend/i18n/frontend_en.json
  2. 2
      backend/i18n/frontend_it.json
  3. 2
      backend/i18n/frontend_nl.json
  4. 2
      backend/i18n/source/frontend_en.json
  5. 2
      backend/src/Migrations/MigrationPath.cs
  6. 7
      backend/src/Migrations/Migrations/CreateAssetSlugs.cs
  7. 1
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/GeoJsonValue.cs
  8. 1
      backend/src/Squidex.Domain.Apps.Core.Operations/DefaultValues/DefaultValueExtensions.cs
  9. 1
      backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ValueReferencesConverter.cs
  10. 1
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/JsonMapper.cs
  11. 18
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs
  12. 18
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderEntity.cs
  13. 45
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository_SnapshotStore.cs
  14. 66
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs
  15. 47
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs
  16. 16
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs
  17. 39
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs
  18. 81
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs
  19. 116
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs
  20. 15
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByIds.cs
  21. 71
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs
  22. 87
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/MongoCountCollection.cs
  23. 25
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/MongoCountEntity.cs
  24. 4
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryParser.cs
  25. 3
      backend/src/Squidex.Domain.Apps.Entities/Assets/Transformations.cs
  26. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOptions.cs
  27. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsSearchSource.cs
  28. 4
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs
  29. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveReferences.cs
  30. 11
      backend/src/Squidex.Domain.Apps.Entities/ContextExtensions.cs
  31. 7
      backend/src/Squidex.Domain.Apps.Entities/Q.cs
  32. 6
      backend/src/Squidex.Domain.Users/DefaultKeyStore.cs
  33. 4
      backend/src/Squidex.Domain.Users/DefaultXmlRepository.cs
  34. 54
      backend/src/Squidex.Infrastructure.MongoDb/MongoDb/InstantSerializer.cs
  35. 33
      backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs
  36. 22
      backend/src/Squidex.Infrastructure.MongoDb/States/MongoSnapshotStoreBase.cs
  37. 1
      backend/src/Squidex.Infrastructure/EventSourcing/EnvelopeExtensions.cs
  38. 5
      backend/src/Squidex.Infrastructure/InstantExtensions.cs
  39. 1
      backend/src/Squidex.Infrastructure/Json/Newtonsoft/JsonValueConverter.cs
  40. 2
      backend/src/Squidex.Infrastructure/Json/Objects/JsonObject.cs
  41. 2
      backend/src/Squidex.Infrastructure/Orleans/GrainState.cs
  42. 12
      backend/src/Squidex.Infrastructure/States/BatchContext.cs
  43. 12
      backend/src/Squidex.Infrastructure/States/BatchPersistence.cs
  44. 12
      backend/src/Squidex.Infrastructure/States/IPersistence.cs
  45. 16
      backend/src/Squidex.Infrastructure/States/ISnapshotStore.cs
  46. 39
      backend/src/Squidex.Infrastructure/States/Persistence.cs
  47. 7
      backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs
  48. 2
      backend/src/Squidex/Config/Domain/AssetServices.cs
  49. 1
      backend/src/Squidex/Config/Domain/EventSourcingServices.cs
  50. 89
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetMappingTests.cs
  51. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryFixture.cs
  52. 159
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentMappingTests.cs
  53. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Properties/Resources.Designer.cs
  54. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs
  55. 16
      backend/tests/Squidex.Domain.Users.Tests/DefaultKeyStoreTests.cs
  56. 6
      backend/tests/Squidex.Domain.Users.Tests/DefaultXmlRepositoryTests.cs
  57. 64
      backend/tests/Squidex.Infrastructure.Tests/Commands/DomainObjectTests.cs
  58. 50
      backend/tests/Squidex.Infrastructure.Tests/MongoDb/Entities.cs
  59. 93
      backend/tests/Squidex.Infrastructure.Tests/MongoDb/InstantSerializerTests.cs
  60. 18
      backend/tests/Squidex.Infrastructure.Tests/States/PersistenceBatchTests.cs
  61. 28
      backend/tests/Squidex.Infrastructure.Tests/States/PersistenceEventSourcingTests.cs
  62. 22
      backend/tests/Squidex.Infrastructure.Tests/States/PersistenceSnapshotTests.cs
  63. 28
      frontend/app/framework/angular/pager.component.html
  64. 2
      frontend/src/app/features/administration/state/users.state.ts
  65. 2
      frontend/src/app/features/assets/pages/assets-page.component.html
  66. 6
      frontend/src/app/features/assets/pages/assets-page.component.ts
  67. 2
      frontend/src/app/features/content/pages/contents/contents-page.component.html
  68. 6
      frontend/src/app/features/content/pages/contents/contents-page.component.ts
  69. 4
      frontend/src/app/framework/angular/modals/dialog-renderer.component.html
  70. 12
      frontend/src/app/framework/angular/pager.component.html
  71. 6
      frontend/src/app/framework/angular/pager.component.scss
  72. 27
      frontend/src/app/framework/angular/pager.component.spec.ts
  73. 20
      frontend/src/app/framework/angular/pager.component.ts
  74. 17
      frontend/src/app/framework/services/dialog.service.ts
  75. 2
      frontend/src/app/shared/components/assets/assets-list.component.html
  76. 4
      frontend/src/app/shared/components/assets/assets-list.component.ts
  77. 2
      frontend/src/app/shared/components/contents/content-value.component.html
  78. 4
      frontend/src/app/shared/components/contents/content-value.component.ts
  79. 2
      frontend/src/app/shared/components/references/content-selector.component.html
  80. 4
      frontend/src/app/shared/components/references/content-selector.component.ts
  81. 61
      frontend/src/app/shared/services/assets.service.spec.ts
  82. 30
      frontend/src/app/shared/services/assets.service.ts
  83. 39
      frontend/src/app/shared/services/contents.service.spec.ts
  84. 61
      frontend/src/app/shared/services/contents.service.ts
  85. 41
      frontend/src/app/shared/state/assets.state.spec.ts
  86. 20
      frontend/src/app/shared/state/assets.state.ts
  87. 26
      frontend/src/app/shared/state/contents.state.ts

2
backend/i18n/frontend_en.json

@ -320,6 +320,8 @@
"common.openAPI": "Open API", "common.openAPI": "Open API",
"common.or": "or", "common.or": "or",
"common.pagerInfo": "{itemFirst}-{itemLast} of {numberOfItems}", "common.pagerInfo": "{itemFirst}-{itemLast} of {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} of total?",
"common.pagerReload": "Click to reload view and get total number of items",
"common.password": "Password", "common.password": "Password",
"common.passwordConfirm": "Confirm Password", "common.passwordConfirm": "Confirm Password",
"common.pattern": "Pattern", "common.pattern": "Pattern",

2
backend/i18n/frontend_it.json

@ -320,6 +320,8 @@
"common.openAPI": "Open API", "common.openAPI": "Open API",
"common.or": "o", "common.or": "o",
"common.pagerInfo": "{itemFirst}-{itemLast} of {numberOfItems}", "common.pagerInfo": "{itemFirst}-{itemLast} of {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} of total?",
"common.pagerReload": "Click to reload view and get total number of items",
"common.password": "Password", "common.password": "Password",
"common.passwordConfirm": "Conferma Password", "common.passwordConfirm": "Conferma Password",
"common.pattern": "Pattern", "common.pattern": "Pattern",

2
backend/i18n/frontend_nl.json

@ -320,6 +320,8 @@
"common.openAPI": "Open API", "common.openAPI": "Open API",
"common.or": "of", "common.or": "of",
"common.pagerInfo": "{itemFirst} - {itemLast} van {numberOfItems}", "common.pagerInfo": "{itemFirst} - {itemLast} van {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} of total?",
"common.pagerReload": "Click to reload view and get total number of items",
"common.password": "Wachtwoord", "common.password": "Wachtwoord",
"common.passwordConfirm": "Bevestig wachtwoord", "common.passwordConfirm": "Bevestig wachtwoord",
"common.pattern": "Patroon", "common.pattern": "Patroon",

2
backend/i18n/source/frontend_en.json

@ -320,6 +320,8 @@
"common.openAPI": "Open API", "common.openAPI": "Open API",
"common.or": "or", "common.or": "or",
"common.pagerInfo": "{itemFirst}-{itemLast} of {numberOfItems}", "common.pagerInfo": "{itemFirst}-{itemLast} of {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} of total?",
"common.pagerReload": "Click to reload view and get total number of items",
"common.password": "Password", "common.password": "Password",
"common.passwordConfirm": "Confirm Password", "common.passwordConfirm": "Confirm Password",
"common.pattern": "Pattern", "common.pattern": "Pattern",

2
backend/src/Migrations/MigrationPath.cs

@ -15,7 +15,7 @@ namespace Migrations
{ {
public sealed class MigrationPath : IMigrationPath public sealed class MigrationPath : IMigrationPath
{ {
private const int CurrentVersion = 26; private const int CurrentVersion = 25;
private readonly IServiceProvider serviceProvider; private readonly IServiceProvider serviceProvider;
public MigrationPath(IServiceProvider serviceProvider) public MigrationPath(IServiceProvider serviceProvider)

7
backend/src/Migrations/Migrations/CreateAssetSlugs.cs

@ -7,7 +7,6 @@
using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.DomainObject; using Squidex.Domain.Apps.Entities.Assets.DomainObject;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Migrations; using Squidex.Infrastructure.Migrations;
using Squidex.Infrastructure.States; using Squidex.Infrastructure.States;
@ -25,13 +24,13 @@ namespace Migrations.Migrations
public async Task UpdateAsync( public async Task UpdateAsync(
CancellationToken ct) CancellationToken ct)
{ {
await foreach (var (state, version) in stateForAssets.ReadAllAsync(ct)) await foreach (var (key, state, version, _) in stateForAssets.ReadAllAsync(ct))
{ {
state.Slug = state.FileName.ToAssetSlug(); state.Slug = state.FileName.ToAssetSlug();
var key = DomainId.Combine(state.AppId.Id, state.Id); var job = new SnapshotWriteJob<AssetDomainObject.State>(key, state, version);
await stateForAssets.WriteAsync(key, state, version, version, ct); await stateForAssets.WriteAsync(job, ct);
} }
} }
} }

1
backend/src/Squidex.Domain.Apps.Core.Model/Contents/GeoJsonValue.cs

@ -8,7 +8,6 @@
using GeoJSON.Net; using GeoJSON.Net;
using GeoJSON.Net.Geometry; using GeoJSON.Net.Geometry;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections;
using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.ObjectPool; using Squidex.Infrastructure.ObjectPool;

1
backend/src/Squidex.Domain.Apps.Core.Operations/DefaultValues/DefaultValueExtensions.cs

@ -9,7 +9,6 @@ using NodaTime;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Core.DefaultValues namespace Squidex.Domain.Apps.Core.DefaultValues
{ {

1
backend/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ValueReferencesConverter.cs

@ -7,7 +7,6 @@
using Squidex.Domain.Apps.Core.ConvertContent; using Squidex.Domain.Apps.Core.ConvertContent;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Core.ExtractReferenceIds namespace Squidex.Domain.Apps.Core.ExtractReferenceIds
{ {

1
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/JsonMapper.cs

@ -10,7 +10,6 @@ using Jint;
using Jint.Native; using Jint.Native;
using Jint.Native.Object; using Jint.Native.Object;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper

18
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs

@ -9,8 +9,11 @@ using MongoDB.Bson.Serialization.Attributes;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core.Assets; using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.DomainObject;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb; using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.MongoDb.Assets namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
{ {
@ -114,5 +117,20 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
{ {
get => DocumentId; get => DocumentId;
} }
public AssetDomainObject.State ToState()
{
return SimpleMapper.Map(this, new AssetDomainObject.State());
}
public static MongoAssetEntity Create(SnapshotWriteJob<AssetDomainObject.State> job)
{
var entity = SimpleMapper.Map(job.Value, new MongoAssetEntity());
entity.DocumentId = job.Key;
entity.IndexedAppId = job.Value.AppId.Id;
return entity;
}
} }
} }

18
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderEntity.cs

@ -8,8 +8,11 @@
using MongoDB.Bson.Serialization.Attributes; using MongoDB.Bson.Serialization.Attributes;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.DomainObject;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb; using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.MongoDb.Assets namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
{ {
@ -67,5 +70,20 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
{ {
get => DocumentId; get => DocumentId;
} }
public AssetFolderDomainObject.State ToState()
{
return SimpleMapper.Map(this, new AssetFolderDomainObject.State());
}
public static MongoAssetFolderEntity Create(SnapshotWriteJob<AssetFolderDomainObject.State> job)
{
var entity = SimpleMapper.Map(job.Value, new MongoAssetFolderEntity());
entity.DocumentId = job.Key;
entity.IndexedAppId = job.Value.AppId.Id;
return entity;
}
} }
} }

45
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository_SnapshotStore.cs

@ -11,7 +11,6 @@ using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Assets.DomainObject; using Squidex.Domain.Apps.Entities.Assets.DomainObject;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb; using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.States; using Squidex.Infrastructure.States;
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
@ -26,13 +25,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
return Collection.DeleteManyAsync(Filter.Eq(x => x.IndexedAppId, app.Id), ct); return Collection.DeleteManyAsync(Filter.Eq(x => x.IndexedAppId, app.Id), ct);
} }
IAsyncEnumerable<(AssetFolderDomainObject.State State, long Version)> ISnapshotStore<AssetFolderDomainObject.State>.ReadAllAsync( IAsyncEnumerable<SnapshotResult<AssetFolderDomainObject.State>> ISnapshotStore<AssetFolderDomainObject.State>.ReadAllAsync(
CancellationToken ct) CancellationToken ct)
{ {
return Collection.Find(new BsonDocument(), Batching.Options).ToAsyncEnumerable(ct).Select(x => (Map(x), x.Version)); return Collection.Find(new BsonDocument(), Batching.Options).ToAsyncEnumerable(ct)
.Select(x => new SnapshotResult<AssetFolderDomainObject.State>(x.DocumentId, x.ToState(), x.Version, true));
} }
async Task<(AssetFolderDomainObject.State Value, bool Valid, long Version)> ISnapshotStore<AssetFolderDomainObject.State>.ReadAsync(DomainId key, async Task<SnapshotResult<AssetFolderDomainObject.State>> ISnapshotStore<AssetFolderDomainObject.State>.ReadAsync(DomainId key,
CancellationToken ct) CancellationToken ct)
{ {
using (Telemetry.Activities.StartActivity("MongoAssetFolderRepository/ReadAsync")) using (Telemetry.Activities.StartActivity("MongoAssetFolderRepository/ReadAsync"))
@ -43,30 +43,30 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
if (existing != null) if (existing != null)
{ {
return (Map(existing), true, existing.Version); return new SnapshotResult<AssetFolderDomainObject.State>(existing.DocumentId, existing.ToState(), existing.Version);
} }
return (null!, true, EtagVersion.Empty); return new SnapshotResult<AssetFolderDomainObject.State>(default, null!, EtagVersion.Empty);
} }
} }
async Task ISnapshotStore<AssetFolderDomainObject.State>.WriteAsync(DomainId key, AssetFolderDomainObject.State value, long oldVersion, long newVersion, async Task ISnapshotStore<AssetFolderDomainObject.State>.WriteAsync(SnapshotWriteJob<AssetFolderDomainObject.State> job,
CancellationToken ct) CancellationToken ct)
{ {
using (Telemetry.Activities.StartActivity("MongoAssetFolderRepository/WriteAsync")) using (Telemetry.Activities.StartActivity("MongoAssetFolderRepository/WriteAsync"))
{ {
var entity = Map(value); var entity = MongoAssetFolderEntity.Create(job);
await Collection.UpsertVersionedAsync(key, oldVersion, newVersion, entity, ct); await Collection.UpsertVersionedAsync(job.Key, job.OldVersion, job.NewVersion, entity, ct);
} }
} }
async Task ISnapshotStore<AssetFolderDomainObject.State>.WriteManyAsync(IEnumerable<(DomainId Key, AssetFolderDomainObject.State Value, long Version)> snapshots, async Task ISnapshotStore<AssetFolderDomainObject.State>.WriteManyAsync(IEnumerable<SnapshotWriteJob<AssetFolderDomainObject.State>> jobs,
CancellationToken ct) CancellationToken ct)
{ {
using (Telemetry.Activities.StartActivity("MongoAssetFolderRepository/WriteManyAsync")) using (Telemetry.Activities.StartActivity("MongoAssetFolderRepository/WriteManyAsync"))
{ {
var updates = snapshots.Select(Map).Select(x => var updates = jobs.Select(MongoAssetFolderEntity.Create).Select(x =>
new ReplaceOneModel<MongoAssetFolderEntity>( new ReplaceOneModel<MongoAssetFolderEntity>(
Filter.Eq(y => y.DocumentId, x.DocumentId), Filter.Eq(y => y.DocumentId, x.DocumentId),
x) x)
@ -91,28 +91,5 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
await Collection.DeleteOneAsync(x => x.DocumentId == key, ct); await Collection.DeleteOneAsync(x => x.DocumentId == key, ct);
} }
} }
private static MongoAssetFolderEntity Map(AssetFolderDomainObject.State value)
{
var entity = SimpleMapper.Map(value, new MongoAssetFolderEntity());
entity.IndexedAppId = value.AppId.Id;
return entity;
}
private static MongoAssetFolderEntity Map((DomainId Key, AssetFolderDomainObject.State Value, long Version) snapshot)
{
var entity = Map(snapshot.Value);
entity.DocumentId = snapshot.Key;
return entity;
}
private static AssetFolderDomainObject.State Map(MongoAssetFolderEntity existing)
{
return SimpleMapper.Map(existing, new AssetFolderDomainObject.State());
}
} }
} }

66
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs

@ -19,9 +19,12 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
{ {
public sealed partial class MongoAssetRepository : MongoRepositoryBase<MongoAssetEntity>, IAssetRepository public sealed partial class MongoAssetRepository : MongoRepositoryBase<MongoAssetEntity>, IAssetRepository
{ {
private readonly MongoCountCollection countCollection;
public MongoAssetRepository(IMongoDatabase database) public MongoAssetRepository(IMongoDatabase database)
: base(database) : base(database)
{ {
countCollection = new MongoCountCollection(database, CollectionName());
} }
public IMongoCollection<MongoAssetEntity> GetInternalCollection() public IMongoCollection<MongoAssetEntity> GetInternalCollection()
@ -50,15 +53,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
new CreateIndexModel<MongoAssetEntity>( new CreateIndexModel<MongoAssetEntity>(
Index Index
.Ascending(x => x.IndexedAppId) .Ascending(x => x.IndexedAppId)
.Ascending(x => x.IsDeleted)
.Ascending(x => x.Slug)), .Ascending(x => x.Slug)),
new CreateIndexModel<MongoAssetEntity>( new CreateIndexModel<MongoAssetEntity>(
Index Index
.Ascending(x => x.IndexedAppId) .Ascending(x => x.IndexedAppId)
.Ascending(x => x.IsDeleted) .Ascending(x => x.FileHash)),
.Ascending(x => x.FileHash)
.Ascending(x => x.FileName)
.Ascending(x => x.FileSize)),
new CreateIndexModel<MongoAssetEntity>( new CreateIndexModel<MongoAssetEntity>(
Index Index
.Ascending(x => x.Id) .Ascending(x => x.Id)
@ -101,13 +100,16 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
.ToListAsync(ct); .ToListAsync(ct);
long assetTotal = assetEntities.Count; long assetTotal = assetEntities.Count;
if (q.NoTotal) if (assetEntities.Count >= q.Query.Take || q.Query.Skip > 0)
{ {
assetTotal = -1; if (q.NoTotal)
} {
else if (assetEntities.Count >= q.Query.Take || q.Query.Skip > 0) assetTotal = -1;
{ }
assetTotal = await Collection.Find(filter).CountDocumentsAsync(ct); else
{
assetTotal = await Collection.Find(filter).CountDocumentsAsync(ct);
}
} }
return ResultList.Create(assetTotal, assetEntities.OfType<IAssetEntity>()); return ResultList.Create(assetTotal, assetEntities.OfType<IAssetEntity>());
@ -116,7 +118,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
{ {
var query = q.Query.AdjustToModel(appId); var query = q.Query.AdjustToModel(appId);
var filter = query.BuildFilter(appId, parentId); var (filter, isDefault) = query.BuildFilter(appId, parentId);
var assetEntities = var assetEntities =
await Collection.Find(filter) await Collection.Find(filter)
@ -126,13 +128,25 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
.ToListAsync(ct); .ToListAsync(ct);
long assetTotal = assetEntities.Count; long assetTotal = assetEntities.Count;
if (q.NoTotal) if (assetEntities.Count >= q.Query.Take || q.Query.Skip > 0)
{ {
assetTotal = -1; var isDefaultQuery = q.Query.Filter == null;
}
else if (assetEntities.Count >= q.Query.Take || q.Query.Skip > 0) if (q.NoTotal || (q.NoSlowTotal && !isDefaultQuery))
{ {
assetTotal = await Collection.Find(filter).CountDocumentsAsync(ct); assetTotal = -1;
}
else if (isDefaultQuery)
{
// Cache total count by app and asset folder.
var totalKey = $"{appId}_{parentId}";
assetTotal = await countCollection.GetOrAddAsync(totalKey, ct => Collection.Find(filter).CountDocumentsAsync(ct), ct);
}
else
{
assetTotal = await Collection.Find(filter).CountDocumentsAsync(ct);
}
} }
return ResultList.Create<IAssetEntity>(assetTotal, assetEntities); return ResultList.Create<IAssetEntity>(assetTotal, assetEntities);
@ -166,7 +180,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
using (Telemetry.Activities.StartActivity("MongoAssetRepository/QueryChildIdsAsync")) using (Telemetry.Activities.StartActivity("MongoAssetRepository/QueryChildIdsAsync"))
{ {
var assetEntities = var assetEntities =
await Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted && x.ParentId == parentId).Only(x => x.Id) await Collection.Find(BuildFilter(appId, parentId)).Only(x => x.Id)
.ToListAsync(ct); .ToListAsync(ct);
var field = Field.Of<MongoAssetFolderEntity>(x => nameof(x.Id)); var field = Field.Of<MongoAssetFolderEntity>(x => nameof(x.Id));
@ -181,7 +195,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
using (Telemetry.Activities.StartActivity("MongoAssetRepository/FindAssetByHashAsync")) using (Telemetry.Activities.StartActivity("MongoAssetRepository/FindAssetByHashAsync"))
{ {
var assetEntity = var assetEntity =
await Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted && x.FileHash == hash && x.FileName == fileName && x.FileSize == fileSize) await Collection.Find(x => x.IndexedAppId == appId && x.FileHash == hash && !x.IsDeleted && x.FileSize == fileSize && x.FileName == fileName)
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);
return assetEntity; return assetEntity;
@ -194,7 +208,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
using (Telemetry.Activities.StartActivity("MongoAssetRepository/FindAssetBySlugAsync")) using (Telemetry.Activities.StartActivity("MongoAssetRepository/FindAssetBySlugAsync"))
{ {
var assetEntity = var assetEntity =
await Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted && x.Slug == slug) await Collection.Find(x => x.IndexedAppId == appId && x.Slug == slug && !x.IsDeleted)
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);
return assetEntity; return assetEntity;
@ -237,5 +251,15 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
Filter.In(x => x.DocumentId, documentIds), Filter.In(x => x.DocumentId, documentIds),
Filter.Ne(x => x.IsDeleted, true)); Filter.Ne(x => x.IsDeleted, true));
} }
private static FilterDefinition<MongoAssetEntity> BuildFilter(DomainId appId, DomainId parentId)
{
return Filter.And(
Filter.Gt(x => x.LastModified, default),
Filter.Gt(x => x.Id, DomainId.Create(string.Empty)),
Filter.Gt(x => x.IndexedAppId, appId),
Filter.Ne(x => x.IsDeleted, true),
Filter.Ne(x => x.ParentId, parentId));
}
} }
} }

47
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs

@ -11,7 +11,6 @@ using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Assets.DomainObject; using Squidex.Domain.Apps.Entities.Assets.DomainObject;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb; using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.States; using Squidex.Infrastructure.States;
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
@ -26,13 +25,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
return Collection.DeleteManyAsync(Filter.Eq(x => x.IndexedAppId, app.Id), ct); return Collection.DeleteManyAsync(Filter.Eq(x => x.IndexedAppId, app.Id), ct);
} }
IAsyncEnumerable<(AssetDomainObject.State State, long Version)> ISnapshotStore<AssetDomainObject.State>.ReadAllAsync( IAsyncEnumerable<SnapshotResult<AssetDomainObject.State>> ISnapshotStore<AssetDomainObject.State>.ReadAllAsync(
CancellationToken ct) CancellationToken ct)
{ {
return Collection.Find(new BsonDocument(), Batching.Options).ToAsyncEnumerable(ct).Select(x => (Map(x), x.Version)); return Collection.Find(new BsonDocument(), Batching.Options).ToAsyncEnumerable(ct)
.Select(x => new SnapshotResult<AssetDomainObject.State>(x.DocumentId, x.ToState(), x.Version));
} }
async Task<(AssetDomainObject.State Value, bool Valid, long Version)> ISnapshotStore<AssetDomainObject.State>.ReadAsync(DomainId key, async Task<SnapshotResult<AssetDomainObject.State>> ISnapshotStore<AssetDomainObject.State>.ReadAsync(DomainId key,
CancellationToken ct) CancellationToken ct)
{ {
using (Telemetry.Activities.StartActivity("MongoAssetRepository/ReadAsync")) using (Telemetry.Activities.StartActivity("MongoAssetRepository/ReadAsync"))
@ -43,30 +43,30 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
if (existing != null) if (existing != null)
{ {
return (Map(existing), true, existing.Version); return new SnapshotResult<AssetDomainObject.State>(existing.DocumentId, existing.ToState(), existing.Version);
} }
return (null!, true, EtagVersion.Empty); return new SnapshotResult<AssetDomainObject.State>(default, null!, EtagVersion.Empty);
} }
} }
async Task ISnapshotStore<AssetDomainObject.State>.WriteAsync(DomainId key, AssetDomainObject.State value, long oldVersion, long newVersion, async Task ISnapshotStore<AssetDomainObject.State>.WriteAsync(SnapshotWriteJob<AssetDomainObject.State> job,
CancellationToken ct) CancellationToken ct)
{ {
using (Telemetry.Activities.StartActivity("MongoAssetRepository/WriteAsync")) using (Telemetry.Activities.StartActivity("MongoAssetRepository/WriteAsync"))
{ {
var entity = Map(value); var entity = MongoAssetEntity.Create(job);
await Collection.UpsertVersionedAsync(key, oldVersion, newVersion, entity, ct); await Collection.UpsertVersionedAsync(job.Key, job.OldVersion, job.NewVersion, entity, ct);
} }
} }
async Task ISnapshotStore<AssetDomainObject.State>.WriteManyAsync(IEnumerable<(DomainId Key, AssetDomainObject.State Value, long Version)> snapshots, async Task ISnapshotStore<AssetDomainObject.State>.WriteManyAsync(IEnumerable<SnapshotWriteJob<AssetDomainObject.State>> jobs,
CancellationToken ct) CancellationToken ct)
{ {
using (Telemetry.Activities.StartActivity("MongoAssetRepository/WriteManyAsync")) using (Telemetry.Activities.StartActivity("MongoAssetRepository/WriteManyAsync"))
{ {
var updates = snapshots.Select(Map).Select(x => var updates = jobs.Select(MongoAssetEntity.Create).Select(x =>
new ReplaceOneModel<MongoAssetEntity>( new ReplaceOneModel<MongoAssetEntity>(
Filter.Eq(y => y.DocumentId, x.DocumentId), Filter.Eq(y => y.DocumentId, x.DocumentId),
x) x)
@ -88,31 +88,8 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
{ {
using (Telemetry.Activities.StartActivity("MongoAssetRepository/RemoveAsync")) using (Telemetry.Activities.StartActivity("MongoAssetRepository/RemoveAsync"))
{ {
await Collection.DeleteOneAsync(x => x.DocumentId == key, ct); await Collection.DeleteOneAsync(x => x.DocumentId == key, null, ct);
} }
} }
private static MongoAssetEntity Map(AssetDomainObject.State value)
{
var entity = SimpleMapper.Map(value, new MongoAssetEntity());
entity.IndexedAppId = value.AppId.Id;
return entity;
}
private static MongoAssetEntity Map((DomainId Key, AssetDomainObject.State Value, long Version) snapshot)
{
var entity = Map(snapshot.Value);
entity.DocumentId = snapshot.Key;
return entity;
}
private static AssetDomainObject.State Map(MongoAssetEntity existing)
{
return SimpleMapper.Map(existing, new AssetDomainObject.State());
}
} }
} }

16
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs

@ -36,18 +36,22 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors
return query; return query;
} }
public static FilterDefinition<MongoAssetEntity> BuildFilter(this ClrQuery query, DomainId appId, DomainId? parentId) public static (FilterDefinition<MongoAssetEntity>, bool) BuildFilter(this ClrQuery query, DomainId appId, DomainId? parentId)
{ {
var filters = new List<FilterDefinition<MongoAssetEntity>> var filters = new List<FilterDefinition<MongoAssetEntity>>
{ {
Filter.Exists(x => x.LastModified), Filter.Ne(x => x.LastModified, default),
Filter.Exists(x => x.Id), Filter.Ne(x => x.Id, default),
Filter.Eq(x => x.IndexedAppId, appId) Filter.Eq(x => x.IndexedAppId, appId)
}; };
var isDefault = false;
if (!query.HasFilterField("IsDeleted")) if (!query.HasFilterField("IsDeleted"))
{ {
filters.Add(Filter.Eq(x => x.IsDeleted, false)); filters.Add(Filter.Eq(x => x.IsDeleted, false));
isDefault = true;
} }
if (parentId != null) if (parentId != null)
@ -63,12 +67,16 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors
{ {
filters.Add(Filter.Eq(x => x.ParentId, parentId.Value)); filters.Add(Filter.Eq(x => x.ParentId, parentId.Value));
} }
isDefault = false;
} }
var (filter, last) = query.BuildFilter<MongoAssetEntity>(false); var (filter, last) = query.BuildFilter<MongoAssetEntity>(false);
if (filter != null) if (filter != null)
{ {
isDefault = false;
if (last) if (last)
{ {
filters.Add(filter); filters.Add(filter);
@ -79,7 +87,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors
} }
} }
return Filter.And(filters); return (Filter.And(filters), isDefault);
} }
} }
} }

39
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using MongoDB.Bson;
using MongoDB.Driver; using MongoDB.Driver;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
@ -27,8 +28,8 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
private readonly QueryReferences queryReferences; private readonly QueryReferences queryReferences;
private readonly QueryReferrers queryReferrers; private readonly QueryReferrers queryReferrers;
private readonly QueryScheduled queryScheduled; private readonly QueryScheduled queryScheduled;
private readonly string name;
private readonly ReadPreference readPreference; private readonly ReadPreference readPreference;
private readonly string name;
public MongoContentCollection(string name, IMongoDatabase database, IAppProvider appProvider, ReadPreference readPreference) public MongoContentCollection(string name, IMongoDatabase database, IAppProvider appProvider, ReadPreference readPreference)
: base(database) : base(database)
@ -38,7 +39,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
queryAsStream = new QueryAsStream(); queryAsStream = new QueryAsStream();
queryBdId = new QueryById(); queryBdId = new QueryById();
queryByIds = new QueryByIds(); queryByIds = new QueryByIds();
queryByQuery = new QueryByQuery(appProvider); queryByQuery = new QueryByQuery(appProvider, new MongoCountCollection(database, name));
queryReferences = new QueryReferences(queryByIds); queryReferences = new QueryReferences(queryByIds);
queryReferrers = new QueryReferrers(); queryReferrers = new QueryReferrers();
queryScheduled = new QueryScheduled(); queryScheduled = new QueryScheduled();
@ -202,42 +203,34 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
} }
} }
public async Task<long> FindVersionAsync(DomainId documentId, public Task<MongoContentEntity> FindAsync(DomainId documentId,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var result = await Collection.Find(x => x.DocumentId == documentId).Only(x => x.Version).FirstOrDefaultAsync(ct); return Collection.Find(x => x.DocumentId == documentId).FirstOrDefaultAsync(ct);
}
return result?["vs"].AsInt64 ?? EtagVersion.Empty; public IAsyncEnumerable<MongoContentEntity> StreamAll(
CancellationToken ct)
{
return Collection.Find(new BsonDocument()).ToAsyncEnumerable(ct);
} }
public Task UpsertVersionedAsync(DomainId documentId, long oldVersion, MongoContentEntity entity, public Task UpsertVersionedAsync(DomainId documentId, long oldVersion, MongoContentEntity value,
CancellationToken ct = default) CancellationToken ct = default)
{ {
return Collection.UpsertVersionedAsync(documentId, oldVersion, entity.Version, entity, ct); return Collection.UpsertVersionedAsync(documentId, oldVersion, value.Version, value, ct);
} }
public Task RemoveAsync(DomainId documentId, public Task RemoveAsync(DomainId key,
CancellationToken ct = default) CancellationToken ct = default)
{ {
return Collection.DeleteOneAsync(x => x.DocumentId == documentId, ct); return Collection.DeleteOneAsync(x => x.DocumentId == key, null, ct);
} }
public Task InsertManyAsync(IReadOnlyList<MongoContentEntity> entities, public Task InsertManyAsync(IReadOnlyList<MongoContentEntity> snapshots,
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (entities.Count == 0) return Collection.InsertManyAsync(snapshots, InsertUnordered, ct);
{
return Task.CompletedTask;
}
var writes = entities.Select(x => new ReplaceOneModel<MongoContentEntity>(
Filter.Eq(y => y.DocumentId, x.DocumentId),
x)
{
IsUpsert = true
}).ToList();
return Collection.BulkWriteAsync(writes, BulkUnordered, ct);
} }
} }
} }

81
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs

@ -8,9 +8,13 @@
using MongoDB.Bson.Serialization.Attributes; using MongoDB.Bson.Serialization.Attributes;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.ExtractReferenceIds;
using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.DomainObject;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb; using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{ {
@ -58,6 +62,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
[BsonJson] [BsonJson]
public ContentData Data { get; set; } public ContentData Data { get; set; }
[BsonIgnoreIfNull]
[BsonElement("dd")]
[BsonJson]
public ContentData? DraftData { get; set; }
[BsonIgnoreIfNull] [BsonIgnoreIfNull]
[BsonElement("sa")] [BsonElement("sa")]
public Instant? ScheduledAt { get; set; } public Instant? ScheduledAt { get; set; }
@ -78,6 +87,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
[BsonElement("dl")] [BsonElement("dl")]
public bool IsDeleted { get; set; } public bool IsDeleted { get; set; }
[BsonIgnoreIfDefault]
[BsonElement("is")]
public bool IsSnapshot { get; set; }
[BsonIgnoreIfDefault] [BsonIgnoreIfDefault]
[BsonElement("sj")] [BsonElement("sj")]
public ScheduleJob? ScheduleJob { get; set; } public ScheduleJob? ScheduleJob { get; set; }
@ -94,5 +107,73 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{ {
get => DocumentId; get => DocumentId;
} }
public ContentDomainObject.State ToState()
{
var state = SimpleMapper.Map(this, new ContentDomainObject.State());
if (DraftData != null && NewStatus.HasValue)
{
state.NewVersion = new ContentVersion(NewStatus.Value, Data);
state.CurrentVersion = new ContentVersion(Status, DraftData);
}
else
{
state.NewVersion = null;
state.CurrentVersion = new ContentVersion(Status, Data);
}
return state;
}
public static async Task<MongoContentEntity> CreatePublishedAsync(SnapshotWriteJob<ContentDomainObject.State> job, IAppProvider appProvider)
{
var entity = await CreateContentAsync(job.Value.CurrentVersion.Data, job, appProvider);
entity.ScheduledAt = null;
entity.ScheduleJob = null;
entity.NewStatus = null;
return entity;
}
public static async Task<MongoContentEntity> CreateDraftAsync(SnapshotWriteJob<ContentDomainObject.State> job, IAppProvider appProvider)
{
var entity = await CreateContentAsync(job.Value.Data, job, appProvider);
entity.ScheduledAt = job.Value.ScheduleJob?.DueTime;
entity.ScheduleJob = job.Value.ScheduleJob;
entity.NewStatus = job.Value.NewStatus;
entity.DraftData = job.Value.NewVersion != null ? job.Value.CurrentVersion.Data : null;
entity.IsSnapshot = true;
return entity;
}
private static async Task<MongoContentEntity> CreateContentAsync(ContentData data, SnapshotWriteJob<ContentDomainObject.State> job, IAppProvider appProvider)
{
var entity = SimpleMapper.Map(job.Value, new MongoContentEntity());
entity.Data = data;
entity.DocumentId = job.Value.UniqueId;
entity.IndexedAppId = job.Value.AppId.Id;
entity.IndexedSchemaId = job.Value.SchemaId.Id;
entity.ReferencedIds ??= new HashSet<DomainId>();
entity.Version = job.NewVersion;
if (data.CanHaveReference())
{
var schema = await appProvider.GetSchemaAsync(job.Value.AppId.Id, job.Value.SchemaId.Id, true);
if (schema != null)
{
var components = await appProvider.GetComponentsAsync(schema);
entity.Data.AddReferencedIds(schema.SchemaDef, entity.ReferencedIds, components);
}
}
return entity;
}
} }
} }

116
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs

@ -6,11 +6,9 @@
// ========================================================================== // ==========================================================================
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.ExtractReferenceIds;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents.DomainObject; using Squidex.Domain.Apps.Entities.Contents.DomainObject;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.States; using Squidex.Infrastructure.States;
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
@ -19,20 +17,27 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{ {
public partial class MongoContentRepository : ISnapshotStore<ContentDomainObject.State>, IDeleter public partial class MongoContentRepository : ISnapshotStore<ContentDomainObject.State>, IDeleter
{ {
IAsyncEnumerable<(ContentDomainObject.State State, long Version)> ISnapshotStore<ContentDomainObject.State>.ReadAllAsync( IAsyncEnumerable<SnapshotResult<ContentDomainObject.State>> ISnapshotStore<ContentDomainObject.State>.ReadAllAsync(
CancellationToken ct) CancellationToken ct)
{ {
return AsyncEnumerable.Empty<(ContentDomainObject.State State, long Version)>(); return collectionAll.StreamAll(ct)
.Select(x => new SnapshotResult<ContentDomainObject.State>(x.DocumentId, x.ToState(), x.Version, true));
} }
async Task<(ContentDomainObject.State Value, bool Valid, long Version)> ISnapshotStore<ContentDomainObject.State>.ReadAsync(DomainId key, async Task<SnapshotResult<ContentDomainObject.State>> ISnapshotStore<ContentDomainObject.State>.ReadAsync(DomainId key,
CancellationToken ct) CancellationToken ct)
{ {
using (Telemetry.Activities.StartActivity("MongoContentRepository/ReadAsync")) using (Telemetry.Activities.StartActivity("MongoContentRepository/ReadAsync"))
{ {
var version = await collectionAll.FindVersionAsync(key, ct); var existing =
await collectionAll.FindAsync(key, ct);
return (null!, false, version); if (existing?.IsSnapshot == true)
{
return new SnapshotResult<ContentDomainObject.State>(existing.DocumentId, existing.ToState(), existing.Version);
}
return new SnapshotResult<ContentDomainObject.State>(default, null!, EtagVersion.Empty);
} }
} }
@ -66,23 +71,23 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
} }
} }
async Task ISnapshotStore<ContentDomainObject.State>.WriteAsync(DomainId key, ContentDomainObject.State value, long oldVersion, long newVersion, async Task ISnapshotStore<ContentDomainObject.State>.WriteAsync(SnapshotWriteJob<ContentDomainObject.State> job,
CancellationToken ct) CancellationToken ct)
{ {
using (Telemetry.Activities.StartActivity("MongoContentRepository/WriteAsync")) using (Telemetry.Activities.StartActivity("MongoContentRepository/WriteAsync"))
{ {
if (value.SchemaId.Id == DomainId.Empty) if (job.Value.SchemaId.Id == DomainId.Empty)
{ {
return; return;
} }
await Task.WhenAll( await Task.WhenAll(
UpsertDraftContentAsync(value, oldVersion, newVersion, ct), UpsertDraftContentAsync(job, ct),
UpsertOrDeletePublishedAsync(value, oldVersion, newVersion, ct)); UpsertOrDeletePublishedAsync(job, ct));
} }
} }
async Task ISnapshotStore<ContentDomainObject.State>.WriteManyAsync(IEnumerable<(DomainId Key, ContentDomainObject.State Value, long Version)> snapshots, async Task ISnapshotStore<ContentDomainObject.State>.WriteManyAsync(IEnumerable<SnapshotWriteJob<ContentDomainObject.State>> jobs,
CancellationToken ct) CancellationToken ct)
{ {
using (Telemetry.Activities.StartActivity("MongoContentRepository/WriteManyAsync")) using (Telemetry.Activities.StartActivity("MongoContentRepository/WriteManyAsync"))
@ -90,20 +95,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
var entitiesPublished = new List<MongoContentEntity>(); var entitiesPublished = new List<MongoContentEntity>();
var entitiesAll = new List<MongoContentEntity>(); var entitiesAll = new List<MongoContentEntity>();
foreach (var (_, value, version) in snapshots) foreach (var job in jobs.Where(IsValid))
{ {
// Some data is corrupt and might throw an exception during migration if we do not skip them. if (ShouldWritePublished(job.Value))
if (value.AppId == null || value.CurrentVersion == null)
{
continue;
}
if (ShouldWritePublished(value))
{ {
entitiesPublished.Add(await CreatePublishedContentAsync(value, version)); entitiesPublished.Add(await MongoContentEntity.CreatePublishedAsync(job, appProvider));
} }
entitiesAll.Add(await CreateDraftContentAsync(value, version)); entitiesAll.Add(await MongoContentEntity.CreateDraftAsync(job, appProvider));
} }
await Task.WhenAll( await Task.WhenAll(
@ -112,16 +111,16 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
} }
} }
private async Task UpsertOrDeletePublishedAsync(ContentDomainObject.State value, long oldVersion, long newVersion, private async Task UpsertOrDeletePublishedAsync(SnapshotWriteJob<ContentDomainObject.State> job,
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (ShouldWritePublished(value)) if (ShouldWritePublished(job.Value))
{ {
await UpsertPublishedContentAsync(value, oldVersion, newVersion, ct); await UpsertPublishedContentAsync(job, ct);
} }
else else
{ {
await DeletePublishedContentAsync(value.AppId.Id, value.Id, ct); await DeletePublishedContentAsync(job.Value.AppId.Id, job.Value.Id, ct);
} }
} }
@ -133,73 +132,32 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
return collectionPublished.RemoveAsync(documentId, ct); return collectionPublished.RemoveAsync(documentId, ct);
} }
private async Task UpsertDraftContentAsync(ContentDomainObject.State value, long oldVersion, long newVersion, private async Task UpsertDraftContentAsync(SnapshotWriteJob<ContentDomainObject.State> job,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var entity = await CreateDraftContentAsync(value, newVersion); var entity = await MongoContentEntity.CreateDraftAsync(job, appProvider);
await collectionAll.UpsertVersionedAsync(entity.DocumentId, oldVersion, entity, ct); await collectionAll.UpsertVersionedAsync(entity.DocumentId, job.OldVersion, entity, ct);
} }
private async Task UpsertPublishedContentAsync(ContentDomainObject.State value, long oldVersion, long newVersion, private async Task UpsertPublishedContentAsync(SnapshotWriteJob<ContentDomainObject.State> job,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var entity = await CreatePublishedContentAsync(value, newVersion); var entity = await MongoContentEntity.CreatePublishedAsync(job, appProvider);
await collectionPublished.UpsertVersionedAsync(entity.DocumentId, oldVersion, entity, ct); await collectionPublished.UpsertVersionedAsync(entity.DocumentId, job.OldVersion, entity, ct);
} }
private async Task<MongoContentEntity> CreatePublishedContentAsync(ContentDomainObject.State value, long newVersion) private static bool ShouldWritePublished(ContentDomainObject.State value)
{
var entity = await CreateContentAsync(value, value.CurrentVersion.Data, newVersion);
entity.ScheduledAt = null;
entity.ScheduleJob = null;
entity.NewStatus = null;
return entity;
}
private async Task<MongoContentEntity> CreateDraftContentAsync(ContentDomainObject.State value, long newVersion)
{
var entity = await CreateContentAsync(value, value.Data, newVersion);
entity.ScheduledAt = value.ScheduleJob?.DueTime;
entity.ScheduleJob = value.ScheduleJob;
entity.NewStatus = value.NewStatus;
return entity;
}
private async Task<MongoContentEntity> CreateContentAsync(ContentDomainObject.State value, ContentData data, long newVersion)
{ {
var entity = SimpleMapper.Map(value, new MongoContentEntity()); // Only published content is written to the published collection.
return value.Status == Status.Published && !value.IsDeleted;
entity.Data = data;
entity.DocumentId = value.UniqueId;
entity.IndexedAppId = value.AppId.Id;
entity.IndexedSchemaId = value.SchemaId.Id;
entity.ReferencedIds ??= new HashSet<DomainId>();
entity.Version = newVersion;
if (data.CanHaveReference())
{
var schema = await appProvider.GetSchemaAsync(value.AppId.Id, value.SchemaId.Id, true);
if (schema != null)
{
var components = await appProvider.GetComponentsAsync(schema);
entity.Data.AddReferencedIds(schema.SchemaDef, entity.ReferencedIds, components);
}
}
return entity;
} }
private static bool ShouldWritePublished(ContentDomainObject.State value) private static bool IsValid(SnapshotWriteJob<ContentDomainObject.State> job)
{ {
return value.Status == Status.Published && !value.IsDeleted; // 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;
} }
} }
} }

15
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByIds.cs

@ -47,13 +47,16 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
var contentEntities = await FindContentsAsync(q.Query, filter); var contentEntities = await FindContentsAsync(q.Query, filter);
var contentTotal = (long)contentEntities.Count; var contentTotal = (long)contentEntities.Count;
if (q.NoTotal) if (contentTotal >= q.Query.Take || q.Query.Skip > 0)
{ {
contentTotal = -1; if (q.NoTotal)
} {
else if (contentTotal >= q.Query.Take || q.Query.Skip > 0) contentTotal = -1;
{ }
contentTotal = await Collection.Find(filter).CountDocumentsAsync(ct); else
{
contentTotal = await Collection.Find(filter).CountDocumentsAsync(ct);
}
} }
return ResultList.Create(contentTotal, contentEntities); return ResultList.Create(contentTotal, contentEntities);

71
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryByQuery.cs

@ -21,6 +21,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
internal sealed class QueryByQuery : OperationBase internal sealed class QueryByQuery : OperationBase
{ {
private readonly IAppProvider appProvider; private readonly IAppProvider appProvider;
private readonly MongoCountCollection countCollection;
[BsonIgnoreExtraElements] [BsonIgnoreExtraElements]
internal sealed class IdOnly internal sealed class IdOnly
@ -32,9 +33,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
public MongoContentEntity[] Joined { get; set; } public MongoContentEntity[] Joined { get; set; }
} }
public QueryByQuery(IAppProvider appProvider) public QueryByQuery(IAppProvider appProvider, MongoCountCollection countCollection)
{ {
this.appProvider = appProvider; this.appProvider = appProvider;
this.countCollection = countCollection;
} }
public override IEnumerable<CreateIndexModel<MongoContentEntity>> CreateIndexes() public override IEnumerable<CreateIndexModel<MongoContentEntity>> CreateIndexes()
@ -93,18 +95,25 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
{ {
var query = q.Query.AdjustToModel(app.Id); var query = q.Query.AdjustToModel(app.Id);
var filter = 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 contentEntities = await FindContentsAsync(query, filter, ct);
var contentTotal = (long)contentEntities.Count; var contentTotal = (long)contentEntities.Count;
if (q.NoTotal) if (contentTotal >= q.Query.Take || q.Query.Skip > 0)
{ {
contentTotal = -1; if (q.NoTotal || (q.NoSlowTotal && q.Query.Filter != null))
} {
else if (contentTotal >= q.Query.Take || q.Query.Skip > 0) contentTotal = -1;
{ }
contentTotal = await Collection.Find(filter).CountDocumentsAsync(ct); else if (IsSatisfiedByIndex(query))
{
contentTotal = await Collection.Find(filter).QuerySort(query).CountDocumentsAsync(ct);
}
else
{
contentTotal = await Collection.Find(filter).CountDocumentsAsync(ct);
}
} }
return ResultList.Create<IContentEntity>(contentTotal, contentEntities); return ResultList.Create<IContentEntity>(contentTotal, contentEntities);
@ -130,18 +139,32 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
{ {
var query = q.Query.AdjustToModel(app.Id); var query = q.Query.AdjustToModel(app.Id);
var filter = CreateFilter(schema.AppId.Id, Enumerable.Repeat(schema.Id, 1), query, q.Reference, q.CreatedBy); 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 contentEntities = await FindContentsAsync(query, filter, ct);
var contentTotal = (long)contentEntities.Count; var contentTotal = (long)contentEntities.Count;
if (q.NoTotal) if (contentTotal >= q.Query.Take || q.Query.Skip > 0)
{
contentTotal = -1;
}
else if (contentTotal >= q.Query.Take || q.Query.Skip > 0)
{ {
contentTotal = await Collection.Find(filter).CountDocumentsAsync(ct); 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);
}
} }
return ResultList.Create<IContentEntity>(contentTotal, contentEntities); return ResultList.Create<IContentEntity>(contentTotal, contentEntities);
@ -224,38 +247,48 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
return Filter.And(filters); return Filter.And(filters);
} }
private static FilterDefinition<MongoContentEntity> 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) DomainId referenced, RefToken? createdBy)
{ {
var filters = new List<FilterDefinition<MongoContentEntity>> var filters = new List<FilterDefinition<MongoContentEntity>>
{ {
Filter.Exists(x => x.LastModified), Filter.Gt(x => x.LastModified, default),
Filter.Exists(x => x.Id), Filter.Gt(x => x.Id, default),
Filter.Eq(x => x.IndexedAppId, appId), Filter.Eq(x => x.IndexedAppId, appId),
Filter.In(x => x.IndexedSchemaId, schemaIds) Filter.In(x => x.IndexedSchemaId, schemaIds)
}; };
var isDefault = false;
if (query?.HasFilterField("dl") != true) if (query?.HasFilterField("dl") != true)
{ {
filters.Add(Filter.Ne(x => x.IsDeleted, true)); filters.Add(Filter.Ne(x => x.IsDeleted, true));
isDefault = true;
} }
if (query?.Filter != null) if (query?.Filter != null)
{ {
filters.Add(query.Filter.BuildFilter<MongoContentEntity>()); filters.Add(query.Filter.BuildFilter<MongoContentEntity>());
isDefault = false;
} }
if (referenced != default) if (referenced != default)
{ {
filters.Add(Filter.AnyEq(x => x.ReferencedIds, referenced)); filters.Add(Filter.AnyEq(x => x.ReferencedIds, referenced));
isDefault = false;
} }
if (createdBy != null) if (createdBy != null)
{ {
filters.Add(Filter.Eq(x => x.CreatedBy, createdBy)); filters.Add(Filter.Eq(x => x.CreatedBy, createdBy));
isDefault = false;
} }
return Filter.And(filters); return (Filter.And(filters), isDefault);
} }
} }
} }

87
backend/src/Squidex.Domain.Apps.Entities.MongoDb/MongoCountCollection.cs

@ -0,0 +1,87 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using MongoDB.Driver;
using NodaTime;
using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.MongoDb
{
internal sealed class MongoCountCollection : MongoRepositoryBase<MongoCountEntity>
{
private readonly string name;
public MongoCountCollection(IMongoDatabase database, string name)
: base(database)
{
this.name = $"{name}_Count";
InitializeAsync(default).Wait();
}
protected override string CollectionName()
{
return name;
}
public async Task<long> GetOrAddAsync(string key, Func<CancellationToken, Task<long>> provider,
CancellationToken ct)
{
var (cachedTotal, isOutdated) = await CountAsync(key, ct);
if (cachedTotal < 5_000)
{
return await RefreshTotalAsync(key, cachedTotal, provider, ct);
}
if (isOutdated)
{
// If we have a loot of items, the query might be slow and therefore we execute it in the background.
RefreshTotalAsync(key, cachedTotal, provider, ct).Forget();
}
return cachedTotal;
}
private async Task<long> RefreshTotalAsync(string key, long cachedCount, Func<CancellationToken, Task<long>> provider,
CancellationToken ct)
{
var actualCount = await provider(ct);
if (actualCount != cachedCount)
{
var now = SystemClock.Instance.GetCurrentInstant();
await Collection.UpdateOneAsync(x => x.Key == key,
Update
.Set(x => x.Key, key)
.SetOnInsert(x => x.Count, actualCount)
.SetOnInsert(x => x.Created, now),
Upsert, ct);
}
return actualCount;
}
private async Task<(long, bool)> CountAsync(string key,
CancellationToken ct)
{
var entity = await Collection.Find(x => x.Key == key).FirstOrDefaultAsync(ct);
if (entity != null)
{
var now = SystemClock.Instance.GetCurrentInstant();
return (entity.Count, now - entity.Created > Duration.FromSeconds(10));
}
return (0, true);
}
}
}

25
backend/src/Squidex.Domain.Apps.Entities.MongoDb/MongoCountEntity.cs

@ -0,0 +1,25 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using MongoDB.Bson.Serialization.Attributes;
using NodaTime;
namespace Squidex.Domain.Apps.Entities.MongoDb
{
internal sealed class MongoCountEntity
{
[BsonId]
[BsonRequired]
public string Key { get; set; }
[BsonElement]
public long Count { get; set; }
[BsonElement]
public Instant Created { get; set; }
}
}

4
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryParser.cs

@ -57,6 +57,10 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
{ {
q = q.WithoutTotal(); q = q.WithoutTotal();
} }
else if (context.ShouldSkipSlowTotal())
{
q = q.WithoutSlowTotal();
}
return q; return q;
} }

3
backend/src/Squidex.Domain.Apps.Entities/Assets/Transformations.cs

@ -77,7 +77,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
public static async Task<string?> GetBlurHashAsync(this AssetRef asset, BlurOptions options, public static async Task<string?> GetBlurHashAsync(this AssetRef asset, BlurOptions options,
IAssetFileStore assetFileStore, IAssetFileStore assetFileStore,
IAssetThumbnailGenerator thumbnailGenerator, CancellationToken ct = default) IAssetThumbnailGenerator thumbnailGenerator,
CancellationToken ct = default)
{ {
using (var stream = DefaultPools.MemoryStream.GetStream()) using (var stream = DefaultPools.MemoryStream.GetStream())
{ {

2
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOptions.cs

@ -11,6 +11,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
public bool CanCache { get; set; } public bool CanCache { get; set; }
public bool OptimizeTotal { get; set; } = true;
public int DefaultPageSize { get; set; } = 200; public int DefaultPageSize { get; set; } = 200;
public int MaxResults { get; set; } = 200; public int MaxResults { get; set; } = 200;

2
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsSearchSource.cs

@ -64,7 +64,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
var appId = context.App.NamedId(); var appId = context.App.NamedId();
var contents = await contentQuery.QueryAsync(context, Q.Empty.WithIds(ids), ct); var contents = await contentQuery.QueryAsync(context, Q.Empty.WithIds(ids).WithoutTotal(), ct);
foreach (var content in contents) foreach (var content in contents)
{ {

4
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs

@ -64,6 +64,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
{ {
q = q.WithoutTotal(); q = q.WithoutTotal();
} }
else if (context.ShouldSkipSlowTotal())
{
q = q.WithoutSlowTotal();
}
return q; return q;
} }

2
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveReferences.cs

@ -162,7 +162,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
.WithoutContentEnrichment(true) .WithoutContentEnrichment(true)
.WithoutTotal()); .WithoutTotal());
var references = await ContentQuery.QueryAsync(queryContext, Q.Empty.WithIds(ids), ct); var references = await ContentQuery.QueryAsync(queryContext, Q.Empty.WithIds(ids).WithoutTotal(), ct);
return references.ToLookup(x => x.Id); return references.ToLookup(x => x.Id);
} }

11
backend/src/Squidex.Domain.Apps.Entities/ContextExtensions.cs

@ -10,6 +10,7 @@ namespace Squidex.Domain.Apps.Entities
public static class ContextExtensions public static class ContextExtensions
{ {
private const string HeaderNoTotal = "X-NoTotal"; private const string HeaderNoTotal = "X-NoTotal";
private const string HeaderNoSlowTotal = "X-NoSlowTotal";
public static bool ShouldSkipTotal(this Context context) public static bool ShouldSkipTotal(this Context context)
{ {
@ -21,6 +22,16 @@ namespace Squidex.Domain.Apps.Entities
return builder.WithBoolean(HeaderNoTotal, value); return builder.WithBoolean(HeaderNoTotal, value);
} }
public static bool ShouldSkipSlowTotal(this Context context)
{
return context.Headers.ContainsKey(HeaderNoSlowTotal);
}
public static ICloneBuilder WithoutSlowTotal(this ICloneBuilder builder, bool value = true)
{
return builder.WithBoolean(HeaderNoSlowTotal, value);
}
public static ICloneBuilder WithBoolean(this ICloneBuilder builder, string key, bool value) public static ICloneBuilder WithBoolean(this ICloneBuilder builder, string key, bool value)
{ {
if (value) if (value)

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

@ -38,6 +38,8 @@ namespace Squidex.Domain.Apps.Entities
public bool NoTotal { get; init; } public bool NoTotal { get; init; }
public bool NoSlowTotal { get; init; }
private Q() private Q()
{ {
} }
@ -54,6 +56,11 @@ namespace Squidex.Domain.Apps.Entities
return this with { NoTotal = value }; return this with { NoTotal = value };
} }
public Q WithoutSlowTotal(bool value = true)
{
return this with { NoSlowTotal = value };
}
public Q WithODataQuery(string? query) public Q WithODataQuery(string? query)
{ {
return this with { QueryAsOdata = query }; return this with { QueryAsOdata = query };

6
backend/src/Squidex.Domain.Users/DefaultKeyStore.cs

@ -47,7 +47,7 @@ namespace Squidex.Domain.Users
private async Task<RsaSecurityKey> GetOrCreateKeyAsync() private async Task<RsaSecurityKey> GetOrCreateKeyAsync()
{ {
var (state, _, _) = await store.ReadAsync(default); var (_, state, _, _) = await store.ReadAsync(default);
RsaSecurityKey securityKey; RsaSecurityKey securityKey;
@ -75,13 +75,13 @@ namespace Squidex.Domain.Users
try try
{ {
await store.WriteAsync(default, state, 0, 0); await store.WriteAsync(new SnapshotWriteJob<State>(default, state, 0));
return securityKey; return securityKey;
} }
catch (InconsistentStateException) catch (InconsistentStateException)
{ {
(state, _, _) = await store.ReadAsync(default); (_, state, _, _) = await store.ReadAsync(default);
} }
} }

4
backend/src/Squidex.Domain.Users/DefaultXmlRepository.cs

@ -50,12 +50,12 @@ namespace Squidex.Domain.Users
{ {
var state = new State(element); var state = new State(element);
store.WriteAsync(DomainId.Create(friendlyName), state, EtagVersion.Any, 0); store.WriteAsync(new SnapshotWriteJob<State>(DomainId.Create(friendlyName), state, 0));
} }
private async Task<IReadOnlyCollection<XElement>> GetAllElementsAsync() private async Task<IReadOnlyCollection<XElement>> GetAllElementsAsync()
{ {
return await store.ReadAllAsync().Select(x => x.State.ToXml()).ToListAsync(); return await store.ReadAllAsync().Select(x => x.Value.ToXml()).ToListAsync();
} }
} }
} }

54
backend/src/Squidex.Infrastructure.MongoDb/MongoDb/InstantSerializer.cs

@ -9,10 +9,11 @@ using MongoDB.Bson;
using MongoDB.Bson.Serialization; using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers; using MongoDB.Bson.Serialization.Serializers;
using NodaTime; using NodaTime;
using NodaTime.Text;
namespace Squidex.Infrastructure.MongoDb namespace Squidex.Infrastructure.MongoDb
{ {
public sealed class InstantSerializer : SerializerBase<Instant>, IBsonPolymorphicSerializer public sealed class InstantSerializer : SerializerBase<Instant>, IBsonPolymorphicSerializer, IRepresentationConfigurable<InstantSerializer>
{ {
public static void Register() public static void Register()
{ {
@ -31,16 +32,61 @@ namespace Squidex.Infrastructure.MongoDb
get => true; get => true;
} }
public BsonType Representation { get; }
public InstantSerializer(BsonType representation = BsonType.DateTime)
{
if (representation != BsonType.DateTime && representation != BsonType.Int64 && representation != BsonType.String)
{
throw new ArgumentException("Unsupported representation.", nameof(representation));
}
Representation = representation;
}
public override Instant Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) public override Instant Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
{ {
var value = context.Reader.ReadDateTime(); var reader = context.Reader;
switch (reader.CurrentBsonType)
{
case BsonType.DateTime:
return Instant.FromUnixTimeMilliseconds(context.Reader.ReadDateTime());
case BsonType.Int64:
return Instant.FromUnixTimeMilliseconds(context.Reader.ReadInt64());
case BsonType.String:
return InstantPattern.ExtendedIso.Parse(context.Reader.ReadString()).Value;
}
return Instant.FromUnixTimeMilliseconds(value); throw new NotSupportedException("Unsupported Representation.");
} }
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, Instant value) public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, Instant value)
{ {
context.Writer.WriteDateTime(value.ToUnixTimeMilliseconds()); switch (Representation)
{
case BsonType.DateTime:
context.Writer.WriteDateTime(value.ToUnixTimeMilliseconds());
return;
case BsonType.Int64:
context.Writer.WriteInt64(value.ToUnixTimeMilliseconds());
return;
case BsonType.String:
context.Writer.WriteString(InstantPattern.ExtendedIso.Format(value));
return;
}
throw new NotSupportedException("Unsupported Representation.");
}
public InstantSerializer WithRepresentation(BsonType representation)
{
return Representation == representation ? this : new InstantSerializer(representation);
}
IBsonSerializer IRepresentationConfigurable.WithRepresentation(BsonType representation)
{
return WithRepresentation(representation);
} }
} }
} }

33
backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs

@ -104,7 +104,22 @@ namespace Squidex.Infrastructure.MongoDb
return find.Project<T>(Builders<T>.Projection.Exclude(exclude1).Exclude(exclude2)); return find.Project<T>(Builders<T>.Projection.Exclude(exclude1).Exclude(exclude2));
} }
public static async Task UpsertVersionedAsync<T, TKey>(this IMongoCollection<T> collection, TKey key, long oldVersion, long newVersion, T document, public static long ToLong(this BsonValue value)
{
switch (value.BsonType)
{
case BsonType.Int32:
return value.AsInt32;
case BsonType.Int64:
return value.AsInt64;
case BsonType.Double:
return (long)value.AsDouble;
default:
throw new InvalidCastException($"Cannot cast from {value.BsonType} to long.");
}
}
public static async Task<bool> UpsertVersionedAsync<T, TKey>(this IMongoCollection<T> collection, TKey key, long oldVersion, long newVersion, T document,
CancellationToken ct = default) CancellationToken ct = default)
where T : IVersionedEntity<TKey> where TKey : notnull where T : IVersionedEntity<TKey> where TKey : notnull
{ {
@ -113,14 +128,14 @@ namespace Squidex.Infrastructure.MongoDb
document.DocumentId = key; document.DocumentId = key;
document.Version = newVersion; document.Version = newVersion;
if (oldVersion > EtagVersion.Any) Expression<Func<T, bool>> filter =
{ oldVersion > EtagVersion.Any ?
await collection.ReplaceOneAsync(x => x.DocumentId.Equals(key) && x.Version == oldVersion, document, UpsertReplace, ct); x => x.DocumentId.Equals(key) && x.Version == oldVersion :
} x => x.DocumentId.Equals(key);
else
{ var result = await collection.ReplaceOneAsync(filter, document, UpsertReplace, ct);
await collection.ReplaceOneAsync(x => x.DocumentId.Equals(key), document, UpsertReplace, ct);
} return result.IsAcknowledged && result.ModifiedCount == 1;
} }
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{ {

22
backend/src/Squidex.Infrastructure.MongoDb/States/MongoSnapshotStoreBase.cs

@ -38,7 +38,7 @@ namespace Squidex.Infrastructure.States
return $"States_{name}"; return $"States_{name}";
} }
public async Task<(T Value, bool Valid, long Version)> ReadAsync(DomainId key, public async Task<SnapshotResult<T>> ReadAsync(DomainId key,
CancellationToken ct = default) CancellationToken ct = default)
{ {
using (Telemetry.Activities.StartActivity("MongoSnapshotStoreBase/ReadAsync")) using (Telemetry.Activities.StartActivity("MongoSnapshotStoreBase/ReadAsync"))
@ -49,31 +49,31 @@ namespace Squidex.Infrastructure.States
if (existing != null) if (existing != null)
{ {
return (existing.Document, true, existing.Version); return new SnapshotResult<T>(existing.DocumentId, existing.Document, existing.Version);
} }
return (default!, true, EtagVersion.Empty); return new SnapshotResult<T>(default, default!, EtagVersion.Empty);
} }
} }
public async Task WriteAsync(DomainId key, T value, long oldVersion, long newVersion, public async Task WriteAsync(SnapshotWriteJob<T> job,
CancellationToken ct = default) CancellationToken ct = default)
{ {
using (Telemetry.Activities.StartActivity("MongoSnapshotStoreBase/WriteAsync")) using (Telemetry.Activities.StartActivity("MongoSnapshotStoreBase/WriteAsync"))
{ {
var document = CreateDocument(key, value, newVersion); var document = CreateDocument(job.Key, job.Value, job.OldVersion);
await Collection.UpsertVersionedAsync(key, oldVersion, newVersion, document, ct); await Collection.UpsertVersionedAsync(job.Key, job.OldVersion, job.NewVersion, document, ct);
} }
} }
public async Task WriteManyAsync(IEnumerable<(DomainId Key, T Value, long Version)> snapshots, public async Task WriteManyAsync(IEnumerable<SnapshotWriteJob<T>> jobs,
CancellationToken ct = default) CancellationToken ct = default)
{ {
using (Telemetry.Activities.StartActivity("MongoSnapshotStoreBase/WriteManyAsync")) using (Telemetry.Activities.StartActivity("MongoSnapshotStoreBase/WriteManyAsync"))
{ {
var writes = snapshots.Select(x => var writes = jobs.Select(x =>
new ReplaceOneModel<TState>(Filter.Eq(y => y.DocumentId, x.Key), CreateDocument(x.Key, x.Value, x.Version)) new ReplaceOneModel<TState>(Filter.Eq(y => y.DocumentId, x.Key), CreateDocument(x.Key, x.Value, x.NewVersion))
{ {
IsUpsert = true IsUpsert = true
}).ToList(); }).ToList();
@ -96,7 +96,7 @@ namespace Squidex.Infrastructure.States
} }
} }
public async IAsyncEnumerable<(T State, long Version)> ReadAllAsync( public async IAsyncEnumerable<SnapshotResult<T>> ReadAllAsync(
[EnumeratorCancellation] CancellationToken ct = default) [EnumeratorCancellation] CancellationToken ct = default)
{ {
using (Telemetry.Activities.StartActivity("MongoSnapshotStoreBase/ReadAllAsync")) using (Telemetry.Activities.StartActivity("MongoSnapshotStoreBase/ReadAllAsync"))
@ -105,7 +105,7 @@ namespace Squidex.Infrastructure.States
await foreach (var document in find.ToAsyncEnumerable(ct)) await foreach (var document in find.ToAsyncEnumerable(ct))
{ {
yield return (document.Document, document.Version); yield return new SnapshotResult<T>(document.DocumentId, document.Document, document.Version, true);
} }
} }
} }

1
backend/src/Squidex.Infrastructure/EventSourcing/EnvelopeExtensions.cs

@ -8,7 +8,6 @@
using System.Globalization; using System.Globalization;
using NodaTime; using NodaTime;
using NodaTime.Text; using NodaTime.Text;
using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Infrastructure.EventSourcing namespace Squidex.Infrastructure.EventSourcing
{ {

5
backend/src/Squidex.Infrastructure/InstantExtensions.cs

@ -15,5 +15,10 @@ namespace Squidex.Infrastructure
{ {
return Instant.FromUnixTimeSeconds(value.ToUnixTimeSeconds()); return Instant.FromUnixTimeSeconds(value.ToUnixTimeSeconds());
} }
public static Instant WithoutNs(this Instant value)
{
return Instant.FromUnixTimeMilliseconds(value.ToUnixTimeMilliseconds());
}
} }
} }

1
backend/src/Squidex.Infrastructure/Json/Newtonsoft/JsonValueConverter.cs

@ -6,7 +6,6 @@
// ========================================================================== // ==========================================================================
using Newtonsoft.Json; using Newtonsoft.Json;
using Squidex.Infrastructure.Collections;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
#pragma warning disable RECS0018 // Comparison of floating point numbers with equality operator #pragma warning disable RECS0018 // Comparison of floating point numbers with equality operator

2
backend/src/Squidex.Infrastructure/Json/Objects/JsonObject.cs

@ -5,8 +5,6 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Infrastructure.Collections;
namespace Squidex.Infrastructure.Json.Objects namespace Squidex.Infrastructure.Json.Objects
{ {
public class JsonObject : Dictionary<string, JsonValue>, IEquatable<JsonObject> public class JsonObject : Dictionary<string, JsonValue>, IEquatable<JsonObject>

2
backend/src/Squidex.Infrastructure/Orleans/GrainState.cs

@ -57,7 +57,7 @@ namespace Squidex.Infrastructure.Orleans
persistence = factory.WithSnapshots(GetType(), key, ApplyState); persistence = factory.WithSnapshots(GetType(), key, ApplyState);
return persistence.ReadAsync(); return persistence.ReadAsync(ct: ct);
} }
private void ApplyState(T value, long version) private void ApplyState(T value, long version)

12
backend/src/Squidex.Infrastructure/States/BatchContext.cs

@ -20,7 +20,7 @@ namespace Squidex.Infrastructure.States
private readonly IEventDataFormatter eventDataFormatter; private readonly IEventDataFormatter eventDataFormatter;
private readonly IStreamNameResolver streamNameResolver; private readonly IStreamNameResolver streamNameResolver;
private readonly Dictionary<DomainId, (long, List<Envelope<IEvent>>)> @events = new Dictionary<DomainId, (long, List<Envelope<IEvent>>)>(); private readonly Dictionary<DomainId, (long, List<Envelope<IEvent>>)> @events = new Dictionary<DomainId, (long, List<Envelope<IEvent>>)>();
private Dictionary<DomainId, (T Snapshot, long Version)>? snapshots; private Dictionary<DomainId, SnapshotWriteJob<T>>? snapshots;
internal BatchContext( internal BatchContext(
Type owner, Type owner,
@ -38,11 +38,11 @@ namespace Squidex.Infrastructure.States
internal void Add(DomainId key, T snapshot, long version) internal void Add(DomainId key, T snapshot, long version)
{ {
snapshots ??= new Dictionary<DomainId, (T Snapshot, long Version)>(); snapshots ??= new ();
if (!snapshots.TryGetValue(key, out var existing) || existing.Version < version) if (!snapshots.TryGetValue(key, out var existing) || existing.NewVersion < version)
{ {
snapshots[key] = (snapshot, version); snapshots[key] = new SnapshotWriteJob<T>(key, snapshot, version);
} }
} }
@ -86,9 +86,7 @@ namespace Squidex.Infrastructure.States
return Task.CompletedTask; return Task.CompletedTask;
} }
var list = current.Select(x => (x.Key, x.Value.Snapshot, x.Value.Version)); return snapshotStore.WriteManyAsync(current.Values);
return snapshotStore.WriteManyAsync(list);
} }
public IPersistence<T> WithEventSourcing(Type owner, DomainId key, HandleEvent? applyEvent) public IPersistence<T> WithEventSourcing(Type owner, DomainId key, HandleEvent? applyEvent)

12
backend/src/Squidex.Infrastructure/States/BatchPersistence.cs

@ -31,17 +31,20 @@ namespace Squidex.Infrastructure.States
Version = version; Version = version;
} }
public Task DeleteAsync() public Task DeleteAsync(
CancellationToken ct = default)
{ {
throw new NotSupportedException(); throw new NotSupportedException();
} }
public Task WriteEventsAsync(IReadOnlyList<Envelope<IEvent>> events) public Task WriteEventsAsync(IReadOnlyList<Envelope<IEvent>> events,
CancellationToken ct = default)
{ {
throw new NotSupportedException(); throw new NotSupportedException();
} }
public Task ReadAsync(long expectedVersion = -2) public Task ReadAsync(long expectedVersion = -2,
CancellationToken ct = default)
{ {
if (applyEvent != null) if (applyEvent != null)
{ {
@ -69,7 +72,8 @@ namespace Squidex.Infrastructure.States
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task WriteSnapshotAsync(T state) public Task WriteSnapshotAsync(T state,
CancellationToken ct = default)
{ {
context.Add(ownerKey, state, Version); context.Add(ownerKey, state, Version);

12
backend/src/Squidex.Infrastructure/States/IPersistence.cs

@ -15,12 +15,16 @@ namespace Squidex.Infrastructure.States
bool IsSnapshotStale { get; } bool IsSnapshotStale { get; }
Task DeleteAsync(); Task DeleteAsync(
CancellationToken ct = default);
Task WriteEventsAsync(IReadOnlyList<Envelope<IEvent>> events); Task WriteEventsAsync(IReadOnlyList<Envelope<IEvent>> events,
CancellationToken ct = default);
Task WriteSnapshotAsync(TState state); Task WriteSnapshotAsync(TState state,
CancellationToken ct = default);
Task ReadAsync(long expectedVersion = EtagVersion.Any); Task ReadAsync(long expectedVersion = EtagVersion.Any,
CancellationToken ct = default);
} }
} }

16
backend/src/Squidex.Infrastructure/States/ISnapshotStore.cs

@ -5,17 +5,20 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
#pragma warning disable MA0048 // File name must match type name
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
namespace Squidex.Infrastructure.States namespace Squidex.Infrastructure.States
{ {
public interface ISnapshotStore<T> public interface ISnapshotStore<T>
{ {
Task WriteAsync(DomainId key, T value, long oldVersion, long newVersion, Task WriteAsync(SnapshotWriteJob<T> job,
CancellationToken ct = default); CancellationToken ct = default);
Task WriteManyAsync(IEnumerable<(DomainId Key, T Value, long Version)> snapshots, Task WriteManyAsync(IEnumerable<SnapshotWriteJob<T>> jobs,
CancellationToken ct = default); CancellationToken ct = default);
Task<(T Value, bool Valid, long Version)> ReadAsync(DomainId key, Task<SnapshotResult<T>> ReadAsync(DomainId key,
CancellationToken ct = default); CancellationToken ct = default);
Task ClearAsync( Task ClearAsync(
@ -24,7 +27,12 @@ namespace Squidex.Infrastructure.States
Task RemoveAsync(DomainId key, Task RemoveAsync(DomainId key,
CancellationToken ct = default); CancellationToken ct = default);
IAsyncEnumerable<(T State, long Version)> ReadAllAsync( IAsyncEnumerable<SnapshotResult<T>> ReadAllAsync(
CancellationToken ct = default); CancellationToken ct = default);
} }
public record struct SnapshotResult<T>(DomainId Key, T Value, long Version,
bool IsValid = true);
public record struct SnapshotWriteJob<T>(DomainId Key, T Value, long NewVersion, long OldVersion = EtagVersion.Any);
} }

39
backend/src/Squidex.Infrastructure/States/Persistence.cs

@ -65,13 +65,14 @@ namespace Squidex.Infrastructure.States
streamName = new Lazy<string>(() => streamNameResolver.GetStreamName(ownerType, ownerKey.ToString()!)); streamName = new Lazy<string>(() => streamNameResolver.GetStreamName(ownerType, ownerKey.ToString()!));
} }
public async Task DeleteAsync() public async Task DeleteAsync(
CancellationToken ct = default)
{ {
if (UseSnapshots) if (UseSnapshots)
{ {
using (Telemetry.Activities.StartActivity("Persistence/ReadState")) using (Telemetry.Activities.StartActivity("Persistence/ReadState"))
{ {
await snapshotStore.RemoveAsync(ownerKey); await snapshotStore.RemoveAsync(ownerKey, ct);
} }
} }
@ -79,24 +80,25 @@ namespace Squidex.Infrastructure.States
{ {
using (Telemetry.Activities.StartActivity("Persistence/ReadEvents")) using (Telemetry.Activities.StartActivity("Persistence/ReadEvents"))
{ {
await eventStore.DeleteStreamAsync(streamName.Value); await eventStore.DeleteStreamAsync(streamName.Value, ct);
} }
} }
} }
public async Task ReadAsync(long expectedVersion = EtagVersion.Any) public async Task ReadAsync(long expectedVersion = EtagVersion.Any,
CancellationToken ct = default)
{ {
versionSnapshot = EtagVersion.Empty; versionSnapshot = EtagVersion.Empty;
versionEvents = EtagVersion.Empty; versionEvents = EtagVersion.Empty;
if (UseSnapshots) if (UseSnapshots)
{ {
await ReadSnapshotAsync(); await ReadSnapshotAsync(ct);
} }
if (UseEventSourcing) if (UseEventSourcing)
{ {
await ReadEventsAsync(); await ReadEventsAsync(ct);
} }
UpdateVersion(); UpdateVersion();
@ -114,9 +116,10 @@ namespace Squidex.Infrastructure.States
} }
} }
private async Task ReadSnapshotAsync() private async Task ReadSnapshotAsync(
CancellationToken ct)
{ {
var (state, valid, version) = await snapshotStore.ReadAsync(ownerKey); var (_, state, version, valid) = await snapshotStore.ReadAsync(ownerKey, ct);
version = Math.Max(version, EtagVersion.Empty); version = Math.Max(version, EtagVersion.Empty);
versionSnapshot = version; versionSnapshot = version;
@ -132,9 +135,10 @@ namespace Squidex.Infrastructure.States
} }
} }
private async Task ReadEventsAsync() private async Task ReadEventsAsync(
CancellationToken ct)
{ {
var events = await eventStore.QueryAsync(streamName.Value, versionEvents + 1); var events = await eventStore.QueryAsync(streamName.Value, versionEvents + 1, ct);
var isStopped = false; var isStopped = false;
@ -161,7 +165,8 @@ namespace Squidex.Infrastructure.States
} }
} }
public async Task WriteSnapshotAsync(T state) public async Task WriteSnapshotAsync(T state,
CancellationToken ct = default)
{ {
var oldVersion = versionSnapshot; var oldVersion = versionSnapshot;
@ -179,7 +184,12 @@ namespace Squidex.Infrastructure.States
using (Telemetry.Activities.StartActivity("Persistence/WriteState")) using (Telemetry.Activities.StartActivity("Persistence/WriteState"))
{ {
await snapshotStore.WriteAsync(ownerKey, state, oldVersion, newVersion); var job = new SnapshotWriteJob<T>(ownerKey, state, newVersion)
{
OldVersion = oldVersion
};
await snapshotStore.WriteAsync(job, ct);
} }
versionSnapshot = newVersion; versionSnapshot = newVersion;
@ -187,7 +197,8 @@ namespace Squidex.Infrastructure.States
UpdateVersion(); UpdateVersion();
} }
public async Task WriteEventsAsync(IReadOnlyList<Envelope<IEvent>> events) public async Task WriteEventsAsync(IReadOnlyList<Envelope<IEvent>> events,
CancellationToken ct = default)
{ {
Guard.NotEmpty(events); Guard.NotEmpty(events);
@ -205,7 +216,7 @@ namespace Squidex.Infrastructure.States
{ {
using (Telemetry.Activities.StartActivity("Persistence/WriteEvents")) using (Telemetry.Activities.StartActivity("Persistence/WriteEvents"))
{ {
await eventStore.AppendAsync(eventCommitId, streamName.Value, oldVersion, eventData); await eventStore.AppendAsync(eventCommitId, streamName.Value, oldVersion, eventData, ct);
} }
} }
catch (WrongEventVersionException ex) catch (WrongEventVersionException ex)

7
backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs

@ -170,13 +170,12 @@ namespace Squidex.Areas.Api.Controllers.Statistics
{ {
var appId = DomainId.Create(dataProtector.Unprotect(token)); var appId = DomainId.Create(dataProtector.Unprotect(token));
var today = DateTime.UtcNow.Date; var fileDate = DateTime.UtcNow.Date;
var fileName = $"Usage-{fileDate:yyy-MM-dd}.csv";
var fileName = $"Usage-{today:yyy-MM-dd}.csv";
var callback = new FileCallback((body, range, ct) => var callback = new FileCallback((body, range, ct) =>
{ {
return appLogStore.ReadLogAsync(appId, today.AddDays(-30), today, body, ct); return appLogStore.ReadLogAsync(appId, fileDate.AddDays(-30), fileDate, body, ct);
}); });
return new FileCallbackResult("text/csv", callback) return new FileCallbackResult("text/csv", callback)

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

@ -6,7 +6,6 @@
// ========================================================================== // ==========================================================================
using FluentFTP; using FluentFTP;
using MongoDB.Driver;
using MongoDB.Driver.GridFS; using MongoDB.Driver.GridFS;
using Squidex.Assets; using Squidex.Assets;
using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities;
@ -16,7 +15,6 @@ using Squidex.Domain.Apps.Entities.Assets.Queries;
using Squidex.Domain.Apps.Entities.History; using Squidex.Domain.Apps.Entities.History;
using Squidex.Domain.Apps.Entities.Search; using Squidex.Domain.Apps.Entities.Search;
using Squidex.Hosting; using Squidex.Hosting;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Orleans;
using tusdotnet.Interfaces; using tusdotnet.Interfaces;

1
backend/src/Squidex/Config/Domain/EventSourcingServices.cs

@ -7,7 +7,6 @@
using EventStore.Client; using EventStore.Client;
using MongoDB.Driver; using MongoDB.Driver;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Diagnostics; using Squidex.Infrastructure.Diagnostics;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;

89
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetMappingTests.cs

@ -0,0 +1,89 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using FluentAssertions;
using NodaTime;
using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Entities.Assets.DomainObject;
using Squidex.Domain.Apps.Entities.MongoDb.Assets;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Assets.MongoDb
{
public class AssetMappingTests
{
[Fact]
public void Should_map_asset()
{
var user = new RefToken(RefTokenType.Subject, "1");
var time = SystemClock.Instance.GetCurrentInstant();
var source = new AssetDomainObject.State
{
Id = DomainId.NewGuid(),
AppId = NamedId.Of(DomainId.NewGuid(), "my-app"),
Created = time,
CreatedBy = user,
FileHash = "my-hash",
FileName = "my-image.png",
FileSize = 1024,
FileVersion = 13,
IsDeleted = true,
IsProtected = true,
LastModified = time,
LastModifiedBy = user,
Metadata = new AssetMetadata().SetPixelHeight(600),
MimeType = "image/png",
ParentId = DomainId.NewGuid(),
Slug = "my-image",
Tags = new HashSet<string> { "image" },
TotalSize = 1024 * 2,
Type = AssetType.Image,
Version = 42,
};
var snapshotJob = new SnapshotWriteJob<AssetDomainObject.State>(source.UniqueId, source, source.Version);
var snapshot = MongoAssetEntity.Create(snapshotJob);
var mapped = snapshot.ToState();
mapped.Should().BeEquivalentTo(source);
}
[Fact]
public void Should_map_asset_folder()
{
var user = new RefToken(RefTokenType.Subject, "1");
var time = SystemClock.Instance.GetCurrentInstant();
var source = new AssetFolderDomainObject.State
{
Id = DomainId.NewGuid(),
AppId = NamedId.Of(DomainId.NewGuid(), "my-app"),
Created = time,
CreatedBy = user,
FolderName = "my-folder",
IsDeleted = true,
LastModified = time,
LastModifiedBy = user,
ParentId = DomainId.NewGuid(),
Version = 42,
};
var snapshotJob = new SnapshotWriteJob<AssetFolderDomainObject.State>(source.UniqueId, source, source.Version);
var snapshot = MongoAssetFolderEntity.Create(snapshotJob);
var mapped = snapshot.ToState();
mapped.Should().BeEquivalentTo(source);
}
}
}

4
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryFixture.cs

@ -36,11 +36,11 @@ namespace Squidex.Domain.Apps.Entities.Assets.MongoDb
public AssetsQueryFixture() public AssetsQueryFixture()
{ {
SetupJson();
mongoClient = new MongoClient(TestConfig.Configuration["mongodb:configuration"]); mongoClient = new MongoClient(TestConfig.Configuration["mongodb:configuration"]);
mongoDatabase = mongoClient.GetDatabase(TestConfig.Configuration["mongodb:database"]); mongoDatabase = mongoClient.GetDatabase(TestConfig.Configuration["mongodb:database"]);
SetupJson();
var assetRepository = new MongoAssetRepository(mongoDatabase); var assetRepository = new MongoAssetRepository(mongoDatabase);
Task.Run(async () => Task.Run(async () =>

159
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentMappingTests.cs

@ -0,0 +1,159 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using FakeItEasy;
using FluentAssertions;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents.DomainObject;
using Squidex.Domain.Apps.Entities.MongoDb.Contents;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents.MongoDb
{
public class ContentMappingTests
{
private readonly IAppProvider appProvider = A.Fake<IAppProvider>();
[Fact]
public async Task Should_map_content_without_new_version_to_draft()
{
var source = CreateContentWithoutNewVersion();
var snapshotJob = new SnapshotWriteJob<ContentDomainObject.State>(source.UniqueId, source, source.Version);
var snapshot = await MongoContentEntity.CreateDraftAsync(snapshotJob, appProvider);
Assert.Equal(source.CurrentVersion.Data, snapshot.Data);
Assert.Null(snapshot.DraftData);
Assert.Null(snapshot.NewStatus);
Assert.NotNull(snapshot.ScheduleJob);
Assert.True(snapshot.IsSnapshot);
var mapped = snapshot.ToState();
mapped.Should().BeEquivalentTo(source);
}
[Fact]
public async Task Should_map_content_without_new_version_to_published()
{
var source = CreateContentWithoutNewVersion();
var snapshotJob = new SnapshotWriteJob<ContentDomainObject.State>(source.UniqueId, source, source.Version);
var snapshot = await MongoContentEntity.CreatePublishedAsync(snapshotJob, appProvider);
Assert.Equal(source.CurrentVersion.Data, snapshot.Data);
Assert.Null(snapshot.DraftData);
Assert.Null(snapshot.NewStatus);
Assert.Null(snapshot.ScheduleJob);
Assert.False(snapshot.IsSnapshot);
}
[Fact]
public async Task Should_map_content_with_new_version_to_draft()
{
var source = CreateContentWithNewVersion();
var snapshotJob = new SnapshotWriteJob<ContentDomainObject.State>(source.UniqueId, source, source.Version);
var snapshot = await MongoContentEntity.CreateDraftAsync(snapshotJob, appProvider);
Assert.Equal(source.NewVersion?.Data, snapshot.Data);
Assert.Equal(source.CurrentVersion.Data, snapshot.DraftData);
Assert.NotNull(snapshot.NewStatus);
Assert.NotNull(snapshot.ScheduleJob);
Assert.True(snapshot.IsSnapshot);
var mapped = snapshot.ToState();
mapped.Should().BeEquivalentTo(source);
}
[Fact]
public async Task Should_map_content_with_new_version_to_published()
{
var source = CreateContentWithNewVersion();
var snapshotJob = new SnapshotWriteJob<ContentDomainObject.State>(source.UniqueId, source, source.Version);
var snapshot = await MongoContentEntity.CreatePublishedAsync(snapshotJob, appProvider);
Assert.Equal(source.CurrentVersion?.Data, snapshot.Data);
Assert.Null(snapshot.DraftData);
Assert.Null(snapshot.NewStatus);
Assert.Null(snapshot.ScheduleJob);
Assert.False(snapshot.IsSnapshot);
}
private static ContentDomainObject.State CreateContentWithoutNewVersion()
{
var user = new RefToken(RefTokenType.Subject, "1");
var data =
new ContentData()
.AddField("my-field",
new ContentFieldData()
.AddInvariant(42));
var time = SystemClock.Instance.GetCurrentInstant();
var state = new ContentDomainObject.State
{
Id = DomainId.NewGuid(),
AppId = NamedId.Of(DomainId.NewGuid(), "my-app"),
Created = time,
CreatedBy = user,
CurrentVersion = new ContentVersion(Status.Archived, data),
IsDeleted = true,
LastModified = time,
LastModifiedBy = user,
ScheduleJob = new ScheduleJob(DomainId.NewGuid(), Status.Published, user, time),
SchemaId = NamedId.Of(DomainId.NewGuid(), "my-schema"),
Version = 42,
};
return state;
}
private static ContentDomainObject.State CreateContentWithNewVersion()
{
var user = new RefToken(RefTokenType.Subject, "1");
var data =
new ContentData()
.AddField("my-field",
new ContentFieldData()
.AddInvariant(42));
var newData =
new ContentData()
.AddField("my-field",
new ContentFieldData()
.AddInvariant(13));
var time = SystemClock.Instance.GetCurrentInstant();
var state = new ContentDomainObject.State
{
Id = DomainId.NewGuid(),
AppId = NamedId.Of(DomainId.NewGuid(), "my-app"),
Created = time,
CreatedBy = user,
CurrentVersion = new ContentVersion(Status.Archived, data),
IsDeleted = true,
LastModified = time,
LastModifiedBy = user,
NewVersion = new ContentVersion(Status.Published, newData),
ScheduleJob = new ScheduleJob(DomainId.NewGuid(), Status.Published, user, time),
SchemaId = NamedId.Of(DomainId.NewGuid(), "my-schema"),
Version = 42,
};
return state;
}
}
}

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Properties/Resources.Designer.cs

@ -19,7 +19,7 @@ namespace Squidex.Domain.Apps.Entities.Properties {
// class via a tool like ResGen or Visual Studio. // class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen // To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project. // with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources { internal class Resources {

6
backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs

@ -64,10 +64,10 @@ namespace Squidex.Domain.Apps.Entities.TestHelpers
A.CallTo(() => persistenceFactory.WithEventSourcing(A<Type>._, Id, A<HandleEvent>._)) A.CallTo(() => persistenceFactory.WithEventSourcing(A<Type>._, Id, A<HandleEvent>._))
.Returns(persistence); .Returns(persistence);
A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>._)) A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>._, default))
.Invokes((IReadOnlyList<Envelope<IEvent>> events) => LastEvents = events); .Invokes((IReadOnlyList<Envelope<IEvent>> events, CancellationToken _) => LastEvents = events);
A.CallTo(() => persistence.DeleteAsync()) A.CallTo(() => persistence.DeleteAsync(default))
.Invokes(() => LastEvents = Enumerable.Empty<Envelope<IEvent>>()); .Invokes(() => LastEvents = Enumerable.Empty<Envelope<IEvent>>());
#pragma warning restore MA0056 // Do not call overridable members in constructor #pragma warning restore MA0056 // Do not call overridable members in constructor
} }

16
backend/tests/Squidex.Domain.Users.Tests/DefaultKeyStoreTests.cs

@ -30,7 +30,7 @@ namespace Squidex.Domain.Users
public void Should_configure_new_keys() public void Should_configure_new_keys()
{ {
A.CallTo(() => store.ReadAsync(A<DomainId>._, default)) A.CallTo(() => store.ReadAsync(A<DomainId>._, default))
.Returns((null!, true, 0)); .Returns(new SnapshotResult<DefaultKeyStore.State>(default, null!, 0));
var options = new OpenIddictServerOptions(); var options = new OpenIddictServerOptions();
@ -39,7 +39,7 @@ namespace Squidex.Domain.Users
Assert.NotEmpty(options.SigningCredentials); Assert.NotEmpty(options.SigningCredentials);
Assert.NotEmpty(options.EncryptionCredentials); Assert.NotEmpty(options.EncryptionCredentials);
A.CallTo(() => store.WriteAsync(A<DomainId>._, A<DefaultKeyStore.State>._, 0, 0, default)) A.CallTo(() => store.WriteAsync(A<SnapshotWriteJob<DefaultKeyStore.State>>._, default))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
} }
@ -47,7 +47,7 @@ namespace Squidex.Domain.Users
public void Should_configure_existing_keys() public void Should_configure_existing_keys()
{ {
A.CallTo(() => store.ReadAsync(A<DomainId>._, default)) A.CallTo(() => store.ReadAsync(A<DomainId>._, default))
.Returns((ExistingKey(), true, 0)); .Returns(new SnapshotResult<DefaultKeyStore.State>(default, ExistingKey(), 0));
var options = new OpenIddictServerOptions(); var options = new OpenIddictServerOptions();
@ -56,7 +56,7 @@ namespace Squidex.Domain.Users
Assert.NotEmpty(options.SigningCredentials); Assert.NotEmpty(options.SigningCredentials);
Assert.NotEmpty(options.EncryptionCredentials); Assert.NotEmpty(options.EncryptionCredentials);
A.CallTo(() => store.WriteAsync(A<DomainId>._, A<DefaultKeyStore.State>._, 0, 0, default)) A.CallTo(() => store.WriteAsync(A<SnapshotWriteJob<DefaultKeyStore.State>>._, default))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -64,11 +64,11 @@ namespace Squidex.Domain.Users
public void Should_configure_existing_keys_when_initial_setup_failed() public void Should_configure_existing_keys_when_initial_setup_failed()
{ {
A.CallTo(() => store.ReadAsync(A<DomainId>._, default)) A.CallTo(() => store.ReadAsync(A<DomainId>._, default))
.Returns((null!, true, 0)).Once() .Returns(new SnapshotResult<DefaultKeyStore.State>(default, null!, 0)).Once()
.Then .Then
.Returns((ExistingKey(), true, 0)); .Returns(new SnapshotResult<DefaultKeyStore.State>(default, ExistingKey(), 0));
A.CallTo(() => store.WriteAsync(A<DomainId>._, A<DefaultKeyStore.State>._, 0, 0, default)) A.CallTo(() => store.WriteAsync(A<SnapshotWriteJob<DefaultKeyStore.State>>._, default))
.Throws(new InconsistentStateException(0, 0)); .Throws(new InconsistentStateException(0, 0));
var options = new OpenIddictServerOptions(); var options = new OpenIddictServerOptions();
@ -78,7 +78,7 @@ namespace Squidex.Domain.Users
Assert.NotEmpty(options.SigningCredentials); Assert.NotEmpty(options.SigningCredentials);
Assert.NotEmpty(options.EncryptionCredentials); Assert.NotEmpty(options.EncryptionCredentials);
A.CallTo(() => store.WriteAsync(A<DomainId>._, A<DefaultKeyStore.State>._, 0, 0, default)) A.CallTo(() => store.WriteAsync(A<SnapshotWriteJob<DefaultKeyStore.State>>._, default))
.MustHaveHappened(); .MustHaveHappened();
} }

6
backend/tests/Squidex.Domain.Users.Tests/DefaultXmlRepositoryTests.cs

@ -29,11 +29,11 @@ namespace Squidex.Domain.Users
A.CallTo(() => store.ReadAllAsync(default)) A.CallTo(() => store.ReadAllAsync(default))
.Returns(new[] .Returns(new[]
{ {
(new DefaultXmlRepository.State new SnapshotResult<DefaultXmlRepository.State>(default, new DefaultXmlRepository.State
{ {
Xml = new XElement("xml").ToString() Xml = new XElement("xml").ToString()
}, 0L), }, 0L),
(new DefaultXmlRepository.State new SnapshotResult<DefaultXmlRepository.State>(default, new DefaultXmlRepository.State
{ {
Xml = new XElement("xml").ToString() Xml = new XElement("xml").ToString()
}, 0L) }, 0L)
@ -51,7 +51,7 @@ namespace Squidex.Domain.Users
sut.StoreElement(xml, "name"); sut.StoreElement(xml, "name");
A.CallTo(() => store.WriteAsync(DomainId.Create("name"), A<DefaultXmlRepository.State>._, A<long>._, 0, default)) A.CallTo(() => store.WriteAsync(A<SnapshotWriteJob<DefaultXmlRepository.State>>.That.Matches(x => x.Key == DomainId.Create("name")), default))
.MustHaveHappened(); .MustHaveHappened();
} }
} }

64
backend/tests/Squidex.Infrastructure.Tests/Commands/DomainObjectTests.cs

@ -42,7 +42,7 @@ namespace Squidex.Infrastructure.Commands
await sut.EnsureLoadedAsync(); await sut.EnsureLoadedAsync();
A.CallTo(() => persistence.WriteSnapshotAsync(A<MyDomainState>._)) A.CallTo(() => persistence.WriteSnapshotAsync(A<MyDomainState>._, default))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -56,7 +56,7 @@ namespace Squidex.Infrastructure.Commands
await sut.EnsureLoadedAsync(); await sut.EnsureLoadedAsync();
A.CallTo(() => persistence.WriteSnapshotAsync(A<MyDomainState>._)) A.CallTo(() => persistence.WriteSnapshotAsync(A<MyDomainState>._, default))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -67,11 +67,11 @@ namespace Squidex.Infrastructure.Commands
var result = await sut.ExecuteAsync(new CreateAuto { Value = 4 }); var result = await sut.ExecuteAsync(new CreateAuto { Value = 4 });
A.CallTo(() => persistence.WriteSnapshotAsync(A<MyDomainState>.That.Matches(x => x.Value == 4))) A.CallTo(() => persistence.WriteSnapshotAsync(A<MyDomainState>.That.Matches(x => x.Value == 4), default))
.MustHaveHappened(); .MustHaveHappened();
A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>.That.Matches(x => x.Count == 1))) A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>.That.Matches(x => x.Count == 1), default))
.MustHaveHappened(); .MustHaveHappened();
A.CallTo(() => persistence.ReadAsync(A<long>._)) A.CallTo(() => persistence.ReadAsync(A<long>._, default))
.MustNotHaveHappened(); .MustNotHaveHappened();
Assert.Equal(CommandResult.Empty(id, 0, EtagVersion.Empty), result); Assert.Equal(CommandResult.Empty(id, 0, EtagVersion.Empty), result);
@ -139,14 +139,14 @@ namespace Squidex.Infrastructure.Commands
SetupCreated(2); SetupCreated(2);
SetupDeleted(); SetupDeleted();
A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>._)) A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>._, default))
.Throws(new InconsistentStateException(2, -1)).Once(); .Throws(new InconsistentStateException(2, -1)).Once();
var result = await sut.ExecuteAsync(new CreateAuto { Value = 4 }); var result = await sut.ExecuteAsync(new CreateAuto { Value = 4 });
A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>.That.Matches(x => x.Count == 1))) A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>.That.Matches(x => x.Count == 1), default))
.MustHaveHappenedANumberOfTimesMatching(x => x == 3); .MustHaveHappenedANumberOfTimesMatching(x => x == 3);
A.CallTo(() => persistence.ReadAsync(A<long>._)) A.CallTo(() => persistence.ReadAsync(A<long>._, default))
.MustHaveHappened(); .MustHaveHappened();
Assert.Equal(CommandResult.Empty(id, 2, 1), result); Assert.Equal(CommandResult.Empty(id, 2, 1), result);
@ -165,7 +165,7 @@ namespace Squidex.Infrastructure.Commands
SetupCreated(2); SetupCreated(2);
SetupDeleted(); SetupDeleted();
A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>._)) A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>._, default))
.Throws(new InconsistentStateException(2, -1)).Once(); .Throws(new InconsistentStateException(2, -1)).Once();
await Assert.ThrowsAsync<DomainObjectConflictException>(() => sut.ExecuteAsync(new CreateAuto { Value = 4 })); await Assert.ThrowsAsync<DomainObjectConflictException>(() => sut.ExecuteAsync(new CreateAuto { Value = 4 }));
@ -180,14 +180,14 @@ namespace Squidex.Infrastructure.Commands
SetupCreated(2); SetupCreated(2);
SetupDeleted(); SetupDeleted();
A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>._)) A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>._, default))
.Throws(new InconsistentStateException(2, -1)).Once(); .Throws(new InconsistentStateException(2, -1)).Once();
var result = await sut.ExecuteAsync(new Upsert { Value = 4 }); var result = await sut.ExecuteAsync(new Upsert { Value = 4 });
A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>.That.Matches(x => x.Count == 1))) A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>.That.Matches(x => x.Count == 1), default))
.MustHaveHappenedANumberOfTimesMatching(x => x == 3); .MustHaveHappenedANumberOfTimesMatching(x => x == 3);
A.CallTo(() => persistence.ReadAsync(A<long>._)) A.CallTo(() => persistence.ReadAsync(A<long>._, default))
.MustHaveHappened(); .MustHaveHappened();
Assert.Equal(CommandResult.Empty(id, 2, 1), result); Assert.Equal(CommandResult.Empty(id, 2, 1), result);
@ -206,7 +206,7 @@ namespace Squidex.Infrastructure.Commands
SetupCreated(2); SetupCreated(2);
SetupDeleted(); SetupDeleted();
A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>._)) A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>._, default))
.Throws(new InconsistentStateException(2, -1)).Once(); .Throws(new InconsistentStateException(2, -1)).Once();
await Assert.ThrowsAsync<DomainObjectDeletedException>(() => sut.ExecuteAsync(new Upsert { Value = 4 })); await Assert.ThrowsAsync<DomainObjectDeletedException>(() => sut.ExecuteAsync(new Upsert { Value = 4 }));
@ -221,11 +221,11 @@ namespace Squidex.Infrastructure.Commands
var result = await sut.ExecuteAsync(new UpdateAuto { Value = 8, ExpectedVersion = 0 }); var result = await sut.ExecuteAsync(new UpdateAuto { Value = 8, ExpectedVersion = 0 });
A.CallTo(() => persistence.WriteSnapshotAsync(A<MyDomainState>.That.Matches(x => x.Value == 8))) A.CallTo(() => persistence.WriteSnapshotAsync(A<MyDomainState>.That.Matches(x => x.Value == 8), default))
.MustHaveHappened(); .MustHaveHappened();
A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>.That.Matches(x => x.Count == 1))) A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>.That.Matches(x => x.Count == 1), default))
.MustHaveHappened(); .MustHaveHappened();
A.CallTo(() => persistence.ReadAsync(A<long>._)) A.CallTo(() => persistence.ReadAsync(A<long>._, default))
.MustNotHaveHappened(); .MustNotHaveHappened();
Assert.Equal(CommandResult.Empty(id, 1, 0), result); Assert.Equal(CommandResult.Empty(id, 1, 0), result);
@ -243,11 +243,11 @@ namespace Squidex.Infrastructure.Commands
var result = await sut.ExecuteAsync(new UpdateAuto { Value = 8, ExpectedVersion = 0 }); var result = await sut.ExecuteAsync(new UpdateAuto { Value = 8, ExpectedVersion = 0 });
A.CallTo(() => persistence.WriteSnapshotAsync(A<MyDomainState>.That.Matches(x => x.Value == 8))) A.CallTo(() => persistence.WriteSnapshotAsync(A<MyDomainState>.That.Matches(x => x.Value == 8), default))
.MustHaveHappened(); .MustHaveHappened();
A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>.That.Matches(x => x.Count == 1))) A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>.That.Matches(x => x.Count == 1), default))
.MustHaveHappened(); .MustHaveHappened();
A.CallTo(() => persistence.ReadAsync(A<long>._)) A.CallTo(() => persistence.ReadAsync(A<long>._, default))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
Assert.Equal(CommandResult.Empty(id, 1, 0), result); Assert.Equal(CommandResult.Empty(id, 1, 0), result);
@ -265,7 +265,7 @@ namespace Squidex.Infrastructure.Commands
await sut.ExecuteAsync(new CreateAuto()); await sut.ExecuteAsync(new CreateAuto());
A.CallTo(() => persistence.ReadAsync(A<long>._)) A.CallTo(() => persistence.ReadAsync(A<long>._, default))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -277,7 +277,7 @@ namespace Squidex.Infrastructure.Commands
await sut.ExecuteAsync(new UpdateAuto { Value = 8, ExpectedVersion = 0 }); await sut.ExecuteAsync(new UpdateAuto { Value = 8, ExpectedVersion = 0 });
await sut.ExecuteAsync(new UpdateAuto { Value = 9, ExpectedVersion = 1 }); await sut.ExecuteAsync(new UpdateAuto { Value = 9, ExpectedVersion = 1 });
A.CallTo(() => persistence.ReadAsync(A<long>._)) A.CallTo(() => persistence.ReadAsync(A<long>._, default))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
Assert.Empty(sut.GetUncomittedEvents()); Assert.Empty(sut.GetUncomittedEvents());
@ -291,9 +291,9 @@ namespace Squidex.Infrastructure.Commands
await sut.RebuildStateAsync(); await sut.RebuildStateAsync();
A.CallTo(() => persistence.WriteSnapshotAsync(A<MyDomainState>.That.Matches(x => x.Value == 4))) A.CallTo(() => persistence.WriteSnapshotAsync(A<MyDomainState>.That.Matches(x => x.Value == 4), default))
.MustHaveHappened(); .MustHaveHappened();
A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>._)) A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>._, default))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -310,7 +310,7 @@ namespace Squidex.Infrastructure.Commands
{ {
SetupEmpty(); SetupEmpty();
A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>._)) A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>._, default))
.Throws(new InconsistentStateException(4, EtagVersion.Empty)); .Throws(new InconsistentStateException(4, EtagVersion.Empty));
await Assert.ThrowsAsync<DomainObjectConflictException>(() => sut.ExecuteAsync(new CreateAuto())); await Assert.ThrowsAsync<DomainObjectConflictException>(() => sut.ExecuteAsync(new CreateAuto()));
@ -396,7 +396,7 @@ namespace Squidex.Infrastructure.Commands
{ {
SetupEmpty(); SetupEmpty();
A.CallTo(() => persistence.WriteSnapshotAsync(A<MyDomainState>._)) A.CallTo(() => persistence.WriteSnapshotAsync(A<MyDomainState>._, default))
.Throws(new InvalidOperationException()); .Throws(new InvalidOperationException());
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.ExecuteAsync(new CreateAuto())); await Assert.ThrowsAsync<InvalidOperationException>(() => sut.ExecuteAsync(new CreateAuto()));
@ -410,7 +410,7 @@ namespace Squidex.Infrastructure.Commands
{ {
SetupCreated(4); SetupCreated(4);
A.CallTo(() => persistence.WriteSnapshotAsync(A<MyDomainState>._)) A.CallTo(() => persistence.WriteSnapshotAsync(A<MyDomainState>._, default))
.Throws(new InvalidOperationException()); .Throws(new InvalidOperationException());
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.ExecuteAsync(new UpdateAuto())); await Assert.ThrowsAsync<InvalidOperationException>(() => sut.ExecuteAsync(new UpdateAuto()));
@ -434,13 +434,13 @@ namespace Squidex.Infrastructure.Commands
AssertSnapshot(sut.Snapshot, 0, EtagVersion.Empty, false); AssertSnapshot(sut.Snapshot, 0, EtagVersion.Empty, false);
A.CallTo(() => persistence.DeleteAsync()) A.CallTo(() => persistence.DeleteAsync(default))
.MustHaveHappened(); .MustHaveHappened();
A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>._)) A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>._, default))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
A.CallTo(() => deleteStream.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>._)) A.CallTo(() => deleteStream.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>._, default))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -518,7 +518,7 @@ namespace Squidex.Infrastructure.Commands
var version = -1; var version = -1;
A.CallTo(() => persistence.ReadAsync(-2)) A.CallTo(() => persistence.ReadAsync(-2, default))
.Invokes(() => .Invokes(() =>
{ {
version++; version++;
@ -548,7 +548,7 @@ namespace Squidex.Infrastructure.Commands
var @events = new List<Envelope<IEvent>>(); var @events = new List<Envelope<IEvent>>();
A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>._)) A.CallTo(() => persistence.WriteEventsAsync(A<IReadOnlyList<Envelope<IEvent>>>._, default))
.Invokes(args => .Invokes(args =>
{ {
@events.AddRange(args.GetArgument<IReadOnlyList<Envelope<IEvent>>>(0)!); @events.AddRange(args.GetArgument<IReadOnlyList<Envelope<IEvent>>>(0)!);
@ -563,7 +563,7 @@ namespace Squidex.Infrastructure.Commands
}) })
.Returns(eventsPersistence); .Returns(eventsPersistence);
A.CallTo(() => eventsPersistence.ReadAsync(EtagVersion.Any)) A.CallTo(() => eventsPersistence.ReadAsync(EtagVersion.Any, default))
.Invokes(_ => .Invokes(_ =>
{ {
foreach (var @event in events) foreach (var @event in events)

50
backend/tests/Squidex.Infrastructure.Tests/MongoDb/Entities.cs

@ -0,0 +1,50 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace Squidex.Infrastructure.MongoDb
{
public static class Entities
{
public sealed class DateTimeEntity<T>
{
[BsonRepresentation(BsonType.DateTime)]
public T Value { get; set; }
}
public sealed class Int64Entity<T>
{
[BsonRepresentation(BsonType.Int64)]
public T Value { get; set; }
}
public sealed class Int32Entity<T>
{
[BsonRepresentation(BsonType.Int32)]
public T Value { get; set; }
}
public sealed class StringEntity<T>
{
[BsonRepresentation(BsonType.String)]
public T Value { get; set; }
}
public sealed class BinaryEntity<T>
{
[BsonRepresentation(BsonType.Binary)]
public T Value { get; set; }
}
public sealed class DefaultEntity<T>
{
public T Value { get; set; }
}
}
}

93
backend/tests/Squidex.Infrastructure.Tests/MongoDb/InstantSerializerTests.cs

@ -0,0 +1,93 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using MongoDB.Bson.IO;
using MongoDB.Bson.Serialization;
using NodaTime;
using Xunit;
namespace Squidex.Infrastructure.MongoDb
{
public class InstantSerializerTests
{
public InstantSerializerTests()
{
InstantSerializer.Register();
}
[Fact]
public void Should_serialize_as_default()
{
var source = new Entities.DefaultEntity<Instant> { Value = GetTime() };
var result1 = SerializeAndDeserializeBson(source);
Assert.Equal(source.Value, result1.Value);
}
[Fact]
public void Should_serialize_as_string()
{
var source = new Entities.StringEntity<Instant> { Value = GetTime() };
var result1 = SerializeAndDeserializeBson(source);
Assert.Equal(source.Value, result1.Value);
}
[Fact]
public void Should_serialize_as_int64()
{
var source = new Entities.Int64Entity<Instant> { Value = GetTime() };
var result1 = SerializeAndDeserializeBson(source);
Assert.Equal(source.Value, result1.Value);
}
[Fact]
public void Should_serialize_as_datetime()
{
var source = new Entities.DateTimeEntity<Instant> { Value = GetTime() };
var result1 = SerializeAndDeserializeBson(source);
Assert.Equal(source.Value, result1.Value);
}
private static Instant GetTime()
{
return SystemClock.Instance.GetCurrentInstant().WithoutNs();
}
private static T SerializeAndDeserializeBson<T>(T source)
{
return SerializeAndDeserializeBson<T, T>(source);
}
private static TOut SerializeAndDeserializeBson<TIn, TOut>(TIn source)
{
var stream = new MemoryStream();
using (var writer = new BsonBinaryWriter(stream))
{
BsonSerializer.Serialize(writer, source);
writer.Flush();
}
stream.Position = 0;
using (var reader = new BsonBinaryReader(stream))
{
var target = BsonSerializer.Deserialize<TOut>(reader);
return target;
}
}
}
}

18
backend/tests/Squidex.Infrastructure.Tests/States/PersistenceBatchTests.cs

@ -111,16 +111,16 @@ namespace Squidex.Infrastructure.States
await persistence1.WriteSnapshotAsync(12); await persistence1.WriteSnapshotAsync(12);
await persistence2.WriteSnapshotAsync(12); await persistence2.WriteSnapshotAsync(12);
A.CallTo(() => snapshotStore.WriteAsync(A<DomainId>._, A<int>._, A<long>._, A<long>._, A<CancellationToken>._)) A.CallTo(() => snapshotStore.WriteAsync(A<SnapshotWriteJob<int>>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
A.CallTo(() => snapshotStore.WriteManyAsync(A<IEnumerable<(DomainId, int, long)>>._, A<CancellationToken>._)) A.CallTo(() => snapshotStore.WriteManyAsync(A<IEnumerable<SnapshotWriteJob<int>>>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
await bulk.CommitAsync(); await bulk.CommitAsync();
await bulk.DisposeAsync(); await bulk.DisposeAsync();
A.CallTo(() => snapshotStore.WriteManyAsync(A<IEnumerable<(DomainId, int, long)>>.That.Matches(x => x.Count() == 2), A<CancellationToken>._)) A.CallTo(() => snapshotStore.WriteManyAsync(A<IEnumerable<SnapshotWriteJob<int>>>.That.Matches(x => x.Count() == 2), A<CancellationToken>._))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
} }
@ -143,16 +143,16 @@ namespace Squidex.Infrastructure.States
await persistence1_1.WriteSnapshotAsync(12); await persistence1_1.WriteSnapshotAsync(12);
await persistence1_2.WriteSnapshotAsync(12); await persistence1_2.WriteSnapshotAsync(12);
A.CallTo(() => snapshotStore.WriteAsync(A<DomainId>._, A<int>._, A<long>._, A<long>._, A<CancellationToken>._)) A.CallTo(() => snapshotStore.WriteAsync(A<SnapshotWriteJob<int>>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
A.CallTo(() => snapshotStore.WriteManyAsync(A<IEnumerable<(DomainId, int, long)>>._, A<CancellationToken>._)) A.CallTo(() => snapshotStore.WriteManyAsync(A<IEnumerable<SnapshotWriteJob<int>>>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
await bulk.CommitAsync(); await bulk.CommitAsync();
await bulk.DisposeAsync(); await bulk.DisposeAsync();
A.CallTo(() => snapshotStore.WriteManyAsync(A<IEnumerable<(DomainId, int, long)>>.That.Matches(x => x.Count() == 1), A<CancellationToken>._)) A.CallTo(() => snapshotStore.WriteManyAsync(A<IEnumerable<SnapshotWriteJob<int>>>.That.Matches(x => x.Count() == 1), A<CancellationToken>._))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
} }
@ -172,16 +172,16 @@ namespace Squidex.Infrastructure.States
await persistence1.WriteSnapshotAsync(12); await persistence1.WriteSnapshotAsync(12);
await persistence1.WriteSnapshotAsync(13); await persistence1.WriteSnapshotAsync(13);
A.CallTo(() => snapshotStore.WriteAsync(A<DomainId>._, A<int>._, A<long>._, A<long>._, A<CancellationToken>._)) A.CallTo(() => snapshotStore.WriteAsync(A<SnapshotWriteJob<int>>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
A.CallTo(() => snapshotStore.WriteManyAsync(A<IEnumerable<(DomainId, int, long)>>._, A<CancellationToken>._)) A.CallTo(() => snapshotStore.WriteManyAsync(A<IEnumerable<SnapshotWriteJob<int>>>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
await bulk.CommitAsync(); await bulk.CommitAsync();
await bulk.DisposeAsync(); await bulk.DisposeAsync();
A.CallTo(() => snapshotStore.WriteManyAsync(A<IEnumerable<(DomainId, int, long)>>.That.Matches(x => x.Count() == 1), A<CancellationToken>._)) A.CallTo(() => snapshotStore.WriteManyAsync(A<IEnumerable<SnapshotWriteJob<int>>>.That.Matches(x => x.Count() == 1), A<CancellationToken>._))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
} }

28
backend/tests/Squidex.Infrastructure.Tests/States/PersistenceEventSourcingTests.cs

@ -86,7 +86,7 @@ namespace Squidex.Infrastructure.States
public async Task Should_read_read_from_snapshot_store() public async Task Should_read_read_from_snapshot_store()
{ {
A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._)) A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._))
.Returns(("2", true, 2L)); .Returns(new SnapshotResult<string>(key, "2", 2));
SetupEventStore(3, 2); SetupEventStore(3, 2);
@ -106,7 +106,7 @@ namespace Squidex.Infrastructure.States
public async Task Should_mark_as_stale_if_snapshot_old_than_events() public async Task Should_mark_as_stale_if_snapshot_old_than_events()
{ {
A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._)) A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._))
.Returns(("2", true, 1L)); .Returns(new SnapshotResult<string>(key, "2", 1));
SetupEventStore(3, 2, 2); SetupEventStore(3, 2, 2);
@ -126,7 +126,7 @@ namespace Squidex.Infrastructure.States
public async Task Should_throw_exception_if_events_are_older_than_snapshot() public async Task Should_throw_exception_if_events_are_older_than_snapshot()
{ {
A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._)) A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._))
.Returns(("2", true, 2L)); .Returns(new SnapshotResult<string>(key, "2", 2));
SetupEventStore(3, 0, 3); SetupEventStore(3, 0, 3);
@ -141,7 +141,7 @@ namespace Squidex.Infrastructure.States
public async Task Should_throw_exception_if_events_have_gaps_to_snapshot() public async Task Should_throw_exception_if_events_have_gaps_to_snapshot()
{ {
A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._)) A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._))
.Returns(("2", true, 2L)); .Returns(new SnapshotResult<string>(key, "2", 2));
SetupEventStore(3, 4, 3); SetupEventStore(3, 4, 3);
@ -178,7 +178,7 @@ namespace Squidex.Infrastructure.States
public async Task Should_throw_exception_if_other_version_found_from_snapshot() public async Task Should_throw_exception_if_other_version_found_from_snapshot()
{ {
A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._)) A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._))
.Returns(("2", true, 2L)); .Returns(new SnapshotResult<string>(key, "2", 2));
SetupEventStore(0); SetupEventStore(0);
@ -219,7 +219,7 @@ namespace Squidex.Infrastructure.States
A.CallTo(() => eventStore.AppendAsync(A<Guid>._, key.ToString(), 3, A<ICollection<EventData>>.That.Matches(x => x.Count == 1), A<CancellationToken>._)) A.CallTo(() => eventStore.AppendAsync(A<Guid>._, key.ToString(), 3, A<ICollection<EventData>>.That.Matches(x => x.Count == 1), A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
A.CallTo(() => snapshotStore.WriteAsync(A<DomainId>._, A<string>._, A<long>._, A<long>._, A<CancellationToken>._)) A.CallTo(() => snapshotStore.WriteAsync(A<SnapshotWriteJob<string>>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -238,7 +238,7 @@ namespace Squidex.Infrastructure.States
public async Task Should_write_snapshot_to_store() public async Task Should_write_snapshot_to_store()
{ {
A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._)) A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._))
.Returns(("2", true, 2L)); .Returns(new SnapshotResult<string>(key, "2", 2));
SetupEventStore(3); SetupEventStore(3);
@ -254,9 +254,9 @@ namespace Squidex.Infrastructure.States
await persistence.WriteEventAsync(Envelope.Create(new MyEvent())); await persistence.WriteEventAsync(Envelope.Create(new MyEvent()));
await persistence.WriteSnapshotAsync("5"); await persistence.WriteSnapshotAsync("5");
A.CallTo(() => snapshotStore.WriteAsync(key, "4", 2, 3, A<CancellationToken>._)) A.CallTo(() => snapshotStore.WriteAsync(new SnapshotWriteJob<string>(key, "4", 3, 2), A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
A.CallTo(() => snapshotStore.WriteAsync(key, "5", 3, 4, A<CancellationToken>._)) A.CallTo(() => snapshotStore.WriteAsync(new SnapshotWriteJob<string>(key, "5", 4, 3), A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -264,7 +264,7 @@ namespace Squidex.Infrastructure.States
public async Task Should_write_snapshot_to_store_if_not_read_before() public async Task Should_write_snapshot_to_store_if_not_read_before()
{ {
A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._)) A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._))
.Returns((null!, true, EtagVersion.Empty)); .Returns(new SnapshotResult<string>(key, null!, EtagVersion.Empty));
SetupEventStore(3); SetupEventStore(3);
@ -280,9 +280,9 @@ namespace Squidex.Infrastructure.States
await persistence.WriteEventAsync(Envelope.Create(new MyEvent())); await persistence.WriteEventAsync(Envelope.Create(new MyEvent()));
await persistence.WriteSnapshotAsync("5"); await persistence.WriteSnapshotAsync("5");
A.CallTo(() => snapshotStore.WriteAsync(key, "4", 2, 3, A<CancellationToken>._)) A.CallTo(() => snapshotStore.WriteAsync(new SnapshotWriteJob<string>(key, "4", 3, 2), A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
A.CallTo(() => snapshotStore.WriteAsync(key, "5", 3, 4, A<CancellationToken>._)) A.CallTo(() => snapshotStore.WriteAsync(new SnapshotWriteJob<string>(key, "5", 4, 3), A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -290,7 +290,7 @@ namespace Squidex.Infrastructure.States
public async Task Should_not_write_snapshot_to_store_if_not_changed() public async Task Should_not_write_snapshot_to_store_if_not_changed()
{ {
A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._)) A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._))
.Returns(("0", true, 2)); .Returns(new SnapshotResult<string>(key, "0", 2));
SetupEventStore(3); SetupEventStore(3);
@ -302,7 +302,7 @@ namespace Squidex.Infrastructure.States
await persistence.WriteSnapshotAsync("4"); await persistence.WriteSnapshotAsync("4");
A.CallTo(() => snapshotStore.WriteAsync(key, A<string>._, A<long>._, A<long>._, A<CancellationToken>._)) A.CallTo(() => snapshotStore.WriteAsync(A<SnapshotWriteJob<string>>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }

22
backend/tests/Squidex.Infrastructure.Tests/States/PersistenceSnapshotTests.cs

@ -29,7 +29,7 @@ namespace Squidex.Infrastructure.States
public async Task Should_read_from_store() public async Task Should_read_from_store()
{ {
A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._)) A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._))
.Returns((20, true, 10)); .Returns(new SnapshotResult<int>(key, 20, 10));
var persistedState = Save.Snapshot(0); var persistedState = Save.Snapshot(0);
var persistence = sut.WithSnapshots(None.Type, key, persistedState.Write); var persistence = sut.WithSnapshots(None.Type, key, persistedState.Write);
@ -44,7 +44,7 @@ namespace Squidex.Infrastructure.States
public async Task Should_not_read_from_store_if_not_valid() public async Task Should_not_read_from_store_if_not_valid()
{ {
A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._)) A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._))
.Returns((20, false, 10)); .Returns(new SnapshotResult<int>(key, 20, 10, false));
var persistedState = Save.Snapshot(0); var persistedState = Save.Snapshot(0);
var persistence = sut.WithSnapshots(None.Type, key, persistedState.Write); var persistence = sut.WithSnapshots(None.Type, key, persistedState.Write);
@ -59,7 +59,7 @@ namespace Squidex.Infrastructure.States
public async Task Should_return_empty_version_if_version_negative() public async Task Should_return_empty_version_if_version_negative()
{ {
A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._)) A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._))
.Returns((20, true, -10)); .Returns(new SnapshotResult<int>(key, 20, -10));
var persistedState = Save.Snapshot(0); var persistedState = Save.Snapshot(0);
var persistence = sut.WithSnapshots(None.Type, key, persistedState.Write); var persistence = sut.WithSnapshots(None.Type, key, persistedState.Write);
@ -73,7 +73,7 @@ namespace Squidex.Infrastructure.States
public async Task Should_set_to_empty_if_store_returns_not_found() public async Task Should_set_to_empty_if_store_returns_not_found()
{ {
A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._)) A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._))
.Returns((20, true, EtagVersion.Empty)); .Returns(new SnapshotResult<int>(key, 20, EtagVersion.Empty));
var persistedState = Save.Snapshot(0); var persistedState = Save.Snapshot(0);
var persistence = sut.WithSnapshots(None.Type, key, persistedState.Write); var persistence = sut.WithSnapshots(None.Type, key, persistedState.Write);
@ -88,7 +88,7 @@ namespace Squidex.Infrastructure.States
public async Task Should_throw_exception_if_not_found_and_version_expected() public async Task Should_throw_exception_if_not_found_and_version_expected()
{ {
A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._)) A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._))
.Returns((123, true, EtagVersion.Empty)); .Returns(new SnapshotResult<int>(key, 42, EtagVersion.Empty));
var persistedState = Save.Snapshot(0); var persistedState = Save.Snapshot(0);
var persistence = sut.WithSnapshots(None.Type, key, persistedState.Write); var persistence = sut.WithSnapshots(None.Type, key, persistedState.Write);
@ -100,7 +100,7 @@ namespace Squidex.Infrastructure.States
public async Task Should_throw_exception_if_other_version_found() public async Task Should_throw_exception_if_other_version_found()
{ {
A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._)) A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._))
.Returns((123, true, 2)); .Returns(new SnapshotResult<int>(key, 42, 2));
var persistedState = Save.Snapshot(0); var persistedState = Save.Snapshot(0);
var persistence = sut.WithSnapshots(None.Type, key, persistedState.Write); var persistence = sut.WithSnapshots(None.Type, key, persistedState.Write);
@ -112,7 +112,7 @@ namespace Squidex.Infrastructure.States
public async Task Should_write_to_store_with_previous_version() public async Task Should_write_to_store_with_previous_version()
{ {
A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._)) A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._))
.Returns((20, true, 10)); .Returns(new SnapshotResult<int>(key, 20, 10));
var persistedState = Save.Snapshot(0); var persistedState = Save.Snapshot(0);
var persistence = sut.WithSnapshots(None.Type, key, persistedState.Write); var persistence = sut.WithSnapshots(None.Type, key, persistedState.Write);
@ -124,7 +124,7 @@ namespace Squidex.Infrastructure.States
await persistence.WriteSnapshotAsync(100); await persistence.WriteSnapshotAsync(100);
A.CallTo(() => snapshotStore.WriteAsync(key, 100, 10, 11, A<CancellationToken>._)) A.CallTo(() => snapshotStore.WriteAsync(new SnapshotWriteJob<int>(key, 100, 11, 10), A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -135,7 +135,7 @@ namespace Squidex.Infrastructure.States
await persistence.WriteSnapshotAsync(100); await persistence.WriteSnapshotAsync(100);
A.CallTo(() => snapshotStore.WriteAsync(key, 100, EtagVersion.Empty, 0, A<CancellationToken>._)) A.CallTo(() => snapshotStore.WriteAsync(new SnapshotWriteJob<int>(key, 100, 0, -1), A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -143,9 +143,9 @@ namespace Squidex.Infrastructure.States
public async Task Should_not_wrap_exception_if_writing_to_store_with_previous_version() public async Task Should_not_wrap_exception_if_writing_to_store_with_previous_version()
{ {
A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._)) A.CallTo(() => snapshotStore.ReadAsync(key, A<CancellationToken>._))
.Returns((20, true, 10)); .Returns(new SnapshotResult<int>(key, 42, 10));
A.CallTo(() => snapshotStore.WriteAsync(key, 100, 10, 11, A<CancellationToken>._)) A.CallTo(() => snapshotStore.WriteAsync(new SnapshotWriteJob<int>(key, 100, 11, 10), A<CancellationToken>._))
.Throws(new InconsistentStateException(1, 1, new InvalidOperationException())); .Throws(new InconsistentStateException(1, 1, new InvalidOperationException()));
var persistedState = Save.Snapshot(0); var persistedState = Save.Snapshot(0);

28
frontend/app/framework/angular/pager.component.html

@ -0,0 +1,28 @@
<div class="clearfix" *ngIf="!autoHide || canGoPrev || canGoNext">
<div class="float-right pagination" *ngIf="paging">
<select class="custom-select custom-select-sm" [ngModel]="paging.pageSize" (ngModelChange)="setPageSize($event)">
<option *ngFor="let pageSize of pageSizes" [ngValue]="pageSize">{{pageSize}}</option>
</select>
<span class="page-info">
<ng-container *ngIf="paging.count > 0 && paging.total > 0">
<button class="btn deactivated">
{{ 'common.pagerInfo' | sqxTranslate: translationInfo }}
</button>
</ng-container>
<ng-container *ngIf="paging.count > 0 && paging.total <= 0">
<button class="btn" title="{{ 'common.pagerReload' | sqxTranslate }}" (click)="loadTotal.emit()">
{{ 'common.pagerInfoNoTotal' | sqxTranslate: translationInfo }}
</button>
</ng-container>
<button type="button" class="btn btn-text-secondary pagination-button" [disabled]="!canGoPrev" (click)="goPrev()">
<i class="icon-angle-left"></i>
</button>
<button type="button" class="btn btn-text-secondary pagination-button" [disabled]="!canGoNext" (click)="goNext()">
<i class="icon-angle-right"></i>
</button>
</span>
</div>
</div>

2
frontend/src/app/features/administration/state/users.state.ts

@ -187,7 +187,7 @@ export class UsersState extends State<Snapshot> {
} }
public search(query: string) { public search(query: string) {
if (!this.next({ query, page: 0 }, 'Loading Search')) { if (!this.next({ query, page: 0, total: 0 }, 'Loading Search')) {
return EMPTY; return EMPTY;
} }

2
frontend/src/app/features/assets/pages/assets-page.component.html

@ -59,7 +59,7 @@
</div> </div>
<ng-container footer> <ng-container footer>
<sqx-pager [paging]="assetsState.paging | async" (pagingChange)="assetsState.page($event)"></sqx-pager> <sqx-pager (loadTotal)="reloadTotal()" [paging]="assetsState.paging | async" (pagingChange)="assetsState.page($event)"></sqx-pager>
</ng-container> </ng-container>
</sqx-list-view> </sqx-list-view>
</ng-container> </ng-container>

6
frontend/src/app/features/assets/pages/assets-page.component.ts

@ -45,7 +45,7 @@ export class AssetsPageComponent extends ResourceOwner implements OnInit {
.withSynchronizer(QueryFullTextSynchronizer.INSTANCE) .withSynchronizer(QueryFullTextSynchronizer.INSTANCE)
.getInitial(); .getInitial();
this.assetsState.load(false, initial); this.assetsState.load(false, true, initial);
this.assetsRoute.listen(); this.assetsRoute.listen();
} }
@ -53,6 +53,10 @@ export class AssetsPageComponent extends ResourceOwner implements OnInit {
this.assetsState.load(true); this.assetsState.load(true);
} }
public reloadTotal() {
this.assetsState.load(true, false);
}
public search(query: Query) { public search(query: Query) {
this.assetsState.search(query); this.assetsState.search(query);
} }

2
frontend/src/app/features/content/pages/contents/contents-page.component.html

@ -134,7 +134,7 @@
</ng-container> </ng-container>
<ng-container footer> <ng-container footer>
<sqx-pager [paging]="contentsState.paging | async" (pagingChange)="contentsState.page($event)"></sqx-pager> <sqx-pager (loadTotal)="reloadTotal()" [paging]="contentsState.paging | async" (pagingChange)="contentsState.page($event)"></sqx-pager>
</ng-container> </ng-container>
</sqx-list-view> </sqx-list-view>
</ng-container> </ng-container>

6
frontend/src/app/features/content/pages/contents/contents-page.component.ts

@ -102,7 +102,7 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
.withSynchronizer(QuerySynchronizer.INSTANCE) .withSynchronizer(QuerySynchronizer.INSTANCE)
.getInitial(); .getInitial();
this.contentsState.load(false, initial); this.contentsState.load(false, true, initial);
this.contentsRoute.listen(); this.contentsRoute.listen();
const languageKey = this.localStore.get(this.languageKey()); const languageKey = this.localStore.get(this.languageKey());
@ -124,6 +124,10 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.contentsState.load(true); this.contentsState.load(true);
} }
public reloadTotal() {
this.contentsState.load(true, false);
}
public delete(content: ContentDto) { public delete(content: ContentDto) {
this.contentsState.deleteMany([content]); this.contentsState.deleteMany([content]);
} }

4
frontend/src/app/framework/angular/modals/dialog-renderer.component.html

@ -43,8 +43,8 @@
<div class="tooltip2 tooltip2-{{tooltip.textPosition}}" <div class="tooltip2 tooltip2-{{tooltip.textPosition}}"
[sqxAnchoredTo]="tooltip.target" [sqxAnchoredTo]="tooltip.target"
[position]="tooltip.textPosition" [position]="tooltip.textPosition"
[offsetY]="6" [offsetY]="tooltip.offsetY"
[offsetX]="6"> [offsetX]="tooltip.offsetX">
{{tooltip.text | sqxTranslate}} {{tooltip.text | sqxTranslate}}
<ng-container *ngIf="tooltip.shortcut"> <ng-container *ngIf="tooltip.shortcut">

12
frontend/src/app/framework/angular/pager.component.html

@ -5,7 +5,17 @@
</select> </select>
<span class="page-info d-flex align-items-center justify-content-end"> <span class="page-info d-flex align-items-center justify-content-end">
<span *ngIf="paging.count > 0" class="pagination-text">{{ 'common.pagerInfo' | sqxTranslate: translationInfo }}</span> <ng-container *ngIf="paging.count > 0 && paging.total > 0">
<button class="btn deactivated">
{{ 'common.pagerInfo' | sqxTranslate: translationInfo }}
</button>
</ng-container>
<ng-container *ngIf="paging.count > 0 && paging.total <= 0">
<button class="btn" title="{{ 'common.pagerReload' | sqxTranslate }}" (click)="loadTotal.emit()">
{{ 'common.pagerInfoNoTotal' | sqxTranslate: translationInfo }}
</button>
</ng-container>
<button type="button" class="btn btn-sm btn-text-secondary ms-2" [disabled]="!canGoPrev" (click)="goPrev()"> <button type="button" class="btn btn-sm btn-text-secondary ms-2" [disabled]="!canGoPrev" (click)="goPrev()">
<i class="icon-angle-left"></i> <i class="icon-angle-left"></i>

6
frontend/src/app/framework/angular/pager.component.scss

@ -12,5 +12,9 @@
display: inline-block; display: inline-block;
text-align: right; text-align: right;
text-decoration: none; text-decoration: none;
width: 15rem; width: 14rem;
}
.deactived {
pointer-events: none;
} }

27
frontend/src/app/framework/angular/pager.component.spec.ts

@ -33,6 +33,7 @@ describe('Pager', () => {
expect(pager.itemFirst).toEqual(1); expect(pager.itemFirst).toEqual(1);
expect(pager.itemLast).toEqual(10); expect(pager.itemLast).toEqual(10);
}); });
it('should init with middle page', () => { it('should init with middle page', () => {
const pager = new PagerComponent(); const pager = new PagerComponent();
@ -85,6 +86,32 @@ describe('Pager', () => {
expect(pager.itemLast).toEqual(99); expect(pager.itemLast).toEqual(99);
}); });
it('should init without total and full page size', () => {
const pager = new PagerComponent();
pager.paging = { page: 9, pageSize: 10, count: 10, total: -1 };
pager.ngOnChanges();
expect(pager.canGoNext).toBeTrue();
expect(pager.canGoPrev).toBeTrue();
expect(pager.itemFirst).toEqual(91);
expect(pager.itemLast).toEqual(100);
});
it('should init without total and partial page size', () => {
const pager = new PagerComponent();
pager.paging = { page: 9, pageSize: 10, count: 5, total: -1 };
pager.ngOnChanges();
expect(pager.canGoNext).toBeFalse();
expect(pager.canGoPrev).toBeTrue();
expect(pager.itemFirst).toEqual(91);
expect(pager.itemLast).toEqual(95);
});
it('should emit if changing size', () => { it('should emit if changing size', () => {
const pager = new PagerComponent(); const pager = new PagerComponent();

20
frontend/src/app/framework/angular/pager.component.ts

@ -17,6 +17,9 @@ export const PAGE_SIZES: ReadonlyArray<number> = [10, 20, 30, 50];
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class PagerComponent implements OnChanges { export class PagerComponent implements OnChanges {
@Output()
public loadTotal = new EventEmitter();
@Output() @Output()
public pagingChange = new EventEmitter<{ page: number; pageSize: number }>(); public pagingChange = new EventEmitter<{ page: number; pageSize: number }>();
@ -27,6 +30,7 @@ export class PagerComponent implements OnChanges {
public autoHide?: boolean | null; public autoHide?: boolean | null;
public totalPages = 0; public totalPages = 0;
public totalItems = 0;
public itemFirst = 0; public itemFirst = 0;
public itemLast = 0; public itemLast = 0;
@ -45,21 +49,23 @@ export class PagerComponent implements OnChanges {
const { page, pageSize, count, total } = this.paging; const { page, pageSize, count, total } = this.paging;
const totalPages = Math.ceil(total / pageSize); const offset = page * pageSize;
if (count > 0) { this.itemFirst = offset + (count > 0 ? 1 : 0);
const offset = page * pageSize; this.itemLast = offset + count;
this.itemFirst = offset + 1; if (count > 0 && total >= 0) {
this.itemLast = offset + count; const totalPages = Math.ceil(total / pageSize);
this.canGoNext = page < totalPages - 1; this.canGoNext = page < totalPages - 1;
this.canGoPrev = page > 0; } else if (count > 0) {
this.canGoNext = count === pageSize;
} else { } else {
this.canGoNext = false; this.canGoNext = false;
this.canGoPrev = false;
} }
this.canGoPrev = page > 0;
this.translationInfo = { this.translationInfo = {
itemFirst: this.itemFirst, itemFirst: this.itemFirst,
itemLast: this.itemLast, itemLast: this.itemLast,

17
frontend/src/app/framework/services/dialog.service.ts

@ -60,6 +60,16 @@ export class DialogRequest {
} }
export class Tooltip { export class Tooltip {
private readonly isHorizontal;
public get offsetX() {
return this.isHorizontal ? 6 : 0;
}
public get offsetY() {
return this.isHorizontal ? 0 : 6;
}
constructor( constructor(
public readonly target: any, public readonly target: any,
public readonly text: string | null | undefined, public readonly text: string | null | undefined,
@ -67,6 +77,13 @@ export class Tooltip {
public readonly multiple?: boolean, public readonly multiple?: boolean,
public readonly shortcut?: string, public readonly shortcut?: string,
) { ) {
this.isHorizontal =
textPosition === 'left-bottom' ||
textPosition === 'left-center' ||
textPosition === 'left-top' ||
textPosition === 'right-bottom' ||
textPosition === 'right-center' ||
textPosition === 'right-top';
} }
} }

2
frontend/src/app/shared/components/assets/assets-list.component.html

@ -89,5 +89,5 @@
</sqx-list-view> </sqx-list-view>
<ng-container *ngIf="showPager"> <ng-container *ngIf="showPager">
<sqx-pager [autoHide]="true" [paging]="assetsState.paging | async" (pagingChange)="assetsState.page($event)"></sqx-pager> <sqx-pager (loadTotal)="reloadTotal()" [autoHide]="true" [paging]="assetsState.paging | async" (pagingChange)="assetsState.page($event)"></sqx-pager>
</ng-container> </ng-container>

4
frontend/src/app/shared/components/assets/assets-list.component.ts

@ -72,6 +72,10 @@ export class AssetsListComponent extends StatefulComponent<State> {
} }
} }
public reloadTotal() {
this.assetsState.load(true, false);
}
public selectFolder(asset: AssetDto) { public selectFolder(asset: AssetDto) {
this.assetsState.navigate(asset.parentId); this.assetsState.navigate(asset.parentId);
} }

2
frontend/src/app/shared/components/contents/content-value.component.html

@ -1,5 +1,5 @@
<ng-container *ngIf="isPlain; else html"> <ng-container *ngIf="isPlain; else html">
<div class="value-container"> <div class="value-container" [title]="title" titlePosition="top-left">
<div #valueElement [class.truncate]="!wrapping">{{value}}</div> <div #valueElement [class.truncate]="!wrapping">{{value}}</div>
</div> </div>

4
frontend/src/app/shared/components/contents/content-value.component.ts

@ -35,6 +35,10 @@ export class ContentValueComponent extends ResourceOwner implements OnChanges {
return !Types.is(this.value, HtmlValue); return !Types.is(this.value, HtmlValue);
} }
public get title() {
return this.isString ? this.value : undefined;
}
constructor( constructor(
private readonly changeDetector: ChangeDetectorRef, private readonly changeDetector: ChangeDetectorRef,
) { ) {

2
frontend/src/app/shared/components/references/content-selector.component.html

@ -84,7 +84,7 @@
</ng-container> </ng-container>
<ng-container footer> <ng-container footer>
<sqx-pager [paging]="contentsState.paging | async" (pagingChange)="contentsState.page($event)"></sqx-pager> <sqx-pager (loadTotal)="reloadTotal()" [paging]="contentsState.paging | async" (pagingChange)="contentsState.page($event)"></sqx-pager>
</ng-container> </ng-container>
</sqx-list-view> </sqx-list-view>
</ng-container> </ng-container>

4
frontend/src/app/shared/components/references/content-selector.component.ts

@ -102,6 +102,10 @@ export class ContentSelectorComponent extends ResourceOwner implements OnInit {
this.contentsState.load(true); this.contentsState.load(true);
} }
public reloadTotal() {
this.contentsState.load(true, false);
}
public search(query: Query) { public search(query: Query) {
this.contentsState.search(query); this.contentsState.search(query);
} }

61
frontend/src/app/shared/services/assets.service.spec.ts

@ -94,6 +94,7 @@ describe('AssetsService', () => {
expect(req.request.method).toEqual('POST'); expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('X-NoSlowTotal')).toBeNull();
expect(req.request.headers.get('X-NoTotal')).toBeNull(); expect(req.request.headers.get('X-NoTotal')).toBeNull();
expect(req.request.body).toEqual({ q: sanitize(expectedQuery) }); expect(req.request.body).toEqual({ q: sanitize(expectedQuery) });
@ -171,7 +172,7 @@ describe('AssetsService', () => {
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => { inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
const query = { fullText: 'my-query' }; const query = { fullText: 'my-query' };
assetsService.getAssets('my-app', { take: 17, skip: 13, query, noTotal: true }).subscribe(); assetsService.getAssets('my-app', { take: 17, skip: 13, query }).subscribe();
const expectedQuery = { filter: { and: [{ path: 'fileName', op: 'contains', value: 'my-query' }] }, take: 17, skip: 13 }; const expectedQuery = { filter: { and: [{ path: 'fileName', op: 'contains', value: 'my-query' }] }, take: 17, skip: 13 };
@ -179,7 +180,8 @@ describe('AssetsService', () => {
expect(req.request.method).toEqual('POST'); expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('X-NoTotal')).toEqual('1'); expect(req.request.headers.get('X-NoSlowTotal')).toBeNull();
expect(req.request.headers.get('X-NoTotal')).toBeNull();
expect(req.request.body).toEqual({ q: sanitize(expectedQuery) }); expect(req.request.body).toEqual({ q: sanitize(expectedQuery) });
req.flush({ total: 10, items: [] }); req.flush({ total: 10, items: [] });
@ -195,6 +197,7 @@ describe('AssetsService', () => {
expect(req.request.method).toEqual('POST'); expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('X-NoSlowTotal')).toBeNull();
expect(req.request.headers.get('X-NoTotal')).toBeNull(); expect(req.request.headers.get('X-NoTotal')).toBeNull();
expect(req.request.body).toEqual({ q: sanitize(expectedQuery) }); expect(req.request.body).toEqual({ q: sanitize(expectedQuery) });
@ -207,35 +210,49 @@ describe('AssetsService', () => {
assetsService.getAssets('my-app', { ids }).subscribe(); assetsService.getAssets('my-app', { ids }).subscribe();
const expectedBody = { ids };
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/query'); const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/query');
expect(req.request.method).toEqual('POST'); expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('X-NoSlowTotal')).toBeNull();
expect(req.request.headers.get('X-NoTotal')).toBeNull(); expect(req.request.headers.get('X-NoTotal')).toBeNull();
expect(req.request.body).toEqual(expectedBody); expect(req.request.body).toEqual({ ids });
req.flush({ total: 10, items: [] });
}));
it('should make post request to get assets by ref',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
const value = '1', op = 'eq';
assetsService.getAssets('my-app', { ref: value }).subscribe();
const expectedQuery = { filter: { or: [{ path: 'id', op, value }, { path: 'slug', op, value }] }, take: 1 };
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/query');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('X-NoTotal')).toEqual('1');
expect(req.request.headers.get('X-NoSlowTotal')).toBeNull();
expect(req.request.body).toEqual({ q: sanitize(expectedQuery) });
req.flush({ total: 10, items: [] }); req.flush({ total: 10, items: [] });
})); }));
it('should make post request to get assets by ref', it('should make post request to get assets without total',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => { inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
const value = '1', op = 'eq'; assetsService.getAssets('my-app', { take: 17, skip: 13, noSlowTotal: true, noTotal: true }).subscribe();
assetsService.getAssets('my-app', { ref: value }).subscribe(); const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/query');
const expectedQuery = { filter: { or: [{ path: 'id', op, value }, { path: 'slug', op, value }] }, take: 1 }; expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/query'); expect(req.request.headers.get('X-NoTotal')).toBe('1');
expect(req.request.headers.get('X-NoSlowTotal')).toBe('1');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull(); req.flush({ total: 10, items: [] });
expect(req.request.headers.get('X-NoTotal')).toEqual('1'); }));
expect(req.request.body).toEqual({ q: sanitize(expectedQuery) });
req.flush({ total: 10, items: [] });
}));
it('should make post request to create asset', it('should make post request to create asset',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => { inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {

30
frontend/src/app/shared/services/assets.service.ts

@ -161,7 +161,7 @@ export type MoveAssetItemDto =
Readonly<{ parentId?: string }>; Readonly<{ parentId?: string }>;
export type AssetsQuery = export type AssetsQuery =
Readonly<{ noTotal?: boolean }>; Readonly<{ noTotal?: boolean; noSlowTotal?: boolean }>;
export type AssetsByRef = export type AssetsByRef =
Readonly<{ ref: string }>; Readonly<{ ref: string }>;
@ -200,17 +200,7 @@ export class AssetsService {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets/query`); const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets/query`);
let options = {}; return this.http.post<any>(url, body, buildHeaders(q, q?.['ref'])).pipe(
if (q?.noTotal || q?.['ref']) {
options = {
headers: {
'X-NoTotal': '1',
},
};
}
return this.http.post<any>(url, body, options).pipe(
map(body => { map(body => {
return parseAssets(body); return parseAssets(body);
}), }),
@ -383,6 +373,22 @@ export class AssetsService {
} }
} }
function buildHeaders(q: AssetsQuery | undefined, noTotal: boolean) {
let options = {
headers: {},
};
if (q?.noTotal || noTotal) {
options.headers['X-NoTotal'] = '1';
}
if (q?.noSlowTotal) {
options.headers['X-NoSlowTotal'] = '1';
}
return options;
}
function buildQuery(q?: AssetsQuery & AssetsByQuery & AssetsByIds & AssetsByRef) { function buildQuery(q?: AssetsQuery & AssetsByQuery & AssetsByIds & AssetsByRef) {
const { ids, parentId, query, ref, skip, tags, take } = q || {}; const { ids, parentId, query, ref, skip, tags, take } = q || {};

39
frontend/src/app/shared/services/contents.service.spec.ts

@ -38,7 +38,7 @@ describe('ContentsService', () => {
let contents: ContentsDto; let contents: ContentsDto;
contentsService.getContents('my-app', 'my-schema', { take: 17, skip: 13, query, noTotal: true }).subscribe(result => { contentsService.getContents('my-app', 'my-schema', { take: 17, skip: 13, query }).subscribe(result => {
contents = result; contents = result;
}); });
@ -48,7 +48,8 @@ describe('ContentsService', () => {
expect(req.request.method).toEqual('POST'); expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('X-NoTotal')).toBe('1'); expect(req.request.headers.get('X-NoSlowTotal')).toBeNull();
expect(req.request.headers.get('X-NoTotal')).toBeNull();
expect(req.request.body).toEqual({ q: sanitize(expectedQuery) }); expect(req.request.body).toEqual({ q: sanitize(expectedQuery) });
req.flush({ req.flush({
@ -79,6 +80,7 @@ describe('ContentsService', () => {
expect(req.request.method).toEqual('POST'); expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('X-NoSlowTotal')).toBeNull();
expect(req.request.headers.get('X-NoTotal')).toBeNull(); expect(req.request.headers.get('X-NoTotal')).toBeNull();
expect(req.request.body).toEqual({ odata: '$filter=my-filter&$top=17&$skip=13' }); expect(req.request.body).toEqual({ odata: '$filter=my-filter&$top=17&$skip=13' });
@ -89,18 +91,47 @@ describe('ContentsService', () => {
inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => { inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => {
const ids = ['1', '2', '3']; const ids = ['1', '2', '3'];
contentsService.getAllContents('my-app', { ids, noTotal: true }).subscribe(); contentsService.getAllContents('my-app', { ids }).subscribe();
const req = httpMock.expectOne('http://service/p/api/content/my-app'); const req = httpMock.expectOne('http://service/p/api/content/my-app');
expect(req.request.method).toEqual('POST'); expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('X-NoTotal')).toBe('1'); expect(req.request.headers.get('X-NoSlowTotal')).toBeNull();
expect(req.request.headers.get('X-NoTotal')).toBeNull();
expect(req.request.body).toEqual({ ids }); expect(req.request.body).toEqual({ ids });
req.flush({ total: 10, items: [] }); req.flush({ total: 10, items: [] });
})); }));
it('should make post request to get contents without total',
inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => {
contentsService.getContents('my-app', 'my-schema', { noTotal: true, noSlowTotal: true }).subscribe();
const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/query');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('X-NoSlowTotal')).toBe('1');
expect(req.request.headers.get('X-NoTotal')).toBe('1');
req.flush({ total: 10, items: [] });
}));
it('should make post request to get all contents without total',
inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => {
contentsService.getAllContents('my-app', { ids: [], noTotal: true, noSlowTotal: true }).subscribe();
const req = httpMock.expectOne('http://service/p/api/content/my-app');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('X-NoSlowTotal')).toBe('1');
expect(req.request.headers.get('X-NoTotal')).toBe('1');
req.flush({ total: 10, items: [] });
}));
it('should make get request to get content', it('should make get request to get content',
inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => { inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => {
let content: ContentDto; let content: ContentDto;

61
frontend/src/app/shared/services/contents.service.ts

@ -118,14 +118,17 @@ export type ContentFieldData<T = any> =
export type ContentData = export type ContentData =
Readonly<{ [fieldName: string ]: ContentFieldData }>; Readonly<{ [fieldName: string ]: ContentFieldData }>;
export type BulkStatusDto =
Readonly<{ status?: string; dueTime?: string | null }>;
export type BulkUpdateDto = export type BulkUpdateDto =
Readonly<{ jobs: ReadonlyArray<BulkUpdateJobDto>; doNotScript?: boolean; checkReferrers?: boolean }>; Readonly<{ jobs: ReadonlyArray<BulkUpdateJobDto>; doNotScript?: boolean; checkReferrers?: boolean }>;
export type BulkUpdateJobDto = export type BulkUpdateJobDto =
Readonly<{ id: string; type: BulkUpdateType; status?: string; schema?: string; dueTime?: string | null; expectedVersion?: number }>; Readonly<{ id: string; type: BulkUpdateType; schema?: string; expectedVersion?: number }> & BulkStatusDto;
export type ContentsQuery = export type ContentsQuery =
Readonly<{ noTotal?: boolean }>; Readonly<{ noTotal?: boolean; noSlowTotal?: boolean }>;
export type ContentsByIds = export type ContentsByIds =
Readonly<{ ids: ReadonlyArray<string> }> & ContentsQuery; Readonly<{ ids: ReadonlyArray<string> }> & ContentsQuery;
@ -150,17 +153,7 @@ export class ContentsService {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/query`); const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/query`);
let options = {}; return this.http.post<any>(url, body, buildHeaders(q, false)).pipe(
if (q?.noTotal) {
options = {
headers: {
'X-NoTotal': '1',
},
};
}
return this.http.post<any>(url, body, options).pipe(
map(body => { map(body => {
return parseContents(body); return parseContents(body);
}), }),
@ -185,21 +178,11 @@ export class ContentsService {
} }
public getAllContents(appName: string, q: ContentsByIds | ContentsBySchedule): Observable<ContentsDto> { public getAllContents(appName: string, q: ContentsByIds | ContentsBySchedule): Observable<ContentsDto> {
const { noTotal, ...body } = q; const { ...body } = q;
const url = this.apiUrl.buildUrl(`/api/content/${appName}`); const url = this.apiUrl.buildUrl(`/api/content/${appName}`);
let options = {}; return this.http.post<any>(url, body, buildHeaders(q, false)).pipe(
if (noTotal) {
options = {
headers: {
'X-NoTotal': '1',
},
};
}
return this.http.post<any>(url, body, options).pipe(
map(body => { map(body => {
return parseContents(body); return parseContents(body);
}), }),
@ -211,17 +194,7 @@ export class ContentsService {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/references?${fullQuery}`); const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/references?${fullQuery}`);
let options = {}; return this.http.get<any>(url, buildHeaders(q, false)).pipe(
if (q?.noTotal) {
options = {
headers: {
'X-NoTotal': '1',
},
};
}
return this.http.get<any>(url, options).pipe(
map(body => { map(body => {
return parseContents(body); return parseContents(body);
}), }),
@ -352,6 +325,22 @@ export class ContentsService {
} }
} }
function buildHeaders(q: ContentsQuery | undefined, noTotal: boolean) {
let options = {
headers: {},
};
if (q?.noTotal || noTotal) {
options.headers['X-NoTotal'] = '1';
}
if (q?.noSlowTotal) {
options.headers['X-NoSlowTotal'] = '1';
}
return options;
}
function buildQuery(q?: ContentsByQuery) { function buildQuery(q?: ContentsByQuery) {
const { query, skip, take } = q || {}; const { query, skip, take } = q || {};

41
frontend/src/app/shared/state/assets.state.spec.ts

@ -53,7 +53,7 @@ describe('AssetsState', () => {
}); });
it('should load assets', () => { it('should load assets', () => {
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true }))
.returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable(); .returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable();
assetsState.load().subscribe(); assetsState.load().subscribe();
@ -67,7 +67,7 @@ describe('AssetsState', () => {
}); });
it('should show notification on load if reload is true', () => { it('should show notification on load if reload is true', () => {
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true }))
.returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable(); .returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable();
assetsState.load(true).subscribe(); assetsState.load(true).subscribe();
@ -77,11 +77,22 @@ describe('AssetsState', () => {
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once()); dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once());
}); });
it('should load with total', () => {
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: false }))
.returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable();
assetsState.load(true, false).subscribe();
expect().nothing();
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once());
});
it('should load without tags if tag untoggled', () => { it('should load without tags if tag untoggled', () => {
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, tags: ['tag1'] })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, tags: ['tag1'], noSlowTotal: true }))
.returns(() => of(new AssetsDto(0, []))).verifiable(); .returns(() => of(new AssetsDto(0, []))).verifiable();
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true }))
.returns(() => of(new AssetsDto(0, []))).verifiable(); .returns(() => of(new AssetsDto(0, []))).verifiable();
assetsState.toggleTag('tag1').subscribe(); assetsState.toggleTag('tag1').subscribe();
@ -91,7 +102,7 @@ describe('AssetsState', () => {
}); });
it('should load without tags if tags reset', () => { it('should load without tags if tags reset', () => {
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true }))
.returns(() => of(new AssetsDto(0, []))).verifiable(); .returns(() => of(new AssetsDto(0, []))).verifiable();
assetsState.resetTags().subscribe(); assetsState.resetTags().subscribe();
@ -100,7 +111,7 @@ describe('AssetsState', () => {
}); });
it('should load with new pagination if paging', () => { it('should load with new pagination if paging', () => {
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 30, parentId: MathHelper.EMPTY_GUID })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 30, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true }))
.returns(() => of(new AssetsDto(200, []))).verifiable(); .returns(() => of(new AssetsDto(200, []))).verifiable();
assetsState.page({ page: 1, pageSize: 30 }).subscribe(); assetsState.page({ page: 1, pageSize: 30 }).subscribe();
@ -109,10 +120,10 @@ describe('AssetsState', () => {
}); });
it('should skip page size if loaded before', () => { it('should skip page size if loaded before', () => {
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true }))
.returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable(); .returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable();
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 30, parentId: MathHelper.EMPTY_GUID, noTotal: true })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 30, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true, noTotal: true }))
.returns(() => of(new AssetsDto(200, []))).verifiable(); .returns(() => of(new AssetsDto(200, []))).verifiable();
assetsState.load().subscribe(); assetsState.load().subscribe();
@ -127,7 +138,7 @@ describe('AssetsState', () => {
assetsService.setup(x => x.getAssetFolders(app, '123', 'PathAndItems')) assetsService.setup(x => x.getAssetFolders(app, '123', 'PathAndItems'))
.returns(() => of(new AssetFoldersDto(2, [assetFolder1, assetFolder2], []))).verifiable(); .returns(() => of(new AssetFoldersDto(2, [assetFolder1, assetFolder2], []))).verifiable();
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: '123' })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: '123', noSlowTotal: true }))
.returns(() => of(new AssetsDto(200, []))).verifiable(); .returns(() => of(new AssetsDto(200, []))).verifiable();
assetsState.navigate('123').subscribe(); assetsState.navigate('123').subscribe();
@ -138,7 +149,7 @@ describe('AssetsState', () => {
describe('Searching', () => { describe('Searching', () => {
it('should load with tags if tag toggled', () => { it('should load with tags if tag toggled', () => {
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, tags: ['tag1'] })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, tags: ['tag1'], noSlowTotal: true }))
.returns(() => of(new AssetsDto(0, []))).verifiable(); .returns(() => of(new AssetsDto(0, []))).verifiable();
assetsState.toggleTag('tag1').subscribe(); assetsState.toggleTag('tag1').subscribe();
@ -147,7 +158,7 @@ describe('AssetsState', () => {
}); });
it('should load with tags if tags selected', () => { it('should load with tags if tags selected', () => {
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, tags: ['tag1', 'tag2'] })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, tags: ['tag1', 'tag2'], noSlowTotal: true }))
.returns(() => of(new AssetsDto(0, []))).verifiable(); .returns(() => of(new AssetsDto(0, []))).verifiable();
assetsState.selectTags(['tag1', 'tag2']).subscribe(); assetsState.selectTags(['tag1', 'tag2']).subscribe();
@ -158,7 +169,7 @@ describe('AssetsState', () => {
it('should load with query if searching', () => { it('should load with query if searching', () => {
const query = { fullText: 'my-query' }; const query = { fullText: 'my-query' };
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, query })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, query, noSlowTotal: true }))
.returns(() => of(new AssetsDto(0, []))).verifiable(); .returns(() => of(new AssetsDto(0, []))).verifiable();
assetsState.search(query).subscribe(); assetsState.search(query).subscribe();
@ -169,7 +180,7 @@ describe('AssetsState', () => {
it('should unset ref when searching', () => { it('should unset ref when searching', () => {
const query = { fullText: 'my-query' }; const query = { fullText: 'my-query' };
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, query })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, query, noSlowTotal: true }))
.returns(() => of(new AssetsDto(0, []))).verifiable(); .returns(() => of(new AssetsDto(0, []))).verifiable();
assetsState.next({ ref: '1' }); assetsState.next({ ref: '1' });
@ -185,10 +196,10 @@ describe('AssetsState', () => {
assetsService.setup(x => x.getAssetFolders(app, MathHelper.EMPTY_GUID, 'PathAndItems')) assetsService.setup(x => x.getAssetFolders(app, MathHelper.EMPTY_GUID, 'PathAndItems'))
.returns(() => of(new AssetFoldersDto(2, [assetFolder1, assetFolder2], []))); .returns(() => of(new AssetFoldersDto(2, [assetFolder1, assetFolder2], [])));
assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID })) assetsService.setup(x => x.getAssets(app, { take: 30, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true }))
.returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable(); .returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable();
assetsService.setup(x => x.getAssets(app, { take: 2, skip: 0, parentId: MathHelper.EMPTY_GUID })) assetsService.setup(x => x.getAssets(app, { take: 2, skip: 0, parentId: MathHelper.EMPTY_GUID, noSlowTotal: true }))
.returns(() => of(new AssetsDto(200, [asset1, asset2]))); .returns(() => of(new AssetsDto(200, [asset1, asset2])));
assetsState.load(true).subscribe(); assetsState.load(true).subscribe();

20
frontend/src/app/shared/state/assets.state.ts

@ -143,18 +143,18 @@ export abstract class AssetsStateBase extends State<Snapshot> {
}, name); }, name);
} }
public load(isReload = false, update: Partial<Snapshot> = {}): Observable<any> { public load(isReload = false, noSlowTotal = true, update: Partial<Snapshot> = {}): Observable<any> {
if (!isReload) { if (!isReload) {
this.resetState(update, 'Loading Initial'); this.resetState(update, 'Loading Initial');
} }
return this.loadInternal(isReload); return this.loadInternal(isReload, noSlowTotal);
} }
private loadInternal(isReload: boolean): Observable<any> { private loadInternal(isReload: boolean, noSlowTotal: boolean): Observable<any> {
this.next({ isLoading: true }, 'Loading Started'); this.next({ isLoading: true }, 'Loading Started');
const query = createQuery(this.snapshot); const query = createQuery(this.snapshot, noSlowTotal);
const assets$ = const assets$ =
this.assetsService.getAssets(this.appName, query); this.assetsService.getAssets(this.appName, query);
@ -368,7 +368,7 @@ export abstract class AssetsStateBase extends State<Snapshot> {
return EMPTY; return EMPTY;
} }
return this.loadInternal(false); return this.loadInternal(false, true);
} }
public page(paging: { page: number; pageSize: number }) { public page(paging: { page: number; pageSize: number }) {
@ -376,7 +376,7 @@ export abstract class AssetsStateBase extends State<Snapshot> {
return EMPTY; return EMPTY;
} }
return this.loadInternal(false); return this.loadInternal(false, true);
} }
public toggleTag(tag: string): Observable<any> { public toggleTag(tag: string): Observable<any> {
@ -410,7 +410,7 @@ export abstract class AssetsStateBase extends State<Snapshot> {
} }
private searchInternal(query?: Query | null, tags?: TagsSelected) { private searchInternal(query?: Query | null, tags?: TagsSelected) {
const update: Partial<Snapshot> = { page: 0, ref: null }; const update: Partial<Snapshot> = { page: 0, ref: null, total: 0 };
if (query !== null) { if (query !== null) {
update.query = query; update.query = query;
@ -424,7 +424,7 @@ export abstract class AssetsStateBase extends State<Snapshot> {
return EMPTY; return EMPTY;
} }
return this.loadInternal(false); return this.loadInternal(false, true);
} }
} }
@ -468,7 +468,7 @@ function updateTags(snapshot: Snapshot, newAsset?: AssetDto, oldAsset?: AssetDto
return { tagsAvailable, tagsSelected }; return { tagsAvailable, tagsSelected };
} }
function createQuery(snapshot: Snapshot) { function createQuery(snapshot: Snapshot, noSlowTotal: boolean) {
const { const {
ref, ref,
page, page,
@ -478,7 +478,7 @@ function createQuery(snapshot: Snapshot) {
total, total,
} = snapshot; } = snapshot;
const result: any = { take: pageSize, skip: pageSize * page }; const result: any = { take: pageSize, skip: pageSize * page, noSlowTotal };
const tags = Object.keys(tagsSelected); const tags = Object.keys(tagsSelected);

26
frontend/src/app/shared/state/contents.state.ts

@ -132,21 +132,21 @@ export abstract class ContentsStateBase extends State<Snapshot> {
public loadReference(contentId: string, update: Partial<Snapshot> = {}) { public loadReference(contentId: string, update: Partial<Snapshot> = {}) {
this.resetState({ reference: contentId, referencing: undefined, ...update }); this.resetState({ reference: contentId, referencing: undefined, ...update });
return this.loadInternal(false); return this.loadInternal(false, true);
} }
public loadReferencing(contentId: string, update: Partial<Snapshot> = {}) { public loadReferencing(contentId: string, update: Partial<Snapshot> = {}) {
this.resetState({ referencing: contentId, reference: undefined, ...update }); this.resetState({ referencing: contentId, reference: undefined, ...update });
return this.loadInternal(false); return this.loadInternal(false, true);
} }
public load(isReload = false, update: Partial<Snapshot> = {}): Observable<any> { public load(isReload = false, noSlowTotal = true, update: Partial<Snapshot> = {}): Observable<any> {
if (!isReload) { if (!isReload) {
this.resetState({ selectedContent: this.snapshot.selectedContent, ...update }, 'Loading Intial'); this.resetState({ selectedContent: this.snapshot.selectedContent, ...update }, 'Loading Intial');
} }
return this.loadInternal(isReload); return this.loadInternal(isReload, noSlowTotal);
} }
public loadIfNotLoaded(): Observable<any> { public loadIfNotLoaded(): Observable<any> {
@ -154,14 +154,14 @@ export abstract class ContentsStateBase extends State<Snapshot> {
return EMPTY; return EMPTY;
} }
return this.loadInternal(false); return this.loadInternal(false, true);
} }
private loadInternal(isReload: boolean) { private loadInternal(isReload: boolean, noSlowTotal: boolean) {
return this.loadInternalCore(isReload).pipe(shareSubscribed(this.dialogs)); return this.loadInternalCore(isReload, noSlowTotal).pipe(shareSubscribed(this.dialogs));
} }
private loadInternalCore(isReload: boolean) { private loadInternalCore(isReload: boolean, noSlowTotal: boolean) {
if (!this.appName || !this.schemaName) { if (!this.appName || !this.schemaName) {
return EMPTY; return EMPTY;
} }
@ -170,7 +170,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
const { page, pageSize, query, reference, referencing, total } = this.snapshot; const { page, pageSize, query, reference, referencing, total } = this.snapshot;
const q: any = { take: pageSize, skip: pageSize * page }; const q: any = { take: pageSize, skip: pageSize * page, noSlowTotal };
if (query) { if (query) {
q.query = query; q.query = query;
@ -277,7 +277,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
'i18n:contents.deleteReferrerConfirmTitle', 'i18n:contents.deleteReferrerConfirmTitle',
'i18n:contents.deleteReferrerConfirmText', 'i18n:contents.deleteReferrerConfirmText',
'deleteReferencngContent').pipe( 'deleteReferencngContent').pipe(
switchMap(() => this.loadInternalCore(false)), shareSubscribed(this.dialogs)); switchMap(() => this.loadInternalCore(false, true)), shareSubscribed(this.dialogs));
} }
public update(content: ContentDto, request: any): Observable<ContentDto> { public update(content: ContentDto, request: any): Observable<ContentDto> {
@ -321,11 +321,11 @@ export abstract class ContentsStateBase extends State<Snapshot> {
} }
public search(query?: Query): Observable<any> { public search(query?: Query): Observable<any> {
if (!this.next({ query, page: 0 }, 'Loading Searched')) { if (!this.next({ query, page: 0, total: 0 }, 'Loading Searched')) {
return EMPTY; return EMPTY;
} }
return this.loadInternal(false); return this.loadInternal(false, true);
} }
public page(paging: { page: number; pageSize: number }) { public page(paging: { page: number; pageSize: number }) {
@ -333,7 +333,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
return EMPTY; return EMPTY;
} }
return this.loadInternal(false); return this.loadInternal(false, true);
} }
private reloadContents(contents: ReadonlyArray<ContentDto>) { private reloadContents(contents: ReadonlyArray<ContentDto>) {

Loading…
Cancel
Save