diff --git a/CodeArchitects.Platform.sln b/CodeArchitects.Platform.sln index 83d970a..2582b58 100644 --- a/CodeArchitects.Platform.sln +++ b/CodeArchitects.Platform.sln @@ -137,6 +137,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodeArchitects.Platform.Dat EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodeArchitects.Platform.Data.MongoDB.DependencyInjection", "src\Data.MongoDB.DependencyInjection\CodeArchitects.Platform.Data.MongoDB.DependencyInjection.csproj", "{3741E062-BECB-4B36-ACF4-175903413842}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodeArchitects.Platform.Data.MongoDB.Mapster", "src\Data.MongoDB.Mapster\CodeArchitects.Platform.Data.MongoDB.Mapster.csproj", "{8A262F9C-C5C6-4A9D-80FF-C0D25C888365}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodeArchitects.Platform.Data.AdoNet.MySQL", "src\Data.AdoNet.MySQL\CodeArchitects.Platform.Data.AdoNet.MySQL.csproj", "{41B066D6-77B1-44C2-8C7C-10AF9D782001}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodeArchitects.Platform.GraphQL", "src\GraphQL\CodeArchitects.Platform.GraphQL.csproj", "{DBA19CAA-D817-4684-94D4-A447EB52A5B5}" @@ -409,6 +411,10 @@ Global {3741E062-BECB-4B36-ACF4-175903413842}.Debug|Any CPU.Build.0 = Debug|Any CPU {3741E062-BECB-4B36-ACF4-175903413842}.Release|Any CPU.ActiveCfg = Release|Any CPU {3741E062-BECB-4B36-ACF4-175903413842}.Release|Any CPU.Build.0 = Release|Any CPU + {8A262F9C-C5C6-4A9D-80FF-C0D25C888365}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8A262F9C-C5C6-4A9D-80FF-C0D25C888365}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8A262F9C-C5C6-4A9D-80FF-C0D25C888365}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8A262F9C-C5C6-4A9D-80FF-C0D25C888365}.Release|Any CPU.Build.0 = Release|Any CPU {41B066D6-77B1-44C2-8C7C-10AF9D782001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {41B066D6-77B1-44C2-8C7C-10AF9D782001}.Debug|Any CPU.Build.0 = Debug|Any CPU {41B066D6-77B1-44C2-8C7C-10AF9D782001}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -523,6 +529,7 @@ Global {7D421D89-D25D-41BE-8194-725467D6F8B8} = {2221E47A-0474-48FC-8840-E8E6CDAED427} {0A3D00CF-DA61-427C-9C49-EB57E8906731} = {AE6B4AEB-0C5A-404B-A5BE-6BAED09F6A3C} {3741E062-BECB-4B36-ACF4-175903413842} = {9B8AE08D-7A27-45A1-8E11-74E23BC55E2E} + {8A262F9C-C5C6-4A9D-80FF-C0D25C888365} = {9B8AE08D-7A27-45A1-8E11-74E23BC55E2E} {41B066D6-77B1-44C2-8C7C-10AF9D782001} = {9B8AE08D-7A27-45A1-8E11-74E23BC55E2E} {FE36CC98-A656-4C5D-BF2D-ABAF02121535} = {646C726C-D374-4213-B169-EB07E0130DEF} {0F1AF08A-F1DB-4A7B-9C00-32CFF4DC3996} = {2221E47A-0474-48FC-8840-E8E6CDAED427} diff --git a/docs/mongodb.md b/docs/mongodb.md index 067b240..2bed951 100644 --- a/docs/mongodb.md +++ b/docs/mongodb.md @@ -277,35 +277,202 @@ builder.Services.AddScoped, MongoDBRepository`. -`[Collection]` belongs on the **document**, never on the domain entity. +different names, value objects, or schema versioning, use a mapped repository. `[Collection]` belongs +on the **document**, never on the domain entity: only the document is registered in the model, and +`Collection` / `Documents` are typed `IMongoCollection`. + +| Base class | Package | Mapping | +|---|---|---| +| `MongoDBMappedRepository` | `CodeArchitects.Platform.Data.MongoDB` | written by hand, overriding `TableToEntity` and `EntityToTable` | +| `MongoDBMapsterRepository` | `CodeArchitects.Platform.Data.MongoDB.Mapster` | Mapster, with rules registered through `AddDataMapster` | + +The direct repository and the hand-written mapped repository do not depend on Mapster: only the +`.Mapster` package brings it in. + +#### With Mapster + +```bash +dotnet add package CodeArchitects.Platform.Data.MongoDB.Mapster +``` ```csharp -[Collection("products")] -public class ProductDocument +builder.Services + .AddData(cfg => cfg + .UseConnectionString(connectionString) + .UseDatabase("store") + .AddEntitiesFrom(typeof(PurchaseOrderDocument).Assembly)) + .AddDataMapster(typeof(PurchaseOrderMapping).Assembly) // or AddDataMapster(config => ...) + // a plain repository... + .AddScoped, MongoDBMapsterRepository>() + // ...or a specialized one, derived from MongoDBMapsterRepository + .AddScoped(); +``` + +`AddDataMapster` builds a dedicated `TypeAdapterConfig`, never `TypeAdapterConfig.GlobalSettings`, +and registers a scoped `IDataMapper` on it, leaving the application's own `IMapper` and `TypeAdapterConfig` +alone. It is called once, with every rule of the data layer. The configuration is **strict**, and every +rule is **compiled at registration**, +so a mapping error makes startup fail with an explicit `InvalidOperationException` instead of surfacing +on the first request: + +- every pair of types needs a rule, nested types included (`RequireExplicitMapping`); +- every destination member needs a source or an explicit `Ignore` (`RequireDestinationMemberSource`). + This also applies to the rules reversed by `TwoWays()`, where Mapster would otherwise skip the check. + Only the members configured **after** `TwoWays()` are reversed; +- the repository constructor checks that both directions, `TEntity → TDocument` and + `TDocument → TEntity`, have a rule. + +Both settings can be relaxed inside `AddDataMapster(config => ...)`. The repository then compiles the +implicit mapping of its two types when it is created, so an invalid one still fails early. + +#### Example + +The domain aggregate uses value objects, an embedded collection and a reference to another aggregate. +The document renames and flattens fields and adds a technical one. + +```csharp +public sealed record PurchaseOrderNumber(string Value); +public sealed record Money(decimal Amount, string Currency); +public sealed record Address(string Street, string City, string PostalCode, string Country); +public sealed record OrderLine(Guid ProductId, int Quantity, Money UnitPrice); + +public class PurchaseOrder // domain: no [Collection] { public Guid Id { get; set; } - public string? Code { get; set; } - public decimal Amount { get; set; } + public PurchaseOrderNumber Number { get; set; } + public Money Total { get; set; } + public Address ShippingAddress { get; set; } + public List Lines { get; set; } = []; + public Guid CustomerId { get; set; } // reference to another aggregate: its key only } -public class ProductRepository : MongoDBMappedRepository, IProductRepository +[Collection("purchase_orders")] +public class PurchaseOrderDocument // persistence { - public ProductRepository(IDataContext context) : base(context) { } + public Guid Id { get; set; } + public string Code { get; set; } // PurchaseOrderNumber + public decimal TotalAmount { get; set; } // Money, flattened + public string TotalCurrency { get; set; } + public AddressDocument Shipping { get; set; } // embedded sub-document + public List Lines { get; set; } = []; + public Guid CustomerId { get; set; } + public int SchemaVersion { get; set; } // technical: written, never read into the domain +} +``` - protected override Product TableToEntity(ProductDocument document) => /* ... */; - protected override ProductDocument EntityToTable(Product entity) => /* ... */; +```csharp +public sealed class PurchaseOrderMapping : IRegister +{ + public void Register(TypeAdapterConfig config) + { + // single-value value object <-> scalar + config.NewConfig().MapWith(number => number.Value); + config.NewConfig().MapWith(value => new PurchaseOrderNumber(value)); + + // immutable value object: shared as it is instead of copied + config.NewConfig().MapWith(money => money); + + config.NewConfig() + .Map(document => document.Code, order => order.Number) + .Map(document => document.TotalAmount, order => order.Total.Amount) + .Map(document => document.TotalCurrency, order => order.Total.Currency) + .Map(document => document.Shipping, order => order.ShippingAddress) + .Map(document => document.SchemaVersion, _ => 2); + + config.NewConfig() + .Map(order => order.Number, document => document.Code) + .Map(order => order.Total, document => new Money(document.TotalAmount, document.TotalCurrency)) + .Map(order => order.ShippingAddress, document => document.Shipping); + + // one rule per nested pair, in both directions + config.NewConfig() + .Map(document => document.Line1, address => address.Street) + .Map(document => document.Zip, address => address.PostalCode) + .Map(document => document.CountryCode, address => address.Country); + config.NewConfig() + .MapWith(document => new Address(document.Line1, document.City, document.Zip, document.CountryCode)); + // ... OrderLine <-> OrderLineDocument likewise + + // same shape except for one member: one rule, reversed by TwoWays + config.NewConfig() + .TwoWays() + .Map(document => document.Subject, ticket => ticket.Title); + } +} +``` + +The complete example, with its tests against a real server, is in +`tests/Data.MongoDB.IntegrationTests` (`Fixtures/PurchaseOrders.cs` and `MapsterRepositoryTests.cs`). + +#### Behavior + +**Operations.** Every operation maps between the two models and then runs the same `IDataContext` +operation as the direct repository, on the document. Transactions, deferral inside a unit of work, +`DBConcurrencyException`, empty batches, and cancellation follow [Operation semantics](#operation-semantics) +and [Unit of work and transactions](#unit-of-work-and-transactions) unchanged. + +**Checked mapping.** A `null` entity throws `ArgumentNullException`, a `null` element of a batch +throws `ArgumentException` with its index, and a mapping that returns `null` throws +`InvalidOperationException`. `InsertMany` and `UpdateMany` map the whole batch **before** writing or +queuing anything: an element that fails to map is named by its index, and nothing of the batch reaches +the database or the unit of work. + +**Keys.** The document key follows the usual [rules](#key). On the domain entity the repository looks +for `Id`, then `Id`, of the repository key type: + +- on every write, if the entity has a key and the mapped document has a different one, + `InvalidOperationException` is thrown: the mapping lost or changed the identity; +- on `Insert` and `InsertMany`, an empty document key is generated as the driver would (for example an + `ObjectId`, also for a `string` key marked `[BsonRepresentation(BsonType.ObjectId)]`) and written back + to the entity, even through a private setter. The entity has its identity as soon as the operation is + issued, also inside a unit of work where the write is deferred to the commit. + +If the domain entity has no key the convention can find, these checks are skipped. + +**Aggregates, embedded documents, and references.** An aggregate maps to one document: its parts map +to embedded sub-documents and arrays, with one rule per nested pair. A reference to another aggregate +is persisted as its key. If the domain holds the other aggregate as an object, map its key on the way +out and `Ignore` the object on the way back, then load it from its own repository: strict mode forces +that choice to be written down. + +**Queries and filters.** Custom queries are written on the **document**, with `Documents`, `Session` +and `Builders`, and their results are mapped with `ToEntities` / `ToEntitiesAsync`: + +```csharp +public class PurchaseOrderRepository(IDataContext context, IDataMapper mapper) + : MongoDBMapsterRepository(context, mapper), IPurchaseOrderRepository +{ + public Task> FindByCustomerAsync(Guid customerId, CancellationToken ct = default) => + ToEntitiesAsync( + Documents.Find(Session, document => document.CustomerId == customerId).SortBy(document => document.Code), + ct); } ``` -The base class exposes `Collection` and its `Documents` alias, both of type -`IMongoCollection`. +A predicate on the domain entity cannot be translated to the document: Mapster maps objects, not +expressions, and a renamed or flattened member has no field to compare with. The repository therefore +offers no API that accepts one. An untranslatable query fails to compile instead of returning wrong +results at runtime. `ToEntity`, `ToDocument` and `ToEntities(IEnumerable)` are also +available to derived repositories. -Mapping can be delegated to Mapster with a bidirectional `TypeAdapterConfig`, as described in -[DAL – Mapped repository](dataaccesslayer.md#mapped-repository): +**Include.** `Find(key, include)` throws `NotSupportedException`, as in the mapped repositories of +the other providers. The include is written on the domain entity, which is not part of the model, so +it cannot be validated against the document. It is not needed either: the document is loaded whole, so +`Find(key)` already returns the embedded parts of the aggregate. + +#### Hand-written mapping + +Without Mapster, derive from `MongoDBMappedRepository` and write the two mappings. Every behavior above +applies in the same way. ```csharp -config.NewConfig().TwoWays(); +public class ProductRepository : MongoDBMappedRepository, IProductRepository +{ + public ProductRepository(IDataContext context) : base(context) { } + + protected override Product TableToEntity(ProductDocument document) => /* ... */; + protected override ProductDocument EntityToTable(Product entity) => /* ... */; +} ``` > The `PreserveTracking` extension from `CodeArchitects.Platform.Data.Mapster` has **no effect** on @@ -370,5 +537,6 @@ Not yet supported, in order of impact: - [`CodeArchitects.Platform.Data.MongoDB`](https://www.nuget.org/packages/CodeArchitects.Platform.Data.MongoDB) - [`CodeArchitects.Platform.Data.MongoDB.DependencyInjection`](https://www.nuget.org/packages/CodeArchitects.Platform.Data.MongoDB.DependencyInjection) +- [`CodeArchitects.Platform.Data.MongoDB.Mapster`](https://www.nuget.org/packages/CodeArchitects.Platform.Data.MongoDB.Mapster), optional: mapped repository based on Mapster They require **MongoDB Server 4.4 or later** (a requirement of driver 3.x). diff --git a/src/Common/Properties/AssemblyInfo.cs b/src/Common/Properties/AssemblyInfo.cs index 09dac1f..0cdc832 100644 --- a/src/Common/Properties/AssemblyInfo.cs +++ b/src/Common/Properties/AssemblyInfo.cs @@ -17,6 +17,7 @@ [assembly: InternalsVisibleTo("CodeArchitects.Platform.Data.EntityFrameworkCore.Shared")] [assembly: InternalsVisibleTo("CodeArchitects.Platform.Data.MongoDB")] [assembly: InternalsVisibleTo("CodeArchitects.Platform.Data.MongoDB.DependencyInjection")] +[assembly: InternalsVisibleTo("CodeArchitects.Platform.Data.MongoDB.Mapster")] [assembly: InternalsVisibleTo("CodeArchitects.Platform.Emit")] [assembly: InternalsVisibleTo("CodeArchitects.Platform.GraphQL")] [assembly: InternalsVisibleTo("CodeArchitects.Platform.GraphQL.ChilliCream")] diff --git a/src/Data.Mapster/CodeArchitects.Platform.Data.Mapster.csproj b/src/Data.Mapster/CodeArchitects.Platform.Data.Mapster.csproj index ffb507b..72264f5 100644 --- a/src/Data.Mapster/CodeArchitects.Platform.Data.Mapster.csproj +++ b/src/Data.Mapster/CodeArchitects.Platform.Data.Mapster.csproj @@ -10,16 +10,12 @@ - - + + - - - - - + diff --git a/src/Data.Mapster/DataMapper.cs b/src/Data.Mapster/DataMapper.cs new file mode 100644 index 0000000..274f6fa --- /dev/null +++ b/src/Data.Mapster/DataMapper.cs @@ -0,0 +1,22 @@ +using Mapster; +using MapsterMapper; + +namespace CodeArchitects.Platform.Data.Mapster; + +/// +/// A on the data layer configuration: it exposes the service provider to the +/// mapping, which PreserveTracking relies on. +/// +internal sealed class DataMapper(IServiceProvider serviceProvider, TypeAdapterConfig config) + : ServiceMapper(serviceProvider, config), IDataMapper +{ +} + +/// +/// Holds the data layer configuration without registering itself, which +/// belongs to the application. +/// +internal sealed class DataMapsterConfiguration(TypeAdapterConfig config) +{ + public TypeAdapterConfig Config { get; } = config; +} diff --git a/src/Data.Mapster/DataMapsterServiceCollectionExtensions.cs b/src/Data.Mapster/DataMapsterServiceCollectionExtensions.cs new file mode 100644 index 0000000..f35c8c3 --- /dev/null +++ b/src/Data.Mapster/DataMapsterServiceCollectionExtensions.cs @@ -0,0 +1,114 @@ +using CodeArchitects.Platform.Data.Mapster; +using Mapster; +using Mapster.Models; +using System.Reflection; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Methods for adding the Mapster mapping used by the mapped repositories to the application services. +/// +public static class DataMapsterServiceCollectionExtensions +{ + /// + /// Registers a dedicated, strict and eagerly validated Mapster configuration for the data layer, + /// together with the that uses it. + /// + /// The service collection. + /// Adds the mapping rules to the configuration. + /// The same instance. + /// + /// The mapping configuration is invalid. + public static IServiceCollection AddDataMapster(this IServiceCollection services, Action configure) + { + if (services is null) + throw new ArgumentNullException(nameof(services)); + if (configure is null) + throw new ArgumentNullException(nameof(configure)); + + TypeAdapterConfig config = new() + { + RequireExplicitMapping = true, + RequireDestinationMemberSource = true + }; + + if (services.Any(descriptor => descriptor.ServiceType == typeof(DataMapsterConfiguration))) + throw new InvalidOperationException( + "AddDataMapster was already called: the data layer has a single mapping configuration. Register every rule in one call, passing all the assemblies to scan or a single configure action."); + + configure(config); + CheckReversedRules(config); + Compile(config); + + services.AddSingleton(new DataMapsterConfiguration(config)); + services.AddScoped(sp => new DataMapper(sp, sp.GetRequiredService().Config)); + + return services; + } + + /// + /// Registers a dedicated, strict and eagerly validated Mapster configuration for the data layer, with the + /// rules of every found in . + /// + /// + /// See . + /// + /// The service collection. + /// The assemblies to scan for implementations. + /// The same instance. + /// + /// is empty. + /// The mapping configuration is invalid. + public static IServiceCollection AddDataMapster(this IServiceCollection services, params Assembly[] assemblies) + { + if (assemblies is null) + throw new ArgumentNullException(nameof(assemblies)); + if (assemblies.Length == 0) + throw new ArgumentException("At least one assembly to scan for IRegister implementations is required.", nameof(assemblies)); + + return services.AddDataMapster(config => config.Scan(assemblies)); + } + + private static void CheckReversedRules(TypeAdapterConfig config) + { + if (!config.RequireDestinationMemberSource) + return; + + foreach (KeyValuePair rule in config.RuleMap) + { + TypeAdapterSettings settings = rule.Value.Settings; + bool reversed = settings.SkipDestinationMemberCheck == true + && settings.Unflattening == true + && config.RuleMap.ContainsKey(new TypeTuple(rule.Key.Destination, rule.Key.Source)); + + if (reversed) + { + settings.SkipDestinationMemberCheck = false; + } + } + } + + private static void Compile(TypeAdapterConfig config) + { + try + { + config.Compile(); + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"The Mapster data mapping configuration is invalid: {Describe(exception)}", exception); + } + } + + private static string Describe(Exception exception) + { + List messages = []; + for (Exception? current = exception; current is not null; current = current.InnerException) + { + messages.Add(current.Message); + } + + return string.Join(" ", messages); + } +} diff --git a/src/Data.Mapster/IDataMapper.cs b/src/Data.Mapster/IDataMapper.cs new file mode 100644 index 0000000..33db81b --- /dev/null +++ b/src/Data.Mapster/IDataMapper.cs @@ -0,0 +1,14 @@ +using MapsterMapper; + +namespace CodeArchitects.Platform.Data.Mapster; + +/// +/// The mapper of the data layer, registered by AddDataMapster. +/// +/// +/// A dedicated service rather than : the application can keep its own , +/// for example the one registered by Mapster's AddMapster, without the two configurations replacing each other. +/// +public interface IDataMapper : IMapper +{ +} diff --git a/src/Data.Mapster/README.md b/src/Data.Mapster/README.md index 5f00f44..26d89f9 100644 --- a/src/Data.Mapster/README.md +++ b/src/Data.Mapster/README.md @@ -15,12 +15,24 @@ dotnet add package CodeArchitects.Platform.Data.Mapster ## Features +- `AddDataMapster` registers a dedicated configuration and a scoped `IDataMapper` for the data layer, separate from the + application's own `IMapper` and `TypeAdapterConfig`. It can be called once. + The configuration is strict: every pair of types needs a rule, and every destination member needs a + source, also in the rules reversed by `TwoWays()`. Every rule is compiled at registration, so a mapping + error fails startup with an explicit `InvalidOperationException`. + + ```csharp + builder.Services.AddDataMapster(typeof(ProductMapping).Assembly); // scans IRegister implementations + builder.Services.AddDataMapster(config => config.NewConfig().TwoWays()); + ``` + - `MapsterTracking` and `TrackingMappingExpressionExtensions` — change-tracking-aware mappings so that mapping table records to entities plays well with the ORM's tracking. ## Related packages - [`CodeArchitects.Platform.Data`](../Data) — DAL abstractions +- [`CodeArchitects.Platform.Data.MongoDB.Mapster`](../Data.MongoDB.Mapster) — MongoDB mapped repository based on Mapster - [`CodeArchitects.Platform.Data.AutoMapper`](../Data.AutoMapper) — legacy AutoMapper integration (maintenance only) ## License diff --git a/src/Data.MongoDB.Mapster/CodeArchitects.Platform.Data.MongoDB.Mapster.csproj b/src/Data.MongoDB.Mapster/CodeArchitects.Platform.Data.MongoDB.Mapster.csproj new file mode 100644 index 0000000..8e7f9d2 --- /dev/null +++ b/src/Data.MongoDB.Mapster/CodeArchitects.Platform.Data.MongoDB.Mapster.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.1;net7.0;net8.0;net9.0;net10.0 + latest + enable + enable + Nullable + True + + + + + + + + diff --git a/src/Data.MongoDB.Mapster/MongoDBMapsterRepository.cs b/src/Data.MongoDB.Mapster/MongoDBMapsterRepository.cs new file mode 100644 index 0000000..5182911 --- /dev/null +++ b/src/Data.MongoDB.Mapster/MongoDBMapsterRepository.cs @@ -0,0 +1,88 @@ +using CodeArchitects.Platform.Common.CodeAnalysis; +using CodeArchitects.Platform.Data.Mapster; +using Mapster; +using Mapster.Models; +using MapsterMapper; +using System.Runtime.CompilerServices; + +namespace CodeArchitects.Platform.Data.MongoDB.Mapster; + +/// +/// A that maps between the domain entity and +/// the document with Mapster. +/// +/// The document type stored in the collection. +/// The domain entity type. +/// The entity key type. +[Experimental] +public class MongoDBMapsterRepository : MongoDBMappedRepository + where TDocument : class + where TEntity : class + where TKey : IEquatable +{ + /// + /// Initializes a new instance of the class. + /// + /// The MongoDB data context used by the repository. + /// The data layer mapper, whose configuration holds the rules for both directions. + /// + /// The configuration has no rule for one of the two directions. + public MongoDBMapsterRepository(IDataContext context, IDataMapper mapper) + : base(context ?? throw new ArgumentNullException(nameof(context))) + { + Mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + + // The repository is created per scope: the configuration is checked once, not on every request. + _ = s_validated.GetValue(mapper.Config, Validate); + } + + /// + /// The configurations already checked for this pair of types. Weak, so that a discarded configuration is not kept alive. + /// + private static readonly ConditionalWeakTable s_validated = new(); + + /// + /// The mapper used by the repository. + /// + protected IMapper Mapper { get; } + + private static object Validate(TypeAdapterConfig config) + { + EnsureRule(config); + EnsureRule(config); + + return config; + } + + /// + protected override TEntity TableToEntity(TDocument document) => Mapper.Map(document); + + /// + protected override TDocument EntityToTable(TEntity entity) => Mapper.Map(entity); + + private static void EnsureRule(TypeAdapterConfig config) + { + if (config.RuleMap.ContainsKey(new TypeTuple(typeof(TSource), typeof(TDestination)))) + return; + + if (!config.RequireExplicitMapping) + { + try + { + _ = config.GetMapFunction(); + return; + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"The implicit Mapster mapping from '{typeof(TSource).Name}' to '{typeof(TDestination).Name}' is invalid: " + + $"{exception.Message} Register an explicit rule in AddDataMapster.", exception); + } + } + + throw new InvalidOperationException( + $"No Mapster mapping from '{typeof(TSource).Name}' to '{typeof(TDestination).Name}' is configured, so the " + + $"repository of '{typeof(TEntity).Name}' cannot map it. Register both directions in AddDataMapster, with " + + $"config.NewConfig<{typeof(TEntity).Name}, {typeof(TDocument).Name}>().TwoWays() or with one rule per direction."); + } +} diff --git a/src/Data.MongoDB.Mapster/README.md b/src/Data.MongoDB.Mapster/README.md new file mode 100644 index 0000000..2d29c90 --- /dev/null +++ b/src/Data.MongoDB.Mapster/README.md @@ -0,0 +1,68 @@ +# CodeArchitects.Platform.Data.MongoDB.Mapster + +[![NuGet](https://img.shields.io/nuget/v/CodeArchitects.Platform.Data.MongoDB.Mapster.svg)](https://www.nuget.org/packages/CodeArchitects.Platform.Data.MongoDB.Mapster) +[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](../../LICENSE) + +A MongoDB mapped repository that maps between the domain entity and the persisted document with +[Mapster](https://github.com/MapsterMapper/Mapster), for the [`CodeArchitects.Platform.Data.MongoDB`](../Data.MongoDB) +Data Access Layer. Use it when the document must differ from the domain model: renamed or flattened +fields, value objects, technical fields, schema versioning. + +## Installation + +```bash +dotnet add package CodeArchitects.Platform.Data.MongoDB.Mapster +``` + +> The package is marked `[Experimental]`, like the MongoDB provider it builds on. + +## Setup + +```csharp +builder.Services + .AddData(cfg => cfg + .UseConnectionString(connectionString) + .UseDatabase("store") + .AddEntitiesFrom(typeof(ProductDocument).Assembly)) // [Collection] is on the document + .AddDataMapster(typeof(ProductMapping).Assembly) // IRegister implementations + .AddScoped, MongoDBMapsterRepository>(); +``` + +```csharp +public sealed class ProductMapping : IRegister +{ + public void Register(TypeAdapterConfig config) => + config.NewConfig() + .TwoWays() + .Map(document => document.Code, product => product.Sku); +} +``` + +`AddDataMapster` (from [`CodeArchitects.Platform.Data.Mapster`](../Data.Mapster)) validates the whole +configuration at startup, in strict mode. `MongoDBMapsterRepository` also checks, when it is created, that +both directions of its pair have a rule. + +## Custom queries + +Derive from `MongoDBMapsterRepository` and write the query on the document, passing `Session`: + +```csharp +public class ProductRepository(IDataContext context, IDataMapper mapper) + : MongoDBMapsterRepository(context, mapper), IProductRepository +{ + public Task> FindByCodeAsync(string code, CancellationToken ct = default) => + ToEntitiesAsync(Documents.Find(Session, document => document.Code == code), ct); +} +``` + +Transactions, concurrency checks and exceptions are the same as the direct `MongoDBRepository`. See +[the documentation](../../docs/mongodb.md#mapped-repository) for keys, aggregates, filters and `Include`. + +## Related packages + +- [`CodeArchitects.Platform.Data.MongoDB`](../Data.MongoDB) — the MongoDB provider, without Mapster +- [`CodeArchitects.Platform.Data.Mapster`](../Data.Mapster) — `AddDataMapster` and the Mapster integration + +## License + +Licensed under the [Apache License 2.0](../../LICENSE). diff --git a/src/Data.MongoDB/DataContext.cs b/src/Data.MongoDB/DataContext.cs index c781749..05014b7 100644 --- a/src/Data.MongoDB/DataContext.cs +++ b/src/Data.MongoDB/DataContext.cs @@ -9,7 +9,7 @@ namespace CodeArchitects.Platform.Data.MongoDB; -internal class DataContext : IDataContext +internal class DataContext : IDataContext, IEntityModelResolver { private readonly IStateManager _stateManager; private readonly IFilterProvider _filters; @@ -342,6 +342,8 @@ public Task ExecuteAsync( cancellationToken); } + IEntityModel IEntityModelResolver.GetEntityModel() => EnsureEntity(); + private IEntityModel EnsureEntity() { if (!_model.TryGetEntity(typeof(TEntity), out IEntityModel? entityModel)) diff --git a/src/Data.MongoDB/Filters/FilterProvider.cs b/src/Data.MongoDB/Filters/FilterProvider.cs index 6ca722c..bc9f39b 100644 --- a/src/Data.MongoDB/Filters/FilterProvider.cs +++ b/src/Data.MongoDB/Filters/FilterProvider.cs @@ -1,5 +1,6 @@ using CodeArchitects.Platform.Data.MongoDB.Model; using CodeArchitects.Platform.Data.MongoDB.Model.Implementation; +using MongoDB.Bson.Serialization; using MongoDB.Driver; namespace CodeArchitects.Platform.Data.MongoDB.Filters; @@ -17,7 +18,30 @@ public FilterDefinition ById(IEntityModel model, TKey ke if (model is null) throw new ArgumentNullException(nameof(model)); - return Builders.Filter.Eq(model.Key.ElementName, key); + return Builders.Filter.Eq( + new StringFieldDefinition(model.Key.ElementName, KeySerializer.Get(model)), key); + } + + private static class KeySerializer + { + private static volatile Holder? s_holder; + + public static IBsonSerializer? Get(IEntityModel model) => (s_holder ??= new Holder(Resolve(model))).Serializer; + + private static IBsonSerializer? Resolve(IEntityModel model) + { + if (BsonSerializer.LookupSerializer() is not IBsonDocumentSerializer serializer || + !serializer.TryGetMemberSerializationInfo(model.Key.Name, out BsonSerializationInfo info) || + info.ElementName != model.Key.ElementName) + return null; + + return info.Serializer as IBsonSerializer; + } + + private sealed class Holder(IBsonSerializer? serializer) + { + public IBsonSerializer? Serializer { get; } = serializer; + } } public FilterDefinition ByEntity(IEntityModel model, TEntity entity) diff --git a/src/Data.MongoDB/Model/IEntityModelResolver.cs b/src/Data.MongoDB/Model/IEntityModelResolver.cs new file mode 100644 index 0000000..d90378d --- /dev/null +++ b/src/Data.MongoDB/Model/IEntityModelResolver.cs @@ -0,0 +1,13 @@ +namespace CodeArchitects.Platform.Data.MongoDB.Model; + +/// +/// Gives access to the entity models registered with the data context. +/// +internal interface IEntityModelResolver +{ + /// + /// Returns the registered model of . + /// + /// is not registered. + IEntityModel GetEntityModel(); +} diff --git a/src/Data.MongoDB/Model/Implementation/MappedKey.cs b/src/Data.MongoDB/Model/Implementation/MappedKey.cs new file mode 100644 index 0000000..1b3fda2 --- /dev/null +++ b/src/Data.MongoDB/Model/Implementation/MappedKey.cs @@ -0,0 +1,124 @@ +using MongoDB.Bson.Serialization; +using System.Linq.Expressions; +using System.Reflection; + +namespace CodeArchitects.Platform.Data.MongoDB.Model.Implementation; + +/// +/// Keeps the key of a domain entity and the key of the document it is mapped to in step. +/// +internal static class MappedKey + where TDocument : class + where TEntity : class + where TKey : IEquatable +{ + private static readonly Lazy s_entityKey = new(ResolveEntityKey); + + /// + /// Throws when the mapping produced a document whose key differs from the entity key. + /// + public static void EnsurePreserved(IEntityModel documentModel, TEntity entity, TDocument document, string? position) + { + if (s_entityKey.Value is not EntityKey entityKey || !TryGetKey(entityKey, entity, out TKey key)) + return; + + TKey documentKey = GetDocumentKey(documentModel, document); + if (key.Equals(documentKey)) + return; + + throw new InvalidOperationException( + $"The mapping from '{typeof(TEntity).Name}' to '{typeof(TDocument).Name}'{position} does not preserve the key: " + + $"the entity has '{key}', the document has '{documentKey}'. Map '{typeof(TEntity).Name}.{entityKey.Property.Name}' " + + $"to '{typeof(TDocument).Name}.{documentModel.Key.Name}'."); + } + + /// + /// Before an insert: generates the document key when it is empty, as the driver would. + /// + public static void GenerateKey(IEntityModel documentModel, TDocument document) + { + BsonMemberMap? idMemberMap = BsonClassMap.LookupClassMap(typeof(TDocument)).IdMemberMap; + + // The driver only generates the key of the member it maps to _id: when the provider resolved + // a different member (the Id convention), there is nothing the driver would do. + if (idMemberMap?.IdGenerator is null || idMemberMap.MemberName != documentModel.Key.Name) + return; + + object? id = idMemberMap.Getter(document); + if (idMemberMap.IdGenerator.IsEmpty(id)) + { + idMemberMap.Setter(document, idMemberMap.IdGenerator.GenerateId(container: null, document)); + } + } + + /// + /// After the data context accepted an insert: writes the document key back to the entity when the entity has none. + /// + public static void AssignKey(IEntityModel documentModel, TEntity entity, TDocument document) + { + if (s_entityKey.Value is not EntityKey { Setter: not null } entityKey || TryGetKey(entityKey, entity, out _)) + return; + + TKey documentKey = GetDocumentKey(documentModel, document); + if (!IsDefault(documentKey)) + { + entityKey.Setter(entity, documentKey); + } + } + + private static TKey GetDocumentKey(IEntityModel documentModel, TDocument document) => + KeyAccessor.Get(documentModel, document); + + /// true when the entity has a key that is not the default value. + private static bool TryGetKey(EntityKey entityKey, TEntity entity, out TKey key) + { + object? value = entityKey.Getter(entity); + key = value is null ? default! : (TKey)value; + + return value is not null && !IsDefault(key); + } + + private static bool IsDefault(TKey key) => key is null || EqualityComparer.Default.Equals(key, default!); + + private static EntityKey? ResolveEntityKey() + { + Type type = typeof(TEntity); + PropertyInfo? property = FindKey(type, "Id") ?? FindKey(type, type.Name + "Id"); + if (property is null) + return null; + + ParameterExpression parameter = Expression.Parameter(type, "entity"); + Func getter = Expression.Lambda>( + Expression.Convert(Expression.Property(parameter, property), typeof(object)), parameter).Compile(); + + // A private setter declared in a base class is only visible from the declaring type. + PropertyInfo declared = property.DeclaringType!.GetProperty( + property.Name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)!; + + // Only used on insert, when the key was generated: reflection keeps private and init setters working. + Action? setter = declared.GetSetMethod(nonPublic: true) is null + ? null + : (entity, key) => declared.SetValue(entity, key); + + return new EntityKey(property, getter, setter); + } + + private static PropertyInfo? FindKey(Type type, string name) + { + PropertyInfo? property = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public); + if (property is null || !property.CanRead) + return null; + + Type propertyType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; + return propertyType == typeof(TKey) ? property : null; + } + + private sealed class EntityKey(PropertyInfo property, Func getter, Action? setter) + { + public PropertyInfo Property { get; } = property; + + public Func Getter { get; } = getter; + + public Action? Setter { get; } = setter; + } +} diff --git a/src/Data.MongoDB/MongoDBMappedRepository.cs b/src/Data.MongoDB/MongoDBMappedRepository.cs index 3f6087a..06c1b25 100644 --- a/src/Data.MongoDB/MongoDBMappedRepository.cs +++ b/src/Data.MongoDB/MongoDBMappedRepository.cs @@ -1,10 +1,14 @@ using CodeArchitects.Platform.Common.CodeAnalysis; +using CodeArchitects.Platform.Data.MongoDB.Model; +using CodeArchitects.Platform.Data.MongoDB.Model.Implementation; +using CodeArchitects.Platform.Data.Navigation; using MongoDB.Driver; namespace CodeArchitects.Platform.Data.MongoDB; /// -/// A MongoDB implementation of +/// A MongoDB implementation of , for when the +/// persisted document differs from the domain entity. /// /// The document type stored in the collection. /// The domain entity type. @@ -55,4 +59,316 @@ protected MongoDBMappedRepository(IDataContext context) protected IClientSessionHandle Session => Context.Session; private protected sealed override Data.IDataContext DataContext => Context; + + #region Find + + /// + public override TEntity? Find(TKey key) + { + TDocument? document = Context.Find(key); + return document is null ? null : ToEntity(document); + } + + /// + public override async Task FindAsync(TKey key, CancellationToken cancellationToken = default) + { + TDocument? document = await Context.FindAsync(key, cancellationToken); + return document is null ? null : ToEntity(document); + } + + /// + /// Not supported by a mapped repository. + /// + /// + /// The include is written on the domain entity, which is not part of the MongoDB model, so it cannot + /// be validated against the document. It is not needed either: the document is always loaded whole, + /// so already returns the embedded parts of the aggregate. + /// + /// Always. + public override TEntity? Find(TKey key, IncludeAction includeAction) + { + throw IncludeNotSupported(); + } + + /// + public override Task FindAsync(TKey key, IncludeAction includeAction, CancellationToken cancellationToken = default) + { + throw IncludeNotSupported(); + } + + #endregion + + #region Insert + + // The entity receives its key only once the data context accepted the operation (written, or queued in the + // unit of work): a failure leaves the entity as it was. + + /// + public override void Insert(TEntity entity) + { + TDocument document = ToInsertedDocument(entity, out IEntityModel documentModel); + Context.Insert(document); + MappedKey.AssignKey(documentModel, entity, document); + } + + /// + public override async Task InsertAsync(TEntity entity, CancellationToken cancellationToken = default) + { + TDocument document = ToInsertedDocument(entity, out IEntityModel documentModel); + await Context.InsertAsync(document, cancellationToken); + MappedKey.AssignKey(documentModel, entity, document); + } + + /// + public override void InsertMany(IEnumerable entities) + { + TDocument[] documents = ToInsertedDocuments(entities, out TEntity[] batch, out IEntityModel documentModel); + Context.InsertMany(documents); + AssignKeys(documentModel, batch, documents); + } + + /// + public override async Task InsertManyAsync(IEnumerable entities, CancellationToken cancellationToken = default) + { + TDocument[] documents = ToInsertedDocuments(entities, out TEntity[] batch, out IEntityModel documentModel); + await Context.InsertManyAsync(documents, cancellationToken); + AssignKeys(documentModel, batch, documents); + } + + #endregion + + #region Update + + /// + public override void Update(TEntity entity) + { + Context.Update(ToDocument(entity)); + } + + /// + public override async Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default) + { + await Context.UpdateAsync(ToDocument(entity), cancellationToken); + } + + /// + public override void UpdateMany(IEnumerable entities) + { + Context.UpdateMany(ToDocuments(entities, out _, DocumentModel)); + } + + /// + public override async Task UpdateManyAsync(IEnumerable entities, CancellationToken cancellationToken = default) + { + await Context.UpdateManyAsync(ToDocuments(entities, out _, DocumentModel), cancellationToken); + } + + #endregion + + #region Upsert + + /// + public override void Upsert(TEntity entity) + { + Context.Upsert(ToDocument(entity)); + } + + /// + public override async Task UpsertAsync(TEntity entity, CancellationToken cancellationToken = default) + { + await Context.UpsertAsync(ToDocument(entity), cancellationToken); + } + + #endregion + + #region Remove + + /// + public override void Remove(TEntity entity) + { + Context.Remove(ToDocument(entity)); + } + + /// + public override async Task RemoveAsync(TEntity entity, CancellationToken cancellationToken = default) + { + await Context.RemoveAsync(ToDocument(entity), cancellationToken); + } + + #endregion + + #region Mapping + + /// + /// Maps a document to a domain entity, through . + /// + /// The document. + /// The domain entity. + /// is null. + /// The mapping returned null. + protected TEntity ToEntity(TDocument document) + { + if (document is null) + throw new ArgumentNullException(nameof(document)); + + return TableToEntity(document) + ?? throw new InvalidOperationException( + $"The mapping from '{typeof(TDocument).Name}' to '{typeof(TEntity).Name}' returned null."); + } + + /// + /// Maps documents to domain entities. + /// + /// The documents. + /// The domain entities, in the same order. + protected List ToEntities(IEnumerable documents) + { + if (documents is null) + throw new ArgumentNullException(nameof(documents)); + + return [.. documents.Select(ToEntity)]; + } + + /// + /// Runs a query written on the document and maps its results to domain entities. + /// + /// + /// The query, for example Documents.Find(Session, filter) or Documents.Aggregate(Session). + /// + /// The domain entities, in the order returned by the query. + protected List ToEntities(IAsyncCursorSource source) + { + if (source is null) + throw new ArgumentNullException(nameof(source)); + + return ToEntities(source.ToList()); + } + + /// + /// + /// The query, for example Documents.Find(Session, filter) or Documents.Aggregate(Session). + /// + /// A token to cancel the query. + protected async Task> ToEntitiesAsync(IAsyncCursorSource source, CancellationToken cancellationToken = default) + { + if (source is null) + throw new ArgumentNullException(nameof(source)); + + return ToEntities(await source.ToListAsync(cancellationToken)); + } + + /// + /// Maps a domain entity to a document, through , + /// and verifies that the key is preserved. + /// + /// The domain entity. + /// The document. + /// is null. + /// The mapping returned null, or changed the key. + protected TDocument ToDocument(TEntity entity) + { + if (entity is null) + throw new ArgumentNullException(nameof(entity)); + + return MapToDocument(entity, DocumentModel, position: null); + } + + /// + /// The registered model of the document, as the data context uses it. A data context that does not expose its + /// model, such as a decorator, falls back to the model built from the document type by the same rules. + /// + private IEntityModel DocumentModel => Context is IEntityModelResolver resolver + ? resolver.GetEntityModel() + : s_fallbackDocumentModel.Value; + + private static readonly Lazy s_fallbackDocumentModel = new(() => EntityModel.Create(typeof(TDocument))); + + private TDocument ToInsertedDocument(TEntity entity, out IEntityModel documentModel) + { + if (entity is null) + throw new ArgumentNullException(nameof(entity)); + + documentModel = DocumentModel; + TDocument document = MapToDocument(entity, documentModel, position: null); + MappedKey.GenerateKey(documentModel, document); + + return document; + } + + private TDocument[] ToInsertedDocuments(IEnumerable entities, out TEntity[] batch, out IEntityModel documentModel) + { + documentModel = DocumentModel; + TDocument[] documents = ToDocuments(entities, out batch, documentModel); + + // Only once the whole batch mapped: a rejected batch leaves every document, and every entity, untouched. + foreach (TDocument document in documents) + { + MappedKey.GenerateKey(documentModel, document); + } + + return documents; + } + + private static void AssignKeys(IEntityModel documentModel, TEntity[] batch, TDocument[] documents) + { + for (int index = 0; index < batch.Length; index++) + { + MappedKey.AssignKey(documentModel, batch[index], documents[index]); + } + } + + /// + /// Maps the whole batch before anything reaches the data context: a failure on one element never + /// leaves part of the batch written or queued in the unit of work. + /// + private TDocument[] ToDocuments(IEnumerable entities, out TEntity[] batch, IEntityModel documentModel) + { + if (entities is null) + throw new ArgumentNullException(nameof(entities)); + + batch = [.. entities]; + TDocument[] documents = new TDocument[batch.Length]; + + for (int index = 0; index < batch.Length; index++) + { + TEntity entity = batch[index] + ?? throw new ArgumentException($"The element at index {index} is null.", nameof(entities)); + + documents[index] = MapToDocument(entity, documentModel, $" (element at index {index})"); + } + + return documents; + } + + // position: where the entity sits in a batch, to name it in the errors; null for a single entity. + private TDocument MapToDocument(TEntity entity, IEntityModel documentModel, string? position) + { + TDocument? mapped; + try + { + mapped = EntityToTable(entity); + } + catch (Exception exception) when (position is not null) + { + // A single entity keeps the mapper's own exception; in a batch the element has to be named. + throw new InvalidOperationException( + $"The mapping from '{typeof(TEntity).Name}' to '{typeof(TDocument).Name}'{position} failed: {exception.Message}", exception); + } + + TDocument document = mapped + ?? throw new InvalidOperationException( + $"The mapping from '{typeof(TEntity).Name}' to '{typeof(TDocument).Name}'{position} returned null."); + + MappedKey.EnsurePreserved(documentModel, entity, document, position); + + return document; + } + + private static NotSupportedException IncludeNotSupported() => new( + $"Include is not supported by the mapped repository of '{typeof(TEntity).Name}': the include is written on the " + + $"domain entity, which cannot be validated against '{typeof(TDocument).Name}'. Use Find(key): the document is " + + "loaded whole, so the embedded parts of the aggregate are already mapped. Load references to other aggregates " + + "from their own repository."); + + #endregion } diff --git a/src/Data.MongoDB/README.md b/src/Data.MongoDB/README.md index 94d36f2..d59e700 100644 --- a/src/Data.MongoDB/README.md +++ b/src/Data.MongoDB/README.md @@ -56,7 +56,11 @@ transaction of the unit of work in progress. For a custom document↔domain mapping, derive from `MongoDBMappedRepository`, which exposes `Collection` (and its alias `Documents`) typed on the *document*. Put `[Collection]` -on the document, never on the domain entity. +on the document, never on the domain entity. Mapping is checked (a `null` result or a lost key throws), +batches are mapped before anything is written, and a key generated on insert is written back to the +entity. To map with Mapster instead of by hand, use +[`CodeArchitects.Platform.Data.MongoDB.Mapster`](../Data.MongoDB.Mapster): this package has no Mapster +dependency. ## Aggregates 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 cec1964..9903cf2 100644 --- a/tests/Data.MongoDB.IntegrationTests/CodeArchitects.Platform.Data.MongoDB.IntegrationTests.csproj +++ b/tests/Data.MongoDB.IntegrationTests/CodeArchitects.Platform.Data.MongoDB.IntegrationTests.csproj @@ -53,6 +53,7 @@ + diff --git a/tests/Data.MongoDB.IntegrationTests/Fixtures/PurchaseOrders.cs b/tests/Data.MongoDB.IntegrationTests/Fixtures/PurchaseOrders.cs new file mode 100644 index 0000000..b70e5fe --- /dev/null +++ b/tests/Data.MongoDB.IntegrationTests/Fixtures/PurchaseOrders.cs @@ -0,0 +1,177 @@ +using CodeArchitects.Platform.Data.MongoDB.Mapster; +using Mapster; +using CodeArchitects.Platform.Data.Mapster; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Driver; + +namespace CodeArchitects.Platform.Data.MongoDB.Fixtures; + +// --------------------------------------------------------------------------------------------- +// The domain model: value objects, an embedded collection and a reference to another aggregate. +// Nothing here knows about MongoDB. +// --------------------------------------------------------------------------------------------- + +/// A value object with a single value, persisted as a plain string. +public sealed record PurchaseOrderNumber(string Value); + +/// A value object with two values, flattened into the document. +public sealed record Money(decimal Amount, string Currency); + +public sealed record Address(string Street, string City, string PostalCode, string Country); + +/// Part of the aggregate: embedded in the document, it has no key of its own. +public sealed record OrderLine(Guid ProductId, int Quantity, Money UnitPrice); + +/// The aggregate root. +public class PurchaseOrder +{ + public Guid Id { get; set; } + public PurchaseOrderNumber Number { get; set; } = default!; + public Money Total { get; set; } = default!; + public Address ShippingAddress { get; set; } = default!; + public List Lines { get; set; } = []; + + /// A reference to another aggregate: only its key, never the aggregate itself. + public Guid CustomerId { get; set; } + + public static PurchaseOrder One(Guid? customerId = null) + { + List lines = + [ + new(Guid.NewGuid(), Random.Shared.Next(1, 5), new Money(Random.Shared.Next(10, 100), "EUR")), + new(Guid.NewGuid(), Random.Shared.Next(1, 5), new Money(Random.Shared.Next(10, 100), "EUR")) + ]; + + return new PurchaseOrder + { + Id = Guid.NewGuid(), + Number = new PurchaseOrderNumber($"PO-{Random.Shared.Next(10000, 99999)}"), + Total = new Money(lines.Sum(line => line.Quantity * line.UnitPrice.Amount), "EUR"), + ShippingAddress = new Address("Via Roma 1", "Milano", "20100", "IT"), + Lines = lines, + CustomerId = customerId ?? Guid.NewGuid() + }; + } +} + +// --------------------------------------------------------------------------------------------- +// The persistence model: renamed and flattened fields, embedded sub-documents, a technical field. +// Only this one is registered in the MongoDB model. +// --------------------------------------------------------------------------------------------- + +[CodeArchitects.Platform.Data.MongoDB.Collection("purchase_orders")] +public class PurchaseOrderDocument +{ + public Guid Id { get; set; } + public string Code { get; set; } = string.Empty; + public decimal TotalAmount { get; set; } + public string TotalCurrency { get; set; } = string.Empty; + public AddressDocument Shipping { get; set; } = default!; + public List Lines { get; set; } = []; + public Guid CustomerId { get; set; } + + /// Exists only in the document: written on every save, never read back into the domain. + public int SchemaVersion { get; set; } +} + +public class AddressDocument +{ + public string Line1 { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + public string Zip { get; set; } = string.Empty; + public string CountryCode { get; set; } = string.Empty; +} + +public class OrderLineDocument +{ + public Guid ProductId { get; set; } + public int Qty { get; set; } + public decimal Price { get; set; } + public string Currency { get; set; } = string.Empty; +} + +/// +/// Every rule of the demo. The configuration is strict, so each pair of types, nested ones included, +/// needs its own rule, and each destination member needs a source. +/// +public sealed class PurchaseOrderMapping : IRegister +{ + public const int CurrentSchemaVersion = 2; + + public void Register(TypeAdapterConfig config) + { + // Single-value value object <-> scalar. + config.NewConfig().MapWith(number => number.Value); + config.NewConfig().MapWith(value => new PurchaseOrderNumber(value)); + + // Immutable value object: built by the rules below, then shared as it is instead of copied. + config.NewConfig().MapWith(money => money); + + // Aggregate root: renamed, flattened and technical fields. Lines, Id and CustomerId map by name. + config.NewConfig() + .Map(document => document.Code, order => order.Number) + .Map(document => document.TotalAmount, order => order.Total.Amount) + .Map(document => document.TotalCurrency, order => order.Total.Currency) + .Map(document => document.Shipping, order => order.ShippingAddress) + .Map(document => document.SchemaVersion, _ => CurrentSchemaVersion); + + config.NewConfig() + .Map(order => order.Number, document => document.Code) + .Map(order => order.Total, document => new Money(document.TotalAmount, document.TotalCurrency)) + .Map(order => order.ShippingAddress, document => document.Shipping); + + // Embedded sub-document. + config.NewConfig() + .Map(document => document.Line1, address => address.Street) + .Map(document => document.Zip, address => address.PostalCode) + .Map(document => document.CountryCode, address => address.Country); + + config.NewConfig() + .MapWith(document => new Address(document.Line1, document.City, document.Zip, document.CountryCode)); + + // Embedded array, with a value object flattened in each element. + config.NewConfig() + .Map(document => document.Qty, line => line.Quantity) + .Map(document => document.Price, line => line.UnitPrice.Amount) + .Map(document => document.Currency, line => line.UnitPrice.Currency); + + config.NewConfig() + .MapWith(document => new OrderLine(document.ProductId, document.Qty, new Money(document.Price, document.Currency))); + + // Same shape but one renamed member: a single rule, reversed by TwoWays. Only the members + // configured after TwoWays() are reversed. + config.NewConfig() + .TwoWays() + .Map(document => document.Subject, ticket => ticket.Title); + } +} + +/// A specialized repository: its query is written on the document and returns domain entities. +public class PurchaseOrderRepository(IDataContext context, IDataMapper mapper) + : MongoDBMapsterRepository(context, mapper) +{ + public Task> FindByCustomerAsync(Guid customerId, CancellationToken cancellationToken = default) => + ToEntitiesAsync( + Documents.Find(Session, document => document.CustomerId == customerId).SortBy(document => document.Code), + cancellationToken); +} + +// --------------------------------------------------------------------------------------------- +// A key generated by the driver: the document key is an ObjectId stored from a string, and the +// domain entity must receive it. +// --------------------------------------------------------------------------------------------- + +public class Ticket +{ + public string? Id { get; set; } + public string Title { get; set; } = string.Empty; +} + +[CodeArchitects.Platform.Data.MongoDB.Collection("tickets")] +public class TicketDocument +{ + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + public string Subject { get; set; } = string.Empty; +} diff --git a/tests/Data.MongoDB.IntegrationTests/Fixtures/TestFixture.cs b/tests/Data.MongoDB.IntegrationTests/Fixtures/TestFixture.cs index d7969fb..a3540d1 100644 --- a/tests/Data.MongoDB.IntegrationTests/Fixtures/TestFixture.cs +++ b/tests/Data.MongoDB.IntegrationTests/Fixtures/TestFixture.cs @@ -1,3 +1,4 @@ +using CodeArchitects.Platform.Data.MongoDB.Mapster; using Microsoft.Extensions.DependencyInjection; using MongoDB.Driver; using Testcontainers.MongoDb; @@ -50,6 +51,9 @@ private static ServiceProvider BuildServices(string connectionString) => .AddEntitiesFrom(typeof(Customer).Assembly) .UseTransactions(TransactionMode.Required)) .AddScoped, MongoDBRepository>() + .AddDataMapster(typeof(PurchaseOrderMapping).Assembly) + .AddScoped() + .AddScoped, MongoDBMapsterRepository>() .BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); /// diff --git a/tests/Data.MongoDB.IntegrationTests/MapsterRepositoryTests.cs b/tests/Data.MongoDB.IntegrationTests/MapsterRepositoryTests.cs new file mode 100644 index 0000000..f68026b --- /dev/null +++ b/tests/Data.MongoDB.IntegrationTests/MapsterRepositoryTests.cs @@ -0,0 +1,423 @@ +using CodeArchitects.Platform.Data.MongoDB.Fixtures; +using MongoDB.Bson; +using MongoDB.Driver; +using System.Data; + +namespace CodeArchitects.Platform.Data.MongoDB; + +/// +/// The demo of a domain model persisted through a different document model, mapped by Mapster. +/// Every operation must behave as on the direct repository: same transactions, same concurrency checks. +/// +[Xunit.Collection(TestCollection.Name)] +public class MapsterRepositoryTests(TestFixture fixture) : TestBase(fixture) +{ + private IMongoCollection RawPurchaseOrders() + { + string name = _fixture.GetCollection().CollectionNamespace.CollectionName; + return _fixture.GetCollection().Database.GetCollection(name); + } + + private Task CountPurchaseOrdersAsync() => + _fixture.GetCollection().CountDocumentsAsync(FilterDefinition.Empty); + + #region Shape + + [Fact] + public async Task InsertAsync_ShouldPersistTheDocumentShape() + { + // Arrange + PurchaseOrder order = PurchaseOrder.One(); + using TestScope scope = _fixture.CreateScope(); + PurchaseOrderRepository repository = scope.Get(); + + // Act + await repository.InsertAsync(order); + + // Assert + // What is stored is the document model, not the domain one. + BsonDocument document = await RawPurchaseOrders() + .Find(Builders.Filter.Eq("_id", order.Id)).SingleAsync(); + + document["Code"].AsString.Should().Be(order.Number.Value); + document["TotalAmount"].ToDecimal().Should().Be(order.Total.Amount); + document["TotalCurrency"].AsString.Should().Be(order.Total.Currency); + document["Shipping"]["Line1"].AsString.Should().Be(order.ShippingAddress.Street); + document["Shipping"]["Zip"].AsString.Should().Be(order.ShippingAddress.PostalCode); + document["Lines"].AsBsonArray.Should().HaveCount(order.Lines.Count); + document["Lines"][0]["Qty"].AsInt32.Should().Be(order.Lines[0].Quantity); + document["Lines"][0]["Price"].ToDecimal().Should().Be(order.Lines[0].UnitPrice.Amount); + document["CustomerId"].AsGuid.Should().Be(order.CustomerId); + document["SchemaVersion"].AsInt32.Should().Be(PurchaseOrderMapping.CurrentSchemaVersion); + document.Contains("Number").Should().BeFalse(); + document.Contains("ShippingAddress").Should().BeFalse(); + } + + [Fact] + public async Task FindAsync_ShouldRebuildTheAggregate() + { + // Arrange + PurchaseOrder expected = PurchaseOrder.One(); + using TestScope scope = _fixture.CreateScope(); + PurchaseOrderRepository repository = scope.Get(); + await repository.InsertAsync(expected); + + // Act + PurchaseOrder? order = await repository.FindAsync(expected.Id); + + // Assert + // Key, value objects and embedded lines come back intact. + order.Should().BeEquivalentTo(expected); + } + + [Fact] + public void Find_ShouldRebuildTheAggregate() + { + // Arrange + PurchaseOrder expected = PurchaseOrder.One(); + using TestScope scope = _fixture.CreateScope(); + PurchaseOrderRepository repository = scope.Get(); + repository.Insert(expected); + + // Act + PurchaseOrder? order = repository.Find(expected.Id); + + // Assert + order.Should().BeEquivalentTo(expected); + } + + [Fact] + public async Task FindAsync_ShouldReturnNull_WhenTheDocumentDoesNotExist() + { + // Arrange + using TestScope scope = _fixture.CreateScope(); + PurchaseOrderRepository repository = scope.Get(); + + // Act & Assert + (await repository.FindAsync(Guid.NewGuid())).Should().BeNull(); + } + + [Fact] + public async Task FindAsync_ShouldThrow_WhenAnIncludeIsRequested() + { + // Arrange + using TestScope scope = _fixture.CreateScope(); + PurchaseOrderRepository repository = scope.Get(); + + // Act + Func act = () => repository.FindAsync(Guid.NewGuid(), include => include.Include(order => order.Lines)); + + // Assert + await act.Should().ThrowAsync().WithMessage("*Find(key)*"); + } + + #endregion + + #region Writes + + [Fact] + public async Task UpdateAsync_ShouldReplaceTheDocument() + { + // Arrange + PurchaseOrder order = PurchaseOrder.One(); + using TestScope scope = _fixture.CreateScope(); + PurchaseOrderRepository repository = scope.Get(); + await repository.InsertAsync(order); + + // Act + order.ShippingAddress = order.ShippingAddress with { City = "Roma" }; + order.Lines.RemoveAt(1); + await repository.UpdateAsync(order); + + // Assert + PurchaseOrder? updated = await repository.FindAsync(order.Id); + updated.Should().BeEquivalentTo(order); + } + + [Fact] + public async Task UpdateAsync_ShouldThrow_WhenTheDocumentDoesNotExist() + { + // Arrange + using TestScope scope = _fixture.CreateScope(); + PurchaseOrderRepository repository = scope.Get(); + + // Act + Func act = () => repository.UpdateAsync(PurchaseOrder.One()); + + // Assert + await act.Should().ThrowAsync().WithMessage("*PurchaseOrderDocument*"); + } + + [Fact] + public async Task UpsertAsync_ShouldInsert_ThenReplace() + { + // Arrange + PurchaseOrder order = PurchaseOrder.One(); + using TestScope scope = _fixture.CreateScope(); + PurchaseOrderRepository repository = scope.Get(); + + // Act + await repository.UpsertAsync(order); + order.Number = new PurchaseOrderNumber("PO-UPSERTED"); + await repository.UpsertAsync(order); + + // Assert + (await CountPurchaseOrdersAsync()).Should().Be(1); + (await repository.FindAsync(order.Id))!.Number.Value.Should().Be("PO-UPSERTED"); + } + + [Fact] + public async Task RemoveAsync_ShouldDeleteTheDocument_ByEntity() + { + // Arrange + PurchaseOrder order = PurchaseOrder.One(); + using TestScope scope = _fixture.CreateScope(); + PurchaseOrderRepository repository = scope.Get(); + await repository.InsertAsync(order); + + // Act + await repository.RemoveAsync(order); + + // Assert + (await repository.FindAsync(order.Id)).Should().BeNull(); + } + + [Fact] + public async Task RemoveAsync_ShouldDeleteTheDocument_ByKey() + { + // Arrange + PurchaseOrder order = PurchaseOrder.One(); + using TestScope scope = _fixture.CreateScope(); + PurchaseOrderRepository repository = scope.Get(); + await repository.InsertAsync(order); + + // Act + await repository.RemoveAsync(order.Id); + + // Assert + (await repository.FindAsync(order.Id)).Should().BeNull(); + } + + [Fact] + public async Task RemoveAsync_ShouldThrow_WhenTheDocumentDoesNotExist() + { + // Arrange + using TestScope scope = _fixture.CreateScope(); + PurchaseOrderRepository repository = scope.Get(); + + // Act + Func act = () => repository.RemoveAsync(PurchaseOrder.One()); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task InsertManyAsync_AndUpdateManyAsync_ShouldMapEveryElement() + { + // Arrange + PurchaseOrder[] orders = [PurchaseOrder.One(), PurchaseOrder.One(), PurchaseOrder.One()]; + using TestScope scope = _fixture.CreateScope(); + PurchaseOrderRepository repository = scope.Get(); + + // Act + await repository.InsertManyAsync(orders); + foreach (PurchaseOrder order in orders) + { + order.Total = order.Total with { Currency = "USD" }; + } + await repository.UpdateManyAsync(orders); + + // Assert + foreach (PurchaseOrder order in orders) + { + (await repository.FindAsync(order.Id)).Should().BeEquivalentTo(order); + } + } + + [Fact] + public async Task InsertManyAsync_ShouldDoNothing_WhenTheSequenceIsEmpty() + { + // Arrange + using TestScope scope = _fixture.CreateStandaloneScope(); + PurchaseOrderRepository repository = scope.Get(); + + // Act + // On the standalone server a round-trip would fail: no transaction is available. + Func act = () => repository.InsertManyAsync([]); + + // Assert + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task UpdateManyAsync_ShouldThrowAndPersistNothing_WhenOneDocumentDoesNotExist() + { + // Arrange + PurchaseOrder existing = PurchaseOrder.One(); + using TestScope scope = _fixture.CreateScope(); + PurchaseOrderRepository repository = scope.Get(); + await repository.InsertAsync(existing); + + // Act + existing.Number = new PurchaseOrderNumber("PO-CHANGED"); + Func act = () => repository.UpdateManyAsync([existing, PurchaseOrder.One()]); + + // Assert + await act.Should().ThrowAsync(); + (await repository.FindAsync(existing.Id))!.Number.Value.Should().NotBe("PO-CHANGED"); + } + + [Fact] + public async Task InsertManyAsync_ShouldThrow_OnAStandaloneServer() + { + // Arrange + using TestScope scope = _fixture.CreateStandaloneScope(); + PurchaseOrderRepository repository = scope.Get(); + + // Act + Func act = () => repository.InsertManyAsync([PurchaseOrder.One(), PurchaseOrder.One()]); + + // Assert + await act.Should().ThrowAsync(); + } + + #endregion + + #region Unit of work + + [Fact] + public async Task UnitOfWork_ShouldDeferMappedWrites_UntilSave() + { + // Arrange + using TestScope scope = _fixture.CreateScope(); + PurchaseOrderRepository repository = scope.Get(); + + // Act + await using IUnitOfWork unitOfWork = scope.UnitOfWorkManager.Begin(); + await repository.InsertAsync(PurchaseOrder.One()); + await repository.InsertManyAsync([PurchaseOrder.One(), PurchaseOrder.One()]); + + // Assert + (await CountPurchaseOrdersAsync()).Should().Be(0); + + await unitOfWork.SaveAsync(); + (await CountPurchaseOrdersAsync()).Should().Be(3); + } + + [Fact] + public async Task UnitOfWork_ShouldPersistNothing_WhenNotSaved() + { + // Arrange + using TestScope scope = _fixture.CreateScope(); + PurchaseOrderRepository repository = scope.Get(); + + // Act + await using (IUnitOfWork unitOfWork = scope.UnitOfWorkManager.Begin()) + { + await repository.InsertAsync(PurchaseOrder.One()); + } + + // Assert + (await CountPurchaseOrdersAsync()).Should().Be(0); + } + + [Fact] + public async Task UnitOfWork_ShouldRollBackTheMappedWrites_WhenOneFails() + { + // Arrange + PurchaseOrder existing = PurchaseOrder.One(); + using TestScope scope = _fixture.CreateScope(); + PurchaseOrderRepository repository = scope.Get(); + await repository.InsertAsync(existing); + + // Act + Func act = async () => + { + await using IUnitOfWork unitOfWork = scope.UnitOfWorkManager.Begin(); + await repository.InsertAsync(PurchaseOrder.One()); + await repository.UpdateAsync(PurchaseOrder.One()); // does not exist + await unitOfWork.SaveAsync(); + }; + + // Assert + await act.Should().ThrowAsync(); + (await CountPurchaseOrdersAsync()).Should().Be(1); + } + + [Fact] + public async Task CustomQuery_ShouldReturnDomainEntities_AndUseTheSession() + { + // Arrange + Guid customerId = Guid.NewGuid(); + PurchaseOrder first = PurchaseOrder.One(customerId); + PurchaseOrder second = PurchaseOrder.One(customerId); + using (TestScope seeding = _fixture.CreateScope()) + { + await seeding.Get().InsertManyAsync([first, second, PurchaseOrder.One()]); + } + + // A fresh scope: its session has not run any operation yet. + using TestScope scope = _fixture.CreateScope(); + PurchaseOrderRepository repository = scope.Get(); + scope.Context.Session.OperationTime.Should().BeNull(); + + // Act + List orders = await repository.FindByCustomerAsync(customerId); + + // Assert + orders.Should().BeEquivalentTo([first, second]); + // The server returns an operation time only to the session the query ran on: a query issued + // without Session would run on an implicit one and leave this session untouched. + scope.Context.Session.OperationTime.Should().NotBeNull(); + } + + #endregion + + #region Generated keys + + [Fact] + public async Task InsertAsync_ShouldGiveTheGeneratedKeyToTheEntity() + { + // Arrange + Ticket ticket = new() { Title = "Printer out of paper" }; + using TestScope scope = _fixture.CreateScope(); + IRepository repository = scope.Get>(); + + // Act + await repository.InsertAsync(ticket); + + // Assert + ObjectId.TryParse(ticket.Id, out _).Should().BeTrue(); + (await repository.FindAsync(ticket.Id!)).Should().BeEquivalentTo(ticket); + } + + [Fact] + public async Task InsertAsync_ShouldGiveTheGeneratedKeyToTheEntity_BeforeTheUnitOfWorkCommits() + { + // Arrange + Ticket first = new() { Title = "First" }; + Ticket second = new() { Title = "Second" }; + using TestScope scope = _fixture.CreateScope(); + IRepository repository = scope.Get>(); + + // Act + await using (IUnitOfWork unitOfWork = scope.UnitOfWorkManager.Begin()) + { + await repository.InsertAsync(first); + await repository.InsertManyAsync([second]); + + // Assert + // The write is only queued, but the entity already has its identity. + first.Id.Should().NotBeNullOrEmpty(); + second.Id.Should().NotBeNullOrEmpty().And.NotBe(first.Id); + + await unitOfWork.SaveAsync(); + } + + (await repository.FindAsync(first.Id!)).Should().BeEquivalentTo(first); + (await repository.FindAsync(second.Id!)).Should().BeEquivalentTo(second); + } + + #endregion +} diff --git a/tests/Data.MongoDB.IntegrationTests/PurchaseOrderMappingTests.cs b/tests/Data.MongoDB.IntegrationTests/PurchaseOrderMappingTests.cs new file mode 100644 index 0000000..bd21a96 --- /dev/null +++ b/tests/Data.MongoDB.IntegrationTests/PurchaseOrderMappingTests.cs @@ -0,0 +1,70 @@ +using CodeArchitects.Platform.Data.MongoDB.Fixtures; +using CodeArchitects.Platform.Data.Mapster; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeArchitects.Platform.Data.MongoDB; + +/// +/// The demo mapping on its own, without a database: it must survive the strict validation and +/// round-trip every part of the aggregate. +/// +public class PurchaseOrderMappingTests +{ + private static IDataMapper CreateMapper() => new ServiceCollection() + .AddDataMapster(typeof(PurchaseOrderMapping).Assembly) + .BuildServiceProvider() + .CreateScope().ServiceProvider.GetRequiredService(); + + [Fact] + public void Mapping_ShouldRoundTripTheAggregate() + { + // Arrange + IDataMapper mapper = CreateMapper(); + PurchaseOrder order = PurchaseOrder.One(); + + // Act + PurchaseOrderDocument document = mapper.Map(order); + PurchaseOrder mapped = mapper.Map(document); + + // Assert + mapped.Should().BeEquivalentTo(order); + mapped.Lines.Should().NotBeSameAs(order.Lines); + } + + [Fact] + public void Mapping_ShouldProduceTheDocumentShape() + { + // Arrange + IDataMapper mapper = CreateMapper(); + PurchaseOrder order = PurchaseOrder.One(); + + // Act + PurchaseOrderDocument document = mapper.Map(order); + + // Assert + document.Id.Should().Be(order.Id); + document.Code.Should().Be(order.Number.Value); + document.TotalAmount.Should().Be(order.Total.Amount); + document.TotalCurrency.Should().Be(order.Total.Currency); + document.Shipping.Line1.Should().Be(order.ShippingAddress.Street); + document.Shipping.CountryCode.Should().Be(order.ShippingAddress.Country); + document.Lines.Select(line => line.Qty).Should().Equal(order.Lines.Select(line => line.Quantity)); + document.CustomerId.Should().Be(order.CustomerId); + document.SchemaVersion.Should().Be(PurchaseOrderMapping.CurrentSchemaVersion); + } + + [Fact] + public void Mapping_ShouldReverseTheRenamedMember_WithTwoWays() + { + // Arrange + IDataMapper mapper = CreateMapper(); + Ticket ticket = new() { Id = "65f0c0ffee00000000000001", Title = "Title" }; + + // Act + TicketDocument document = mapper.Map(ticket); + + // Assert + document.Subject.Should().Be(ticket.Title); + mapper.Map(document).Should().BeEquivalentTo(ticket); + } +} diff --git a/tests/Data.MongoDB.IntegrationTests/RepositoryTests.cs b/tests/Data.MongoDB.IntegrationTests/RepositoryTests.cs index 77c7052..41751c3 100644 --- a/tests/Data.MongoDB.IntegrationTests/RepositoryTests.cs +++ b/tests/Data.MongoDB.IntegrationTests/RepositoryTests.cs @@ -130,6 +130,27 @@ public async Task Find_ShouldThrow_WhenIncludingAReferenceToAnotherCollection(bo await act.Should().ThrowAsync().WithMessage("*Invoice.Customer*"); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Find_ShouldMatchTheKey_WhenItIsStoredWithAnotherRepresentation(bool async) + { + // Arrange + // A string key stored as an ObjectId: the filter must compare it as an ObjectId. + TicketDocument expected = new() { Subject = "subject" }; + using TestScope scope = _fixture.CreateScope(); + MongoDBRepository repository = scope.Repository(); + await repository.InsertAsync(expected); + + // Act + TicketDocument? ticket = await Run(async, + () => repository.Find(expected.Id!), + () => repository.FindAsync(expected.Id!)); + + // Assert + ticket.Should().BeEquivalentTo(expected); + } + #endregion #region Insert diff --git a/tests/Data.MongoDB.UnitTests/CodeArchitects.Platform.Data.MongoDB.UnitTests.csproj b/tests/Data.MongoDB.UnitTests/CodeArchitects.Platform.Data.MongoDB.UnitTests.csproj index a7e52fb..672979e 100644 --- a/tests/Data.MongoDB.UnitTests/CodeArchitects.Platform.Data.MongoDB.UnitTests.csproj +++ b/tests/Data.MongoDB.UnitTests/CodeArchitects.Platform.Data.MongoDB.UnitTests.csproj @@ -33,6 +33,7 @@ + diff --git a/tests/Data.MongoDB.UnitTests/Filters/FilterProviderTests.cs b/tests/Data.MongoDB.UnitTests/Filters/FilterProviderTests.cs index 7037f0f..db5021d 100644 --- a/tests/Data.MongoDB.UnitTests/Filters/FilterProviderTests.cs +++ b/tests/Data.MongoDB.UnitTests/Filters/FilterProviderTests.cs @@ -50,6 +50,21 @@ public void ById_ShouldFilterOnIdElement_WhenKeyPropertyHasAnotherName() filter.Names.Should().NotContain("Identifier"); } + [Fact] + public void ById_ShouldRenderTheKeyAsItIsStored_WhenTheKeyHasAnotherRepresentation() + { + // Arrange + // The key is a string in the class and an ObjectId in the document. + ObjectId key = ObjectId.GenerateNewId(); + IEntityModel model = Model.Implementation.EntityModel.Create(typeof(NoteDocument)); + + // Act + BsonDocument filter = Render(_sut.ById(model, key.ToString())); + + // Assert + filter["_id"].Should().Be(new BsonObjectId(key)); + } + [Fact] public void ByEntity_ShouldFilterOnTheEntityKey() { diff --git a/tests/Data.MongoDB.UnitTests/Fixtures/MappedEntities.cs b/tests/Data.MongoDB.UnitTests/Fixtures/MappedEntities.cs new file mode 100644 index 0000000..caf392f --- /dev/null +++ b/tests/Data.MongoDB.UnitTests/Fixtures/MappedEntities.cs @@ -0,0 +1,85 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace CodeArchitects.Platform.Data.MongoDB.Fixtures; + +public class Widget +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public WidgetPart Part { get; set; } = new(); +} + +public class WidgetPart +{ + public int Size { get; set; } +} + +public class WidgetDocument +{ + public Guid Id { get; set; } + public string Label { get; set; } = string.Empty; + public WidgetPartDocument Part { get; set; } = new(); +} + +public class WidgetPartDocument +{ + public int Size { get; set; } +} + +/// The domain key has a private setter: a generated key must reach it all the same. +public class Note +{ + public Note(string text) => Text = text; + + public string? Id { get; private set; } + public string Text { get; } + + public static Note WithId(string id, string text) => new(text) { Id = id }; +} + +public class NoteDocument +{ + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + public string Text { get; set; } = string.Empty; +} + +/// The usual DDD base class: the key setter is private and declared here, not in the entity. +public abstract class EntityBase +{ + public string? Id { get; private set; } +} + +public class Memo : EntityBase +{ + public string Text { get; set; } = string.Empty; +} + +/// A domain entity without any key the repository can resolve by convention. +public class Keyless +{ + public Guid Identifier { get; set; } +} + +public class KeylessDocument +{ + public Guid Id { get; set; } +} + +/// +/// Maps with the delegates it is given, so that each test can make the mapping misbehave. +/// +public class DelegatingRepository( + IDataContext context, + Func toDocument, + Func toEntity) + : MongoDBMappedRepository(context) + where TDocument : class + where TEntity : class + where TKey : IEquatable +{ + protected override TDocument EntityToTable(TEntity entity) => toDocument(entity)!; + + protected override TEntity TableToEntity(TDocument document) => toEntity(document)!; +} diff --git a/tests/Data.MongoDB.UnitTests/Mapping/MapsterMappingTests.cs b/tests/Data.MongoDB.UnitTests/Mapping/MapsterMappingTests.cs new file mode 100644 index 0000000..9fd2d9f --- /dev/null +++ b/tests/Data.MongoDB.UnitTests/Mapping/MapsterMappingTests.cs @@ -0,0 +1,303 @@ +using CodeArchitects.Platform.Data.MongoDB.Fixtures; +using CodeArchitects.Platform.Data.Mapster; +using CodeArchitects.Platform.Data.MongoDB.Mapster; +using global::Mapster; +using MapsterMapper; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeArchitects.Platform.Data.MongoDB.Mapping; + +public class MapsterMappingTests +{ + /// Every rule the Widget pair needs in strict mode. + private static void ConfigureWidget(TypeAdapterConfig config) + { + config.NewConfig() + .TwoWays() + .Map(document => document.Label, widget => widget.Name); + config.NewConfig().TwoWays(); + } + + private static IDataMapper CreateMapper(Action configure) => new ServiceCollection() + .AddDataMapster(configure) + .BuildServiceProvider() + .CreateScope().ServiceProvider.GetRequiredService(); + + #region AddDataMapster + + [Fact] + public void AddDataMapster_ShouldRegisterADedicatedMapper_AndNothingOfTheApplication() + { + // Arrange + ServiceProvider provider = new ServiceCollection() + .AddDataMapster(ConfigureWidget) + .BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + + // Act + using IServiceScope scope = provider.CreateScope(); + IDataMapper mapper = scope.ServiceProvider.GetRequiredService(); + + // Assert + mapper.Config.Should().NotBeSameAs(TypeAdapterConfig.GlobalSettings); + mapper.Config.RequireExplicitMapping.Should().BeTrue(); + mapper.Config.RequireDestinationMemberSource.Should().BeTrue(); + scope.ServiceProvider.GetService().Should().BeNull(); + provider.GetService().Should().BeNull(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddDataMapster_ShouldNotReplaceTheApplicationMapper(bool dataFirst) + { + // Arrange + // The application maps its DTOs by convention, with Mapster's own registration. + IServiceCollection services = new ServiceCollection(); + if (dataFirst) + { + services.AddDataMapster(ConfigureWidget); + services.AddMapster(); + } + else + { + services.AddMapster(); + services.AddDataMapster(ConfigureWidget); + } + + // Act + using IServiceScope scope = services.BuildServiceProvider().CreateScope(); + IMapper applicationMapper = scope.ServiceProvider.GetRequiredService(); + IDataMapper dataMapper = scope.ServiceProvider.GetRequiredService(); + + // Assert + applicationMapper.Config.Should().BeSameAs(TypeAdapterConfig.GlobalSettings); + dataMapper.Config.Should().NotBeSameAs(applicationMapper.Config); + dataMapper.Map(new Widget { Name = "name" }).Label.Should().Be("name"); + } + + [Fact] + public void AddDataMapster_ShouldThrow_WhenCalledTwice() + { + // Arrange + IServiceCollection services = new ServiceCollection().AddDataMapster(ConfigureWidget); + + // Act + // A second call would silently drop the rules of the first one. + Action act = () => services.AddDataMapster(ConfigureWidget); + + // Assert + act.Should().Throw().WithMessage("AddDataMapster was already called*"); + } + + [Fact] + public void AddDataMapster_ShouldKeepADeliberatelySkippedCheck() + { + // Act + // A partially mapped legacy document: the check is skipped on purpose, not by TwoWays(). + Action act = () => new ServiceCollection().AddDataMapster(config => + { + config.NewConfig().Settings.SkipDestinationMemberCheck = true; + config.NewConfig(); + }); + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void AddDataMapster_ShouldThrow_WhenANestedRuleIsMissing() + { + // Act + Action act = () => new ServiceCollection().AddDataMapster(config => config + .NewConfig() + .Map(document => document.Label, widget => widget.Name)); + + // Assert + // The pair is only reached through Widget.Part, and it still fails at registration. + act.Should().Throw() + .WithMessage("*Mapster data mapping configuration is invalid*WidgetPart*WidgetPartDocument*"); + } + + [Fact] + public void AddDataMapster_ShouldThrow_WhenADestinationMemberHasNoSource() + { + // Act + Action act = () => new ServiceCollection().AddDataMapster(config => + { + config.NewConfig(); // Label has no source + config.NewConfig(); + }); + + // Assert + act.Should().Throw().WithMessage("*Label*"); + } + + [Fact] + public void AddDataMapster_ShouldThrow_WhenAReversedMemberHasNoSource() + { + // Act + // Configured before TwoWays(), the rename only applies in one direction: Widget.Name has no source. + Action act = () => new ServiceCollection().AddDataMapster(config => + { + config.NewConfig() + .Map(document => document.Label, widget => widget.Name) + .TwoWays(); + config.NewConfig().TwoWays(); + }); + + // Assert + act.Should().Throw().WithMessage("*Name*"); + } + + [Fact] + public void AddDataMapster_ShouldAllowRelaxingTheStrictSettings() + { + // Act + Action act = () => new ServiceCollection().AddDataMapster(config => + { + config.RequireExplicitMapping = false; + config.RequireDestinationMemberSource = false; + }); + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void AddDataMapster_ShouldApplyTheRegistersOfTheScannedAssemblies() + { + // Arrange + IDataMapper mapper = new ServiceCollection() + .AddDataMapster(typeof(WidgetMapping).Assembly) + .BuildServiceProvider() + .CreateScope().ServiceProvider.GetRequiredService(); + Widget widget = new() { Id = Guid.NewGuid(), Name = "name", Part = new WidgetPart { Size = 7 } }; + + // Act + WidgetDocument document = mapper.Map(widget); + + // Assert + document.Label.Should().Be(widget.Name); + document.Part.Size.Should().Be(widget.Part.Size); + } + + [Fact] + public void AddDataMapster_ShouldThrow_WhenNoAssemblyIsGiven() + { + // Act + Action act = () => new ServiceCollection().AddDataMapster(Array.Empty()); + + // Assert + act.Should().Throw(); + } + + #endregion + + #region MongoDBMapsterRepository + + [Fact] + public void Repository_ShouldThrow_WhenTheEntityToDocumentRuleIsMissing() + { + // Arrange + IDataMapper mapper = CreateMapper(config => + { + config.NewConfig().Map(widget => widget.Name, document => document.Label); + config.NewConfig(); + }); + + // Act + Action act = () => _ = new MongoDBMapsterRepository(Mock.Of(), mapper); + + // Assert + act.Should().Throw() + .WithMessage("No Mapster mapping from 'Widget' to 'WidgetDocument'*NewConfig().TwoWays()*"); + } + + [Fact] + public void Repository_ShouldThrow_WhenTheDocumentToEntityRuleIsMissing() + { + // Arrange + IDataMapper mapper = CreateMapper(config => + { + config.NewConfig().Map(document => document.Label, widget => widget.Name); + config.NewConfig(); + }); + + // Act + Action act = () => _ = new MongoDBMapsterRepository(Mock.Of(), mapper); + + // Assert + act.Should().Throw().WithMessage("No Mapster mapping from 'WidgetDocument' to 'Widget'*"); + } + + [Fact] + public void Repository_ShouldThrow_WhenAnImplicitMappingIsInvalid() + { + // Arrange + // Implicit mappings are allowed, but Widget.Name still has no source in WidgetDocument. + IDataMapper mapper = CreateMapper(config => config.RequireExplicitMapping = false); + + // Act + Action act = () => _ = new MongoDBMapsterRepository(Mock.Of(), mapper); + + // Assert + act.Should().Throw().WithMessage("The implicit Mapster mapping*"); + } + + [Fact] + public async Task Repository_ShouldMapBothWays_WithMapster() + { + // Arrange + Mock context = new(); + MongoDBMapsterRepository repository = new(context.Object, CreateMapper(ConfigureWidget)); + Widget widget = new() { Id = Guid.NewGuid(), Name = "name", Part = new WidgetPart { Size = 7 } }; + + WidgetDocument? stored = null; + context + .Setup(dataContext => dataContext.InsertAsync(It.IsAny(), It.IsAny())) + .Callback((document, _) => stored = document) + .Returns(Task.CompletedTask); + context + .Setup(dataContext => dataContext.FindAsync(widget.Id, It.IsAny())) + .ReturnsAsync(() => stored); + + // Act + await repository.InsertAsync(widget); + Widget? found = await repository.FindAsync(widget.Id); + + // Assert + stored!.Label.Should().Be(widget.Name); + stored.Part.Should().NotBeNull().And.NotBeSameAs(widget.Part); + found.Should().BeEquivalentTo(widget); + } + + #endregion + + #region Independence + + [Theory] + [InlineData(typeof(MongoDBRepository<,>))] + [InlineData(typeof(DataMongoDBServiceCollectionExtensions))] + public void DirectRepository_ShouldNotDependOnMapster(Type type) + { + // Act + IEnumerable references = type.Assembly.GetReferencedAssemblies().Select(assembly => assembly.Name); + + // Assert + references.Should().NotContain(name => name!.StartsWith("Mapster", StringComparison.Ordinal)); + } + + #endregion +} + +/// Found by AddDataMapster(assembly). +public sealed class WidgetMapping : IRegister +{ + public void Register(TypeAdapterConfig config) + { + config.NewConfig() + .TwoWays() + .Map(document => document.Label, widget => widget.Name); + config.NewConfig().TwoWays(); + } +} diff --git a/tests/Data.MongoDB.UnitTests/Mapping/MongoDBMappedRepositoryTests.cs b/tests/Data.MongoDB.UnitTests/Mapping/MongoDBMappedRepositoryTests.cs new file mode 100644 index 0000000..d4cbf16 --- /dev/null +++ b/tests/Data.MongoDB.UnitTests/Mapping/MongoDBMappedRepositoryTests.cs @@ -0,0 +1,391 @@ +using CodeArchitects.Platform.Data.MongoDB.Fixtures; +using CodeArchitects.Platform.Data.MongoDB.Model; +using MongoDB.Bson; + +namespace CodeArchitects.Platform.Data.MongoDB.Mapping; + +public class MongoDBMappedRepositoryTests +{ + private readonly Mock _context = new(); + + private static WidgetDocument ToDocument(Widget widget) => new() + { + Id = widget.Id, + Label = widget.Name, + Part = new WidgetPartDocument { Size = widget.Part.Size } + }; + + private static Widget ToEntity(WidgetDocument document) => new() + { + Id = document.Id, + Name = document.Label, + Part = new WidgetPart { Size = document.Part.Size } + }; + + private DelegatingRepository CreateRepository( + Func? toDocument = null, + Func? toEntity = null) => + new(_context.Object, toDocument ?? ToDocument, toEntity ?? ToEntity); + + private static Widget NewWidget() => new() { Id = Guid.NewGuid(), Name = "widget", Part = new WidgetPart { Size = 3 } }; + + #region Delegation + + [Fact] + public void Insert_ShouldPassTheMappedDocumentToTheDataContext() + { + // Arrange + Widget widget = NewWidget(); + DelegatingRepository repository = CreateRepository(); + + // Act + repository.Insert(widget); + + // Assert + _context.Verify(context => context.Insert(It.Is(document => + document.Id == widget.Id && document.Label == widget.Name && document.Part.Size == widget.Part.Size)), Times.Once); + } + + [Fact] + public async Task FindAsync_ShouldMapTheDocumentFromTheDataContext() + { + // Arrange + Widget expected = NewWidget(); + _context + .Setup(context => context.FindAsync(expected.Id, It.IsAny())) + .ReturnsAsync(ToDocument(expected)); + DelegatingRepository repository = CreateRepository(); + + // Act + Widget? widget = await repository.FindAsync(expected.Id); + + // Assert + widget.Should().BeEquivalentTo(expected); + } + + [Fact] + public void Find_ShouldReturnNull_WithoutMapping_WhenTheDocumentDoesNotExist() + { + // Arrange + DelegatingRepository repository = CreateRepository( + toEntity: _ => throw new InvalidOperationException("must not map")); + + // Act & Assert + repository.Find(Guid.NewGuid()).Should().BeNull(); + } + + [Fact] + public void Find_ShouldThrow_WhenAnIncludeIsRequested() + { + // Arrange + DelegatingRepository repository = CreateRepository(); + + // Act + Action act = () => repository.Find(Guid.NewGuid(), include => include.Include(widget => widget.Part)); + + // Assert + act.Should().Throw().WithMessage("*Widget*Find(key)*"); + _context.VerifyNoOtherCalls(); + } + + [Fact] + public void Remove_ByKey_ShouldNotMap() + { + // Arrange + Guid key = Guid.NewGuid(); + DelegatingRepository repository = CreateRepository( + toDocument: _ => throw new InvalidOperationException("must not map")); + + // Act + repository.Remove(key); + + // Assert + _context.Verify(context => context.Remove(key), Times.Once); + } + + #endregion + + #region Checked mapping + + [Fact] + public void Insert_ShouldThrow_WhenTheEntityIsNull() + { + // Arrange + DelegatingRepository repository = CreateRepository(); + + // Act + Action act = () => repository.Insert(null!); + + // Assert + act.Should().Throw().WithParameterName("entity"); + } + + [Fact] + public void InsertMany_ShouldThrow_WhenAnElementIsNull() + { + // Arrange + DelegatingRepository repository = CreateRepository(); + + // Act + Action act = () => repository.InsertMany([NewWidget(), null!]); + + // Assert + act.Should().Throw().WithMessage("*index 1*").WithParameterName("entities"); + _context.VerifyNoOtherCalls(); + } + + [Fact] + public void Update_ShouldThrow_WhenTheMappingReturnsNull() + { + // Arrange + DelegatingRepository repository = CreateRepository(toDocument: _ => null); + + // Act + Action act = () => repository.Update(NewWidget()); + + // Assert + act.Should().Throw().WithMessage("*'Widget' to 'WidgetDocument' returned null*"); + _context.VerifyNoOtherCalls(); + } + + [Fact] + public async Task FindAsync_ShouldThrow_WhenTheMappingReturnsNull() + { + // Arrange + _context + .Setup(context => context.FindAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new WidgetDocument()); + DelegatingRepository repository = CreateRepository(toEntity: _ => null); + + // Act + Func act = () => repository.FindAsync(Guid.NewGuid()); + + // Assert + await act.Should().ThrowAsync().WithMessage("*'WidgetDocument' to 'Widget' returned null*"); + } + + [Theory] + [InlineData(nameof(IRepository.Update))] + [InlineData(nameof(IRepository.Upsert))] + [InlineData(nameof(IRepository.Remove))] + [InlineData(nameof(IRepository.Insert))] + public void Write_ShouldThrow_WhenTheMappingDoesNotPreserveTheKey(string operation) + { + // Arrange + Widget widget = NewWidget(); + DelegatingRepository repository = CreateRepository( + toDocument: entity => new WidgetDocument { Id = Guid.NewGuid(), Label = entity.Name }); + + // Act + Action act = operation switch + { + nameof(repository.Update) => () => repository.Update(widget), + nameof(repository.Upsert) => () => repository.Upsert(widget), + nameof(repository.Remove) => () => repository.Remove(widget), + _ => () => repository.Insert(widget) + }; + + // Assert + act.Should().Throw().WithMessage("*does not preserve the key*Widget.Id*WidgetDocument.Id*"); + _context.VerifyNoOtherCalls(); + } + + [Fact] + public void UpdateMany_ShouldQueueNothing_WhenOneElementFailsToMap() + { + // Arrange + Widget broken = NewWidget(); + DelegatingRepository repository = CreateRepository( + toDocument: entity => entity == broken ? throw new FormatException("bad") : ToDocument(entity)); + + // Act + Action act = () => repository.UpdateMany([NewWidget(), NewWidget(), broken]); + + // Assert + // The batch is mapped before the data context sees any of it. + act.Should().Throw() + .WithMessage("*'Widget' to 'WidgetDocument' (element at index 2) failed: bad*") + .WithInnerException(); + _context.VerifyNoOtherCalls(); + } + + [Fact] + public void UpdateMany_ShouldNameTheElement_WhenItsKeyIsNotPreserved() + { + // Arrange + Widget broken = NewWidget(); + DelegatingRepository repository = CreateRepository( + toDocument: entity => entity == broken ? new WidgetDocument { Id = Guid.NewGuid() } : ToDocument(entity)); + + // Act + Action act = () => repository.UpdateMany([NewWidget(), broken]); + + // Assert + act.Should().Throw().WithMessage("*(element at index 1) does not preserve the key*"); + _context.VerifyNoOtherCalls(); + } + + [Fact] + public void Update_ShouldSkipTheKeyCheck_WhenTheEntityKeyCannotBeResolved() + { + // Arrange + Keyless entity = new() { Identifier = Guid.NewGuid() }; + DelegatingRepository repository = new( + _context.Object, + keyless => new KeylessDocument { Id = keyless.Identifier }, + document => new Keyless { Identifier = document.Id }); + + // Act + repository.Update(entity); + + // Assert + _context.Verify(context => context.Update(It.Is(document => document.Id == entity.Identifier)), Times.Once); + } + + #endregion + + #region Generated keys + + private DelegatingRepository CreateNoteRepository() => new( + _context.Object, + note => new NoteDocument { Id = note.Id, Text = note.Text }, + document => Note.WithId(document.Id!, document.Text)); + + [Fact] + public void Insert_ShouldGenerateTheKey_AndGiveItToTheEntity() + { + // Arrange + Note note = new("text"); + DelegatingRepository repository = CreateNoteRepository(); + + // Act + repository.Insert(note); + + // Assert + // Generated before the data context is called, and written through the private setter. + ObjectId.TryParse(note.Id, out _).Should().BeTrue(); + _context.Verify(context => context.Insert(It.Is(document => document.Id == note.Id)), Times.Once); + } + + [Fact] + public async Task InsertManyAsync_ShouldGenerateADistinctKeyForEachEntity() + { + // Arrange + Note first = new("first"); + Note second = new("second"); + DelegatingRepository repository = CreateNoteRepository(); + + // Act + await repository.InsertManyAsync([first, second]); + + // Assert + first.Id.Should().NotBeNullOrEmpty(); + second.Id.Should().NotBeNullOrEmpty().And.NotBe(first.Id); + } + + [Fact] + public void Insert_ShouldKeepTheKey_WhenTheEntityAlreadyHasOne() + { + // Arrange + string id = ObjectId.GenerateNewId().ToString(); + Note note = Note.WithId(id, "text"); + DelegatingRepository repository = CreateNoteRepository(); + + // Act + repository.Insert(note); + + // Assert + note.Id.Should().Be(id); + _context.Verify(context => context.Insert(It.Is(document => document.Id == id)), Times.Once); + } + + [Fact] + public void Insert_ShouldGiveTheKeyToTheEntity_ThroughAPrivateSetterOfABaseClass() + { + // Arrange + Memo memo = new() { Text = "text" }; + DelegatingRepository repository = new( + _context.Object, + entity => new NoteDocument { Id = entity.Id, Text = entity.Text }, + document => new Memo { Text = document.Text }); + + // Act + repository.Insert(memo); + + // Assert + ObjectId.TryParse(memo.Id, out _).Should().BeTrue(); + } + + [Fact] + public void Insert_ShouldLeaveTheEntityUnchanged_WhenTheDataContextRejectsIt() + { + // Arrange + Note note = new("text"); + _context + .Setup(context => context.Insert(It.IsAny())) + .Throws(new InvalidOperationException("rejected")); + DelegatingRepository repository = CreateNoteRepository(); + + // Act + Action act = () => repository.Insert(note); + + // Assert + act.Should().Throw().WithMessage("rejected"); + note.Id.Should().BeNull(); + } + + [Fact] + public async Task InsertManyAsync_ShouldLeaveEveryEntityUnchanged_WhenOneFailsToMap() + { + // Arrange + Note first = new("first"); + Note broken = new("broken"); + DelegatingRepository repository = new( + _context.Object, + note => note == broken ? throw new FormatException("bad") : new NoteDocument { Id = note.Id, Text = note.Text }, + document => Note.WithId(document.Id!, document.Text)); + + // Act + Func act = () => repository.InsertManyAsync([first, broken]); + + // Assert + // The batch is rejected before any key is generated, so the first entity keeps no key either. + await act.Should().ThrowAsync().WithMessage("*index 1*"); + first.Id.Should().BeNull(); + _context.VerifyNoOtherCalls(); + } + + [Fact] + public void Write_ShouldUseTheModelRegisteredInTheDataContext() + { + // Arrange + // A data context built by AddData exposes its model: an unregistered document fails with its message. + _context.As() + .Setup(resolver => resolver.GetEntityModel()) + .Throws(new InvalidOperationException("'WidgetDocument' is not registered as a MongoDB entity.")); + DelegatingRepository repository = CreateRepository(); + + // Act + Action act = () => repository.Update(NewWidget()); + + // Assert + act.Should().Throw().WithMessage("*not registered as a MongoDB entity*"); + _context.Verify(context => context.Update(It.IsAny()), Times.Never); + } + + [Fact] + public void Update_ShouldNotGenerateAKey() + { + // Arrange + Note note = new("text"); + DelegatingRepository repository = CreateNoteRepository(); + + // Act + repository.Update(note); + + // Assert + note.Id.Should().BeNull(); + } + + #endregion +}