Skip to content

Latest commit

 

History

History
177 lines (152 loc) · 9.84 KB

File metadata and controls

177 lines (152 loc) · 9.84 KB

EF Core → Weasel Mapping Improvements (July 2026 Sweep)

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 comparison harness

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:

  1. EF Core creates the schema via its own GenerateCreateScript() (the exact DDL EF migrations would produce), and a neutral catalog introspector (querying pg_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.
  2. 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.
  3. 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).
  4. 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.

Bugs found and fixed

All of these existed on master before this sweep:

  1. 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 on master because of this. Fix: a new opt-in ITable.PreserveIdentifierCase seam (set automatically by the EF Core mapper), identifier quoting in the PostgreSQL PK / FK / index DDL, and case-insensitive delta comparison throughout (ItemDelta matching, TableColumn equality, PK columns, FK names/columns). All-lowercase callers (i.e. Marten) emit byte-identical DDL as before.

  2. 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 to DROP. A CreateOrUpdate migration against an EF-created schema would have destroyed EF's indexes. Fix: a new provider-neutral ITableIndex abstraction with ITable.AddIndex(...), implemented across all five providers through a TableBase factory hook. The mapper now maps entityType.GetIndexes() (composite, unique, custom-named, filtered, and covering/INCLUDE indexes) plus alternate keys (HasAlternateKey) as unique indexes.

  3. Literal default values (HasDefaultValue(...)) were silently dropped. Only HasDefaultValueSql was mapped. Literal defaults are now rendered through EF's own relational type mapping (GenerateSqlLiteral), so int/bool/string/Guid/DateTime/decimal/enum literals — including enum-to-string conversions — match EF migration output exactly (CAST(1 AS bit), N'...', TRUE, ...).

  4. Delete behaviors were mapped incorrectly. ClientSetNull (EF's default for optional relationships), ClientCascade, and ClientNoAction are enforced client-side; EF emits no ON DELETE clause for them, but Weasel mapped them to SET NULL / CASCADE. They now map to no action, and SQL Server (which has no RESTRICT) normalizes Restrict ≡ NO ACTION during FK comparison.

  5. Identity / value generation was not mapped. EF expects GENERATED BY DEFAULT AS IDENTITY (PostgreSQL) or IDENTITY(1,1) (SQL Server) for conventional integer keys, but Weasel created plain integer columns — inserts through EF failed against Weasel-created tables. ITableColumn gained IsAutoNumber (four providers already modeled it; PostgreSQL now emits GENERATED BY DEFAULT AS IDENTITY), and the mapper detects the provider ValueGenerationStrategy annotations 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.

  6. SQL Server covering-index introspection was wrong. FetchExisting read INCLUDE columns as key columns, so every covering index produced an endless drop/recreate delta. The catalog query now honors is_included_column. Also fixed: the rowversiontimestamp type synonym, quote characters leaking out of FK catalog parsing, and PostgreSQL RawType() mishandling mid-type precision such as timestamp(3) with time zone.

Mapper correctness fixes that fell out of the sweep:

  • TPH hierarchies use IsColumnNullable per store object, so required properties of derived types correctly map to nullable columns.
  • Table-split owned entities (OwnsOne without ToTable) now contribute their Nav_Prop columns 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.

Test coverage added

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)

Known gaps (documented, deliberate)

  • Per-column descending sort needs the customizeTables escape hatch (provider index methods like gin/gist now map automatically via ITableIndex.Method).
  • Npgsql UseIdentityAlwaysColumn is created as GENERATED 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).

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 previously ran in no CI workflow (which is how a deterministically failing test sat unnoticed on master). 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 shared marten_testing / weasel_testing database mid-run, killing every concurrent test's connections.
  • TestTfmsInParallel=false for 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).