This document describes a comprehensive sweep of the Weasel.EntityFrameworkCore
subsystem that maps EF Core DbContext models into Weasel table definitions.
The goal was to verify — permutation by permutation — that the tables and
indexes Weasel generates for a DbContext match what EF Core's own migration
system creates, and to fix every divergence found.
The heart of the sweep is a dual-schema comparison harness
(src/Weasel.EntityFrameworkCore.Tests/SchemaComparison/). For each
permutation DbContext, scoped to a dedicated database schema:
- EF Core creates the schema via its own
GenerateCreateScript()(the exact DDL EF migrations would produce), and a neutral catalog introspector (queryingpg_catalog/sys.*directly, independent of both EF and Weasel) snapshots the result: columns, types, nullability, defaults, identity, primary keys, foreign keys with delete actions, indexes with filters/INCLUDE columns/sort order, and check constraints. - Weasel's delta detection runs against that EF-created schema — it must
report
SchemaPatchDifference.None. Weasel must never want to "migrate" a schema EF Core just created. - The schema is dropped and Weasel creates it from scratch from the mapped model; the catalog is snapshotted again and Weasel's delta is re-run against its own work (idempotency).
- The two snapshots are diffed field by field. Because both sides are read back from the same catalog, the database has already canonicalized expression text — remaining differences are real.
Provider-specific index features that the provider-neutral mapping cannot
express (Npgsql HasMethod("gin"), descending sort) are supported through a
customizeTables hook that downcasts to the concrete provider Table, and the
test suite demonstrates that escape hatch.
All of these existed on master before this sweep:
-
PostgreSQL identifier casing (fatal for default-convention models). EF Core emits quoted PascalCase identifiers (
"BlogId"), while Weasel folded all column names to lowercase — so EF's own SQL could not find the columns in a Weasel-created table. An existing test (can_apply_migration_with_fk_dependencies) was failing onmasterbecause of this. Fix: a new opt-inITable.PreserveIdentifierCaseseam (set automatically by the EF Core mapper), identifier quoting in the PostgreSQL PK / FK / index DDL, and case-insensitive delta comparison throughout (ItemDeltamatching,TableColumnequality, PK columns, FK names/columns). All-lowercase callers (i.e. Marten) emit byte-identical DDL as before. -
Indexes were not mapped at all — and Weasel would drop EF's indexes. EF Core creates an
IX_*index for every foreign key by convention, and Weasel's delta detection treats indexes it doesn't know about as extras toDROP. ACreateOrUpdatemigration against an EF-created schema would have destroyed EF's indexes. Fix: a new provider-neutralITableIndexabstraction withITable.AddIndex(...), implemented across all five providers through aTableBasefactory hook. The mapper now mapsentityType.GetIndexes()(composite, unique, custom-named, filtered, and covering/INCLUDE indexes) plus alternate keys (HasAlternateKey) as unique indexes. -
Literal default values (
HasDefaultValue(...)) were silently dropped. OnlyHasDefaultValueSqlwas mapped. Literal defaults are now rendered through EF's own relational type mapping (GenerateSqlLiteral), soint/bool/string/Guid/DateTime/decimal/enum literals — including enum-to-string conversions — match EF migration output exactly (CAST(1 AS bit),N'...',TRUE, ...). -
Delete behaviors were mapped incorrectly.
ClientSetNull(EF's default for optional relationships),ClientCascade, andClientNoActionare enforced client-side; EF emits noON DELETEclause for them, but Weasel mapped them toSET NULL/CASCADE. They now map to no action, and SQL Server (which has noRESTRICT) normalizes Restrict ≡ NO ACTION during FK comparison. -
Identity / value generation was not mapped. EF expects
GENERATED BY DEFAULT AS IDENTITY(PostgreSQL) orIDENTITY(1,1)(SQL Server) for conventional integer keys, but Weasel created plain integer columns — inserts through EF failed against Weasel-created tables.ITableColumngainedIsAutoNumber(four providers already modeled it; PostgreSQL now emitsGENERATED BY DEFAULT AS IDENTITY), and the mapper detects the providerValueGenerationStrategyannotations with the correct suppressions: no identity for columns with defaults, for TPT/owned linking keys (per store object, so the TPT base table keeps its identity), or for non-integral types. -
SQL Server covering-index introspection was wrong.
FetchExistingread INCLUDE columns as key columns, so every covering index produced an endless drop/recreate delta. The catalog query now honorsis_included_column. Also fixed: therowversion↔timestamptype synonym, quote characters leaking out of FK catalog parsing, and PostgreSQLRawType()mishandling mid-type precision such astimestamp(3) with time zone.
Mapper correctness fixes that fell out of the sweep:
- TPH hierarchies use
IsColumnNullableper store object, so required properties of derived types correctly map to nullable columns. - Table-split owned entities (
OwnsOnewithoutToTable) now contribute theirNav_Propcolumns to the owner's table (previously missing entirely). - Owned entities mapped to their own table (
OwnsOne/OwnsMany+ToTable) are included in migrations again (an over-correction from #234 had excluded all owned types). - Row-internal linking FKs (an owned type sharing its owner's table and key) are skipped, exactly as EF migrations skip them.
Nineteen comparison tests across the permutation matrix:
| Area | PostgreSQL | SQL Server |
|---|---|---|
Baseline conventions (identity keys, FK + IX_*, string keys) |
✅ | ✅ (incl. nvarchar(450) key columns) |
| Literal defaults + default SQL | ✅ | ✅ |
Composite PKs / composite FKs / HasColumnOrder |
✅ | — |
| Indexes: unique, composite, named, filtered, INCLUDE | ✅ | ✅ (incl. EF's automatic IS NOT NULL filter on unique-nullable indexes) |
gin / descending via customizeTables escape hatch |
✅ | — |
| Alternate keys + FKs targeting them | ✅ | — |
| Implicit many-to-many join tables | ✅ | — |
| Self-reference, shadow FKs, delete-behavior matrix | ✅ | ✅ |
| Inheritance: TPH (discriminator, folded FKs), TPT (PK-as-FK) | ✅ | — |
Owned entities: table splitting, ToTable, OwnsMany |
✅ | — |
String/precision facets (HasMaxLength, HasPrecision, fixed length) |
✅ | — |
| Identity strategies (by-default, always, never, Guid) | ✅ | ✅ |
rowversion concurrency tokens |
(xmin covered by existing tests) | ✅ |
| Check constraints (documented gap) | ✅ | — |
- Per-column descending sort needs the
customizeTablesescape hatch (provider index methods like gin/gist now map automatically viaITableIndex.Method). - Npgsql
UseIdentityAlwaysColumnis created asGENERATED BY DEFAULT(more permissive; inserts behave identically through EF). - Alternate keys are created as unique indexes rather than unique constraints — functionally equivalent, including as FK targets, and reported as a tolerated difference category by the harness.
- Column-default drift detection is opt-in (
ITable.DetectColumnDrift) — it stays off by default because canonicalizing datetime literals across providers is not stable enough to be safe for existing Marten schemas.
Previously listed gaps now closed: check constraints (modeled with
conservative delta comparison), computed columns (HasComputedColumnSql
maps to ITableColumn.ComputedExpression, is read back by FetchExisting
on PostgreSQL and SQL Server, and participates in delta detection with
canonicalized expression comparison), and HiLo / HasSequence sequences
(mapped through Migrator.CreateSequence).
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.
- New CI workflow
ci-build-efcore.yml— the EF Core test project previously ran in no CI workflow (which is how a deterministically failing test sat unnoticed onmaster). It runs net9.0/net10.0 against PostgreSQL and SQL Server services, excluding the MySql/Oracle suites that have no services in CI. EnsureDeletedAsync()removed from the PostgreSQL / SQL Server end-to-end tests: it dropped the entire sharedmarten_testing/weasel_testingdatabase mid-run, killing every concurrent test's connections.TestTfmsInParallel=falsefor the EF test project (the net9 and net10 runs raced on shared schemas), plus xunit collections serializing test classes that share a schema and the SQL Server comparison suites (Azure SQL Edge deadlocks on concurrent DDL).