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
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<Version>8.13.0</Version>
<Version>8.14.0</Version>
<LangVersion>13.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
Expand Down
33 changes: 33 additions & 0 deletions docs/efcore/database-cleaner.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,39 @@ public class TestOrderSeedData : IInitialData<ShopDbContext>
<sup><a href='https://github.com/JasperFx/weasel/blob/master/src/DocSamples/DatabaseCleanerSamples.cs#L9-L19' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_efcore_initial_data' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

### Inline lambda seeders

For small amounts of seed data, authoring a dedicated `IInitialData<TContext>` class is often overkill. The `AddInitialData<TContext>(Func<TContext, CancellationToken, Task>)` overload wraps a delegate in a `LambdaInitialData<TContext>` and registers it as a singleton `IInitialData<TContext>`. Lambda and class-based seeders coexist and run in registration order:

<!-- snippet: sample_efcore_lambda_initial_data -->
<a id='snippet-sample_efcore_lambda_initial_data'></a>
```cs
var builder = Host.CreateDefaultBuilder();
builder.ConfigureServices(services =>
{
services.AddDbContext<ShopDbContext>(options =>
options.UseNpgsql("Host=localhost;Database=mydb"));

services.AddSingleton<Migrator, Weasel.Postgresql.PostgresqlMigrator>();
services.AddDatabaseCleaner<ShopDbContext>();

// Class-based seeder (as before)
services.AddInitialData<ShopDbContext, TestOrderSeedData>();

// Inline lambda seeder — registered as a singleton LambdaInitialData<T>.
// Runs alongside class-based seeders, in registration order, each time
// ResetAllDataAsync is invoked.
services.AddInitialData<ShopDbContext>(async (ctx, ct) =>
{
ctx.Customers.Add(new ShopCustomer { Name = "Inline Customer" });
await ctx.SaveChangesAsync(ct);
});
});
```
<!-- endSnippet -->

The lambda receives a scoped `TContext` and the caller's `CancellationToken`. Call `SaveChangesAsync` as normal — the cleaner does not wrap the seeder in a transaction.

## Multi-Tenancy

For multi-tenant scenarios where each tenant has its own database, pass an explicit `DbConnection` to target a specific tenant:
Expand Down
27 changes: 27 additions & 0 deletions src/DocSamples/DatabaseCleanerSamples.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,31 @@ public async Task use_with_explicit_connection(IHost host)
await cleaner.ResetAllDataAsync(tenantConnection);
#endregion
}

