diff --git a/CLAUDE.md b/CLAUDE.md index e2d84167..12168a20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,10 @@ src/ │ └── Tables/ # Table handling with Oracle-specific features ├── Weasel.Sqlite/ # SQLite implementation (NEW!) │ └── Tables/ # Table handling with JSON support +├── Weasel.EntityFrameworkCore/ # EF Core bridge: DbContext -> Weasel mapping, +│ │ # EF migration file generation (translation layer, +│ │ # emitter, snapshot differ, db-ef-migration CLI) +│ └── CommandLine/ # db-ef-migration add | script | baseline └── *Tests/ # Test projects for each library ``` diff --git a/EFCORE_IMPROVEMENTS.md b/EFCORE_IMPROVEMENTS.md index a5b73e30..375c6c89 100644 --- a/EFCORE_IMPROVEMENTS.md +++ b/EFCORE_IMPROVEMENTS.md @@ -147,6 +147,20 @@ on PostgreSQL and SQL Server, and participates in delta detection with canonicalized expression comparison), and HiLo / `HasSequence` sequences (mapped through `Migrator.CreateSequence`). +## EF Core migration generation (epic #371) + +The reverse direction landed as a phased epic: Weasel schema objects (from any +`IDatabase`) translate into EF Core `MigrationOperation` lists +(`MigrationOperationTranslation`), render as compilable attribute-only +migration files + a stub DbContext with a relocated history table +(`EfMigrationFileEmitter`), diff incrementally against a serialized JSON +snapshot or a live database (`EfSchemaSnapshot` / `EfSnapshotDiffer`), and ship +through the `db-ef-migration add | script | baseline` command. Validated by an +inverted comparison harness that compiles the generated migrations with Roslyn, +applies them through the real EF runtime, and requires catalog parity plus a +`None` Weasel delta. Docs: `docs/efcore/migration-generation.md` and +`docs/efcore/migration-coexistence.md`. + ## Test infrastructure - **New CI workflow** `ci-build-efcore.yml` — the EF Core test project diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 19ad684b..38d8c720 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -135,6 +135,8 @@ export default withMermaid( { text: 'Overview', link: '/efcore/' }, { text: 'Table Mapping', link: '/efcore/table-mapping' }, { text: 'Migrations', link: '/efcore/migrations' }, + { text: 'Migration Generation', link: '/efcore/migration-generation' }, + { text: 'Mixed EF + Critter Stack Apps', link: '/efcore/migration-coexistence' }, { text: 'JSON Columns', link: '/efcore/json-columns' }, { text: 'Database Reset for Testing', link: '/efcore/database-cleaner' }, { text: 'Batch Queries', link: '/efcore/batch-queries' } diff --git a/docs/efcore/migration-coexistence.md b/docs/efcore/migration-coexistence.md new file mode 100644 index 00000000..7fda1963 --- /dev/null +++ b/docs/efcore/migration-coexistence.md @@ -0,0 +1,58 @@ +# Mixed EF Core + Critter Stack Applications + +The [migration generation](/efcore/migration-generation) feature is built for applications that combine an EF Core application model with Marten, Wolverine, or Polecat storage in **one database**. This page covers how the pieces coexist. + +## Two migration streams, one database + +A mixed application has two independent migration streams: + +- **Your application's own `DbContext`** with its entities, its migrations, and its `__EFMigrationsHistory` in the default location. +- **The generated Weasel stub context** carrying the critter-stack schema, with its history table **relocated into the critter-stack schema** (e.g. `marten.__EFMigrationsHistory`), so the two never collide. + +Both apply cleanly to the same database; each `dotnet ef` invocation targets one stream via `--context`: + +```bash +dotnet ef database update --context AppDbContext +dotnet ef database update --context MartenSchemaDbContext +``` + +At runtime, register both contexts — the stub context needs nothing beyond a connection string and its history table location (the registration snippet is generated into the stub's XML docs): + +```csharp +services.AddDbContext(o => o.UseNpgsql(connectionString)); +services.AddDbContext(o => + o.UseNpgsql(connectionString, + m => m.MigrationsHistoryTable("__EFMigrationsHistory", "marten"))); +``` + +Multiple `IDatabase` registrations (any mix of Marten + Polecat + Wolverine) produce **per-database migration sets and stub contexts** — select which one to generate for with the same `--database` flag the other `db-*` commands use. For multi-tenancy with a database per tenant, generate **one** migration set and apply the same artifacts against each tenant connection string. + +## The single-owner rule + +**A table is managed by exactly one migration stream.** The generated Weasel migrations own the critter-stack tables; your application context owns its entity tables. Nothing may own a table twice — otherwise the two streams fight over its shape. + +If your application context *maps* critter-stack-managed tables (for querying with EF), exclude them from its migrations: + +```csharp +modelBuilder.Entity() + .ToTable("order_projections", "marten", + t => t.ExcludeFromMigrations()); +``` + +## EF Core projections — the round trip + +The full round-trip story for EF-projected documents: + +1. Your projection `DbContext` defines the projection tables. +2. [`MapToTable` / `GetSchemaObjectsForMigration`](/efcore/table-mapping) turns that model into Weasel schema objects registered with the `IDatabase`. +3. `db-ef-migration add` then includes the projection tables in the generated migrations automatically — one stream owns them end to end. + +Ownership guidance: when a projection table enters the `IDatabase` this way, the **generated Weasel stream owns it** — mark it `ExcludeFromMigrations` in the projection context (which continues to be the query surface). This keeps the single-owner rule intact while both EF (queries) and the critter stack (writes) use the table. + +## Verification + +The test suite proves coexistence and parity in both directions: + +- The dual-schema comparison harness: EF creates a schema → Weasel's delta detection reports `None`, and vice versa. +- The inverted harness: Weasel-defined schemas → generated migrations compiled and applied via the real EF runtime → catalog parity against a Weasel-created schema **and** `SchemaMigration.DetermineAsync` reporting `None`. +- Coexistence scenarios: two generated migration sets (separate schemas and history tables) applying cleanly to one database, and a second application context alongside the stub context. diff --git a/docs/efcore/migration-generation.md b/docs/efcore/migration-generation.md new file mode 100644 index 00000000..fbcf12a4 --- /dev/null +++ b/docs/efcore/migration-generation.md @@ -0,0 +1,184 @@ +# EF Core Migration Generation + +Weasel can generate **EF Core migration files from its own schema model** — the reverse direction of [table mapping](/efcore/table-mapping). Instead of Weasel applying schema changes itself (`db-patch` / `db-apply`), it emits standard, compilable EF Core migration artifacts that your team applies with the tools it already knows: `dotnet ef database update`, idempotent SQL scripts, and migration bundles. + +## When to use which flow + +| | Weasel-native (`db-patch` / `db-apply`) | EF migration generation (`db-ef-migration`) | +|---|---|---| +| Schema is applied by | Weasel at startup or CLI | EF Core toolchain (`dotnet ef`, bundles, scripts) | +| Change history | none (delta against live DB) | versioned migration files + `__EFMigrationsHistory` | +| DBA review artifact | patch SQL file | migration `.cs` files / idempotent script | +| Best for | dev loops, Marten-style auto-migration | teams standardizing on EF migrations for deployment | + +Both flows read the same source of truth: **`IDatabase.AllObjects()`** — Marten system tables, Wolverine envelope storage, Polecat event storage, and [EF-projection tables](/efcore/table-mapping) all flow through one door. + +## The generated artifacts + +`db-ef-migration add ` (or `EfMigrationGenerator.AddAsync`) writes three kinds of files: + +1. **Migration classes** (`_.cs`) — attribute-only `Migration` subclasses carrying `[DbContext]` and `[Migration]` attributes with real `Up()` **and** `Down()` bodies over the public `MigrationBuilder` surface. There is deliberately no `BuildTargetModel` body: the empty target model is fully supported by the EF toolchain (runtime `Migrate()`, CLI update, `--idempotent` scripts, and bundles were all verified end-to-end on EF 9 and EF 10). +2. **A stub `DbContext`** (once per `IDatabase`) — no entities; provider configured; the `__EFMigrationsHistory` table **relocated into the critter-stack schema** so it never collides with your application's own EF context; the EF 9+ `PendingModelChangesWarning` suppressed; plus an `IDesignTimeDbContextFactory` reading the `WEASEL_EF_CONNECTION` environment variable so the EF CLI works without an application host. +3. **A schema snapshot** (`weasel-schema-snapshot.json`) — Weasel's analog of EF's `ModelSnapshot`: design-time JSON written beside the migrations and never compiled. It is the baseline the next `add` diffs against. + +## Getting started + +Generate the first migration for the selected `IDatabase` (same `--database` selection UX as `db-patch`): + +```bash +dotnet run -- db-ef-migration add Initial +``` + +Programmatically: + + + +```cs +// IDatabase is the source of all schema objects — Marten system +// tables, Wolverine envelope storage, EF projection tables, ... +var result = await EfMigrationGenerator.AddAsync( + database, + "Initial", + new EfMigrationGenerationOptions + { + Directory = "WeaselMigrations", + Namespace = "MyApp.WeaselMigrations" + }); + +// first run writes three artifacts: +// result.MigrationFile -> 20260718120000_Initial.cs (attribute-only migration) +// result.ContextFile -> SchemaDbContext.cs (stub context) +// result.SnapshotFile -> weasel-schema-snapshot.json (design-time baseline) +``` +snippet source | anchor + + +Compile the generated files into a project that references the EF provider package, then apply them exactly like any EF migration: + +```bash +export WEASEL_EF_CONNECTION="Host=localhost;Database=app;..." +dotnet ef database update --context MyStoreSchemaDbContext +``` + +Idempotent scripts and bundles work the same way: + +```bash +dotnet ef migrations script --idempotent --context MyStoreSchemaDbContext -o migrations.sql +dotnet ef migrations bundle --context MyStoreSchemaDbContext +``` + +## Incremental migrations + +The next `db-ef-migration add ` diffs the current model against the snapshot **entirely in memory** — no live database, no shadow container — and emits an incremental migration (`AddColumn` / `AlterColumn` / `DropColumn`, index and foreign-key recreation, primary-key changes, sequence changes) with a real reverse-ordered `Down()`. Migration ids are `yyyyMMddHHmmss_Name` with a monotonicity guard, since EF orders migrations by plain string sort of the id. + + + +```cs +var options = new MigrationOperationTranslationOptions(EfMigrationProvider.PostgreSql) +{ + Migrator = new PostgresqlMigrator() +}; + +var table = new PgTable("app.orders"); +table.AddColumn("id").AsPrimaryKey(); +table.AddColumn("name").NotNull(); + +// the serialized snapshot is Weasel's analog of EF's ModelSnapshot: +// design-time JSON written beside the migrations, never compiled +var baseline = EfSchemaSnapshot.FromSchemaObjects(new ISchemaObject[] { table }, options); +var json = baseline.ToJson(); + +// ... later: the model changed +table.AddColumn("tenant_id").NotNull(); +table.ColumnFor("tenant_id")!.DefaultExpression = "'*DEFAULT*'"; +var target = EfSchemaSnapshot.FromSchemaObjects(new ISchemaObject[] { table }, options); + +// diff entirely in memory — no live database, no shadow container +var incremental = EfSnapshotDiffer.Diff(EfSchemaSnapshot.FromJson(json), target, options); +// incremental.UpOperations -> AddColumn tenant_id +// incremental.DownOperations -> DropColumn tenant_id +``` +snippet source | anchor + + +Pass `--against-database` to use the secondary **live-database baseline mode**: Weasel's own delta detection runs against the actual database and the resulting migration SQL (and rollback SQL) is wrapped in `Sql()` operations. This mode handles everything Weasel can migrate, including the cases the snapshot diff deliberately refuses: + + + +```cs +// the secondary mode: let Weasel's own delta detection diff against +// the actual database, and wrap the migration SQL in Sql() operations. +// Handles everything Weasel can migrate — including partition deltas +// and function changes the snapshot diff refuses. +var operations = await EfSnapshotDiffer.DiffAgainstDatabaseAsync(database); +``` +snippet source | anchor + + +## Adopting an existing database + +For a database that already has the schema (a running Marten/Wolverine application), record the generated migrations as applied without executing anything — the EF-sanctioned baselining technique: + +```bash +dotnet run -- db-ef-migration baseline +``` + + + +```cs +// adopt a pre-existing database: record every generated migration file +// as already applied (history rows only, nothing is executed) +var recorded = await EfMigrationGenerator.BaselineAsync( + database, + new EfMigrationGenerationOptions { Directory = "WeaselMigrations" }); +``` +snippet source | anchor + + +Afterwards `dotnet ef database update` reports nothing pending, and future incremental migrations apply on top. + +## The translation layer + +Under the CLI sits a public API: Weasel schema objects translate into EF `MigrationOperation` instances with **raw store type strings everywhere**, so the DDL EF generates matches Weasel's own byte-for-byte — identity columns become the proper provider annotations, computed columns become `ComputedColumnSql`, cascade actions map onto `ReferentialAction` (with SQL Server's `Restrict` ≡ `NO ACTION` normalization), and schemas get `EnsureSchema` operations. + + + +```cs +var table = new PgTable("app.orders"); +table.AddColumn("id").AsPrimaryKey(); +table.AddColumn("name").NotNull(); + +// translate Weasel schema objects into EF Core MigrationOperation +// instances — raw store types everywhere, so the DDL EF generates +// matches Weasel's own +var options = new MigrationOperationTranslationOptions(EfMigrationProvider.PostgreSql) +{ + Migrator = new PostgresqlMigrator() +}; + +var operations = new ISchemaObject[] { table }.ToMigrationOperations(options); +var downOperations = new ISchemaObject[] { table }.ToDropMigrationOperations(options); + +// render as a compilable, attribute-only migration file +var migration = EfMigrationFileEmitter.EmitMigration( + "AddOrders", operations, downOperations, + new EfMigrationEmissionOptions("AppSchemaDbContext")); +``` +snippet source | anchor + + +## Limitations and raw-SQL fallbacks + +Everything EF cannot model routes through `migrationBuilder.Sql(...)` blocks carrying Weasel's own DDL, so these work from day one: + +- **PostgreSQL table partitioning** (RANGE/LIST/HASH and the managed strategies) — partitioned tables are detected automatically and emitted as raw DDL (the Npgsql EF provider has no partitioning model). +- **PL/pgSQL functions, SQL Server stored procedures and table types**. +- **Expression indexes**, and **SQL Server unique indexes without a filter** (EF's SqlServer generator would otherwise add a spurious `WHERE ... IS NOT NULL` filter, because an attribute-only migration has no model to prove the columns non-nullable). + +Deliberate boundaries: + +- `dotnet ef migrations add` / `remove` are **not supported against the stub context** — Weasel authors the migrations; the EF scaffolder needs the model snapshot the stub deliberately doesn't have. Use `db-ef-migration add`. +- Changed raw-SQL objects (a partition layout change, a rewritten function body) are refused by the snapshot diff with guidance — generate that migration with `--against-database` or author it by hand. +- Renames are not inferred from the model (Weasel carries no rename intent); today a rename diffs as drop + add. +- v1 providers are **PostgreSQL and SQL Server**. SQLite is out: its ALTER-emulation rebuilds tables from the migration's target model, which attribute-only migrations don't carry. +- The `PendingModelChangesWarning` suppression baked into the stub context is defensive: with no `ModelSnapshot` at all the EF 9+ pending-changes check has nothing to fire on, but the suppression protects anyone who later adds entities to the same context. diff --git a/docs/efcore/migrations.md b/docs/efcore/migrations.md index ad18db75..c4a23575 100644 --- a/docs/efcore/migrations.md +++ b/docs/efcore/migrations.md @@ -1,6 +1,11 @@ # Migrations -Weasel's EF Core integration provides methods to detect schema differences and apply migrations using the same delta-detection engine that powers Weasel's core migration infrastructure. +Weasel's EF Core integration supports schema migration in **two directions**: + +1. **Weasel applies the schema** (this page) — the EF Core model is mapped into Weasel schema objects and Weasel's delta-detection engine detects and applies changes directly, exactly like it does for Marten. +2. **Weasel generates EF Core migration files** — the reverse: Weasel's schema model (including everything an `IDatabase` carries) is emitted as standard, compilable EF Core migrations that your team applies with `dotnet ef database update`, idempotent scripts, or bundles. See [EF Core Migration Generation](/efcore/migration-generation) and the [coexistence guide](/efcore/migration-coexistence). + +The rest of this page covers the first direction: detecting schema differences and applying migrations using the same delta-detection engine that powers Weasel's core migration infrastructure. ## Creating a Migration diff --git a/src/DocSamples/EfCoreMigrationSamples.cs b/src/DocSamples/EfCoreMigrationSamples.cs new file mode 100644 index 00000000..8b6dba6d --- /dev/null +++ b/src/DocSamples/EfCoreMigrationSamples.cs @@ -0,0 +1,108 @@ +using Weasel.Core; +using Weasel.Core.Migrations; +using Weasel.EntityFrameworkCore; +using Weasel.EntityFrameworkCore.CommandLine; +using Weasel.Postgresql; +using PgTable = Weasel.Postgresql.Tables.Table; + +namespace DocSamples; + +public class EfCoreMigrationSamples +{ + public async Task generate_first_migration(IDatabase database) + { + #region sample_efgen_add_migration + // IDatabase is the source of all schema objects — Marten system + // tables, Wolverine envelope storage, EF projection tables, ... + var result = await EfMigrationGenerator.AddAsync( + database, + "Initial", + new EfMigrationGenerationOptions + { + Directory = "WeaselMigrations", + Namespace = "MyApp.WeaselMigrations" + }); + + // first run writes three artifacts: + // result.MigrationFile -> 20260718120000_Initial.cs (attribute-only migration) + // result.ContextFile -> SchemaDbContext.cs (stub context) + // result.SnapshotFile -> weasel-schema-snapshot.json (design-time baseline) + #endregion + } + + public void translate_a_table() + { + #region sample_efgen_translate_table + var table = new PgTable("app.orders"); + table.AddColumn("id").AsPrimaryKey(); + table.AddColumn("name").NotNull(); + + // translate Weasel schema objects into EF Core MigrationOperation + // instances — raw store types everywhere, so the DDL EF generates + // matches Weasel's own + var options = new MigrationOperationTranslationOptions(EfMigrationProvider.PostgreSql) + { + Migrator = new PostgresqlMigrator() + }; + + var operations = new ISchemaObject[] { table }.ToMigrationOperations(options); + var downOperations = new ISchemaObject[] { table }.ToDropMigrationOperations(options); + + // render as a compilable, attribute-only migration file + var migration = EfMigrationFileEmitter.EmitMigration( + "AddOrders", operations, downOperations, + new EfMigrationEmissionOptions("AppSchemaDbContext")); + #endregion + } + + public void diff_against_the_snapshot() + { + #region sample_efgen_snapshot_diff + var options = new MigrationOperationTranslationOptions(EfMigrationProvider.PostgreSql) + { + Migrator = new PostgresqlMigrator() + }; + + var table = new PgTable("app.orders"); + table.AddColumn("id").AsPrimaryKey(); + table.AddColumn("name").NotNull(); + + // the serialized snapshot is Weasel's analog of EF's ModelSnapshot: + // design-time JSON written beside the migrations, never compiled + var baseline = EfSchemaSnapshot.FromSchemaObjects(new ISchemaObject[] { table }, options); + var json = baseline.ToJson(); + + // ... later: the model changed + table.AddColumn("tenant_id").NotNull(); + table.ColumnFor("tenant_id")!.DefaultExpression = "'*DEFAULT*'"; + var target = EfSchemaSnapshot.FromSchemaObjects(new ISchemaObject[] { table }, options); + + // diff entirely in memory — no live database, no shadow container + var incremental = EfSnapshotDiffer.Diff(EfSchemaSnapshot.FromJson(json), target, options); + // incremental.UpOperations -> AddColumn tenant_id + // incremental.DownOperations -> DropColumn tenant_id + #endregion + } + + public async Task live_database_baseline(IDatabase database) + { + #region sample_efgen_live_database_diff + // the secondary mode: let Weasel's own delta detection diff against + // the actual database, and wrap the migration SQL in Sql() operations. + // Handles everything Weasel can migrate — including partition deltas + // and function changes the snapshot diff refuses. + var operations = await EfSnapshotDiffer.DiffAgainstDatabaseAsync(database); + #endregion + } + + public async Task baseline_an_existing_database(IDatabase database) + { + #region sample_efgen_baseline + // adopt a pre-existing database: record every generated migration file + // as already applied (history rows only, nothing is executed) + var recorded = await EfMigrationGenerator.BaselineAsync( + database, + new EfMigrationGenerationOptions { Directory = "WeaselMigrations" }); + #endregion + } +}