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)
+ };
+ }
+}