Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions src/Persistence/EfCoreTests/batch_query_tests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
[Collection("sqlserver")]
public class batch_query_tests : IClassFixture<EFCorePersistenceContext>
{
private readonly IHost _host;

public batch_query_tests(EFCorePersistenceContext ctx)
{
_host = ctx.theHost;
}

private async Task<Guid> InsertItemAsync(string name)
{
var id = Guid.NewGuid();
using var scope = _host.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ItemsDbContext>();
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<ItemsDbContext>();

// 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<ItemsDbContext>();
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<ItemsDbContext>();
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<ItemsDbContext>();
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<ItemsDbContext>();

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);
}
}
}

/// <summary>
/// Unit tests for the BatchedLoadEntityFrame / CreateBatchQueryFrame /
/// ExecuteBatchQueryFrame codegen infrastructure introduced in #2478.
/// </summary>
public class batched_load_entity_frame_codegen_tests
{
[Fact]
public void batched_load_frame_creates_saga_and_task_variables()
{
var sagaIdVar = Variable.For<Guid>();
var frame = new BatchedLoadEntityFrame(
typeof(ItemsDbContext),
typeof(Item),
sagaIdVar,
"Id");

frame.Saga.VariableType.ShouldBe(typeof(Item));
frame.SagaTask.VariableType.ShouldBe(typeof(Task<Item>));
}

[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<Guid>();

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();
}
}
151 changes: 151 additions & 0 deletions src/Persistence/EfCoreTests/database_cleaner_tests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Demonstrates IInitialData&lt;TContext&gt; for FK-safe seed data applied after
/// IDatabaseCleaner&lt;TContext&gt;.ResetAllDataAsync().
/// </summary>
public class SeedItemsForTests : IInitialData<ItemsDbContext>
{
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);
}
}

/// <summary>
/// Test fixture that registers IDatabaseCleaner&lt;ItemsDbContext&gt; and IInitialData&lt;ItemsDbContext&gt;
/// alongside Wolverine's normal setup.
/// </summary>
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<ItemsDbContext>(x => x.UseSqlServer(Servers.SqlServerConnectionString));

// Registers IDatabaseCleaner<ItemsDbContext> 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<ItemsDbContext>();

// Registers seed data applied after ResetAllDataAsync().
// Multiple IInitialData<T> registrations execute in order.
services.AddInitialData<ItemsDbContext, SeedItemsForTests>();
})
.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();
}
}

/// <summary>
/// Integration tests demonstrating Weasel's IDatabaseCleaner&lt;TContext&gt; as the
/// recommended pattern for FK-aware database reset in EF Core integration tests.
/// Replaces manual DELETE FROM / TRUNCATE / schema-drop cleanup code.
/// </summary>
[Collection("sqlserver")]
public class database_cleaner_usage_tests : IClassFixture<DatabaseCleanerContext>
{
private readonly DatabaseCleanerContext _ctx;

public database_cleaner_usage_tests(DatabaseCleanerContext ctx)
{
_ctx = ctx;
}

private IDatabaseCleaner<ItemsDbContext> Cleaner =>
_ctx.Host.Services.GetRequiredService<IDatabaseCleaner<ItemsDbContext>>();

private async Task<int> CountItemsAsync()
{
using var scope = _ctx.Host.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ItemsDbContext>();
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<ItemsDbContext>();
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<ItemsDbContext>();
db.Items.Add(new Item { Id = Guid.NewGuid(), Name = "Noise Item" });
await db.SaveChangesAsync();
}

// Act: delete all + run IInitialData<T> seeders
await Cleaner.ResetAllDataAsync();

// Assert: exactly the seed rows remain
using var checkScope = _ctx.Host.Services.CreateScope();
var checkDb = checkScope.ServiceProvider.GetRequiredService<ItemsDbContext>();
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<IDatabaseCleaner<ItemsDbContext>>();
var cleaner2 = _ctx.Host.Services.GetRequiredService<IDatabaseCleaner<ItemsDbContext>>();
cleaner1.ShouldBeSameAs(cleaner2);
}
}
Loading
Loading