From 8d189bb7e7dcdd45a41e03b6b0b05f0c148a952a Mon Sep 17 00:00:00 2001 From: Amedeo D'Amelio Date: Wed, 23 Sep 2026 14:37:07 +0200 Subject: [PATCH] feat: enhance MongoDB data context with embedded navigation support and batch execution methods --- docs/mongodb.md | 85 +++- ...rm.Data.MongoDB.DependencyInjection.csproj | 2 +- src/Data.MongoDB/DataContext.cs | 108 +++-- .../Navigation/EmbeddedIncluder.cs | 197 ++++++++ src/Data.MongoDB/README.md | 5 +- .../MongoDBSerializationInitializer.cs | 55 ++- src/Data.MongoDB/StateManager.cs | 6 + src/Data.MongoDB/StateManagerExtensions.cs | 4 + src/Data/StateManager.cs | 18 +- ...tform.Data.MongoDB.IntegrationTests.csproj | 4 +- .../Fixtures/Entities.cs | 11 + .../Fixtures/TestFixture.cs | 5 + .../RepositoryTests.cs | 442 ++++++++++++++---- .../UnitOfWorkTests.cs | 129 +++++ .../DataContextTests.cs | 351 ++++++++++++++ ...MongoDBServiceCollectionExtensionsTests.cs | 64 +++ .../Fixtures/IncludeEntities.cs | 61 +++ .../Navigation/EmbeddedIncluderTests.cs | 114 +++++ .../StateManagerTests.cs | 45 ++ 19 files changed, 1549 insertions(+), 157 deletions(-) create mode 100644 src/Data.MongoDB/Navigation/EmbeddedIncluder.cs create mode 100644 tests/Data.MongoDB.UnitTests/DataContextTests.cs create mode 100644 tests/Data.MongoDB.UnitTests/Fixtures/IncludeEntities.cs create mode 100644 tests/Data.MongoDB.UnitTests/Navigation/EmbeddedIncluderTests.cs diff --git a/docs/mongodb.md b/docs/mongodb.md index 5629066..067b240 100644 --- a/docs/mongodb.md +++ b/docs/mongodb.md @@ -174,23 +174,66 @@ documents. ## Operation semantics +Every row applies to both the synchronous and the asynchronous variant: they run the same driver +operation, on the same session, with the same success criterion and the same exceptions. + | Method | Behavior | Exception | |---|---|---| -| `FindAsync(key)` | filter on `_id` | — (`null` if absent) | -| `FindAsync(key, include)` | **not supported** | `NotSupportedException` | -| `InsertAsync` | `insertOne` | `MongoWriteException` on duplicate key | -| `InsertManyAsync` | ordered `insertMany`, in a transaction | `MongoBulkWriteException` | -| `UpdateAsync` | replaces the entire document | `DBConcurrencyException` if it does not exist | -| `UpdateManyAsync` | ordered `bulkWrite`, in a transaction | `DBConcurrencyException` if any do not exist | -| `UpsertAsync` | replaces or inserts | `DBConcurrencyException` if not applied | -| `RemoveAsync` | `deleteOne` | `DBConcurrencyException` if it does not exist | - -Two points may surprise users coming from relational providers: - -- **`UpdateAsync` replaces the entire document**, not only the modified fields: there is no change +| `Find(key)` | filter on `_id` | — (`null` if absent) | +| `Find(key, include)` | as `Find(key)`: embedded navigations are already loaded, see below | `NotSupportedException` for references to other collections | +| `Insert` | `insertOne` | `MongoWriteException` on duplicate key | +| `InsertMany` | ordered `insertMany`, in a transaction | `MongoBulkWriteException` on duplicate key | +| `Update` | replaces the entire document | `DBConcurrencyException` if it does not exist | +| `UpdateMany` | ordered `bulkWrite` of replacements, in a transaction | `DBConcurrencyException` if any do not exist | +| `Upsert` | replaces or inserts | `DBConcurrencyException` if not applied | +| `Remove(entity)` / `Remove(key)` | `deleteOne` | `DBConcurrencyException` if it does not exist | + +Some points may surprise users coming from relational providers: + +- **`Update` replaces the entire document**, not only the modified fields: there is no change tracking. For a partial update, use `Collection.UpdateOneAsync` directly with the current session. -- **An update that changes nothing is successful.** The criterion is "the document exists", not - "the document was rewritten". +- **An update or upsert that changes nothing is successful.** The criterion is "the document + exists", not "the document was rewritten". +- **`InsertMany` and `UpdateMany` with an empty sequence do nothing**: no round-trip and no + transaction, so they succeed on a standalone server too. A `null` element in the sequence throws + `ArgumentException`. +- **Duplicate keys** surface as the driver's own `MongoWriteException` / `MongoBulkWriteException`, + unchanged, as the ADO.NET provider does with the database exceptions. In a transaction, nothing + of the failed commit is persisted. +- **Unacknowledged writes** (write concern `w: 0`) are not checked: the server returns no counts, + so a missing document cannot be told apart from a successful write, and no + `DBConcurrencyException` is raised. +- **Cancellation.** Outside a unit of work the token reaches the driver. Inside a unit of work an + operation is only queued: its token is checked when it is queued (an operation already cancelled + is not queued and throws `OperationCanceledException`), while the commit is governed by the token + passed to `SaveAsync`. + +### Include + +The provider behaves as EF Core does with owned types. **Embedded navigations are always loaded** +with the document, so including them is accepted and has no effect. This keeps provider-agnostic +code, such as generated code that calls `Include`, working unchanged on MongoDB. + +```csharp +// all valid, and equivalent to FindAsync(id): Items and their children are in the document +await cartRepository.FindAsync(id, include => include.Include(cart => cart.Items)); +await cartRepository.FindAsync(id, include => include + .Include(cart => cart.Items, items => items.Include(item => item.Discount))); +await cartRepository.FindAsync(id, include => include.Include("Items.Discount")); +``` + +Every navigation in the path is validated **before** the query, so the outcome does not depend +on whether the document exists. A request the provider cannot satisfy is never ignored: + +| Request | Exception | +|---|---| +| navigation to an entity stored in **another collection** (a type with `[Collection]` / `[Table]`) | `NotSupportedException` | +| member that is **not persisted** (for example `[BsonIgnore]`) | `NotSupportedException` | +| member that does not exist, a scalar value, or an expression that is not a member access (a filtered include such as `x => x.Items.Where(...)`) | `InvalidOperationException` | + +References between collections are only keys, and the provider does not resolve them, as the +EF Core providers for document databases also do. Load the referenced aggregate from its own +repository, or query its collection from a specialized repository, passing `Session`. ## Repository @@ -221,6 +264,16 @@ The base class exposes `Collection` (`IMongoCollection`), `Database`, a > Passing `Session` to custom queries is not optional: an operation executed without a session runs > on an implicit session, and therefore **outside** the transaction of the current unit of work. +The repository is registered and consumed through the CAEP abstractions, like the repositories of +the other providers: `AddData` registers the MongoDB `IDataContext` the constructor needs. + +```csharp +builder.Services.AddScoped(); + +// a plain repository, without specialized queries +builder.Services.AddScoped, MongoDBRepository>(); +``` + ### Mapped repository When the document must differ from the domain, for example for denormalization, technical fields, @@ -305,8 +358,8 @@ MongoDbContainer container = new MongoDbBuilder() Not yet supported, in order of impact: -- **`Include`** — both for embedded navigations (where it would be a no-op) and inter-aggregate - references. Throws `NotSupportedException`. +- **`Include` of references between collections** and **filtered includes** — see + [Include](#include). Embedded navigations are supported. - **Optimistic concurrency** — no version token. - **Multitenancy and soft delete** — available in the EF Core provider, not here. - **Change tracking** — has no equivalent in the aggregate-based model. diff --git a/src/Data.MongoDB.DependencyInjection/CodeArchitects.Platform.Data.MongoDB.DependencyInjection.csproj b/src/Data.MongoDB.DependencyInjection/CodeArchitects.Platform.Data.MongoDB.DependencyInjection.csproj index 1eb4b14..ed6baba 100644 --- a/src/Data.MongoDB.DependencyInjection/CodeArchitects.Platform.Data.MongoDB.DependencyInjection.csproj +++ b/src/Data.MongoDB.DependencyInjection/CodeArchitects.Platform.Data.MongoDB.DependencyInjection.csproj @@ -1,7 +1,7 @@ - net7.0 + net7.0;net8.0;net9.0;net10.0 latest enable enable diff --git a/src/Data.MongoDB/DataContext.cs b/src/Data.MongoDB/DataContext.cs index 5ce2927..c781749 100644 --- a/src/Data.MongoDB/DataContext.cs +++ b/src/Data.MongoDB/DataContext.cs @@ -2,6 +2,7 @@ using CodeArchitects.Platform.Data.MongoDB.Filters; using CodeArchitects.Platform.Data.MongoDB.Model; using CodeArchitects.Platform.Data.MongoDB.Model.Implementation; +using CodeArchitects.Platform.Data.MongoDB.Navigation; using CodeArchitects.Platform.Data.Navigation; using MongoDB.Driver; using System.Data; @@ -61,14 +62,28 @@ public DataContext( where TEntity : class where TKey : IEquatable { - throw IncludeNotSupported(); + ValidateInclude(includeAction); + + return Find(key); } public Task FindAsync(TKey key, IncludeAction includeAction, CancellationToken cancellationToken = default) where TEntity : class where TKey : IEquatable { - throw IncludeNotSupported(); + ValidateInclude(includeAction); + + return FindAsync(key, cancellationToken); + } + + private void ValidateInclude(IncludeAction includeAction) + where TEntity : class + { + if (includeAction is null) + throw new ArgumentNullException(nameof(includeAction)); + + _ = EnsureEntity(); + includeAction(new EmbeddedIncluder(_model)); } #endregion @@ -93,14 +108,14 @@ public void InsertMany(IEnumerable entities) where TEntity : class where TKey : IEquatable { - _stateManager.Execute(InsertManyExecution(entities), requiresTransaction: true); + ExecuteMany(entities, InsertManyExecution); } public Task InsertManyAsync(IEnumerable entities, CancellationToken cancellationToken = default) where TEntity : class where TKey : IEquatable { - return _stateManager.ExecuteAsync(InsertManyExecution(entities), requiresTransaction: true, cancellationToken); + return ExecuteManyAsync(entities, InsertManyExecution, cancellationToken); } private Execution InsertExecution(TEntity entity) @@ -118,16 +133,11 @@ private Execution InsertExecution(TEntity entity) (session, cancellationToken) => collection.InsertOneAsync(session, entity, cancellationToken: cancellationToken)); } - private Execution InsertManyExecution(IEnumerable entities) + private Execution InsertManyExecution(TEntity[] documents) where TEntity : class where TKey : IEquatable { - if (entities is null) - throw new ArgumentNullException(nameof(entities)); - - _ = EnsureEntity(); IMongoCollection collection = _collections.GetCollection(); - TEntity[] documents = entities as TEntity[] ?? entities.ToArray(); InsertManyOptions options = new() { IsOrdered = true }; @@ -158,14 +168,14 @@ public void UpdateMany(IEnumerable entities) where TEntity : class where TKey : IEquatable { - _stateManager.Execute(UpdateManyExecution(entities), requiresTransaction: true); + ExecuteMany(entities, UpdateManyExecution); } public Task UpdateManyAsync(IEnumerable entities, CancellationToken cancellationToken = default) where TEntity : class where TKey : IEquatable { - return _stateManager.ExecuteAsync(UpdateManyExecution(entities), requiresTransaction: true, cancellationToken); + return ExecuteManyAsync(entities, UpdateManyExecution, cancellationToken); } private Execution UpdateExecution(TEntity entity) @@ -185,40 +195,25 @@ private Execution UpdateExecution(TEntity entity) await collection.ReplaceOneAsync(session, filter, entity, cancellationToken: cancellationToken), entityModel, entity)); } - private Execution UpdateManyExecution(IEnumerable entities) + private Execution UpdateManyExecution(TEntity[] documents) where TEntity : class where TKey : IEquatable { - if (entities is null) - throw new ArgumentNullException(nameof(entities)); - IEntityModel entityModel = EnsureEntity(); IMongoCollection collection = _collections.GetCollection(); // A single BulkWrite instead of N ReplaceOne: one round-trip, and an aggregate // MatchedCount to verify that every document existed. - ReplaceOneModel[] requests = entities + ReplaceOneModel[] requests = documents .Select(entity => new ReplaceOneModel(_filters.ByEntity(entityModel, entity), entity)) .ToArray(); BulkWriteOptions options = new() { IsOrdered = true }; return new Execution( - session => - { - if (requests.Length == 0) - return; - - EnsureAllUpdated(collection.BulkWrite(session, requests, options), requests.Length, entityModel); - }, - async (session, cancellationToken) => - { - if (requests.Length == 0) - return; - - EnsureAllUpdated( - await collection.BulkWriteAsync(session, requests, options, cancellationToken), requests.Length, entityModel); - }); + session => EnsureAllUpdated(collection.BulkWrite(session, requests, options), requests.Length, entityModel), + async (session, cancellationToken) => EnsureAllUpdated( + await collection.BulkWriteAsync(session, requests, options, cancellationToken), requests.Length, entityModel)); } #endregion @@ -357,12 +352,43 @@ private IEntityModel EnsureEntity() return entityModel; } - private static NotSupportedException IncludeNotSupported() + private void ExecuteMany(IEnumerable entities, Func execution) + where TEntity : class { - return new NotSupportedException( - $"The MongoDB provider does not support Find/FindAsync with Include yet (entity '{typeof(TEntity).Name}'). " + - "Intra-aggregate associations are already embedded in the document and need no Include; " + - "for inter-aggregate references, query the target collection explicitly."); + TEntity[] documents = ToBatch(entities); + if (documents.Length == 0) + return; + + _stateManager.Execute(execution(documents), requiresTransaction: true); + } + + private Task ExecuteManyAsync( + IEnumerable entities, + Func execution, + CancellationToken cancellationToken) + where TEntity : class + { + TEntity[] documents = ToBatch(entities); + if (documents.Length == 0) + return Task.CompletedTask; + + return _stateManager.ExecuteAsync(execution(documents), requiresTransaction: true, cancellationToken); + } + + private TEntity[] ToBatch(IEnumerable entities) + where TEntity : class + { + if (entities is null) + throw new ArgumentNullException(nameof(entities)); + + _ = EnsureEntity(); + TEntity[] documents = [.. entities]; + + int index = Array.FindIndex(documents, document => document is null); + if (index >= 0) + throw new ArgumentException($"The element at index {index} is null.", nameof(entities)); + + return documents; } /// @@ -373,7 +399,7 @@ private static void EnsureUpdated(ReplaceOneResult result, IEntit where TEntity : class where TKey : IEquatable { - if (result.IsAcknowledged && result.MatchedCount > 0) + if (!result.IsAcknowledged || result.MatchedCount > 0) return; throw new DBConcurrencyException( @@ -383,7 +409,7 @@ private static void EnsureUpdated(ReplaceOneResult result, IEntit private static void EnsureAllUpdated(BulkWriteResult result, int expected, IEntityModel entityModel) { - if (result.IsAcknowledged && result.MatchedCount == expected) + if (!result.IsAcknowledged || result.MatchedCount == expected) return; throw new DBConcurrencyException( @@ -396,7 +422,7 @@ private static void EnsureUpserted(ReplaceOneResult result, IEnti where TEntity : class where TKey : IEquatable { - if (result.IsAcknowledged && (result.MatchedCount > 0 || result.UpsertedId is not null)) + if (!result.IsAcknowledged || result.MatchedCount > 0 || result.UpsertedId is not null) return; throw new DBConcurrencyException( @@ -406,7 +432,7 @@ private static void EnsureUpserted(ReplaceOneResult result, IEnti private static void EnsureRemoved(DeleteResult result, IEntityModel entityModel, object? key) { - if (result.IsAcknowledged && result.DeletedCount > 0) + if (!result.IsAcknowledged || result.DeletedCount > 0) return; throw new DBConcurrencyException( diff --git a/src/Data.MongoDB/Navigation/EmbeddedIncluder.cs b/src/Data.MongoDB/Navigation/EmbeddedIncluder.cs new file mode 100644 index 0000000..c73487f --- /dev/null +++ b/src/Data.MongoDB/Navigation/EmbeddedIncluder.cs @@ -0,0 +1,197 @@ +using CodeArchitects.Platform.Data.MongoDB.Model; +using CodeArchitects.Platform.Data.Navigation; +using MongoDB.Bson.Serialization; +using System.Linq.Expressions; +using System.Reflection; + +namespace CodeArchitects.Platform.Data.MongoDB.Navigation; + +/// +/// Validates the navigations requested through an . +/// +/// +/// A navigation embedded in the document is already loaded with it, so including it has no +/// effect, as EF Core does with owned types. Every other request is rejected instead of being +/// ignored: a reference to a document of another collection, which the provider does not +/// resolve, and a member that is not persisted, which would stay empty. +/// +internal sealed class EmbeddedIncluder : IIncluder + where TEntity : class +{ + private readonly IDataModel _model; + + public EmbeddedIncluder(IDataModel model) + { + _model = model; + } + + public IExpressionIncluder Include(Expression> includeExpression) + where T : class + { + if (includeExpression is null) + throw new ArgumentNullException(nameof(includeExpression)); + + switch (includeExpression.Body) + { + case MemberExpression memberExpression: + ValidatePath(memberExpression); + break; + + case NewExpression newExpression when newExpression.Type.IsAnonymousType(): + foreach (Expression argument in newExpression.Arguments) + { + if (argument is not MemberExpression memberExpression) + throw InvalidExpression(argument); + + ValidatePath(memberExpression); + } + break; + + default: + throw InvalidExpression(includeExpression.Body); + } + + return this; + } + + public IExpressionIncluder Include(Expression> includeExpression, Action> thenInclude) + where T : class + { + return Include(includeExpression?.Body, thenInclude); + } + + public IExpressionIncluder Include(Expression?>> includeExpression, Action> thenInclude) + where T : class + { + return Include(includeExpression?.Body, thenInclude); + } + + public IStringIncluder Include(string navigation) + { + if (string.IsNullOrWhiteSpace(navigation)) + throw new ArgumentException("The include path cannot be empty.", nameof(navigation)); + + Type owner = typeof(TEntity); + foreach (string segment in navigation.Split('.')) + { + if (segment.Length == 0) + throw new InvalidOperationException($"The include path '{navigation}' has an empty segment."); + + MemberInfo member = (MemberInfo?)owner.GetProperty(segment, BindingFlags.Public | BindingFlags.Instance) + ?? owner.GetField(segment, BindingFlags.Public | BindingFlags.Instance) + ?? throw new InvalidOperationException( + $"Navigation '{segment}' of the include path '{navigation}' was not found on '{owner.Name}'."); + + owner = ValidateSegment(owner, member); + } + + return this; + } + + private IExpressionIncluder Include(Expression? body, Action> thenInclude) + where T : class + { + if (body is null) + throw new ArgumentNullException("includeExpression"); + if (thenInclude is null) + throw new ArgumentNullException(nameof(thenInclude)); + + if (body is not MemberExpression memberExpression) + throw InvalidExpression(body); + + ValidatePath(memberExpression); + + // The sub-navigations are validated from the navigation's own type. + thenInclude(new EmbeddedIncluder(_model)); + + return this; + } + + /// + /// Validates every segment of x => x.A.B, from the root down. + /// + private void ValidatePath(MemberExpression expression) + { + Stack members = new(); + Expression? current = expression; + + while (current is MemberExpression memberExpression) + { + members.Push(memberExpression.Member); + current = memberExpression.Expression; + } + + if (current is not ParameterExpression) + throw InvalidExpression(expression); + + Type owner = typeof(TEntity); + foreach (MemberInfo member in members) + { + owner = ValidateSegment(owner, member); + } + } + + /// + /// Validates one navigation and returns the type the next segment is resolved on: the + /// navigation's type, or its element type for a collection. + /// + private Type ValidateSegment(Type owner, MemberInfo member) + { + Type memberType = member switch + { + PropertyInfo property => property.PropertyType, + FieldInfo field => field.FieldType, + _ => throw new InvalidOperationException( + $"'{owner.Name}.{member.Name}' cannot be included: it is not a property or a field.") + }; + + Type target = ElementType(memberType); + + if (target.IsValueType || target == typeof(string)) + throw new InvalidOperationException( + $"'{owner.Name}.{member.Name}' cannot be included: it is a scalar value, not a navigation."); + + if (!owner.IsClass) + throw new NotSupportedException( + $"'{owner.Name}.{member.Name}' cannot be included: '{owner.Name}' is not a class, so the provider " + + "cannot verify that the member is persisted in the document."); + + if (!BsonClassMap.LookupClassMap(owner).AllMemberMaps.Any(map => map.MemberName == member.Name)) + throw new NotSupportedException( + $"'{owner.Name}.{member.Name}' cannot be included: it is not persisted in the document, " + + "so there is nothing to load."); + + if (_model.TryGetEntity(target, out IEntityModel? entity)) + throw new NotSupportedException( + $"'{owner.Name}.{member.Name}' cannot be included: '{target.Name}' is stored in its own collection " + + $"'{entity.CollectionName}', and the MongoDB provider does not resolve references between collections. " + + "Query that collection explicitly, passing the Session of the scope."); + + return target; + } + + private static Type ElementType(Type type) + { + if (type == typeof(string)) + return type; + + if (type.IsArray) + return type.GetElementType()!; + + Type? enumerable = IsEnumerable(type) + ? type + : type.GetInterfaces().FirstOrDefault(IsEnumerable); + + return enumerable?.GetGenericArguments()[0] ?? type; + + static bool IsEnumerable(Type candidate) => + candidate.IsGenericType && candidate.GetGenericTypeDefinition() == typeof(IEnumerable<>); + } + + private static InvalidOperationException InvalidExpression(Expression expression) + { + return new InvalidOperationException( + $"The include expression '{expression}' is not valid. Use member accesses, such as 'x => x.Items', " + + "'x => x.Details.Address' or 'x => new { x.Items, x.Details }'. Filtered includes are not supported."); + } +} diff --git a/src/Data.MongoDB/README.md b/src/Data.MongoDB/README.md index 9d1c40e..94d36f2 100644 --- a/src/Data.MongoDB/README.md +++ b/src/Data.MongoDB/README.md @@ -80,8 +80,9 @@ Seeding is idempotent per collection and commits every seed in a single transact ## Not supported yet -`Include`, optimistic concurrency, multitenancy, soft delete, change tracking, composite keys and -index management. See [the documentation](../../docs/mongodb.md) for the full list. +`Include` of references between collections (embedded navigations are supported and already +loaded with the document), optimistic concurrency, multitenancy, soft delete, change tracking, +composite keys and index management. See [the documentation](../../docs/mongodb.md) for the full list. ## Related packages diff --git a/src/Data.MongoDB/Serialization/MongoDBSerializationInitializer.cs b/src/Data.MongoDB/Serialization/MongoDBSerializationInitializer.cs index 0dd4fac..04d805f 100644 --- a/src/Data.MongoDB/Serialization/MongoDBSerializationInitializer.cs +++ b/src/Data.MongoDB/Serialization/MongoDBSerializationInitializer.cs @@ -18,33 +18,56 @@ internal static class MongoDBSerializationInitializer { private const string ConventionPackName = "CodeArchitects.Platform.Data.MongoDB"; - private static int s_initialized; + private static readonly object s_lock = new(); + + private static GuidRepresentation? s_guidRepresentation; public static void EnsureInitialized( GuidRepresentation guidRepresentation = GuidRepresentation.Standard, IReadOnlyList>? conventionConfigurators = null) { - // AddData may be called more than once (multiple databases): only the first call registers. - if (Interlocked.CompareExchange(ref s_initialized, 1, 0) != 0) - return; + lock (s_lock) + { + if (s_guidRepresentation is GuidRepresentation applied) + { + EnsureCompatible(applied, guidRepresentation, conventionConfigurators); + return; + } - // Driver 3.x has no implicit Guid representation: without this, serializing a Guid key - // throws "GuidSerializer cannot serialize a Guid when GuidRepresentation is Unspecified". - BsonSerializer.RegisterSerializer(new GuidSerializer(guidRepresentation)); + BsonSerializer.RegisterSerializer(new GuidSerializer(guidRepresentation)); - ConventionPack pack = new() - { - new IgnoreExtraElementsConvention(true) - }; + ConventionPack pack = + [ + new IgnoreExtraElementsConvention(true) + ]; - if (conventionConfigurators is not null) - { - foreach (Action configure in conventionConfigurators) + if (conventionConfigurators is not null) { - configure(pack); + foreach (Action configure in conventionConfigurators) + { + configure(pack); + } } + + ConventionRegistry.Register(ConventionPackName, pack, _ => true); + + s_guidRepresentation = guidRepresentation; } + } + + private static void EnsureCompatible( + GuidRepresentation applied, + GuidRepresentation requested, + IReadOnlyList>? conventionConfigurators) + { + if (requested != applied) + throw new InvalidOperationException( + $"The MongoDB serialization was already initialized with GuidRepresentation.{applied}: GuidRepresentation.{requested} cannot be applied."); - ConventionRegistry.Register(ConventionPackName, pack, _ => true); + if (conventionConfigurators is { Count: > 0 }) + throw new InvalidOperationException( + "The MongoDB conventions were already registered by a previous AddData call and cannot be " + + "changed, because the convention registry is shared by the whole process. Call " + + "ConfigureConventions only in the first AddData call."); } } diff --git a/src/Data.MongoDB/StateManager.cs b/src/Data.MongoDB/StateManager.cs index 29953e6..ab5330b 100644 --- a/src/Data.MongoDB/StateManager.cs +++ b/src/Data.MongoDB/StateManager.cs @@ -133,6 +133,12 @@ await session.WithTransactionAsync( return (batch, requiresTransaction); } + protected override void DiscardPending() + { + _executions.Clear(); + _requiresTransaction = false; + } + private bool ShouldUseTransaction(int executionCount, bool requiresTransaction) { if (_options.TransactionMode == TransactionMode.Disabled) diff --git a/src/Data.MongoDB/StateManagerExtensions.cs b/src/Data.MongoDB/StateManagerExtensions.cs index 129fe1b..79929c7 100644 --- a/src/Data.MongoDB/StateManagerExtensions.cs +++ b/src/Data.MongoDB/StateManagerExtensions.cs @@ -16,6 +16,10 @@ public static Task ExecuteAsync( bool requiresTransaction, CancellationToken cancellationToken) { + // Inside a unit of work the operation is only queued and this token never reaches the + // driver: an operation already cancelled by its caller must not be committed later. + cancellationToken.ThrowIfCancellationRequested(); + stateManager.AddExecution(execution, requiresTransaction); return stateManager.SaveAsync(cancellationToken); diff --git a/src/Data/StateManager.cs b/src/Data/StateManager.cs index a3ad8e0..8d74117 100644 --- a/src/Data/StateManager.cs +++ b/src/Data/StateManager.cs @@ -42,6 +42,14 @@ public void Save() protected abstract Task SaveCoreAsync(CancellationToken cancellationToken); + /// + /// Called when a unit of work ends without saving: a provider that queues its writes drops + /// them here, so that a later save in the same scope cannot commit them. + /// + protected virtual void DiscardPending() + { + } + private sealed class UnitOfWork : IUnitOfWork { private readonly StateManager _manager; @@ -73,7 +81,11 @@ public ValueTask DisposeAsync() _isDisposed = true; _manager._current = null; - return new(_autoSave ? _manager.SaveCoreAsync(_cancellationToken) : Task.CompletedTask); + if (_autoSave) + return new(_manager.SaveCoreAsync(_cancellationToken)); + + _manager.DiscardPending(); + return new(Task.CompletedTask); } public void Dispose() @@ -87,6 +99,10 @@ public void Dispose() { _manager.SaveCore(); } + else + { + _manager.DiscardPending(); + } } } } diff --git a/tests/Data.MongoDB.IntegrationTests/CodeArchitects.Platform.Data.MongoDB.IntegrationTests.csproj b/tests/Data.MongoDB.IntegrationTests/CodeArchitects.Platform.Data.MongoDB.IntegrationTests.csproj index b55e938..cec1964 100644 --- a/tests/Data.MongoDB.IntegrationTests/CodeArchitects.Platform.Data.MongoDB.IntegrationTests.csproj +++ b/tests/Data.MongoDB.IntegrationTests/CodeArchitects.Platform.Data.MongoDB.IntegrationTests.csproj @@ -30,8 +30,8 @@ - - + + diff --git a/tests/Data.MongoDB.IntegrationTests/Fixtures/Entities.cs b/tests/Data.MongoDB.IntegrationTests/Fixtures/Entities.cs index 18a552c..dab2989 100644 --- a/tests/Data.MongoDB.IntegrationTests/Fixtures/Entities.cs +++ b/tests/Data.MongoDB.IntegrationTests/Fixtures/Entities.cs @@ -35,6 +35,17 @@ public class CartItem public static CartItem One() => s_faker.Generate(); } +/// +/// Holds a navigation typed as another aggregate root: including it would ask the provider to +/// resolve a reference between collections, which it does not do. +/// +[CodeArchitects.Platform.Data.MongoDB.Collection("invoices")] +public class Invoice +{ + public Guid Id { get; set; } + public Customer? Customer { get; set; } +} + /// The persisted shape: only this one is registered in the model. [CodeArchitects.Platform.Data.MongoDB.Collection("orders")] public class OrderDocument diff --git a/tests/Data.MongoDB.IntegrationTests/Fixtures/TestFixture.cs b/tests/Data.MongoDB.IntegrationTests/Fixtures/TestFixture.cs index 0bc1654..d7969fb 100644 --- a/tests/Data.MongoDB.IntegrationTests/Fixtures/TestFixture.cs +++ b/tests/Data.MongoDB.IntegrationTests/Fixtures/TestFixture.cs @@ -49,6 +49,7 @@ private static ServiceProvider BuildServices(string connectionString) => .UseDatabase(DatabaseName) .AddEntitiesFrom(typeof(Customer).Assembly) .UseTransactions(TransactionMode.Required)) + .AddScoped, MongoDBRepository>() .BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); /// @@ -113,6 +114,10 @@ public sealed class TestScope : IDisposable internal Seeder Seeder => _scope.ServiceProvider.GetRequiredService(); + public TService Get() + where TService : notnull + => _scope.ServiceProvider.GetRequiredService(); + public MongoDBRepository Repository() where TEntity : class where TKey : IEquatable diff --git a/tests/Data.MongoDB.IntegrationTests/RepositoryTests.cs b/tests/Data.MongoDB.IntegrationTests/RepositoryTests.cs index 12411ad..77c7052 100644 --- a/tests/Data.MongoDB.IntegrationTests/RepositoryTests.cs +++ b/tests/Data.MongoDB.IntegrationTests/RepositoryTests.cs @@ -4,15 +4,39 @@ namespace CodeArchitects.Platform.Data.MongoDB; +/// +/// Every operation is exercised through both its synchronous and its asynchronous variant, +/// against the same assertions: the two paths must behave the same. +/// [Xunit.Collection(TestCollection.Name)] public class RepositoryTests(TestFixture fixture) : TestBase(fixture) { private static MongoDBRepository Repository(TestScope scope) => scope.Repository(); + private static Task Run(bool async, Action sync, Func asyncOperation) + { + if (async) + return asyncOperation(); + + sync(); + return Task.CompletedTask; + } + + private static async Task Run(bool async, Func sync, Func> asyncOperation) => + async ? await asyncOperation() : sync(); + + private Task Persisted(Guid id) => + _fixture.GetCollection().Find(x => x.Id == id).FirstOrDefaultAsync()!; + + private Task> AllPersisted() => + _fixture.GetCollection().Find(FilterDefinition.Empty).ToListAsync(); + #region Find - [Fact] - public async Task FindAsync_ShouldReturnEntity_WhenEntityExists() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Find_ShouldReturnEntity_WhenEntityExists(bool async) { // Arrange Customer expected = Customer.One(); @@ -20,61 +44,118 @@ public async Task FindAsync_ShouldReturnEntity_WhenEntityExists() using TestScope scope = _fixture.CreateScope(); // Act - Customer? customer = await Repository(scope).FindAsync(expected.Id); + Customer? customer = await Run(async, + () => Repository(scope).Find(expected.Id), + () => Repository(scope).FindAsync(expected.Id)); // Assert customer.Should().BeEquivalentTo(expected); } - [Fact] - public async Task FindAsync_ShouldReturnNull_WhenEntityDoesNotExist() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Find_ShouldReturnNull_WhenEntityDoesNotExist(bool async) { // Arrange using TestScope scope = _fixture.CreateScope(); // Act - Customer? customer = await Repository(scope).FindAsync(Guid.NewGuid()); + Customer? customer = await Run(async, + () => Repository(scope).Find(Guid.NewGuid()), + () => Repository(scope).FindAsync(Guid.NewGuid())); // Assert customer.Should().BeNull(); } - [Fact] - public async Task FindAsync_ShouldThrow_WhenIncludeIsRequested() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Find_ShouldReturnEntity_WhenNothingIsIncluded(bool async) + { + // Arrange + Customer expected = Customer.One(); + await _fixture.SeedAsync([expected]); + using TestScope scope = _fixture.CreateScope(); + + // Act + Customer? customer = await Run(async, + () => Repository(scope).Find(expected.Id, _ => { }), + () => Repository(scope).FindAsync(expected.Id, _ => { })); + + // Assert + customer.Should().BeEquivalentTo(expected); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Find_ShouldReturnTheWholeAggregate_WhenIncludingAnEmbeddedNavigation(bool async) { // Arrange + Cart expected = Cart.One(); + await _fixture.SeedAsync([expected]); using TestScope scope = _fixture.CreateScope(); + MongoDBRepository repository = scope.Repository(); // Act - Func act = () => Repository(scope).FindAsync(Guid.NewGuid(), _ => { }); + Cart? cart = await Run(async, + () => repository.Find(expected.Id, include => include.Include(c => c.Items)), + () => repository.FindAsync(expected.Id, include => include.Include(c => c.Items))); // Assert - // No silent degradation: Include is not supported yet and says so. - await act.Should().ThrowAsync(); + // As EF Core does with owned types: the items are in the document, so the include is + // accepted and loads nothing more than Find(key) does. + cart.Should().BeEquivalentTo(expected); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Find_ShouldThrow_WhenIncludingAReferenceToAnotherCollection(bool async) + { + // Arrange + using TestScope scope = _fixture.CreateScope(); + MongoDBRepository repository = scope.Repository(); + + // Act + Func act = () => Run(async, + () => repository.Find(Guid.NewGuid(), include => include.Include(invoice => invoice.Customer)), + () => repository.FindAsync(Guid.NewGuid(), include => include.Include(invoice => invoice.Customer))); + + // Assert + // No silent degradation: the reference is not resolved, so the include is rejected rather + // than ignored, even when there is no document to return. + await act.Should().ThrowAsync().WithMessage("*Invoice.Customer*"); } #endregion #region Insert - [Fact] - public async Task InsertAsync_ShouldInsertEntity_WhenEntityDoesNotExist() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Insert_ShouldInsertEntity_WhenEntityDoesNotExist(bool async) { // Arrange Customer expected = Customer.One(); using TestScope scope = _fixture.CreateScope(); // Act - await Repository(scope).InsertAsync(expected); + await Run(async, + () => Repository(scope).Insert(expected), + () => Repository(scope).InsertAsync(expected)); // Assert - Customer? customer = await _fixture.GetCollection() - .Find(x => x.Id == expected.Id).FirstOrDefaultAsync(); - customer.Should().BeEquivalentTo(expected); + (await Persisted(expected.Id)).Should().BeEquivalentTo(expected); } - [Fact] - public async Task InsertAsync_ShouldThrow_WhenEntityAlreadyExists() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Insert_ShouldThrow_WhenEntityAlreadyExists(bool async) { // Arrange Customer existing = Customer.One(); @@ -82,34 +163,79 @@ public async Task InsertAsync_ShouldThrow_WhenEntityAlreadyExists() using TestScope scope = _fixture.CreateScope(); // Act - Func act = () => Repository(scope).InsertAsync(existing); + Func act = () => Run(async, + () => Repository(scope).Insert(existing), + () => Repository(scope).InsertAsync(existing)); // Assert await act.Should().ThrowAsync(); } - [Fact] - public async Task InsertManyAsync_ShouldInsertEveryEntity() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task InsertMany_ShouldInsertEveryEntity(bool async) { // Arrange Customer[] expected = [Customer.One(), Customer.One(), Customer.One()]; using TestScope scope = _fixture.CreateScope(); // Act - await Repository(scope).InsertManyAsync(expected); + await Run(async, + () => Repository(scope).InsertMany(expected), + () => Repository(scope).InsertManyAsync(expected)); // Assert - List customers = await _fixture.GetCollection() - .Find(FilterDefinition.Empty).ToListAsync(); - customers.Should().BeEquivalentTo(expected); + (await AllPersisted()).Should().BeEquivalentTo(expected); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task InsertMany_ShouldThrowAndInsertNothing_WhenOneEntityAlreadyExists(bool async) + { + // Arrange + Customer existing = Customer.One(); + await _fixture.SeedAsync([existing]); + Customer fresh = Customer.One(); + using TestScope scope = _fixture.CreateScope(); + + // Act + Func act = () => Run(async, + () => Repository(scope).InsertMany([fresh, existing]), + () => Repository(scope).InsertManyAsync([fresh, existing])); + + // Assert + await act.Should().ThrowAsync(); + (await Persisted(fresh.Id)).Should().BeNull("the insert of several documents runs inside a transaction"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task InsertMany_ShouldDoNothing_WhenTheSequenceIsEmpty(bool async) + { + // Arrange + // The standalone server rejects transactions: an empty batch must not even ask for one. + using TestScope scope = _fixture.CreateStandaloneScope(); + + // Act + Func act = () => Run(async, + () => Repository(scope).InsertMany([]), + () => Repository(scope).InsertManyAsync([])); + + // Assert + await act.Should().NotThrowAsync(); } #endregion #region Update - [Fact] - public async Task UpdateAsync_ShouldUpdateEntity_WhenEntityExists() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Update_ShouldUpdateEntity_WhenEntityExists(bool async) { // Arrange Customer expected = Customer.One(); @@ -118,16 +244,18 @@ public async Task UpdateAsync_ShouldUpdateEntity_WhenEntityExists() // Act expected.Name = "ModifiedName"; - await Repository(scope).UpdateAsync(expected); + await Run(async, + () => Repository(scope).Update(expected), + () => Repository(scope).UpdateAsync(expected)); // Assert - Customer? customer = await _fixture.GetCollection() - .Find(x => x.Id == expected.Id).FirstOrDefaultAsync(); - customer.Should().BeEquivalentTo(expected); + (await Persisted(expected.Id)).Should().BeEquivalentTo(expected); } - [Fact] - public async Task UpdateAsync_ShouldNotThrow_WhenNothingChanged() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Update_ShouldNotThrow_WhenNothingChanged(bool async) { // Arrange Customer existing = Customer.One(); @@ -135,7 +263,9 @@ public async Task UpdateAsync_ShouldNotThrow_WhenNothingChanged() using TestScope scope = _fixture.CreateScope(); // Act - Func act = () => Repository(scope).UpdateAsync(existing); + Func act = () => Run(async, + () => Repository(scope).Update(existing), + () => Repository(scope).UpdateAsync(existing)); // Assert // A replace that matches but rewrites nothing reports ModifiedCount == 0. The success @@ -143,21 +273,28 @@ public async Task UpdateAsync_ShouldNotThrow_WhenNothingChanged() await act.Should().NotThrowAsync(); } - [Fact] - public async Task UpdateAsync_ShouldThrow_WhenEntityDoesNotExist() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Update_ShouldThrow_WhenEntityDoesNotExist(bool async) { // Arrange + Customer missing = Customer.One(); using TestScope scope = _fixture.CreateScope(); // Act - Func act = () => Repository(scope).UpdateAsync(Customer.One()); + Func act = () => Run(async, + () => Repository(scope).Update(missing), + () => Repository(scope).UpdateAsync(missing)); // Assert - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync().WithMessage($"*{missing.Id}*"); } - [Fact] - public async Task UpdateManyAsync_ShouldUpdateEveryEntity() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UpdateMany_ShouldUpdateEveryEntity(bool async) { // Arrange Customer[] seed = [Customer.One(), Customer.One()]; @@ -167,16 +304,37 @@ public async Task UpdateManyAsync_ShouldUpdateEveryEntity() // Act seed[0].Name = "First"; seed[1].Name = "Second"; - await Repository(scope).UpdateManyAsync(seed); + await Run(async, + () => Repository(scope).UpdateMany(seed), + () => Repository(scope).UpdateManyAsync(seed)); // Assert - List customers = await _fixture.GetCollection() - .Find(FilterDefinition.Empty).ToListAsync(); - customers.Select(customer => customer.Name).Should().BeEquivalentTo(["First", "Second"]); + (await AllPersisted()).Select(customer => customer.Name).Should().BeEquivalentTo(["First", "Second"]); } - [Fact] - public async Task UpdateManyAsync_ShouldThrowAndApplyNothing_WhenOneEntityDoesNotExist() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UpdateMany_ShouldNotThrow_WhenNothingChanged(bool async) + { + // Arrange + Customer[] seed = [Customer.One(), Customer.One()]; + await _fixture.SeedAsync(seed); + using TestScope scope = _fixture.CreateScope(); + + // Act + Func act = () => Run(async, + () => Repository(scope).UpdateMany(seed), + () => Repository(scope).UpdateManyAsync(seed)); + + // Assert + await act.Should().NotThrowAsync(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UpdateMany_ShouldThrowAndApplyNothing_WhenOneEntityDoesNotExist(bool async) { // Arrange Customer existing = Customer.One(); @@ -185,22 +343,57 @@ public async Task UpdateManyAsync_ShouldThrowAndApplyNothing_WhenOneEntityDoesNo // Act existing.Name = "Modified"; - Func act = () => Repository(scope).UpdateManyAsync([existing, Customer.One()]); + Func act = () => Run(async, + () => Repository(scope).UpdateMany([existing, Customer.One()]), + () => Repository(scope).UpdateManyAsync([existing, Customer.One()])); // Assert await act.Should().ThrowAsync(); + (await Persisted(existing.Id))!.Name.Should().NotBe("Modified", "the bulk write runs inside a transaction"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UpdateMany_ShouldDoNothing_WhenTheSequenceIsEmpty(bool async) + { + // Arrange + using TestScope scope = _fixture.CreateStandaloneScope(); + + // Act + Func act = () => Run(async, + () => Repository(scope).UpdateMany([]), + () => Repository(scope).UpdateManyAsync([])); + + // Assert + await act.Should().NotThrowAsync(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UpdateMany_ShouldThrow_WhenAnElementIsNull(bool async) + { + // Arrange + using TestScope scope = _fixture.CreateScope(); + + // Act + Func act = () => Run(async, + () => Repository(scope).UpdateMany([Customer.One(), null!]), + () => Repository(scope).UpdateManyAsync([Customer.One(), null!])); - Customer? customer = await _fixture.GetCollection() - .Find(x => x.Id == existing.Id).FirstOrDefaultAsync(); - customer!.Name.Should().NotBe("Modified", "the bulk write runs inside a transaction"); + // Assert + await act.Should().ThrowAsync(); } #endregion #region Upsert - [Fact] - public async Task UpsertAsync_ShouldUpdateEntity_WhenEntityExists() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Upsert_ShouldUpdateEntity_WhenEntityExists(bool async) { // Arrange Customer expected = Customer.One(); @@ -209,36 +402,59 @@ public async Task UpsertAsync_ShouldUpdateEntity_WhenEntityExists() // Act expected.Name = "ModifiedName"; - await Repository(scope).UpsertAsync(expected); + await Run(async, + () => Repository(scope).Upsert(expected), + () => Repository(scope).UpsertAsync(expected)); // Assert - Customer? customer = await _fixture.GetCollection() - .Find(x => x.Id == expected.Id).FirstOrDefaultAsync(); - customer.Should().BeEquivalentTo(expected); + (await Persisted(expected.Id)).Should().BeEquivalentTo(expected); } - [Fact] - public async Task UpsertAsync_ShouldInsertEntity_WhenEntityDoesNotExist() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Upsert_ShouldInsertEntity_WhenEntityDoesNotExist(bool async) { // Arrange Customer expected = Customer.One(); using TestScope scope = _fixture.CreateScope(); // Act - await Repository(scope).UpsertAsync(expected); + await Run(async, + () => Repository(scope).Upsert(expected), + () => Repository(scope).UpsertAsync(expected)); // Assert - Customer? customer = await _fixture.GetCollection() - .Find(x => x.Id == expected.Id).FirstOrDefaultAsync(); - customer.Should().BeEquivalentTo(expected); + (await Persisted(expected.Id)).Should().BeEquivalentTo(expected); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Upsert_ShouldNotThrow_WhenNothingChanged(bool async) + { + // Arrange + Customer existing = Customer.One(); + await _fixture.SeedAsync([existing]); + using TestScope scope = _fixture.CreateScope(); + + // Act + Func act = () => Run(async, + () => Repository(scope).Upsert(existing), + () => Repository(scope).UpsertAsync(existing)); + + // Assert + await act.Should().NotThrowAsync(); } #endregion #region Remove - [Fact] - public async Task RemoveAsync_ShouldRemoveEntity_WhenEntityExists() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Remove_ShouldRemoveEntity_WhenEntityExists(bool async) { // Arrange Customer existing = Customer.One(); @@ -246,16 +462,18 @@ public async Task RemoveAsync_ShouldRemoveEntity_WhenEntityExists() using TestScope scope = _fixture.CreateScope(); // Act - await Repository(scope).RemoveAsync(existing); + await Run(async, + () => Repository(scope).Remove(existing), + () => Repository(scope).RemoveAsync(existing)); // Assert - Customer? customer = await _fixture.GetCollection() - .Find(x => x.Id == existing.Id).FirstOrDefaultAsync(); - customer.Should().BeNull(); + (await Persisted(existing.Id)).Should().BeNull(); } - [Fact] - public async Task RemoveAsync_ShouldRemoveEntity_WhenEntityKeyExists() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Remove_ShouldRemoveEntity_WhenEntityKeyExists(bool async) { // Arrange Customer existing = Customer.One(); @@ -263,25 +481,93 @@ public async Task RemoveAsync_ShouldRemoveEntity_WhenEntityKeyExists() using TestScope scope = _fixture.CreateScope(); // Act - await Repository(scope).RemoveAsync(existing.Id); + await Run(async, + () => Repository(scope).Remove(existing.Id), + () => Repository(scope).RemoveAsync(existing.Id)); // Assert - Customer? customer = await _fixture.GetCollection() - .Find(x => x.Id == existing.Id).FirstOrDefaultAsync(); - customer.Should().BeNull(); + (await Persisted(existing.Id)).Should().BeNull(); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Remove_ShouldThrow_WhenEntityDoesNotExist(bool async) + { + // Arrange + Customer missing = Customer.One(); + using TestScope scope = _fixture.CreateScope(); + + // Act + Func act = () => Run(async, + () => Repository(scope).Remove(missing), + () => Repository(scope).RemoveAsync(missing)); + + // Assert + await act.Should().ThrowAsync().WithMessage($"*{missing.Id}*"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Remove_ShouldThrow_WhenEntityKeyDoesNotExist(bool async) + { + // Arrange + Guid missing = Guid.NewGuid(); + using TestScope scope = _fixture.CreateScope(); + + // Act + Func act = () => Run(async, + () => Repository(scope).Remove(missing), + () => Repository(scope).RemoveAsync(missing)); + + // Assert + await act.Should().ThrowAsync().WithMessage($"*{missing}*"); + } + + #endregion + + #region Cancellation + [Fact] - public async Task RemoveAsync_ShouldThrow_WhenEntityDoesNotExist() + public async Task InsertAsync_ShouldThrowAndWriteNothing_WhenTheTokenIsAlreadyCancelled() { // Arrange + Customer customer = Customer.One(); using TestScope scope = _fixture.CreateScope(); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); // Act - Func act = () => Repository(scope).RemoveAsync(Guid.NewGuid()); + Func act = () => Repository(scope).InsertAsync(customer, cancellation.Token); // Assert - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); + (await Persisted(customer.Id)).Should().BeNull(); + } + + #endregion + + #region Registration + + [Fact] + public async Task Repository_ShouldWork_WhenResolvedThroughTheCaepAbstraction() + { + // Arrange + using TestScope scope = _fixture.CreateScope(); + IRepository repository = scope.Get>(); + Customer customer = Customer.One(); + + // Act + await repository.InsertAsync(customer); + customer.Name = "Modified"; + await repository.UpdateAsync(customer); + Customer? found = await repository.FindAsync(customer.Id); + await repository.RemoveAsync(customer.Id); + + // Assert + found.Should().BeEquivalentTo(customer); + (await repository.FindAsync(customer.Id)).Should().BeNull(); } #endregion diff --git a/tests/Data.MongoDB.IntegrationTests/UnitOfWorkTests.cs b/tests/Data.MongoDB.IntegrationTests/UnitOfWorkTests.cs index acbba30..eb08d26 100644 --- a/tests/Data.MongoDB.IntegrationTests/UnitOfWorkTests.cs +++ b/tests/Data.MongoDB.IntegrationTests/UnitOfWorkTests.cs @@ -106,6 +106,135 @@ public async Task UnitOfWork_ShouldRollBackEveryOperation_WhenOneFails() (await collection.CountDocumentsAsync(FilterDefinition.Empty)).Should().Be(0); } + [Fact] + public async Task UnitOfWork_ShouldRollBackEveryOperation_WhenOneFails_OnTheSynchronousPath() + { + // Arrange + using TestScope scope = _fixture.CreateScope(); + MongoDBRepository repository = scope.Repository(); + Customer valid = Customer.One(); + Customer duplicate = Customer.One(); + + // Act + Action act = () => + { + using IUnitOfWork unitOfWork = scope.UnitOfWorkManager.Begin(); + + repository.Insert(valid); + repository.Insert(duplicate); + repository.Insert(duplicate); // duplicate key: fails at commit + + unitOfWork.Save(); + }; + + // Assert + // The synchronous commit runs the driver's synchronous APIs on the same session and + // transaction as the asynchronous one. + act.Should().Throw(); + + IMongoCollection collection = _fixture.GetCollection(); + (await collection.CountDocumentsAsync(FilterDefinition.Empty)).Should().Be(0); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UnitOfWork_ShouldDiscardItsOperations_WhenItEndsWithoutSave(bool async) + { + // Arrange + using TestScope scope = _fixture.CreateScope(); + MongoDBRepository repository = scope.Repository(); + Customer abandoned = Customer.One(); + Customer later = Customer.One(); + + // Act + if (async) + { + await using (IUnitOfWork unitOfWork = scope.UnitOfWorkManager.Begin()) + { + await repository.InsertAsync(abandoned); + } + + await repository.InsertAsync(later); + } + else + { + using (IUnitOfWork unitOfWork = scope.UnitOfWorkManager.Begin()) + { + repository.Insert(abandoned); + } + + repository.Insert(later); + } + + // Assert + // The unit of work was left without Save: a later write of the same scope must not commit + // the abandoned one along with it. + IMongoCollection collection = _fixture.GetCollection(); + List customers = await collection.Find(FilterDefinition.Empty).ToListAsync(); + customers.Should().ContainSingle().Which.Id.Should().Be(later.Id); + } + + [Fact] + public async Task UnitOfWork_ShouldRollBackAMultipleUpdate_WhenALaterOperationFails() + { + // Arrange + Customer existing = Customer.One(); + Customer duplicate = Customer.One(); + await _fixture.SeedAsync([existing, duplicate]); + using TestScope scope = _fixture.CreateScope(); + MongoDBRepository repository = scope.Repository(); + + // Act + Func act = async () => + { + await using IUnitOfWork unitOfWork = scope.UnitOfWorkManager.Begin(); + + existing.Name = "Modified"; + await repository.UpdateManyAsync([existing]); + await repository.InsertAsync(duplicate); // duplicate key: fails at commit + + await unitOfWork.SaveAsync(); + }; + + // Assert + await act.Should().ThrowAsync(); + + Customer? persisted = await _fixture.GetCollection() + .Find(x => x.Id == existing.Id).FirstOrDefaultAsync(); + persisted!.Name.Should().NotBe("Modified"); + } + + [Fact] + public async Task UnitOfWork_ShouldNotCommitAnOperation_WhoseTokenWasAlreadyCancelled() + { + // Arrange + using TestScope scope = _fixture.CreateScope(); + MongoDBRepository repository = scope.Repository(); + Customer kept = Customer.One(); + Customer cancelled = Customer.One(); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + // Act + await using (IUnitOfWork unitOfWork = scope.UnitOfWorkManager.Begin()) + { + await repository.InsertAsync(kept); + + Func act = () => repository.InsertAsync(cancelled, cancellation.Token); + await act.Should().ThrowAsync(); + + await unitOfWork.SaveAsync(); + } + + // Assert + // Inside a unit of work the operation's token never reaches the driver: it is checked + // when the operation is queued, so the cancelled insert is never committed. + IMongoCollection collection = _fixture.GetCollection(); + List customers = await collection.Find(FilterDefinition.Empty).ToListAsync(); + customers.Should().ContainSingle().Which.Id.Should().Be(kept.Id); + } + [Fact] public async Task UnitOfWork_ShouldNotReplayOperations_WhenSaveIsCalledTwice() { diff --git a/tests/Data.MongoDB.UnitTests/DataContextTests.cs b/tests/Data.MongoDB.UnitTests/DataContextTests.cs new file mode 100644 index 0000000..b98b056 --- /dev/null +++ b/tests/Data.MongoDB.UnitTests/DataContextTests.cs @@ -0,0 +1,351 @@ +using CodeArchitects.Platform.Data.MongoDB.Collections; +using CodeArchitects.Platform.Data.MongoDB.Filters; +using CodeArchitects.Platform.Data.MongoDB.Fixtures; +using CodeArchitects.Platform.Data.MongoDB.Model.Implementation; +using MongoDB.Driver; +using System.Data; + +namespace CodeArchitects.Platform.Data.MongoDB; + +public class DataContextTests +{ + private readonly Mock> _collection = new(); + private readonly ImmediateStateManager _stateManager = new(Mock.Of()); + private readonly DataContext _sut; + + public DataContextTests() + { + Mock collections = new(); + collections + .Setup(provider => provider.GetCollection()) + .Returns(_collection.Object); + + _sut = new DataContext( + new FilterProvider(), + collections.Object, + _stateManager, + new DataModelBuilder().AddEntity(typeof(DiscoverableEntity)).Build(), + Mock.Of()); + } + + private static Task Run(bool async, Action sync, Func asyncOperation) + { + if (async) + return asyncOperation(); + + sync(); + return Task.CompletedTask; + } + + private static DiscoverableEntity Entity() => new() { Id = Guid.NewGuid() }; + + #region Driver results + + private void SetupReplace(ReplaceOneResult result) + { + _collection + .Setup(collection => collection.ReplaceOne( + It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(result); + _collection + .Setup(collection => collection.ReplaceOneAsync( + It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(result); + } + + private void SetupBulkWrite(BulkWriteResult result) + { + _collection + .Setup(collection => collection.BulkWrite( + It.IsAny(), It.IsAny>>(), + It.IsAny(), It.IsAny())) + .Returns(result); + _collection + .Setup(collection => collection.BulkWriteAsync( + It.IsAny(), It.IsAny>>(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(result); + } + + private void SetupDelete(DeleteResult result) + { + _collection + .Setup(collection => collection.DeleteOne( + It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny())) + .Returns(result); + _collection + .Setup(collection => collection.DeleteOneAsync( + It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(result); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Update_ShouldSucceed_WhenTheDocumentMatchedButNothingChanged(bool async) + { + // Arrange + SetupReplace(new ReplaceOneResult.Acknowledged(matchedCount: 1, modifiedCount: 0, upsertedId: null)); + DiscoverableEntity entity = Entity(); + + // Act + Func act = () => Run(async, + () => _sut.Update(entity), + () => _sut.UpdateAsync(entity)); + + // Assert + await act.Should().NotThrowAsync(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Update_ShouldThrow_WhenNoDocumentMatched(bool async) + { + // Arrange + SetupReplace(new ReplaceOneResult.Acknowledged(matchedCount: 0, modifiedCount: 0, upsertedId: null)); + DiscoverableEntity entity = Entity(); + + // Act + Func act = () => Run(async, + () => _sut.Update(entity), + () => _sut.UpdateAsync(entity)); + + // Assert + await act.Should().ThrowAsync().WithMessage($"*{entity.Id}*"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Update_ShouldNotReportAConflict_WhenTheWriteIsUnacknowledged(bool async) + { + // Arrange + SetupReplace(ReplaceOneResult.Unacknowledged.Instance); + DiscoverableEntity entity = Entity(); + + // Act + Func act = () => Run(async, + () => _sut.Update(entity), + () => _sut.UpdateAsync(entity)); + + // Assert + // With w:0 the server reports no counts: a missing document cannot be told apart from a + // successful write, so the outcome must not be reported as a concurrency failure. + await act.Should().NotThrowAsync(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Upsert_ShouldNotReportAConflict_WhenTheWriteIsUnacknowledged(bool async) + { + // Arrange + SetupReplace(ReplaceOneResult.Unacknowledged.Instance); + DiscoverableEntity entity = Entity(); + + // Act + Func act = () => Run(async, + () => _sut.Upsert(entity), + () => _sut.UpsertAsync(entity)); + + // Assert + await act.Should().NotThrowAsync(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UpdateMany_ShouldNotReportAConflict_WhenTheWriteIsUnacknowledged(bool async) + { + // Arrange + DiscoverableEntity[] entities = [Entity(), Entity()]; + SetupBulkWrite(new BulkWriteResult.Unacknowledged( + entities.Length, + [.. entities.Select(entity => new InsertOneModel(entity))])); + + // Act + Func act = () => Run(async, + () => _sut.UpdateMany(entities), + () => _sut.UpdateManyAsync(entities)); + + // Assert + // Reading MatchedCount of an unacknowledged result throws InvalidOperationException. + await act.Should().NotThrowAsync(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Remove_ShouldNotReportAConflict_WhenTheWriteIsUnacknowledged(bool async) + { + // Arrange + SetupDelete(DeleteResult.Unacknowledged.Instance); + Guid key = Guid.NewGuid(); + + // Act + Func act = () => Run(async, + () => _sut.Remove(key), + () => _sut.RemoveAsync(key)); + + // Assert + await act.Should().NotThrowAsync(); + } + + #endregion + + #region Multiple operations + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task InsertMany_ShouldDoNothing_WhenTheSequenceIsEmpty(bool async) + { + // Act + await Run(async, + () => _sut.InsertMany([]), + () => _sut.InsertManyAsync([])); + + // Assert + // Nothing queued means no round-trip and no transaction: the driver would reject an empty + // batch, and a standalone server would reject the transaction. + _stateManager.QueuedCount.Should().Be(0); + _collection.VerifyNoOtherCalls(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UpdateMany_ShouldDoNothing_WhenTheSequenceIsEmpty(bool async) + { + // Act + await Run(async, + () => _sut.UpdateMany([]), + () => _sut.UpdateManyAsync([])); + + // Assert + _stateManager.QueuedCount.Should().Be(0); + _collection.VerifyNoOtherCalls(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task InsertMany_ShouldThrow_WhenAnElementIsNull(bool async) + { + // Arrange + DiscoverableEntity[] entities = [Entity(), null!]; + + // Act + Func act = () => Run(async, + () => _sut.InsertMany(entities), + () => _sut.InsertManyAsync(entities)); + + // Assert + await act.Should().ThrowAsync().WithMessage("*index 1*"); + _stateManager.QueuedCount.Should().Be(0); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UpdateMany_ShouldThrow_WhenAnElementIsNull(bool async) + { + // Arrange + DiscoverableEntity[] entities = [null!, Entity()]; + + // Act + Func act = () => Run(async, + () => _sut.UpdateMany(entities), + () => _sut.UpdateManyAsync(entities)); + + // Assert + await act.Should().ThrowAsync().WithMessage("*index 0*"); + _stateManager.QueuedCount.Should().Be(0); + } + + #endregion + + #region Cancellation + + [Fact] + public async Task InsertAsync_ShouldNotQueueTheOperation_WhenTheTokenIsAlreadyCancelled() + { + // Arrange + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + // Act + Func act = () => _sut.InsertAsync(Entity(), cancellation.Token); + + // Assert + // Inside a unit of work the token never reaches the driver: the operation would otherwise be + // committed later, although its caller cancelled it. + await act.Should().ThrowAsync(); + _stateManager.QueuedCount.Should().Be(0); + } + + [Fact] + public async Task UpdateAsync_ShouldPassTheTokenToTheDriver() + { + // Arrange + SetupReplace(new ReplaceOneResult.Acknowledged(matchedCount: 1, modifiedCount: 1, upsertedId: null)); + using CancellationTokenSource cancellation = new(); + + // Act + await _sut.UpdateAsync(Entity(), cancellation.Token); + + // Assert + _collection.Verify(collection => collection.ReplaceOneAsync( + It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny(), cancellation.Token)); + } + + #endregion + + /// + /// Runs every queued write as soon as it is saved, as the real state manager does outside a + /// unit of work, and counts what was queued. + /// + private sealed class ImmediateStateManager(IClientSessionHandle session) : IStateManager + { + private readonly List _pending = []; + + public IClientSessionHandle Session { get; } = session; + + public int QueuedCount { get; private set; } + + public void AddExecution(Execution execution, bool requiresTransaction) + { + QueuedCount++; + _pending.Add(execution); + } + + public void Save() + { + foreach (Execution execution in Drain()) + { + execution.Sync(Session); + } + } + + public async Task SaveAsync(CancellationToken cancellationToken) + { + foreach (Execution execution in Drain()) + { + await execution.Async(Session, cancellationToken); + } + } + + private Execution[] Drain() + { + Execution[] batch = [.. _pending]; + _pending.Clear(); + return batch; + } + } +} diff --git a/tests/Data.MongoDB.UnitTests/DependencyInjection/DataMongoDBServiceCollectionExtensionsTests.cs b/tests/Data.MongoDB.UnitTests/DependencyInjection/DataMongoDBServiceCollectionExtensionsTests.cs index aca18be..58ad300 100644 --- a/tests/Data.MongoDB.UnitTests/DependencyInjection/DataMongoDBServiceCollectionExtensionsTests.cs +++ b/tests/Data.MongoDB.UnitTests/DependencyInjection/DataMongoDBServiceCollectionExtensionsTests.cs @@ -4,6 +4,7 @@ using CodeArchitects.Platform.Data.MongoDB.Model; using CodeArchitects.Platform.Data.MongoDB.Transactions; using Microsoft.Extensions.DependencyInjection; +using MongoDB.Bson; using MongoDB.Driver; namespace CodeArchitects.Platform.Data.MongoDB.DependencyInjection; @@ -80,6 +81,47 @@ public void AddData_ShouldThrow_WhenNoEntityIsRegistered() act.Should().Throw().WithMessage("*AddEntitiesFrom*"); } + [Fact] + public void AddData_ShouldSucceed_WhenCalledAgainWithTheSameSerializationSettings() + { + // Arrange + using ServiceProvider first = BuildProvider(); + + // Act + Action act = () => BuildProvider().Dispose(); + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void AddData_ShouldThrow_WhenALaterCallAsksForADifferentGuidRepresentation() + { + // Arrange + // The serialization settings are process-wide: make sure they are already applied, with the + // default representation, whatever test ran first. + using ServiceProvider first = BuildProvider(); + + // Act + Action act = () => BuildProvider(builder => builder.UseGuidRepresentation(GuidRepresentation.CSharpLegacy)); + + // Assert + act.Should().Throw().WithMessage("*GuidRepresentation.CSharpLegacy*"); + } + + [Fact] + public void AddData_ShouldThrow_WhenALaterCallConfiguresConventions() + { + // Arrange + using ServiceProvider first = BuildProvider(); + + // Act + Action act = () => BuildProvider(builder => builder.ConfigureConventions(_ => { })); + + // Assert + act.Should().Throw().WithMessage("*ConfigureConventions*"); + } + [Fact] public void AddData_ShouldThrow_WhenTwoEntitiesShareTheSameCollection() { @@ -170,6 +212,28 @@ public void AddData_ShouldRegisterTheSingletonServices(Type serviceType) first.Should().NotBeNull().And.BeSameAs(second); } + [Fact] + public void AddData_ShouldLetARepositoryBeResolvedThroughTheCaepAbstraction() + { + // Arrange + using ServiceProvider provider = new ServiceCollection() + .AddData(options => options + .UseClient(_client.Object) + .UseDatabase("tests") + .AddEntity()) + .AddScoped, MongoDBRepository>() + .BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true }); + using IServiceScope scope = provider.CreateScope(); + + // Act + IRepository repository = + scope.ServiceProvider.GetRequiredService>(); + + // Assert + // The constructor depends on the MongoDB IDataContext: AddData alone must satisfy it. + repository.Should().BeOfType>(); + } + [Fact] public void AddData_ShouldScopeTheDataContextPerScope() { diff --git a/tests/Data.MongoDB.UnitTests/Fixtures/IncludeEntities.cs b/tests/Data.MongoDB.UnitTests/Fixtures/IncludeEntities.cs new file mode 100644 index 0000000..13aee00 --- /dev/null +++ b/tests/Data.MongoDB.UnitTests/Fixtures/IncludeEntities.cs @@ -0,0 +1,61 @@ +using MongoDB.Bson.Serialization.Attributes; + +namespace CodeArchitects.Platform.Data.MongoDB.Fixtures; + +// Internal on purpose: AddEntitiesFrom must not discover these types, they are registered +// explicitly by the include tests. + +/// +/// An aggregate root with every shape of navigation the include validation distinguishes. +/// +[CodeArchitects.Platform.Data.MongoDB.Collection("includeOrders")] +internal class IncludeOrder +{ + public Guid Id { get; set; } + + public string? Code { get; set; } + + /// Embedded, single. + public Shipping? Shipping { get; set; } + + /// Embedded, collection. + public List Lines { get; set; } = []; + + /// An aggregate root of another collection. + public IncludeBuyer? Buyer { get; set; } + + /// A collection of aggregate roots of another collection. + public List Watchers { get; set; } = []; + + /// Not persisted: an include would leave it empty. + [BsonIgnore] + public Shipping? Cached { get; set; } +} + +internal class Shipping +{ + public Address? Address { get; set; } + + public IncludeBuyer? Courier { get; set; } +} + +internal class Address +{ + public string? City { get; set; } +} + +internal class OrderLine +{ + public Discount? Discount { get; set; } +} + +internal class Discount +{ + public decimal Rate { get; set; } +} + +[CodeArchitects.Platform.Data.MongoDB.Collection("includeBuyers")] +internal class IncludeBuyer +{ + public Guid Id { get; set; } +} diff --git a/tests/Data.MongoDB.UnitTests/Navigation/EmbeddedIncluderTests.cs b/tests/Data.MongoDB.UnitTests/Navigation/EmbeddedIncluderTests.cs new file mode 100644 index 0000000..b143ecb --- /dev/null +++ b/tests/Data.MongoDB.UnitTests/Navigation/EmbeddedIncluderTests.cs @@ -0,0 +1,114 @@ +using CodeArchitects.Platform.Data.MongoDB.Fixtures; +using CodeArchitects.Platform.Data.MongoDB.Model; +using CodeArchitects.Platform.Data.MongoDB.Model.Implementation; +using CodeArchitects.Platform.Data.Navigation; + +namespace CodeArchitects.Platform.Data.MongoDB.Navigation; + +public class EmbeddedIncluderTests +{ + private static readonly IDataModel s_model = new DataModelBuilder() + .AddEntity(typeof(IncludeOrder)) + .AddEntity(typeof(IncludeBuyer)) + .Build(); + + private static Action Validate(IncludeAction include) => + () => include(new EmbeddedIncluder(s_model)); + + #region Accepted: embedded navigations + + [Fact] + public void Include_ShouldAcceptAnEmbeddedDocument() => + Validate(include => include.Include(order => order.Shipping)).Should().NotThrow(); + + [Fact] + public void Include_ShouldAcceptAnEmbeddedCollection() => + Validate(include => include.Include(order => order.Lines)).Should().NotThrow(); + + [Fact] + public void Include_ShouldAcceptAChainOfEmbeddedDocuments() => + Validate(include => include.Include(order => order.Shipping!.Address)).Should().NotThrow(); + + [Fact] + public void Include_ShouldAcceptAnAnonymousObjectOfEmbeddedNavigations() => + Validate(include => include.Include(order => new { order.Shipping, order.Lines })).Should().NotThrow(); + + [Fact] + public void Include_ShouldAcceptThenInclude_OnAnEmbeddedDocument() => + Validate(include => include.Include(order => order.Shipping, shipping => shipping.Include(s => s.Address))) + .Should().NotThrow(); + + [Fact] + public void Include_ShouldAcceptThenInclude_OnAnEmbeddedCollection() => + Validate(include => include.Include(order => order.Lines, lines => lines.Include(line => line.Discount))) + .Should().NotThrow(); + + [Theory] + [InlineData("Shipping")] + [InlineData("Shipping.Address")] + [InlineData("Lines.Discount")] + public void Include_ShouldAcceptAPathOfEmbeddedNavigations(string path) => + Validate(include => include.Include(path)).Should().NotThrow(); + + #endregion + + #region Rejected: not supported by the provider + + [Fact] + public void Include_ShouldReject_AReferenceToAnotherCollection() => + Validate(include => include.Include(order => order.Buyer)) + .Should().Throw().WithMessage("*IncludeOrder.Buyer*includeBuyers*"); + + [Fact] + public void Include_ShouldReject_ACollectionOfReferencesToAnotherCollection() => + Validate(include => include.Include(order => order.Watchers)) + .Should().Throw().WithMessage("*IncludeOrder.Watchers*"); + + [Fact] + public void Include_ShouldReject_AReferenceAtTheEndOfAChain() => + Validate(include => include.Include(order => order.Shipping!.Courier)) + .Should().Throw().WithMessage("*Shipping.Courier*"); + + [Fact] + public void Include_ShouldReject_AReferenceInsideAnAnonymousObject() => + Validate(include => include.Include(order => new { order.Lines, order.Buyer })) + .Should().Throw(); + + [Fact] + public void Include_ShouldReject_ThenIncludeReachingAnotherCollection() => + Validate(include => include.Include(order => order.Shipping, shipping => shipping.Include(s => s.Courier))) + .Should().Throw(); + + [Fact] + public void Include_ShouldReject_AReferenceInAPath() => + Validate(include => include.Include("Shipping.Courier")) + .Should().Throw(); + + [Fact] + public void Include_ShouldReject_AMemberThatIsNotPersisted() => + Validate(include => include.Include(order => order.Cached)) + .Should().Throw().WithMessage("*not persisted*"); + + #endregion + + #region Rejected: invalid include + + [Fact] + public void Include_ShouldReject_AFilteredInclude() => + Validate(include => include.Include(order => order.Lines.Where(line => line.Discount != null))) + .Should().Throw().WithMessage("*Filtered includes*"); + + [Fact] + public void Include_ShouldReject_AScalarValue() => + Validate(include => include.Include(order => order.Code)) + .Should().Throw().WithMessage("*scalar*"); + + [Theory] + [InlineData("Missing")] + [InlineData("Shipping.Missing")] + [InlineData("Shipping..Address")] + public void Include_ShouldReject_AnInvalidPath(string path) => + Validate(include => include.Include(path)).Should().Throw(); + + #endregion +} diff --git a/tests/Data.MongoDB.UnitTests/StateManagerTests.cs b/tests/Data.MongoDB.UnitTests/StateManagerTests.cs index 2eee09b..11676d2 100644 --- a/tests/Data.MongoDB.UnitTests/StateManagerTests.cs +++ b/tests/Data.MongoDB.UnitTests/StateManagerTests.cs @@ -140,6 +140,51 @@ public void Save_ShouldNotReplayOperations_AfterAFailedCommit() executions.Should().Be(0); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Save_ShouldNotCommitTheOperationsOfAUnitOfWork_ThatEndedWithoutSaving(bool async) + { + // Arrange + StateManager sut = CreateSut(); + List executed = new(); + Execution Named(string name) => + new(_ => executed.Add(name), (_, _) => { executed.Add(name); return Task.CompletedTask; }); + + IUnitOfWork unitOfWork = sut.Begin(); + sut.AddExecution(Named("abandoned"), requiresTransaction: false); + + // Act + if (async) + await unitOfWork.DisposeAsync(); + else + unitOfWork.Dispose(); + + sut.AddExecution(Named("later"), requiresTransaction: false); + sut.Save(); + + // Assert + // Leaving the unit of work without Save is its rollback: the queued write must be dropped, + // not committed by the next Save of the scope. + executed.Should().Equal("later"); + } + + [Fact] + public void Save_ShouldCommitTheOperationsOfAUnitOfWork_WhenItEndsWithAutoSave() + { + // Arrange + StateManager sut = CreateSut(); + int executions = 0; + IUnitOfWork unitOfWork = sut.Begin(autoSave: true); + sut.AddExecution(new Execution(_ => executions++, (_, _) => { executions++; return Task.CompletedTask; }), false); + + // Act + unitOfWork.Dispose(); + + // Assert + executions.Should().Be(1); + } + [Fact] public void Dispose_ShouldReleaseTheSession() {