From 13864194a00acf46202fc50365d97d8c46481be3 Mon Sep 17 00:00:00 2001 From: Amedeo D'Amelio Date: Fri, 18 Sep 2026 12:18:21 +0200 Subject: [PATCH 1/8] fix: upgrade MongoDB.Driver to version 3.12.0 and clean up AssemblyInfo.cs --- Directory.Build.props | 2 +- src/Data.MongoDB/CodeArchitects.Platform.Data.MongoDB.csproj | 4 ++-- src/Data.MongoDB/Properties/AssemblyInfo.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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/src/Data.MongoDB/CodeArchitects.Platform.Data.MongoDB.csproj b/src/Data.MongoDB/CodeArchitects.Platform.Data.MongoDB.csproj index 504a214..233e292 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,7 @@ - + + + + - 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 859e936..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 diff --git a/tests/Data.MongoDB.UnitTests/DependencyInjection/DataMongoDBServiceCollectionExtensionsTests.cs b/tests/Data.MongoDB.UnitTests/DependencyInjection/DataMongoDBServiceCollectionExtensionsTests.cs index d02a3ba..aca18be 100644 --- a/tests/Data.MongoDB.UnitTests/DependencyInjection/DataMongoDBServiceCollectionExtensionsTests.cs +++ b/tests/Data.MongoDB.UnitTests/DependencyInjection/DataMongoDBServiceCollectionExtensionsTests.cs @@ -213,6 +213,73 @@ public void AddData_ShouldRegisterTheSeed_WhenConfigured() seed.Should().BeOfType(); } + [Fact] + public void AddData_ShouldRegisterEverySeed_WhenSeveralAreConfigured() + { + // Arrange + using ServiceProvider provider = BuildProvider(builder => builder + .UseSeed() + .UseSeed()); + using IServiceScope scope = provider.CreateScope(); + + // Act + DataSeed[] seeds = [.. scope.ServiceProvider.GetServices()]; + + // Assert + seeds.Should().HaveCount(2); + } + + [Fact] + public void UseSeed_ShouldIgnoreDuplicates() + { + // Arrange + using ServiceProvider provider = BuildProvider(builder => builder + .UseSeed() + .UseSeed()); + using IServiceScope scope = provider.CreateScope(); + + // Act + DataSeed[] seeds = [.. scope.ServiceProvider.GetServices()]; + + // Assert + // Registering the same seed twice would apply it twice within one commit. + seeds.Should().ContainSingle(); + } + + [Fact] + public void AddSeedsFrom_ShouldDiscoverConcreteSeeds() + { + // Arrange + MongoDBConfigurationBuilder builder = new(); + builder.UseClient(_client.Object).UseDatabase("tests").AddEntity(); + + // Act + builder.AddSeedsFrom(typeof(DiscoverableSeed).Assembly); + + ServiceCollection services = new(); + builder.AddServices(services); + using ServiceProvider provider = services.BuildServiceProvider(); + using IServiceScope scope = provider.CreateScope(); + + // Assert + scope.ServiceProvider.GetServices() + .Should().ContainSingle(seed => seed is DiscoverableSeed); + } + + [Fact] + public void SeedMongo_ShouldNotThrow_WhenNoSeedIsRegistered() + { + // Arrange + using ServiceProvider provider = BuildProvider(); + + // Act + Action act = provider.SeedMongo; + + // Assert + // Nothing to do is not an error: it is logged and skipped. + act.Should().NotThrow(); + } + [Fact] public void UseSeed_ShouldThrow_WhenTheTypeIsNotADataSeed() { @@ -234,4 +301,11 @@ public override void Seed(ISeeder seeder) { } } + + private sealed class OtherTestSeed : DataSeed + { + public override void Seed(ISeeder seeder) + { + } + } } diff --git a/tests/Data.MongoDB.UnitTests/Fixtures/DiscoverableSeed.cs b/tests/Data.MongoDB.UnitTests/Fixtures/DiscoverableSeed.cs new file mode 100644 index 0000000..7e18732 --- /dev/null +++ b/tests/Data.MongoDB.UnitTests/Fixtures/DiscoverableSeed.cs @@ -0,0 +1,12 @@ +namespace CodeArchitects.Platform.Data.MongoDB.Fixtures; + +/// +/// The only concrete declared at namespace level in this assembly: +/// AddSeedsFrom must find exactly this one. +/// +public sealed class DiscoverableSeed : DataSeed +{ + public override void Seed(ISeeder seeder) + { + } +} diff --git a/tests/Data.MongoDB.UnitTests/SeederTests.cs b/tests/Data.MongoDB.UnitTests/SeederTests.cs new file mode 100644 index 0000000..5e3946d --- /dev/null +++ b/tests/Data.MongoDB.UnitTests/SeederTests.cs @@ -0,0 +1,199 @@ +using CodeArchitects.Platform.Data.MongoDB.Collections; +using CodeArchitects.Platform.Data.MongoDB.Fixtures; +using MongoDB.Driver; + +namespace CodeArchitects.Platform.Data.MongoDB; + +public class SeederTests +{ + private readonly Mock _collections = new(); + private readonly Mock _stateManager = new(); + + public SeederTests() + { + _collections + .Setup(collections => collections.GetCollection()) + .Returns(Mock.Of>()); + } + + private Seeder CreateSut() => new(_collections.Object, _stateManager.Object); + + #region Ordering + + [Fact] + public void Order_ShouldHonourTheDeclaredOrder() + { + // Arrange + DataSeed[] seeds = [new ThirdSeed(), new FirstSeed(), new SecondSeed()]; + + // Act + DataSeed[] ordered = [.. Seeder.Order(seeds)]; + + // Assert + ordered.Select(seed => seed.GetType()) + .Should().Equal(typeof(FirstSeed), typeof(SecondSeed), typeof(ThirdSeed)); + } + + [Fact] + public void Order_ShouldBeDeterministic_WhenNoOrderIsDeclared() + { + // Arrange + DataSeed[] seeds = [new UnorderedB(), new UnorderedA()]; + + // Act + DataSeed[] first = [.. Seeder.Order(seeds)]; + // AsEnumerable: on an array, Reverse() would bind to MemoryExtensions.Reverse(Span), + // which reverses in place and returns void. + DataSeed[] second = [.. Seeder.Order(seeds.AsEnumerable().Reverse())]; + + // Assert + // Neither assembly scanning nor service resolution guarantees a stable order: + // the type name is the tie-breaker. + first.Select(seed => seed.GetType()).Should().Equal(typeof(UnorderedA), typeof(UnorderedB)); + second.Select(seed => seed.GetType()).Should().Equal(typeof(UnorderedA), typeof(UnorderedB)); + } + + [Fact] + public void Order_ShouldTreatAnUndeclaredOrderAsZero() + { + // Arrange + DataSeed[] seeds = [new FirstSeed(), new UnorderedA()]; + + // Act + DataSeed[] ordered = [.. Seeder.Order(seeds)]; + + // Assert + // UnorderedA declares nothing, so it counts as 0 and runs before FirstSeed, which declares 1. + ordered.Select(seed => seed.GetType()).Should().Equal(typeof(UnorderedA), typeof(FirstSeed)); + } + + #endregion + + #region Seed + + [Fact] + public void Seed_ShouldEnqueueATransactionalExecution() + { + // Arrange + Seeder sut = CreateSut(); + + // Act + sut.Seed([new DiscoverableEntity { Id = Guid.NewGuid() }]); + + // Assert + // Seeding writes several documents: it is only atomic inside a transaction. + _stateManager.Verify( + manager => manager.AddExecution(It.IsAny(), true), + Times.Once); + } + + [Fact] + public void Seed_ShouldEnqueueNothing_WhenThereIsNothingToSeed() + { + // Arrange + Seeder sut = CreateSut(); + + // Act + sut.Seed([]); + + // Assert + _stateManager.Verify( + manager => manager.AddExecution(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public void Seed_ShouldThrow_WhenEntitiesIsNull() + { + // Arrange + Seeder sut = CreateSut(); + + // Act + Action act = () => sut.Seed(null!); + + // Assert + act.Should().Throw(); + } + + #endregion + + #region Apply + + [Fact] + public void Apply_ShouldCommitOnce_ForAllSeeds() + { + // Arrange + Seeder sut = CreateSut(); + + // Act + sut.Apply([new EntitySeed(), new EntitySeed()]); + + // Assert + // Both seeds are enqueued, then committed together: an interrupted seeding must not + // leave the database half populated. + _stateManager.Verify(manager => manager.AddExecution(It.IsAny(), true), Times.Exactly(2)); + _stateManager.Verify(manager => manager.Save(), Times.Once); + } + + [Fact] + public async Task ApplyAsync_ShouldCommitOnce_ForAllSeeds() + { + // Arrange + Seeder sut = CreateSut(); + + // Act + await sut.ApplyAsync([new EntitySeed()]); + + // Assert + _stateManager.Verify(manager => manager.SaveAsync(It.IsAny()), Times.Once); + } + + [Fact] + public void Apply_ShouldThrow_WhenSeedsIsNull() + { + // Arrange + Seeder sut = CreateSut(); + + // Act + Action act = () => sut.Apply(null!); + + // Assert + act.Should().Throw(); + } + + #endregion + + [SeedOrder(1)] + private sealed class FirstSeed : DataSeed + { + public override void Seed(ISeeder seeder) { } + } + + [SeedOrder(2)] + private sealed class SecondSeed : DataSeed + { + public override void Seed(ISeeder seeder) { } + } + + [SeedOrder(3)] + private sealed class ThirdSeed : DataSeed + { + public override void Seed(ISeeder seeder) { } + } + + private sealed class UnorderedA : DataSeed + { + public override void Seed(ISeeder seeder) { } + } + + private sealed class UnorderedB : DataSeed + { + public override void Seed(ISeeder seeder) { } + } + + private sealed class EntitySeed : DataSeed + { + public override void Seed(ISeeder seeder) => + seeder.Seed([new DiscoverableEntity { Id = Guid.NewGuid() }]); + } +} From 78ec471e70a6bc18036384d63b68fb4357198dd1 Mon Sep 17 00:00:00 2001 From: Amedeo D'Amelio Date: Tue, 22 Sep 2026 10:07:26 +0200 Subject: [PATCH 7/8] feat: add MongoDB integration tests to CI workflow and update documentation for experimental packages --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ docs/mongodb.md | 20 +++++++------------- 2 files changed, 27 insertions(+), 13 deletions(-) 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/docs/mongodb.md b/docs/mongodb.md index 71408d0..e380ec8 100644 --- a/docs/mongodb.md +++ b/docs/mongodb.md @@ -1,10 +1,9 @@ # DAL con MongoDB > **Nota.** I pacchetti `CodeArchitects.Platform.Data.MongoDB` e -> `CodeArchitects.Platform.Data.MongoDB.DependencyInjection` sono marcati `[Experimental]`: la -> superficie pubblica può cambiare fra due versioni minori. Le funzionalità non ancora supportate -> sono elencate in [Limiti noti](#limiti-noti); l'architettura di riferimento completa è in -> [`docs/design/mongodb-provider.md`](design/mongodb-provider.md). +> `CodeArchitects.Platform.Data.MongoDB.DependencyInjection` sono marcati `[Experimental]`: è +> molto probabile che verranno introdotte breaking changes tra il rilascio di una versione e la successiva. +> Non è stato ancora raggiunto un livello di maturità pari agli altri Data Provider attualmente supportati. MongoDB è un database documentale: non ha tabelle né foreign key, e garantisce atomicità sul **singolo documento**. Il provider MongoDB del DAL CAEP espone gli stessi `IRepository`, @@ -146,9 +145,9 @@ mette il nome della collection dentro il dato. L'uso di `IUnitOfWorkManager` e `IUnitOfWork` è identico agli altri provider ([DAL](dataaccesslayer.md#il-pattern-unit-of-work)). Le differenze sono due. -**Le scritture sono differite.** Dentro un'unità di lavoro le operazioni si accumulano e vengono +**Le scritture sono differite.** Dentro la UnitOfWork le operazioni si accumulano e vengono applicate al `SaveAsync` (o al `Dispose` con `autoSave: true`), tutte in **un'unica transazione** -MongoDB. Ne consegue che una lettura effettuata dentro l'unità di lavoro **non vede** le scritture +MongoDB. Ne consegue che una lettura effettuata dentro il contesto della UnitOfWork **non vede** le scritture ancora non committate. ```csharp @@ -170,12 +169,7 @@ comportamento è governato da `UseTransactions`: | `WhenSupported` | esegue senza atomicità ed emette un warning sul logger | | `Disabled` | non usa mai transazioni | -Il default è `Required` perché l'unità di lavoro è una **promessa di atomicità**: eseguirla senza -garanzie, in silenzio, è peggio di un errore esplicito. Per lo sviluppo locale su istanza -standalone si usa `Disabled`; il `docker-compose` di un servizio che usa l'unità di lavoro dovrebbe -però prevedere un replica set a nodo singolo. - -Una scrittura su un singolo documento **fuori** da un'unità di lavoro non apre alcuna transazione: +Una scrittura su un singolo documento **fuori** da contesto di una UnitOfWork non apre alcuna transazione: è già atomica. `InsertMany` e `UpdateMany` invece la aprono sempre, perché coinvolgono più documenti. @@ -204,7 +198,7 @@ Due punti che sorprendono chi arriva dai provider relazionali: ### Repository diretto -L'entità di dominio **è** il documento. Da usare quando il modello di dominio è serializzabile 1:1. +L'entità di dominio è il documento. Da usare quando il modello di dominio è serializzabile 1:1. ```csharp using CodeArchitects.Platform.Data.MongoDB; From 8655adbe3ecdbffd3879305a5b4f0efd3d73c1b5 Mon Sep 17 00:00:00 2001 From: Amedeo D'Amelio Date: Tue, 22 Sep 2026 12:15:07 +0200 Subject: [PATCH 8/8] fix: translate doc in english --- docs/mongodb.md | 264 ++++++++++++++++++++++++------------------------ 1 file changed, 131 insertions(+), 133 deletions(-) diff --git a/docs/mongodb.md b/docs/mongodb.md index e380ec8..5629066 100644 --- a/docs/mongodb.md +++ b/docs/mongodb.md @@ -1,16 +1,16 @@ -# DAL con MongoDB +# DAL with MongoDB -> **Nota.** I pacchetti `CodeArchitects.Platform.Data.MongoDB` e -> `CodeArchitects.Platform.Data.MongoDB.DependencyInjection` sono marcati `[Experimental]`: è -> molto probabile che verranno introdotte breaking changes tra il rilascio di una versione e la successiva. -> Non è stato ancora raggiunto un livello di maturità pari agli altri Data Provider attualmente supportati. +> **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 è un database documentale: non ha tabelle né foreign key, e garantisce atomicità sul -**singolo documento**. Il provider MongoDB del DAL CAEP espone gli stessi `IRepository`, -`IUnitOfWork` e `IDataContext` degli altri provider, ma alcune semantiche cambiano di conseguenza. -Questa pagina descrive quelle differenze. +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. -## Configurazione +## Configuration ```csharp builder.Services.AddData(cfg => cfg @@ -19,50 +19,50 @@ builder.Services.AddData(cfg => cfg .AddEntitiesFrom(typeof(Product).Assembly)); ``` -`AddData` vive nel namespace `Microsoft.Extensions.DependencyInjection`, come per gli altri -provider: in un `Program.cs` non serve alcun `using` aggiuntivo. +`AddData` lives in the `Microsoft.Extensions.DependencyInjection` namespace, as with the other +providers: no additional `using` directive is required in `Program.cs`. -La configurazione è **validata subito**, senza contattare il server. Client mancante, database -mancante, modello vuoto, chiave non risolvibile o due entità mappate sulla stessa collection -fanno fallire l'avvio con un messaggio esplicito, non una `NullReferenceException` alla prima -richiesta. +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 -// dalla connection string +// from the connection string .UseConnectionString(connectionString) -// intervenendo sulle impostazioni derivate dalla connection string +// modifying the settings derived from the connection string .UseConnectionString(connectionString, settings => settings.RetryWrites = true) -// fornendo il client +// providing the client .UseClient(client) .UseClient(sp => sp.GetRequiredService()) ``` -`UseClient` accetta `IMongoClient`, non `MongoClient`: nel driver 3.x la classe concreta è -`sealed`, quindi l'interfaccia è l'unico modo per decorare o sostituire il client. +`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. -### Opzioni +### Options ```csharp builder.Services.AddData(cfg => cfg .UseConnectionString(connectionString) .UseDatabase("store") .AddEntitiesFrom(typeof(Product).Assembly) - .AddEntity() // registrazione esplicita + .AddEntity() // explicit registration .UseTransactions(TransactionMode.Required) // default .UseGuidRepresentation(GuidRepresentation.Standard) // default .ConfigureConventions(pack => pack.Add(new CamelCaseElementNameConvention())) .UseSeed()); ``` -## Entità e convenzioni +## Entities and conventions -### Nome della collection +### Collection name -Un'entità è una classe **pubblica, concreta e non generica** marcata con `[Collection]`: +An entity is a **public, concrete, non-generic** class marked with `[Collection]`: ```csharp using CodeArchitects.Platform.Data.MongoDB; @@ -76,79 +76,78 @@ public class Product } ``` -`[Table]` è accettato come alternativa, per retro-compatibilità. Senza attributo il nome è quello -del tipo, invariato: nessuna pluralizzazione e nessuna trasformazione di case. `AddEntity()` -registra un tipo anche se non ha attributo. +`[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. -> L'attributo si chiama `Collection` come quello di xUnit: in un progetto di test che importa -> entrambi va qualificato (`[CodeArchitects.Platform.Data.MongoDB.Collection("...")]`). +> 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("...")]`). -### Chiave +### Key -La chiave è **sempre** mappata sull'elemento `_id` ed è risolta in quest'ordine: +The key is **always** mapped to the `_id` element and is resolved in this order: -1. proprietà marcata `[BsonId]`; -2. proprietà `Id`; -3. proprietà `Id`. +1. property marked `[BsonId]`; +2. `Id` property; +3. `Id` property. -Tipi supportati: `Guid`, `string`, `ObjectId`, `int`, `long`. Le chiavi composite non sono -supportate. +Supported types: `Guid`, `string`, `ObjectId`, `int`, `long`. Composite keys are not supported. -La generazione del valore non è a carico del provider: se la chiave è `default` al momento -dell'insert, il valore viene delegato al driver per `ObjectId` e `string`, mentre per `Guid` e per -gli interi va valorizzata dal dominio — com'è nella pipeline CAEP. +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. -## Aggregati, documenti incorporati e riferimenti +## Aggregates, embedded documents, and references -> **L'unità di consistenza è il documento.** Un aggregate root corrisponde a un documento; tutto -> l'aggregato vive dentro quel documento. Le operazioni sull'aggregate root sono quindi atomiche -> **per costruzione**, senza transazioni. +> **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. -Questo cambia il modo di modellare le associazioni rispetto ai provider relazionali: +This changes how associations are modeled compared with relational providers: -| Associazione | Rappresentazione | +| Association | Representation | |---|---| -| Intra-aggregato (1:1 e 1:N) | sotto-documento o array di sotto-documenti **incorporati** | -| Inter-aggregato (1:1, N:1, 1:N) | campo contenente la **chiave** dell'altro aggregato | -| Molti-a-molti | array di chiavi sul lato proprietario; nessuna collection di giunzione | +| 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-aggregato: incorporato - public Guid CustomerId { get; set; } // inter-aggregato: riferimento + public List Items { get; set; } = []; // intra-aggregate: embedded + public Guid CustomerId { get; set; } // inter-aggregate: reference } -public class CartItem // nessun [Collection], nessun Id: non è un'entità +public class CartItem // no [Collection], no Id: not an entity { public string? Sku { get; set; } public int Quantity { get; set; } } ``` -Le entità incorporate **non hanno una collection propria** e non sono raggiungibili da un -repository dedicato: un'entità che deve avere un repository è per definizione un aggregate root. +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. -Le scritture seguono la semantica già documentata nel [DAL](dataaccesslayer.md#associazioni-e-aggregati): -`Insert` e `Update` sull'aggregate root scrivono l'intero documento, incorporati compresi, mentre -le entità inter-aggregato non vengono scritte — viene persistito solo il riferimento. `Remove` -cancella il documento e con esso gli incorporati; **non** esiste cascata attraverso i riferimenti, -perché MongoDB non ha un `delete behavior`: resta responsabilità applicativa. +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` non è supportato di proposito: non è risolvibile lato server in una pipeline `$lookup` e -mette il nome della collection dentro il dato. +`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 e transazioni +## Unit of work and transactions -L'uso di `IUnitOfWorkManager` e `IUnitOfWork` è identico agli altri provider -([DAL](dataaccesslayer.md#il-pattern-unit-of-work)). Le differenze sono due. +The use of `IUnitOfWorkManager` and `IUnitOfWork` is identical to the other providers +([DAL](dataaccesslayer.md#il-pattern-unit-of-work)). There are two differences. -**Le scritture sono differite.** Dentro la UnitOfWork le operazioni si accumulano e vengono -applicate al `SaveAsync` (o al `Dispose` con `autoSave: true`), tutte in **un'unica transazione** -MongoDB. Ne consegue che una lettura effettuata dentro il contesto della UnitOfWork **non vede** le scritture -ancora non committate. +**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()) @@ -156,49 +155,48 @@ await using (IUnitOfWork uow = _uowManager.Begin()) await _cartRepo.UpdateAsync(cart); await _productRepo.UpdateAsync(product); - await uow.SaveAsync(); // una sola transazione: o entrambe, o nessuna + await uow.SaveAsync(); // one transaction: both or neither } ``` -**Le transazioni richiedono un replica set.** MongoDB non le supporta su un server standalone. Il -comportamento è governato da `UseTransactions`: +**Transactions require a replica set.** MongoDB does not support them on a standalone server. The +behavior is governed by `UseTransactions`: -| Modalità | Comportamento su topologia non compatibile | +| Mode | Behavior on an incompatible topology | |---|---| -| `Required` (default) | solleva `TransactionsNotSupportedException` | -| `WhenSupported` | esegue senza atomicità ed emette un warning sul logger | -| `Disabled` | non usa mai transazioni | +| `Required` (default) | throws `TransactionsNotSupportedException` | +| `WhenSupported` | runs without atomicity and emits a warning to the logger | +| `Disabled` | never uses transactions | -Una scrittura su un singolo documento **fuori** da contesto di una UnitOfWork non apre alcuna transazione: -è già atomica. `InsertMany` e `UpdateMany` invece la aprono sempre, perché coinvolgono più -documenti. +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. -## Semantica delle operazioni +## Operation semantics -| Metodo | Comportamento | Eccezione | +| Method | Behavior | Exception | |---|---|---| -| `FindAsync(key)` | filtro su `_id` | — (`null` se assente) | -| `FindAsync(key, include)` | **non supportato** | `NotSupportedException` | -| `InsertAsync` | `insertOne` | `MongoWriteException` su chiave duplicata | -| `InsertManyAsync` | `insertMany` ordinata, in transazione | `MongoBulkWriteException` | -| `UpdateAsync` | sostituisce l'intero documento | `DBConcurrencyException` se non esiste | -| `UpdateManyAsync` | `bulkWrite` ordinata, in transazione | `DBConcurrencyException` se qualcuno non esiste | -| `UpsertAsync` | sostituisce o inserisce | `DBConcurrencyException` se non applicato | -| `RemoveAsync` | `deleteOne` | `DBConcurrencyException` se non esiste | - -Due punti che sorprendono chi arriva dai provider relazionali: - -- **`UpdateAsync` sostituisce l'intero documento**, non i soli campi modificati: non esiste change - tracking. Per un aggiornamento parziale si usa direttamente `Collection.UpdateOneAsync` con la - sessione corrente. -- **Un aggiornamento che non cambia nulla è un successo.** Il criterio è «il documento esiste», non - «il documento è stato riscritto». +| `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 -### Repository diretto +### Direct repository -L'entità di dominio è il documento. Da usare quando il modello di dominio è serializzabile 1:1. +The domain entity is the document. Use this when the domain model is serializable 1:1. ```csharp using CodeArchitects.Platform.Data.MongoDB; @@ -210,7 +208,7 @@ public class ProductRepository : MongoDBRepository, IProductRepos public async Task> GetTopSellingProductsAsync(int count, CancellationToken ct = default) { return await Collection - .Find(Session, Builders.Filter.Empty) // Session: partecipa all'unità di lavoro + .Find(Session, Builders.Filter.Empty) // Session: participates in the unit of work .SortByDescending(product => product.SaleCount) .Limit(count) .ToListAsync(ct); @@ -218,16 +216,16 @@ public class ProductRepository : MongoDBRepository, IProductRepos } ``` -La classe base espone `Collection` (`IMongoCollection`), `Database` e **`Session`**. +The base class exposes `Collection` (`IMongoCollection`), `Database`, and **`Session`**. -> Passare `Session` alle query personalizzate non è opzionale: un'operazione eseguita senza sessione -> gira su una sessione implicita, quindi **fuori** dalla transazione dell'unità di lavoro in corso. +> 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. -### Repository mapped +### Mapped repository -Quando il documento deve divergere dal dominio — denormalizzazione, campi tecnici, nomi diversi, -versionamento dello schema — si usa `MongoDBMappedRepository`. -`[Collection]` sta sul **documento**, mai sull'entità di dominio. +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")] @@ -247,22 +245,22 @@ public class ProductRepository : MongoDBMappedRepository`. -Il mapping può essere delegato a Mapster con un `TypeAdapterConfig` bidirezionale, come descritto -in [DAL – Mapped repository](dataaccesslayer.md#mapped-repository): +Mapping can be delegated to Mapster with a bidirectional `TypeAdapterConfig`, as described in +[DAL – Mapped repository](dataaccesslayer.md#mapped-repository): ```csharp config.NewConfig().TwoWays(); ``` -> L'estensione `PreserveTracking` di `CodeArchitects.Platform.Data.Mapster` **non ha effetto** su -> questo provider: MongoDB non ha change tracking. +> The `PreserveTracking` extension from `CodeArchitects.Platform.Data.Mapster` has **no effect** on +> this provider: MongoDB has no change tracking. ## Seeding -Il seeding usa la stessa `DataSeed` degli altri provider. +Seeding uses the same `DataSeed` as the other providers. ```csharp [SeedOrder(1)] @@ -274,27 +272,27 @@ public class CategorySeed : DataSeed } ``` -Registrazione e applicazione: +Registration and application: ```csharp builder.Services.AddData(cfg => cfg // ... .UseSeed() - .AddSeedsFrom(typeof(CategorySeed).Assembly)); // oppure per scansione + .AddSeedsFrom(typeof(CategorySeed).Assembly)); // or by scanning WebApplication app = builder.Build(); -app.Services.SeedMongo(); // oppure await SeedMongoAsync() +app.Services.SeedMongo(); // or await SeedMongoAsync() ``` -- L'**ordine** è deterministico: `[SeedOrder]` (assente ⇒ `0`), a parità di ordine il nome del tipo. -- Il seeding è **idempotente per collection**: un seed è applicato solo a una collection vuota. -- Tutti i seed sono committati **in un'unica transazione**: un seeding interrotto non lascia il - database popolato a metà. Richiede quindi un replica set. +- 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 -Gli integration test del provider usano [Testcontainers](https://dotnet.testcontainers.org/) con un -replica set a nodo singolo, indispensabile per le transazioni: +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() @@ -303,21 +301,21 @@ MongoDbContainer container = new MongoDbBuilder() .Build(); ``` -## Limiti noti +## Known limitations -Non ancora supportati, in ordine di impatto: +Not yet supported, in order of impact: -- **`Include`** — sia sulle navigazioni incorporate (dove sarebbe un no-op) sia sui riferimenti - inter-aggregato. Solleva `NotSupportedException`. -- **Concorrenza ottimistica** — nessun token di versione. -- **Multitenancy e soft delete** — disponibili sul provider EF Core, non qui. -- **Change tracking** — non ha corrispettivo nel modello ad aggregati. -- **Chiavi composite**, **gestione degli indici**, **database multipli** con contesto tipizzato. -- **Interceptor** sulle operazioni. +- **`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. -## Pacchetti +## 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) -Richiedono **MongoDB Server 4.4 o superiore** (requisito del driver 3.x). +They require **MongoDB Server 4.4 or later** (a requirement of driver 3.x).