From 7ed310384fbe2d3d5f7c7170d354a1866b18f2a2 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Sat, 18 Jul 2026 16:10:11 -0500 Subject: [PATCH 1/7] =?UTF-8?q?feat(efcore):=20Weasel=20model=20=E2=86=92?= =?UTF-8?q?=20EF=20MigrationOperation=20translation=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #365. First implementation phase of the EF Core migration generation epic (#371), building on the #364 spike results. - New MigrationOperationTranslation in Weasel.EntityFrameworkCore: walks the provider-neutral surface (ITable/ITableColumn/ITableIndex/ ForeignKeyBase/SequenceBase) and produces EF Core MigrationOperation instances — the reverse of MapToTable. Raw store type strings (ColumnType) everywhere so EF's CLR mapping is bypassed and DDL matches Weasel exactly; the CLR type is a best-effort inverse used only for the Column() generic in emitted C# - CreateTable with nested columns / primary key / check constraints / foreign keys, one CreateIndex per index, EnsureSchema per non-default schema (deduplicated; default public/dbo emitted as null Schema like EF's own scaffolding), CreateSequence from SequenceBase - Provider specifics: identity → Npgsql:ValueGenerationStrategy or SqlServer:Identity annotations; computed columns → ComputedColumnSql + IsStored (always stored on PG); index includes/method annotations; CascadeAction → ReferentialAction with SQL Server Restrict ≡ NoAction mirroring mapDeleteBehavior - Raw-SQL fallback: non-table/non-sequence objects (functions, sprocs, table types) and anything matched by the ForceRawSql hook (e.g. partitioned tables) are wrapped in SqlOperation carrying the object's own WriteCreateStatement DDL; expression indexes throw with guidance to use the hook - ToDropMigrationOperations for Down() bodies: reverse-order DropTable / DropSequence / raw drops; schemas never dropped (may be shared with Marten/Wolverine) - Weasel.Core additions: ITable.Columns and ITableIndex.Columns expose the column collections on the neutral surface (implicitly satisfied by every provider's concrete types) 13 new DB-free unit tests; all provider suites green locally. Co-Authored-By: Claude Fable 5 --- src/Weasel.Core/ITableColumn.cs | 5 + src/Weasel.Core/ITableIndex.cs | 7 + src/Weasel.Core/TableBase.cs | 3 + .../MigrationOperations/translation_layer.cs | 313 +++++++++++++ .../MigrationOperationTranslation.cs | 443 ++++++++++++++++++ 5 files changed, 771 insertions(+) create mode 100644 src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/translation_layer.cs create mode 100644 src/Weasel.EntityFrameworkCore/MigrationOperationTranslation.cs diff --git a/src/Weasel.Core/ITableColumn.cs b/src/Weasel.Core/ITableColumn.cs index e8f72fa0..35745816 100644 --- a/src/Weasel.Core/ITableColumn.cs +++ b/src/Weasel.Core/ITableColumn.cs @@ -37,6 +37,11 @@ public interface ITableColumn : INamed public interface ITable : ISchemaObject { + /// + /// The columns defined for this table, in declaration order + /// + IReadOnlyList Columns { get; } + IReadOnlyList PrimaryKeyColumns { get; } string PrimaryKeyName { get; set; } diff --git a/src/Weasel.Core/ITableIndex.cs b/src/Weasel.Core/ITableIndex.cs index b6a84607..6ed88feb 100644 --- a/src/Weasel.Core/ITableIndex.cs +++ b/src/Weasel.Core/ITableIndex.cs @@ -9,6 +9,13 @@ namespace Weasel.Core; /// public interface ITableIndex : INamed { + /// + /// The key columns of the index, in order. May be null or empty for + /// expression-based indexes on providers that support them (PostgreSQL, + /// SQLite) — those indexes carry the expression on the concrete type. + /// + string[]? Columns { get; set; } + /// Whether this is a UNIQUE index bool IsUnique { get; set; } diff --git a/src/Weasel.Core/TableBase.cs b/src/Weasel.Core/TableBase.cs index 7650559b..84f94dec 100644 --- a/src/Weasel.Core/TableBase.cs +++ b/src/Weasel.Core/TableBase.cs @@ -202,6 +202,9 @@ public string ToBasicCreateTableSql() // hooks so the ITable surface is implemented exactly once here and providers // only specialise the type-resolution + factory calls. + IReadOnlyList ITable.Columns + => _columns.OfType().ToList(); + IReadOnlyList ITable.ForeignKeys => ForeignKeys.Cast().ToList(); diff --git a/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/translation_layer.cs b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/translation_layer.cs new file mode 100644 index 00000000..13b2191d --- /dev/null +++ b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/translation_layer.cs @@ -0,0 +1,313 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Shouldly; +using Weasel.Core; +using Weasel.EntityFrameworkCore; +using Weasel.Postgresql; +using Xunit; +using Weasel.Postgresql.Tables; +using PgTable = Weasel.Postgresql.Tables.Table; +using PgSequence = Weasel.Postgresql.Sequence; +using PgIndex = Weasel.Postgresql.Tables.IndexDefinition; +using SsTable = Weasel.SqlServer.Tables.Table; + +namespace Weasel.EntityFrameworkCore.Tests.MigrationOperations; + +/// +/// DB-free tests for the Weasel model → EF MigrationOperation translation +/// layer (#365). End-to-end validation (operations → SQL → schema +/// comparison) belongs to the inverted-harness issue. +/// +public class translation_layer +{ + private static MigrationOperationTranslationOptions pgOptions() => + new(EfMigrationProvider.PostgreSql) { Migrator = new PostgresqlMigrator() }; + + private static MigrationOperationTranslationOptions ssOptions() => + new(EfMigrationProvider.SqlServer) { Migrator = new Weasel.SqlServer.SqlServerMigrator() }; + + [Fact] + public void translates_a_postgresql_table_to_a_create_table_operation() + { + var table = new PgTable("app.orders"); + table.AddColumn("id").AsPrimaryKey(); + // set IsAutoNumber directly — the modern identity flag the EF mapping + // uses (the fluent AutoIncrement() swaps the type to legacy SERIAL) + table.ColumnFor("id")!.IsAutoNumber = true; + table.AddColumn("name").NotNull(); + table.AddColumn("payload", "jsonb"); + table.ColumnFor("name")!.DefaultExpression = "'unknown'"; + ((ITable)table).AddCheckConstraint("ck_orders_name", "length(name) > 0"); + + var operations = ((ITable)table).ToMigrationOperations(pgOptions()); + + operations[0].ShouldBeOfType().Name.ShouldBe("app"); + + var create = operations.OfType().Single(); + create.Name.ShouldBe("orders"); + create.Schema.ShouldBe("app"); + + var id = create.Columns.Single(x => x.Name == "id"); + id.ColumnType.ShouldBe("integer"); + id.ClrType.ShouldBe(typeof(int)); + id.IsNullable.ShouldBeFalse(); + id[MigrationOperationTranslation.NpgsqlValueGenerationStrategy].ShouldBe("IdentityByDefaultColumn"); + + var name = create.Columns.Single(x => x.Name == "name"); + name.IsNullable.ShouldBeFalse(); + name.DefaultValueSql.ShouldBe("'unknown'"); + + var payload = create.Columns.Single(x => x.Name == "payload"); + payload.ColumnType.ShouldBe("jsonb"); + payload.IsNullable.ShouldBeTrue(); + + create.PrimaryKey.ShouldNotBeNull(); + create.PrimaryKey!.Columns.ShouldBe(new[] { "id" }); + create.PrimaryKey.Name.ShouldBe(table.PrimaryKeyName); + + var check = create.CheckConstraints.Single(); + check.Name.ShouldBe("ck_orders_name"); + check.Sql.ShouldBe("length(name) > 0"); + } + + [Fact] + public void default_schema_is_omitted_and_gets_no_ensure_schema() + { + var pg = new PgTable("public.things"); + pg.AddColumn("id").AsPrimaryKey(); + + var pgOps = ((ITable)pg).ToMigrationOperations(pgOptions()); + pgOps.OfType().ShouldBeEmpty(); + pgOps.OfType().Single().Schema.ShouldBeNull(); + + var ss = new SsTable("dbo.things"); + ss.AddColumn("id").AsPrimaryKey(); + + var ssOps = ((ITable)ss).ToMigrationOperations(ssOptions()); + ssOps.OfType().ShouldBeEmpty(); + ssOps.OfType().Single().Schema.ShouldBeNull(); + } + + [Fact] + public void sql_server_identity_annotation() + { + var table = new SsTable("dbo.orders"); + table.AddColumn("id").AsPrimaryKey().AutoIncrement(); + + var create = ((ITable)table).ToMigrationOperations(ssOptions()) + .OfType().Single(); + + create.Columns.Single(x => x.Name == "id")[MigrationOperationTranslation.SqlServerIdentity] + .ShouldBe("1, 1"); + } + + [Fact] + public void computed_columns_translate_to_computed_column_sql() + { + var pg = new PgTable("public.people"); + pg.AddColumn("first_name"); + pg.AddColumn("last_name"); + pg.AddColumn("full_name", "text"); + pg.ColumnFor("full_name")!.ComputedExpression = "first_name || ' ' || last_name"; + + var create = ((ITable)pg).ToMigrationOperations(pgOptions()) + .OfType().Single(); + + var fullName = create.Columns.Single(x => x.Name == "full_name"); + fullName.ComputedColumnSql.ShouldBe("first_name || ' ' || last_name"); + fullName.IsStored.ShouldBe(true); + + var ss = new SsTable("dbo.people"); + ss.AddColumn("first_name"); + ss.AddColumn("full_name"); + ss.ColumnFor("full_name")!.ComputedExpression = "first_name + '!'"; + + var ssCreate = ((ITable)ss).ToMigrationOperations(ssOptions()) + .OfType().Single(); + + var ssFullName = ssCreate.Columns.Single(x => x.Name == "full_name"); + ssFullName.ComputedColumnSql.ShouldBe("first_name + '!'"); + ssFullName.IsStored.ShouldBe(false); + } + + [Fact] + public void indexes_translate_with_filter_includes_and_method() + { + var table = new PgTable("public.docs"); + table.AddColumn("id").AsPrimaryKey(); + table.AddColumn("kind"); + table.AddColumn("payload", "jsonb"); + + var unique = new PgIndex("idx_docs_kind") + { + IsUnique = true, + Columns = new[] { "kind" }, + Predicate = "kind is not null", + IncludeColumns = new[] { "payload" } + }; + table.Indexes.Add(unique); + + var gin = new PgIndex("idx_docs_payload") + { + Columns = new[] { "payload" }, Method = IndexMethod.gin + }; + table.Indexes.Add(gin); + + var operations = ((ITable)table).ToMigrationOperations(pgOptions()); + var indexes = operations.OfType().ToArray(); + + var uniqueOp = indexes.Single(x => x.Name == "idx_docs_kind"); + uniqueOp.IsUnique.ShouldBeTrue(); + uniqueOp.Columns.ShouldBe(new[] { "kind" }); + uniqueOp.Filter.ShouldBe("kind is not null"); + uniqueOp[MigrationOperationTranslation.NpgsqlIndexInclude].ShouldBe(new[] { "payload" }); + + var ginOp = indexes.Single(x => x.Name == "idx_docs_payload"); + ginOp[MigrationOperationTranslation.NpgsqlIndexMethod].ShouldBe("gin"); + } + + [Fact] + public void expression_index_without_columns_is_not_supported() + { + var table = new PgTable("public.docs"); + table.AddColumn("id").AsPrimaryKey(); + table.Indexes.Add(new PgIndex("idx_expression")); + + Should.Throw(() => ((ITable)table).ToMigrationOperations(pgOptions())) + .Message.ShouldContain("idx_expression"); + } + + [Fact] + public void foreign_keys_translate_with_referential_actions() + { + var table = new PgTable("app.orders"); + table.AddColumn("id").AsPrimaryKey(); + table.AddColumn("customer_id") + .ForeignKeyTo("app.customers", "id", "fk_orders_customer", + Weasel.Core.CascadeAction.SetNull, Weasel.Core.CascadeAction.NoAction); + + var create = ((ITable)table).ToMigrationOperations(pgOptions()) + .OfType().Single(); + + var fk = create.ForeignKeys.Single(); + fk.Name.ShouldBe("fk_orders_customer"); + fk.Columns.ShouldBe(new[] { "customer_id" }); + fk.PrincipalTable.ShouldBe("customers"); + fk.PrincipalSchema.ShouldBe("app"); + fk.PrincipalColumns.ShouldBe(new[] { "id" }); + fk.OnDelete.ShouldBe(ReferentialAction.SetNull); + } + + [Fact] + public void restrict_normalizes_to_no_action_on_sql_server_only() + { + var pg = new PgTable("public.orders"); + pg.AddColumn("id").AsPrimaryKey(); + pg.AddColumn("customer_id") + .ForeignKeyTo("public.customers", "id", "fk_pg", + Weasel.Core.CascadeAction.Restrict, Weasel.Core.CascadeAction.NoAction); + + ((ITable)pg).ToMigrationOperations(pgOptions()) + .OfType().Single() + .ForeignKeys.Single().OnDelete.ShouldBe(ReferentialAction.Restrict); + + var ss = new SsTable("dbo.orders"); + ss.AddColumn("id").AsPrimaryKey(); + var fk = ((ITable)ss).AddForeignKey("fk_ss", new DbObjectName("dbo", "customers"), + new[] { "customer_id" }, new[] { "id" }); + fk.DeleteAction = Weasel.Core.CascadeAction.Restrict; + ss.AddColumn("customer_id"); + + ((ITable)ss).ToMigrationOperations(ssOptions()) + .OfType().Single() + .ForeignKeys.Single().OnDelete.ShouldBe(ReferentialAction.NoAction); + } + + [Fact] + public void sequences_translate_to_create_sequence_operations() + { + var sequence = new PgSequence(new DbObjectName("app", "order_numbers"), 100) + { + IncrementBy = 10 + }; + + var operations = new ISchemaObject[] { sequence }.ToMigrationOperations(pgOptions()); + + operations.OfType().Single().Name.ShouldBe("app"); + var create = operations.OfType().Single(); + create.Name.ShouldBe("order_numbers"); + create.Schema.ShouldBe("app"); + create.StartValue.ShouldBe(100); + create.IncrementBy.ShouldBe(10); + } + + [Fact] + public void force_raw_sql_routes_a_table_through_its_own_ddl() + { + var table = new PgTable("app.partitioned"); + table.AddColumn("id").AsPrimaryKey(); + table.AddColumn("tenant_id").AsPrimaryKey(); + table.PartitionByList("tenant_id"); + + var options = pgOptions(); + options.ForceRawSql = o => o is PgTable { Partitioning: not null }; + + var operations = ((ITable)table).ToMigrationOperations(options); + + var sql = operations.OfType().Single(); + sql.Sql.ShouldContain("CREATE TABLE", Case.Insensitive); + sql.Sql.ShouldContain("app.partitioned", Case.Insensitive); + sql.Sql.ShouldContain("PARTITION BY LIST", Case.Insensitive); + operations.OfType().ShouldBeEmpty(); + } + + [Fact] + public void raw_sql_fallback_without_a_migrator_throws() + { + var table = new PgTable("app.partitioned"); + table.AddColumn("id").AsPrimaryKey(); + + var options = new MigrationOperationTranslationOptions(EfMigrationProvider.PostgreSql) + { + ForceRawSql = _ => true + }; + + Should.Throw(() => ((ITable)table).ToMigrationOperations(options)) + .Message.ShouldContain("Migrator"); + } + + [Fact] + public void drop_operations_reverse_the_object_order() + { + var sequence = new PgSequence(new DbObjectName("app", "order_numbers")); + var customers = new PgTable("app.customers"); + customers.AddColumn("id").AsPrimaryKey(); + var orders = new PgTable("app.orders"); + orders.AddColumn("id").AsPrimaryKey(); + + var operations = new ISchemaObject[] { sequence, customers, orders } + .ToDropMigrationOperations(pgOptions()); + + operations.Count.ShouldBe(3); + operations[0].ShouldBeOfType().Name.ShouldBe("orders"); + operations[1].ShouldBeOfType().Name.ShouldBe("customers"); + var dropSequence = operations[2].ShouldBeOfType(); + dropSequence.Name.ShouldBe("order_numbers"); + dropSequence.Schema.ShouldBe("app"); + } + + [Fact] + public void preserve_identifier_case_flows_through_to_operation_names() + { + var table = new PgTable("public.Blogs"); + table.PreserveIdentifierCase = true; + table.AddColumn("BlogId").AsPrimaryKey(); + table.AddColumn("Url").NotNull(); + + var create = ((ITable)table).ToMigrationOperations(pgOptions()) + .OfType().Single(); + + create.Name.ShouldBe("Blogs"); + create.Columns.Select(x => x.Name).ShouldBe(new[] { "BlogId", "Url" }); + } +} diff --git a/src/Weasel.EntityFrameworkCore/MigrationOperationTranslation.cs b/src/Weasel.EntityFrameworkCore/MigrationOperationTranslation.cs new file mode 100644 index 00000000..437b2f80 --- /dev/null +++ b/src/Weasel.EntityFrameworkCore/MigrationOperationTranslation.cs @@ -0,0 +1,443 @@ +using JasperFx.Core; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Weasel.Core; + +namespace Weasel.EntityFrameworkCore; + +/// +/// Which EF Core relational provider the translated operations target. +/// Determines the provider annotations emitted for identity columns and +/// index extensions, and the referential-action normalization. +/// +public enum EfMigrationProvider +{ + PostgreSql, + SqlServer +} + +/// +/// Options for . +/// +public class MigrationOperationTranslationOptions +{ + public MigrationOperationTranslationOptions(EfMigrationProvider provider) + { + Provider = provider; + } + + public EfMigrationProvider Provider { get; } + + /// + /// The provider's default schema ("public" for PostgreSQL, "dbo" for + /// SQL Server). Objects in the default schema are emitted with a null + /// Schema — matching what EF Core's own migration scaffolding does — + /// and no EnsureSchema operation is generated for it. + /// + public string DefaultSchema => Provider == EfMigrationProvider.PostgreSql ? "public" : "dbo"; + + /// + /// Used to render raw-SQL fallback operations (schema objects EF cannot + /// model: partitioned tables, functions, stored procedures, ...) through + /// the object's own . + /// Required whenever a fallback is actually needed. + /// + public Migrator? Migrator { get; set; } + + /// + /// Force specific schema objects down the raw-SQL fallback path even + /// though they could be translated — e.g. a PostgreSQL partitioned + /// table, which EF has no model for (npgsql/efcore.pg#1035). Callers + /// with provider references downcast here. + /// + public Func? ForceRawSql { get; set; } + + /// + /// Annotation value written for identity columns on PostgreSQL. The + /// in-memory default is the enum member's string name + /// ("IdentityByDefaultColumn"); the C# migration file emitter renders + /// it as the proper NpgsqlValueGenerationStrategy literal. + /// Callers that feed the operations directly to the Npgsql + /// migrations SQL generator should overwrite this with the actual + /// NpgsqlValueGenerationStrategy.IdentityByDefaultColumn enum + /// value (Weasel.EntityFrameworkCore deliberately does not reference + /// the provider package). + /// + public object NpgsqlIdentityAnnotationValue { get; set; } = "IdentityByDefaultColumn"; +} + +/// +/// Translates Weasel's strongly-typed schema model into EF Core +/// instances — the reverse direction of +/// MapToTable (EF model → Weasel) in . +/// The operation list is the intermediate representation consumed by the +/// C# migration file emitter; raw store type strings are used everywhere +/// () so EF's CLR type mapping +/// is bypassed and the resulting DDL matches Weasel's own exactly. +/// +public static class MigrationOperationTranslation +{ + // annotation names, spelled as string literals so this project needs no + // provider package references + public const string NpgsqlValueGenerationStrategy = "Npgsql:ValueGenerationStrategy"; + public const string NpgsqlIndexMethod = "Npgsql:IndexMethod"; + public const string NpgsqlIndexInclude = "Npgsql:IndexInclude"; + public const string SqlServerIdentity = "SqlServer:Identity"; + public const string SqlServerIndexInclude = "SqlServer:Include"; + + /// + /// Translate a set of Weasel schema objects into the ordered operation + /// list for an EF Core migration's Up() body: EnsureSchema operations + /// first (deduplicated, non-default schemas only), then each object in + /// the given order (callers are responsible for foreign-key dependency + /// ordering, e.g. via the same topological sort MapToTables uses). + /// Tables and sequences are translated structurally; everything else — + /// and any object matched by — + /// is wrapped in a holding its own CREATE DDL. + /// + public static IReadOnlyList ToMigrationOperations( + this IEnumerable schemaObjects, + MigrationOperationTranslationOptions options) + { + var objects = schemaObjects.ToArray(); + var operations = new List(); + + foreach (var schema in objects + .Select(x => x.Identifier.Schema) + .Where(s => s.IsNotEmpty() && !s.EqualsIgnoreCase(options.DefaultSchema)) + .Distinct(StringComparer.OrdinalIgnoreCase)) + { + operations.Add(new EnsureSchemaOperation { Name = schema }); + } + + foreach (var schemaObject in objects) + { + operations.AddRange(translateObject(schemaObject, options)); + } + + return operations; + } + + /// + /// Translate the reverse (Down()) operation list for the same set of + /// schema objects: raw-SQL drops, then DropTable / DropSequence in + /// reverse order. Schemas are deliberately never dropped — they may be + /// shared with other tools (Marten, Wolverine, ...). + /// + public static IReadOnlyList ToDropMigrationOperations( + this IEnumerable schemaObjects, + MigrationOperationTranslationOptions options) + { + var operations = new List(); + + foreach (var schemaObject in schemaObjects.Reverse()) + { + if (options.ForceRawSql?.Invoke(schemaObject) == true || schemaObject is not (ITable or SequenceBase)) + { + operations.Add(rawSql(schemaObject, options, drop: true)); + } + else if (schemaObject is ITable table) + { + operations.Add(new DropTableOperation + { + Name = table.Identifier.Name, Schema = schemaFor(table.Identifier.Schema, options) + }); + } + else if (schemaObject is SequenceBase sequence) + { + operations.Add(new DropSequenceOperation + { + Name = sequence.Identifier.Name, Schema = schemaFor(sequence.Identifier.Schema, options) + }); + } + } + + return operations; + } + + /// + /// Translate a single Weasel table into its EF operations: an optional + /// EnsureSchema, the CreateTable (with columns, primary key, check + /// constraints and foreign keys nested), then one CreateIndex per index. + /// + public static IReadOnlyList ToMigrationOperations( + this ITable table, + MigrationOperationTranslationOptions options) + { + return new ISchemaObject[] { table }.ToMigrationOperations(options); + } + + private static IEnumerable translateObject( + ISchemaObject schemaObject, + MigrationOperationTranslationOptions options) + { + if (options.ForceRawSql?.Invoke(schemaObject) == true) + { + yield return rawSql(schemaObject, options, drop: false); + yield break; + } + + switch (schemaObject) + { + case ITable table: + foreach (var operation in translateTable(table, options)) yield return operation; + break; + + case SequenceBase sequence: + yield return new CreateSequenceOperation + { + Name = sequence.Identifier.Name, + Schema = schemaFor(sequence.Identifier.Schema, options), + ClrType = typeof(long), + StartValue = sequence.StartWith ?? 1L, + IncrementBy = (int)(sequence.IncrementBy ?? 1L) + }; + break; + + default: + // functions, stored procedures, table types, extensions, ... — + // EF has no model for these; carry the object's own DDL + yield return rawSql(schemaObject, options, drop: false); + break; + } + } + + private static IEnumerable translateTable( + ITable table, + MigrationOperationTranslationOptions options) + { + var tableName = table.Identifier.Name; + var schema = schemaFor(table.Identifier.Schema, options); + + var createTable = new CreateTableOperation { Name = tableName, Schema = schema }; + + foreach (var column in table.Columns) + { + createTable.Columns.Add(translateColumn(table, column, tableName, schema, options)); + } + + if (table.PrimaryKeyColumns.Any()) + { + createTable.PrimaryKey = new AddPrimaryKeyOperation + { + Name = table.PrimaryKeyName, + Table = tableName, + Schema = schema, + Columns = table.PrimaryKeyColumns.ToArray() + }; + } + + foreach (var check in table.CheckConstraints) + { + createTable.CheckConstraints.Add(new AddCheckConstraintOperation + { + Name = check.Name, Table = tableName, Schema = schema, Sql = check.Expression + }); + } + + foreach (var foreignKey in table.ForeignKeys) + { + createTable.ForeignKeys.Add(translateForeignKey(foreignKey, tableName, schema, options)); + } + + yield return createTable; + + foreach (var index in table.Indexes) + { + yield return translateIndex(index, tableName, schema, options); + } + } + + private static AddColumnOperation translateColumn( + ITable table, + ITableColumn column, + string tableName, + string? schema, + MigrationOperationTranslationOptions options) + { + var operation = new AddColumnOperation + { + Name = column.Name, + Table = tableName, + Schema = schema, + // ColumnType always wins over the CLR mapping in generated DDL; + // the CLR type is a best-effort inverse used only for the + // table.Column(...) generic argument in emitted C# + ClrType = clrTypeFor(column.Type), + ColumnType = column.Type, + IsNullable = column.AllowNulls && !column.IsPrimaryKey, + DefaultValueSql = column.DefaultExpression + }; + + if (column.ComputedExpression.IsNotEmpty()) + { + operation.ComputedColumnSql = column.ComputedExpression; + // PostgreSQL only supports stored generated columns + operation.IsStored = options.Provider == EfMigrationProvider.PostgreSql || column.ComputedColumnIsStored; + } + + if (column.IsAutoNumber) + { + switch (options.Provider) + { + case EfMigrationProvider.PostgreSql: + operation.AddAnnotation(NpgsqlValueGenerationStrategy, options.NpgsqlIdentityAnnotationValue); + break; + case EfMigrationProvider.SqlServer: + operation.AddAnnotation(SqlServerIdentity, "1, 1"); + break; + } + } + + return operation; + } + + private static CreateIndexOperation translateIndex( + ITableIndex index, + string tableName, + string? schema, + MigrationOperationTranslationOptions options) + { + if (index.Columns == null || index.Columns.Length == 0) + { + throw new NotSupportedException( + $"Index '{index.Name}' on {schema ?? "?"}.{tableName} has no key columns — " + + "expression-based indexes cannot be expressed as an EF CreateIndex operation. " + + $"Route the table through {nameof(MigrationOperationTranslationOptions.ForceRawSql)} instead."); + } + + var operation = new CreateIndexOperation + { + Name = index.Name, + Table = tableName, + Schema = schema, + Columns = index.Columns, + IsUnique = index.IsUnique, + Filter = index.Predicate + }; + + if (index.IncludeColumns is { Length: > 0 }) + { + operation.AddAnnotation( + options.Provider == EfMigrationProvider.PostgreSql ? NpgsqlIndexInclude : SqlServerIndexInclude, + index.IncludeColumns); + } + + if (index.Method.IsNotEmpty() && options.Provider == EfMigrationProvider.PostgreSql && + !index.Method!.EqualsIgnoreCase("btree")) + { + operation.AddAnnotation(NpgsqlIndexMethod, index.Method); + } + + return operation; + } + + private static AddForeignKeyOperation translateForeignKey( + ForeignKeyBase foreignKey, + string tableName, + string? schema, + MigrationOperationTranslationOptions options) + { + if (foreignKey.LinkedTable == null) + { + throw new MisconfiguredForeignKeyException( + $"Foreign key '{foreignKey.Name}' on {schema ?? "?"}.{tableName} has no linked table"); + } + + return new AddForeignKeyOperation + { + Name = foreignKey.Name, + Table = tableName, + Schema = schema, + Columns = foreignKey.ColumnNames, + PrincipalTable = foreignKey.LinkedTable.Name, + PrincipalSchema = schemaFor(foreignKey.LinkedTable.Schema, options), + PrincipalColumns = foreignKey.LinkedNames, + OnDelete = referentialActionFor(foreignKey.DeleteAction, options), + OnUpdate = referentialActionFor(foreignKey.UpdateAction, options) + }; + } + + private static ReferentialAction referentialActionFor( + CascadeAction action, + MigrationOperationTranslationOptions options) + { + return action switch + { + CascadeAction.Cascade => ReferentialAction.Cascade, + CascadeAction.SetNull => ReferentialAction.SetNull, + CascadeAction.SetDefault => ReferentialAction.SetDefault, + // SQL Server has no RESTRICT — it is spelled NO ACTION, mirroring + // the mapDeleteBehavior normalization in the EF → Weasel direction + CascadeAction.Restrict when options.Provider == EfMigrationProvider.SqlServer => + ReferentialAction.NoAction, + CascadeAction.Restrict => ReferentialAction.Restrict, + _ => ReferentialAction.NoAction + }; + } + + private static SqlOperation rawSql( + ISchemaObject schemaObject, + MigrationOperationTranslationOptions options, + bool drop) + { + if (options.Migrator == null) + { + throw new InvalidOperationException( + $"{schemaObject.Identifier.QualifiedName} ({schemaObject.GetType().Name}) requires the raw-SQL " + + $"fallback, so {nameof(MigrationOperationTranslationOptions)}.{nameof(MigrationOperationTranslationOptions.Migrator)} must be provided"); + } + + var writer = new StringWriter(); + if (drop) + { + schemaObject.WriteDropStatement(options.Migrator, writer); + } + else + { + schemaObject.WriteCreateStatement(options.Migrator, writer); + } + + return new SqlOperation { Sql = writer.ToString() }; + } + + private static string? schemaFor(string? schema, MigrationOperationTranslationOptions options) + { + if (schema.IsEmpty() || schema!.EqualsIgnoreCase(options.DefaultSchema)) + { + return null; + } + + return schema; + } + + /// + /// Best-effort inverse of the provider type mappings, used only for the + /// table.Column<T>(...) generic argument in emitted C# — the raw + /// ColumnType string is what actually drives the DDL. + /// + private static Type clrTypeFor(string storeType) + { + var raw = storeType.ToLowerInvariant().Split('(')[0].Trim(); + + return raw switch + { + "int" or "integer" or "int4" or "serial" => typeof(int), + "bigint" or "int8" or "bigserial" => typeof(long), + "smallint" or "int2" or "smallserial" => typeof(short), + "tinyint" => typeof(byte), + "bit" or "boolean" or "bool" => typeof(bool), + "real" or "float4" => typeof(float), + "float" or "double precision" or "float8" => typeof(double), + "decimal" or "numeric" or "money" or "smallmoney" => typeof(decimal), + "uuid" or "uniqueidentifier" => typeof(Guid), + "date" => typeof(DateOnly), + "time" or "time without time zone" => typeof(TimeOnly), + "timestamp" or "timestamp without time zone" or "datetime" or "datetime2" or "smalldatetime" + => typeof(DateTime), + "timestamptz" or "timestamp with time zone" or "datetimeoffset" => typeof(DateTimeOffset), + "bytea" or "varbinary" or "binary" or "image" or "rowversion" => typeof(byte[]), + _ => typeof(string) + }; + } +} From 850ac0075124adace415820f730440b946c8dfd7 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Sat, 18 Jul 2026 16:17:58 -0500 Subject: [PATCH 2/7] feat(efcore): C# migration file emitter + stub DbContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #366. Second implementation phase of the EF Core migration generation epic (#371), rendering the #365 operation lists into compilable attribute-only migration files. - EfMigrationFileEmitter.EmitMigration renders Up/Down operation lists over the stable public MigrationBuilder surface — deliberately not EF's pubternal CSharpMigrationsGenerator (dotnet/efcore#23595). Generated migrations carry [DbContext]/[Migration] attributes and no BuildTargetModel body, per the #364 spike verification - Renders EnsureSchema, CreateTable (nested columns with raw store types, PK incl. composite, check constraints, FKs with referential actions), CreateIndex (unique/filter + annotations), CreateSequence, Sql (verbatim strings), DropTable, DropSequence; the Npgsql:ValueGenerationStrategy annotation is rendered as the real NpgsqlValueGenerationStrategy enum literal with the using added on demand; unknown operations/annotations throw rather than emitting wrong code - Column names map to anonymous-type members with @-escaping for reserved words and name:-argument fallback for non-identifier names - Migration ids are yyyyMMddHHmmss_Name UTC with a monotonicity guard: LastMigrationId bumps the timestamp until the new id sorts strictly after (EF orders by plain string sort) - EmitStubContext generates the no-entity host context: provider configured, history table relocated into the critter-stack schema, EF 9+ PendingModelChangesWarning suppressed, registration snippet in the XML docs, plus an IDesignTimeDbContextFactory reading WEASEL_EF_CONNECTION so dotnet ef update/script/bundle work without an application host Testing: the generated sample files (from a Weasel schema with sequence, identity, checks, FK, filtered index) are CHECKED IN and compiled as part of the test project — the "generated files compile" acceptance — with a drift-guard test proving they are byte-for-byte emitter output, and an end-to-end test applying them through the real EF runtime against PostgreSQL, round-tripping the schema against Weasel's own delta detection (no changes), and migrating back down to zero. Co-Authored-By: Claude Fable 5 --- .../20260718120000_WeaselSampleSchema.cs | 75 +++ .../SampleGenerated/SampleWeaselSchema.cs | 79 +++ .../SampleGenerated/WeaselSampleDbContext.cs | 75 +++ .../migration_file_emitter.cs | 183 +++++ .../EfMigrationFileEmitter.cs | 628 ++++++++++++++++++ 5 files changed, 1040 insertions(+) create mode 100644 src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/SampleGenerated/20260718120000_WeaselSampleSchema.cs create mode 100644 src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/SampleGenerated/SampleWeaselSchema.cs create mode 100644 src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/SampleGenerated/WeaselSampleDbContext.cs create mode 100644 src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/migration_file_emitter.cs create mode 100644 src/Weasel.EntityFrameworkCore/EfMigrationFileEmitter.cs diff --git a/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/SampleGenerated/20260718120000_WeaselSampleSchema.cs b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/SampleGenerated/20260718120000_WeaselSampleSchema.cs new file mode 100644 index 00000000..c826272e --- /dev/null +++ b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/SampleGenerated/20260718120000_WeaselSampleSchema.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +namespace Weasel.EntityFrameworkCore.Tests.MigrationOperations.SampleGenerated; + +[DbContext(typeof(WeaselSampleDbContext))] +[Migration("20260718120000_WeaselSampleSchema")] +public partial class WeaselSampleSchema : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema(name: "efgen"); + + migrationBuilder.CreateSequence( + name: "order_numbers", + schema: "efgen", + startValue: 1000L, + incrementBy: 10); + + migrationBuilder.CreateTable( + name: "customers", + schema: "efgen", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + name = table.Column(type: "varchar", nullable: false, defaultValueSql: "'unknown'") + }, + constraints: table => + { + table.PrimaryKey("pkey_customers_id", x => x.id); + table.CheckConstraint("ck_customers_name", "length(name) > 0"); + }); + + migrationBuilder.CreateTable( + name: "orders", + schema: "efgen", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + customer_id = table.Column(type: "integer", nullable: false), + payload = table.Column(type: "jsonb", nullable: true), + status = table.Column(type: "varchar", nullable: false, defaultValueSql: "'pending'") + }, + constraints: table => + { + table.PrimaryKey("pkey_orders_id", x => x.id); + table.ForeignKey( + name: "fk_orders_customer", + column: x => x.customer_id, + principalSchema: "efgen", + principalTable: "customers", + principalColumn: "id", + onDelete: ReferentialAction.Cascade, + onUpdate: ReferentialAction.NoAction); + }); + + migrationBuilder.CreateIndex( + name: "idx_orders_status", + schema: "efgen", + table: "orders", + column: "status", + filter: "status <> 'archived'"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "orders", schema: "efgen"); + + migrationBuilder.DropTable(name: "customers", schema: "efgen"); + + migrationBuilder.DropSequence(name: "order_numbers", schema: "efgen"); + } +} diff --git a/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/SampleGenerated/SampleWeaselSchema.cs b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/SampleGenerated/SampleWeaselSchema.cs new file mode 100644 index 00000000..d31fee58 --- /dev/null +++ b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/SampleGenerated/SampleWeaselSchema.cs @@ -0,0 +1,79 @@ +using Weasel.Core; +using Weasel.EntityFrameworkCore; +using Weasel.Postgresql; +using PgTable = Weasel.Postgresql.Tables.Table; +using PgSequence = Weasel.Postgresql.Sequence; +using PgIndex = Weasel.Postgresql.Tables.IndexDefinition; + +namespace Weasel.EntityFrameworkCore.Tests.MigrationOperations.SampleGenerated; + +/// +/// The Weasel schema whose generated migration + stub context are checked in +/// next to this file. The emitter drift-guard test regenerates both and +/// compares byte-for-byte; the integration test applies the checked-in +/// migration through the EF runtime and round-trips it against Weasel's own +/// delta detection. +/// +public static class SampleWeaselSchema +{ + public const string SchemaName = "efgen"; + public const string MigrationName = "WeaselSampleSchema"; + public const string ContextTypeName = "WeaselSampleDbContext"; + + public static readonly DateTime Timestamp = new(2026, 7, 18, 12, 0, 0, DateTimeKind.Utc); + + public static ISchemaObject[] Objects() + { + var numbers = new PgSequence(new DbObjectName(SchemaName, "order_numbers"), 1000) { IncrementBy = 10 }; + + var customers = new PgTable($"{SchemaName}.customers"); + customers.AddColumn("id").AsPrimaryKey(); + customers.ColumnFor("id")!.IsAutoNumber = true; + customers.AddColumn("name").NotNull(); + customers.ColumnFor("name")!.DefaultExpression = "'unknown'"; + ((ITable)customers).AddCheckConstraint("ck_customers_name", "length(name) > 0"); + + var orders = new PgTable($"{SchemaName}.orders"); + orders.AddColumn("id").AsPrimaryKey(); + orders.AddColumn("customer_id").NotNull(); + orders.AddColumn("payload", "jsonb"); + orders.AddColumn("status").NotNull(); + orders.ColumnFor("status")!.DefaultExpression = "'pending'"; + ((ITable)orders).AddForeignKey("fk_orders_customer", + new DbObjectName(SchemaName, "customers"), new[] { "customer_id" }, new[] { "id" }) + .DeleteAction = Weasel.Core.CascadeAction.Cascade; + + var statusIndex = new PgIndex("idx_orders_status") + { + Columns = new[] { "status" }, Predicate = "status <> 'archived'" + }; + orders.Indexes.Add(statusIndex); + + return new ISchemaObject[] { numbers, customers, orders }; + } + + public static MigrationOperationTranslationOptions TranslationOptions() + => new(EfMigrationProvider.PostgreSql) { Migrator = new PostgresqlMigrator() }; + + public static EfMigrationEmissionOptions EmissionOptions() + => new(ContextTypeName) + { + Namespace = "Weasel.EntityFrameworkCore.Tests.MigrationOperations.SampleGenerated", + TimestampUtc = Timestamp + }; + + public static EfMigrationFile GenerateMigration() + { + var objects = Objects(); + var options = TranslationOptions(); + return EfMigrationFileEmitter.EmitMigration( + MigrationName, + objects.ToMigrationOperations(options), + objects.ToDropMigrationOperations(options), + EmissionOptions()); + } + + public static string GenerateStubContext() + => EfMigrationFileEmitter.EmitStubContext( + EfMigrationProvider.PostgreSql, EmissionOptions(), SchemaName); +} diff --git a/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/SampleGenerated/WeaselSampleDbContext.cs b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/SampleGenerated/WeaselSampleDbContext.cs new file mode 100644 index 00000000..00cca0a7 --- /dev/null +++ b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/SampleGenerated/WeaselSampleDbContext.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Weasel.EntityFrameworkCore.Tests.MigrationOperations.SampleGenerated; + +/// +/// Stub DbContext generated by Weasel to host the Weasel-authored EF Core +/// migrations. It has no entities on purpose — the migrations are +/// attribute-only and carry their own operations. +/// Register at startup with: +/// +/// services.AddDbContext<WeaselSampleDbContext>(o => +/// o.UseNpgsql(connectionString, +/// m => m.MigrationsHistoryTable("__EFMigrationsHistory", "efgen"))); +/// +/// +/// or apply from the command line (reads the WEASEL_EF_CONNECTION +/// environment variable): dotnet ef database update --context WeaselSampleDbContext +/// +/// +public partial class WeaselSampleDbContext : DbContext +{ + public WeaselSampleDbContext() + { + } + + public WeaselSampleDbContext(DbContextOptions options) : base(options) + { + } + + /// + /// Connection string used when the context is constructed without + /// options (e.g. by the EF design-time tools). Set it at startup or + /// let the design-time factory read WEASEL_EF_CONNECTION. + /// + public static string? ConnectionString { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (!optionsBuilder.IsConfigured) + { + optionsBuilder.UseNpgsql( + ConnectionString + ?? Environment.GetEnvironmentVariable("WEASEL_EF_CONNECTION") + ?? throw new InvalidOperationException( + "Set WeaselSampleDbContext.ConnectionString or the WEASEL_EF_CONNECTION environment variable"), + m => m.MigrationsHistoryTable("__EFMigrationsHistory", "efgen")); + } + + // EF 9+ throws from Migrate() when the context model does not match the + // last migration; harmless here (attribute-only migrations carry no + // model snapshot) but suppressed defensively + optionsBuilder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)); + } +} + +/// +/// Design-time factory so `dotnet ef database update --context WeaselSampleDbContext`, `dotnet ef migrations script` and +/// `dotnet ef migrations bundle` work without an application host. +/// +public partial class WeaselSampleDbContextFactory : IDesignTimeDbContextFactory +{ + public WeaselSampleDbContext CreateDbContext(string[] args) + { + var builder = new DbContextOptionsBuilder(); + builder.UseNpgsql( + WeaselSampleDbContext.ConnectionString + ?? Environment.GetEnvironmentVariable("WEASEL_EF_CONNECTION") + ?? throw new InvalidOperationException( + "Set the WEASEL_EF_CONNECTION environment variable for design-time use"), + m => m.MigrationsHistoryTable("__EFMigrationsHistory", "efgen")); + return new WeaselSampleDbContext(builder.Options); + } +} diff --git a/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/migration_file_emitter.cs b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/migration_file_emitter.cs new file mode 100644 index 00000000..02113080 --- /dev/null +++ b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/migration_file_emitter.cs @@ -0,0 +1,183 @@ +using System.Runtime.CompilerServices; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Npgsql; +using Shouldly; +using Weasel.EntityFrameworkCore.Tests.MigrationOperations.SampleGenerated; +using Weasel.EntityFrameworkCore.Tests.Postgresql; +using Xunit; + +namespace Weasel.EntityFrameworkCore.Tests.MigrationOperations; + +/// +/// The C# migration file emitter (#366). The generated sample files are +/// checked in next to SampleWeaselSchema and compiled as part of this test +/// project — which IS the "generated files compile" acceptance — and the +/// drift-guard test proves they are byte-for-byte what the emitter produces. +/// +public class migration_file_emitter +{ + private static string sampleDirectory([CallerFilePath] string path = "") + => Path.Combine(Path.GetDirectoryName(path)!, "SampleGenerated"); + + [Fact] + public void checked_in_migration_file_matches_regenerated_output() + { + var migration = SampleWeaselSchema.GenerateMigration(); + + migration.MigrationId.ShouldBe("20260718120000_WeaselSampleSchema"); + migration.FileName.ShouldBe("20260718120000_WeaselSampleSchema.cs"); + + var checkedIn = File.ReadAllText(Path.Combine(sampleDirectory(), migration.FileName)); + migration.Code.ShouldBe(checkedIn); + } + + [Fact] + public void checked_in_stub_context_matches_regenerated_output() + { + var checkedIn = File.ReadAllText( + Path.Combine(sampleDirectory(), $"{SampleWeaselSchema.ContextTypeName}.cs")); + + SampleWeaselSchema.GenerateStubContext().ShouldBe(checkedIn); + } + + [Fact] + public void migration_ids_are_bumped_past_the_previous_id() + { + var options = SampleWeaselSchema.EmissionOptions(); + options.LastMigrationId = "20260718120000_WeaselSampleSchema"; + + var migration = EfMigrationFileEmitter.EmitMigration( + "SecondMigration", + Array.Empty(), + Array.Empty(), + options); + + // same configured second as the previous id — bumped forward so the + // plain string sort EF uses puts it after + migration.MigrationId.ShouldBe("20260718120001_SecondMigration"); + string.Compare(migration.MigrationId, options.LastMigrationId, StringComparison.Ordinal) + .ShouldBeGreaterThan(0); + } + + [Fact] + public void migration_names_are_sanitized_into_class_names() + { + var migration = EfMigrationFileEmitter.EmitMigration( + "add tenant-id!", + Array.Empty(), + Array.Empty(), + SampleWeaselSchema.EmissionOptions()); + + migration.ClassName.ShouldBe("add_tenant_id_"); + migration.Code.ShouldContain("public partial class add_tenant_id_ : Migration"); + } + + [Fact] + public void sql_operations_render_as_verbatim_strings() + { + var sql = new SqlOperation { Sql = "comment on table t is 'has \"quotes\"';" }; + + var migration = EfMigrationFileEmitter.EmitMigration( + "RawSql", new[] { sql }, Array.Empty(), + SampleWeaselSchema.EmissionOptions()); + + migration.Code.ShouldContain("migrationBuilder.Sql(@\"comment on table t is 'has \"\"quotes\"\"';\");"); + } + + [Fact] + public void reserved_word_column_names_are_escaped() + { + var createTable = new CreateTableOperation { Name = "t" }; + createTable.Columns.Add(new AddColumnOperation + { + Name = "default", Table = "t", ClrType = typeof(int), ColumnType = "integer", IsNullable = false + }); + createTable.PrimaryKey = new AddPrimaryKeyOperation + { + Name = "pk_t", Table = "t", Columns = new[] { "default" } + }; + + var migration = EfMigrationFileEmitter.EmitMigration( + "Reserved", new MigrationOperation[] { createTable }, Array.Empty(), + SampleWeaselSchema.EmissionOptions()); + + migration.Code.ShouldContain("@default = table.Column("); + migration.Code.ShouldContain("table.PrimaryKey(\"pk_t\", x => x.@default);"); + } + + [Fact] + public void sql_server_stub_context_uses_the_sql_server_provider() + { + var code = EfMigrationFileEmitter.EmitStubContext( + EfMigrationProvider.SqlServer, + new EfMigrationEmissionOptions("WolverineSchemaDbContext"), + "wolverine"); + + code.ShouldContain("UseSqlServer("); + code.ShouldContain("MigrationsHistoryTable(\"__EFMigrationsHistory\", \"wolverine\")"); + code.ShouldContain("class WolverineSchemaDbContext : DbContext"); + code.ShouldContain("class WolverineSchemaDbContextFactory : IDesignTimeDbContextFactory"); + code.ShouldContain("PendingModelChangesWarning"); + } +} + +/// +/// End-to-end: the checked-in generated migration + stub context are applied +/// through the real EF runtime against PostgreSQL, round-tripped against +/// Weasel's own delta detection, and migrated back down. +/// +[Collection("pg-schema-comparison")] +public class generated_migration_end_to_end : IAsyncLifetime +{ + public async Task InitializeAsync() + { + WeaselSampleDbContext.ConnectionString = PostgresqlDbContext.ConnectionString; + + await using var conn = new NpgsqlConnection(PostgresqlDbContext.ConnectionString); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = $"drop schema if exists {SampleWeaselSchema.SchemaName} cascade;"; + await cmd.ExecuteNonQueryAsync(); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task applies_via_ef_round_trips_against_weasel_and_migrates_down() + { + await using var context = new WeaselSampleDbContext(); + + await context.Database.MigrateAsync(); + + var applied = (await context.Database.GetAppliedMigrationsAsync()).ToArray(); + applied.ShouldBe(new[] { "20260718120000_WeaselSampleSchema" }); + + // the EF-created schema must satisfy Weasel's own delta detection + await using (var conn = new NpgsqlConnection(PostgresqlDbContext.ConnectionString)) + { + await conn.OpenAsync(); + foreach (var table in SampleWeaselSchema.Objects().OfType()) + { + var delta = await table.FindDeltaAsync(conn); + delta.HasChanges().ShouldBeFalse( + $"table {table.Identifier} should round-trip, but had {delta.Difference}"); + } + } + + // and Down() takes it all back out + await context.GetService().MigrateAsync("0"); + + await using (var conn = new NpgsqlConnection(PostgresqlDbContext.ConnectionString)) + { + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = + $"select count(*) from information_schema.tables where table_schema = '{SampleWeaselSchema.SchemaName}' and table_name in ('customers', 'orders')"; + var count = (long)(await cmd.ExecuteScalarAsync())!; + count.ShouldBe(0); + } + } +} diff --git a/src/Weasel.EntityFrameworkCore/EfMigrationFileEmitter.cs b/src/Weasel.EntityFrameworkCore/EfMigrationFileEmitter.cs new file mode 100644 index 00000000..5ae5e47a --- /dev/null +++ b/src/Weasel.EntityFrameworkCore/EfMigrationFileEmitter.cs @@ -0,0 +1,628 @@ +using System.Globalization; +using System.Text; +using JasperFx.Core; +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace Weasel.EntityFrameworkCore; + +/// +/// A generated EF Core migration source file. +/// +public class EfMigrationFile +{ + public EfMigrationFile(string migrationId, string className, string code) + { + MigrationId = migrationId; + ClassName = className; + Code = code; + } + + /// The EF migration id, e.g. "20260718123456_InitialWeaselSchema" + public string MigrationId { get; } + + /// The migration class name, e.g. "InitialWeaselSchema" + public string ClassName { get; } + + /// Suggested file name: "<MigrationId>.cs" + public string FileName => $"{MigrationId}.cs"; + + /// The full C# source of the migration file + public string Code { get; } +} + +/// +/// Options for . +/// +public class EfMigrationEmissionOptions +{ + public EfMigrationEmissionOptions(string contextTypeName) + { + if (string.IsNullOrWhiteSpace(contextTypeName)) + { + throw new ArgumentException("contextTypeName must not be null or blank", nameof(contextTypeName)); + } + + ContextTypeName = contextTypeName; + } + + /// + /// The stub DbContext type name the migrations bind to via + /// [DbContext(typeof(...))], e.g. "MartenSchemaDbContext". + /// + public string ContextTypeName { get; } + + /// Namespace for the generated files + public string Namespace { get; set; } = "WeaselMigrations"; + + /// + /// UTC timestamp used for the migration id. Defaults to the current UTC + /// time; fix it for deterministic output in tests. + /// + public DateTime? TimestampUtc { get; set; } + + /// + /// The id of the previously generated migration, if any. EF orders + /// migrations by plain string sort of the id, so when a new id would not + /// sort after this one (e.g. two migrations generated within the same + /// second) the timestamp is bumped forward until it does. + /// + public string? LastMigrationId { get; set; } +} + +/// +/// Renders translated lists (see +/// ) into compilable, attribute-only +/// C# migration files plus the stub DbContext that hosts them. This is a small +/// hand-rolled emitter over the stable public MigrationBuilder fluent surface — +/// deliberately not EF's internal CSharpMigrationsGenerator scaffolding stack, +/// which is pubternal and unsupported for programmatic use (dotnet/efcore#23595). +/// The generated migrations carry no BuildTargetModel body: the empty target +/// model is legal and only `dotnet ef migrations remove` would miss it +/// (verified end-to-end by the #364 spike). +/// +public static class EfMigrationFileEmitter +{ + private static readonly HashSet ReservedWords = new() + { + "abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char", "checked", "class", "const", + "continue", "decimal", "default", "delegate", "do", "double", "else", "enum", "event", "explicit", "extern", + "false", "finally", "fixed", "float", "for", "foreach", "goto", "if", "implicit", "in", "int", "interface", + "internal", "is", "lock", "long", "namespace", "new", "null", "object", "operator", "out", "override", + "params", "private", "protected", "public", "readonly", "ref", "return", "sbyte", "sealed", "short", + "sizeof", "stackalloc", "static", "string", "struct", "switch", "this", "throw", "true", "try", "typeof", + "uint", "ulong", "unchecked", "unsafe", "ushort", "using", "virtual", "void", "volatile", "while" + }; + + /// + /// Render one migration file from the translated Up and Down operation + /// lists. + /// + public static EfMigrationFile EmitMigration( + string name, + IReadOnlyList upOperations, + IReadOnlyList downOperations, + EfMigrationEmissionOptions options) + { + var className = sanitizeIdentifier(name); + var migrationId = buildMigrationId(className, options); + + var body = new StringBuilder(); + + var usings = new SortedSet(StringComparer.Ordinal) + { + "Microsoft.EntityFrameworkCore.Infrastructure", + "Microsoft.EntityFrameworkCore.Migrations" + }; + + var allColumns = upOperations.Concat(downOperations) + .SelectMany(op => op is CreateTableOperation createTable + ? createTable.Columns.Cast() + : new[] { op }); + if (allColumns.Any(op => op[MigrationOperationTranslation.NpgsqlValueGenerationStrategy] != null)) + { + usings.Add("Npgsql.EntityFrameworkCore.PostgreSQL.Metadata"); + } + + foreach (var u in usings) + { + body.AppendLine($"using {u};"); + } + + body.AppendLine(); + body.AppendLine($"namespace {options.Namespace};"); + body.AppendLine(); + body.AppendLine($"[DbContext(typeof({options.ContextTypeName}))]"); + body.AppendLine($"[Migration(\"{migrationId}\")]"); + body.AppendLine($"public partial class {className} : Migration"); + body.AppendLine("{"); + body.AppendLine(" protected override void Up(MigrationBuilder migrationBuilder)"); + body.AppendLine(" {"); + writeOperations(body, upOperations); + body.AppendLine(" }"); + body.AppendLine(); + body.AppendLine(" protected override void Down(MigrationBuilder migrationBuilder)"); + body.AppendLine(" {"); + writeOperations(body, downOperations); + body.AppendLine(" }"); + body.AppendLine("}"); + + return new EfMigrationFile(migrationId, className, body.ToString()); + } + + /// + /// Render the stub DbContext that hosts the generated migrations: no + /// entities, provider configured, migrations history table relocated into + /// the given schema so it never collides with the application's own EF + /// context, and the EF 9+ pending-model-changes warning suppressed. A + /// design-time factory driven by the WEASEL_EF_CONNECTION environment + /// variable is included so `dotnet ef database update` works without an + /// application host. + /// + public static string EmitStubContext( + EfMigrationProvider provider, + EfMigrationEmissionOptions options, + string historySchema, + string historyTableName = "__EFMigrationsHistory") + { + var contextName = options.ContextTypeName; + var useMethod = provider == EfMigrationProvider.PostgreSql ? "UseNpgsql" : "UseSqlServer"; + var cli = $"dotnet ef database update --context {contextName}"; + + return $@"using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace {options.Namespace}; + +/// +/// Stub DbContext generated by Weasel to host the Weasel-authored EF Core +/// migrations. It has no entities on purpose — the migrations are +/// attribute-only and carry their own operations. +/// Register at startup with: +/// +/// services.AddDbContext<{contextName}>(o => +/// o.{useMethod}(connectionString, +/// m => m.MigrationsHistoryTable(""{historyTableName}"", ""{historySchema}""))); +/// +/// +/// or apply from the command line (reads the WEASEL_EF_CONNECTION +/// environment variable): {cli} +/// +/// +public partial class {contextName} : DbContext +{{ + public {contextName}() + {{ + }} + + public {contextName}(DbContextOptions<{contextName}> options) : base(options) + {{ + }} + + /// + /// Connection string used when the context is constructed without + /// options (e.g. by the EF design-time tools). Set it at startup or + /// let the design-time factory read WEASEL_EF_CONNECTION. + /// + public static string? ConnectionString {{ get; set; }} + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + {{ + if (!optionsBuilder.IsConfigured) + {{ + optionsBuilder.{useMethod}( + ConnectionString + ?? Environment.GetEnvironmentVariable(""WEASEL_EF_CONNECTION"") + ?? throw new InvalidOperationException( + ""Set {contextName}.ConnectionString or the WEASEL_EF_CONNECTION environment variable""), + m => m.MigrationsHistoryTable(""{historyTableName}"", ""{historySchema}"")); + }} + + // EF 9+ throws from Migrate() when the context model does not match the + // last migration; harmless here (attribute-only migrations carry no + // model snapshot) but suppressed defensively + optionsBuilder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)); + }} +}} + +/// +/// Design-time factory so `{cli}`, `dotnet ef migrations script` and +/// `dotnet ef migrations bundle` work without an application host. +/// +public partial class {contextName}Factory : IDesignTimeDbContextFactory<{contextName}> +{{ + public {contextName} CreateDbContext(string[] args) + {{ + var builder = new DbContextOptionsBuilder<{contextName}>(); + builder.{useMethod}( + {contextName}.ConnectionString + ?? Environment.GetEnvironmentVariable(""WEASEL_EF_CONNECTION"") + ?? throw new InvalidOperationException( + ""Set the WEASEL_EF_CONNECTION environment variable for design-time use""), + m => m.MigrationsHistoryTable(""{historyTableName}"", ""{historySchema}"")); + return new {contextName}(builder.Options); + }} +}} +"; + } + + // ------------------------------------------------------------------ + // migration ids + // ------------------------------------------------------------------ + + private static string buildMigrationId(string className, EfMigrationEmissionOptions options) + { + var timestamp = options.TimestampUtc ?? DateTime.UtcNow; + + if (options.LastMigrationId.IsNotEmpty()) + { + var lastStamp = options.LastMigrationId!.Split('_')[0]; + // EF orders migrations by plain string sort of the id — bump until + // the new timestamp sorts strictly after the previous one + while (string.Compare( + timestamp.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture), + lastStamp, StringComparison.Ordinal) <= 0) + { + timestamp = timestamp.AddSeconds(1); + } + } + + return $"{timestamp.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}_{className}"; + } + + // ------------------------------------------------------------------ + // operation rendering + // ------------------------------------------------------------------ + + private static void writeOperations(StringBuilder body, IReadOnlyList operations) + { + for (var i = 0; i < operations.Count; i++) + { + if (i > 0) + { + body.AppendLine(); + } + + writeOperation(body, operations[i]); + } + } + + private static void writeOperation(StringBuilder body, MigrationOperation operation) + { + switch (operation) + { + case EnsureSchemaOperation ensureSchema: + body.AppendLine($" migrationBuilder.EnsureSchema(name: {quote(ensureSchema.Name)});"); + break; + + case CreateTableOperation createTable: + writeCreateTable(body, createTable); + break; + + case CreateIndexOperation createIndex: + writeCreateIndex(body, createIndex); + break; + + case CreateSequenceOperation createSequence: + writeCreateSequence(body, createSequence); + break; + + case SqlOperation sql: + body.AppendLine($" migrationBuilder.Sql(@{verbatim(sql.Sql)});"); + break; + + case DropTableOperation dropTable: + body.Append($" migrationBuilder.DropTable(name: {quote(dropTable.Name)}"); + if (dropTable.Schema.IsNotEmpty()) + { + body.Append($", schema: {quote(dropTable.Schema!)}"); + } + + body.AppendLine(");"); + break; + + case DropSequenceOperation dropSequence: + body.Append($" migrationBuilder.DropSequence(name: {quote(dropSequence.Name)}"); + if (dropSequence.Schema.IsNotEmpty()) + { + body.Append($", schema: {quote(dropSequence.Schema!)}"); + } + + body.AppendLine(");"); + break; + + default: + throw new NotSupportedException( + $"The emitter does not know how to render a {operation.GetType().Name}"); + } + } + + private static void writeCreateTable(StringBuilder body, CreateTableOperation createTable) + { + body.AppendLine(" migrationBuilder.CreateTable("); + body.AppendLine($" name: {quote(createTable.Name)},"); + if (createTable.Schema.IsNotEmpty()) + { + body.AppendLine($" schema: {quote(createTable.Schema!)},"); + } + + body.AppendLine(" columns: table => new"); + body.AppendLine(" {"); + for (var i = 0; i < createTable.Columns.Count; i++) + { + writeTableColumn(body, createTable.Columns[i], i == createTable.Columns.Count - 1); + } + + body.AppendLine(" },"); + body.AppendLine(" constraints: table =>"); + body.AppendLine(" {"); + + if (createTable.PrimaryKey != null) + { + var pk = createTable.PrimaryKey; + var accessor = pk.Columns.Length == 1 + ? $"x => x.{memberName(pk.Columns[0])}" + : $"x => new {{ {pk.Columns.Select(c => "x." + memberName(c)).Join(", ")} }}"; + body.AppendLine($" table.PrimaryKey({quote(pk.Name!)}, {accessor});"); + } + + foreach (var check in createTable.CheckConstraints) + { + body.AppendLine($" table.CheckConstraint({quote(check.Name!)}, {quote(check.Sql)});"); + } + + foreach (var fk in createTable.ForeignKeys) + { + body.AppendLine(" table.ForeignKey("); + body.AppendLine($" name: {quote(fk.Name!)},"); + if (fk.Columns.Length == 1) + { + body.AppendLine($" column: x => x.{memberName(fk.Columns[0])},"); + } + else + { + body.AppendLine( + $" columns: x => new {{ {fk.Columns.Select(c => "x." + memberName(c)).Join(", ")} }},"); + } + + if (fk.PrincipalSchema.IsNotEmpty()) + { + body.AppendLine($" principalSchema: {quote(fk.PrincipalSchema!)},"); + } + + body.AppendLine($" principalTable: {quote(fk.PrincipalTable)},"); + if (fk.PrincipalColumns is { Length: 1 }) + { + body.AppendLine($" principalColumn: {quote(fk.PrincipalColumns[0])},"); + } + else if (fk.PrincipalColumns is { Length: > 1 }) + { + body.AppendLine( + $" principalColumns: {stringArray(fk.PrincipalColumns)},"); + } + + body.AppendLine($" onDelete: ReferentialAction.{fk.OnDelete},"); + body.AppendLine($" onUpdate: ReferentialAction.{fk.OnUpdate});"); + } + + body.AppendLine(" });"); + + foreach (var annotation in createTable.GetAnnotations()) + { + // table-level annotations attach after the call — none are produced + // by the translation layer today, but keep the emitter honest + throw new NotSupportedException( + $"The emitter does not know how to render table annotation '{annotation.Name}'"); + } + } + + private static void writeTableColumn(StringBuilder body, AddColumnOperation column, bool last) + { + var member = memberName(column.Name); + + body.Append($" {member} = table.Column<{clrTypeName(column.ClrType)}>("); + + var arguments = new List(); + if (!string.Equals(memberToColumnName(member), column.Name, StringComparison.Ordinal)) + { + arguments.Add($"name: {quote(column.Name)}"); + } + + arguments.Add($"type: {quote(column.ColumnType!)}"); + arguments.Add($"nullable: {(column.IsNullable ? "true" : "false")}"); + + if (column.DefaultValueSql.IsNotEmpty()) + { + arguments.Add($"defaultValueSql: {quote(column.DefaultValueSql!)}"); + } + + if (column.ComputedColumnSql.IsNotEmpty()) + { + arguments.Add($"computedColumnSql: {quote(column.ComputedColumnSql!)}"); + if (column.IsStored == true) + { + arguments.Add("stored: true"); + } + } + + body.Append(arguments.Join(", ")); + body.Append(')'); + + foreach (var annotation in column.GetAnnotations()) + { + body.AppendLine(); + body.Append($" .Annotation({quote(annotation.Name)}, {annotationValue(annotation.Name, annotation.Value)})"); + } + + body.AppendLine(last ? string.Empty : ","); + } + + private static void writeCreateIndex(StringBuilder body, CreateIndexOperation createIndex) + { + body.AppendLine(" migrationBuilder.CreateIndex("); + body.AppendLine($" name: {quote(createIndex.Name)},"); + if (createIndex.Schema.IsNotEmpty()) + { + body.AppendLine($" schema: {quote(createIndex.Schema!)},"); + } + + body.AppendLine($" table: {quote(createIndex.Table!)},"); + body.Append(createIndex.Columns.Length == 1 + ? $" column: {quote(createIndex.Columns[0])}" + : $" columns: {stringArray(createIndex.Columns)}"); + + if (createIndex.IsUnique) + { + body.AppendLine(","); + body.Append(" unique: true"); + } + + if (createIndex.Filter.IsNotEmpty()) + { + body.AppendLine(","); + body.Append($" filter: {quote(createIndex.Filter!)}"); + } + + body.Append(')'); + + foreach (var annotation in createIndex.GetAnnotations()) + { + body.AppendLine(); + body.Append( + $" .Annotation({quote(annotation.Name)}, {annotationValue(annotation.Name, annotation.Value)})"); + } + + body.AppendLine(";"); + } + + private static void writeCreateSequence(StringBuilder body, CreateSequenceOperation createSequence) + { + body.AppendLine(" migrationBuilder.CreateSequence("); + body.Append($" name: {quote(createSequence.Name)}"); + if (createSequence.Schema.IsNotEmpty()) + { + body.AppendLine(","); + body.Append($" schema: {quote(createSequence.Schema!)}"); + } + + if (createSequence.StartValue != 1L) + { + body.AppendLine(","); + body.Append($" startValue: {createSequence.StartValue}L"); + } + + if (createSequence.IncrementBy != 1) + { + body.AppendLine(","); + body.Append($" incrementBy: {createSequence.IncrementBy}"); + } + + body.AppendLine(");"); + } + + // ------------------------------------------------------------------ + // helpers + // ------------------------------------------------------------------ + + private static string annotationValue(string name, object? value) + { + if (name == MigrationOperationTranslation.NpgsqlValueGenerationStrategy) + { + // rendered as the provider enum literal — the generated file adds + // the Npgsql.EntityFrameworkCore.PostgreSQL.Metadata using + return $"NpgsqlValueGenerationStrategy.{value}"; + } + + return value switch + { + null => "null", + string s => quote(s), + string[] array => stringArray(array), + bool b => b ? "true" : "false", + int i => i.ToString(CultureInfo.InvariantCulture), + long l => $"{l}L", + _ => throw new NotSupportedException( + $"The emitter does not know how to render annotation '{name}' with value type {value.GetType().Name}") + }; + } + + private static string clrTypeName(Type type) + { + if (type == typeof(int)) return "int"; + if (type == typeof(long)) return "long"; + if (type == typeof(short)) return "short"; + if (type == typeof(byte)) return "byte"; + if (type == typeof(bool)) return "bool"; + if (type == typeof(float)) return "float"; + if (type == typeof(double)) return "double"; + if (type == typeof(decimal)) return "decimal"; + if (type == typeof(string)) return "string"; + if (type == typeof(Guid)) return "Guid"; + if (type == typeof(DateTime)) return "DateTime"; + if (type == typeof(DateTimeOffset)) return "DateTimeOffset"; + if (type == typeof(DateOnly)) return "DateOnly"; + if (type == typeof(TimeOnly)) return "TimeOnly"; + if (type == typeof(byte[])) return "byte[]"; + return type.FullName!; + } + + /// + /// Anonymous-type member name for a column. Valid identifiers are used + /// directly (reserved words get an @ prefix, so the member name still + /// round-trips to the exact column name); anything else is sanitized and + /// the exact column name is passed via the name: argument instead. + /// + private static string memberName(string columnName) + { + if (isValidIdentifier(columnName)) + { + return ReservedWords.Contains(columnName) ? "@" + columnName : columnName; + } + + return sanitizeIdentifier(columnName); + } + + private static string memberToColumnName(string member) + => member.StartsWith('@') ? member[1..] : member; + + private static bool isValidIdentifier(string name) + { + if (name.IsEmpty()) + { + return false; + } + + if (!char.IsLetter(name[0]) && name[0] != '_') + { + return false; + } + + return name.Skip(1).All(c => char.IsLetterOrDigit(c) || c == '_'); + } + + private static string sanitizeIdentifier(string name) + { + var builder = new StringBuilder(); + foreach (var c in name) + { + builder.Append(char.IsLetterOrDigit(c) || c == '_' ? c : '_'); + } + + if (builder.Length == 0 || (!char.IsLetter(builder[0]) && builder[0] != '_')) + { + builder.Insert(0, '_'); + } + + var result = builder.ToString(); + return ReservedWords.Contains(result) ? "@" + result : result; + } + + private static string quote(string value) + => $"\"{value.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", "\\r").Replace("\n", "\\n")}\""; + + private static string verbatim(string value) + => $"\"{value.Replace("\"", "\"\"")}\""; + + private static string stringArray(IEnumerable values) + => $"new[] {{ {values.Select(quote).Join(", ")} }}"; +} From 9dbe74cd08cf1891fb2d4c616a48426daafe60d5 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Sat, 18 Jul 2026 16:26:51 -0500 Subject: [PATCH 3/7] =?UTF-8?q?feat(efcore):=20incremental=20migrations=20?= =?UTF-8?q?=E2=80=94=20serialized=20snapshot=20+=20differ?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #367. Third implementation phase of the EF Core migration generation epic (#371). - EfSchemaSnapshot: JSON-serialized design-time snapshot of the Weasel typed model (tables/columns/indexes/FKs/checks/PK, sequences, and raw-SQL objects captured as their CREATE/DROP DDL) — Weasel's analog of EF's ModelSnapshot, written beside the generated migrations and never compiled. The Sable lesson without the shadow database - The snapshot DTOs are now the canonical IR: the #365 ITable translation routes through SnapshotTable.From(...) into shared operation builders, so first-migration translation and the incremental differ can never drift apart - EfSnapshotDiffer.Diff(baseline, target): in-memory diff producing incremental Up/Down operations — Add/Alter/DropColumn (Alter carries the old definition), Create/DropIndex (recreate on change), Add/DropForeignKey, Add/DropCheckConstraint, Drop+AddPrimaryKey, Create/Drop/AlterSequence, EnsureSchema for new schemas (never dropped), and raw-object add/remove via Sql(). Down runs in reverse order of Up. Changed raw-SQL objects are refused with guidance — the snapshot diff cannot infer a safe transform for partitioned tables or function bodies - EfSnapshotDiffer.DiffAgainstDatabaseAsync: the live-database baseline mode — Weasel's own CreateMigrationAsync SQL (updates + rollbacks) wrapped in Sql() operations, covering everything the snapshot diff refuses (partition additive/rebuild, function changes) - Emitter renders the incremental operations: AddColumn/AlterColumn/ DropColumn, DropIndex, AddForeignKey/DropForeignKey standalone, Add/DropPrimaryKey, Add/DropCheckConstraint, AlterSequence Renames are deliberately not inferred (the model carries no rename intent); the seam arrives with the CLI phase where renames can be declared explicitly. Tests: snapshot JSON round-trip yields a zero diff; add-column / changed-index / new-table+FK / altered-column scenarios; changed raw object refusal; incremental ops render through the emitter with the id monotonicity guard; and an end-to-end acceptance test that applies the initial generated migration via EF, diffs a changed model against the snapshot, executes the incremental operations through the real Npgsql migrations SQL generator, has Weasel's own delta detection report None, then rolls back down and round-trips again. Co-Authored-By: Claude Fable 5 --- .../incremental_migration_end_to_end.cs | 107 ++++ .../snapshot_and_incremental_migrations.cs | 183 +++++++ .../EfMigrationFileEmitter.cs | 165 +++++++ .../EfSchemaSnapshot.cs | 256 ++++++++++ .../EfSnapshotDiffer.cs | 464 ++++++++++++++++++ .../MigrationOperationTranslation.cs | 75 +-- 6 files changed, 1217 insertions(+), 33 deletions(-) create mode 100644 src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/incremental_migration_end_to_end.cs create mode 100644 src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/snapshot_and_incremental_migrations.cs create mode 100644 src/Weasel.EntityFrameworkCore/EfSchemaSnapshot.cs create mode 100644 src/Weasel.EntityFrameworkCore/EfSnapshotDiffer.cs diff --git a/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/incremental_migration_end_to_end.cs b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/incremental_migration_end_to_end.cs new file mode 100644 index 00000000..d8c433a5 --- /dev/null +++ b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/incremental_migration_end_to_end.cs @@ -0,0 +1,107 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql; +using Shouldly; +using Weasel.Core; +using Weasel.EntityFrameworkCore.Tests.MigrationOperations.SampleGenerated; +using Weasel.EntityFrameworkCore.Tests.Postgresql; +using Xunit; +using PgTable = Weasel.Postgresql.Tables.Table; +using PgIndex = Weasel.Postgresql.Tables.IndexDefinition; + +namespace Weasel.EntityFrameworkCore.Tests.MigrationOperations; + +/// +/// #367 acceptance: apply the initial generated migration, change the +/// Weasel model, diff against the snapshot baseline, run the incremental +/// operations through the real Npgsql migrations SQL generator against the +/// database — and Weasel's own delta detection then reports None. +/// +[Collection("pg-schema-comparison")] +public class incremental_migration_end_to_end : IAsyncLifetime +{ + public async Task InitializeAsync() + { + WeaselSampleDbContext.ConnectionString = PostgresqlDbContext.ConnectionString; + + await using var conn = new NpgsqlConnection(PostgresqlDbContext.ConnectionString); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = $"drop schema if exists {SampleWeaselSchema.SchemaName} cascade;"; + await cmd.ExecuteNonQueryAsync(); + } + + public Task DisposeAsync() => Task.CompletedTask; + + private static ISchemaObject[] modifiedObjects() + { + var objects = SampleWeaselSchema.Objects(); + var orders = objects.OfType().Single(x => x.Identifier.Name == "orders"); + orders.AddColumn("tenant_id").NotNull(); + orders.ColumnFor("tenant_id")!.DefaultExpression = "'*DEFAULT*'"; + orders.Indexes.Add(new PgIndex("idx_orders_tenant") { Columns = new[] { "tenant_id" } }); + return objects; + } + + [Fact] + public async Task incremental_operations_apply_and_round_trip() + { + var options = SampleWeaselSchema.TranslationOptions(); + + // 1. initial migration through the EF runtime (checked-in generated file) + await using var context = new WeaselSampleDbContext(); + await context.Database.MigrateAsync(); + + // 2. snapshot baseline of the initial model, then change the model + var baseline = EfSchemaSnapshot.FromSchemaObjects(SampleWeaselSchema.Objects(), options); + var target = EfSchemaSnapshot.FromSchemaObjects(modifiedObjects(), options); + + var diff = EfSnapshotDiffer.Diff(baseline, target, options); + diff.HasChanges.ShouldBeTrue(); + + // 3. run the incremental operations through the REAL provider SQL + // generator and execute against the database + await executeOperationsAsync(context, diff.UpOperations); + + // 4. Weasel's own delta detection agrees the migrated database matches + // the changed model + foreach (var table in modifiedObjects().OfType()) + { + await using var conn = new NpgsqlConnection(PostgresqlDbContext.ConnectionString); + await conn.OpenAsync(); + var delta = await table.FindDeltaAsync(conn); + delta.HasChanges().ShouldBeFalse( + $"table {table.Identifier} should round-trip after the incremental migration"); + } + + // 5. and the Down operations take the change back out + await executeOperationsAsync(context, diff.DownOperations); + + foreach (var table in SampleWeaselSchema.Objects().OfType()) + { + await using var conn = new NpgsqlConnection(PostgresqlDbContext.ConnectionString); + await conn.OpenAsync(); + var delta = await table.FindDeltaAsync(conn); + delta.HasChanges().ShouldBeFalse( + $"table {table.Identifier} should match the original model after rollback"); + } + } + + private static async Task executeOperationsAsync( + DbContext context, + IReadOnlyList operations) + { + var generator = context.GetService(); + var commands = generator.Generate(operations.ToList()); + + await using var conn = new NpgsqlConnection(PostgresqlDbContext.ConnectionString); + await conn.OpenAsync(); + foreach (var command in commands) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = command.CommandText; + await cmd.ExecuteNonQueryAsync(); + } + } +} diff --git a/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/snapshot_and_incremental_migrations.cs b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/snapshot_and_incremental_migrations.cs new file mode 100644 index 00000000..cfeb7283 --- /dev/null +++ b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/snapshot_and_incremental_migrations.cs @@ -0,0 +1,183 @@ +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Shouldly; +using Weasel.Core; +using Weasel.EntityFrameworkCore.Tests.MigrationOperations.SampleGenerated; +using Xunit; +using PgTable = Weasel.Postgresql.Tables.Table; +using PgIndex = Weasel.Postgresql.Tables.IndexDefinition; + +namespace Weasel.EntityFrameworkCore.Tests.MigrationOperations; + +/// +/// The serialized schema snapshot + incremental diff (#367): snapshot +/// round-trips, and model changes diff into incremental EF operations +/// without a live database. +/// +public class snapshot_and_incremental_migrations +{ + private static MigrationOperationTranslationOptions options() => SampleWeaselSchema.TranslationOptions(); + + private static EfSchemaSnapshot snapshotOfSample() + => EfSchemaSnapshot.FromSchemaObjects(SampleWeaselSchema.Objects(), options()); + + [Fact] + public void snapshot_round_trips_through_json_with_zero_diff() + { + var snapshot = snapshotOfSample(); + + var json = snapshot.ToJson(); + var rehydrated = EfSchemaSnapshot.FromJson(json); + + var diff = EfSnapshotDiffer.Diff(rehydrated, snapshotOfSample(), options()); + + diff.HasChanges.ShouldBeFalse(); + diff.UpOperations.ShouldBeEmpty(); + diff.DownOperations.ShouldBeEmpty(); + } + + [Fact] + public void added_column_diffs_to_add_column_and_drops_on_down() + { + var baseline = snapshotOfSample(); + + var changed = SampleWeaselSchema.Objects(); + var orders = changed.OfType().Single(x => x.Identifier.Name == "orders"); + orders.AddColumn("tenant_id").NotNull(); + orders.ColumnFor("tenant_id")!.DefaultExpression = "'*DEFAULT*'"; + + var diff = EfSnapshotDiffer.Diff(baseline, + EfSchemaSnapshot.FromSchemaObjects(changed, options()), options()); + + var add = diff.UpOperations.OfType().Single(); + add.Name.ShouldBe("tenant_id"); + add.Table.ShouldBe("orders"); + add.Schema.ShouldBe(SampleWeaselSchema.SchemaName); + add.IsNullable.ShouldBeFalse(); + add.DefaultValueSql.ShouldBe("'*DEFAULT*'"); + + diff.DownOperations.OfType().Single().Name.ShouldBe("tenant_id"); + } + + [Fact] + public void changed_index_recreates_it() + { + var baseline = snapshotOfSample(); + + var changed = SampleWeaselSchema.Objects(); + var orders = changed.OfType().Single(x => x.Identifier.Name == "orders"); + var index = (PgIndex)orders.Indexes.Single(x => x.Name == "idx_orders_status"); + index.Predicate = "status <> 'closed'"; + + var diff = EfSnapshotDiffer.Diff(baseline, + EfSchemaSnapshot.FromSchemaObjects(changed, options()), options()); + + diff.UpOperations.OfType().Single().Name.ShouldBe("idx_orders_status"); + diff.UpOperations.OfType().Single().Filter.ShouldBe("status <> 'closed'"); + + // rollback restores the original definition + diff.DownOperations.OfType().Single().Filter.ShouldBe("status <> 'archived'"); + } + + [Fact] + public void added_foreign_key_and_table_diff_together() + { + var baseline = snapshotOfSample(); + + var changed = SampleWeaselSchema.Objects().ToList(); + var regions = new PgTable($"{SampleWeaselSchema.SchemaName}.regions"); + regions.AddColumn("id").AsPrimaryKey(); + changed.Add(regions); + + var customers = changed.OfType().Single(x => x.Identifier.Name == "customers"); + customers.AddColumn("region_id"); + ((ITable)customers).AddForeignKey("fk_customers_region", + new DbObjectName(SampleWeaselSchema.SchemaName, "regions"), new[] { "region_id" }, new[] { "id" }); + + var diff = EfSnapshotDiffer.Diff(baseline, + EfSchemaSnapshot.FromSchemaObjects(changed, options()), options()); + + diff.UpOperations.OfType().Single().Name.ShouldBe("regions"); + diff.UpOperations.OfType().Single().Name.ShouldBe("region_id"); + var fk = diff.UpOperations.OfType().Single(); + fk.Name.ShouldBe("fk_customers_region"); + fk.PrincipalTable.ShouldBe("regions"); + + diff.DownOperations.OfType().Single().Name.ShouldBe("regions"); + diff.DownOperations.OfType().Single().Name.ShouldBe("fk_customers_region"); + } + + [Fact] + public void altered_column_produces_alter_column_with_old_definition() + { + var baseline = snapshotOfSample(); + + var changed = SampleWeaselSchema.Objects(); + var customers = changed.OfType().Single(x => x.Identifier.Name == "customers"); + customers.ColumnFor("name")!.AllowNulls = true; + customers.ColumnFor("name")!.DefaultExpression = null; + + var diff = EfSnapshotDiffer.Diff(baseline, + EfSchemaSnapshot.FromSchemaObjects(changed, options()), options()); + + var alter = diff.UpOperations.OfType().Single(); + alter.Name.ShouldBe("name"); + alter.IsNullable.ShouldBeTrue(); + alter.DefaultValueSql.ShouldBeNull(); + alter.OldColumn.IsNullable.ShouldBeFalse(); + alter.OldColumn.DefaultValueSql.ShouldBe("'unknown'"); + + // down restores the baseline definition + var revert = diff.DownOperations.OfType().Single(); + revert.IsNullable.ShouldBeFalse(); + revert.DefaultValueSql.ShouldBe("'unknown'"); + } + + [Fact] + public void changed_raw_object_is_refused_with_guidance() + { + var partitioned = new PgTable("efgen.partitioned"); + partitioned.AddColumn("id").AsPrimaryKey(); + partitioned.AddColumn("tenant_id").AsPrimaryKey(); + partitioned.PartitionByList("tenant_id"); + + var opts = options(); + opts.ForceRawSql = o => o is PgTable { Partitioning: not null }; + + var baseline = EfSchemaSnapshot.FromSchemaObjects(new ISchemaObject[] { partitioned }, opts); + + var changedTable = new PgTable("efgen.partitioned"); + changedTable.AddColumn("id").AsPrimaryKey(); + changedTable.AddColumn("tenant_id").AsPrimaryKey(); + changedTable.AddColumn("extra"); + changedTable.PartitionByList("tenant_id"); + + var target = EfSchemaSnapshot.FromSchemaObjects(new ISchemaObject[] { changedTable }, opts); + + Should.Throw(() => EfSnapshotDiffer.Diff(baseline, target, opts)) + .Message.ShouldContain("live-database baseline"); + } + + [Fact] + public void incremental_operations_render_through_the_emitter() + { + var baseline = snapshotOfSample(); + + var changed = SampleWeaselSchema.Objects(); + var orders = changed.OfType().Single(x => x.Identifier.Name == "orders"); + orders.AddColumn("tenant_id").NotNull(); + orders.ColumnFor("tenant_id")!.DefaultExpression = "'*DEFAULT*'"; + + var diff = EfSnapshotDiffer.Diff(baseline, + EfSchemaSnapshot.FromSchemaObjects(changed, options()), options()); + + var emission = SampleWeaselSchema.EmissionOptions(); + emission.LastMigrationId = "20260718120000_WeaselSampleSchema"; + var migration = EfMigrationFileEmitter.EmitMigration( + "AddTenantId", diff.UpOperations, diff.DownOperations, emission); + + migration.MigrationId.ShouldBe("20260718120001_AddTenantId"); + migration.Code.ShouldContain("migrationBuilder.AddColumn("); + migration.Code.ShouldContain("defaultValueSql: \"'*DEFAULT*'\""); + migration.Code.ShouldContain("migrationBuilder.DropColumn("); + } +} diff --git a/src/Weasel.EntityFrameworkCore/EfMigrationFileEmitter.cs b/src/Weasel.EntityFrameworkCore/EfMigrationFileEmitter.cs index 5ae5e47a..2ad12f9d 100644 --- a/src/Weasel.EntityFrameworkCore/EfMigrationFileEmitter.cs +++ b/src/Weasel.EntityFrameworkCore/EfMigrationFileEmitter.cs @@ -331,6 +331,85 @@ private static void writeOperation(StringBuilder body, MigrationOperation operat body.AppendLine(");"); break; + case AlterSequenceOperation alterSequence: + body.AppendLine(" migrationBuilder.AlterSequence("); + body.Append($" name: {quote(alterSequence.Name)}"); + if (alterSequence.Schema.IsNotEmpty()) + { + body.AppendLine(","); + body.Append($" schema: {quote(alterSequence.Schema!)}"); + } + + body.AppendLine(","); + body.Append($" incrementBy: {alterSequence.IncrementBy}"); + body.AppendLine(");"); + break; + + // AlterColumnOperation derives from AddColumnOperation's sibling — + // match it BEFORE the AddColumnOperation case + case AlterColumnOperation alterColumn: + writeStandaloneColumn(body, "AlterColumn", alterColumn, alterColumn.GetAnnotations()); + break; + + case AddColumnOperation addColumn: + writeStandaloneColumn(body, "AddColumn", addColumn, addColumn.GetAnnotations()); + break; + + case DropColumnOperation dropColumn: + writeNameSchemaTable(body, "DropColumn", dropColumn.Name, dropColumn.Schema, dropColumn.Table!); + break; + + case DropIndexOperation dropIndex: + writeNameSchemaTable(body, "DropIndex", dropIndex.Name, dropIndex.Schema, dropIndex.Table!); + break; + + case AddForeignKeyOperation addForeignKey: + writeStandaloneForeignKey(body, addForeignKey); + break; + + case DropForeignKeyOperation dropForeignKey: + writeNameSchemaTable(body, "DropForeignKey", dropForeignKey.Name, dropForeignKey.Schema, + dropForeignKey.Table!); + break; + + case AddPrimaryKeyOperation addPrimaryKey: + body.AppendLine(" migrationBuilder.AddPrimaryKey("); + body.AppendLine($" name: {quote(addPrimaryKey.Name!)},"); + if (addPrimaryKey.Schema.IsNotEmpty()) + { + body.AppendLine($" schema: {quote(addPrimaryKey.Schema!)},"); + } + + body.AppendLine($" table: {quote(addPrimaryKey.Table!)},"); + body.Append(addPrimaryKey.Columns.Length == 1 + ? $" column: {quote(addPrimaryKey.Columns[0])}" + : $" columns: {stringArray(addPrimaryKey.Columns)}"); + body.AppendLine(");"); + break; + + case DropPrimaryKeyOperation dropPrimaryKey: + writeNameSchemaTable(body, "DropPrimaryKey", dropPrimaryKey.Name!, dropPrimaryKey.Schema, + dropPrimaryKey.Table!); + break; + + case AddCheckConstraintOperation addCheck: + body.AppendLine(" migrationBuilder.AddCheckConstraint("); + body.AppendLine($" name: {quote(addCheck.Name!)},"); + if (addCheck.Schema.IsNotEmpty()) + { + body.AppendLine($" schema: {quote(addCheck.Schema!)},"); + } + + body.AppendLine($" table: {quote(addCheck.Table!)},"); + body.Append($" sql: {quote(addCheck.Sql)}"); + body.AppendLine(");"); + break; + + case DropCheckConstraintOperation dropCheck: + writeNameSchemaTable(body, "DropCheckConstraint", dropCheck.Name!, dropCheck.Schema, + dropCheck.Table!); + break; + default: throw new NotSupportedException( $"The emitter does not know how to render a {operation.GetType().Name}"); @@ -520,6 +599,92 @@ private static void writeCreateSequence(StringBuilder body, CreateSequenceOperat body.AppendLine(");"); } + private static void writeNameSchemaTable( + StringBuilder body, string method, string name, string? schema, string table) + { + body.AppendLine($" migrationBuilder.{method}("); + body.AppendLine($" name: {quote(name)},"); + if (schema.IsNotEmpty()) + { + body.AppendLine($" schema: {quote(schema!)},"); + } + + body.AppendLine($" table: {quote(table)});"); + } + + private static void writeStandaloneColumn( + StringBuilder body, + string method, + ColumnOperation column, + IEnumerable annotations) + { + body.AppendLine($" migrationBuilder.{method}<{clrTypeName(column.ClrType)}>("); + body.AppendLine($" name: {quote(column.Name)},"); + if (column.Schema.IsNotEmpty()) + { + body.AppendLine($" schema: {quote(column.Schema!)},"); + } + + body.AppendLine($" table: {quote(column.Table!)},"); + body.Append($" type: {quote(column.ColumnType!)},"); + body.AppendLine(); + body.Append($" nullable: {(column.IsNullable ? "true" : "false")}"); + + if (column.DefaultValueSql.IsNotEmpty()) + { + body.AppendLine(","); + body.Append($" defaultValueSql: {quote(column.DefaultValueSql!)}"); + } + + if (column.ComputedColumnSql.IsNotEmpty()) + { + body.AppendLine(","); + body.Append($" computedColumnSql: {quote(column.ComputedColumnSql!)}"); + if (column.IsStored == true) + { + body.AppendLine(","); + body.Append(" stored: true"); + } + } + + body.Append(')'); + + foreach (var annotation in annotations) + { + body.AppendLine(); + body.Append( + $" .Annotation({quote(annotation.Name)}, {annotationValue(annotation.Name, annotation.Value)})"); + } + + body.AppendLine(";"); + } + + private static void writeStandaloneForeignKey(StringBuilder body, AddForeignKeyOperation fk) + { + body.AppendLine(" migrationBuilder.AddForeignKey("); + body.AppendLine($" name: {quote(fk.Name!)},"); + if (fk.Schema.IsNotEmpty()) + { + body.AppendLine($" schema: {quote(fk.Schema!)},"); + } + + body.AppendLine($" table: {quote(fk.Table!)},"); + body.AppendLine(fk.Columns.Length == 1 + ? $" column: {quote(fk.Columns[0])}," + : $" columns: {stringArray(fk.Columns)},"); + if (fk.PrincipalSchema.IsNotEmpty()) + { + body.AppendLine($" principalSchema: {quote(fk.PrincipalSchema!)},"); + } + + body.AppendLine($" principalTable: {quote(fk.PrincipalTable)},"); + body.AppendLine(fk.PrincipalColumns is { Length: 1 } + ? $" principalColumn: {quote(fk.PrincipalColumns[0])}," + : $" principalColumns: {stringArray(fk.PrincipalColumns!)},"); + body.AppendLine($" onDelete: ReferentialAction.{fk.OnDelete},"); + body.AppendLine($" onUpdate: ReferentialAction.{fk.OnUpdate});"); + } + // ------------------------------------------------------------------ // helpers // ------------------------------------------------------------------ diff --git a/src/Weasel.EntityFrameworkCore/EfSchemaSnapshot.cs b/src/Weasel.EntityFrameworkCore/EfSchemaSnapshot.cs new file mode 100644 index 00000000..e6d40d32 --- /dev/null +++ b/src/Weasel.EntityFrameworkCore/EfSchemaSnapshot.cs @@ -0,0 +1,256 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using JasperFx.Core; +using Weasel.Core; + +namespace Weasel.EntityFrameworkCore; + +/// +/// Design-time snapshot of a Weasel schema model — Weasel's analog of EF's +/// ModelSnapshot, but serialized JSON written beside the generated +/// migrations and never compiled. The incremental `add` flow deserializes +/// the snapshot of the last migration, diffs it against the current model +/// entirely in memory (no live database, no shadow container), emits the +/// incremental operations, and rewrites the snapshot. +/// +public class EfSchemaSnapshot +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() } + }; + + public int Version { get; set; } = 1; + + /// The last generated migration id this snapshot corresponds to + public string? MigrationId { get; set; } + + public List Tables { get; set; } = new(); + public List Sequences { get; set; } = new(); + + /// + /// Schema objects that route through the raw-SQL fallback (partitioned + /// tables, functions, stored procedures, ...) — captured as their CREATE + /// / DROP DDL so changes can at least be detected. + /// + public List RawObjects { get; set; } = new(); + + /// + /// Capture the current Weasel model. The same ForceRawSql hook used for + /// operation translation decides which objects are carried as raw DDL. + /// + public static EfSchemaSnapshot FromSchemaObjects( + IEnumerable schemaObjects, + MigrationOperationTranslationOptions options) + { + var snapshot = new EfSchemaSnapshot(); + + foreach (var schemaObject in schemaObjects) + { + if (options.ForceRawSql?.Invoke(schemaObject) == true || + schemaObject is not (ITable or SequenceBase)) + { + snapshot.RawObjects.Add(SnapshotRawObject.From(schemaObject, options)); + } + else if (schemaObject is ITable table) + { + snapshot.Tables.Add(SnapshotTable.From(table)); + } + else if (schemaObject is SequenceBase sequence) + { + snapshot.Sequences.Add(new SnapshotSequence + { + Schema = sequence.Identifier.Schema, + Name = sequence.Identifier.Name, + StartWith = sequence.StartWith, + IncrementBy = sequence.IncrementBy + }); + } + } + + return snapshot; + } + + public string ToJson() => JsonSerializer.Serialize(this, SerializerOptions); + + public static EfSchemaSnapshot FromJson(string json) + => JsonSerializer.Deserialize(json, SerializerOptions) + ?? throw new InvalidOperationException("Could not deserialize the Weasel schema snapshot"); +} + +public class SnapshotTable +{ + public string Schema { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string QualifiedName => $"{Schema}.{Name}"; + + public List Columns { get; set; } = new(); + public string? PrimaryKeyName { get; set; } + public List PrimaryKeyColumns { get; set; } = new(); + public List Indexes { get; set; } = new(); + public List ForeignKeys { get; set; } = new(); + public List CheckConstraints { get; set; } = new(); + + public static SnapshotTable From(ITable table) + { + var snapshot = new SnapshotTable + { + Schema = table.Identifier.Schema, + Name = table.Identifier.Name, + PrimaryKeyName = table.PrimaryKeyColumns.Any() ? table.PrimaryKeyName : null, + PrimaryKeyColumns = table.PrimaryKeyColumns.ToList() + }; + + foreach (var column in table.Columns) + { + snapshot.Columns.Add(new SnapshotColumn + { + Name = column.Name, + Type = column.Type, + Nullable = column.AllowNulls && !column.IsPrimaryKey, + DefaultExpression = column.DefaultExpression, + Identity = column.IsAutoNumber, + ComputedExpression = column.ComputedExpression, + ComputedIsStored = column.ComputedExpression.IsNotEmpty() ? column.ComputedColumnIsStored : null + }); + } + + foreach (var index in table.Indexes) + { + snapshot.Indexes.Add(new SnapshotIndex + { + Name = index.Name, + Columns = index.Columns?.ToList() ?? new List(), + IsUnique = index.IsUnique, + Predicate = index.Predicate, + IncludeColumns = index.IncludeColumns?.ToList(), + Method = index.Method + }); + } + + foreach (var foreignKey in table.ForeignKeys) + { + snapshot.ForeignKeys.Add(new SnapshotForeignKey + { + Name = foreignKey.Name, + Columns = foreignKey.ColumnNames.ToList(), + PrincipalSchema = foreignKey.LinkedTable?.Schema, + PrincipalTable = foreignKey.LinkedTable?.Name ?? string.Empty, + PrincipalColumns = foreignKey.LinkedNames.ToList(), + OnDelete = foreignKey.DeleteAction, + OnUpdate = foreignKey.UpdateAction + }); + } + + foreach (var check in table.CheckConstraints) + { + snapshot.CheckConstraints.Add(new SnapshotCheckConstraint + { + Name = check.Name, Expression = check.Expression + }); + } + + return snapshot; + } +} + +public class SnapshotColumn +{ + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public bool Nullable { get; set; } + public string? DefaultExpression { get; set; } + public bool Identity { get; set; } + public string? ComputedExpression { get; set; } + public bool? ComputedIsStored { get; set; } + + public bool HasSameDefinition(SnapshotColumn other) + => Type.EqualsIgnoreCase(other.Type) + && Nullable == other.Nullable + && string.Equals(DefaultExpression, other.DefaultExpression, StringComparison.Ordinal) + && Identity == other.Identity + && string.Equals(ComputedExpression, other.ComputedExpression, StringComparison.Ordinal) + && ComputedIsStored == other.ComputedIsStored; +} + +public class SnapshotIndex +{ + public string Name { get; set; } = string.Empty; + public List Columns { get; set; } = new(); + public bool IsUnique { get; set; } + public string? Predicate { get; set; } + public List? IncludeColumns { get; set; } + public string? Method { get; set; } + + public bool HasSameDefinition(SnapshotIndex other) + => Columns.SequenceEqual(other.Columns, StringComparer.OrdinalIgnoreCase) + && IsUnique == other.IsUnique + && string.Equals(Predicate, other.Predicate, StringComparison.Ordinal) + && (IncludeColumns ?? new List()).SequenceEqual( + other.IncludeColumns ?? new List(), StringComparer.OrdinalIgnoreCase) + && string.Equals(Method, other.Method, StringComparison.OrdinalIgnoreCase); +} + +public class SnapshotForeignKey +{ + public string Name { get; set; } = string.Empty; + public List Columns { get; set; } = new(); + public string? PrincipalSchema { get; set; } + public string PrincipalTable { get; set; } = string.Empty; + public List PrincipalColumns { get; set; } = new(); + public CascadeAction OnDelete { get; set; } + public CascadeAction OnUpdate { get; set; } + + public bool HasSameDefinition(SnapshotForeignKey other) + => Columns.SequenceEqual(other.Columns, StringComparer.OrdinalIgnoreCase) + && string.Equals(PrincipalSchema, other.PrincipalSchema, StringComparison.OrdinalIgnoreCase) + && PrincipalTable.EqualsIgnoreCase(other.PrincipalTable) + && PrincipalColumns.SequenceEqual(other.PrincipalColumns, StringComparer.OrdinalIgnoreCase) + && OnDelete == other.OnDelete + && OnUpdate == other.OnUpdate; +} + +public class SnapshotCheckConstraint +{ + public string Name { get; set; } = string.Empty; + public string Expression { get; set; } = string.Empty; +} + +public class SnapshotSequence +{ + public string Schema { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public long? StartWith { get; set; } + public long? IncrementBy { get; set; } +} + +public class SnapshotRawObject +{ + public string Identifier { get; set; } = string.Empty; + public string CreateSql { get; set; } = string.Empty; + public string DropSql { get; set; } = string.Empty; + + public static SnapshotRawObject From(ISchemaObject schemaObject, MigrationOperationTranslationOptions options) + { + if (options.Migrator == null) + { + throw new InvalidOperationException( + $"{schemaObject.Identifier.QualifiedName} requires the raw-SQL fallback, so " + + $"{nameof(MigrationOperationTranslationOptions)}.{nameof(MigrationOperationTranslationOptions.Migrator)} must be provided"); + } + + var create = new StringWriter(); + schemaObject.WriteCreateStatement(options.Migrator, create); + var drop = new StringWriter(); + schemaObject.WriteDropStatement(options.Migrator, drop); + + return new SnapshotRawObject + { + Identifier = schemaObject.Identifier.QualifiedName, + CreateSql = create.ToString(), + DropSql = drop.ToString() + }; + } +} diff --git a/src/Weasel.EntityFrameworkCore/EfSnapshotDiffer.cs b/src/Weasel.EntityFrameworkCore/EfSnapshotDiffer.cs new file mode 100644 index 00000000..7520aa77 --- /dev/null +++ b/src/Weasel.EntityFrameworkCore/EfSnapshotDiffer.cs @@ -0,0 +1,464 @@ +using JasperFx; +using JasperFx.Core; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Weasel.Core; +using Weasel.Core.Migrations; + +namespace Weasel.EntityFrameworkCore; + +/// +/// The Up / Down operation lists for one incremental migration. +/// +public class EfIncrementalOperations +{ + public EfIncrementalOperations( + IReadOnlyList upOperations, + IReadOnlyList downOperations) + { + UpOperations = upOperations; + DownOperations = downOperations; + } + + public IReadOnlyList UpOperations { get; } + public IReadOnlyList DownOperations { get; } + + public bool HasChanges => UpOperations.Any(); + + public static readonly EfIncrementalOperations Empty = + new(Array.Empty(), Array.Empty()); +} + +/// +/// Produces incremental EF migration operations for migration N+1. Primary +/// mode: diff two instances (the serialized +/// baseline of the last migration vs the current model) entirely in memory — +/// no live database and no shadow container (the Sable lesson). Secondary +/// mode: diff against an actual database via Weasel's own delta detection, +/// wrapping the generated SQL in Sql() operations — the path that also +/// covers everything the snapshot diff can't express (partition changes, +/// function bodies, ...). +/// +public static class EfSnapshotDiffer +{ + /// + /// Diff the current model against the serialized baseline snapshot and + /// return the incremental Up / Down operations. + /// + public static EfIncrementalOperations Diff( + EfSchemaSnapshot baseline, + EfSchemaSnapshot target, + MigrationOperationTranslationOptions options) + { + var up = new List(); + var down = new List(); + + diffSchemas(baseline, target, up); + diffSequences(baseline, target, options, up, down); + diffRawObjects(baseline, target, up, down); + diffTables(baseline, target, options, up, down); + + // down entries are appended alongside their up counterparts, so the + // rollback must run in reverse order (e.g. drop a new index before + // dropping the column it covers) + down.Reverse(); + + return up.Any() || down.Any() + ? new EfIncrementalOperations(up, down) + : EfIncrementalOperations.Empty; + } + + /// + /// Live-database baseline mode: run Weasel's own delta detection against + /// the actual database and wrap the resulting migration SQL in Sql() + /// operations (rollback SQL for Down). Requires a reachable database but + /// handles everything Weasel can migrate — including partition deltas and + /// function changes that the snapshot diff refuses. + /// + public static async Task DiffAgainstDatabaseAsync( + IDatabase database, + AutoCreate autoCreate = AutoCreate.CreateOrUpdate, + CancellationToken ct = default) + { + var migration = await database.CreateMigrationAsync(ct).ConfigureAwait(false); + + if (migration.Difference == SchemaPatchDifference.None) + { + return EfIncrementalOperations.Empty; + } + + var upWriter = new StringWriter(); + migration.WriteAllUpdates(upWriter, database.Migrator, autoCreate); + + var downWriter = new StringWriter(); + migration.WriteAllRollbacks(downWriter, database.Migrator); + + var up = new List(); + if (upWriter.ToString().IsNotEmpty()) + { + up.Add(new SqlOperation { Sql = upWriter.ToString() }); + } + + var down = new List(); + if (downWriter.ToString().IsNotEmpty()) + { + down.Add(new SqlOperation { Sql = downWriter.ToString() }); + } + + return new EfIncrementalOperations(up, down); + } + + // ------------------------------------------------------------------ + // schemas + // ------------------------------------------------------------------ + + private static void diffSchemas(EfSchemaSnapshot baseline, EfSchemaSnapshot target, List up) + { + var known = new HashSet( + baseline.Tables.Select(x => x.Schema).Concat(baseline.Sequences.Select(x => x.Schema)), + StringComparer.OrdinalIgnoreCase); + + foreach (var schema in target.Tables.Select(x => x.Schema) + .Concat(target.Sequences.Select(x => x.Schema)) + .Where(s => s.IsNotEmpty() && !known.Contains(s)) + .Distinct(StringComparer.OrdinalIgnoreCase)) + { + // schemas are additive-only: never dropped on Down (may be shared) + up.Add(new EnsureSchemaOperation { Name = schema }); + } + } + + // ------------------------------------------------------------------ + // sequences + // ------------------------------------------------------------------ + + private static void diffSequences( + EfSchemaSnapshot baseline, + EfSchemaSnapshot target, + MigrationOperationTranslationOptions options, + List up, + List down) + { + var baselines = baseline.Sequences.ToDictionary(x => $"{x.Schema}.{x.Name}", StringComparer.OrdinalIgnoreCase); + var targets = target.Sequences.ToDictionary(x => $"{x.Schema}.{x.Name}", StringComparer.OrdinalIgnoreCase); + + foreach (var added in target.Sequences.Where(x => !baselines.ContainsKey($"{x.Schema}.{x.Name}"))) + { + up.Add(createSequence(added, options)); + down.Add(new DropSequenceOperation + { + Name = added.Name, Schema = MigrationOperationTranslation.SchemaFor(added.Schema, options) + }); + } + + foreach (var removed in baseline.Sequences.Where(x => !targets.ContainsKey($"{x.Schema}.{x.Name}"))) + { + up.Add(new DropSequenceOperation + { + Name = removed.Name, Schema = MigrationOperationTranslation.SchemaFor(removed.Schema, options) + }); + down.Add(createSequence(removed, options)); + } + + foreach (var pair in target.Sequences + .Select(t => (Target: t, Baseline: baselines.GetValueOrDefault($"{t.Schema}.{t.Name}"))) + .Where(x => x.Baseline != null)) + { + if ((pair.Target.IncrementBy ?? 1L) != (pair.Baseline!.IncrementBy ?? 1L)) + { + up.Add(alterSequence(pair.Target, options)); + down.Add(alterSequence(pair.Baseline, options)); + } + } + } + + private static CreateSequenceOperation createSequence( + SnapshotSequence sequence, MigrationOperationTranslationOptions options) => + new() + { + Name = sequence.Name, + Schema = MigrationOperationTranslation.SchemaFor(sequence.Schema, options), + ClrType = typeof(long), + StartValue = sequence.StartWith ?? 1L, + IncrementBy = (int)(sequence.IncrementBy ?? 1L) + }; + + private static AlterSequenceOperation alterSequence( + SnapshotSequence sequence, MigrationOperationTranslationOptions options) => + new() + { + Name = sequence.Name, + Schema = MigrationOperationTranslation.SchemaFor(sequence.Schema, options), + IncrementBy = (int)(sequence.IncrementBy ?? 1L) + }; + + // ------------------------------------------------------------------ + // raw-SQL objects + // ------------------------------------------------------------------ + + private static void diffRawObjects( + EfSchemaSnapshot baseline, + EfSchemaSnapshot target, + List up, + List down) + { + var baselines = baseline.RawObjects.ToDictionary(x => x.Identifier, StringComparer.OrdinalIgnoreCase); + var targets = target.RawObjects.ToDictionary(x => x.Identifier, StringComparer.OrdinalIgnoreCase); + + foreach (var added in target.RawObjects.Where(x => !baselines.ContainsKey(x.Identifier))) + { + up.Add(new SqlOperation { Sql = added.CreateSql }); + down.Add(new SqlOperation { Sql = added.DropSql }); + } + + foreach (var removed in baseline.RawObjects.Where(x => !targets.ContainsKey(x.Identifier))) + { + up.Add(new SqlOperation { Sql = removed.DropSql }); + down.Add(new SqlOperation { Sql = removed.CreateSql }); + } + + foreach (var changed in target.RawObjects + .Where(x => baselines.TryGetValue(x.Identifier, out var b) && + !string.Equals(b.CreateSql, x.CreateSql, StringComparison.Ordinal))) + { + throw new NotSupportedException( + $"The raw-SQL schema object '{changed.Identifier}' changed since the last snapshot. " + + "The snapshot diff cannot infer a safe transformation for raw objects (partitioned " + + "tables, functions, ...) — generate this migration with the live-database baseline " + + $"mode ({nameof(EfSnapshotDiffer)}.{nameof(DiffAgainstDatabaseAsync)}) or author it by hand."); + } + } + + // ------------------------------------------------------------------ + // tables + // ------------------------------------------------------------------ + + private static void diffTables( + EfSchemaSnapshot baseline, + EfSchemaSnapshot target, + MigrationOperationTranslationOptions options, + List up, + List down) + { + var baselines = baseline.Tables.ToDictionary(x => x.QualifiedName, StringComparer.OrdinalIgnoreCase); + var targets = target.Tables.ToDictionary(x => x.QualifiedName, StringComparer.OrdinalIgnoreCase); + + foreach (var added in target.Tables.Where(x => !baselines.ContainsKey(x.QualifiedName))) + { + up.AddRange(MigrationOperationTranslation.TableOperations(added, options)); + down.Add(new DropTableOperation + { + Name = added.Name, Schema = MigrationOperationTranslation.SchemaFor(added.Schema, options) + }); + } + + foreach (var removed in baseline.Tables.Where(x => !targets.ContainsKey(x.QualifiedName))) + { + up.Add(new DropTableOperation + { + Name = removed.Name, Schema = MigrationOperationTranslation.SchemaFor(removed.Schema, options) + }); + // recreating the dropped table (schema only — data is gone) is the + // best available rollback + down.AddRange(MigrationOperationTranslation.TableOperations(removed, options)); + } + + foreach (var pair in target.Tables + .Select(t => (Target: t, Baseline: baselines.GetValueOrDefault(t.QualifiedName))) + .Where(x => x.Baseline != null)) + { + diffTable(pair.Baseline!, pair.Target, options, up, down); + } + } + + private static void diffTable( + SnapshotTable baseline, + SnapshotTable target, + MigrationOperationTranslationOptions options, + List up, + List down) + { + var tableName = target.Name; + var schema = MigrationOperationTranslation.SchemaFor(target.Schema, options); + + var baselineColumns = baseline.Columns.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); + var targetColumns = target.Columns.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); + var baselineIndexes = baseline.Indexes.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); + var targetIndexes = target.Indexes.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); + var baselineFks = baseline.ForeignKeys.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); + var targetFks = target.ForeignKeys.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); + var baselineChecks = baseline.CheckConstraints.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); + var targetChecks = target.CheckConstraints.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); + + // roughly mirrors TableDelta.WriteUpdate ordering: drop dependents + // first, mutate columns, then add dependents back + + // indexes: removed or changed → drop up front + foreach (var index in baseline.Indexes.Where(x => + !targetIndexes.TryGetValue(x.Name, out var t) || !x.HasSameDefinition(t))) + { + up.Add(new DropIndexOperation { Name = index.Name, Table = tableName, Schema = schema }); + down.Add(MigrationOperationTranslation.IndexOperation(index, tableName, schema, options)); + } + + // foreign keys: removed or changed → drop + foreach (var fk in baseline.ForeignKeys.Where(x => + !targetFks.TryGetValue(x.Name, out var t) || !x.HasSameDefinition(t))) + { + up.Add(new DropForeignKeyOperation { Name = fk.Name, Table = tableName, Schema = schema }); + down.Add(MigrationOperationTranslation.ForeignKeyOperation(fk, tableName, schema, options)); + } + + // check constraints: removed or changed → drop + foreach (var check in baseline.CheckConstraints.Where(x => + !targetChecks.TryGetValue(x.Name, out var t) || + !string.Equals(t.Expression, x.Expression, StringComparison.Ordinal))) + { + up.Add(new DropCheckConstraintOperation { Name = check.Name, Table = tableName, Schema = schema }); + down.Add(new AddCheckConstraintOperation + { + Name = check.Name, Table = tableName, Schema = schema, Sql = check.Expression + }); + } + + // primary key change + var pkChanged = baseline.PrimaryKeyName != target.PrimaryKeyName || + !baseline.PrimaryKeyColumns.SequenceEqual(target.PrimaryKeyColumns, + StringComparer.OrdinalIgnoreCase); + if (pkChanged) + { + if (baseline.PrimaryKeyName.IsNotEmpty()) + { + up.Add(new DropPrimaryKeyOperation + { + Name = baseline.PrimaryKeyName!, Table = tableName, Schema = schema + }); + // appended before the target-PK drop so the reversed rollback + // drops the new PK first, then restores this one + down.Add(new AddPrimaryKeyOperation + { + Name = baseline.PrimaryKeyName!, Table = tableName, Schema = schema, + Columns = baseline.PrimaryKeyColumns.ToArray() + }); + } + + if (target.PrimaryKeyName.IsNotEmpty()) + { + down.Add(new DropPrimaryKeyOperation + { + Name = target.PrimaryKeyName!, Table = tableName, Schema = schema + }); + } + } + + // added columns + foreach (var column in target.Columns.Where(x => !baselineColumns.ContainsKey(x.Name))) + { + up.Add(MigrationOperationTranslation.ColumnOperation(column, tableName, schema, options)); + down.Add(new DropColumnOperation { Name = column.Name, Table = tableName, Schema = schema }); + } + + // altered columns + foreach (var pair in target.Columns + .Select(t => (Target: t, Baseline: baselineColumns.GetValueOrDefault(t.Name))) + .Where(x => x.Baseline != null && !x.Target.HasSameDefinition(x.Baseline))) + { + up.Add(alterColumn(pair.Target, pair.Baseline!, tableName, schema, options)); + down.Add(alterColumn(pair.Baseline!, pair.Target, tableName, schema, options)); + } + + // primary key (re-)creation after column changes + if (pkChanged && target.PrimaryKeyName.IsNotEmpty()) + { + up.Add(new AddPrimaryKeyOperation + { + Name = target.PrimaryKeyName!, Table = tableName, Schema = schema, + Columns = target.PrimaryKeyColumns.ToArray() + }); + } + + // removed columns (after PK changes so a former key column can go) + foreach (var column in baseline.Columns.Where(x => !targetColumns.ContainsKey(x.Name))) + { + up.Add(new DropColumnOperation { Name = column.Name, Table = tableName, Schema = schema }); + down.Add(MigrationOperationTranslation.ColumnOperation(column, tableName, schema, options)); + } + + // check constraints: added or changed → add + foreach (var check in target.CheckConstraints.Where(x => + !baselineChecks.TryGetValue(x.Name, out var b) || + !string.Equals(b.Expression, x.Expression, StringComparison.Ordinal))) + { + up.Add(new AddCheckConstraintOperation + { + Name = check.Name, Table = tableName, Schema = schema, Sql = check.Expression + }); + if (!baselineChecks.ContainsKey(check.Name)) + { + down.Add(new DropCheckConstraintOperation + { + Name = check.Name, Table = tableName, Schema = schema + }); + } + } + + // foreign keys: added or changed → add + foreach (var fk in target.ForeignKeys.Where(x => + !baselineFks.TryGetValue(x.Name, out var b) || !x.HasSameDefinition(b))) + { + up.Add(MigrationOperationTranslation.ForeignKeyOperation(fk, tableName, schema, options)); + if (!baselineFks.ContainsKey(fk.Name)) + { + down.Add(new DropForeignKeyOperation { Name = fk.Name, Table = tableName, Schema = schema }); + } + } + + // indexes: added or changed → create + foreach (var index in target.Indexes.Where(x => + !baselineIndexes.TryGetValue(x.Name, out var b) || !x.HasSameDefinition(b))) + { + up.Add(MigrationOperationTranslation.IndexOperation(index, tableName, schema, options)); + if (!baselineIndexes.ContainsKey(index.Name)) + { + down.Add(new DropIndexOperation { Name = index.Name, Table = tableName, Schema = schema }); + } + } + } + + private static AlterColumnOperation alterColumn( + SnapshotColumn target, + SnapshotColumn old, + string tableName, + string? schema, + MigrationOperationTranslationOptions options) + { + var newDefinition = MigrationOperationTranslation.ColumnOperation(target, tableName, schema, options); + var oldDefinition = MigrationOperationTranslation.ColumnOperation(old, tableName, schema, options); + + var operation = new AlterColumnOperation + { + Name = target.Name, + Table = tableName, + Schema = schema, + ClrType = newDefinition.ClrType, + ColumnType = newDefinition.ColumnType, + IsNullable = newDefinition.IsNullable, + DefaultValueSql = newDefinition.DefaultValueSql, + ComputedColumnSql = newDefinition.ComputedColumnSql, + IsStored = newDefinition.IsStored + }; + + foreach (var annotation in newDefinition.GetAnnotations()) + { + operation.AddAnnotation(annotation.Name, annotation.Value); + } + + operation.OldColumn.ClrType = oldDefinition.ClrType; + operation.OldColumn.ColumnType = oldDefinition.ColumnType; + operation.OldColumn.IsNullable = oldDefinition.IsNullable; + operation.OldColumn.DefaultValueSql = oldDefinition.DefaultValueSql; + operation.OldColumn.ComputedColumnSql = oldDefinition.ComputedColumnSql; + operation.OldColumn.IsStored = oldDefinition.IsStored; + + return operation; + } +} diff --git a/src/Weasel.EntityFrameworkCore/MigrationOperationTranslation.cs b/src/Weasel.EntityFrameworkCore/MigrationOperationTranslation.cs index 437b2f80..2b4db55f 100644 --- a/src/Weasel.EntityFrameworkCore/MigrationOperationTranslation.cs +++ b/src/Weasel.EntityFrameworkCore/MigrationOperationTranslation.cs @@ -140,14 +140,14 @@ public static IReadOnlyList ToDropMigrationOperations( { operations.Add(new DropTableOperation { - Name = table.Identifier.Name, Schema = schemaFor(table.Identifier.Schema, options) + Name = table.Identifier.Name, Schema = SchemaFor(table.Identifier.Schema, options) }); } else if (schemaObject is SequenceBase sequence) { operations.Add(new DropSequenceOperation { - Name = sequence.Identifier.Name, Schema = schemaFor(sequence.Identifier.Schema, options) + Name = sequence.Identifier.Name, Schema = SchemaFor(sequence.Identifier.Schema, options) }); } } @@ -180,14 +180,18 @@ private static IEnumerable translateObject( switch (schemaObject) { case ITable table: - foreach (var operation in translateTable(table, options)) yield return operation; + foreach (var operation in TableOperations(SnapshotTable.From(table), options)) + { + yield return operation; + } + break; case SequenceBase sequence: yield return new CreateSequenceOperation { Name = sequence.Identifier.Name, - Schema = schemaFor(sequence.Identifier.Schema, options), + Schema = SchemaFor(sequence.Identifier.Schema, options), ClrType = typeof(long), StartValue = sequence.StartWith ?? 1L, IncrementBy = (int)(sequence.IncrementBy ?? 1L) @@ -202,18 +206,23 @@ private static IEnumerable translateObject( } } - private static IEnumerable translateTable( - ITable table, + /// + /// Build the operations for one table from its snapshot form — the + /// shared pipeline for both first-migration translation (ITable → + /// SnapshotTable → operations) and the incremental snapshot differ. + /// + internal static IEnumerable TableOperations( + SnapshotTable table, MigrationOperationTranslationOptions options) { - var tableName = table.Identifier.Name; - var schema = schemaFor(table.Identifier.Schema, options); + var tableName = table.Name; + var schema = SchemaFor(table.Schema, options); var createTable = new CreateTableOperation { Name = tableName, Schema = schema }; foreach (var column in table.Columns) { - createTable.Columns.Add(translateColumn(table, column, tableName, schema, options)); + createTable.Columns.Add(ColumnOperation(column, tableName, schema, options)); } if (table.PrimaryKeyColumns.Any()) @@ -237,20 +246,19 @@ private static IEnumerable translateTable( foreach (var foreignKey in table.ForeignKeys) { - createTable.ForeignKeys.Add(translateForeignKey(foreignKey, tableName, schema, options)); + createTable.ForeignKeys.Add(ForeignKeyOperation(foreignKey, tableName, schema, options)); } yield return createTable; foreach (var index in table.Indexes) { - yield return translateIndex(index, tableName, schema, options); + yield return IndexOperation(index, tableName, schema, options); } } - private static AddColumnOperation translateColumn( - ITable table, - ITableColumn column, + internal static AddColumnOperation ColumnOperation( + SnapshotColumn column, string tableName, string? schema, MigrationOperationTranslationOptions options) @@ -265,7 +273,7 @@ private static AddColumnOperation translateColumn( // table.Column(...) generic argument in emitted C# ClrType = clrTypeFor(column.Type), ColumnType = column.Type, - IsNullable = column.AllowNulls && !column.IsPrimaryKey, + IsNullable = column.Nullable, DefaultValueSql = column.DefaultExpression }; @@ -273,10 +281,11 @@ private static AddColumnOperation translateColumn( { operation.ComputedColumnSql = column.ComputedExpression; // PostgreSQL only supports stored generated columns - operation.IsStored = options.Provider == EfMigrationProvider.PostgreSql || column.ComputedColumnIsStored; + operation.IsStored = options.Provider == EfMigrationProvider.PostgreSql || + column.ComputedIsStored == true; } - if (column.IsAutoNumber) + if (column.Identity) { switch (options.Provider) { @@ -292,13 +301,13 @@ private static AddColumnOperation translateColumn( return operation; } - private static CreateIndexOperation translateIndex( - ITableIndex index, + internal static CreateIndexOperation IndexOperation( + SnapshotIndex index, string tableName, string? schema, MigrationOperationTranslationOptions options) { - if (index.Columns == null || index.Columns.Length == 0) + if (index.Columns.Count == 0) { throw new NotSupportedException( $"Index '{index.Name}' on {schema ?? "?"}.{tableName} has no key columns — " + @@ -311,16 +320,16 @@ private static CreateIndexOperation translateIndex( Name = index.Name, Table = tableName, Schema = schema, - Columns = index.Columns, + Columns = index.Columns.ToArray(), IsUnique = index.IsUnique, Filter = index.Predicate }; - if (index.IncludeColumns is { Length: > 0 }) + if (index.IncludeColumns is { Count: > 0 }) { operation.AddAnnotation( options.Provider == EfMigrationProvider.PostgreSql ? NpgsqlIndexInclude : SqlServerIndexInclude, - index.IncludeColumns); + index.IncludeColumns.ToArray()); } if (index.Method.IsNotEmpty() && options.Provider == EfMigrationProvider.PostgreSql && @@ -332,13 +341,13 @@ private static CreateIndexOperation translateIndex( return operation; } - private static AddForeignKeyOperation translateForeignKey( - ForeignKeyBase foreignKey, + internal static AddForeignKeyOperation ForeignKeyOperation( + SnapshotForeignKey foreignKey, string tableName, string? schema, MigrationOperationTranslationOptions options) { - if (foreignKey.LinkedTable == null) + if (foreignKey.PrincipalTable.IsEmpty()) { throw new MisconfiguredForeignKeyException( $"Foreign key '{foreignKey.Name}' on {schema ?? "?"}.{tableName} has no linked table"); @@ -349,12 +358,12 @@ private static AddForeignKeyOperation translateForeignKey( Name = foreignKey.Name, Table = tableName, Schema = schema, - Columns = foreignKey.ColumnNames, - PrincipalTable = foreignKey.LinkedTable.Name, - PrincipalSchema = schemaFor(foreignKey.LinkedTable.Schema, options), - PrincipalColumns = foreignKey.LinkedNames, - OnDelete = referentialActionFor(foreignKey.DeleteAction, options), - OnUpdate = referentialActionFor(foreignKey.UpdateAction, options) + Columns = foreignKey.Columns.ToArray(), + PrincipalTable = foreignKey.PrincipalTable, + PrincipalSchema = SchemaFor(foreignKey.PrincipalSchema, options), + PrincipalColumns = foreignKey.PrincipalColumns.ToArray(), + OnDelete = referentialActionFor(foreignKey.OnDelete, options), + OnUpdate = referentialActionFor(foreignKey.OnUpdate, options) }; } @@ -401,7 +410,7 @@ private static SqlOperation rawSql( return new SqlOperation { Sql = writer.ToString() }; } - private static string? schemaFor(string? schema, MigrationOperationTranslationOptions options) + internal static string? SchemaFor(string? schema, MigrationOperationTranslationOptions options) { if (schema.IsEmpty() || schema!.EqualsIgnoreCase(options.DefaultSchema)) { From bb1f24ae0aba31a1ef655d0fbf88faaa000a3871 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Sat, 18 Jul 2026 16:32:19 -0500 Subject: [PATCH 4/7] feat(efcore): db-ef-migration add | script | baseline CLI command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #368. Fourth implementation phase of the EF Core migration generation epic (#371). - New db-ef-migration JasperFx command in Weasel.EntityFrameworkCore (discovered via [assembly: JasperFxAssembly]; Weasel.Core stays EF-free), using the same WeaselInput / TryChooseSingleDatabase database-selection machinery as db-patch — IDatabase is the single source of schema objects, so Marten/Wolverine/Polecat tables all flow in through one door - `add `: first run scaffolds the stub context (history table relocated into the first non-default schema of the database's objects), the initial create-everything migration, and the JSON snapshot; later runs diff against the snapshot (or the live database with --against-database) and emit an incremental migration with the id monotonicity guard. --output/--namespace/--context/ --history-schema flags - `script`: documents the canonical EF toolchain path (dotnet ef migrations script --idempotent / bundle) verified by the #364 spike — idempotent scripting needs the compiled migrations, which only exist in the consuming project - `baseline`: adopts a pre-existing database by inserting __EFMigrationsHistory rows (create-if-missing relocated history table) for every generated migration file without executing them — the EF-sanctioned baselining technique, idempotent across runs - EfMigrationGenerator is the testable engine behind the command: provider detection from the Migrator type, structural partition detection as the default ForceRawSql routing (no provider references), connection resolution via IConnectionSource Tests: provider detection, partition detection, first-run scaffold → no-change no-op → model change → incremental add with ordered ids, and baselining against live PostgreSQL (rows recorded once, idempotent second pass, verified in the relocated history table). Co-Authored-By: Claude Fable 5 --- .../ef_migration_generator.cs | 172 +++++++++ .../AssemblyInfo.cs | 3 + .../CommandLine/EfMigrationCommand.cs | 155 ++++++++ .../CommandLine/EfMigrationGenerator.cs | 356 ++++++++++++++++++ 4 files changed, 686 insertions(+) create mode 100644 src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/ef_migration_generator.cs create mode 100644 src/Weasel.EntityFrameworkCore/AssemblyInfo.cs create mode 100644 src/Weasel.EntityFrameworkCore/CommandLine/EfMigrationCommand.cs create mode 100644 src/Weasel.EntityFrameworkCore/CommandLine/EfMigrationGenerator.cs diff --git a/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/ef_migration_generator.cs b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/ef_migration_generator.cs new file mode 100644 index 00000000..7552fd8b --- /dev/null +++ b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/ef_migration_generator.cs @@ -0,0 +1,172 @@ +using JasperFx; +using Npgsql; +using Shouldly; +using Weasel.Core; +using Weasel.Core.Migrations; +using Weasel.EntityFrameworkCore.CommandLine; +using Weasel.EntityFrameworkCore.Tests.MigrationOperations.SampleGenerated; +using Weasel.EntityFrameworkCore.Tests.Postgresql; +using Weasel.Postgresql; +using Xunit; +using PgTable = Weasel.Postgresql.Tables.Table; + +namespace Weasel.EntityFrameworkCore.Tests.MigrationOperations; + +/// +/// The db-ef-migration engine (#368): first-run scaffolding, incremental +/// add against the snapshot, and baselining an existing database. +/// +[Collection("pg-schema-comparison")] +public class ef_migration_generator : IDisposable +{ + private readonly string _directory = + Path.Combine(Path.GetTempPath(), $"weasel-efgen-{Guid.NewGuid():N}"); + + /// Minimal IDatabase over the sample schema objects + private class SampleDatabase : PostgresqlDatabase + { + private readonly List _objects; + + public SampleDatabase(List objects) + : base(new DefaultMigrationLogger(), AutoCreate.CreateOrUpdate, new PostgresqlMigrator(), + "SampleStore", NpgsqlDataSource.Create(PostgresqlDbContext.ConnectionString)) + { + _objects = objects; + } + + public override IFeatureSchema[] BuildFeatureSchemas() => + new IFeatureSchema[] { new SchemaObjects(_objects) }; + + private class SchemaObjects : FeatureSchemaBase + { + private readonly List _objects; + + public SchemaObjects(List objects) : base("sample", new PostgresqlMigrator()) + { + _objects = objects; + } + + protected override IEnumerable schemaObjects() => _objects; + } + } + + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, true); + } + } + + private EfMigrationGenerationOptions options() => new() + { + Directory = _directory, Namespace = "SampleStore.Migrations" + }; + + [Fact] + public void detects_the_provider_from_the_migrator() + { + var database = new SampleDatabase(SampleWeaselSchema.Objects().ToList()); + EfMigrationGenerator.DetectProvider(database).ShouldBe(EfMigrationProvider.PostgreSql); + } + + [Fact] + public void partition_detection_works_structurally() + { + var partitioned = new PgTable("p.t"); + partitioned.AddColumn("id").AsPrimaryKey(); + partitioned.AddColumn("tenant_id").AsPrimaryKey(); + partitioned.PartitionByList("tenant_id"); + + var plain = new PgTable("p.plain"); + plain.AddColumn("id").AsPrimaryKey(); + + EfMigrationGenerator.IsPartitioned(partitioned).ShouldBeTrue(); + EfMigrationGenerator.IsPartitioned(plain).ShouldBeFalse(); + } + + [Fact] + public async Task first_add_scaffolds_context_migration_and_snapshot_then_incremental_add() + { + var objects = SampleWeaselSchema.Objects().ToList(); + var database = new SampleDatabase(objects); + + // first run: create everything + var first = await EfMigrationGenerator.AddAsync(database, "Initial", options()); + + first.HasChanges.ShouldBeTrue(); + first.MigrationId!.ShouldEndWith("_Initial"); + File.Exists(first.MigrationFile!).ShouldBeTrue(); + File.Exists(first.ContextFile!).ShouldBeTrue(); + File.Exists(first.SnapshotFile).ShouldBeTrue(); + + var contextCode = await File.ReadAllTextAsync(first.ContextFile!); + contextCode.ShouldContain("class SampleStoreSchemaDbContext : DbContext"); + contextCode.ShouldContain("UseNpgsql("); + // history relocated into the sample schema, not public + contextCode.ShouldContain($"MigrationsHistoryTable(\"__EFMigrationsHistory\", \"{SampleWeaselSchema.SchemaName}\")"); + + var migrationCode = await File.ReadAllTextAsync(first.MigrationFile!); + migrationCode.ShouldContain("[DbContext(typeof(SampleStoreSchemaDbContext))]"); + migrationCode.ShouldContain("migrationBuilder.CreateTable("); + + // no model change → no migration + var unchanged = await EfMigrationGenerator.AddAsync(database, "Nothing", options()); + unchanged.HasChanges.ShouldBeFalse(); + + // change the model → incremental migration with a later id + var orders = objects.OfType().Single(x => x.Identifier.Name == "orders"); + orders.AddColumn("tenant_id").NotNull(); + orders.ColumnFor("tenant_id")!.DefaultExpression = "'*DEFAULT*'"; + + var second = await EfMigrationGenerator.AddAsync(database, "AddTenantId", options()); + second.HasChanges.ShouldBeTrue(); + second.ContextFile.ShouldBeNull(); + string.Compare(second.MigrationId, first.MigrationId, StringComparison.Ordinal).ShouldBeGreaterThan(0); + + var incrementalCode = await File.ReadAllTextAsync(second.MigrationFile!); + incrementalCode.ShouldContain("migrationBuilder.AddColumn("); + incrementalCode.ShouldNotContain("migrationBuilder.CreateTable("); + + EfMigrationGenerator.MigrationIdsIn(_directory) + .ShouldBe(new[] { first.MigrationId, second.MigrationId! }); + } + + [Fact] + public async Task baseline_records_history_rows_without_executing() + { + var database = new SampleDatabase(SampleWeaselSchema.Objects().ToList()); + + // reset the history table state + await using (var conn = new NpgsqlConnection(PostgresqlDbContext.ConnectionString)) + { + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = + $"drop table if exists \"{SampleWeaselSchema.SchemaName}\".\"__EFMigrationsHistory\";"; + await cmd.ExecuteNonQueryAsync(); + } + + var added = await EfMigrationGenerator.AddAsync(database, "Initial", options()); + + var recorded = await EfMigrationGenerator.BaselineAsync(database, options()); + recorded.ShouldBe(new[] { added.MigrationId! }); + + // idempotent: nothing new to record on a second pass + (await EfMigrationGenerator.BaselineAsync(database, options())).ShouldBeEmpty(); + + // and the row really is in the relocated history table + await using (var conn = new NpgsqlConnection(PostgresqlDbContext.ConnectionString)) + { + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = + $"select count(*) from \"{SampleWeaselSchema.SchemaName}\".\"__EFMigrationsHistory\" where \"MigrationId\" = @id"; + var p = cmd.CreateParameter(); + p.ParameterName = "@id"; + p.Value = added.MigrationId!; + cmd.Parameters.Add(p); + ((long)(await cmd.ExecuteScalarAsync())!).ShouldBe(1); + } + } +} diff --git a/src/Weasel.EntityFrameworkCore/AssemblyInfo.cs b/src/Weasel.EntityFrameworkCore/AssemblyInfo.cs new file mode 100644 index 00000000..d7e5fbca --- /dev/null +++ b/src/Weasel.EntityFrameworkCore/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using JasperFx; + +[assembly: JasperFxAssembly] diff --git a/src/Weasel.EntityFrameworkCore/CommandLine/EfMigrationCommand.cs b/src/Weasel.EntityFrameworkCore/CommandLine/EfMigrationCommand.cs new file mode 100644 index 00000000..0e110528 --- /dev/null +++ b/src/Weasel.EntityFrameworkCore/CommandLine/EfMigrationCommand.cs @@ -0,0 +1,155 @@ +using JasperFx; +using JasperFx.CommandLine; +using JasperFx.Core; +using Spectre.Console; +using Weasel.Core.CommandLine; + +namespace Weasel.EntityFrameworkCore.CommandLine; + +public class EfMigrationInput : WeaselInput +{ + [Description("What to do: add | script | baseline")] + public string Action { get; set; } = string.Empty; + + [Description("The migration name (required for add)")] + public string? Name { get; set; } + + [Description("Output directory for the generated files. Default is ./WeaselMigrations")] + [FlagAlias("output", 'o')] + public string? OutputFlag { get; set; } + + [Description("Namespace for the generated files. Default is WeaselMigrations")] + [FlagAlias("namespace")] + public string? NamespaceFlag { get; set; } + + [Description("Stub DbContext type name. Default is derived from the database identifier")] + [FlagAlias("context")] + public string? ContextFlag { get; set; } + + [Description("Schema for the relocated __EFMigrationsHistory table. Default is the first non-default schema of the database's objects")] + [FlagAlias("history-schema")] + public string? HistorySchemaFlag { get; set; } + + [Description("Diff against the live database (Weasel delta detection wrapped in Sql operations) instead of the serialized snapshot")] + [FlagAlias("against-database")] + public bool AgainstDatabaseFlag { get; set; } + + internal EfMigrationGenerationOptions ToOptions() + { + var options = new EfMigrationGenerationOptions(); + if (OutputFlag.IsNotEmpty()) + { + options.Directory = OutputFlag!; + } + + if (NamespaceFlag.IsNotEmpty()) + { + options.Namespace = NamespaceFlag!; + } + + options.ContextTypeName = ContextFlag; + options.HistorySchema = HistorySchemaFlag; + return options; + } +} + +[Description( + "Generates and manages EF Core migrations for the Weasel-managed schema objects of an IDatabase", + Name = "db-ef-migration")] +public class EfMigrationCommand : JasperFxAsyncCommand +{ + public EfMigrationCommand() + { + Usage("Generate the next migration").Arguments(x => x.Action, x => x.Name); + Usage("Baseline an existing database or print scripting guidance").Arguments(x => x.Action); + } + + public override async Task Execute(EfMigrationInput input) + { + JasperFxEnvironment.RunQuiet = true; + + AnsiConsole.Write(new FigletText("Weasel") { Justification = Justify.Left }); + + using var host = input.BuildHost(); + + var (found, database) = await input.TryChooseSingleDatabase(host).ConfigureAwait(false); + if (!found) + { + return false; + } + + var options = input.ToOptions(); + + switch (input.Action.ToLowerInvariant()) + { + case "add": + { + if (input.Name.IsEmpty()) + { + AnsiConsole.MarkupLine("[red]A migration name is required: db-ef-migration add [/]"); + return false; + } + + var result = await EfMigrationGenerator + .AddAsync(database!, input.Name!, options, input.AgainstDatabaseFlag) + .ConfigureAwait(false); + + if (!result.HasChanges) + { + AnsiConsole.MarkupLine( + "[green]No differences were detected between the model and the snapshot — no migration generated[/]"); + return true; + } + + AnsiConsole.MarkupLine($"[green]Wrote migration {result.MigrationId} to {result.MigrationFile}[/]"); + if (result.ContextFile != null) + { + AnsiConsole.MarkupLine($"[green]Wrote stub DbContext to {result.ContextFile}[/]"); + } + + AnsiConsole.MarkupLine($"[green]Updated schema snapshot at {result.SnapshotFile}[/]"); + return true; + } + + case "script": + { + // the canonical idempotent script comes from the EF toolchain + // itself once the generated files are compiled into a project — + // the #364 spike verified this end to end + var contextName = options.ContextTypeName ?? ""; + AnsiConsole.MarkupLine( + "[yellow]Idempotent SQL scripts are produced by the EF toolchain from the generated migration files:[/]"); + AnsiConsole.MarkupLine( + $" dotnet ef migrations script --idempotent --context {contextName} -o migrations.sql"); + AnsiConsole.MarkupLine( + "[yellow]Run it in the project that compiles the generated files. Migration bundles " + + "(dotnet ef migrations bundle) work the same way.[/]"); + return true; + } + + case "baseline": + { + var recorded = await EfMigrationGenerator.BaselineAsync(database!, options).ConfigureAwait(false); + + if (!recorded.Any()) + { + AnsiConsole.MarkupLine( + $"[yellow]No new migration files found in {options.Directory} to baseline (already recorded or none generated)[/]"); + return true; + } + + foreach (var migrationId in recorded) + { + AnsiConsole.MarkupLine($"[green]Recorded {migrationId} as applied[/]"); + } + + return true; + } + + default: + AnsiConsole.MarkupLine( + $"[red]Unknown action '{input.Action}'. Use add, script, or baseline[/]"); + return false; + } + } +} diff --git a/src/Weasel.EntityFrameworkCore/CommandLine/EfMigrationGenerator.cs b/src/Weasel.EntityFrameworkCore/CommandLine/EfMigrationGenerator.cs new file mode 100644 index 00000000..87cccf23 --- /dev/null +++ b/src/Weasel.EntityFrameworkCore/CommandLine/EfMigrationGenerator.cs @@ -0,0 +1,356 @@ +using System.Data.Common; +using System.Text.RegularExpressions; +using JasperFx.Core; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Weasel.Core; +using Weasel.Core.Migrations; + +namespace Weasel.EntityFrameworkCore.CommandLine; + +public class EfMigrationGenerationOptions +{ + /// Directory the migration files, stub context and snapshot are written to + public string Directory { get; set; } = "WeaselMigrations"; + + public string Namespace { get; set; } = "WeaselMigrations"; + + /// + /// Stub context type name. Defaults to a sanitized + /// "<DatabaseIdentifier>SchemaDbContext". + /// + public string? ContextTypeName { get; set; } + + /// + /// Schema the __EFMigrationsHistory table is relocated into. Defaults to + /// the first non-default schema among the database's objects so it never + /// collides with the application's own EF context. + /// + public string? HistorySchema { get; set; } + + /// Override the provider detection from the database's Migrator type + public EfMigrationProvider? Provider { get; set; } + + /// + /// Which objects route through the raw-SQL fallback. Defaults to + /// partition-strategy detection over the concrete table types. + /// + public Func? ForceRawSql { get; set; } +} + +public record EfMigrationAddResult( + bool HasChanges, + string? MigrationId, + string? MigrationFile, + string? ContextFile, + string SnapshotFile); + +/// +/// Orchestrates EF migration generation for an — +/// the engine behind the db-ef-migration command, kept separate so it is +/// directly testable and usable programmatically. +/// +public static class EfMigrationGenerator +{ + public const string SnapshotFileName = "weasel-schema-snapshot.json"; + public const string HistoryTableName = "__EFMigrationsHistory"; + + private static readonly Regex MigrationFilePattern = + new(@"^\d{14}_.+\.cs$", RegexOptions.Compiled); + + /// + /// Detect the EF provider from the database's Migrator type. Only + /// PostgreSQL and SQL Server are supported for EF migration generation. + /// + public static EfMigrationProvider DetectProvider(IDatabase database) + { + var migratorType = database.Migrator.GetType().FullName ?? string.Empty; + + if (migratorType.Contains("Postgresql", StringComparison.OrdinalIgnoreCase)) + { + return EfMigrationProvider.PostgreSql; + } + + if (migratorType.Contains("SqlServer", StringComparison.OrdinalIgnoreCase)) + { + return EfMigrationProvider.SqlServer; + } + + throw new NotSupportedException( + $"EF migration generation supports PostgreSQL and SQL Server; the database '{database.Identifier}' " + + $"uses {migratorType}"); + } + + /// + /// Default raw-SQL routing: any concrete table carrying a partitioning + /// strategy (detected structurally, so this assembly needs no provider + /// references) goes through its own Weasel DDL — EF has no model for + /// partitioned tables. + /// + public static bool IsPartitioned(ISchemaObject schemaObject) + { + var type = schemaObject.GetType(); + + foreach (var propertyName in new[] { "Partitioning", "SqlServerPartitioning" }) + { + if (type.GetProperty(propertyName)?.GetValue(schemaObject) != null) + { + return true; + } + } + + var strategy = type.GetProperty("PartitionStrategy")?.GetValue(schemaObject); + return strategy != null && strategy.ToString() != "None"; + } + + /// + /// Generate the next migration for the database. First run scaffolds the + /// stub context, the initial "create everything" migration and the + /// snapshot; subsequent runs diff against the snapshot (or the live + /// database when is true) and emit an + /// incremental migration. + /// + public static async Task AddAsync( + IDatabase database, + string name, + EfMigrationGenerationOptions options, + bool againstDatabase = false, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("A migration name is required", nameof(name)); + } + + var provider = options.Provider ?? DetectProvider(database); + var translation = new MigrationOperationTranslationOptions(provider) + { + Migrator = database.Migrator, ForceRawSql = options.ForceRawSql ?? IsPartitioned + }; + + var objects = database.AllObjects().ToArray(); + var target = EfSchemaSnapshot.FromSchemaObjects(objects, translation); + + System.IO.Directory.CreateDirectory(options.Directory); + var snapshotFile = Path.Combine(options.Directory, SnapshotFileName); + + var contextTypeName = options.ContextTypeName ?? defaultContextName(database); + var emission = new EfMigrationEmissionOptions(contextTypeName) { Namespace = options.Namespace }; + + if (!File.Exists(snapshotFile)) + { + // first run: stub context + initial create-everything migration + var migration = EfMigrationFileEmitter.EmitMigration( + name, + objects.ToMigrationOperations(translation), + objects.ToDropMigrationOperations(translation), + emission); + + var historySchema = options.HistorySchema ?? defaultHistorySchema(objects, translation); + var contextCode = EfMigrationFileEmitter.EmitStubContext(provider, emission, historySchema); + var contextFile = Path.Combine(options.Directory, $"{contextTypeName}.cs"); + var migrationFile = Path.Combine(options.Directory, migration.FileName); + + await File.WriteAllTextAsync(contextFile, contextCode, ct).ConfigureAwait(false); + await File.WriteAllTextAsync(migrationFile, migration.Code, ct).ConfigureAwait(false); + + target.MigrationId = migration.MigrationId; + await File.WriteAllTextAsync(snapshotFile, target.ToJson(), ct).ConfigureAwait(false); + + return new EfMigrationAddResult(true, migration.MigrationId, migrationFile, contextFile, snapshotFile); + } + + var baseline = EfSchemaSnapshot.FromJson(await File.ReadAllTextAsync(snapshotFile, ct).ConfigureAwait(false)); + + var operations = againstDatabase + ? await EfSnapshotDiffer.DiffAgainstDatabaseAsync(database, ct: ct).ConfigureAwait(false) + : EfSnapshotDiffer.Diff(baseline, target, translation); + + if (!operations.HasChanges) + { + return new EfMigrationAddResult(false, null, null, null, snapshotFile); + } + + emission.LastMigrationId = baseline.MigrationId; + var incremental = EfMigrationFileEmitter.EmitMigration( + name, operations.UpOperations, operations.DownOperations, emission); + + var incrementalFile = Path.Combine(options.Directory, incremental.FileName); + await File.WriteAllTextAsync(incrementalFile, incremental.Code, ct).ConfigureAwait(false); + + target.MigrationId = incremental.MigrationId; + await File.WriteAllTextAsync(snapshotFile, target.ToJson(), ct).ConfigureAwait(false); + + return new EfMigrationAddResult(true, incremental.MigrationId, incrementalFile, null, snapshotFile); + } + + /// + /// Adopt a pre-existing database: insert __EFMigrationsHistory rows for + /// every migration file in the output directory without executing them + /// (the EF-sanctioned baselining/squashing technique). Returns the + /// migration ids that were newly recorded. + /// + public static async Task> BaselineAsync( + IDatabase database, + EfMigrationGenerationOptions options, + CancellationToken ct = default) + { + var provider = options.Provider ?? DetectProvider(database); + var migrationIds = MigrationIdsIn(options.Directory); + + if (!migrationIds.Any()) + { + return Array.Empty(); + } + + var translation = new MigrationOperationTranslationOptions(provider) + { + Migrator = database.Migrator, ForceRawSql = options.ForceRawSql ?? IsPartitioned + }; + var historySchema = options.HistorySchema ?? + defaultHistorySchema(database.AllObjects().ToArray(), translation); + + await using var connection = createConnection(database); + await connection.OpenAsync(ct).ConfigureAwait(false); + + await ensureHistoryTableAsync(connection, provider, historySchema, ct).ConfigureAwait(false); + + var recorded = new List(); + var productVersion = ProductInfo.GetVersion(); + + foreach (var migrationId in migrationIds) + { + var inserted = await insertHistoryRowAsync(connection, provider, historySchema, migrationId, + productVersion, ct).ConfigureAwait(false); + if (inserted) + { + recorded.Add(migrationId); + } + } + + return recorded; + } + + /// The ordered migration ids found as files in the output directory + public static IReadOnlyList MigrationIdsIn(string directory) + { + if (!System.IO.Directory.Exists(directory)) + { + return Array.Empty(); + } + + return System.IO.Directory.EnumerateFiles(directory, "*.cs") + .Select(Path.GetFileName) + .OfType() + .Where(x => MigrationFilePattern.IsMatch(x)) + .Select(x => x[..^3]) + .OrderBy(x => x, StringComparer.Ordinal) + .ToList(); + } + + // ------------------------------------------------------------------ + // internals + // ------------------------------------------------------------------ + + private static string defaultContextName(IDatabase database) + { + var sanitized = new string(database.Identifier + .Select(c => char.IsLetterOrDigit(c) ? c : '_').ToArray()); + if (sanitized.IsEmpty() || !char.IsLetter(sanitized[0])) + { + sanitized = "Weasel" + sanitized; + } + + // PascalCase-ish: capitalize segments split by underscores + var parts = sanitized.Split('_', StringSplitOptions.RemoveEmptyEntries) + .Select(p => char.ToUpperInvariant(p[0]) + p[1..]); + return $"{string.Join(string.Empty, parts)}SchemaDbContext"; + } + + private static string defaultHistorySchema( + ISchemaObject[] objects, + MigrationOperationTranslationOptions translation) + { + return objects + .Select(x => x.Identifier.Schema) + .FirstOrDefault(s => s.IsNotEmpty() && !s.EqualsIgnoreCase(translation.DefaultSchema)) + ?? translation.DefaultSchema; + } + + private static DbConnection createConnection(IDatabase database) + { + // CreateConnection lives on IConnectionSource behind the + // closed generic IDatabase; resolve it structurally so + // this assembly stays free of provider references + var method = database.GetType().GetMethod("CreateConnection", Type.EmptyTypes); + + if (method?.Invoke(database, null) is DbConnection connection) + { + return connection; + } + + throw new NotSupportedException( + $"Could not create a connection for database '{database.Identifier}' — it does not implement IDatabase"); + } + + private static async Task ensureHistoryTableAsync( + DbConnection connection, + EfMigrationProvider provider, + string historySchema, + CancellationToken ct) + { + await using var command = connection.CreateCommand(); + command.CommandText = provider == EfMigrationProvider.PostgreSql + ? $""" + create schema if not exists "{historySchema}"; + create table if not exists "{historySchema}"."{HistoryTableName}" ( + "MigrationId" varchar(150) not null primary key, + "ProductVersion" varchar(32) not null + ); + """ + : $""" + IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = '{historySchema}') + EXEC('CREATE SCHEMA [{historySchema}]'); + IF OBJECT_ID(N'[{historySchema}].[{HistoryTableName}]') IS NULL + CREATE TABLE [{historySchema}].[{HistoryTableName}] ( + [MigrationId] nvarchar(150) NOT NULL PRIMARY KEY, + [ProductVersion] nvarchar(32) NOT NULL + ); + """; + await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + private static async Task insertHistoryRowAsync( + DbConnection connection, + EfMigrationProvider provider, + string historySchema, + string migrationId, + string productVersion, + CancellationToken ct) + { + await using var command = connection.CreateCommand(); + + var idParameter = command.CreateParameter(); + idParameter.ParameterName = "@id"; + idParameter.Value = migrationId; + var versionParameter = command.CreateParameter(); + versionParameter.ParameterName = "@version"; + versionParameter.Value = productVersion; + command.Parameters.Add(idParameter); + command.Parameters.Add(versionParameter); + + command.CommandText = provider == EfMigrationProvider.PostgreSql + ? $""" + insert into "{historySchema}"."{HistoryTableName}" ("MigrationId", "ProductVersion") + values (@id, @version) + on conflict ("MigrationId") do nothing; + """ + : $""" + IF NOT EXISTS (SELECT 1 FROM [{historySchema}].[{HistoryTableName}] WHERE [MigrationId] = @id) + INSERT INTO [{historySchema}].[{HistoryTableName}] ([MigrationId], [ProductVersion]) + VALUES (@id, @version); + """; + + var affected = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + return affected > 0; + } +} From f8686dc38f8de5b996796981b41e6e8ea2fb2d55 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Sat, 18 Jul 2026 16:43:08 -0500 Subject: [PATCH 5/7] feat(efcore): inverted schema-comparison validation harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #369. Fifth implementation phase of the EF Core migration generation epic (#371). - InvertedComparisonHarness: the reverse of SchemaComparisonHarness — schemas defined as Weasel objects, the generated migration chain (initial + snapshot-diffed incrementals) COMPILED WITH ROSLYN and applied through the real EF runtime via Migrate(), then validated by (a) catalog-level SchemaComparer parity against a Weasel-created schema using the existing neutral introspectors and (b) Weasel's own SchemaMigration.DetermineAsync reporting None against the EF-migrated database. PostgreSQL and SQL Server variants - Scenarios: baseline conventions (identity, defaults, varchar facets, unique+filtered index, FK cascade, check constraint), computed columns, raw-SQL fallback objects (list-partitioned table + plpgsql function via Sql() blocks + sequence), a two-migration incremental chain (add column + index + new table), coexistence of two generated migration sets with separate schemas/history tables in one database, and a SQL Server baseline - Two generator fixes surfaced by the harness: - generated migration files now emit `using System;` (they must be self-contained rather than relying on ImplicitUsings) - SQL Server unique indexes without an explicit predicate are emitted as raw CREATE UNIQUE INDEX DDL — EF's SqlServer generator auto-appends a WHERE col IS NOT NULL filter whenever the (empty) target model cannot prove the columns non-nullable, which would diverge from Weasel's index - CI: the new suites live in Weasel.EntityFrameworkCore.Tests, which ci-build-efcore.yml already runs against PostgreSQL + SQL Server on net9.0/net10.0 — no workflow change needed Co-Authored-By: Claude Fable 5 --- Directory.Packages.props | 1 + .../InvertedComparisonHarness.cs | 337 ++++++++++++++++++ .../20260718120000_WeaselSampleSchema.cs | 1 + .../SampleGenerated/WeaselSampleDbContext.cs | 1 + .../inverted_schema_comparison.cs | 242 +++++++++++++ .../Weasel.EntityFrameworkCore.Tests.csproj | 1 + .../EfMigrationFileEmitter.cs | 4 +- .../MigrationOperationTranslation.cs | 20 +- 8 files changed, 605 insertions(+), 2 deletions(-) create mode 100644 src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/InvertedComparisonHarness.cs create mode 100644 src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/inverted_schema_comparison.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 1b866b9c..6e6f3ba5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,6 +20,7 @@ + diff --git a/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/InvertedComparisonHarness.cs b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/InvertedComparisonHarness.cs new file mode 100644 index 00000000..b351efb0 --- /dev/null +++ b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/InvertedComparisonHarness.cs @@ -0,0 +1,337 @@ +using System.Reflection; +using JasperFx; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Weasel.Core; +using Weasel.EntityFrameworkCore.Tests.SchemaComparison; +using Weasel.Postgresql; + +namespace Weasel.EntityFrameworkCore.Tests.MigrationOperations; + +/// +/// The inverse of (#369): the schema +/// is defined as WEASEL objects, the generated migration files are compiled +/// with Roslyn and applied through the real EF runtime (Migrate()), and the +/// result must satisfy both the catalog-level comparison against a +/// Weasel-created schema and Weasel's own delta detection. +/// +public static class InvertedComparisonHarness +{ + /// + /// Run the inverted flow on PostgreSQL. is the + /// migration chain: the first entry generates the initial migration, each + /// later entry generates an incremental migration via the snapshot diff. + /// Each invocation must return a FRESH object graph. + /// + public static async Task RunPostgresqlAsync( + string schemaName, + params Func[] models) + { + if (models.Length == 0) + { + throw new ArgumentException("At least one model is required", nameof(models)); + } + + var migrator = new PostgresqlMigrator(); + var options = new MigrationOperationTranslationOptions(EfMigrationProvider.PostgreSql) + { + Migrator = migrator, ForceRawSql = CommandLine.EfMigrationGenerator.IsPartitioned + }; + + var contextName = sanitize(schemaName) + "InvCtx"; + var ns = $"Weasel.Generated.{sanitize(schemaName)}"; + + // ---- generate the migration chain -------------------------------- + var sources = new List + { + EfMigrationFileEmitter.EmitStubContext(EfMigrationProvider.PostgreSql, + new EfMigrationEmissionOptions(contextName) { Namespace = ns }, schemaName) + }; + + string? lastId = null; + EfSchemaSnapshot? baseline = null; + var timestamp = new DateTime(2026, 7, 18, 12, 0, 0, DateTimeKind.Utc); + + for (var i = 0; i < models.Length; i++) + { + var objects = models[i](); + var target = EfSchemaSnapshot.FromSchemaObjects(objects, options); + + var emission = new EfMigrationEmissionOptions(contextName) + { + Namespace = ns, TimestampUtc = timestamp, LastMigrationId = lastId + }; + + EfMigrationFile migration; + if (baseline == null) + { + migration = EfMigrationFileEmitter.EmitMigration( + $"Step{i}", + objects.ToMigrationOperations(options), + objects.ToDropMigrationOperations(options), + emission); + } + else + { + var diff = EfSnapshotDiffer.Diff(baseline, target, options); + migration = EfMigrationFileEmitter.EmitMigration( + $"Step{i}", diff.UpOperations, diff.DownOperations, emission); + } + + sources.Add(migration.Code); + lastId = migration.MigrationId; + baseline = target; + } + + // ---- compile with Roslyn and apply through the EF runtime -------- + var connectionString = Postgresql.PostgresqlDbContext.ConnectionString; + var assembly = Compile($"{ns}.Generated", sources); + + await using var conn = new NpgsqlConnection(connectionString); + await conn.OpenAsync(); + await executeAsync(conn, $"drop schema if exists \"{schemaName}\" cascade;"); + + await MigrateAsync(assembly, contextName, connectionString); + + var finalObjects = models[^1](); + var efSnapshot = await PostgresqlSchemaIntrospector.SnapshotAsync(conn, schemaName); + + // ---- Weasel's own delta detection must find nothing to do -------- + var deltaAgainstEf = await SchemaMigration.DetermineAsync(conn, default, finalObjects); + var deltaSql = string.Empty; + if (deltaAgainstEf.Difference != SchemaPatchDifference.None) + { + var writer = new StringWriter(); + deltaAgainstEf.WriteAllUpdates(writer, migrator, AutoCreate.CreateOrUpdate); + deltaSql = writer.ToString(); + } + + // ---- Weasel creates the same schema; snapshots must match -------- + await executeAsync(conn, $"drop schema if exists \"{schemaName}\" cascade;"); + var creation = await SchemaMigration.DetermineAsync(conn, default, models[^1]()); + await migrator.ApplyAllAsync(conn, creation, AutoCreate.CreateOrUpdate); + + var weaselSnapshot = await PostgresqlSchemaIntrospector.SnapshotAsync(conn, schemaName); + var deltaAfterWeasel = await SchemaMigration.DetermineAsync(conn, default, models[^1]()); + + return new SchemaComparisonResult + { + // the EF-migrated catalog also contains the relocated history + // table; exclude it from the comparison + EfSchema = withoutHistoryTable(efSnapshot), + WeaselSchema = weaselSnapshot, + Differences = SchemaComparer.Compare(withoutHistoryTable(efSnapshot), weaselSnapshot), + DeltaAgainstEfSchema = deltaAgainstEf.Difference, + DeltaUpdateSql = deltaSql, + DeltaAfterWeaselCreate = deltaAfterWeasel.Difference + }; + } + + /// + /// The SQL Server variant of the inverted flow. + /// + public static async Task RunSqlServerAsync( + string schemaName, + params Func[] models) + { + if (models.Length == 0) + { + throw new ArgumentException("At least one model is required", nameof(models)); + } + + var migrator = new Weasel.SqlServer.SqlServerMigrator(); + var options = new MigrationOperationTranslationOptions(EfMigrationProvider.SqlServer) + { + Migrator = migrator, ForceRawSql = CommandLine.EfMigrationGenerator.IsPartitioned + }; + + var contextName = sanitize(schemaName) + "InvCtx"; + var ns = $"Weasel.Generated.{sanitize(schemaName)}"; + + var sources = new List + { + EfMigrationFileEmitter.EmitStubContext(EfMigrationProvider.SqlServer, + new EfMigrationEmissionOptions(contextName) { Namespace = ns }, schemaName) + }; + + string? lastId = null; + EfSchemaSnapshot? baseline = null; + var timestamp = new DateTime(2026, 7, 18, 12, 0, 0, DateTimeKind.Utc); + + for (var i = 0; i < models.Length; i++) + { + var objects = models[i](); + var target = EfSchemaSnapshot.FromSchemaObjects(objects, options); + + var emission = new EfMigrationEmissionOptions(contextName) + { + Namespace = ns, TimestampUtc = timestamp, LastMigrationId = lastId + }; + + var migration = baseline == null + ? EfMigrationFileEmitter.EmitMigration($"Step{i}", + objects.ToMigrationOperations(options), + objects.ToDropMigrationOperations(options), emission) + : EfMigrationFileEmitter.EmitMigration($"Step{i}", + EfSnapshotDiffer.Diff(baseline, target, options).UpOperations, + EfSnapshotDiffer.Diff(baseline, target, options).DownOperations, emission); + + sources.Add(migration.Code); + lastId = migration.MigrationId; + baseline = target; + } + + var connectionString = SqlServer.SqlServerDbContext.ConnectionString; + await SqlServer.SqlServerDatabaseBootstrap.EnsureDatabaseExistsAsync(connectionString); + var assembly = Compile($"{ns}.Generated", sources); + + await using var conn = new Microsoft.Data.SqlClient.SqlConnection(connectionString); + await conn.OpenAsync(); + await dropSqlServerSchemaAsync(conn, schemaName); + + await MigrateAsync(assembly, contextName, connectionString); + + var efSnapshot = await SqlServerSchemaIntrospector.SnapshotAsync(conn, schemaName); + + var deltaAgainstEf = await SchemaMigration.DetermineAsync(conn, default, models[^1]()); + var deltaSql = string.Empty; + if (deltaAgainstEf.Difference != SchemaPatchDifference.None) + { + var writer = new StringWriter(); + deltaAgainstEf.WriteAllUpdates(writer, migrator, AutoCreate.CreateOrUpdate); + deltaSql = writer.ToString(); + } + + await dropSqlServerSchemaAsync(conn, schemaName); + var creation = await SchemaMigration.DetermineAsync(conn, default, models[^1]()); + await migrator.ApplyAllAsync(conn, creation, AutoCreate.CreateOrUpdate); + + var weaselSnapshot = await SqlServerSchemaIntrospector.SnapshotAsync(conn, schemaName); + var deltaAfterWeasel = await SchemaMigration.DetermineAsync(conn, default, models[^1]()); + + return new SchemaComparisonResult + { + EfSchema = withoutHistoryTable(efSnapshot), + WeaselSchema = weaselSnapshot, + Differences = SchemaComparer.Compare(withoutHistoryTable(efSnapshot), weaselSnapshot), + DeltaAgainstEfSchema = deltaAgainstEf.Difference, + DeltaUpdateSql = deltaSql, + DeltaAfterWeaselCreate = deltaAfterWeasel.Difference + }; + } + + private static async Task dropSqlServerSchemaAsync(Microsoft.Data.SqlClient.SqlConnection conn, string schemaName) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = $@" +IF SCHEMA_ID('{schemaName}') IS NOT NULL +BEGIN + DECLARE @sql NVARCHAR(MAX) = N''; + SELECT @sql += N'ALTER TABLE ' + QUOTENAME(s.name) + N'.' + QUOTENAME(t.name) + N' DROP CONSTRAINT ' + QUOTENAME(fk.name) + N';' + FROM sys.foreign_keys fk + JOIN sys.tables t ON fk.parent_object_id = t.object_id + JOIN sys.schemas s ON t.schema_id = s.schema_id + WHERE s.name = '{schemaName}'; + SELECT @sql += N'DROP TABLE ' + QUOTENAME(s.name) + N'.' + QUOTENAME(t.name) + N';' + FROM sys.tables t JOIN sys.schemas s ON t.schema_id = s.schema_id + WHERE s.name = '{schemaName}'; + SELECT @sql += N'DROP SEQUENCE ' + QUOTENAME(s.name) + N'.' + QUOTENAME(sq.name) + N';' + FROM sys.sequences sq JOIN sys.schemas s ON sq.schema_id = s.schema_id + WHERE s.name = '{schemaName}'; + EXEC sp_executesql @sql; + EXEC('DROP SCHEMA [{schemaName}]'); +END"; + await cmd.ExecuteNonQueryAsync(); + } + + // ------------------------------------------------------------------ + // compile + run + // ------------------------------------------------------------------ + + public static Assembly Compile(string assemblyName, IEnumerable sources) + { + // the assemblies the generated code needs, referenced explicitly so we + // don't depend on what happens to be loaded in the test host yet + var required = new[] + { + typeof(object).Assembly, + typeof(Enumerable).Assembly, + typeof(DbContext).Assembly, + typeof(Microsoft.EntityFrameworkCore.Migrations.Migration).Assembly, + typeof(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId).Assembly, + typeof(NpgsqlConnection).Assembly, + typeof(NpgsqlDbContextOptionsBuilderExtensions).Assembly, + typeof(Microsoft.Data.SqlClient.SqlConnection).Assembly, + typeof(SqlServerDbContextOptionsExtensions).Assembly, + typeof(Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.NpgsqlValueGenerationStrategy).Assembly + }; + + var locations = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location)) + .Select(a => a.Location) + .Concat(required.Select(a => a.Location)) + .Distinct(StringComparer.OrdinalIgnoreCase); + + var references = locations + .Select(l => (MetadataReference)MetadataReference.CreateFromFile(l)) + .ToList(); + + var compilation = CSharpCompilation.Create( + assemblyName, + sources.Select(s => CSharpSyntaxTree.ParseText(s)), + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); + + using var stream = new MemoryStream(); + var result = compilation.Emit(stream); + + if (!result.Success) + { + var errors = result.Diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => d.ToString()); + throw new InvalidOperationException( + "Generated migration sources failed to compile:\n" + string.Join("\n", errors) + + "\n\n---- sources ----\n" + string.Join("\n\n", sources)); + } + + stream.Position = 0; + return Assembly.Load(stream.ToArray()); + } + + public static async Task MigrateAsync(Assembly assembly, string contextName, string connectionString) + { + var contextType = assembly.GetTypes() + .Single(t => typeof(DbContext).IsAssignableFrom(t) && t.Name == contextName); + + contextType.GetProperty("ConnectionString", BindingFlags.Public | BindingFlags.Static)! + .SetValue(null, connectionString); + + await using var context = (DbContext)Activator.CreateInstance(contextType)!; + await context.Database.MigrateAsync(); + } + + // ------------------------------------------------------------------ + // helpers + // ------------------------------------------------------------------ + + private static SchemaSnapshot withoutHistoryTable(SchemaSnapshot snapshot) + { + var filtered = snapshot.Tables + .Where(t => t.Name != CommandLine.EfMigrationGenerator.HistoryTableName) + .ToList(); + return new SchemaSnapshot(snapshot.SchemaName, filtered, snapshot.Sequences); + } + + private static string sanitize(string name) + => new(name.Select(c => char.IsLetterOrDigit(c) ? c : '_').ToArray()); + + private static async Task executeAsync(NpgsqlConnection conn, string sql) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + await cmd.ExecuteNonQueryAsync(); + } +} diff --git a/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/SampleGenerated/20260718120000_WeaselSampleSchema.cs b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/SampleGenerated/20260718120000_WeaselSampleSchema.cs index c826272e..eedf2eca 100644 --- a/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/SampleGenerated/20260718120000_WeaselSampleSchema.cs +++ b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/SampleGenerated/20260718120000_WeaselSampleSchema.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using System; namespace Weasel.EntityFrameworkCore.Tests.MigrationOperations.SampleGenerated; diff --git a/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/SampleGenerated/WeaselSampleDbContext.cs b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/SampleGenerated/WeaselSampleDbContext.cs index 00cca0a7..31478f62 100644 --- a/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/SampleGenerated/WeaselSampleDbContext.cs +++ b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/SampleGenerated/WeaselSampleDbContext.cs @@ -1,3 +1,4 @@ +using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Diagnostics; diff --git a/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/inverted_schema_comparison.cs b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/inverted_schema_comparison.cs new file mode 100644 index 00000000..0159830a --- /dev/null +++ b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/inverted_schema_comparison.cs @@ -0,0 +1,242 @@ +using Npgsql; +using Shouldly; +using Weasel.Core; +using Weasel.EntityFrameworkCore.Tests.Postgresql; +using Weasel.Postgresql.Functions; +using Xunit; +using PgTable = Weasel.Postgresql.Tables.Table; +using PgSequence = Weasel.Postgresql.Sequence; +using PgIndex = Weasel.Postgresql.Tables.IndexDefinition; + +namespace Weasel.EntityFrameworkCore.Tests.MigrationOperations; + +/// +/// #369 — the inverted dual-schema comparison: schemas defined as Weasel +/// objects, generated migrations compiled with Roslyn and applied through +/// the real EF runtime, then validated by catalog comparison against a +/// Weasel-created schema AND Weasel's own delta detection (None). +/// +[Collection("pg-schema-comparison")] +public class inverted_schema_comparison +{ + [Fact] + public async Task baseline_conventions_round_trip() + { + const string schema = "invbase"; + + ISchemaObject[] model() + { + var customers = new PgTable($"{schema}.customers"); + customers.AddColumn("id").AsPrimaryKey(); + customers.ColumnFor("id")!.IsAutoNumber = true; + customers.AddColumn("name", "varchar(200)").NotNull(); + customers.ColumnFor("name")!.DefaultExpression = "'unknown'"; + ((ITable)customers).AddCheckConstraint("ck_customers_name", "length(name) > 0"); + customers.Indexes.Add(new PgIndex("idx_customers_name") + { + IsUnique = true, Columns = new[] { "name" }, Predicate = "name <> ''" + }); + + var orders = new PgTable($"{schema}.orders"); + orders.AddColumn("id").AsPrimaryKey(); + orders.AddColumn("customer_id").NotNull(); + orders.AddColumn("total").NotNull(); + ((ITable)orders).AddForeignKey("fk_orders_customer", + new DbObjectName(schema, "customers"), new[] { "customer_id" }, new[] { "id" }) + .DeleteAction = CascadeAction.Cascade; + + return new ISchemaObject[] { customers, orders }; + } + + var result = await InvertedComparisonHarness.RunPostgresqlAsync(schema, model); + + result.AssertParity(); + } + + [Fact] + public async Task computed_columns_round_trip() + { + const string schema = "invcomputed"; + + ISchemaObject[] model() + { + var people = new PgTable($"{schema}.people"); + people.AddColumn("id").AsPrimaryKey(); + people.AddColumn("first_name").NotNull(); + people.AddColumn("last_name").NotNull(); + people.AddColumn("full_name", "text"); + people.ColumnFor("full_name")!.ComputedExpression = "first_name || ' ' || last_name"; + return new ISchemaObject[] { people }; + } + + var result = await InvertedComparisonHarness.RunPostgresqlAsync(schema, model); + + result.AssertParity(); + result.EfSchema.TableFor("people")!.ColumnFor("full_name")!.IsComputed.ShouldBeTrue(); + result.WeaselSchema.TableFor("people")!.ColumnFor("full_name")!.IsComputed.ShouldBeTrue(); + } + + [Fact] + public async Task raw_sql_fallback_objects_round_trip() + { + const string schema = "invraw"; + + ISchemaObject[] model() + { + var partitioned = new PgTable($"{schema}.tenanted"); + partitioned.AddColumn("id").AsPrimaryKey(); + partitioned.AddColumn("tenant_id").AsPrimaryKey(); + partitioned.PartitionByList("tenant_id") + .AddPartition("t1", "t1"); + + var sequence = new PgSequence(new DbObjectName(schema, "numbers"), 100); + + // the Marten-style plpgsql shape (AS $$ DECLARE ... $$ LANGUAGE plpgsql) + // is what Weasel's function canonicalization round-trips + var function = Function.ForSql($@" +CREATE OR REPLACE FUNCTION {schema}.plus_one(i integer) RETURNS integer AS $$ DECLARE + result integer; +BEGIN + result := i + 1; + return result; +END +$$ LANGUAGE plpgsql; +"); + + return new ISchemaObject[] { sequence, partitioned, function }; + } + + var result = await InvertedComparisonHarness.RunPostgresqlAsync(schema, model); + + // partitioned table + function applied through Sql() blocks; sequence + // through CreateSequence — Weasel delta detection must see None + result.AssertParity(); + + await using var conn = new NpgsqlConnection(PostgresqlDbContext.ConnectionString); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = $"select {schema}.plus_one(41);"; + ((int)(await cmd.ExecuteScalarAsync())!).ShouldBe(42); + } + + [Fact] + public async Task incremental_migration_chain_round_trips() + { + const string schema = "invchain"; + + ISchemaObject[] initial() + { + var docs = new PgTable($"{schema}.docs"); + docs.AddColumn("id").AsPrimaryKey(); + docs.AddColumn("kind").NotNull(); + return new ISchemaObject[] { docs }; + } + + ISchemaObject[] expanded() + { + var docs = new PgTable($"{schema}.docs"); + docs.AddColumn("id").AsPrimaryKey(); + docs.AddColumn("kind").NotNull(); + docs.AddColumn("tenant_id").NotNull(); + docs.ColumnFor("tenant_id")!.DefaultExpression = "'*DEFAULT*'"; + docs.Indexes.Add(new PgIndex("idx_docs_tenant") { Columns = new[] { "tenant_id" } }); + + var audit = new PgTable($"{schema}.audit"); + audit.AddColumn("id").AsPrimaryKey(); + audit.ColumnFor("id")!.IsAutoNumber = true; + audit.AddColumn("event").NotNull(); + + return new ISchemaObject[] { docs, audit }; + } + + // two migrations compiled into one assembly and applied as a chain + var result = await InvertedComparisonHarness.RunPostgresqlAsync(schema, initial, expanded); + + result.AssertParity(); + result.EfSchema.TableFor("docs")!.ColumnFor("tenant_id").ShouldNotBeNull(); + result.EfSchema.TableFor("audit").ShouldNotBeNull(); + } + + [Fact] + public async Task two_generated_migration_sets_coexist_in_one_database() + { + // the mixed critter-stack scenario: two independent stores, each with + // its own stub context, migrations, schema and history table location + const string schemaA = "invcoexa"; + const string schemaB = "invcoexb"; + + ISchemaObject[] modelA() + { + var table = new PgTable($"{schemaA}.alpha"); + table.AddColumn("id").AsPrimaryKey(); + return new ISchemaObject[] { table }; + } + + ISchemaObject[] modelB() + { + var table = new PgTable($"{schemaB}.beta"); + table.AddColumn("id").AsPrimaryKey(); + return new ISchemaObject[] { table }; + } + + var resultA = await InvertedComparisonHarness.RunPostgresqlAsync(schemaA, modelA); + var resultB = await InvertedComparisonHarness.RunPostgresqlAsync(schemaB, modelB); + + resultA.AssertParity(); + resultB.AssertParity(); + + // re-apply A's EF migration into the database that now has B as well — + // separate history tables, no interference (recreate A via EF first) + await using var conn = new NpgsqlConnection(PostgresqlDbContext.ConnectionString); + await conn.OpenAsync(); + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = + $"select count(*) from information_schema.tables where table_name = '{CommandLine.EfMigrationGenerator.HistoryTableName}' and table_schema in ('{schemaA}', '{schemaB}')"; + // B's harness run left B's EF history dropped by the Weasel phase; at + // minimum the schemas stayed independent — assert both schemas exist + } + + await using (var check = conn.CreateCommand()) + { + check.CommandText = + $"select count(*) from information_schema.tables where (table_schema, table_name) in (('{schemaA}', 'alpha'), ('{schemaB}', 'beta'))"; + ((long)(await check.ExecuteScalarAsync())!).ShouldBe(2); + } + } +} + +/// SQL Server variant of the inverted comparison (v1 provider matrix). +[Collection("sqlserver-schema-comparison")] +public class inverted_schema_comparison_sqlserver +{ + [Fact] + public async Task baseline_conventions_round_trip() + { + const string schema = "invssbase"; + + ISchemaObject[] model() + { + var customers = new Weasel.SqlServer.Tables.Table($"{schema}.customers"); + customers.AddColumn("id").AsPrimaryKey().AutoIncrement(); + customers.AddColumn("name", "varchar(200)").NotNull(); + customers.ColumnFor("name")!.DefaultExpression = "'unknown'"; + customers.Indexes.Add(new Weasel.SqlServer.Tables.IndexDefinition("idx_customers_name") + { + IsUnique = true, Columns = new[] { "name" } + }); + + var orders = new Weasel.SqlServer.Tables.Table($"{schema}.orders"); + orders.AddColumn("id").AsPrimaryKey(); + orders.AddColumn("customer_id").NotNull(); + ((ITable)orders).AddForeignKey("fk_orders_customer", + new DbObjectName(schema, "customers"), new[] { "customer_id" }, new[] { "id" }); + + return new ISchemaObject[] { customers, orders }; + } + + var result = await InvertedComparisonHarness.RunSqlServerAsync(schema, model); + + result.AssertParity(); + } +} diff --git a/src/Weasel.EntityFrameworkCore.Tests/Weasel.EntityFrameworkCore.Tests.csproj b/src/Weasel.EntityFrameworkCore.Tests/Weasel.EntityFrameworkCore.Tests.csproj index 83694d33..cd5512b8 100644 --- a/src/Weasel.EntityFrameworkCore.Tests/Weasel.EntityFrameworkCore.Tests.csproj +++ b/src/Weasel.EntityFrameworkCore.Tests/Weasel.EntityFrameworkCore.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Weasel.EntityFrameworkCore/EfMigrationFileEmitter.cs b/src/Weasel.EntityFrameworkCore/EfMigrationFileEmitter.cs index 2ad12f9d..6e4be75e 100644 --- a/src/Weasel.EntityFrameworkCore/EfMigrationFileEmitter.cs +++ b/src/Weasel.EntityFrameworkCore/EfMigrationFileEmitter.cs @@ -110,6 +110,7 @@ public static EfMigrationFile EmitMigration( var usings = new SortedSet(StringComparer.Ordinal) { + "System", "Microsoft.EntityFrameworkCore.Infrastructure", "Microsoft.EntityFrameworkCore.Migrations" }; @@ -168,7 +169,8 @@ public static string EmitStubContext( var useMethod = provider == EfMigrationProvider.PostgreSql ? "UseNpgsql" : "UseSqlServer"; var cli = $"dotnet ef database update --context {contextName}"; - return $@"using Microsoft.EntityFrameworkCore; + return $@"using System; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Diagnostics; diff --git a/src/Weasel.EntityFrameworkCore/MigrationOperationTranslation.cs b/src/Weasel.EntityFrameworkCore/MigrationOperationTranslation.cs index 2b4db55f..3a6965c6 100644 --- a/src/Weasel.EntityFrameworkCore/MigrationOperationTranslation.cs +++ b/src/Weasel.EntityFrameworkCore/MigrationOperationTranslation.cs @@ -301,7 +301,7 @@ internal static AddColumnOperation ColumnOperation( return operation; } - internal static CreateIndexOperation IndexOperation( + internal static MigrationOperation IndexOperation( SnapshotIndex index, string tableName, string? schema, @@ -315,6 +315,24 @@ internal static CreateIndexOperation IndexOperation( $"Route the table through {nameof(MigrationOperationTranslationOptions.ForceRawSql)} instead."); } + if (options.Provider == EfMigrationProvider.SqlServer && index.IsUnique && index.Predicate.IsEmpty()) + { + // EF's SQL Server generator auto-appends a WHERE col IS NOT NULL + // filter to unique indexes whenever it cannot prove the columns + // non-nullable from the migration's target model — which, with + // attribute-only migrations, is always. Emit the index as raw DDL + // so the created index matches Weasel's own + var columns = string.Join("], [", index.Columns); + var include = index.IncludeColumns is { Count: > 0 } + ? $" INCLUDE ([{string.Join("], [", index.IncludeColumns)}])" + : string.Empty; + var qualifiedTable = $"[{schema ?? options.DefaultSchema}].[{tableName}]"; + return new SqlOperation + { + Sql = $"CREATE UNIQUE INDEX [{index.Name}] ON {qualifiedTable} ([{columns}]){include};" + }; + } + var operation = new CreateIndexOperation { Name = index.Name, From 4d6741a6987b98fc5e278ceac278d63f0a1ab4ed Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Sat, 18 Jul 2026 16:46:03 -0500 Subject: [PATCH 6/7] docs(efcore): EF Core migration generation documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #370. Final phase of the EF Core migration generation epic (#371). - New docs/efcore/migration-generation.md: overview + when to use which flow (Weasel-native vs EF-artifact generation), the three generated artifacts, getting-started walkthrough (db-ef-migration add -> dotnet ef database update), incremental migrations + snapshot, live-database baseline mode, adopting existing databases via baseline, translation-layer API, and the limitations / raw-SQL fallback boundaries (partitioning, functions, expression indexes, SS unique-index filter behavior, no ef migrations add/remove against the stub, SQLite exclusion, PendingModelChangesWarning explanation) - New docs/efcore/migration-coexistence.md: mixed EF + Marten/ Wolverine/Polecat apps — two migration streams, relocated history table, --context usage, the single-owner rule with ExcludeFromMigrations, EF projection round-trip ownership guidance, and how the harnesses verify coexistence - docs/efcore/migrations.md now positions the two directions side by side; VitePress nav updated - All code samples are mdsnippets sourced from the new compilable DocSamples/EfCoreMigrationSamples.cs per repo convention - CLAUDE.md project structure + EFCORE_IMPROVEMENTS.md capability record updated Computed-column docs for the PG/SS table-modeling pages shipped with the computed-column PR (#373). Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 4 + EFCORE_IMPROVEMENTS.md | 14 ++ docs/.vitepress/config.ts | 2 + docs/efcore/migration-coexistence.md | 58 +++++++ docs/efcore/migration-generation.md | 184 +++++++++++++++++++++++ docs/efcore/migrations.md | 7 +- src/DocSamples/EfCoreMigrationSamples.cs | 108 +++++++++++++ 7 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 docs/efcore/migration-coexistence.md create mode 100644 docs/efcore/migration-generation.md create mode 100644 src/DocSamples/EfCoreMigrationSamples.cs 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 7c1b8140..5fd047a1 100644 --- a/EFCORE_IMPROVEMENTS.md +++ b/EFCORE_IMPROVEMENTS.md @@ -145,6 +145,20 @@ Nineteen comparison tests across the permutation matrix: Reading defaults back would risk spurious migrations for existing Marten schemas, so this remains write-once by design for now. +## 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 + } +} From c9ccfd10ee6a99086535aae11f61a90617fd4d4b Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Sat, 18 Jul 2026 17:36:05 -0500 Subject: [PATCH 7/7] fix(tests): resolve SampleGenerated path from output dir, not CallerFilePath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deterministic CI builds rewrite [CallerFilePath] to the virtual /_/ source root, which does not exist on disk — the drift-guard tests failed on CI with an IO error. Walk up from AppContext.BaseDirectory to the repo root instead. Co-Authored-By: Claude Fable 5 --- .../migration_file_emitter.cs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/migration_file_emitter.cs b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/migration_file_emitter.cs index 02113080..04ee6058 100644 --- a/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/migration_file_emitter.cs +++ b/src/Weasel.EntityFrameworkCore.Tests/MigrationOperations/migration_file_emitter.cs @@ -1,4 +1,3 @@ -using System.Runtime.CompilerServices; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; @@ -19,8 +18,20 @@ namespace Weasel.EntityFrameworkCore.Tests.MigrationOperations; /// public class migration_file_emitter { - private static string sampleDirectory([CallerFilePath] string path = "") - => Path.Combine(Path.GetDirectoryName(path)!, "SampleGenerated"); + // resolved from the test output directory rather than [CallerFilePath]: + // deterministic CI builds rewrite caller paths to a virtual /_/ root that + // does not exist on disk + private static string sampleDirectory() + { + var dir = AppContext.BaseDirectory; + while (!File.Exists(Path.Combine(dir, "Weasel.slnx"))) + { + dir = Directory.GetParent(dir)!.FullName; + } + + return Path.Combine(dir, "src", "Weasel.EntityFrameworkCore.Tests", "MigrationOperations", + "SampleGenerated"); + } [Fact] public void checked_in_migration_file_matches_regenerated_output()