diff --git a/src/Persistence/EfCoreTests/batch_query_tests.cs b/src/Persistence/EfCoreTests/batch_query_tests.cs new file mode 100644 index 000000000..6bc4b59c5 --- /dev/null +++ b/src/Persistence/EfCoreTests/batch_query_tests.cs @@ -0,0 +1,169 @@ +using IntegrationTests; +using JasperFx.CodeGeneration.Model; +using JasperFx.Resources; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using SharedPersistenceModels.Items; +using Shouldly; +using Weasel.EntityFrameworkCore.Batching; +using Wolverine; +using Wolverine.EntityFrameworkCore; +using Wolverine.EntityFrameworkCore.Codegen; +using Wolverine.SqlServer; + +namespace EfCoreTests; + +/// +/// Integration tests demonstrating Weasel's BatchedQuery API for combining multiple +/// EF Core SELECT queries into a single database round trip. See also the +/// BatchedLoadEntityFrame / CreateBatchQueryFrame / ExecuteBatchQueryFrame codegen +/// infrastructure in Wolverine.EntityFrameworkCore.Codegen for generated-code equivalents. +/// +[Collection("sqlserver")] +public class batch_query_tests : IClassFixture +{ + private readonly IHost _host; + + public batch_query_tests(EFCorePersistenceContext ctx) + { + _host = ctx.theHost; + } + + private async Task InsertItemAsync(string name) + { + var id = Guid.NewGuid(); + using var scope = _host.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Items.Add(new Item { Id = id, Name = name }); + await db.SaveChangesAsync(); + return id; + } + + [Fact] + public async Task load_two_entities_in_single_round_trip() + { + var id1 = await InsertItemAsync("Batch Load A"); + var id2 = await InsertItemAsync("Batch Load B"); + + using var scope = _host.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // All three calls to batch.Query*/Scalar return Tasks that resolve + // only after batch.ExecuteAsync() issues one DbBatch to the server. + var batch = db.CreateBatchQuery(); + var item1Task = batch.QuerySingle(db.Items.Where(x => x.Id == id1)); + var item2Task = batch.QuerySingle(db.Items.Where(x => x.Id == id2)); + await batch.ExecuteAsync(); + + var item1 = await item1Task; + var item2 = await item2Task; + + item1.ShouldNotBeNull(); + item1.Name.ShouldBe("Batch Load A"); + item2.ShouldNotBeNull(); + item2.Name.ShouldBe("Batch Load B"); + } + + [Fact] + public async Task load_list_of_entities_via_batch() + { + var prefix = Guid.NewGuid().ToString("N")[..8]; + using (var scope = _host.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Items.AddRange( + new Item { Id = Guid.NewGuid(), Name = $"{prefix}_list_0" }, + new Item { Id = Guid.NewGuid(), Name = $"{prefix}_list_1" }, + new Item { Id = Guid.NewGuid(), Name = $"{prefix}_list_2" }); + await db.SaveChangesAsync(); + } + + using (var scope = _host.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var batch = db.CreateBatchQuery(); + var listTask = batch.Query(db.Items.Where(x => x.Name.StartsWith(prefix))); + await batch.ExecuteAsync(); + + var items = await listTask; + items.Count.ShouldBe(3); + items.All(x => x.Name.StartsWith(prefix)).ShouldBeTrue(); + } + } + + [Fact] + public async Task mix_single_and_list_queries_in_same_batch() + { + var id1 = await InsertItemAsync("Mixed Single"); + var prefix = Guid.NewGuid().ToString("N")[..8]; + + using (var scope = _host.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Items.AddRange( + new Item { Id = Guid.NewGuid(), Name = $"{prefix}_a" }, + new Item { Id = Guid.NewGuid(), Name = $"{prefix}_b" }); + await db.SaveChangesAsync(); + } + + using (var scope = _host.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + + var batch = db.CreateBatchQuery(); + var singleTask = batch.QuerySingle(db.Items.Where(x => x.Id == id1)); + var listTask = batch.Query(db.Items.Where(x => x.Name.StartsWith(prefix))); + await batch.ExecuteAsync(); + + var single = await singleTask; + var list = await listTask; + + single.ShouldNotBeNull(); + single.Id.ShouldBe(id1); + list.Count.ShouldBe(2); + } + } +} + +/// +/// Unit tests for the BatchedLoadEntityFrame / CreateBatchQueryFrame / +/// ExecuteBatchQueryFrame codegen infrastructure introduced in #2478. +/// +public class batched_load_entity_frame_codegen_tests +{ + [Fact] + public void batched_load_frame_creates_saga_and_task_variables() + { + var sagaIdVar = Variable.For(); + var frame = new BatchedLoadEntityFrame( + typeof(ItemsDbContext), + typeof(Item), + sagaIdVar, + "Id"); + + frame.Saga.VariableType.ShouldBe(typeof(Item)); + frame.SagaTask.VariableType.ShouldBe(typeof(Task)); + } + + [Fact] + public void create_batch_query_frame_creates_batch_variable() + { + var frame = new CreateBatchQueryFrame(typeof(ItemsDbContext)); + frame.BatchQuery.VariableType.ShouldBe(typeof(BatchedQuery)); + frame.BatchQuery.Usage.ShouldBe("batchQuery"); + } + + [Fact] + public void execute_batch_query_frame_accepts_multiple_load_frames() + { + var batchVar = new CreateBatchQueryFrame(typeof(ItemsDbContext)).BatchQuery; + var idVar = Variable.For(); + + var load1 = new BatchedLoadEntityFrame(typeof(ItemsDbContext), typeof(Item), idVar, "Id"); + var load2 = new BatchedLoadEntityFrame(typeof(ItemsDbContext), typeof(Item), idVar, "Id"); + + var execFrame = new ExecuteBatchQueryFrame(batchVar, [load1, load2]); + execFrame.ShouldNotBeNull(); + } +} diff --git a/src/Persistence/EfCoreTests/database_cleaner_tests.cs b/src/Persistence/EfCoreTests/database_cleaner_tests.cs new file mode 100644 index 000000000..be444e1a9 --- /dev/null +++ b/src/Persistence/EfCoreTests/database_cleaner_tests.cs @@ -0,0 +1,151 @@ +using IntegrationTests; +using JasperFx.Resources; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using SharedPersistenceModels.Items; +using Shouldly; +using Weasel.EntityFrameworkCore; +using Wolverine; +using Wolverine.EntityFrameworkCore; +using Wolverine.SqlServer; + +namespace EfCoreTests; + +/// +/// Demonstrates IInitialData<TContext> for FK-safe seed data applied after +/// IDatabaseCleaner<TContext>.ResetAllDataAsync(). +/// +public class SeedItemsForTests : IInitialData +{ + public static readonly Item[] Items = + [ + new Item { Id = Guid.Parse("10000000-0000-0000-0000-000000000001"), Name = "Alpha Item" }, + new Item { Id = Guid.Parse("10000000-0000-0000-0000-000000000002"), Name = "Beta Item" } + ]; + + public async Task Populate(ItemsDbContext context, CancellationToken cancellation) + { + context.Items.AddRange(Items); + await context.SaveChangesAsync(cancellation); + } +} + +/// +/// Test fixture that registers IDatabaseCleaner<ItemsDbContext> and IInitialData<ItemsDbContext> +/// alongside Wolverine's normal setup. +/// +public class DatabaseCleanerContext : IAsyncLifetime +{ + public IHost Host { get; private set; } = null!; + + public async Task InitializeAsync() + { + Host = await Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder() + .ConfigureServices((_, services) => + { + services.AddDbContext(x => x.UseSqlServer(Servers.SqlServerConnectionString)); + + // Registers IDatabaseCleaner as a singleton. + // It discovers tables from DbContext.Model, topologically sorts them by + // FK dependencies, and generates provider-specific deletion SQL + // (TRUNCATE CASCADE for PostgreSQL, DELETE FROM for SQL Server). + services.AddDatabaseCleaner(); + + // Registers seed data applied after ResetAllDataAsync(). + // Multiple IInitialData registrations execute in order. + services.AddInitialData(); + }) + .UseWolverine(opts => + { + opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString); + opts.UseEntityFrameworkCoreTransactions(); + opts.Services.AddResourceSetupOnStartup(StartupAction.ResetState); + opts.UseEntityFrameworkCoreWolverineManagedMigrations(); + opts.Policies.AutoApplyTransactions(); + }) + .StartAsync(); + } + + public async Task DisposeAsync() + { + await Host.StopAsync(); + Host.Dispose(); + } +} + +/// +/// Integration tests demonstrating Weasel's IDatabaseCleaner<TContext> as the +/// recommended pattern for FK-aware database reset in EF Core integration tests. +/// Replaces manual DELETE FROM / TRUNCATE / schema-drop cleanup code. +/// +[Collection("sqlserver")] +public class database_cleaner_usage_tests : IClassFixture +{ + private readonly DatabaseCleanerContext _ctx; + + public database_cleaner_usage_tests(DatabaseCleanerContext ctx) + { + _ctx = ctx; + } + + private IDatabaseCleaner Cleaner => + _ctx.Host.Services.GetRequiredService>(); + + private async Task CountItemsAsync() + { + using var scope = _ctx.Host.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.Items.CountAsync(); + } + + [Fact] + public async Task delete_all_data_removes_every_row() + { + // Arrange: insert some extra items so the table is non-empty + using (var scope = _ctx.Host.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Items.Add(new Item { Id = Guid.NewGuid(), Name = "Temp Item" }); + await db.SaveChangesAsync(); + } + + // Act: FK-safe bulk delete (no seeding) + await Cleaner.DeleteAllDataAsync(); + + // Assert + (await CountItemsAsync()).ShouldBe(0); + } + + [Fact] + public async Task reset_all_data_clears_then_applies_seed_data() + { + // Arrange: insert extra data that should be removed + using (var scope = _ctx.Host.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Items.Add(new Item { Id = Guid.NewGuid(), Name = "Noise Item" }); + await db.SaveChangesAsync(); + } + + // Act: delete all + run IInitialData seeders + await Cleaner.ResetAllDataAsync(); + + // Assert: exactly the seed rows remain + using var checkScope = _ctx.Host.Services.CreateScope(); + var checkDb = checkScope.ServiceProvider.GetRequiredService(); + var items = await checkDb.Items.OrderBy(x => x.Name).ToListAsync(); + + items.Count.ShouldBe(SeedItemsForTests.Items.Length); + items.Select(x => x.Name).ShouldBe( + SeedItemsForTests.Items.Select(x => x.Name).OrderBy(n => n)); + } + + [Fact] + public async Task database_cleaner_is_registered_as_singleton() + { + var cleaner1 = _ctx.Host.Services.GetRequiredService>(); + var cleaner2 = _ctx.Host.Services.GetRequiredService>(); + cleaner1.ShouldBeSameAs(cleaner2); + } +} diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/BatchedLoadEntityFrame.cs b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/BatchedLoadEntityFrame.cs new file mode 100644 index 000000000..c4618bad1 --- /dev/null +++ b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/BatchedLoadEntityFrame.cs @@ -0,0 +1,166 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using Microsoft.EntityFrameworkCore; +using Weasel.EntityFrameworkCore.Batching; + +namespace Wolverine.EntityFrameworkCore.Codegen; + +/// +/// Code generation frame that loads an entity using Weasel's +/// API instead of DbContext.FindAsync. This enables multiple entity loads within +/// the same handler to be combined into a single database round trip by sharing a +/// instance. +/// +/// Usage: replace with this frame when you want batch +/// query participation. A must appear earlier in +/// the middleware chain to supply the BatchedQuery variable, and an +/// must appear after all batch loads. +/// +internal class BatchedLoadEntityFrame : SyncFrame +{ + private readonly Type _dbContextType; + private readonly Variable _sagaId; + private readonly string _pkPropertyName; + private Variable? _context; + private Variable? _batchQuery; + + public BatchedLoadEntityFrame(Type dbContextType, Type sagaType, Variable sagaId, string pkPropertyName) + { + _dbContextType = dbContextType; + _sagaId = sagaId; + _pkPropertyName = pkPropertyName; + + Saga = new Variable(sagaType, this); + // The task that will be awaited by ExecuteBatchQueryFrame + SagaTask = new Variable(typeof(Task<>).MakeGenericType(sagaType), sagaType.Name.ToLowerInvariant() + "Task", this); + } + + public Variable Saga { get; } + + /// + /// The pending returned by batch.QuerySingle(). + /// Must be awaited by the consumer (e.g. ) + /// after batch.ExecuteAsync(). + /// + public Variable SagaTask { get; } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _context = chain.FindVariable(_dbContextType); + yield return _context; + + var batch = chain.TryFindVariable(typeof(BatchedQuery), VariableSource.All); + if (batch != null) + { + _batchQuery = batch; + yield return _batchQuery; + } + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteLine(""); + writer.WriteComment("Queue entity load into a BatchedQuery for a single-round-trip multi-entity fetch"); + + if (_batchQuery != null) + { + writer.WriteLine( + $"var {SagaTask.Usage} = {_batchQuery.Usage}.{nameof(BatchedQuery.QuerySingle)}(" + + $"{_context!.Usage}.{nameof(DbContext.Set)}<{Saga.VariableType.FullNameInCode()}>().Where(x => x.{_pkPropertyName} == {_sagaId.Usage}));"); + } + else + { + // Fallback: create a local single-use batch (no sharing benefit, but still correct) + writer.WriteLine( + $"var {Saga.VariableType.Name.ToLowerInvariant()}Batch = {_context!.Usage}.{nameof(BatchQueryExtensions.CreateBatchQuery)}();"); + writer.WriteLine( + $"var {SagaTask.Usage} = {Saga.VariableType.Name.ToLowerInvariant()}Batch.{nameof(BatchedQuery.QuerySingle)}(" + + $"{_context!.Usage}.{nameof(DbContext.Set)}<{Saga.VariableType.FullNameInCode()}>().Where(x => x.{_pkPropertyName} == {_sagaId.Usage}));"); + } + + Next?.GenerateCode(method, writer); + } +} + +/// +/// Frame that creates a shared instance to be shared +/// by multiple instances in the same handler chain. +/// Insert this frame before any frames. +/// +internal class CreateBatchQueryFrame : SyncFrame +{ + private Variable? _context; + private readonly Type _dbContextType; + + public CreateBatchQueryFrame(Type dbContextType) + { + _dbContextType = dbContextType; + BatchQuery = new Variable(typeof(BatchedQuery), "batchQuery", this); + } + + public Variable BatchQuery { get; } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _context = chain.FindVariable(_dbContextType); + yield return _context; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteLine(""); + writer.WriteComment("Create a shared BatchedQuery so multiple entity loads share one round trip"); + writer.WriteLine( + $"var {BatchQuery.Usage} = {_context!.Usage}.{nameof(BatchQueryExtensions.CreateBatchQuery)}();"); + Next?.GenerateCode(method, writer); + } +} + +/// +/// Frame that executes a shared and then awaits all +/// pending entity-load tasks queued by instances. +/// Insert this frame after all frames. +/// +internal class ExecuteBatchQueryFrame : AsyncFrame +{ + private readonly Variable _batchQuery; + private readonly IReadOnlyList _loadFrames; + private Variable? _cancellation; + + public ExecuteBatchQueryFrame(Variable batchQuery, IReadOnlyList loadFrames) + { + _batchQuery = batchQuery; + _loadFrames = loadFrames; + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + yield return _batchQuery; + + _cancellation = chain.FindVariable(typeof(CancellationToken)); + yield return _cancellation; + + foreach (var frame in _loadFrames) + { + yield return frame.SagaTask; + } + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteLine(""); + writer.WriteComment("Execute all queued batch queries in a single database round trip"); + writer.WriteLine( + $"await {_batchQuery.Usage}.{nameof(BatchedQuery.ExecuteAsync)}({_cancellation!.Usage}).ConfigureAwait(false);"); + + foreach (var frame in _loadFrames) + { + writer.WriteLine( + $"var {frame.Saga.Usage} = await {frame.SagaTask.Usage}.ConfigureAwait(false);"); + } + + Next?.GenerateCode(method, writer); + } +}