public void register_lambda_initial_data()
{
#region sample_efcore_lambda_initial_data
var builder = Host.CreateDefaultBuilder();
builder.ConfigureServices(services =>
{
services.AddDbContext<ShopDbContext>(options =>
options.UseNpgsql("Host=localhost;Database=mydb"));

services.AddSingleton<Migrator, Weasel.Postgresql.PostgresqlMigrator>();
services.AddDatabaseCleaner<ShopDbContext>();

// Class-based seeder (as before)
services.AddInitialData<ShopDbContext, TestOrderSeedData>();

// Inline lambda seeder — registered as a singleton LambdaInitialData<T>.
// Runs alongside class-based seeders, in registration order, each time
// ResetAllDataAsync is invoked.
services.AddInitialData<ShopDbContext>(async (ctx, ct) =>
{
ctx.Customers.Add(new ShopCustomer { Name = "Inline Customer" });
await ctx.SaveChangesAsync(ct);
});
});
#endregion
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

namespace Weasel.EntityFrameworkCore.Tests.Postgresql;

[Collection("FkDependencyDbContext")]
public class database_cleaner_tests : IAsyncLifetime
{
private IHost _host = null!;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shouldly;
using Weasel.Core;
using Weasel.Postgresql;
using Xunit;

namespace Weasel.EntityFrameworkCore.Tests.Postgresql;

/// <summary>
/// Tests for <see cref="LambdaInitialData{TContext}" /> and the
/// <c>AddInitialData&lt;TContext&gt;(Func&lt;TContext, CancellationToken, Task&gt;)</c>
/// extension. These sit alongside the class-based
/// <c>AddInitialData&lt;TContext, TData&gt;()</c> form and let callers register small
/// inline seeders without authoring a dedicated class.
/// </summary>
/// <remarks>
/// Placed in the same xUnit collection as <see cref="database_cleaner_tests" /> so the
/// two suites never race on <see cref="FkDependencyDbContext" />'s shared schema.
/// </remarks>
[Collection("FkDependencyDbContext")]
public class lambda_initial_data_tests : IAsyncLifetime
{
private IHost _host = null!;

public async Task InitializeAsync()
{
_host = Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
services.AddDbContext<FkDependencyDbContext>(options =>
options.UseNpgsql(FkDependencyDbContext.ConnectionString));

services.AddSingleton<Migrator, PostgresqlMigrator>();
services.AddDatabaseCleaner<FkDependencyDbContext>();

// One class-based seeder + one lambda seeder. The cleaner should run
// both — in the order they were registered — on ResetAllDataAsync.
services.AddInitialData<FkDependencyDbContext, TestCategorySeedData>();
services.AddInitialData<FkDependencyDbContext>(async (ctx, ct) =>
{
ctx.EntityCategories.Add(new EntityCategory
{ Id = 50, Key = "lambda-seed", Name = "Lambda-registered seed" });
await ctx.SaveChangesAsync(ct);
});
})
.Build();

await _host.StartAsync();

using var scope = _host.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<FkDependencyDbContext>();
await ctx.Database.ExecuteSqlRawAsync(
$"DROP SCHEMA IF EXISTS {FkDependencyDbContext.TestSchema} CASCADE; CREATE SCHEMA {FkDependencyDbContext.TestSchema};");
var creator = ctx.GetService<Microsoft.EntityFrameworkCore.Storage.IRelationalDatabaseCreator>();
await creator!.CreateTablesAsync();
}

public async Task DisposeAsync()
{
await _host.StopAsync();
_host.Dispose();
}

[Fact]
public void lambda_extension_registers_LambdaInitialData_as_an_IInitialData_service()
{
// Both the class-based and lambda registrations should be resolvable, and the
// lambda one specifically should materialize as LambdaInitialData<T>.
var seeders = _host.Services.GetServices<IInitialData<FkDependencyDbContext>>().ToArray();

seeders.Length.ShouldBe(2);
seeders.OfType<LambdaInitialData<FkDependencyDbContext>>().ShouldHaveSingleItem();
seeders.OfType<TestCategorySeedData>().ShouldHaveSingleItem();
}

[Fact]
public async Task reset_all_data_runs_class_and_lambda_seeders()
{
// Stale data that should be cleared before the seeders run.
using (var scope = _host.Services.CreateScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<FkDependencyDbContext>();
ctx.EntityCategories.Add(new EntityCategory { Id = 99, Key = "stale", Name = "Stale" });
await ctx.SaveChangesAsync();
}

var cleaner = _host.Services.GetRequiredService<IDatabaseCleaner<FkDependencyDbContext>>();
await cleaner.ResetAllDataAsync();

using var verify = _host.Services.CreateScope();
var verifyCtx = verify.ServiceProvider.GetRequiredService<FkDependencyDbContext>();
var keys = (await verifyCtx.EntityCategories.ToListAsync()).Select(c => c.Key).ToArray();

keys.ShouldNotContain("stale");
keys.ShouldContain("seed1"); // class-based TestCategorySeedData
keys.ShouldContain("seed2"); // class-based TestCategorySeedData
keys.ShouldContain("lambda-seed"); // LambdaInitialData registration
}

[Fact]
public void add_initial_data_lambda_rejects_null_delegate()
{
var services = new ServiceCollection();
Should.Throw<ArgumentNullException>(
() => services.AddInitialData<FkDependencyDbContext>(null!));
}
}
22 changes: 22 additions & 0 deletions src/Weasel.EntityFrameworkCore/DatabaseCleanerExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,26 @@ public static IServiceCollection AddInitialData<TContext, TData>(this IServiceCo
services.AddTransient<IInitialData<TContext>, TData>();
return services;
}

/// <summary>
/// Registers an inline <see cref="IInitialData{TContext}" /> seeder that executes
/// <paramref name="apply" /> after <see cref="IDatabaseCleaner{TContext}.ResetAllDataAsync" />.
/// Useful for small amounts of seed data where authoring a dedicated
/// <see cref="IInitialData{TContext}" /> class is unwarranted. Registered as a singleton;
/// multiple lambda seeders execute in registration order alongside class-based seeders.
/// </summary>
/// <param name="services">The DI service collection.</param>
/// <param name="apply">
/// Delegate invoked with a scoped <typeparamref name="TContext" /> and the caller's
/// cancellation token. Implementations should call <c>SaveChangesAsync</c> as appropriate.
/// </param>
public static IServiceCollection AddInitialData<TContext>(
this IServiceCollection services,
Func<TContext, CancellationToken, Task> apply) where TContext : DbContext
{
if (apply is null) throw new ArgumentNullException(nameof(apply));

services.AddSingleton<IInitialData<TContext>>(new LambdaInitialData<TContext>(apply));
return services;
}
}
22 changes: 22 additions & 0 deletions src/Weasel.EntityFrameworkCore/LambdaInitialData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;

namespace Weasel.EntityFrameworkCore;

/// <summary>
/// Adapter that turns a delegate into an <see cref="IInitialData{TContext}" />.
/// Useful for seeding small amounts of data inline from a composition-root
/// registration without authoring a dedicated class — see
/// <see cref="DatabaseCleanerExtensions.AddInitialData{TContext}(Microsoft.Extensions.DependencyInjection.IServiceCollection, System.Func{TContext, System.Threading.CancellationToken, System.Threading.Tasks.Task})" />.
/// </summary>
/// <typeparam name="TContext">The <see cref="DbContext" /> the seeder writes to.</typeparam>
public sealed class LambdaInitialData<TContext> : IInitialData<TContext> where TContext : DbContext
{
private readonly Func<TContext, CancellationToken, Task> _apply;

public LambdaInitialData(Func<TContext, CancellationToken, Task> apply)
{
_apply = apply ?? throw new ArgumentNullException(nameof(apply));
}

public Task Populate(TContext context, CancellationToken cancellation) => _apply(context, cancellation);
}
Loading