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