diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f030ad6..334dbd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,26 @@ jobs: echo "::endgroup::" done + mongodb-integration-tests: + name: MongoDB integration tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Test (MongoDB integration) + run: > + dotnet test tests/Data.MongoDB.IntegrationTests/CodeArchitects.Platform.Data.MongoDB.IntegrationTests.csproj + --configuration Release --framework net8.0 --verbosity normal + security: name: Security & SBOM runs-on: ubuntu-latest diff --git a/Directory.Build.props b/Directory.Build.props index d95d71e..a5148ce 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -61,7 +61,7 @@ maintenance-only; new code should use CodeArchitects.Platform.Data.Mapster. * GHSA-6c8g-7p36-r338 SharpCompress 0.30.1 (Moderate) — transitive via - MongoDB.Driver 2.19.0. No patched release is compatible with the + MongoDB.Driver 3.12.0. No patched release is compatible with the netstandard2.1 target. Real fix = upgrade MongoDB.Driver (breaking). * GHSA-2m69-gcr7-jv3q SQLitePCLRaw.lib.e_sqlite3 2.1.x (High) — TEST-ONLY diff --git a/docs/mongodb.md b/docs/mongodb.md new file mode 100644 index 0000000..5629066 --- /dev/null +++ b/docs/mongodb.md @@ -0,0 +1,321 @@ +# DAL with MongoDB + +> **Note.** The `CodeArchitects.Platform.Data.MongoDB` and +> `CodeArchitects.Platform.Data.MongoDB.DependencyInjection` packages are marked `[Experimental]`: +> breaking changes are very likely to be introduced between one version and the next. +> They have not yet reached the same level of maturity as the other currently supported Data Providers. + +MongoDB is a document database: it has no tables or foreign keys, and guarantees atomicity for a +**single document**. The CAEP MongoDB provider exposes the same `IRepository`, `IUnitOfWork`, and +`IDataContext` as the other providers, but some semantics consequently differ. +This page describes those differences. + +## Configuration + +```csharp +builder.Services.AddData(cfg => cfg + .UseConnectionString(builder.Configuration.GetConnectionString("Mongo")!) + .UseDatabase("store") + .AddEntitiesFrom(typeof(Product).Assembly)); +``` + +`AddData` lives in the `Microsoft.Extensions.DependencyInjection` namespace, as with the other +providers: no additional `using` directive is required in `Program.cs`. + +The configuration is **validated immediately**, without contacting the server. A missing client, +missing database, empty model, unresolvable key, or two entities mapped to the same collection +causes startup to fail with an explicit message, rather than a `NullReferenceException` on the +first request. + +### Client + +```csharp +// from the connection string +.UseConnectionString(connectionString) + +// modifying the settings derived from the connection string +.UseConnectionString(connectionString, settings => settings.RetryWrites = true) + +// providing the client +.UseClient(client) +.UseClient(sp => sp.GetRequiredService()) +``` + +`UseClient` accepts `IMongoClient`, not `MongoClient`: in driver 3.x the concrete class is +`sealed`, so the interface is the only way to decorate or replace the client. + +### Options + +```csharp +builder.Services.AddData(cfg => cfg + .UseConnectionString(connectionString) + .UseDatabase("store") + .AddEntitiesFrom(typeof(Product).Assembly) + .AddEntity() // explicit registration + .UseTransactions(TransactionMode.Required) // default + .UseGuidRepresentation(GuidRepresentation.Standard) // default + .ConfigureConventions(pack => pack.Add(new CamelCaseElementNameConvention())) + .UseSeed()); +``` + +## Entities and conventions + +### Collection name + +An entity is a **public, concrete, non-generic** class marked with `[Collection]`: + +```csharp +using CodeArchitects.Platform.Data.MongoDB; + +[Collection("products")] +public class Product +{ + public Guid Id { get; set; } + public string? Denomination { get; set; } + public decimal Price { get; set; } +} +``` + +`[Table]` is accepted as an alternative for backward compatibility. Without an attribute, the name +is the type name unchanged: there is no pluralization or case transformation. `AddEntity()` +registers a type even if it has no attribute. + +> The attribute is called `Collection`, like the xUnit one: in a test project that imports both, +> it must be qualified (`[CodeArchitects.Platform.Data.MongoDB.Collection("...")]`). + +### Key + +The key is **always** mapped to the `_id` element and is resolved in this order: + +1. property marked `[BsonId]`; +2. `Id` property; +3. `Id` property. + +Supported types: `Guid`, `string`, `ObjectId`, `int`, `long`. Composite keys are not supported. + +Value generation is not handled by the provider: if the key is `default` at insert time, value +generation is delegated to the driver for `ObjectId` and `string`, while `Guid` and integer keys +must be assigned by the domain, as in the CAEP pipeline. + +## Aggregates, embedded documents, and references + +> **The document is the unit of consistency.** An aggregate root corresponds to a document; the +> entire aggregate lives inside that document. Operations on the aggregate root are therefore +> atomic **by construction**, without transactions. + +This changes how associations are modeled compared with relational providers: + +| Association | Representation | +|---|---| +| Intra-aggregate (1:1 and 1:N) | embedded sub-document or array of **embedded** sub-documents | +| Inter-aggregate (1:1, N:1, 1:N) | field containing the other aggregate's **key** | +| Many-to-many | array of keys on the owning side; no junction collection | + +```csharp +[Collection("carts")] +public class Cart +{ + public Guid Id { get; set; } + public List Items { get; set; } = []; // intra-aggregate: embedded + public Guid CustomerId { get; set; } // inter-aggregate: reference +} + +public class CartItem // no [Collection], no Id: not an entity +{ + public string? Sku { get; set; } + public int Quantity { get; set; } +} +``` + +Embedded entities **do not have their own collection** and cannot be reached through a dedicated +repository: an entity that needs a repository is, by definition, an aggregate root. + +Writes follow the semantics already documented in the [DAL](dataaccesslayer.md#associazioni-e-aggregati): +`Insert` and `Update` on the aggregate root write the entire document, including embedded entities, +while inter-aggregate entities are not written, only the reference is persisted. `Remove` deletes +the document and its embedded entities; there is **no** cascade through references, because MongoDB +has no `delete behavior`: this remains the application's responsibility. + +`DBRef` is intentionally not supported: it cannot be resolved server-side in a `$lookup` pipeline +and puts the collection name into the data. + +## Unit of work and transactions + +The use of `IUnitOfWorkManager` and `IUnitOfWork` is identical to the other providers +([DAL](dataaccesslayer.md#il-pattern-unit-of-work)). There are two differences. + +**Writes are deferred.** Within the UnitOfWork, operations accumulate and are applied on +`SaveAsync` (or on `Dispose` with `autoSave: true`), all in **a single** MongoDB transaction. +Consequently, a read performed within the UnitOfWork context **does not see** writes that have not +yet been committed. + +```csharp +await using (IUnitOfWork uow = _uowManager.Begin()) +{ + await _cartRepo.UpdateAsync(cart); + await _productRepo.UpdateAsync(product); + + await uow.SaveAsync(); // one transaction: both or neither +} +``` + +**Transactions require a replica set.** MongoDB does not support them on a standalone server. The +behavior is governed by `UseTransactions`: + +| Mode | Behavior on an incompatible topology | +|---|---| +| `Required` (default) | throws `TransactionsNotSupportedException` | +| `WhenSupported` | runs without atomicity and emits a warning to the logger | +| `Disabled` | never uses transactions | + +A write to a single document **outside** a UnitOfWork does not open a transaction: it is already +atomic. `InsertMany` and `UpdateMany`, however, always open one because they involve multiple +documents. + +## Operation semantics + +| Method | Behavior | Exception | +|---|---|---| +| `FindAsync(key)` | filter on `_id` | — (`null` if absent) | +| `FindAsync(key, include)` | **not supported** | `NotSupportedException` | +| `InsertAsync` | `insertOne` | `MongoWriteException` on duplicate key | +| `InsertManyAsync` | ordered `insertMany`, in a transaction | `MongoBulkWriteException` | +| `UpdateAsync` | replaces the entire document | `DBConcurrencyException` if it does not exist | +| `UpdateManyAsync` | ordered `bulkWrite`, in a transaction | `DBConcurrencyException` if any do not exist | +| `UpsertAsync` | replaces or inserts | `DBConcurrencyException` if not applied | +| `RemoveAsync` | `deleteOne` | `DBConcurrencyException` if it does not exist | + +Two points may surprise users coming from relational providers: + +- **`UpdateAsync` replaces the entire document**, not only the modified fields: there is no change + tracking. For a partial update, use `Collection.UpdateOneAsync` directly with the current session. +- **An update that changes nothing is successful.** The criterion is "the document exists", not + "the document was rewritten". + +## Repository + +### Direct repository + +The domain entity is the document. Use this when the domain model is serializable 1:1. + +```csharp +using CodeArchitects.Platform.Data.MongoDB; + +public class ProductRepository : MongoDBRepository, IProductRepository +{ + public ProductRepository(IDataContext context) : base(context) { } + + public async Task> GetTopSellingProductsAsync(int count, CancellationToken ct = default) + { + return await Collection + .Find(Session, Builders.Filter.Empty) // Session: participates in the unit of work + .SortByDescending(product => product.SaleCount) + .Limit(count) + .ToListAsync(ct); + } +} +``` + +The base class exposes `Collection` (`IMongoCollection`), `Database`, and **`Session`**. + +> Passing `Session` to custom queries is not optional: an operation executed without a session runs +> on an implicit session, and therefore **outside** the transaction of the current unit of work. + +### Mapped repository + +When the document must differ from the domain, for example for denormalization, technical fields, +different names, or schema versioning, use `MongoDBMappedRepository`. +`[Collection]` belongs on the **document**, never on the domain entity. + +```csharp +[Collection("products")] +public class ProductDocument +{ + public Guid Id { get; set; } + public string? Code { get; set; } + public decimal Amount { get; set; } +} + +public class ProductRepository : MongoDBMappedRepository, IProductRepository +{ + public ProductRepository(IDataContext context) : base(context) { } + + protected override Product TableToEntity(ProductDocument document) => /* ... */; + protected override ProductDocument EntityToTable(Product entity) => /* ... */; +} +``` + +The base class exposes `Collection` and its `Documents` alias, both of type +`IMongoCollection`. + +Mapping can be delegated to Mapster with a bidirectional `TypeAdapterConfig`, as described in +[DAL – Mapped repository](dataaccesslayer.md#mapped-repository): + +```csharp +config.NewConfig().TwoWays(); +``` + +> The `PreserveTracking` extension from `CodeArchitects.Platform.Data.Mapster` has **no effect** on +> this provider: MongoDB has no change tracking. + +## Seeding + +Seeding uses the same `DataSeed` as the other providers. + +```csharp +[SeedOrder(1)] +public class CategorySeed : DataSeed +{ + public override void Seed(ISeeder seeder) => seeder.Seed( + new Category { ... }, + new Category { ... }); +} +``` + +Registration and application: + +```csharp +builder.Services.AddData(cfg => cfg + // ... + .UseSeed() + .AddSeedsFrom(typeof(CategorySeed).Assembly)); // or by scanning + +WebApplication app = builder.Build(); +app.Services.SeedMongo(); // or await SeedMongoAsync() +``` + +- The **order** is deterministic: `[SeedOrder]` (missing ⇒ `0`), then the type name for equal order. +- Seeding is **idempotent per collection**: a seed is applied only to an empty collection. +- All seeds are committed **in a single transaction**: interrupted seeding does not leave the + database partially populated. It therefore requires a replica set. + +## Testing + +The provider's integration tests use [Testcontainers](https://dotnet.testcontainers.org/) with a +single-node replica set, which is required for transactions: + +```csharp +MongoDbContainer container = new MongoDbBuilder() + .WithImage("mongo:7.0") + .WithReplicaSet() + .Build(); +``` + +## Known limitations + +Not yet supported, in order of impact: + +- **`Include`** — both for embedded navigations (where it would be a no-op) and inter-aggregate + references. Throws `NotSupportedException`. +- **Optimistic concurrency** — no version token. +- **Multitenancy and soft delete** — available in the EF Core provider, not here. +- **Change tracking** — has no equivalent in the aggregate-based model. +- **Composite keys**, **index management**, **multiple databases** with a typed context. +- **Interceptors** for operations. + +## Packages + +- [`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) + +They require **MongoDB Server 4.4 or later** (a requirement of driver 3.x). diff --git a/mkdocs.yml b/mkdocs.yml index 007203c..4bfb5fc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,4 +12,5 @@ nav: - Data access: - Overview: dataaccesslayer.md - Entity Framework Core: efcore.md - - ADO.NET: adonet.md \ No newline at end of file + - ADO.NET: adonet.md + - MongoDB: mongodb.md \ No newline at end of file diff --git a/src/Data.MongoDB.DependencyInjection/CodeArchitects.Platform.Data.MongoDB.DependencyInjection.csproj b/src/Data.MongoDB.DependencyInjection/CodeArchitects.Platform.Data.MongoDB.DependencyInjection.csproj index 2533479..1eb4b14 100644 --- a/src/Data.MongoDB.DependencyInjection/CodeArchitects.Platform.Data.MongoDB.DependencyInjection.csproj +++ b/src/Data.MongoDB.DependencyInjection/CodeArchitects.Platform.Data.MongoDB.DependencyInjection.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/Data.MongoDB.DependencyInjection/DataMongoDBServiceCollectionExtensions.cs b/src/Data.MongoDB.DependencyInjection/DataMongoDBServiceCollectionExtensions.cs index e0957e5..86ad8f2 100644 --- a/src/Data.MongoDB.DependencyInjection/DataMongoDBServiceCollectionExtensions.cs +++ b/src/Data.MongoDB.DependencyInjection/DataMongoDBServiceCollectionExtensions.cs @@ -1,6 +1,6 @@ -using Microsoft.Extensions.DependencyInjection; +using CodeArchitects.Platform.Data.MongoDB.DependencyInjection; -namespace CodeArchitects.Platform.Data.MongoDB.DependencyInjection; +namespace Microsoft.Extensions.DependencyInjection; /// /// Methods for adding the MongoDB data context to the application services. @@ -14,7 +14,13 @@ public static class DataMongoDBServiceCollectionExtensions /// The MongoDB configuration. /// The same instance. /// - public static IServiceCollection AddData(this IServiceCollection services, Func configure) + /// + /// The configuration is incomplete, or the entity model is empty or inconsistent. The check is + /// performed eagerly, without contacting the server. + /// + public static IServiceCollection AddData( + this IServiceCollection services, + Func configure) { if (services is null) throw new ArgumentNullException(nameof(services)); diff --git a/src/Data.MongoDB.DependencyInjection/IMongoDBConfigurationBuilder.cs b/src/Data.MongoDB.DependencyInjection/IMongoDBConfigurationBuilder.cs index eae51c9..5ceda27 100644 --- a/src/Data.MongoDB.DependencyInjection/IMongoDBConfigurationBuilder.cs +++ b/src/Data.MongoDB.DependencyInjection/IMongoDBConfigurationBuilder.cs @@ -1,11 +1,15 @@ -using CodeArchitects.Platform.Common.CodeAnalysis; +using CodeArchitects.Platform.Common.CodeAnalysis; +using CodeArchitects.Platform.Data; +using CodeArchitects.Platform.Data.MongoDB; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Conventions; using MongoDB.Driver; using System.Reflection; -namespace CodeArchitects.Platform.Data.MongoDB.DependencyInjection; +namespace Microsoft.Extensions.DependencyInjection; /// -/// An object used to configure the MongoDB context and services. +/// An object used to configure the MongoDB client. /// [Experimental] public interface IMongoDBConfigurationBuilder @@ -17,24 +21,34 @@ public interface IMongoDBConfigurationBuilder /// An for further MongoDB client configuration. IMongoDBConfigurationBuilderWithClient UseConnectionString(string connectionString); + /// + /// Specify the connection string used for the database connection, and adjust the resulting + /// client settings. + /// + /// The MongoDB connection string. + /// Applied to the settings parsed from the connection string. + /// An for further MongoDB client configuration. + IMongoDBConfigurationBuilderWithClient UseConnectionString(string connectionString, Action configure); + /// /// Specify the client used for the database communication. /// - /// The client. + /// The MongoDB client. /// An for further MongoDB client configuration. - IMongoDBConfigurationBuilderWithClient UseClient(MongoClient client); + IMongoDBConfigurationBuilderWithClient UseClient(IMongoClient client); /// - /// Specifies the seed type to use for seeding the database. + /// Specify how to build the client used for the database communication. /// - /// The seed type. It must extend . - /// An for further configurations. - IMongoDBConfigurationBuilder UseSeed(Type seedType); + /// Resolves the client from the application services. + /// An for further MongoDB client configuration. + IMongoDBConfigurationBuilderWithClient UseClient(Func factory); } /// -/// An object used to configure the MongoDB client. +/// An object used to configure the MongoDB database. /// +[Experimental] public interface IMongoDBConfigurationBuilderWithClient { /// @@ -42,19 +56,66 @@ public interface IMongoDBConfigurationBuilderWithClient /// /// The database name. /// The database settings. - /// And for further MongoDB database configuration. + /// An for further MongoDB database configuration. IMongoDBConfigurationBuilderWithDatabase UseDatabase(string databaseName, MongoDatabaseSettings? settings = null); } /// -/// An object used to configure the MongoDB database. +/// An object used to configure the MongoDB model and behaviour. /// +[Experimental] public interface IMongoDBConfigurationBuilderWithDatabase { /// - /// Specifies the assembly to retrieve the entities from. + /// Registers every entity of an assembly, that is every public, concrete, non-generic class + /// marked with [Collection] or [Table]. + /// + /// The source assembly. + /// An optional additional filter over the candidate types. + /// An for further MongoDB database configuration. + IMongoDBConfigurationBuilderWithDatabase AddEntitiesFrom(Assembly assembly, Func? filter = null); + + /// + /// Registers a single entity type, whether or not it carries a discovery attribute. + /// + /// The entity type. + /// An for further MongoDB database configuration. + IMongoDBConfigurationBuilderWithDatabase AddEntity(Type entityType); + + /// + /// Specifies how the provider behaves when an operation needs a MongoDB transaction. + /// + /// The transaction mode. Defaults to . + /// Read/write concern and timeout applied to the transactions. + /// An for further MongoDB database configuration. + IMongoDBConfigurationBuilderWithDatabase UseTransactions(TransactionMode mode, TransactionOptions? options = null); + + /// + /// Specifies how values are stored. Defaults to + /// . + /// + /// The representation to use. + /// An for further MongoDB database configuration. + IMongoDBConfigurationBuilderWithDatabase UseGuidRepresentation(GuidRepresentation representation); + + /// + /// Adds conventions to the pack the provider registers for its entities. + /// + /// Applied to the provider convention pack. + /// An for further MongoDB database configuration. + IMongoDBConfigurationBuilderWithDatabase ConfigureConventions(Action configure); + + /// + /// Adds a seed type to apply when the database is seeded. + /// + /// The seed type. It must extend . + /// An for further MongoDB database configuration. + IMongoDBConfigurationBuilderWithDatabase UseSeed(Type seedType); + + /// + /// Adds every concrete of an assembly. /// /// The source assembly. - /// And for further MongoDB database configuration. - IMongoDBConfigurationBuilderWithDatabase AddEntitiesFrom(Assembly assembly); + /// An for further MongoDB database configuration. + IMongoDBConfigurationBuilderWithDatabase AddSeedsFrom(Assembly assembly); } diff --git a/src/Data.MongoDB.DependencyInjection/MongoDBConfigurationBuilder.cs b/src/Data.MongoDB.DependencyInjection/MongoDBConfigurationBuilder.cs index e485a22..44bfaa4 100644 --- a/src/Data.MongoDB.DependencyInjection/MongoDBConfigurationBuilder.cs +++ b/src/Data.MongoDB.DependencyInjection/MongoDBConfigurationBuilder.cs @@ -1,89 +1,123 @@ -using CodeArchitects.Platform.Data.MongoDB.Model; +using CodeArchitects.Platform.Data.MongoDB.Collections; +using CodeArchitects.Platform.Data.MongoDB.Filters; +using CodeArchitects.Platform.Data.MongoDB.Model; using CodeArchitects.Platform.Data.MongoDB.Model.Implementation; -using CodeArchitects.Platform.Data.MongoDB.Query; +using CodeArchitects.Platform.Data.MongoDB.Serialization; +using CodeArchitects.Platform.Data.MongoDB.Transactions; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Conventions; using MongoDB.Driver; -using System.ComponentModel.DataAnnotations.Schema; using System.Reflection; namespace CodeArchitects.Platform.Data.MongoDB.DependencyInjection; -internal class MongoDBConfigurationBuilder : IMongoDBConfigurationBuilder, IMongoDBConfigurationBuilderWithClient, IMongoDBConfigurationBuilderWithDatabase +internal class MongoDBConfigurationBuilder : + IMongoDBConfigurationBuilder, + IMongoDBConfigurationBuilderWithClient, + IMongoDBConfigurationBuilderWithDatabase { - private readonly HashSet _entityAssemblies; - private MongoClient? _client; - private string? _databaseName; - private MongoDatabaseSettings? _settings; - private Type? _seedType; + private readonly DataModelBuilder _modelBuilder = new(); + private readonly List> _conventionConfigurators = new(); + private readonly MongoDBOptions _options = new(); - public MongoDBConfigurationBuilder() + private readonly List _seedTypes = new(); + + private Func? _clientFactory; + + #region Client + + public IMongoDBConfigurationBuilderWithClient UseConnectionString(string connectionString) { - _entityAssemblies = new(); + if (string.IsNullOrWhiteSpace(connectionString)) + throw new ArgumentException("The MongoDB connection string cannot be empty.", nameof(connectionString)); + + MongoClientSettings settings = MongoClientSettings.FromConnectionString(connectionString); + return UseClient(_ => new MongoClient(settings)); } - public void AddServices(IServiceCollection services) + public IMongoDBConfigurationBuilderWithClient UseConnectionString(string connectionString, Action configure) { - if (_client is null) - throw new InvalidOperationException("MongoClient was not configured."); - if (_databaseName is null) - throw new InvalidOperationException("Database was not configured."); + if (string.IsNullOrWhiteSpace(connectionString)) + throw new ArgumentException("The MongoDB connection string cannot be empty.", nameof(connectionString)); + if (configure is null) + throw new ArgumentNullException(nameof(configure)); - DataModel dataModel = new(); - IEnumerable entityTypes = _entityAssemblies - .SelectMany(assebly => assebly.GetTypes()) - .Where(type => type.IsDefined(typeof(TableAttribute), inherit: true)); + MongoClientSettings settings = MongoClientSettings.FromConnectionString(connectionString); + configure(settings); - foreach (Type entityType in entityTypes) - { - dataModel.AddEntity(entityType); - } + return UseClient(_ => new MongoClient(settings)); + } - if (_seedType is not null) - { - services.AddScoped(typeof(DataSeed), _seedType); - } + public IMongoDBConfigurationBuilderWithClient UseClient(IMongoClient client) + { + if (client is null) + throw new ArgumentNullException(nameof(client)); - services.AddSingleton(); - services.AddSingleton(); + return UseClient(_ => client); + } - services.AddSingleton(dataModel); + public IMongoDBConfigurationBuilderWithClient UseClient(Func factory) + { + _clientFactory = factory ?? throw new ArgumentNullException(nameof(factory)); + return this; + } - services.AddSingleton(_client); - services.AddScoped(sp => sp.GetRequiredService().GetDatabase(_databaseName, _settings)); + #endregion - services.AddScoped(); - services.AddScoped(); - services.AddScoped(sp => sp.GetRequiredService()); - services.AddScoped(sp => sp.GetRequiredService()); - services.AddScoped(sp => sp.GetRequiredService().Begin()); + #region Database + + public IMongoDBConfigurationBuilderWithDatabase UseDatabase(string databaseName, MongoDatabaseSettings? settings = null) + { + if (string.IsNullOrWhiteSpace(databaseName)) + throw new ArgumentException("The MongoDB database name cannot be empty.", nameof(databaseName)); + + _options.DatabaseName = databaseName; + _options.DatabaseSettings = settings; + return this; } - public IMongoDBConfigurationBuilderWithDatabase AddEntitiesFrom(Assembly assembly) + #endregion + + #region Model and behaviour + + public IMongoDBConfigurationBuilderWithDatabase AddEntitiesFrom(Assembly assembly, Func? filter = null) { - _entityAssemblies.Add(assembly); + _modelBuilder.AddEntitiesFrom(assembly, filter); return this; } - public IMongoDBConfigurationBuilderWithClient UseClient(MongoClient client) + public IMongoDBConfigurationBuilderWithDatabase AddEntity(Type entityType) { - _client = client; + _modelBuilder.AddEntity(entityType); return this; } - public IMongoDBConfigurationBuilderWithClient UseConnectionString(string connectionString) + public IMongoDBConfigurationBuilderWithDatabase UseTransactions(TransactionMode mode, TransactionOptions? options = null) { - _client = new MongoClient(connectionString); + _options.TransactionMode = mode; + _options.TransactionOptions = options; return this; } - public IMongoDBConfigurationBuilderWithDatabase UseDatabase(string databaseName, MongoDatabaseSettings? settings = null) + public IMongoDBConfigurationBuilderWithDatabase UseGuidRepresentation(GuidRepresentation representation) + { + _options.GuidRepresentation = representation; + return this; + } + + public IMongoDBConfigurationBuilderWithDatabase ConfigureConventions(Action configure) { - _databaseName = databaseName; - _settings = settings; + if (configure is null) + throw new ArgumentNullException(nameof(configure)); + + _conventionConfigurators.Add(configure); return this; } - public IMongoDBConfigurationBuilder UseSeed(Type seedType) + public IMongoDBConfigurationBuilderWithDatabase UseSeed(Type seedType) { if (seedType is null) throw new ArgumentNullException(nameof(seedType)); @@ -91,7 +125,95 @@ public IMongoDBConfigurationBuilder UseSeed(Type seedType) if (!seedType.IsSubclassOf(typeof(DataSeed))) throw new ArgumentException($"Type '{seedType}' does not extend '{nameof(DataSeed)}'."); - _seedType = seedType; + if (!_seedTypes.Contains(seedType)) + { + _seedTypes.Add(seedType); + } + return this; } + + public IMongoDBConfigurationBuilderWithDatabase AddSeedsFrom(Assembly assembly) + { + if (assembly is null) + throw new ArgumentNullException(nameof(assembly)); + + foreach (Type type in assembly.GetTypes()) + { + if (type.IsClass && !type.IsAbstract && !type.IsGenericTypeDefinition && type.IsSubclassOf(typeof(DataSeed))) + { + UseSeed(type); + } + } + + return this; + } + + #endregion + + public void AddServices(IServiceCollection services) + { + // 1. Configuration validation. Everything verifiable without touching the server fails here, + // while the stack trace still points at the composition code. + if (_clientFactory is null) + throw new InvalidOperationException( + "The MongoDB client was not configured. Use UseConnectionString(connectionString) or UseClient(client)."); + + if (string.IsNullOrWhiteSpace(_options.DatabaseName)) + throw new InvalidOperationException("The MongoDB database was not configured. Use UseDatabase(name)."); + + // 2. Model construction and validation: empty model, unresolvable key, collection collisions. + DataModel dataModel = _modelBuilder.Build(); + + // 3. Global serialization setup: must run before any document is serialized, once per process. + MongoDBSerializationInitializer.EnsureInitialized(_options.GuidRepresentation, _conventionConfigurators); + + // 4. Service registrations. + Func clientFactory = _clientFactory; + + services.AddSingleton(_options); + services.AddSingleton(dataModel); + services.AddSingleton(); + + services.AddSingleton(clientFactory); + // IMongoDatabase is thread-safe and immutable: a singleton lets CollectionProvider + // cache collections for the lifetime of the application instead of per request. + services.AddSingleton(sp => sp.GetRequiredService() + .GetDatabase(_options.DatabaseName, _options.DatabaseSettings)); + services.AddSingleton(); + services.AddSingleton(sp => new TransactionCapabilityProbe( + sp.GetRequiredService(), + Logger(sp))); + + // Explicit factory: ILogger is only registered when the application calls AddLogging, + // so fall back to NullLogger instead of making the whole registration fail. + services.AddScoped(sp => new StateManager( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + Logger(sp))); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(sp => sp.GetRequiredService().Begin()); + + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + // The provider-agnostic contract must resolve too: generated code depends on it. + services.AddScoped(sp => sp.GetRequiredService()); + + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + + // One registration per seed: GetServices() then returns them all. + foreach (Type seedType in _seedTypes) + { + services.AddScoped(typeof(DataSeed), seedType); + } + } + + private static ILogger Logger(IServiceProvider services) + { + ILoggerFactory? factory = services.GetService(); + return factory is null ? NullLogger.Instance : new Logger(factory); + } } diff --git a/src/Data.MongoDB.DependencyInjection/MongoDBConfigurationBuilderExtensions.cs b/src/Data.MongoDB.DependencyInjection/MongoDBConfigurationBuilderExtensions.cs index 1f1e2a0..5e44d51 100644 --- a/src/Data.MongoDB.DependencyInjection/MongoDBConfigurationBuilderExtensions.cs +++ b/src/Data.MongoDB.DependencyInjection/MongoDBConfigurationBuilderExtensions.cs @@ -1,7 +1,9 @@ -namespace CodeArchitects.Platform.Data.MongoDB.DependencyInjection; +using CodeArchitects.Platform.Data; + +namespace Microsoft.Extensions.DependencyInjection; /// -/// Extension methods for . +/// Extension methods for . /// public static class MongoDBConfigurationBuilderExtensions { @@ -10,9 +12,9 @@ public static class MongoDBConfigurationBuilderExtensions /// /// The seed type. /// The MongoDB configuration builder. - /// Returns an for further configuration. + /// Returns an for further configuration. /// - public static IMongoDBConfigurationBuilder UseSeed(this IMongoDBConfigurationBuilder builder) + public static IMongoDBConfigurationBuilderWithDatabase UseSeed(this IMongoDBConfigurationBuilderWithDatabase builder) where TDataSeed : DataSeed { if (builder is null) @@ -20,4 +22,20 @@ public static IMongoDBConfigurationBuilder UseSeed(this IMongoDBConfi return builder.UseSeed(typeof(TDataSeed)); } + + /// + /// Registers a single entity type, whether or not it carries a discovery attribute. + /// + /// The entity type. + /// The MongoDB configuration builder. + /// Returns an for further configuration. + /// + public static IMongoDBConfigurationBuilderWithDatabase AddEntity(this IMongoDBConfigurationBuilderWithDatabase builder) + where TEntity : class + { + if (builder is null) + throw new ArgumentNullException(nameof(builder)); + + return builder.AddEntity(typeof(TEntity)); + } } diff --git a/src/Data.MongoDB.DependencyInjection/Properties/AssemblyInfo.cs b/src/Data.MongoDB.DependencyInjection/Properties/AssemblyInfo.cs index 7ad52e3..707c76a 100644 --- a/src/Data.MongoDB.DependencyInjection/Properties/AssemblyInfo.cs +++ b/src/Data.MongoDB.DependencyInjection/Properties/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("CodeArchitects.Platform.Data.MongoDB.UnitTests")] [assembly: InternalsVisibleTo("CodeArchitects.Platform.Data.MongoDB.IntegrationTests")] diff --git a/src/Data.MongoDB.DependencyInjection/README.md b/src/Data.MongoDB.DependencyInjection/README.md index 3a33dca..faf35e0 100644 --- a/src/Data.MongoDB.DependencyInjection/README.md +++ b/src/Data.MongoDB.DependencyInjection/README.md @@ -16,14 +16,41 @@ dotnet add package CodeArchitects.Platform.Data.MongoDB.DependencyInjection ```csharp builder.Services.AddData(cfg => cfg - .UseClient(builder.Configuration.GetConnectionString("Mongo")) - .UseDatabase("mydb")); + .UseConnectionString(builder.Configuration.GetConnectionString("Mongo")!) + .UseDatabase("mydb") + .AddEntitiesFrom(typeof(Product).Assembly)); // register your specialized repositories, e.g.: builder.Services.AddScoped(); ``` The fluent builder guides you from client (`IMongoDBConfigurationBuilderWithClient`) to database -(`IMongoDBConfigurationBuilderWithDatabase`). +(`IMongoDBConfigurationBuilderWithDatabase`), where the entity model and the provider behaviour +are configured. `AddData` lives in the `Microsoft.Extensions.DependencyInjection` namespace, like +the other providers, so no extra `using` is needed. + +Entities are discovered by scanning an assembly for public, concrete classes marked with +`[Collection]` (`[Table]` is also accepted); `AddEntity()` registers one explicitly. The +configuration is validated eagerly — a missing client or database, an empty model, an +unresolvable key or two entities mapped to the same collection all fail at startup, without +contacting the server. + +## Options + +```csharp +builder.Services.AddData(cfg => cfg + .UseClient(sp => sp.GetRequiredService()) // bring your own client + .UseDatabase("mydb") + .AddEntitiesFrom(typeof(Product).Assembly) + .UseTransactions(TransactionMode.Required) // default + .UseGuidRepresentation(GuidRepresentation.Standard) // default + .ConfigureConventions(pack => pack.Add(new CamelCaseElementNameConvention())) + .UseSeed()); +``` + +`UseTransactions` controls what happens when an operation needs a multi-document transaction. +MongoDB provides them only on a replica set or a sharded cluster: with the default `Required` the +operation fails explicitly on a standalone server, `WhenSupported` degrades with a warning, and +`Disabled` never opens one (local development). ## Related packages diff --git a/src/Data.MongoDB/CodeArchitects.Platform.Data.MongoDB.csproj b/src/Data.MongoDB/CodeArchitects.Platform.Data.MongoDB.csproj index 504a214..25b677e 100644 --- a/src/Data.MongoDB/CodeArchitects.Platform.Data.MongoDB.csproj +++ b/src/Data.MongoDB/CodeArchitects.Platform.Data.MongoDB.csproj @@ -1,4 +1,4 @@ - + netstandard2.1 @@ -10,7 +10,10 @@ - + + + + - runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Data.MongoDB.IntegrationTests/Fixtures/Entities.cs b/tests/Data.MongoDB.IntegrationTests/Fixtures/Entities.cs new file mode 100644 index 0000000..18a552c --- /dev/null +++ b/tests/Data.MongoDB.IntegrationTests/Fixtures/Entities.cs @@ -0,0 +1,92 @@ +using Bogus; +using MongoDB.Driver; + +namespace CodeArchitects.Platform.Data.MongoDB.Fixtures; + +/// +/// An aggregate root whose children are embedded in the same document: no second collection, +/// no Include, and the whole aggregate is written atomically by construction. +/// +[CodeArchitects.Platform.Data.MongoDB.Collection("carts")] +public class Cart +{ + public Guid Id { get; set; } + public string? Owner { get; set; } + public List Items { get; set; } = []; + + private static readonly Faker s_faker = new Faker() + .RuleFor(cart => cart.Id, Guid.NewGuid) + .RuleFor(cart => cart.Owner, faker => faker.Name.FullName()) + .RuleFor(cart => cart.Items, faker => [.. Enumerable.Range(0, 2).Select(_ => CartItem.One())]); + + public static Cart One() => s_faker.Generate(); +} + +/// Embedded, not an entity: it has no collection and needs no key. +public class CartItem +{ + public string? Sku { get; set; } + public int Quantity { get; set; } + + private static readonly Faker s_faker = new Faker() + .RuleFor(item => item.Sku, faker => faker.Commerce.Ean13()) + .RuleFor(item => item.Quantity, faker => faker.Random.Int(1, 5)); + + public static CartItem One() => s_faker.Generate(); +} + +/// The persisted shape: only this one is registered in the model. +[CodeArchitects.Platform.Data.MongoDB.Collection("orders")] +public class OrderDocument +{ + public Guid Id { get; set; } + public string? Code { get; set; } + public decimal Total { get; set; } +} + +/// The domain shape, deliberately different from the document. +public class Order +{ + public Guid Id { get; set; } + public string Reference { get; set; } = string.Empty; + public decimal Amount { get; set; } + + public static Order One() => new() + { + Id = Guid.NewGuid(), + Reference = $"ORD-{Random.Shared.Next(1000, 9999)}", + Amount = Random.Shared.Next(10, 500) + }; +} + +/// Maps by hand, to keep the test independent of any mapping library. +public class OrderRepository(IDataContext context) : MongoDBMappedRepository(context) +{ + /// Surfaces the protected members a derived repository would use for custom queries. + public IMongoCollection ExposedDocuments => Documents; + + public IClientSessionHandle ExposedSession => Session; + + protected override Order TableToEntity(OrderDocument document) => new() + { + Id = document.Id, + Reference = document.Code ?? string.Empty, + Amount = document.Total + }; + + protected override OrderDocument EntityToTable(Order entity) => new() + { + Id = entity.Id, + Code = entity.Reference, + Total = entity.Amount + }; +} + +/// Used by the seeding tests. +[SeedOrder(1)] +public sealed class CustomerSeed : DataSeed +{ + public static readonly Customer[] Customers = [Customer.One(), Customer.One()]; + + public override void Seed(ISeeder seeder) => seeder.Seed(Customers); +} diff --git a/tests/Data.MongoDB.IntegrationTests/Fixtures/TestFixture.cs b/tests/Data.MongoDB.IntegrationTests/Fixtures/TestFixture.cs index 6b73608..0bc1654 100644 --- a/tests/Data.MongoDB.IntegrationTests/Fixtures/TestFixture.cs +++ b/tests/Data.MongoDB.IntegrationTests/Fixtures/TestFixture.cs @@ -1,70 +1,126 @@ -using CodeArchitects.Platform.Data.MongoDB.DependencyInjection; using Microsoft.Extensions.DependencyInjection; -using Mongo2Go; using MongoDB.Driver; +using Testcontainers.MongoDb; namespace CodeArchitects.Platform.Data.MongoDB.Fixtures; -public sealed class TestFixture : IDisposable +/// +/// Starts two MongoDB deployments: a single-node replica set, which supports transactions, and a +/// standalone server, which does not. The second one is what makes the behaviour of +/// verifiable. +/// +public sealed class TestFixture : IAsyncLifetime { - private const string s_databaseName = "customers"; + private const string DatabaseName = "customers"; + private const string Image = "mongo:7.0"; - private readonly MongoDbRunner _runner; - private readonly IServiceProvider _services; - private readonly IDataContext _context; + private readonly MongoDbContainer _replicaSet = new MongoDbBuilder() + .WithImage(Image) + .WithReplicaSet() + .Build(); - public TestFixture() - { - _runner = MongoDbRunner.Start(singleNodeReplSet: true); + private readonly MongoDbContainer _standalone = new MongoDbBuilder() + .WithImage(Image) + .Build(); - _services = new ServiceCollection() - .AddData(options => options - .UseConnectionString(_runner.ConnectionString) - .UseDatabase(s_databaseName) - .AddEntitiesFrom(typeof(Customer).Assembly)) - .BuildServiceProvider(); - _context = _services.GetRequiredService(); - } + private ServiceProvider _services = default!; + private ServiceProvider _standaloneServices = default!; - public MongoDBRepository CreateRepository() - where TEntity : class - where TKey : IEquatable + public async Task InitializeAsync() { - return new MongoDBRepository(_context); + await Task.WhenAll(_replicaSet.StartAsync(), _standalone.StartAsync()); + + _services = BuildServices(_replicaSet.GetConnectionString()); + _standaloneServices = BuildServices(_standalone.GetConnectionString()); } - public MongoDBRepository CreateRepository(IEnumerable seed) - where TEntity : class - where TKey : IEquatable + public async Task DisposeAsync() { - Seeder seeder = new(_context); - seeder.Seed(seed); - return new MongoDBRepository(_context); + await _services.DisposeAsync(); + await _standaloneServices.DisposeAsync(); + await _replicaSet.DisposeAsync(); + await _standalone.DisposeAsync(); } + private static ServiceProvider BuildServices(string connectionString) => + new ServiceCollection() + .AddData(options => options + .UseConnectionString(connectionString) + .UseDatabase(DatabaseName) + .AddEntitiesFrom(typeof(Customer).Assembly) + .UseTransactions(TransactionMode.Required)) + .BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + + /// + /// A scope against the replica set: every test works in its own scope, as a request would. + /// + public TestScope CreateScope() => new(_services.CreateScope()); + + /// + /// A scope against the standalone server, where transactions are unavailable. + /// + public TestScope CreateStandaloneScope() => new(_standaloneServices.CreateScope()); + + /// + /// Reads a collection outside any provider scope, to assert on what was really persisted. + /// public IMongoCollection GetCollection() where TEntity : class { - return _context.GetCollection(); + using TestScope scope = CreateScope(); + string name = scope.Context.GetCollection().CollectionNamespace.CollectionName; + + return _services.GetRequiredService().GetCollection(name); } - public IUnitOfWorkManager GetUnitOfWorkManager() + /// + /// Seeds through the real seeding path: enqueue on the state manager, then commit. + /// + public async Task SeedAsync(IEnumerable entities) + where TEntity : class { - return _services.GetRequiredService(); + using TestScope scope = CreateScope(); + await scope.Seeder.ApplyAsync([new InlineSeed(entities)]); } - public void Dispose() + public async Task ResetAsync() { - _runner.Dispose(); + IMongoDatabase database = _services.GetRequiredService(); + IAsyncCursor names = await database.ListCollectionNamesAsync(); + + await names.ForEachAsync(collection => database.DropCollectionAsync(collection)); } - public async Task ResetAsync() + private sealed class InlineSeed(IEnumerable entities) : DataSeed + where TEntity : class { - IMongoDatabase database = _context.Database; - IAsyncCursor collectionNames = await database.ListCollectionNamesAsync(); - await collectionNames.ForEachAsync(async collection => - { - await database.DropCollectionAsync(collection); - }); + public override void Seed(ISeeder seeder) => seeder.Seed(entities); } } + +/// +/// One dependency-injection scope, with the services a test needs. +/// +public sealed class TestScope : IDisposable +{ + private readonly IServiceScope _scope; + + internal TestScope(IServiceScope scope) => _scope = scope; + + public IDataContext Context => _scope.ServiceProvider.GetRequiredService(); + + public IUnitOfWorkManager UnitOfWorkManager => _scope.ServiceProvider.GetRequiredService(); + + internal Seeder Seeder => _scope.ServiceProvider.GetRequiredService(); + + public MongoDBRepository Repository() + where TEntity : class + where TKey : IEquatable + => new(Context); + + public IMongoCollection Collection() + where TEntity : class + => Context.GetCollection(); + + public void Dispose() => _scope.Dispose(); +} diff --git a/tests/Data.MongoDB.IntegrationTests/MappedRepositoryTests.cs b/tests/Data.MongoDB.IntegrationTests/MappedRepositoryTests.cs new file mode 100644 index 0000000..6bf4fd1 --- /dev/null +++ b/tests/Data.MongoDB.IntegrationTests/MappedRepositoryTests.cs @@ -0,0 +1,112 @@ +using CodeArchitects.Platform.Data.MongoDB.Fixtures; +using MongoDB.Driver; +using System.Data; + +namespace CodeArchitects.Platform.Data.MongoDB; + +/// +/// The mapped repository used to be unusable: Collection asked for the collection of the +/// domain entity, which is not registered in the model, so every access threw. +/// +[Xunit.Collection(TestCollection.Name)] +public class MappedRepositoryTests(TestFixture fixture) : TestBase(fixture) +{ + [Fact] + public void Collection_ShouldResolveTheDocumentCollection() + { + // Arrange + using TestScope scope = _fixture.CreateScope(); + OrderRepository repository = new(scope.Context); + + // Act + Action act = () => _ = repository.ExposedDocuments; + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public async Task InsertAsync_ShouldPersistTheDocumentShape() + { + // Arrange + Order order = Order.One(); + using TestScope scope = _fixture.CreateScope(); + OrderRepository repository = new(scope.Context); + + // Act + await repository.InsertAsync(order); + + // Assert + // What is stored is the document, with its own field names. + OrderDocument? document = await _fixture.GetCollection() + .Find(x => x.Id == order.Id).FirstOrDefaultAsync(); + + document.Should().NotBeNull(); + document!.Code.Should().Be(order.Reference); + document.Total.Should().Be(order.Amount); + } + + [Fact] + public async Task FindAsync_ShouldReturnTheDomainShape() + { + // Arrange + Order expected = Order.One(); + using TestScope scope = _fixture.CreateScope(); + OrderRepository repository = new(scope.Context); + await repository.InsertAsync(expected); + + // Act + Order? order = await repository.FindAsync(expected.Id); + + // Assert + order.Should().BeEquivalentTo(expected); + } + + [Fact] + public async Task UpdateAsync_ShouldRoundTripThroughTheMapping() + { + // Arrange + Order order = Order.One(); + using TestScope scope = _fixture.CreateScope(); + OrderRepository repository = new(scope.Context); + await repository.InsertAsync(order); + + // Act + order.Reference = "ORD-0001"; + await repository.UpdateAsync(order); + + // Assert + Order? updated = await repository.FindAsync(order.Id); + updated!.Reference.Should().Be("ORD-0001"); + } + + [Fact] + public async Task RemoveAsync_ShouldDeleteByKey() + { + // Arrange + Order order = Order.One(); + using TestScope scope = _fixture.CreateScope(); + OrderRepository repository = new(scope.Context); + await repository.InsertAsync(order); + + // Act + await repository.RemoveAsync(order.Id); + + // Assert + (await repository.FindAsync(order.Id)).Should().BeNull(); + } + + [Fact] + public async Task UpdateAsync_ShouldThrow_WhenTheDocumentDoesNotExist() + { + // Arrange + using TestScope scope = _fixture.CreateScope(); + OrderRepository repository = new(scope.Context); + + // Act + Func act = () => repository.UpdateAsync(Order.One()); + + // Assert + await act.Should().ThrowAsync(); + } +} diff --git a/tests/Data.MongoDB.IntegrationTests/RepositoryTests.cs b/tests/Data.MongoDB.IntegrationTests/RepositoryTests.cs index 0cf77d9..12411ad 100644 --- a/tests/Data.MongoDB.IntegrationTests/RepositoryTests.cs +++ b/tests/Data.MongoDB.IntegrationTests/RepositoryTests.cs @@ -1,124 +1,219 @@ -using CodeArchitects.Platform.Data.MongoDB.Fixtures; +using CodeArchitects.Platform.Data.MongoDB.Fixtures; using MongoDB.Driver; using System.Data; namespace CodeArchitects.Platform.Data.MongoDB; -[Collection(TestCollection.Name)] -public class RepositoryTests : TestBase +[Xunit.Collection(TestCollection.Name)] +public class RepositoryTests(TestFixture fixture) : TestBase(fixture) { - private readonly Customer[] _seed; + private static MongoDBRepository Repository(TestScope scope) => scope.Repository(); - public RepositoryTests(TestFixture fixture) : base(fixture) - { - _seed = new Customer[] - { - Customer.One() - }; - } + #region Find [Fact] public async Task FindAsync_ShouldReturnEntity_WhenEntityExists() { // Arrange - MongoDBRepository sut = _fixture.CreateRepository(_seed); + Customer expected = Customer.One(); + await _fixture.SeedAsync([expected]); + using TestScope scope = _fixture.CreateScope(); // Act - Customer? customer = await sut.FindAsync(_seed[0].Id); + Customer? customer = await Repository(scope).FindAsync(expected.Id); // Assert - customer.Should().NotBeNull(); - customer.Should().BeEquivalentTo(_seed[0]); + customer.Should().BeEquivalentTo(expected); } [Fact] public async Task FindAsync_ShouldReturnNull_WhenEntityDoesNotExist() { // Arrange - MongoDBRepository sut = _fixture.CreateRepository(); + using TestScope scope = _fixture.CreateScope(); // Act - Customer? customer = await sut.FindAsync(_seed[0].Id); + Customer? customer = await Repository(scope).FindAsync(Guid.NewGuid()); // Assert customer.Should().BeNull(); } + [Fact] + public async Task FindAsync_ShouldThrow_WhenIncludeIsRequested() + { + // Arrange + using TestScope scope = _fixture.CreateScope(); + + // Act + Func act = () => Repository(scope).FindAsync(Guid.NewGuid(), _ => { }); + + // Assert + // No silent degradation: Include is not supported yet and says so. + await act.Should().ThrowAsync(); + } + + #endregion + + #region Insert + [Fact] public async Task InsertAsync_ShouldInsertEntity_WhenEntityDoesNotExist() { // Arrange - MongoDBRepository sut = _fixture.CreateRepository(); - IMongoCollection collection = _fixture.GetCollection(); + Customer expected = Customer.One(); + using TestScope scope = _fixture.CreateScope(); // Act - await sut.InsertAsync(_seed[0]); - Customer? customer = await collection.Find(x => x.Id == _seed[0].Id).FirstOrDefaultAsync(); + await Repository(scope).InsertAsync(expected); // Assert - customer.Should().NotBeNull(); - customer.Should().BeEquivalentTo(_seed[0]); + Customer? customer = await _fixture.GetCollection() + .Find(x => x.Id == expected.Id).FirstOrDefaultAsync(); + customer.Should().BeEquivalentTo(expected); } [Fact] public async Task InsertAsync_ShouldThrow_WhenEntityAlreadyExists() { // Arrange - MongoDBRepository sut = _fixture.CreateRepository(_seed); + Customer existing = Customer.One(); + await _fixture.SeedAsync([existing]); + using TestScope scope = _fixture.CreateScope(); // Act - Func act = () => sut.InsertAsync(_seed[0]); + Func act = () => Repository(scope).InsertAsync(existing); // Assert await act.Should().ThrowAsync(); } + [Fact] + public async Task InsertManyAsync_ShouldInsertEveryEntity() + { + // Arrange + Customer[] expected = [Customer.One(), Customer.One(), Customer.One()]; + using TestScope scope = _fixture.CreateScope(); + + // Act + await Repository(scope).InsertManyAsync(expected); + + // Assert + List customers = await _fixture.GetCollection() + .Find(FilterDefinition.Empty).ToListAsync(); + customers.Should().BeEquivalentTo(expected); + } + + #endregion + + #region Update + [Fact] public async Task UpdateAsync_ShouldUpdateEntity_WhenEntityExists() { // Arrange - Customer expected = _seed[0]; - MongoDBRepository sut = _fixture.CreateRepository(_seed); - IMongoCollection collection = _fixture.GetCollection(); + Customer expected = Customer.One(); + await _fixture.SeedAsync([expected]); + using TestScope scope = _fixture.CreateScope(); // Act expected.Name = "ModifiedName"; - await sut.UpdateAsync(expected); - Customer? customer = await collection.Find(x => x.Id == expected.Id).FirstOrDefaultAsync(); + await Repository(scope).UpdateAsync(expected); // Assert - customer.Should().NotBeNull(); + Customer? customer = await _fixture.GetCollection() + .Find(x => x.Id == expected.Id).FirstOrDefaultAsync(); customer.Should().BeEquivalentTo(expected); } + [Fact] + public async Task UpdateAsync_ShouldNotThrow_WhenNothingChanged() + { + // Arrange + Customer existing = Customer.One(); + await _fixture.SeedAsync([existing]); + using TestScope scope = _fixture.CreateScope(); + + // Act + Func act = () => Repository(scope).UpdateAsync(existing); + + // Assert + // A replace that matches but rewrites nothing reports ModifiedCount == 0. The success + // criterion is MatchedCount, otherwise an idempotent update would look like a conflict. + await act.Should().NotThrowAsync(); + } + [Fact] public async Task UpdateAsync_ShouldThrow_WhenEntityDoesNotExist() { // Arrange - MongoDBRepository sut = _fixture.CreateRepository(); + using TestScope scope = _fixture.CreateScope(); + + // Act + Func act = () => Repository(scope).UpdateAsync(Customer.One()); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task UpdateManyAsync_ShouldUpdateEveryEntity() + { + // Arrange + Customer[] seed = [Customer.One(), Customer.One()]; + await _fixture.SeedAsync(seed); + using TestScope scope = _fixture.CreateScope(); + + // Act + seed[0].Name = "First"; + seed[1].Name = "Second"; + await Repository(scope).UpdateManyAsync(seed); + + // Assert + List customers = await _fixture.GetCollection() + .Find(FilterDefinition.Empty).ToListAsync(); + customers.Select(customer => customer.Name).Should().BeEquivalentTo(["First", "Second"]); + } + + [Fact] + public async Task UpdateManyAsync_ShouldThrowAndApplyNothing_WhenOneEntityDoesNotExist() + { + // Arrange + Customer existing = Customer.One(); + await _fixture.SeedAsync([existing]); + using TestScope scope = _fixture.CreateScope(); // Act - Func act = () => sut.UpdateAsync(_seed[0]); + existing.Name = "Modified"; + Func act = () => Repository(scope).UpdateManyAsync([existing, Customer.One()]); // Assert - await act.Should().ThrowAsync("Object not updated."); + await act.Should().ThrowAsync(); + + Customer? customer = await _fixture.GetCollection() + .Find(x => x.Id == existing.Id).FirstOrDefaultAsync(); + customer!.Name.Should().NotBe("Modified", "the bulk write runs inside a transaction"); } + #endregion + + #region Upsert + [Fact] public async Task UpsertAsync_ShouldUpdateEntity_WhenEntityExists() { // Arrange - Customer expected = _seed[0]; - MongoDBRepository sut = _fixture.CreateRepository(_seed); - IMongoCollection collection = _fixture.GetCollection(); + Customer expected = Customer.One(); + await _fixture.SeedAsync([expected]); + using TestScope scope = _fixture.CreateScope(); // Act expected.Name = "ModifiedName"; - await sut.UpsertAsync(expected); - Customer? customer = await collection.Find(x => x.Id == expected.Id).FirstOrDefaultAsync(); + await Repository(scope).UpsertAsync(expected); // Assert - customer.Should().NotBeNull(); + Customer? customer = await _fixture.GetCollection() + .Find(x => x.Id == expected.Id).FirstOrDefaultAsync(); customer.Should().BeEquivalentTo(expected); } @@ -126,71 +221,109 @@ public async Task UpsertAsync_ShouldUpdateEntity_WhenEntityExists() public async Task UpsertAsync_ShouldInsertEntity_WhenEntityDoesNotExist() { // Arrange - MongoDBRepository sut = _fixture.CreateRepository(); - IMongoCollection collection = _fixture.GetCollection(); + Customer expected = Customer.One(); + using TestScope scope = _fixture.CreateScope(); // Act - await sut.UpsertAsync(_seed[0]); - Customer? customer = await collection.Find(x => x.Id == _seed[0].Id).FirstOrDefaultAsync(); + await Repository(scope).UpsertAsync(expected); // Assert - customer.Should().NotBeNull(); - customer.Should().BeEquivalentTo(_seed[0]); + Customer? customer = await _fixture.GetCollection() + .Find(x => x.Id == expected.Id).FirstOrDefaultAsync(); + customer.Should().BeEquivalentTo(expected); } + #endregion + + #region Remove + [Fact] public async Task RemoveAsync_ShouldRemoveEntity_WhenEntityExists() { // Arrange - MongoDBRepository sut = _fixture.CreateRepository(_seed); - IMongoCollection collection = _fixture.GetCollection(); + Customer existing = Customer.One(); + await _fixture.SeedAsync([existing]); + using TestScope scope = _fixture.CreateScope(); // Act - await sut.RemoveAsync(_seed[0]); - Customer? customer = await collection.Find(x => x.Id == _seed[0].Id).FirstOrDefaultAsync(); + await Repository(scope).RemoveAsync(existing); // Assert + Customer? customer = await _fixture.GetCollection() + .Find(x => x.Id == existing.Id).FirstOrDefaultAsync(); customer.Should().BeNull(); } [Fact] - public async Task RemoveAsync_ShouldThrow_WhenEntityNotExists() + public async Task RemoveAsync_ShouldRemoveEntity_WhenEntityKeyExists() { // Arrange - MongoDBRepository sut = _fixture.CreateRepository(); + Customer existing = Customer.One(); + await _fixture.SeedAsync([existing]); + using TestScope scope = _fixture.CreateScope(); // Act - Func act = () => sut.RemoveAsync(_seed[0]); + await Repository(scope).RemoveAsync(existing.Id); + + // Assert + Customer? customer = await _fixture.GetCollection() + .Find(x => x.Id == existing.Id).FirstOrDefaultAsync(); + customer.Should().BeNull(); + } + + [Fact] + public async Task RemoveAsync_ShouldThrow_WhenEntityDoesNotExist() + { + // Arrange + using TestScope scope = _fixture.CreateScope(); + + // Act + Func act = () => Repository(scope).RemoveAsync(Guid.NewGuid()); // Assert await act.Should().ThrowAsync(); } + #endregion + + #region Aggregates + [Fact] - public async Task RemoveAsync_ShouldRemoveEntity_WhenEntityKeyExists() + public async Task Repository_ShouldRoundTripAnAggregate_WithEmbeddedDocuments() { // Arrange - MongoDBRepository sut = _fixture.CreateRepository(_seed); - IMongoCollection collection = _fixture.GetCollection(); + Cart expected = Cart.One(); + using TestScope scope = _fixture.CreateScope(); + MongoDBRepository repository = scope.Repository(); // Act - await sut.RemoveAsync(_seed[0].Id); - Customer? customer = await collection.Find(x => x.Id == _seed[0].Id).FirstOrDefaultAsync(); + await repository.InsertAsync(expected); + Cart? cart = await repository.FindAsync(expected.Id); // Assert - customer.Should().BeNull(); + // Intra-aggregate children live in the same document: no Include, and the whole aggregate + // is written in a single atomic operation. + cart.Should().BeEquivalentTo(expected); + cart!.Items.Should().HaveCount(expected.Items.Count); } [Fact] - public async Task RemoveAsync_ShouldThrow_WhenEntityKeyNotExists() + public async Task Repository_ShouldReplaceTheWholeAggregate_OnUpdate() { // Arrange - MongoDBRepository sut = _fixture.CreateRepository(); + Cart cart = Cart.One(); + using TestScope scope = _fixture.CreateScope(); + MongoDBRepository repository = scope.Repository(); + await repository.InsertAsync(cart); // Act - Func act = () => sut.RemoveAsync(_seed[0].Id); + cart.Items.RemoveAt(0); + await repository.UpdateAsync(cart); // Assert - await act.Should().ThrowAsync(); + Cart? updated = await repository.FindAsync(cart.Id); + updated!.Items.Should().HaveCount(cart.Items.Count); } + + #endregion } diff --git a/tests/Data.MongoDB.IntegrationTests/SeedingTests.cs b/tests/Data.MongoDB.IntegrationTests/SeedingTests.cs new file mode 100644 index 0000000..2ad43f3 --- /dev/null +++ b/tests/Data.MongoDB.IntegrationTests/SeedingTests.cs @@ -0,0 +1,75 @@ +using CodeArchitects.Platform.Data.MongoDB.Fixtures; +using MongoDB.Driver; + +namespace CodeArchitects.Platform.Data.MongoDB; + +[Xunit.Collection(TestCollection.Name)] +public class SeedingTests(TestFixture fixture) : TestBase(fixture) +{ + [Fact] + public async Task Seeding_ShouldPopulateAnEmptyCollection() + { + // Arrange + Customer[] seed = [Customer.One(), Customer.One()]; + + // Act + await _fixture.SeedAsync(seed); + + // Assert + List customers = await _fixture.GetCollection() + .Find(FilterDefinition.Empty).ToListAsync(); + customers.Should().BeEquivalentTo(seed); + } + + [Fact] + public async Task Seeding_ShouldBeIdempotent() + { + // Arrange + Customer[] seed = [Customer.One(), Customer.One()]; + await _fixture.SeedAsync(seed); + + // Act + await _fixture.SeedAsync([Customer.One(), Customer.One(), Customer.One()]); + + // Assert + // Idempotency is per collection: a non-empty collection is left untouched, whatever the + // second seed contains. + IMongoCollection collection = _fixture.GetCollection(); + (await collection.CountDocumentsAsync(FilterDefinition.Empty)).Should().Be(2); + } + + [Fact] + public async Task Seeding_ShouldApplyNothing_WhenOneCollectionFails() + { + // Arrange + Customer duplicate = Customer.One(); + + // Act + Func act = () => _fixture.SeedAsync([duplicate, duplicate]); + + // Assert + await act.Should().ThrowAsync(); + + // Every seed is committed as a single unit: a failing one must not leave the database + // half populated. + IMongoCollection collection = _fixture.GetCollection(); + (await collection.CountDocumentsAsync(FilterDefinition.Empty)).Should().Be(0); + } + + [Fact] + public async Task Seeding_ShouldSpanSeveralCollections_InOneCommit() + { + // Arrange + using TestScope scope = _fixture.CreateScope(); + + // Act + await _fixture.SeedAsync([Customer.One()]); + await _fixture.SeedAsync([Cart.One()]); + + // Assert + (await _fixture.GetCollection().CountDocumentsAsync(FilterDefinition.Empty)) + .Should().Be(1); + (await _fixture.GetCollection().CountDocumentsAsync(FilterDefinition.Empty)) + .Should().Be(1); + } +} diff --git a/tests/Data.MongoDB.IntegrationTests/TransactionTopologyTests.cs b/tests/Data.MongoDB.IntegrationTests/TransactionTopologyTests.cs new file mode 100644 index 0000000..8365b9b --- /dev/null +++ b/tests/Data.MongoDB.IntegrationTests/TransactionTopologyTests.cs @@ -0,0 +1,79 @@ +using CodeArchitects.Platform.Data.MongoDB.Fixtures; + +namespace CodeArchitects.Platform.Data.MongoDB; + +/// +/// Verifies that the provider tells the truth about transactions instead of silently running +/// without them. +/// +[Xunit.Collection(TestCollection.Name)] +public class TransactionTopologyTests(TestFixture fixture) : TestBase(fixture) +{ + [Fact] + public async Task SingleWrite_ShouldSucceed_OnAStandaloneServer() + { + // Arrange + using TestScope scope = _fixture.CreateStandaloneScope(); + MongoDBRepository repository = scope.Repository(); + + // Act + Func act = () => repository.InsertAsync(Customer.One()); + + // Assert + // A write on a single document is already atomic: no transaction is needed, so the topology + // never comes into play. + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task MultipleWrites_ShouldThrow_OnAStandaloneServer() + { + // Arrange + using TestScope scope = _fixture.CreateStandaloneScope(); + MongoDBRepository repository = scope.Repository(); + + // Act + Func act = () => repository.InsertManyAsync([Customer.One(), Customer.One()]); + + // Assert + // InsertMany is only atomic inside a transaction, and a standalone server cannot provide + // one. With TransactionMode.Required the provider refuses rather than degrading silently. + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task UnitOfWork_ShouldThrow_OnAStandaloneServer() + { + // Arrange + using TestScope scope = _fixture.CreateStandaloneScope(); + MongoDBRepository repository = scope.Repository(); + + // Act + Func act = async () => + { + await using IUnitOfWork unitOfWork = scope.UnitOfWorkManager.Begin(); + + await repository.InsertAsync(Customer.One()); + await repository.InsertAsync(Customer.One()); + + await unitOfWork.SaveAsync(); + }; + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task MultipleWrites_ShouldSucceed_OnAReplicaSet() + { + // Arrange + using TestScope scope = _fixture.CreateScope(); + MongoDBRepository repository = scope.Repository(); + + // Act + Func act = () => repository.InsertManyAsync([Customer.One(), Customer.One()]); + + // Assert + await act.Should().NotThrowAsync(); + } +} diff --git a/tests/Data.MongoDB.IntegrationTests/UnitOfWorkTests.cs b/tests/Data.MongoDB.IntegrationTests/UnitOfWorkTests.cs index e668eee..acbba30 100644 --- a/tests/Data.MongoDB.IntegrationTests/UnitOfWorkTests.cs +++ b/tests/Data.MongoDB.IntegrationTests/UnitOfWorkTests.cs @@ -1,97 +1,146 @@ -using CodeArchitects.Platform.Data.MongoDB.Fixtures; +using CodeArchitects.Platform.Data.MongoDB.Fixtures; using MongoDB.Driver; namespace CodeArchitects.Platform.Data.MongoDB; -[Collection(TestCollection.Name)] -public class UnitOfWorkTests : TestBase +[Xunit.Collection(TestCollection.Name)] +public class UnitOfWorkTests(TestFixture fixture) : TestBase(fixture) { - private readonly Customer[] _seed; + [Fact] + public void Context_ShouldExposeTheSameSession_WithinAScope() + { + // Arrange + using TestScope scope = _fixture.CreateScope(); - public UnitOfWorkTests(TestFixture fixture) : base(fixture) + // Act + IClientSessionHandle first = scope.Context.Session; + IClientSessionHandle second = scope.Context.Session; + + // Assert + // Every operation of the scope — reads included — must run on this one session, + // otherwise it cannot take part in the transaction opened at commit time. + second.Should().BeSameAs(first); + } + + [Fact] + public void Context_ShouldExposeADifferentSession_PerScope() { - _seed = new Customer[] - { - Customer.One(), - Customer.One() - }; + // Arrange + using TestScope first = _fixture.CreateScope(); + using TestScope second = _fixture.CreateScope(); + + // Act & Assert + second.Context.Session.Should().NotBeSameAs(first.Context.Session); } [Fact] - public async Task InsertAsync_ShouldInsertEntity_WhenEntityNotExists() + public async Task UnitOfWork_ShouldCommitEveryOperation_WhenSaveSucceeds() { // Arrange - MongoDBRepository sut = _fixture.CreateRepository(); - IUnitOfWorkManager uowManager = _fixture.GetUnitOfWorkManager(); - IMongoCollection collection = _fixture.GetCollection(); + using TestScope scope = _fixture.CreateScope(); + MongoDBRepository repository = scope.Repository(); + Customer first = Customer.One(); + Customer second = Customer.One(); // Act - await using (IUnitOfWork uow = uowManager.Begin(autoSave: true)) + await using (IUnitOfWork unitOfWork = scope.UnitOfWorkManager.Begin()) { - await sut.InsertAsync(_seed[0]); - await sut.InsertAsync(_seed[1]); - } + await repository.InsertAsync(first); + await repository.InsertAsync(second); - Customer? customer1 = await collection.Find(x => x.Id == _seed[0].Id).FirstOrDefaultAsync(); - Customer? customer2 = await collection.Find(x => x.Id == _seed[1].Id).FirstOrDefaultAsync(); + await unitOfWork.SaveAsync(); + } // Assert - customer1.Should().NotBeNull(); - customer1.Should().BeEquivalentTo(_seed[0]); - customer2.Should().NotBeNull(); - customer2.Should().BeEquivalentTo(_seed[1]); + IMongoCollection collection = _fixture.GetCollection(); + (await collection.CountDocumentsAsync(FilterDefinition.Empty)).Should().Be(2); } [Fact] - public async Task UpdateAsync_ShouldUpdateEntity_WhenEntityExists() + public async Task UnitOfWork_ShouldDeferEveryWrite_UntilSave() { // Arrange - MongoDBRepository sut = _fixture.CreateRepository(_seed); - IUnitOfWorkManager uowManager = _fixture.GetUnitOfWorkManager(); + using TestScope scope = _fixture.CreateScope(); + MongoDBRepository repository = scope.Repository(); IMongoCollection collection = _fixture.GetCollection(); - Customer expected1 = _seed[0]; - Customer expected2 = _seed[1]; // Act - expected1.Name = "ModifiedName1"; - expected2.Name = "ModifiedName2"; + await using IUnitOfWork unitOfWork = scope.UnitOfWorkManager.Begin(); + await repository.InsertAsync(Customer.One()); - await using (IUnitOfWork uow = uowManager.Begin(autoSave: true)) + // Assert + // Inside a unit of work the write is queued, not applied: nothing is visible yet. + (await collection.CountDocumentsAsync(FilterDefinition.Empty)).Should().Be(0); + + await unitOfWork.SaveAsync(); + (await collection.CountDocumentsAsync(FilterDefinition.Empty)).Should().Be(1); + } + + [Fact] + public async Task UnitOfWork_ShouldRollBackEveryOperation_WhenOneFails() + { + // Arrange + using TestScope scope = _fixture.CreateScope(); + MongoDBRepository repository = scope.Repository(); + Customer valid = Customer.One(); + Customer duplicate = Customer.One(); + + // Act + Func act = async () => { - await sut.UpdateAsync(expected1); - await sut.UpdateAsync(expected2); - } + await using IUnitOfWork unitOfWork = scope.UnitOfWorkManager.Begin(); - Customer? customer1 = await collection.Find(x => x.Id == expected1.Id).FirstOrDefaultAsync(); - Customer? customer2 = await collection.Find(x => x.Id == expected2.Id).FirstOrDefaultAsync(); + await repository.InsertAsync(valid); + await repository.InsertAsync(duplicate); + await repository.InsertAsync(duplicate); // duplicate key: fails at commit + + await unitOfWork.SaveAsync(); + }; // Assert - customer1.Should().NotBeNull(); - customer1.Should().BeEquivalentTo(expected1); - customer2.Should().NotBeNull(); - customer2.Should().BeEquivalentTo(expected2); + await act.Should().ThrowAsync(); + + // This is the test the previous implementation did not have: the transaction was opened on a + // session no operation used, so the writes went through anyway and nothing rolled back. + IMongoCollection collection = _fixture.GetCollection(); + (await collection.CountDocumentsAsync(FilterDefinition.Empty)).Should().Be(0); } [Fact] - public async Task RemoveAsync_ShouldRemoveEntity_WhenEntityExists() + public async Task UnitOfWork_ShouldNotReplayOperations_WhenSaveIsCalledTwice() { // Arrange - MongoDBRepository sut = _fixture.CreateRepository(_seed); - IUnitOfWorkManager uowManager = _fixture.GetUnitOfWorkManager(); + using TestScope scope = _fixture.CreateScope(); + MongoDBRepository repository = scope.Repository(); + + // Act + await using IUnitOfWork unitOfWork = scope.UnitOfWorkManager.Begin(); + await repository.InsertAsync(Customer.One()); + await unitOfWork.SaveAsync(); + await unitOfWork.SaveAsync(); + + // Assert + // The queue is drained before the commit: a second Save must not rewrite the same document. IMongoCollection collection = _fixture.GetCollection(); + (await collection.CountDocumentsAsync(FilterDefinition.Empty)).Should().Be(1); + } + + [Fact] + public async Task UnitOfWork_ShouldCommitOnDispose_WhenAutoSaveIsOn() + { + // Arrange + using TestScope scope = _fixture.CreateScope(); + MongoDBRepository repository = scope.Repository(); // Act - await using (IUnitOfWork uow = uowManager.Begin(autoSave: true)) + await using (IUnitOfWork unitOfWork = scope.UnitOfWorkManager.Begin(autoSave: true)) { - await sut.RemoveAsync(_seed[0]); - await sut.RemoveAsync(_seed[1]); + await repository.InsertAsync(Customer.One()); + await repository.InsertAsync(Customer.One()); } - Customer? customer1 = await collection.Find(x => x.Id == _seed[0].Id).FirstOrDefaultAsync(); - Customer? customer2 = await collection.Find(x => x.Id == _seed[1].Id).FirstOrDefaultAsync(); - // Assert - customer1.Should().BeNull(); - customer2.Should().BeNull(); + IMongoCollection collection = _fixture.GetCollection(); + (await collection.CountDocumentsAsync(FilterDefinition.Empty)).Should().Be(2); } } 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 428d1f0..a7e52fb 100644 --- a/tests/Data.MongoDB.UnitTests/CodeArchitects.Platform.Data.MongoDB.UnitTests.csproj +++ b/tests/Data.MongoDB.UnitTests/CodeArchitects.Platform.Data.MongoDB.UnitTests.csproj @@ -2,6 +2,7 @@ net7.0;net8.0;net9.0;net10.0 + latest enable enable @@ -12,6 +13,7 @@ +