From 742c2793d98aa13320daba54d8e58e556f2bcb4c Mon Sep 17 00:00:00 2001 From: Tom Wolfe Date: Thu, 18 Jun 2026 22:32:16 +0100 Subject: [PATCH 01/12] build: bump version --- src/NSchema.Postgres/NSchema.Postgres.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NSchema.Postgres/NSchema.Postgres.csproj b/src/NSchema.Postgres/NSchema.Postgres.csproj index 78afa8c..b019ac7 100644 --- a/src/NSchema.Postgres/NSchema.Postgres.csproj +++ b/src/NSchema.Postgres/NSchema.Postgres.csproj @@ -20,7 +20,7 @@ true true snupkg - 3.0.0-alpha.11 + 3.0.0-alpha.12 $(Version.Split('-')[0]) $(Version.Split('-')[0]) true From 8a9bfbfeffea692bbd8011e856b7d3b324f92c75 Mon Sep 17 00:00:00 2001 From: Tom Wolfe Date: Thu, 18 Jun 2026 22:33:29 +0100 Subject: [PATCH 02/12] build: bump package version --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 13358cf..b29ffe1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,7 +7,7 @@ - + From 596d6332b2e4134253079910ac11494847f4c95f Mon Sep 17 00:00:00 2001 From: Tom Wolfe Date: Thu, 18 Jun 2026 23:03:56 +0100 Subject: [PATCH 03/12] chore: update to alpha 41 --- Directory.Packages.props | 2 +- .../Sql/PostgresSchemaProvider.cs | 43 +++++++------ .../Sql/PostgresSqlGenerator.cs | 50 +++++++++------ .../SqlTypePostgresExtensions.cs | 2 +- .../Sql/PostgresSchemaProviderTests.cs | 33 +++++----- .../Sql/PostgresSqlGeneratorSnapshotTests.cs | 43 ++++++++----- .../Sql/PostgresSqlGeneratorTests.cs | 61 ++++++++++++------- .../Sql/SequenceOptionsNormalizationTests.cs | 3 +- 8 files changed, 144 insertions(+), 93 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index b29ffe1..c2742e6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,7 +7,7 @@ - + diff --git a/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs b/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs index 565aff8..0f9c7ee 100644 --- a/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs +++ b/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs @@ -3,6 +3,15 @@ using NSchema.Postgres.Models; using NSchema.Schema; using NSchema.Schema.Model; +using NSchema.Schema.Model.Columns; +using NSchema.Schema.Model.Constraints; +using NSchema.Schema.Model.Enums; +using NSchema.Schema.Model.Indexes; +using NSchema.Schema.Model.Routines; +using NSchema.Schema.Model.Schemas; +using NSchema.Schema.Model.Sequences; +using NSchema.Schema.Model.Tables; +using NSchema.Schema.Model.Views; namespace NSchema.Postgres.Sql; @@ -1063,19 +1072,16 @@ private static DatabaseSchema Build( g => g.Select(s => new Sequence(s.Name, NormalizeSequenceOptions(s), OldName: null, Comment: sequenceComments.GetValueOrDefault((s.Schema, s.Name)))).ToList()); - var functionsBySchema = functions - .GroupBy(f => f.Schema) - .ToDictionary( - g => g.Key, - g => g.Select(f => new Function(f.Name, f.Arguments, f.Definition, OldName: null, - Comment: functionComments.GetValueOrDefault((f.Schema, f.Name)))).ToList()); - - var proceduresBySchema = procedures - .GroupBy(p => p.Schema) - .ToDictionary( - g => g.Key, - g => g.Select(p => new Procedure(p.Name, p.Arguments, p.Definition, OldName: null, - Comment: procedureComments.GetValueOrDefault((p.Schema, p.Name)))).ToList()); + // Functions and procedures are one model (Routine) distinguished by Kind, sharing a single name space, so + // the two query results merge into one routine list per schema. + var routinesBySchema = functions + .Select(f => (f.Schema, Routine: new Routine(f.Name, RoutineKind.Function, f.Arguments, f.Definition, + OldName: null, Comment: functionComments.GetValueOrDefault((f.Schema, f.Name))))) + .Concat(procedures + .Select(p => (p.Schema, Routine: new Routine(p.Name, RoutineKind.Procedure, p.Arguments, p.Definition, + OldName: null, Comment: procedureComments.GetValueOrDefault((p.Schema, p.Name)))))) + .GroupBy(x => x.Schema) + .ToDictionary(g => g.Key, g => g.Select(x => x.Routine).ToList()); // Drive schema list from what actually exists in the database, not from what was requested. var existingSchemas = schemaComments.Keys @@ -1083,8 +1089,7 @@ private static DatabaseSchema Build( .Union(viewsBySchema.Keys) .Union(enumsBySchema.Keys) .Union(sequencesBySchema.Keys) - .Union(functionsBySchema.Keys) - .Union(proceduresBySchema.Keys) + .Union(routinesBySchema.Keys) .Union(schemaGrants.Select(g => g.SchemaName)) .Distinct(StringComparer.OrdinalIgnoreCase); @@ -1102,10 +1107,8 @@ private static DatabaseSchema Build( DroppedEnums: [], Sequences: sequencesBySchema.GetValueOrDefault(name, []), DroppedSequences: [], - Functions: functionsBySchema.GetValueOrDefault(name, []), - DroppedFunctions: [], - Procedures: proceduresBySchema.GetValueOrDefault(name, []), - DroppedProcedures: []); + Routines: routinesBySchema.GetValueOrDefault(name, []), + DroppedRoutines: []); }) .ToList(); @@ -1202,7 +1205,7 @@ List allTableGrants var idxs = allIndexes .Where(i => i.SchemaName == tableRow.Schema && i.TableName == tableRow.Name) .Select(i => new TableIndex( - i.IndexName, i.ColumnNames, i.IsUnique, + i.IndexName, i.ColumnNames.Select(c => new IndexColumn(c)).ToList(), i.IsUnique, indexComments.GetValueOrDefault((tableRow.Schema, i.IndexName)), i.Predicate)) .ToList(); diff --git a/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs b/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs index 00931b4..b6478c2 100644 --- a/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs +++ b/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs @@ -1,5 +1,17 @@ using NSchema.Plan.Model; -using NSchema.Schema.Model; +using NSchema.Plan.Model.Columns; +using NSchema.Plan.Model.Constraints; +using NSchema.Plan.Model.Enums; +using NSchema.Plan.Model.Indexes; +using NSchema.Plan.Model.Routines; +using NSchema.Plan.Model.Schemas; +using NSchema.Plan.Model.Sequence; +using NSchema.Plan.Model.Tables; +using NSchema.Plan.Model.Views; +using NSchema.Schema.Model.Columns; +using NSchema.Schema.Model.Routines; +using NSchema.Schema.Model.Sequences; +using NSchema.Schema.Model.Tables; using NSchema.Sql; using NSchema.Sql.Model; @@ -24,8 +36,7 @@ public SqlPlan Generate(MigrationPlan plan) // the statement is carved out of the surrounding transaction. The executor commits the pending segment, // runs it alone, and resumes — ordering relative to later statements that use the value is preserved. AddEnumValue x => [new SqlStatement(BuildAddEnumValue(x), RunOutsideTransaction: true)], - RecreateFunction x => BuildRecreateRoutine("FUNCTION", x.SchemaName, x.Function.Name, x.Function.Arguments, x.Function.Definition, x.Function.Comment), - RecreateProcedure x => BuildRecreateRoutine("PROCEDURE", x.SchemaName, x.Procedure.Name, x.Procedure.Arguments, x.Procedure.Definition, x.Procedure.Comment), + RecreateRoutine x => BuildRecreateRoutine(RoutineKeyword(x.Routine.Kind), x.SchemaName, x.Routine.Name, x.Routine.Arguments, x.Routine.Definition, x.Routine.Comment), _ => [new SqlStatement(GenerateSql(action))], }; @@ -78,21 +89,17 @@ public SqlPlan Generate(MigrationPlan plan) SetSequenceComment x => x.NewComment is null ? $"""COMMENT ON SEQUENCE "{x.SchemaName}"."{x.SequenceName}" IS NULL""" : $"""COMMENT ON SEQUENCE "{x.SchemaName}"."{x.SequenceName}" IS $comment${x.NewComment}$comment$""", - // A routine Add and a definition-only Modify both arrive as Create; CREATE OR REPLACE serves both. The model - // has no overloading (one routine per name), so drops, renames and comments omit the signature — Postgres - // resolves the bare name, and rejects it loudly if an out-of-model overload makes it ambiguous. - CreateFunction x => $"""CREATE OR REPLACE FUNCTION "{x.SchemaName}"."{x.Function.Name}"({x.Function.Arguments}) {x.Function.Definition}""", - DropFunction x => $"DROP FUNCTION \"{x.SchemaName}\".\"{x.FunctionName}\"", - RenameFunction x => $"ALTER FUNCTION \"{x.SchemaName}\".\"{x.OldName}\" RENAME TO \"{x.NewName}\"", - SetFunctionComment x => x.NewComment is null - ? $"""COMMENT ON FUNCTION "{x.SchemaName}"."{x.FunctionName}" IS NULL""" - : $"""COMMENT ON FUNCTION "{x.SchemaName}"."{x.FunctionName}" IS $comment${x.NewComment}$comment$""", - CreateProcedure x => $"""CREATE OR REPLACE PROCEDURE "{x.SchemaName}"."{x.Procedure.Name}"({x.Procedure.Arguments}) {x.Procedure.Definition}""", - DropProcedure x => $"DROP PROCEDURE \"{x.SchemaName}\".\"{x.ProcedureName}\"", - RenameProcedure x => $"ALTER PROCEDURE \"{x.SchemaName}\".\"{x.OldName}\" RENAME TO \"{x.NewName}\"", - SetProcedureComment x => x.NewComment is null - ? $"""COMMENT ON PROCEDURE "{x.SchemaName}"."{x.ProcedureName}" IS NULL""" - : $"""COMMENT ON PROCEDURE "{x.SchemaName}"."{x.ProcedureName}" IS $comment${x.NewComment}$comment$""", + // A routine Add and a definition-only Modify both arrive as CreateRoutine; CREATE OR REPLACE serves both. + // Functions and procedures are one model distinguished by Kind, so a single set of actions carries the + // keyword. The model has no overloading (one routine per name), so drops, renames and comments omit the + // signature — Postgres resolves the bare name, and rejects it loudly if an out-of-model overload makes it + // ambiguous. + CreateRoutine x => $"""CREATE OR REPLACE {RoutineKeyword(x.Routine.Kind)} "{x.SchemaName}"."{x.Routine.Name}"({x.Routine.Arguments}) {x.Routine.Definition}""", + DropRoutine x => $"DROP {RoutineKeyword(x.Kind)} \"{x.SchemaName}\".\"{x.RoutineName}\"", + RenameRoutine x => $"ALTER {RoutineKeyword(x.Kind)} \"{x.SchemaName}\".\"{x.OldName}\" RENAME TO \"{x.NewName}\"", + SetRoutineComment x => x.NewComment is null + ? $"""COMMENT ON {RoutineKeyword(x.Kind)} "{x.SchemaName}"."{x.RoutineName}" IS NULL""" + : $"""COMMENT ON {RoutineKeyword(x.Kind)} "{x.SchemaName}"."{x.RoutineName}" IS $comment${x.NewComment}$comment$""", SetSchemaComment x => x.NewComment is null ? $"""COMMENT ON SCHEMA "{x.SchemaName}" IS NULL""" : $"""COMMENT ON SCHEMA "{x.SchemaName}" IS $comment${x.NewComment}$comment$""", @@ -143,7 +150,10 @@ private static string BuildAddForeignKey(AddForeignKey x) private static string BuildCreateIndex(CreateIndex x) { - var sql = $"""CREATE {(x.Index.IsUnique ? "UNIQUE " : "")}INDEX "{x.Index.Name}" ON "{x.SchemaName}"."{x.TableName}" ({ColList(x.Index.ColumnNames)})"""; + // Baseline: render plain column keys exactly as before. Access method, INCLUDE, expression keys, and + // per-key ordering carried on the richer index model are handled by the index-depth feature work. + var keys = ColList(x.Index.Columns.Select(c => c.Expression).ToList()); + var sql = $"""CREATE {(x.Index.IsUnique ? "UNIQUE " : "")}INDEX "{x.Index.Name}" ON "{x.SchemaName}"."{x.TableName}" ({keys})"""; return x.Index.Predicate is { } pred ? $"{sql} WHERE {pred}" : sql; } @@ -328,6 +338,8 @@ private static IEnumerable BuildRecreateRoutine(string kind, strin } } + private static string RoutineKeyword(RoutineKind kind) => kind == RoutineKind.Procedure ? "PROCEDURE" : "FUNCTION"; + private static long DefaultStart(SequenceOptions options) => (options.IncrementBy ?? 1) > 0 ? options.MinValue ?? 1 : options.MaxValue ?? -1; diff --git a/src/NSchema.Postgres/SqlTypePostgresExtensions.cs b/src/NSchema.Postgres/SqlTypePostgresExtensions.cs index 4aed8fe..6ce592d 100644 --- a/src/NSchema.Postgres/SqlTypePostgresExtensions.cs +++ b/src/NSchema.Postgres/SqlTypePostgresExtensions.cs @@ -1,4 +1,4 @@ -using NSchema.Schema.Model; +using NSchema.Schema.Model.Columns; namespace NSchema.Postgres; diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSchemaProviderTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSchemaProviderTests.cs index d541e5a..87f49c5 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSchemaProviderTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSchemaProviderTests.cs @@ -1,7 +1,11 @@ using Npgsql; using NSchema.Postgres.Sql; using NSchema.Postgres.Tests.Fixtures; -using NSchema.Schema.Model; +using NSchema.Schema.Model.Columns; +using NSchema.Schema.Model.Routines; +using NSchema.Schema.Model.Sequences; +using NSchema.Schema.Model.Tables; +using NSchema.Schema.Model.Views; namespace NSchema.Postgres.Tests.Sql; @@ -359,7 +363,7 @@ email TEXT NOT NULL // Assert idx.Name.ShouldBe("ix_users_email"); - idx.ColumnNames.ShouldBe(["email"]); + idx.Columns.Select(c => c.Expression).ShouldBe(["email"]); idx.IsUnique.ShouldBeFalse(); } @@ -401,7 +405,7 @@ happened TIMESTAMP NOT NULL .Schemas[0].Tables[0].Indexes.Single(); // Assert - idx.ColumnNames.ShouldBe(["user_id", "happened"]); + idx.Columns.Select(c => c.Expression).ShouldBe(["user_id", "happened"]); } [Fact] @@ -897,7 +901,7 @@ RETURNS integer LANGUAGE sql AS $$ SELECT a + b $$ // Act var function = (await _sut.GetSchema([_schema], TestContext.Current.CancellationToken)) - .Schemas[0].Functions.ShouldHaveSingleItem(); + .Schemas[0].Routines.ShouldHaveSingleItem(); // Assert — both parts are the DB's canonical form: the argument list as pg_get_function_arguments renders // it, and the definition starting right after the CREATE header (at RETURNS). @@ -920,7 +924,7 @@ RETURNS text LANGUAGE sql AS $$ SELECT value $$ // Act var function = (await _sut.GetSchema([_schema], TestContext.Current.CancellationToken)) - .Schemas[0].Functions.ShouldHaveSingleItem(); + .Schemas[0].Routines.ShouldHaveSingleItem(); // Assert function.Arguments.ShouldStartWith("value text DEFAULT repeat("); @@ -935,7 +939,7 @@ public async Task GetSchema_QuotedFunctionName_HeaderStripSurvives() // Act var function = (await _sut.GetSchema([_schema], TestContext.Current.CancellationToken)) - .Schemas[0].Functions.ShouldHaveSingleItem(); + .Schemas[0].Routines.ShouldHaveSingleItem(); // Assert function.Name.ShouldBe("GetAnswer"); @@ -954,7 +958,7 @@ await Exec($""" // Act var function = (await _sut.GetSchema([_schema], TestContext.Current.CancellationToken)) - .Schemas[0].Functions.ShouldHaveSingleItem(); + .Schemas[0].Routines.ShouldHaveSingleItem(); // Assert function.Comment.ShouldBe("the answer"); @@ -969,9 +973,9 @@ public async Task GetSchema_Procedure_ReturnedAsProcedureNotFunction() // Act var schema = (await _sut.GetSchema([_schema], TestContext.Current.CancellationToken)).Schemas[0]; - // Assert — prokind separates the two sets; a procedure must not leak into Functions. - schema.Functions.ShouldBeEmpty(); - var procedure = schema.Procedures.ShouldHaveSingleItem(); + // Assert — prokind is carried as Routine.Kind; a procedure must be tagged Procedure, not Function. + var procedure = schema.Routines.ShouldHaveSingleItem(); + procedure.Kind.ShouldBe(RoutineKind.Procedure); procedure.Name.ShouldBe("noop"); procedure.Arguments.ShouldBe("a integer"); procedure.Definition.ShouldStartWith("LANGUAGE sql"); @@ -989,9 +993,10 @@ await Exec($""" // Act var procedure = (await _sut.GetSchema([_schema], TestContext.Current.CancellationToken)) - .Schemas[0].Procedures.ShouldHaveSingleItem(); + .Schemas[0].Routines.ShouldHaveSingleItem(); // Assert + procedure.Kind.ShouldBe(RoutineKind.Procedure); procedure.Comment.ShouldBe("does nothing"); } @@ -1007,8 +1012,7 @@ public async Task GetSchema_ExtensionFunctions_AreExcluded() .Schemas.Single(s => s.Name == "public"); // Assert - publicSchema.Functions.ShouldBeEmpty(); - publicSchema.Procedures.ShouldBeEmpty(); + publicSchema.Routines.ShouldBeEmpty(); } [Fact] @@ -1021,7 +1025,6 @@ public async Task GetSchema_Aggregate_IsNotReturnedAsFunction() var schema = (await _sut.GetSchema([_schema], TestContext.Current.CancellationToken)).Schemas[0]; // Assert - schema.Functions.ShouldBeEmpty(); - schema.Procedures.ShouldBeEmpty(); + schema.Routines.ShouldBeEmpty(); } } diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs index 27929da..c3da531 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs @@ -1,6 +1,21 @@ using NSchema.Plan.Model; +using NSchema.Plan.Model.Columns; +using NSchema.Plan.Model.Enums; +using NSchema.Plan.Model.Indexes; +using NSchema.Plan.Model.Routines; +using NSchema.Plan.Model.Schemas; +using NSchema.Plan.Model.Sequence; +using NSchema.Plan.Model.Tables; +using NSchema.Plan.Model.Views; using NSchema.Postgres.Sql; -using NSchema.Schema.Model; +using NSchema.Schema.Model.Columns; +using NSchema.Schema.Model.Enums; +using NSchema.Schema.Model.Indexes; +using NSchema.Schema.Model.Routines; +using NSchema.Schema.Model.Scripts; +using NSchema.Schema.Model.Sequences; +using NSchema.Schema.Model.Tables; +using NSchema.Schema.Model.Views; using NSchema.Sql; namespace NSchema.Postgres.Tests.Sql; @@ -136,30 +151,30 @@ public Task SequenceOperations() => VerifyPlan( [Fact] public Task FunctionOperations() => VerifyPlan( - new CreateFunction("public", new Function("active_user_count", "", + new CreateRoutine("public", new Routine("active_user_count", RoutineKind.Function, "", "RETURNS integer LANGUAGE sql AS $$ SELECT count(*) FROM public.users WHERE active $$")), - new RenameFunction("public", "user_count", "active_user_count"), + new RenameRoutine("public", "user_count", "active_user_count", RoutineKind.Function), // A signature change: drop + recreate, re-issuing the comment the drop discarded. - new RecreateFunction("public", new Function("add_numbers", "a integer, b integer, c integer DEFAULT 0", + new RecreateRoutine("public", new Routine("add_numbers", RoutineKind.Function, "a integer, b integer, c integer DEFAULT 0", "RETURNS integer LANGUAGE sql AS $$ SELECT a + b + c $$", Comment: "Adds numbers")), - new RecreateFunction("public", new Function("subtract_numbers", "a integer, b integer", + new RecreateRoutine("public", new Routine("subtract_numbers", RoutineKind.Function, "a integer, b integer", "RETURNS integer LANGUAGE sql AS $$ SELECT a - b $$")), - new SetFunctionComment("public", "active_user_count", null, "Count of active users"), - new SetFunctionComment("public", "active_user_count", "Count of active users", null), - new DropFunction("public", "active_user_count")); + new SetRoutineComment("public", "active_user_count", null, "Count of active users", RoutineKind.Function), + new SetRoutineComment("public", "active_user_count", "Count of active users", null, RoutineKind.Function), + new DropRoutine("public", "active_user_count", RoutineKind.Function)); // ── Procedures ──────────────────────────────────────────────────────────── [Fact] public Task ProcedureOperations() => VerifyPlan( - new CreateProcedure("public", new Procedure("archive_users", "cutoff date", + new CreateRoutine("public", new Routine("archive_users", RoutineKind.Procedure, "cutoff date", "LANGUAGE sql AS $$ DELETE FROM public.users WHERE created_at < cutoff $$")), - new RenameProcedure("public", "purge_users", "archive_users"), - new RecreateProcedure("public", new Procedure("archive_users", "cutoff timestamptz", + new RenameRoutine("public", "purge_users", "archive_users", RoutineKind.Procedure), + new RecreateRoutine("public", new Routine("archive_users", RoutineKind.Procedure, "cutoff timestamptz", "LANGUAGE sql AS $$ DELETE FROM public.users WHERE created_at < cutoff $$", Comment: "Archives stale users")), - new SetProcedureComment("public", "archive_users", null, "Archive job"), - new SetProcedureComment("public", "archive_users", "Archive job", null), - new DropProcedure("public", "archive_users")); + new SetRoutineComment("public", "archive_users", null, "Archive job", RoutineKind.Procedure), + new SetRoutineComment("public", "archive_users", "Archive job", null, RoutineKind.Procedure), + new DropRoutine("public", "archive_users", RoutineKind.Procedure)); // ── Comments ──────────────────────────────────────────────────────────────── diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs index 2d10d5b..aafffda 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs @@ -1,8 +1,25 @@ using Npgsql; using NSchema.Plan.Model; +using NSchema.Plan.Model.Columns; +using NSchema.Plan.Model.Constraints; +using NSchema.Plan.Model.Enums; +using NSchema.Plan.Model.Indexes; +using NSchema.Plan.Model.Routines; +using NSchema.Plan.Model.Schemas; +using NSchema.Plan.Model.Sequence; +using NSchema.Plan.Model.Tables; +using NSchema.Plan.Model.Views; using NSchema.Postgres.Sql; using NSchema.Postgres.Tests.Fixtures; -using NSchema.Schema.Model; +using NSchema.Schema.Model.Columns; +using NSchema.Schema.Model.Constraints; +using NSchema.Schema.Model.Enums; +using NSchema.Schema.Model.Indexes; +using NSchema.Schema.Model.Routines; +using NSchema.Schema.Model.Scripts; +using NSchema.Schema.Model.Sequences; +using NSchema.Schema.Model.Tables; +using NSchema.Schema.Model.Views; using NSchema.Sql.Model; namespace NSchema.Postgres.Tests.Sql; @@ -847,12 +864,12 @@ SELECT COUNT(*) > 0 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespac public async Task CreateFunction_CreatesFunctionInDatabase() { // Arrange - var function = new Function("add_numbers", "a integer, b integer", + var function = new Routine("add_numbers", RoutineKind.Function, "a integer, b integer", "RETURNS integer LANGUAGE sql AS $$ SELECT a + b $$"); // Act await _executor.Execute(_generator.Generate(new MigrationPlan( - [new CreateFunction(_schema, function)], [], [])), TestContext.Current.CancellationToken); + [new CreateRoutine(_schema, function)], [], [])), TestContext.Current.CancellationToken); // Assert (await ScalarString($"""SELECT "{_schema}".add_numbers(2, 3)::text""")).ShouldBe("5"); @@ -863,11 +880,11 @@ public async Task CreateFunction_OnExistingFunction_ReplacesDefinition() { // Arrange — CreateFunction serves both add and definition-only modify; the second create must replace. await Exec($"""CREATE FUNCTION "{_schema}".answer() RETURNS integer LANGUAGE sql AS $$ SELECT 1 $$"""); - var replacement = new Function("answer", "", "RETURNS integer LANGUAGE sql AS $$ SELECT 42 $$"); + var replacement = new Routine("answer", RoutineKind.Function, "", "RETURNS integer LANGUAGE sql AS $$ SELECT 42 $$"); // Act await _executor.Execute(_generator.Generate(new MigrationPlan( - [new CreateFunction(_schema, replacement)], [], [])), TestContext.Current.CancellationToken); + [new CreateRoutine(_schema, replacement)], [], [])), TestContext.Current.CancellationToken); // Assert (await ScalarString($"""SELECT "{_schema}".answer()::text""")).ShouldBe("42"); @@ -882,12 +899,12 @@ await Exec($""" CREATE FUNCTION "{_schema}".add_numbers(a integer, b integer) RETURNS integer LANGUAGE sql AS $$ SELECT a + b $$; COMMENT ON FUNCTION "{_schema}".add_numbers IS 'Adds numbers'; """); - var desired = new Function("add_numbers", "a integer, b integer, c integer", + var desired = new Routine("add_numbers", RoutineKind.Function, "a integer, b integer, c integer", "RETURNS integer LANGUAGE sql AS $$ SELECT a + b + c $$", Comment: "Adds numbers"); // Act await _executor.Execute(_generator.Generate(new MigrationPlan( - [new RecreateFunction(_schema, desired)], [], [])), TestContext.Current.CancellationToken); + [new RecreateRoutine(_schema, desired)], [], [])), TestContext.Current.CancellationToken); // Assert — exactly one routine remains, under the new signature, with the comment restored. var count = await ScalarString($""" @@ -907,7 +924,7 @@ public async Task RenameFunction_RenamesFunction() // Act await _executor.Execute(_generator.Generate(new MigrationPlan( - [new RenameFunction(_schema, "old_answer", "answer")], [], [])), TestContext.Current.CancellationToken); + [new RenameRoutine(_schema, "old_answer", "answer", RoutineKind.Function)], [], [])), TestContext.Current.CancellationToken); // Assert (await ScalarString($"""SELECT "{_schema}".answer()::text""")).ShouldBe("42"); @@ -922,12 +939,12 @@ public async Task SetFunctionComment_SetsAndClearsComment() // Act + Assert — set... await _executor.Execute(_generator.Generate(new MigrationPlan( - [new SetFunctionComment(_schema, "answer", null, "the answer")], [], [])), TestContext.Current.CancellationToken); + [new SetRoutineComment(_schema, "answer", null, "the answer", RoutineKind.Function)], [], [])), TestContext.Current.CancellationToken); (await ScalarString(commentSql)).ShouldBe("the answer"); // ...and clear. await _executor.Execute(_generator.Generate(new MigrationPlan( - [new SetFunctionComment(_schema, "answer", "the answer", null)], [], [])), TestContext.Current.CancellationToken); + [new SetRoutineComment(_schema, "answer", "the answer", null, RoutineKind.Function)], [], [])), TestContext.Current.CancellationToken); (await ScalarBool($"SELECT ({commentSql}) IS NULL")).ShouldBeTrue(); } @@ -939,7 +956,7 @@ public async Task DropFunction_RemovesFunction() // Act await _executor.Execute(_generator.Generate(new MigrationPlan( - [new DropFunction(_schema, "answer")], [], [])), TestContext.Current.CancellationToken); + [new DropRoutine(_schema, "answer", RoutineKind.Function)], [], [])), TestContext.Current.CancellationToken); // Assert var exists = await ScalarBool($""" @@ -956,12 +973,12 @@ public async Task CreateProcedure_CreatesProcedureInDatabase() { // Arrange await Exec($"""CREATE TABLE "{_schema}".audit (entry text)"""); - var procedure = new Procedure("log_entry", "message text", + var procedure = new Routine("log_entry", RoutineKind.Procedure, "message text", $"""LANGUAGE sql AS $$ INSERT INTO "{_schema}".audit (entry) VALUES (message) $$"""); // Act await _executor.Execute(_generator.Generate(new MigrationPlan( - [new CreateProcedure(_schema, procedure)], [], [])), TestContext.Current.CancellationToken); + [new CreateRoutine(_schema, procedure)], [], [])), TestContext.Current.CancellationToken); // Assert — the procedure exists and is callable. await Exec($"""CALL "{_schema}".log_entry('hello')"""); @@ -976,12 +993,12 @@ await Exec($""" CREATE PROCEDURE "{_schema}".noop(a integer) LANGUAGE sql AS $$ SELECT 1 $$; COMMENT ON PROCEDURE "{_schema}".noop IS 'does nothing'; """); - var desired = new Procedure("noop", "a integer, b integer", + var desired = new Routine("noop", RoutineKind.Procedure, "a integer, b integer", "LANGUAGE sql AS $$ SELECT 1 $$", Comment: "does nothing"); // Act await _executor.Execute(_generator.Generate(new MigrationPlan( - [new RecreateProcedure(_schema, desired)], [], [])), TestContext.Current.CancellationToken); + [new RecreateRoutine(_schema, desired)], [], [])), TestContext.Current.CancellationToken); // Assert var count = await ScalarString($""" @@ -1001,7 +1018,7 @@ public async Task RenameProcedure_RenamesProcedure() // Act await _executor.Execute(_generator.Generate(new MigrationPlan( - [new RenameProcedure(_schema, "old_noop", "noop")], [], [])), TestContext.Current.CancellationToken); + [new RenameRoutine(_schema, "old_noop", "noop", RoutineKind.Procedure)], [], [])), TestContext.Current.CancellationToken); // Assert await Exec($"""CALL "{_schema}".noop()"""); @@ -1016,12 +1033,12 @@ public async Task SetProcedureComment_SetsAndClearsComment() // Act + Assert — set... await _executor.Execute(_generator.Generate(new MigrationPlan( - [new SetProcedureComment(_schema, "noop", null, "does nothing")], [], [])), TestContext.Current.CancellationToken); + [new SetRoutineComment(_schema, "noop", null, "does nothing", RoutineKind.Procedure)], [], [])), TestContext.Current.CancellationToken); (await ScalarString(commentSql)).ShouldBe("does nothing"); // ...and clear. await _executor.Execute(_generator.Generate(new MigrationPlan( - [new SetProcedureComment(_schema, "noop", "does nothing", null)], [], [])), TestContext.Current.CancellationToken); + [new SetRoutineComment(_schema, "noop", "does nothing", null, RoutineKind.Procedure)], [], [])), TestContext.Current.CancellationToken); (await ScalarBool($"SELECT ({commentSql}) IS NULL")).ShouldBeTrue(); } @@ -1033,7 +1050,7 @@ public async Task DropProcedure_RemovesProcedure() // Act await _executor.Execute(_generator.Generate(new MigrationPlan( - [new DropProcedure(_schema, "noop")], [], [])), TestContext.Current.CancellationToken); + [new DropRoutine(_schema, "noop", RoutineKind.Procedure)], [], [])), TestContext.Current.CancellationToken); // Assert var exists = await ScalarBool($""" @@ -1103,17 +1120,17 @@ public async Task RoundTrip_Function_IntrospectsWithSameArguments() // Arrange — the argument list is the recreate trigger, so what was applied must read back verbatim. // (The definition reads back in the DB's canonical form — $function$ quoting, qualified names — which the // core reconciles by storing the DB-reported form, as with view bodies.) - var function = new Function("add_numbers", "a integer, b integer", + var function = new Routine("add_numbers", RoutineKind.Function, "a integer, b integer", "RETURNS integer LANGUAGE sql AS $$ SELECT a + b $$"); // Act await _executor.Execute(_generator.Generate(new MigrationPlan( - [new CreateFunction(_schema, function)], [], [])), TestContext.Current.CancellationToken); + [new CreateRoutine(_schema, function)], [], [])), TestContext.Current.CancellationToken); // Assert var provider = new PostgresSchemaProvider(_dataSource); var introspected = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken)) - .Schemas[0].Functions.ShouldHaveSingleItem(); + .Schemas[0].Routines.ShouldHaveSingleItem(); introspected.Name.ShouldBe("add_numbers"); introspected.Arguments.ShouldBe("a integer, b integer"); introspected.Definition.ShouldContain("SELECT a + b"); diff --git a/tests/NSchema.Postgres.Tests/Sql/SequenceOptionsNormalizationTests.cs b/tests/NSchema.Postgres.Tests/Sql/SequenceOptionsNormalizationTests.cs index 5120997..2a26b72 100644 --- a/tests/NSchema.Postgres.Tests/Sql/SequenceOptionsNormalizationTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/SequenceOptionsNormalizationTests.cs @@ -1,6 +1,7 @@ using NSchema.Postgres.Models; using NSchema.Postgres.Sql; -using NSchema.Schema.Model; +using NSchema.Schema.Model.Columns; +using NSchema.Schema.Model.Sequences; namespace NSchema.Postgres.Tests.Sql; From 713e745d9e89acf313402747609d6f101e56f16a Mon Sep 17 00:00:00 2001 From: Tom Wolfe Date: Thu, 18 Jun 2026 23:17:03 +0100 Subject: [PATCH 04/12] feat: better index support. --- src/NSchema.Postgres/Models/IndexRow.cs | 13 +++- .../Sql/PostgresSchemaProvider.cs | 70 +++++++++++++++---- .../Sql/PostgresSqlGenerator.cs | 33 +++++++-- .../Sql/PostgresSqlGeneratorSnapshotTests.cs | 5 ++ .../Sql/PostgresSqlGeneratorTests.cs | 43 ++++++++++++ ...SnapshotTests.IndexOperations.verified.txt | 10 ++- 6 files changed, 154 insertions(+), 20 deletions(-) diff --git a/src/NSchema.Postgres/Models/IndexRow.cs b/src/NSchema.Postgres/Models/IndexRow.cs index e08f5f6..2d5fdb3 100644 --- a/src/NSchema.Postgres/Models/IndexRow.cs +++ b/src/NSchema.Postgres/Models/IndexRow.cs @@ -1,9 +1,18 @@ namespace NSchema.Postgres.Models; +/// +/// A row of index metadata. Columns are carried positionally: the first entries are the +/// index keys (a column name or an expression, per , with ordering in +/// ); the remainder are covering INCLUDE columns. +/// internal sealed record IndexRow( string SchemaName, string TableName, string IndexName, bool IsUnique, - string[] ColumnNames, - string? Predicate); + string? Method, + int NumKeyAtts, + string? Predicate, + string[] ColumnTexts, + bool[] IsExpressions, + int[] Options); diff --git a/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs b/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs index 0f9c7ee..3e4ba42 100644 --- a/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs +++ b/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs @@ -411,29 +411,38 @@ private static async Task> QueryIndexes(NpgsqlConnection conn, st await using var cmd = conn.CreateCommand(); // Exclude primary-key indexes and any index that backs a constraint (a UNIQUE constraint's implicit index // surfaces as a UniqueConstraint, not a TableIndex). Standalone CREATE UNIQUE INDEXes have no backing - // constraint, so they are still returned. Exclude expression indexes (attnum = 0). + // constraint, so they are still returned. + // + // Columns are read positionally (indkey order, all indnatts of them — keys then INCLUDE): a plain column + // is its attname; an expression key (attnum = 0) is its pg_get_indexdef text. indnkeyatts marks the + // key/INCLUDE split, indoption carries per-key ASC/DESC + NULLS bits, and the access method is btree-folded + // to null (the default) so a plain index round-trips clean. cmd.CommandText = """ SELECT n.nspname AS schema_name, t.relname AS table_name, i.relname AS index_name, ix.indisunique AS is_unique, - array_agg(a.attname ORDER BY k.ordinality) AS column_names, - pg_get_expr(ix.indpred, ix.indrelid) AS predicate + NULLIF(am.amname, 'btree') AS method, + ix.indnkeyatts AS num_key_atts, + pg_get_expr(ix.indpred, ix.indrelid) AS predicate, + array_agg(CASE WHEN k.attnum = 0 THEN pg_get_indexdef(ix.indexrelid, k.ordinality::int, true) ELSE a.attname END ORDER BY k.ordinality) AS column_texts, + array_agg(k.attnum = 0 ORDER BY k.ordinality) AS is_expressions, + array_agg(COALESCE((string_to_array(ix.indoption::text, ' '))[k.ordinality]::int, 0) ORDER BY k.ordinality) AS options FROM pg_index ix JOIN pg_class t ON t.oid = ix.indrelid JOIN pg_class i ON i.oid = ix.indexrelid JOIN pg_namespace n ON n.oid = t.relnamespace + JOIN pg_am am ON am.oid = i.relam JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ordinality) ON TRUE - JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum + LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum WHERE (@schemas::text[] IS NULL OR n.nspname = ANY(@schemas)) AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg\_toast%' ESCAPE '\' AND n.nspname NOT LIKE 'pg\_temp%' ESCAPE '\' AND NOT ix.indisprimary AND NOT EXISTS (SELECT 1 FROM pg_constraint con WHERE con.conindid = i.oid) - AND k.attnum > 0 - GROUP BY n.nspname, t.relname, i.relname, ix.indisunique, ix.indpred, ix.indrelid + GROUP BY n.nspname, t.relname, i.relname, ix.indisunique, am.amname, ix.indnkeyatts, ix.indpred, ix.indrelid, ix.indexrelid ORDER BY n.nspname, t.relname, i.relname """; AddSchemasParameter(cmd, schemas); @@ -446,8 +455,12 @@ AND k.attnum > 0 TableName: reader.GetString(1), IndexName: reader.GetString(2), IsUnique: reader.GetBoolean(3), - ColumnNames: reader.GetFieldValue(4), - Predicate: reader.IsDBNull(5) ? null : reader.GetString(5) + Method: reader.IsDBNull(4) ? null : reader.GetString(4), + NumKeyAtts: reader.GetInt16(5), + Predicate: reader.IsDBNull(6) ? null : reader.GetString(6), + ColumnTexts: reader.GetFieldValue(7), + IsExpressions: reader.GetFieldValue(8), + Options: reader.GetFieldValue(9) )); } @@ -1204,10 +1217,7 @@ List allTableGrants var idxs = allIndexes .Where(i => i.SchemaName == tableRow.Schema && i.TableName == tableRow.Name) - .Select(i => new TableIndex( - i.IndexName, i.ColumnNames.Select(c => new IndexColumn(c)).ToList(), i.IsUnique, - indexComments.GetValueOrDefault((tableRow.Schema, i.IndexName)), - i.Predicate)) + .Select(i => MapIndex(i, indexComments.GetValueOrDefault((tableRow.Schema, i.IndexName)))) .ToList(); tableComments.TryGetValue((tableRow.Schema, tableRow.Name), out var tableComment); @@ -1234,6 +1244,42 @@ List allTableGrants // ── Mapping ─────────────────────────────────────────────────────────────── + private static TableIndex MapIndex(IndexRow row, string? comment) + { + var keys = new List(); + var include = new List(); + for (var i = 0; i < row.ColumnTexts.Length; i++) + { + if (i < row.NumKeyAtts) + { + var (sort, nulls) = DecodeIndexOption(row.Options[i]); + keys.Add(new IndexColumn(row.ColumnTexts[i], row.IsExpressions[i], sort, nulls)); + } + else + { + // INCLUDE columns are always plain columns and carry no ordering. + include.Add(row.ColumnTexts[i]); + } + } + + return new TableIndex(row.IndexName, keys, row.IsUnique, comment, row.Predicate, row.Method, include); + } + + // indoption packs two bits per key: 0x01 = DESC, 0x02 = NULLS FIRST. The engine default is NULLS LAST for an + // ascending key and NULLS FIRST for a descending one (i.e. the default of the NULLS-FIRST bit equals the DESC + // bit), so a key matching that default normalizes to Default/Default and a plain index round-trips without + // phantom drift — only an explicitly non-default ordering surfaces. + private static (IndexSort Sort, IndexNulls Nulls) DecodeIndexOption(int option) + { + var descending = (option & 1) != 0; + var nullsFirst = (option & 2) != 0; + var sort = descending ? IndexSort.Descending : IndexSort.Default; + var nulls = nullsFirst == descending + ? IndexNulls.Default + : nullsFirst ? IndexNulls.First : IndexNulls.Last; + return (sort, nulls); + } + private static Column MapColumn(ColumnRow row, Dictionary<(string, string, string), string?> columnComments) { var type = MapSqlType(row.DataType, row.UdtName, row.DomainSchema, row.DomainName, row.MaxLength, row.NumericPrecision, row.NumericScale); diff --git a/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs b/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs index b6478c2..aff50bd 100644 --- a/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs +++ b/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs @@ -9,6 +9,7 @@ using NSchema.Plan.Model.Tables; using NSchema.Plan.Model.Views; using NSchema.Schema.Model.Columns; +using NSchema.Schema.Model.Indexes; using NSchema.Schema.Model.Routines; using NSchema.Schema.Model.Sequences; using NSchema.Schema.Model.Tables; @@ -150,11 +151,33 @@ private static string BuildAddForeignKey(AddForeignKey x) private static string BuildCreateIndex(CreateIndex x) { - // Baseline: render plain column keys exactly as before. Access method, INCLUDE, expression keys, and - // per-key ordering carried on the richer index model are handled by the index-depth feature work. - var keys = ColList(x.Index.Columns.Select(c => c.Expression).ToList()); - var sql = $"""CREATE {(x.Index.IsUnique ? "UNIQUE " : "")}INDEX "{x.Index.Name}" ON "{x.SchemaName}"."{x.TableName}" ({keys})"""; - return x.Index.Predicate is { } pred ? $"{sql} WHERE {pred}" : sql; + var idx = x.Index; + var method = idx.Method is { } m ? $" USING {m}" : ""; + var keys = string.Join(", ", idx.Columns.Select(IndexKeyText)); + var include = idx.Include.Count > 0 ? $" INCLUDE ({ColList(idx.Include)})" : ""; + var sql = $"""CREATE {(idx.IsUnique ? "UNIQUE " : "")}INDEX "{idx.Name}" ON "{x.SchemaName}"."{x.TableName}"{method} ({keys}){include}"""; + return idx.Predicate is { } pred ? $"{sql} WHERE {pred}" : sql; + } + + // A plain column key is quoted; an expression key is emitted parenthesised and verbatim. ASC/DESC and + // NULLS FIRST/LAST are rendered only when explicit (IndexSort/IndexNulls.Default omits them, letting the + // engine default stand so the index introspects back without drift). + private static string IndexKeyText(IndexColumn col) + { + var key = col.IsExpression ? $"({col.Expression})" : $"\"{col.Expression}\""; + var sort = col.Sort switch + { + IndexSort.Ascending => " ASC", + IndexSort.Descending => " DESC", + _ => "", + }; + var nulls = col.Nulls switch + { + IndexNulls.First => " NULLS FIRST", + IndexNulls.Last => " NULLS LAST", + _ => "", + }; + return $"{key}{sort}{nulls}"; } private static string BuildColumnDef(Column col) diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs index c3da531..33c26f8 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs @@ -106,6 +106,11 @@ public Task ForeignKeyOperations() => VerifyPlan( public Task IndexOperations() => VerifyPlan( new CreateIndex("public", "users", new TableIndex("idx_users_email", ["email"], IsUnique: true)), new CreateIndex("public", "users", new TableIndex("idx_users_active", ["created_at"], Predicate: "notes IS NOT NULL")), + // An access method (USING), a covering INCLUDE, descending / nulls ordering, and an expression key. + new CreateIndex("public", "users", new TableIndex("idx_users_tags", ["tags"], Method: "gin")), + new CreateIndex("public", "users", new TableIndex("idx_users_recent", + [new IndexColumn("created_at", Sort: IndexSort.Descending, Nulls: IndexNulls.Last), new IndexColumn("lower(email)", IsExpression: true)], + Include: ["id", "notes"])), new DropIndex("public", "users", "idx_users_email")); // ── Views ───────────────────────────────────────────────────────────────── diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs index aafffda..8696960 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs @@ -497,6 +497,49 @@ public async Task CreateIndex_Unique_CreatesUniqueIndexOnTable() isUnique.ShouldBeTrue(); } + [Fact] + public async Task CreateIndex_RichIndex_RoundTripsThroughIntrospection() + { + // Arrange — a covering index with a descending key, an explicit non-default null ordering, and an + // expression key. What is applied must introspect back to the same shape (no phantom drift). + await Exec($"""CREATE TABLE "{_schema}"."items" (id integer, name text, qty integer)"""); + var index = new TableIndex("idx_items_rich", + [new IndexColumn("id", Sort: IndexSort.Descending, Nulls: IndexNulls.Last), new IndexColumn("lower(name)", IsExpression: true)], + Include: ["qty"]); + + // Act + await _executor.Execute(_generator.Generate(new MigrationPlan([new CreateIndex(_schema, "items", index)], [], [])), TestContext.Current.CancellationToken); + + // Assert + var provider = new PostgresSchemaProvider(_dataSource); + var introspected = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken)) + .Schemas[0].Tables[0].Indexes.ShouldHaveSingleItem(); + introspected.Method.ShouldBeNull(); // btree folds to null + introspected.Include.ShouldBe(["qty"]); + introspected.Columns.Count.ShouldBe(2); + introspected.Columns[0].ShouldBe(new IndexColumn("id", IsExpression: false, Sort: IndexSort.Descending, Nulls: IndexNulls.Last)); + introspected.Columns[1].IsExpression.ShouldBeTrue(); + introspected.Columns[1].Expression.ShouldContain("lower"); + } + + [Fact] + public async Task CreateIndex_GinMethod_RoundTripsPreservingMethod() + { + // Arrange — a non-btree access method must survive introspection (it does not fold to null). + await Exec($"""CREATE TABLE "{_schema}"."docs" (tags text[])"""); + var index = new TableIndex("idx_docs_tags", ["tags"], Method: "gin"); + + // Act + await _executor.Execute(_generator.Generate(new MigrationPlan([new CreateIndex(_schema, "docs", index)], [], [])), TestContext.Current.CancellationToken); + + // Assert + var provider = new PostgresSchemaProvider(_dataSource); + var introspected = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken)) + .Schemas[0].Tables[0].Indexes.ShouldHaveSingleItem(); + introspected.Method.ShouldBe("gin"); + introspected.Columns.ShouldHaveSingleItem().Expression.ShouldBe("tags"); + } + [Fact] public async Task DropIndex_RemovesIndexFromTable() { diff --git a/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.IndexOperations.verified.txt b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.IndexOperations.verified.txt index cfed93e..a167a37 100644 --- a/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.IndexOperations.verified.txt +++ b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.IndexOperations.verified.txt @@ -8,10 +8,18 @@ Sql: CREATE INDEX "idx_users_active" ON "public"."users" ("created_at") WHERE notes IS NOT NULL, RunOutsideTransaction: false }, + { + Sql: CREATE INDEX "idx_users_tags" ON "public"."users" USING gin ("tags"), + RunOutsideTransaction: false + }, + { + Sql: CREATE INDEX "idx_users_recent" ON "public"."users" ("created_at" DESC NULLS LAST, (lower(email))) INCLUDE ("id", "notes"), + RunOutsideTransaction: false + }, { Sql: DROP INDEX "public"."idx_users_email", RunOutsideTransaction: false } ], IsEmpty: false -} +} \ No newline at end of file From 03b9c23c7e6602d65b5ba69cf4a90c52db4b49d7 Mon Sep 17 00:00:00 2001 From: Tom Wolfe Date: Thu, 18 Jun 2026 23:24:16 +0100 Subject: [PATCH 05/12] feat: add generators --- src/NSchema.Postgres/Models/ColumnRow.cs | 3 +- .../Sql/PostgresSchemaProvider.cs | 8 ++-- .../Sql/PostgresSqlGenerator.cs | 16 ++++++- .../Sql/PostgresSqlGeneratorSnapshotTests.cs | 14 ++++++ .../Sql/PostgresSqlGeneratorTests.cs | 48 +++++++++++++++++++ ...sts.GeneratedColumnOperations.verified.txt | 26 ++++++++++ 6 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.GeneratedColumnOperations.verified.txt diff --git a/src/NSchema.Postgres/Models/ColumnRow.cs b/src/NSchema.Postgres/Models/ColumnRow.cs index 847a507..52df046 100644 --- a/src/NSchema.Postgres/Models/ColumnRow.cs +++ b/src/NSchema.Postgres/Models/ColumnRow.cs @@ -16,4 +16,5 @@ internal sealed record ColumnRow( bool IsIdentity, long? IdentityStart = null, long? IdentityMinValue = null, - long? IdentityIncrement = null); + long? IdentityIncrement = null, + string? GeneratedExpression = null); diff --git a/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs b/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs index 3e4ba42..59c7083 100644 --- a/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs +++ b/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs @@ -116,7 +116,8 @@ private static async Task> QueryColumns(NpgsqlConnection conn, s c.is_identity, seq.seqstart AS identity_start, seq.seqmin AS identity_min_value, - seq.seqincrement AS identity_increment + seq.seqincrement AS identity_increment, + CASE WHEN c.is_generated = 'ALWAYS' THEN c.generation_expression END AS generation_expression FROM information_schema.columns c LEFT JOIN pg_class t ON t.relname = c.table_name AND t.relkind = 'r' @@ -157,7 +158,8 @@ AND c.table_schema NOT LIKE 'pg\_temp%' ESCAPE '\' IsIdentity: reader.GetString(12) == "YES", IdentityStart: reader.IsDBNull(13) ? null : reader.GetInt64(13), IdentityMinValue: reader.IsDBNull(14) ? null : reader.GetInt64(14), - IdentityIncrement: reader.IsDBNull(15) ? null : reader.GetInt64(15) + IdentityIncrement: reader.IsDBNull(15) ? null : reader.GetInt64(15), + GeneratedExpression: reader.IsDBNull(16) ? null : reader.GetString(16) )); } @@ -1287,7 +1289,7 @@ private static Column MapColumn(ColumnRow row, Dictionary<(string, string, strin var identityOptions = row.IsIdentity ? new IdentityOptions(row.IdentityStart, row.IdentityMinValue, row.IdentityIncrement) : null; - return new Column(row.ColumnName, type, row.IsNullable, row.IsIdentity, row.DefaultExpression, null, comment, identityOptions); + return new Column(row.ColumnName, type, row.IsNullable, row.IsIdentity, row.DefaultExpression, null, comment, identityOptions, row.GeneratedExpression); } private static SqlType MapSqlType(string dataType, string udtName, string? domainSchema, string? domainName, int? maxLength, int? precision, int? scale) diff --git a/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs b/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs index aff50bd..c73b94b 100644 --- a/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs +++ b/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs @@ -58,6 +58,7 @@ public SqlPlan Generate(MigrationPlan plan) AlterIdentitySequence x => BuildAlterIdentitySequence(x), SetColumnDefault { NewDefault: null } x => $"""ALTER TABLE "{x.SchemaName}"."{x.TableName}" ALTER COLUMN "{x.ColumnName}" DROP DEFAULT""", SetColumnDefault x => $"""ALTER TABLE "{x.SchemaName}"."{x.TableName}" ALTER COLUMN "{x.ColumnName}" SET DEFAULT {x.NewDefault}""", + SetColumnGenerated x => BuildSetColumnGenerated(x), AddPrimaryKey x => $"""ALTER TABLE "{x.SchemaName}"."{x.TableName}" ADD CONSTRAINT "{x.PrimaryKey.Name}" PRIMARY KEY ({ColList(x.PrimaryKey.ColumnNames)})""", DropPrimaryKey x => $"ALTER TABLE \"{x.SchemaName}\".\"{x.TableName}\" DROP CONSTRAINT \"{x.PrimaryKeyName}\"", AddForeignKey x => BuildAddForeignKey(x), @@ -186,7 +187,9 @@ private static string BuildColumnDef(Column col) var nullable = col.IsNullable ? "" : " NOT NULL"; var identity = col.IsIdentity ? BuildIdentityClause(col.IdentityOptions) : ""; var def = col is { DefaultExpression: { } d, IsIdentity: false } ? $" DEFAULT {d}" : ""; - return $"\"{col.Name}\" {type}{nullable}{identity}{def}"; + // A generated column is mutually exclusive with a default (the core's structural policy enforces this). + var generated = col.GeneratedExpression is { } g ? $" GENERATED ALWAYS AS ({g}) STORED" : ""; + return $"\"{col.Name}\" {type}{nullable}{identity}{def}{generated}"; } private static string BuildIdentityClause(IdentityOptions? options) @@ -361,6 +364,17 @@ private static IEnumerable BuildRecreateRoutine(string kind, strin } } + // Changing a column's generation expression in place: PG 17+ replaces it with SET EXPRESSION, and a generated + // column is converted back to a plain one with DROP EXPRESSION (data is kept). PostgreSQL has no in-place way + // to make an existing plain column generated, so that transition is rejected — the column must be re-added. + private static string BuildSetColumnGenerated(SetColumnGenerated x) => x switch + { + { NewExpression: null } => $"""ALTER TABLE "{x.SchemaName}"."{x.TableName}" ALTER COLUMN "{x.ColumnName}" DROP EXPRESSION""", + { OldExpression: not null, NewExpression: { } expr } => $"""ALTER TABLE "{x.SchemaName}"."{x.TableName}" ALTER COLUMN "{x.ColumnName}" SET EXPRESSION AS ({expr})""", + _ => throw new NotSupportedException( + $"""Cannot make existing column "{x.SchemaName}"."{x.TableName}"."{x.ColumnName}" generated in place; PostgreSQL has no ADD GENERATED — drop and re-add the column."""), + }; + private static string RoutineKeyword(RoutineKind kind) => kind == RoutineKind.Procedure ? "PROCEDURE" : "FUNCTION"; private static long DefaultStart(SequenceOptions options) => diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs index 33c26f8..9193a62 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs @@ -88,6 +88,20 @@ public Task AlterIdentitySequence() => VerifyPlan( OldOptions: new IdentityOptions(StartWith: 1, MinValue: 1, IncrementBy: 1), NewOptions: new IdentityOptions(StartWith: 500, MinValue: 100, IncrementBy: 2))); + [Fact] + public Task GeneratedColumnOperations() => VerifyPlan( + new CreateTable("public", new Table("boxes", + Columns: + [ + new Column("w", SqlType.Int, IsNullable: false), + new Column("h", SqlType.Int, IsNullable: false), + new Column("area", SqlType.Int, GeneratedExpression: "w * h"), + ])), + new AddColumn("public", "boxes", new Column("perimeter", SqlType.Int, GeneratedExpression: "2 * (w + h)")), + // Change the expression in place (SET EXPRESSION), then drop the generation (DROP EXPRESSION). + new SetColumnGenerated("public", "boxes", "area", "w * h", "w * h * 2"), + new SetColumnGenerated("public", "boxes", "area", "w * h * 2", null)); + // ── Keys, indexes and constraints ─────────────────────────────────────────── [Fact] diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs index 8696960..34c9fcf 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs @@ -297,6 +297,54 @@ public async Task SetColumnDefault_DropsDefaultExpression() hasDefault.ShouldBeFalse(); } + [Fact] + public async Task RoundTrip_GeneratedColumn_IntrospectsAsGeneratedNotDefault() + { + // Arrange — a stored generated column applied via CREATE TABLE must read back as generated, with the + // expression in GeneratedExpression and no DefaultExpression (the two are mutually exclusive). + var table = new Table("boxes", Columns: + [ + new Column("w", SqlType.Int, IsNullable: false), + new Column("h", SqlType.Int, IsNullable: false), + new Column("area", SqlType.Int, GeneratedExpression: "w * h"), + ]); + + // Act + await _executor.Execute(_generator.Generate(new MigrationPlan([new CreateTable(_schema, table)], [], [])), TestContext.Current.CancellationToken); + + // Assert + var provider = new PostgresSchemaProvider(_dataSource); + var area = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken)) + .Schemas[0].Tables[0].Columns.Single(c => c.Name == "area"); + area.GeneratedExpression.ShouldNotBeNull(); + area.GeneratedExpression!.ShouldContain("w * h"); + area.DefaultExpression.ShouldBeNull(); + } + + [Fact] + public async Task SetColumnGenerated_ChangesAndDropsExpression() + { + // Arrange + await Exec($"""CREATE TABLE "{_schema}".boxes (w int, h int, area int GENERATED ALWAYS AS (w * h) STORED)"""); + var provider = new PostgresSchemaProvider(_dataSource); + + // Act — change the expression (SET EXPRESSION)... + await _executor.Execute(_generator.Generate(new MigrationPlan( + [new SetColumnGenerated(_schema, "boxes", "area", "w * h", "w + h")], [], [])), TestContext.Current.CancellationToken); + var changed = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken)) + .Schemas[0].Tables[0].Columns.Single(c => c.Name == "area"); + + // ...then drop it (DROP EXPRESSION), making it a plain column. + await _executor.Execute(_generator.Generate(new MigrationPlan( + [new SetColumnGenerated(_schema, "boxes", "area", "w + h", null)], [], [])), TestContext.Current.CancellationToken); + var dropped = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken)) + .Schemas[0].Tables[0].Columns.Single(c => c.Name == "area"); + + // Assert + changed.GeneratedExpression!.ShouldContain("w + h"); + dropped.GeneratedExpression.ShouldBeNull(); + } + // ── Primary key operations ──────────────────────────────────────────────── [Fact] diff --git a/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.GeneratedColumnOperations.verified.txt b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.GeneratedColumnOperations.verified.txt new file mode 100644 index 0000000..68ef75f --- /dev/null +++ b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.GeneratedColumnOperations.verified.txt @@ -0,0 +1,26 @@ +{ + Statements: [ + { + Sql: +CREATE TABLE "public"."boxes" ( + "w" integer NOT NULL, + "h" integer NOT NULL, + "area" integer NOT NULL GENERATED ALWAYS AS (w * h) STORED +), + RunOutsideTransaction: false + }, + { + Sql: ALTER TABLE "public"."boxes" ADD COLUMN "perimeter" integer NOT NULL GENERATED ALWAYS AS (2 * (w + h)) STORED, + RunOutsideTransaction: false + }, + { + Sql: ALTER TABLE "public"."boxes" ALTER COLUMN "area" SET EXPRESSION AS (w * h * 2), + RunOutsideTransaction: false + }, + { + Sql: ALTER TABLE "public"."boxes" ALTER COLUMN "area" DROP EXPRESSION, + RunOutsideTransaction: false + } + ], + IsEmpty: false +} \ No newline at end of file From 180e75f51d44bcf194d51571ded5aef740bb8dcb Mon Sep 17 00:00:00 2001 From: Tom Wolfe Date: Thu, 18 Jun 2026 23:36:00 +0100 Subject: [PATCH 06/12] feat: add exclusions --- .../Models/ExclusionConstraintRow.cs | 15 ++++ .../Sql/PostgresSchemaProvider.cs | 81 ++++++++++++++++++- .../Sql/PostgresSqlGenerator.cs | 20 +++++ .../Sql/PostgresSqlGeneratorSnapshotTests.cs | 11 +++ .../Sql/PostgresSqlGeneratorTests.cs | 49 +++++++++++ ...ExclusionConstraintOperations.verified.txt | 17 ++++ 6 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 src/NSchema.Postgres/Models/ExclusionConstraintRow.cs create mode 100644 tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.ExclusionConstraintOperations.verified.txt diff --git a/src/NSchema.Postgres/Models/ExclusionConstraintRow.cs b/src/NSchema.Postgres/Models/ExclusionConstraintRow.cs new file mode 100644 index 0000000..12a94b0 --- /dev/null +++ b/src/NSchema.Postgres/Models/ExclusionConstraintRow.cs @@ -0,0 +1,15 @@ +namespace NSchema.Postgres.Models; + +/// +/// A row of exclusion-constraint metadata. Elements are carried positionally: each is a column name or an +/// expression (per ) paired with the operator in . +/// +internal sealed record ExclusionConstraintRow( + string TableSchema, + string TableName, + string ConstraintName, + string? Method, + string? Predicate, + string[] ElementTexts, + bool[] IsExpressions, + string[] Operators); diff --git a/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs b/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs index 59c7083..188410d 100644 --- a/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs +++ b/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs @@ -33,6 +33,7 @@ public async ValueTask GetSchema(string[]? schemas = null, Cance var foreignKeys = await QueryForeignKeys(conn, schemas, cancellationToken); var uniqueConstraints = await QueryUniqueConstraints(conn, schemas, cancellationToken); var checkConstraints = await QueryCheckConstraints(conn, schemas, cancellationToken); + var exclusionConstraints = await QueryExclusionConstraints(conn, schemas, cancellationToken); var indexes = await QueryIndexes(conn, schemas, cancellationToken); var schemaComments = await QuerySchemaComments(conn, schemas, cancellationToken); var tableComments = await QueryTableComments(conn, schemas, cancellationToken); @@ -54,7 +55,7 @@ public async ValueTask GetSchema(string[]? schemas = null, Cance var procedureComments = await QueryRoutineComments(conn, schemas, ProcedureKind, cancellationToken); return Build( - tables, columns, primaryKeys, foreignKeys, uniqueConstraints, checkConstraints, indexes, + tables, columns, primaryKeys, foreignKeys, uniqueConstraints, checkConstraints, exclusionConstraints, indexes, schemaComments, tableComments, columnComments, indexComments, constraintComments, schemaGrants, tableGrants, views, viewComments, viewDependencies, enums, enumComments, sequences, sequenceComments, @@ -378,6 +379,63 @@ private static string StripEnclosingParens(string expression) return expression[1..^1].Trim(); } + private static async Task> QueryExclusionConstraints(NpgsqlConnection conn, string[]? schemas, CancellationToken ct) + { + var rows = new List(); + await using var cmd = conn.CreateCommand(); + // Exclusion constraints (contype = 'x'). Each is backed by an index (conindid): the index supplies the + // access method and the per-element column/expression text (read the same way as a plain index), while + // conexclop supplies the parallel operator for each element. The conexclop unnest joins on position, so it + // naturally restricts to the key elements (any INCLUDE columns have no operator and drop out). The access + // method is btree-folded to null so an EXCLUDE without USING round-trips clean. + cmd.CommandText = """ + SELECT + n.nspname AS table_schema, + t.relname AS table_name, + c.conname AS constraint_name, + NULLIF(am.amname, 'btree') AS method, + pg_get_expr(ix.indpred, ix.indrelid) AS predicate, + array_agg(CASE WHEN k.attnum = 0 THEN pg_get_indexdef(c.conindid, k.ordinality::int, true) ELSE a.attname END ORDER BY k.ordinality) AS element_texts, + array_agg(k.attnum = 0 ORDER BY k.ordinality) AS is_expressions, + array_agg(op.oprname ORDER BY k.ordinality) AS operators + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + JOIN pg_index ix ON ix.indexrelid = c.conindid + JOIN pg_class i ON i.oid = c.conindid + JOIN pg_am am ON am.oid = i.relam + JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ordinality) ON TRUE + LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum + JOIN LATERAL unnest(c.conexclop) WITH ORDINALITY AS e(opoid, eord) ON e.eord = k.ordinality + JOIN pg_operator op ON op.oid = e.opoid + WHERE c.contype = 'x' + AND (@schemas::text[] IS NULL OR n.nspname = ANY(@schemas)) + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + AND n.nspname NOT LIKE 'pg\_toast%' ESCAPE '\' + AND n.nspname NOT LIKE 'pg\_temp%' ESCAPE '\' + GROUP BY n.nspname, t.relname, c.conname, am.amname, ix.indpred, ix.indrelid, c.conindid + ORDER BY n.nspname, t.relname, c.conname + """; + AddSchemasParameter(cmd, schemas); + + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + rows.Add(new ExclusionConstraintRow( + TableSchema: reader.GetString(0), + TableName: reader.GetString(1), + ConstraintName: reader.GetString(2), + Method: reader.IsDBNull(3) ? null : reader.GetString(3), + Predicate: reader.IsDBNull(4) ? null : reader.GetString(4), + ElementTexts: reader.GetFieldValue(5), + IsExpressions: reader.GetFieldValue(6), + Operators: reader.GetFieldValue(7) + )); + } + + return rows; + } + private static async Task> QueryConstraintComments(NpgsqlConnection conn, string[]? schemas, CancellationToken ct) { var result = new Dictionary<(string, string, string), string?>(); @@ -1040,6 +1098,7 @@ private static DatabaseSchema Build( List foreignKeys, List uniqueConstraints, List checkConstraints, + List exclusionConstraints, List indexes, Dictionary schemaComments, Dictionary<(string, string), string?> tableComments, @@ -1065,7 +1124,7 @@ private static DatabaseSchema Build( .GroupBy(t => t.Schema) .ToDictionary( g => g.Key, - g => g.Select(t => BuildTable(t, columns, primaryKeys, foreignKeys, uniqueConstraints, checkConstraints, indexes, tableComments, columnComments, indexComments, constraintComments, tableGrants)).ToList()); + g => g.Select(t => BuildTable(t, columns, primaryKeys, foreignKeys, uniqueConstraints, checkConstraints, exclusionConstraints, indexes, tableComments, columnComments, indexComments, constraintComments, tableGrants)).ToList()); var viewsBySchema = views .GroupBy(v => v.Schema) @@ -1180,6 +1239,7 @@ private static Table BuildTable( List allForeignKeys, List allUniqueConstraints, List allCheckConstraints, + List allExclusionConstraints, List allIndexes, Dictionary<(string, string), string?> tableComments, Dictionary<(string, string, string), string?> columnComments, @@ -1217,6 +1277,11 @@ List allTableGrants constraintComments.GetValueOrDefault((tableRow.Schema, tableRow.Name, c.ConstraintName)))) .ToList(); + var exclusions = allExclusionConstraints + .Where(e => e.TableSchema == tableRow.Schema && e.TableName == tableRow.Name) + .Select(e => MapExclusionConstraint(e, constraintComments.GetValueOrDefault((tableRow.Schema, tableRow.Name, e.ConstraintName)))) + .ToList(); + var idxs = allIndexes .Where(i => i.SchemaName == tableRow.Schema && i.TableName == tableRow.Name) .Select(i => MapIndex(i, indexComments.GetValueOrDefault((tableRow.Schema, i.IndexName)))) @@ -1239,11 +1304,23 @@ List allTableGrants ForeignKeys: fks, UniqueConstraints: uniques, CheckConstraints: checks, + ExclusionConstraints: exclusions, Indexes: idxs, Grants: grants ); } + private static ExclusionConstraint MapExclusionConstraint(ExclusionConstraintRow row, string? comment) + { + var elements = new List(); + for (var i = 0; i < row.ElementTexts.Length; i++) + { + elements.Add(new ExclusionElement(row.ElementTexts[i], row.Operators[i], row.IsExpressions[i])); + } + + return new ExclusionConstraint(row.ConstraintName, elements, row.Method, row.Predicate, comment); + } + // ── Mapping ─────────────────────────────────────────────────────────────── private static TableIndex MapIndex(IndexRow row, string? comment) diff --git a/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs b/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs index c73b94b..195b340 100644 --- a/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs +++ b/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs @@ -9,6 +9,7 @@ using NSchema.Plan.Model.Tables; using NSchema.Plan.Model.Views; using NSchema.Schema.Model.Columns; +using NSchema.Schema.Model.Constraints; using NSchema.Schema.Model.Indexes; using NSchema.Schema.Model.Routines; using NSchema.Schema.Model.Sequences; @@ -67,6 +68,8 @@ public SqlPlan Generate(MigrationPlan plan) DropUniqueConstraint x => $"ALTER TABLE \"{x.SchemaName}\".\"{x.TableName}\" DROP CONSTRAINT \"{x.ConstraintName}\"", AddCheckConstraint x => $"""ALTER TABLE "{x.SchemaName}"."{x.TableName}" ADD CONSTRAINT "{x.CheckConstraint.Name}" CHECK ({x.CheckConstraint.Expression})""", DropCheckConstraint x => $"ALTER TABLE \"{x.SchemaName}\".\"{x.TableName}\" DROP CONSTRAINT \"{x.ConstraintName}\"", + AddExclusionConstraint x => BuildAddExclusionConstraint(x), + DropExclusionConstraint x => $"ALTER TABLE \"{x.SchemaName}\".\"{x.TableName}\" DROP CONSTRAINT \"{x.ConstraintName}\"", CreateIndex x => BuildCreateIndex(x), DropIndex x => $"DROP INDEX \"{x.SchemaName}\".\"{x.IndexName}\"", // A view Add and a body Modify both arrive as CreateView; CREATE OR REPLACE serves both. An incompatible @@ -142,6 +145,23 @@ private static string BuildCreateTable(CreateTable x) """; } + private static string BuildAddExclusionConstraint(AddExclusionConstraint x) + { + var ex = x.ExclusionConstraint; + var method = ex.Method is { } m ? $" USING {m}" : ""; + var elements = string.Join(", ", ex.Elements.Select(ExclusionElementText)); + var where = ex.Predicate is { } p ? $" WHERE ({p})" : ""; + return $"""ALTER TABLE "{x.SchemaName}"."{x.TableName}" ADD CONSTRAINT "{ex.Name}" EXCLUDE{method} ({elements}){where}"""; + } + + // A plain column element is quoted; an expression element is parenthesised and verbatim. The operator follows + // WITH (e.g. =, &&) and needs no quoting. + private static string ExclusionElementText(ExclusionElement element) + { + var target = element.IsExpression ? $"({element.Expression})" : $"\"{element.Expression}\""; + return $"{target} WITH {element.Operator}"; + } + private static string BuildAddForeignKey(AddForeignKey x) { var fk = x.ForeignKey; diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs index 9193a62..e49cf3f 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs @@ -1,5 +1,6 @@ using NSchema.Plan.Model; using NSchema.Plan.Model.Columns; +using NSchema.Plan.Model.Constraints; using NSchema.Plan.Model.Enums; using NSchema.Plan.Model.Indexes; using NSchema.Plan.Model.Routines; @@ -9,6 +10,7 @@ using NSchema.Plan.Model.Views; using NSchema.Postgres.Sql; using NSchema.Schema.Model.Columns; +using NSchema.Schema.Model.Constraints; using NSchema.Schema.Model.Enums; using NSchema.Schema.Model.Indexes; using NSchema.Schema.Model.Routines; @@ -127,6 +129,15 @@ public Task IndexOperations() => VerifyPlan( Include: ["id", "notes"])), new DropIndex("public", "users", "idx_users_email")); + [Fact] + public Task ExclusionConstraintOperations() => VerifyPlan( + new AddExclusionConstraint("public", "bookings", new ExclusionConstraint("no_overlap", + [new ExclusionElement("room", "="), new ExclusionElement("during", "&&")], Method: "gist", Predicate: "room > 0")), + // An expression element is parenthesised. + new AddExclusionConstraint("public", "events", new ExclusionConstraint("no_clash", + [new ExclusionElement("tstzrange(starts, ends)", "&&", IsExpression: true)], Method: "gist")), + new DropExclusionConstraint("public", "bookings", "no_overlap")); + // ── Views ───────────────────────────────────────────────────────────────── [Fact] diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs index 34c9fcf..55cf449 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs @@ -478,6 +478,55 @@ public async Task DropCheckConstraint_RemovesConstraintFromTable() exists.ShouldBeFalse(); } + // ── Exclusion constraint operations ─────────────────────────────────────── + + [Fact] + public async Task AddExclusionConstraint_MultiElement_RoundTripsThroughIntrospection() + { + // Arrange — the canonical "no overlapping booking of the same room": a scalar `=` plus a range `&&`. + // The scalar element in a gist index needs btree_gist (a contrib extension shipped with the image). + await Exec("CREATE EXTENSION IF NOT EXISTS btree_gist"); + await Exec($"""CREATE TABLE "{_schema}".bookings (room integer, during tstzrange)"""); + var exclusion = new ExclusionConstraint("no_overlap", + [new ExclusionElement("room", "="), new ExclusionElement("during", "&&")], Method: "gist", Predicate: "room > 0"); + + // Act + await _executor.Execute(_generator.Generate(new MigrationPlan( + [new AddExclusionConstraint(_schema, "bookings", exclusion)], [], [])), TestContext.Current.CancellationToken); + + // Assert + var provider = new PostgresSchemaProvider(_dataSource); + var introspected = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken)) + .Schemas[0].Tables[0].ExclusionConstraints.ShouldHaveSingleItem(); + introspected.Name.ShouldBe("no_overlap"); + introspected.Method.ShouldBe("gist"); + introspected.Predicate.ShouldNotBeNull(); + introspected.Predicate!.ShouldContain("room > 0"); + introspected.Elements.Select(e => (e.Expression, e.Operator, e.IsExpression)) + .ShouldBe([("room", "=", false), ("during", "&&", false)]); + } + + [Fact] + public async Task AddExclusionConstraint_ExpressionElement_RoundTripsThroughIntrospection() + { + // Arrange — an expression element (a computed range) excluded with &&. No btree_gist needed. + await Exec($"""CREATE TABLE "{_schema}".events (starts timestamptz, ends timestamptz)"""); + var exclusion = new ExclusionConstraint("no_clash", + [new ExclusionElement("tstzrange(starts, ends)", "&&", IsExpression: true)], Method: "gist"); + + // Act + await _executor.Execute(_generator.Generate(new MigrationPlan( + [new AddExclusionConstraint(_schema, "events", exclusion)], [], [])), TestContext.Current.CancellationToken); + + // Assert + var provider = new PostgresSchemaProvider(_dataSource); + var element = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken)) + .Schemas[0].Tables[0].ExclusionConstraints.ShouldHaveSingleItem().Elements.ShouldHaveSingleItem(); + element.IsExpression.ShouldBeTrue(); + element.Expression.ShouldContain("tstzrange"); + element.Operator.ShouldBe("&&"); + } + // ── Constraint comments ─────────────────────────────────────────────────── [Fact] diff --git a/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.ExclusionConstraintOperations.verified.txt b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.ExclusionConstraintOperations.verified.txt new file mode 100644 index 0000000..1434b3e --- /dev/null +++ b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.ExclusionConstraintOperations.verified.txt @@ -0,0 +1,17 @@ +{ + Statements: [ + { + Sql: ALTER TABLE "public"."bookings" ADD CONSTRAINT "no_overlap" EXCLUDE USING gist ("room" WITH =, "during" WITH &&) WHERE (room > 0), + RunOutsideTransaction: false + }, + { + Sql: ALTER TABLE "public"."events" ADD CONSTRAINT "no_clash" EXCLUDE USING gist ((tstzrange(starts, ends)) WITH &&), + RunOutsideTransaction: false + }, + { + Sql: ALTER TABLE "public"."bookings" DROP CONSTRAINT "no_overlap", + RunOutsideTransaction: false + } + ], + IsEmpty: false +} \ No newline at end of file From 889f8cee7be31bfe50d8c8292c7872a11f14ddf2 Mon Sep 17 00:00:00 2001 From: Tom Wolfe Date: Thu, 18 Jun 2026 23:44:34 +0100 Subject: [PATCH 07/12] feat: add materialized views --- src/NSchema.Postgres/Models/ViewRow.cs | 3 +- .../Sql/PostgresSchemaProvider.cs | 34 ++++++++++++++----- .../Sql/PostgresSqlGenerator.cs | 14 +++++--- .../Sql/PostgresSqlGeneratorSnapshotTests.cs | 10 ++++++ .../Sql/PostgresSqlGeneratorTests.cs | 25 ++++++++++++++ ...ts.MaterializedViewOperations.verified.txt | 25 ++++++++++++++ 6 files changed, 97 insertions(+), 14 deletions(-) create mode 100644 tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.MaterializedViewOperations.verified.txt diff --git a/src/NSchema.Postgres/Models/ViewRow.cs b/src/NSchema.Postgres/Models/ViewRow.cs index dffc346..e7f05a9 100644 --- a/src/NSchema.Postgres/Models/ViewRow.cs +++ b/src/NSchema.Postgres/Models/ViewRow.cs @@ -3,4 +3,5 @@ namespace NSchema.Postgres.Models; internal sealed record ViewRow( string Schema, string Name, - string Definition); + string Definition, + bool IsMaterialized); diff --git a/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs b/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs index 188410d..cc6a010 100644 --- a/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs +++ b/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs @@ -709,15 +709,17 @@ private static async Task> QueryViews(NpgsqlConnection conn, strin { var rows = new List(); await using var cmd = conn.CreateCommand(); - // relkind 'v' = plain views (materialized views, 'm', are not part of the model). pg_get_viewdef returns the - // canonical definition — this is what makes apply → plan round-trip cleanly: state captures the DB's own form. + // relkind 'v' = plain views, 'm' = materialized views (one model, distinguished by the flag). pg_get_viewdef + // returns the canonical definition for both — this is what makes apply → plan round-trip cleanly: state + // captures the DB's own form. cmd.CommandText = """ SELECT n.nspname AS schema_name, c.relname AS view_name, - pg_get_viewdef(c.oid) AS definition + pg_get_viewdef(c.oid) AS definition, + c.relkind = 'm' AS is_materialized FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relkind = 'v' + WHERE c.relkind IN ('v', 'm') AND (@schemas::text[] IS NULL OR n.nspname = ANY(@schemas)) AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg\_toast%' ESCAPE '\' @@ -729,7 +731,7 @@ AND n.nspname NOT LIKE 'pg\_temp%' ESCAPE '\' await using var reader = await cmd.ExecuteReaderAsync(ct); while (await reader.ReadAsync(ct)) { - rows.Add(new ViewRow(reader.GetString(0), reader.GetString(1), CleanViewBody(reader.GetString(2)))); + rows.Add(new ViewRow(reader.GetString(0), reader.GetString(1), CleanViewBody(reader.GetString(2)), reader.GetBoolean(3))); } return rows; @@ -793,7 +795,7 @@ FROM pg_rewrite r AND dep.refclassid = 'pg_class'::regclass JOIN pg_class d ON d.oid = dep.refobjid JOIN pg_namespace dn ON dn.oid = d.relnamespace - WHERE v.relkind = 'v' + WHERE v.relkind IN ('v', 'm') AND d.relkind IN ('r', 'v', 'm') AND d.oid <> v.oid AND (@schemas::text[] IS NULL OR vn.nspname = ANY(@schemas)) @@ -1130,7 +1132,7 @@ private static DatabaseSchema Build( .GroupBy(v => v.Schema) .ToDictionary( g => g.Key, - g => g.Select(v => BuildView(v, viewComments, viewDependencies)).ToList()); + g => g.Select(v => BuildView(v, viewComments, viewDependencies, indexes, indexComments)).ToList()); var enumsBySchema = enums .GroupBy(e => e.Schema) @@ -1222,14 +1224,28 @@ internal static SequenceOptions NormalizeSequenceOptions(SequenceRow row) private static View BuildView( ViewRow row, Dictionary<(string, string), string?> viewComments, - List viewDependencies) + List viewDependencies, + List allIndexes, + Dictionary<(string, string), string?> indexComments) { var dependsOn = viewDependencies .Where(d => d.ViewSchema == row.Schema && d.ViewName == row.Name) .Select(d => new ViewDependency(d.RefSchema, d.RefName)) .ToList(); viewComments.TryGetValue((row.Schema, row.Name), out var comment); - return new View(row.Name, row.Definition, OldName: null, Comment: comment, DependsOn: dependsOn); + + // Only a materialized view can carry indexes; the same index rows that a table would consume are routed + // here when the relation they sit on is this matview (relation names are unique per schema, so there is no + // overlap with a table's indexes). + var indexes = row.IsMaterialized + ? allIndexes + .Where(i => i.SchemaName == row.Schema && i.TableName == row.Name) + .Select(i => MapIndex(i, indexComments.GetValueOrDefault((row.Schema, i.IndexName)))) + .ToList() + : []; + + return new View(row.Name, row.Definition, OldName: null, Comment: comment, DependsOn: dependsOn, + IsMaterialized: row.IsMaterialized, Indexes: indexes); } private static Table BuildTable( diff --git a/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs b/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs index 195b340..dd3796b 100644 --- a/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs +++ b/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs @@ -75,12 +75,16 @@ public SqlPlan Generate(MigrationPlan plan) // A view Add and a body Modify both arrive as CreateView; CREATE OR REPLACE serves both. An incompatible // output-column change (rename/drop/retype/reorder) is rejected loudly by Postgres rather than silently // dropping dependents — see CLAUDE.md / the core view-body decision. + // A materialized view has no CREATE OR REPLACE form, so the core plans a body change as drop + recreate; + // CreateView for a matview is therefore always a fresh CREATE MATERIALIZED VIEW. A plain view's body + // change is an in-place CREATE OR REPLACE. + CreateView { View.IsMaterialized: true } x => $"""CREATE MATERIALIZED VIEW "{x.SchemaName}"."{x.View.Name}" AS {x.View.Body}""", CreateView x => $"""CREATE OR REPLACE VIEW "{x.SchemaName}"."{x.View.Name}" AS {x.View.Body}""", - DropView x => $"DROP VIEW \"{x.SchemaName}\".\"{x.ViewName}\"", - RenameView x => $"ALTER VIEW \"{x.SchemaName}\".\"{x.OldName}\" RENAME TO \"{x.NewName}\"", + DropView x => $"DROP {ViewKind(x.IsMaterialized)} \"{x.SchemaName}\".\"{x.ViewName}\"", + RenameView x => $"ALTER {ViewKind(x.IsMaterialized)} \"{x.SchemaName}\".\"{x.OldName}\" RENAME TO \"{x.NewName}\"", SetViewComment x => x.NewComment is null - ? $"""COMMENT ON VIEW "{x.SchemaName}"."{x.ViewName}" IS NULL""" - : $"""COMMENT ON VIEW "{x.SchemaName}"."{x.ViewName}" IS $comment${x.NewComment}$comment$""", + ? $"""COMMENT ON {ViewKind(x.IsMaterialized)} "{x.SchemaName}"."{x.ViewName}" IS NULL""" + : $"""COMMENT ON {ViewKind(x.IsMaterialized)} "{x.SchemaName}"."{x.ViewName}" IS $comment${x.NewComment}$comment$""", CreateEnum x => BuildCreateEnum(x), DropEnum x => $"DROP TYPE \"{x.SchemaName}\".\"{x.EnumName}\"", RenameEnum x => $"ALTER TYPE \"{x.SchemaName}\".\"{x.OldName}\" RENAME TO \"{x.NewName}\"", @@ -395,6 +399,8 @@ private static IEnumerable BuildRecreateRoutine(string kind, strin $"""Cannot make existing column "{x.SchemaName}"."{x.TableName}"."{x.ColumnName}" generated in place; PostgreSQL has no ADD GENERATED — drop and re-add the column."""), }; + private static string ViewKind(bool isMaterialized) => isMaterialized ? "MATERIALIZED VIEW" : "VIEW"; + private static string RoutineKeyword(RoutineKind kind) => kind == RoutineKind.Procedure ? "PROCEDURE" : "FUNCTION"; private static long DefaultStart(SequenceOptions options) => diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs index e49cf3f..c354002 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs @@ -148,6 +148,16 @@ public Task ViewOperations() => VerifyPlan( new SetViewComment("public", "active_users", "Active users only", null), new DropView("public", "active_users")); + [Fact] + public Task MaterializedViewOperations() => VerifyPlan( + // A materialized view: CREATE MATERIALIZED VIEW (never CREATE OR REPLACE), an index on it (a plain + // CreateIndex), and the MATERIALIZED variants of rename/comment/drop. + new CreateView("public", new View("daily_totals", "SELECT date, sum(amount) AS total FROM public.sales GROUP BY date", IsMaterialized: true)), + new CreateIndex("public", "daily_totals", new TableIndex("idx_daily_totals_date", ["date"], IsUnique: true)), + new RenameView("public", "legacy_totals", "daily_totals", IsMaterialized: true), + new SetViewComment("public", "daily_totals", null, "Daily rollup", IsMaterialized: true), + new DropView("public", "daily_totals", IsMaterialized: true)); + // ── Enums ────────────────────────────────────────────────────────────────── [Fact] diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs index 55cf449..007335d 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs @@ -736,6 +736,31 @@ public async Task SetViewComment_SetsComment() comment.ShouldBe("the view"); } + [Fact] + public async Task RoundTrip_MaterializedView_IntrospectsAsMaterializedWithIndex() + { + // Arrange — a materialized view over a base table, plus a unique index on it. + await Exec($"""CREATE TABLE "{_schema}".sales (id integer, amount integer)"""); + var view = new View("totals", $"""SELECT id, sum(amount) AS total FROM "{_schema}".sales GROUP BY id""", IsMaterialized: true); + + // Act + await _executor.Execute(_generator.Generate(new MigrationPlan([new CreateView(_schema, view)], [], [])), TestContext.Current.CancellationToken); + await _executor.Execute(_generator.Generate(new MigrationPlan( + [new CreateIndex(_schema, "totals", new TableIndex("idx_totals_id", ["id"], IsUnique: true))], [], [])), TestContext.Current.CancellationToken); + + // Assert + var provider = new PostgresSchemaProvider(_dataSource); + var introspected = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken)) + .Schemas[0].Views.ShouldHaveSingleItem(); + introspected.IsMaterialized.ShouldBeTrue(); + introspected.Body.ShouldContain("sum"); + introspected.DependsOn.ShouldContain(d => d.Name == "sales"); + var index = introspected.Indexes.ShouldHaveSingleItem(); + index.Name.ShouldBe("idx_totals_id"); + index.IsUnique.ShouldBeTrue(); + index.Columns.ShouldHaveSingleItem().Expression.ShouldBe("id"); + } + // ── Deployment scripts ──────────────────────────────────────────────────── [Fact] diff --git a/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.MaterializedViewOperations.verified.txt b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.MaterializedViewOperations.verified.txt new file mode 100644 index 0000000..7d2fd7b --- /dev/null +++ b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.MaterializedViewOperations.verified.txt @@ -0,0 +1,25 @@ +{ + Statements: [ + { + Sql: CREATE MATERIALIZED VIEW "public"."daily_totals" AS SELECT date, sum(amount) AS total FROM public.sales GROUP BY date, + RunOutsideTransaction: false + }, + { + Sql: CREATE UNIQUE INDEX "idx_daily_totals_date" ON "public"."daily_totals" ("date"), + RunOutsideTransaction: false + }, + { + Sql: ALTER MATERIALIZED VIEW "public"."legacy_totals" RENAME TO "daily_totals", + RunOutsideTransaction: false + }, + { + Sql: COMMENT ON MATERIALIZED VIEW "public"."daily_totals" IS $comment$Daily rollup$comment$, + RunOutsideTransaction: false + }, + { + Sql: DROP MATERIALIZED VIEW "public"."daily_totals", + RunOutsideTransaction: false + } + ], + IsEmpty: false +} \ No newline at end of file From bb85921614cf3b959204a7eb3f2702b038d8111c Mon Sep 17 00:00:00 2001 From: Tom Wolfe Date: Thu, 18 Jun 2026 23:54:34 +0100 Subject: [PATCH 08/12] feat: domains --- src/NSchema.Postgres/Models/DomainCheckRow.cs | 7 ++ src/NSchema.Postgres/Models/DomainRow.cs | 13 ++ .../Sql/PostgresSchemaProvider.cs | 113 +++++++++++++++++- .../Sql/PostgresSqlGenerator.cs | 47 ++++++++ .../Sql/PostgresSqlGeneratorSnapshotTests.cs | 19 +++ .../Sql/PostgresSqlGeneratorTests.cs | 26 ++++ ...napshotTests.DomainOperations.verified.txt | 53 ++++++++ 7 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 src/NSchema.Postgres/Models/DomainCheckRow.cs create mode 100644 src/NSchema.Postgres/Models/DomainRow.cs create mode 100644 tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.DomainOperations.verified.txt diff --git a/src/NSchema.Postgres/Models/DomainCheckRow.cs b/src/NSchema.Postgres/Models/DomainCheckRow.cs new file mode 100644 index 0000000..8897812 --- /dev/null +++ b/src/NSchema.Postgres/Models/DomainCheckRow.cs @@ -0,0 +1,7 @@ +namespace NSchema.Postgres.Models; + +internal sealed record DomainCheckRow( + string Schema, + string DomainName, + string CheckName, + string Expression); diff --git a/src/NSchema.Postgres/Models/DomainRow.cs b/src/NSchema.Postgres/Models/DomainRow.cs new file mode 100644 index 0000000..3346ff6 --- /dev/null +++ b/src/NSchema.Postgres/Models/DomainRow.cs @@ -0,0 +1,13 @@ +namespace NSchema.Postgres.Models; + +internal sealed record DomainRow( + string Schema, + string Name, + string DataType, + string UdtName, + int? MaxLength, + int? Precision, + int? Scale, + bool NotNull, + string? Default, + string? Comment); diff --git a/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs b/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs index cc6a010..56d9fc8 100644 --- a/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs +++ b/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs @@ -5,6 +5,7 @@ using NSchema.Schema.Model; using NSchema.Schema.Model.Columns; using NSchema.Schema.Model.Constraints; +using NSchema.Schema.Model.Domains; using NSchema.Schema.Model.Enums; using NSchema.Schema.Model.Indexes; using NSchema.Schema.Model.Routines; @@ -49,6 +50,8 @@ public async ValueTask GetSchema(string[]? schemas = null, Cance var enumComments = await QueryEnumComments(conn, schemas, cancellationToken); var sequences = await QuerySequences(conn, schemas, cancellationToken); var sequenceComments = await QuerySequenceComments(conn, schemas, cancellationToken); + var domains = await QueryDomains(conn, schemas, cancellationToken); + var domainChecks = await QueryDomainChecks(conn, schemas, cancellationToken); var functions = await QueryRoutines(conn, schemas, FunctionKind, cancellationToken); var functionComments = await QueryRoutineComments(conn, schemas, FunctionKind, cancellationToken); var procedures = await QueryRoutines(conn, schemas, ProcedureKind, cancellationToken); @@ -59,6 +62,7 @@ public async ValueTask GetSchema(string[]? schemas = null, Cance schemaComments, tableComments, columnComments, indexComments, constraintComments, schemaGrants, tableGrants, views, viewComments, viewDependencies, enums, enumComments, sequences, sequenceComments, + domains, domainChecks, functions, functionComments, procedures, procedureComments ); } @@ -436,6 +440,94 @@ AND n.nspname NOT LIKE 'pg\_temp%' ESCAPE '\' return rows; } + private static async Task> QueryDomains(NpgsqlConnection conn, string[]? schemas, CancellationToken ct) + { + var rows = new List(); + await using var cmd = conn.CreateCommand(); + // The base type (and its length/precision/scale) come from information_schema.domains — the same shape a + // column reports — so MapSqlType can be reused. NOT NULL is not exposed there, so it is read from + // pg_type.typnotnull; the comment is on the domain's type entry. + cmd.CommandText = """ + SELECT + d.domain_schema, + d.domain_name, + d.data_type, + d.udt_name, + d.character_maximum_length, + d.numeric_precision, + d.numeric_scale, + t.typnotnull AS not_null, + d.domain_default, + obj_description(t.oid, 'pg_type') AS comment + FROM information_schema.domains d + JOIN pg_namespace n ON n.nspname = d.domain_schema + JOIN pg_type t ON t.typname = d.domain_name AND t.typnamespace = n.oid AND t.typtype = 'd' + WHERE (@schemas::text[] IS NULL OR d.domain_schema = ANY(@schemas)) + AND d.domain_schema NOT IN ('pg_catalog', 'information_schema') + AND d.domain_schema NOT LIKE 'pg\_toast%' ESCAPE '\' + AND d.domain_schema NOT LIKE 'pg\_temp%' ESCAPE '\' + ORDER BY d.domain_schema, d.domain_name + """; + AddSchemasParameter(cmd, schemas); + + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + rows.Add(new DomainRow( + Schema: reader.GetString(0), + Name: reader.GetString(1), + DataType: reader.GetString(2), + UdtName: reader.GetString(3), + MaxLength: reader.IsDBNull(4) ? null : reader.GetInt32(4), + Precision: reader.IsDBNull(5) ? null : reader.GetInt32(5), + Scale: reader.IsDBNull(6) ? null : reader.GetInt32(6), + NotNull: reader.GetBoolean(7), + Default: reader.IsDBNull(8) ? null : reader.GetString(8), + Comment: reader.IsDBNull(9) ? null : reader.GetString(9) + )); + } + + return rows; + } + + private static async Task> QueryDomainChecks(NpgsqlConnection conn, string[]? schemas, CancellationToken ct) + { + var rows = new List(); + await using var cmd = conn.CreateCommand(); + // Domain check constraints are pg_constraint rows attached to the type (contypid), not a table (conrelid). + // pg_get_constraintdef renders "CHECK (expr)" — strip the wrapper, as for table checks. + cmd.CommandText = """ + SELECT + n.nspname AS schema_name, + t.typname AS domain_name, + c.conname AS check_name, + pg_get_constraintdef(c.oid) AS definition + FROM pg_constraint c + JOIN pg_type t ON t.oid = c.contypid + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE c.contype = 'c' AND c.contypid <> 0 + AND (@schemas::text[] IS NULL OR n.nspname = ANY(@schemas)) + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + AND n.nspname NOT LIKE 'pg\_toast%' ESCAPE '\' + AND n.nspname NOT LIKE 'pg\_temp%' ESCAPE '\' + ORDER BY n.nspname, t.typname, c.conname + """; + AddSchemasParameter(cmd, schemas); + + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + rows.Add(new DomainCheckRow( + Schema: reader.GetString(0), + DomainName: reader.GetString(1), + CheckName: reader.GetString(2), + Expression: StripCheckWrapper(reader.GetString(3)) + )); + } + + return rows; + } + private static async Task> QueryConstraintComments(NpgsqlConnection conn, string[]? schemas, CancellationToken ct) { var result = new Dictionary<(string, string, string), string?>(); @@ -1116,6 +1208,8 @@ private static DatabaseSchema Build( Dictionary<(string, string), string?> enumComments, List sequences, Dictionary<(string, string), string?> sequenceComments, + List domains, + List domainChecks, List functions, Dictionary<(string, string), string?> functionComments, List procedures, @@ -1159,6 +1253,10 @@ private static DatabaseSchema Build( .GroupBy(x => x.Schema) .ToDictionary(g => g.Key, g => g.Select(x => x.Routine).ToList()); + var domainsBySchema = domains + .GroupBy(d => d.Schema) + .ToDictionary(g => g.Key, g => g.Select(d => MapDomain(d, domainChecks)).ToList()); + // Drive schema list from what actually exists in the database, not from what was requested. var existingSchemas = schemaComments.Keys .Union(bySchema.Keys) @@ -1166,6 +1264,7 @@ private static DatabaseSchema Build( .Union(enumsBySchema.Keys) .Union(sequencesBySchema.Keys) .Union(routinesBySchema.Keys) + .Union(domainsBySchema.Keys) .Union(schemaGrants.Select(g => g.SchemaName)) .Distinct(StringComparer.OrdinalIgnoreCase); @@ -1184,7 +1283,9 @@ private static DatabaseSchema Build( Sequences: sequencesBySchema.GetValueOrDefault(name, []), DroppedSequences: [], Routines: routinesBySchema.GetValueOrDefault(name, []), - DroppedRoutines: []); + DroppedRoutines: [], + Domains: domainsBySchema.GetValueOrDefault(name, []), + DroppedDomains: []); }) .ToList(); @@ -1326,6 +1427,16 @@ List allTableGrants ); } + private static Domain MapDomain(DomainRow row, List allChecks) + { + var type = MapSqlType(row.DataType, row.UdtName, domainSchema: null, domainName: null, row.MaxLength, row.Precision, row.Scale); + var checks = allChecks + .Where(c => c.Schema == row.Schema && c.DomainName == row.Name) + .Select(c => new CheckConstraint(c.CheckName, c.Expression)) + .ToList(); + return new Domain(row.Name, type, row.Default, row.NotNull, checks, OldName: null, Comment: row.Comment); + } + private static ExclusionConstraint MapExclusionConstraint(ExclusionConstraintRow row, string? comment) { var elements = new List(); diff --git a/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs b/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs index dd3796b..665cf4a 100644 --- a/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs +++ b/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs @@ -1,6 +1,8 @@ +using System.Text; using NSchema.Plan.Model; using NSchema.Plan.Model.Columns; using NSchema.Plan.Model.Constraints; +using NSchema.Plan.Model.Domains; using NSchema.Plan.Model.Enums; using NSchema.Plan.Model.Indexes; using NSchema.Plan.Model.Routines; @@ -10,6 +12,7 @@ using NSchema.Plan.Model.Views; using NSchema.Schema.Model.Columns; using NSchema.Schema.Model.Constraints; +using NSchema.Schema.Model.Domains; using NSchema.Schema.Model.Indexes; using NSchema.Schema.Model.Routines; using NSchema.Schema.Model.Sequences; @@ -39,6 +42,7 @@ public SqlPlan Generate(MigrationPlan plan) // runs it alone, and resumes — ordering relative to later statements that use the value is preserved. AddEnumValue x => [new SqlStatement(BuildAddEnumValue(x), RunOutsideTransaction: true)], RecreateRoutine x => BuildRecreateRoutine(RoutineKeyword(x.Routine.Kind), x.SchemaName, x.Routine.Name, x.Routine.Arguments, x.Routine.Definition, x.Routine.Comment), + RecreateDomain x => BuildRecreateDomain(x), _ => [new SqlStatement(GenerateSql(action))], }; @@ -91,6 +95,18 @@ public SqlPlan Generate(MigrationPlan plan) SetEnumComment x => x.NewComment is null ? $"""COMMENT ON TYPE "{x.SchemaName}"."{x.EnumName}" IS NULL""" : $"""COMMENT ON TYPE "{x.SchemaName}"."{x.EnumName}" IS $comment${x.NewComment}$comment$""", + CreateDomain x => BuildCreateDomain(x.SchemaName, x.Domain), + DropDomain x => $"DROP DOMAIN \"{x.SchemaName}\".\"{x.DomainName}\"", + RenameDomain x => $"ALTER DOMAIN \"{x.SchemaName}\".\"{x.OldName}\" RENAME TO \"{x.NewName}\"", + AlterDomainDefault { NewDefault: null } x => $"""ALTER DOMAIN "{x.SchemaName}"."{x.DomainName}" DROP DEFAULT""", + AlterDomainDefault x => $"""ALTER DOMAIN "{x.SchemaName}"."{x.DomainName}" SET DEFAULT {x.NewDefault}""", + AlterDomainNotNull { NotNull: true } x => $"""ALTER DOMAIN "{x.SchemaName}"."{x.DomainName}" SET NOT NULL""", + AlterDomainNotNull x => $"""ALTER DOMAIN "{x.SchemaName}"."{x.DomainName}" DROP NOT NULL""", + AddDomainCheck x => $"""ALTER DOMAIN "{x.SchemaName}"."{x.DomainName}" ADD CONSTRAINT "{x.Check.Name}" CHECK ({x.Check.Expression})""", + DropDomainCheck x => $"ALTER DOMAIN \"{x.SchemaName}\".\"{x.DomainName}\" DROP CONSTRAINT \"{x.CheckName}\"", + SetDomainComment x => x.NewComment is null + ? $"""COMMENT ON DOMAIN "{x.SchemaName}"."{x.DomainName}" IS NULL""" + : $"""COMMENT ON DOMAIN "{x.SchemaName}"."{x.DomainName}" IS $comment${x.NewComment}$comment$""", CreateSequence x => BuildCreateSequence(x), DropSequence x => $"DROP SEQUENCE \"{x.SchemaName}\".\"{x.SequenceName}\"", RenameSequence x => $"ALTER SEQUENCE \"{x.SchemaName}\".\"{x.OldName}\" RENAME TO \"{x.NewName}\"", @@ -399,6 +415,37 @@ private static IEnumerable BuildRecreateRoutine(string kind, strin $"""Cannot make existing column "{x.SchemaName}"."{x.TableName}"."{x.ColumnName}" generated in place; PostgreSQL has no ADD GENERATED — drop and re-add the column."""), }; + // CREATE DOMAIN name AS type [DEFAULT expr] [NOT NULL] [CONSTRAINT n CHECK (expr)]… + private static string BuildCreateDomain(string schema, Domain domain) + { + var sb = new StringBuilder($"""CREATE DOMAIN "{schema}"."{domain.Name}" AS {ToPostgresType(domain.DataType)}"""); + if (domain.Default is { } def) + { + sb.Append($" DEFAULT {def}"); + } + if (domain.NotNull) + { + sb.Append(" NOT NULL"); + } + foreach (var check in domain.Checks) + { + sb.Append($""" CONSTRAINT "{check.Name}" CHECK ({check.Expression})"""); + } + return sb.ToString(); + } + + // A domain's base type cannot be altered in place (Postgres has no ALTER DOMAIN … TYPE), so a base-type change + // drops + recreates — re-issuing the comment the drop discarded. Fails loudly if a column still uses the domain. + private static IEnumerable BuildRecreateDomain(RecreateDomain x) + { + yield return new SqlStatement($"DROP DOMAIN \"{x.SchemaName}\".\"{x.Domain.Name}\""); + yield return new SqlStatement(BuildCreateDomain(x.SchemaName, x.Domain)); + if (x.Domain.Comment is { } comment) + { + yield return new SqlStatement($"""COMMENT ON DOMAIN "{x.SchemaName}"."{x.Domain.Name}" IS $comment${comment}$comment$"""); + } + } + private static string ViewKind(bool isMaterialized) => isMaterialized ? "MATERIALIZED VIEW" : "VIEW"; private static string RoutineKeyword(RoutineKind kind) => kind == RoutineKind.Procedure ? "PROCEDURE" : "FUNCTION"; diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs index c354002..1d1f470 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs @@ -1,6 +1,7 @@ using NSchema.Plan.Model; using NSchema.Plan.Model.Columns; using NSchema.Plan.Model.Constraints; +using NSchema.Plan.Model.Domains; using NSchema.Plan.Model.Enums; using NSchema.Plan.Model.Indexes; using NSchema.Plan.Model.Routines; @@ -11,6 +12,7 @@ using NSchema.Postgres.Sql; using NSchema.Schema.Model.Columns; using NSchema.Schema.Model.Constraints; +using NSchema.Schema.Model.Domains; using NSchema.Schema.Model.Enums; using NSchema.Schema.Model.Indexes; using NSchema.Schema.Model.Routines; @@ -171,6 +173,23 @@ public Task EnumOperations() => VerifyPlan( new SetEnumComment("public", "order_status", "Order lifecycle", null), new DropEnum("public", "order_status")); + // ── Domains ──────────────────────────────────────────────────────────────── + + [Fact] + public Task DomainOperations() => VerifyPlan( + new CreateDomain("public", new Domain("email", SqlType.Text, Default: "'n/a'", NotNull: true, + Checks: [new CheckConstraint("email_fmt", "VALUE ~ '@'")])), + new AlterDomainDefault("public", "email", "'n/a'", "'unknown'"), + new AlterDomainDefault("public", "email", "'unknown'", null), + new AlterDomainNotNull("public", "email", false), + new AddDomainCheck("public", "email", new CheckConstraint("email_len", "length(VALUE) > 3")), + new DropDomainCheck("public", "email", "email_fmt"), + // A base-type change recreates (drop + create, re-issuing the comment). + new RecreateDomain("public", new Domain("code", SqlType.VarChar(8), Comment: "a code")), + new RenameDomain("public", "old_code", "code"), + new SetDomainComment("public", "email", null, "an email"), + new DropDomain("public", "email")); + // ── Sequences ────────────────────────────────────────────────────────────── [Fact] diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs index 007335d..ef10dc5 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs @@ -2,6 +2,7 @@ using NSchema.Plan.Model; using NSchema.Plan.Model.Columns; using NSchema.Plan.Model.Constraints; +using NSchema.Plan.Model.Domains; using NSchema.Plan.Model.Enums; using NSchema.Plan.Model.Indexes; using NSchema.Plan.Model.Routines; @@ -13,6 +14,7 @@ using NSchema.Postgres.Tests.Fixtures; using NSchema.Schema.Model.Columns; using NSchema.Schema.Model.Constraints; +using NSchema.Schema.Model.Domains; using NSchema.Schema.Model.Enums; using NSchema.Schema.Model.Indexes; using NSchema.Schema.Model.Routines; @@ -761,6 +763,30 @@ await _executor.Execute(_generator.Generate(new MigrationPlan( index.Columns.ShouldHaveSingleItem().Expression.ShouldBe("id"); } + // ── Domains ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task RoundTrip_Domain_IntrospectsWithAllFacets() + { + // Arrange — a domain over text with a default, NOT NULL, and a named check. + var domain = new Domain("us_postal", SqlType.Text, Default: "'00000'", NotNull: true, + Checks: [new CheckConstraint("us_postal_fmt", "VALUE ~ '^[0-9]{5}$'")]); + + // Act + await _executor.Execute(_generator.Generate(new MigrationPlan([new CreateDomain(_schema, domain)], [], [])), TestContext.Current.CancellationToken); + + // Assert + var provider = new PostgresSchemaProvider(_dataSource); + var introspected = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken)) + .Schemas[0].Domains.ShouldHaveSingleItem(); + introspected.Name.ShouldBe("us_postal"); + introspected.DataType.ShouldBe(SqlType.Text); + introspected.NotNull.ShouldBeTrue(); + introspected.Default.ShouldNotBeNull(); + introspected.Default!.ShouldContain("00000"); + introspected.Checks.ShouldHaveSingleItem().Name.ShouldBe("us_postal_fmt"); + } + // ── Deployment scripts ──────────────────────────────────────────────────── [Fact] diff --git a/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.DomainOperations.verified.txt b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.DomainOperations.verified.txt new file mode 100644 index 0000000..757f2c0 --- /dev/null +++ b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.DomainOperations.verified.txt @@ -0,0 +1,53 @@ +{ + Statements: [ + { + Sql: CREATE DOMAIN "public"."email" AS text DEFAULT 'n/a' NOT NULL CONSTRAINT "email_fmt" CHECK (VALUE ~ '@'), + RunOutsideTransaction: false + }, + { + Sql: ALTER DOMAIN "public"."email" SET DEFAULT 'unknown', + RunOutsideTransaction: false + }, + { + Sql: ALTER DOMAIN "public"."email" DROP DEFAULT, + RunOutsideTransaction: false + }, + { + Sql: ALTER DOMAIN "public"."email" DROP NOT NULL, + RunOutsideTransaction: false + }, + { + Sql: ALTER DOMAIN "public"."email" ADD CONSTRAINT "email_len" CHECK (length(VALUE) > 3), + RunOutsideTransaction: false + }, + { + Sql: ALTER DOMAIN "public"."email" DROP CONSTRAINT "email_fmt", + RunOutsideTransaction: false + }, + { + Sql: DROP DOMAIN "public"."code", + RunOutsideTransaction: false + }, + { + Sql: CREATE DOMAIN "public"."code" AS character varying(8), + RunOutsideTransaction: false + }, + { + Sql: COMMENT ON DOMAIN "public"."code" IS $comment$a code$comment$, + RunOutsideTransaction: false + }, + { + Sql: ALTER DOMAIN "public"."old_code" RENAME TO "code", + RunOutsideTransaction: false + }, + { + Sql: COMMENT ON DOMAIN "public"."email" IS $comment$an email$comment$, + RunOutsideTransaction: false + }, + { + Sql: DROP DOMAIN "public"."email", + RunOutsideTransaction: false + } + ], + IsEmpty: false +} \ No newline at end of file From 435994ecf24de637e24de0a0f3a5df2641609973 Mon Sep 17 00:00:00 2001 From: Tom Wolfe Date: Fri, 19 Jun 2026 00:03:40 +0100 Subject: [PATCH 09/12] feat: support composite types --- .../Models/CompositeFieldRow.cs | 12 ++ .../Models/CompositeTypeRow.cs | 6 + .../Sql/PostgresSchemaProvider.cs | 107 +++++++++++++++++- .../Sql/PostgresSqlGenerator.cs | 17 +++ .../Sql/PostgresSqlGeneratorSnapshotTests.cs | 15 +++ .../Sql/PostgresSqlGeneratorTests.cs | 24 ++++ ...Tests.CompositeTypeOperations.verified.txt | 33 ++++++ 7 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 src/NSchema.Postgres/Models/CompositeFieldRow.cs create mode 100644 src/NSchema.Postgres/Models/CompositeTypeRow.cs create mode 100644 tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.CompositeTypeOperations.verified.txt diff --git a/src/NSchema.Postgres/Models/CompositeFieldRow.cs b/src/NSchema.Postgres/Models/CompositeFieldRow.cs new file mode 100644 index 0000000..65106a3 --- /dev/null +++ b/src/NSchema.Postgres/Models/CompositeFieldRow.cs @@ -0,0 +1,12 @@ +namespace NSchema.Postgres.Models; + +internal sealed record CompositeFieldRow( + string Schema, + string TypeName, + string FieldName, + int OrdinalPosition, + string DataType, + string UdtName, + int? MaxLength, + int? Precision, + int? Scale); diff --git a/src/NSchema.Postgres/Models/CompositeTypeRow.cs b/src/NSchema.Postgres/Models/CompositeTypeRow.cs new file mode 100644 index 0000000..5d913e5 --- /dev/null +++ b/src/NSchema.Postgres/Models/CompositeTypeRow.cs @@ -0,0 +1,6 @@ +namespace NSchema.Postgres.Models; + +internal sealed record CompositeTypeRow( + string Schema, + string Name, + string? Comment); diff --git a/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs b/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs index 56d9fc8..337aeb3 100644 --- a/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs +++ b/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs @@ -4,6 +4,7 @@ using NSchema.Schema; using NSchema.Schema.Model; using NSchema.Schema.Model.Columns; +using NSchema.Schema.Model.CompositeTypes; using NSchema.Schema.Model.Constraints; using NSchema.Schema.Model.Domains; using NSchema.Schema.Model.Enums; @@ -52,6 +53,8 @@ public async ValueTask GetSchema(string[]? schemas = null, Cance var sequenceComments = await QuerySequenceComments(conn, schemas, cancellationToken); var domains = await QueryDomains(conn, schemas, cancellationToken); var domainChecks = await QueryDomainChecks(conn, schemas, cancellationToken); + var compositeTypes = await QueryCompositeTypes(conn, schemas, cancellationToken); + var compositeFields = await QueryCompositeFields(conn, schemas, cancellationToken); var functions = await QueryRoutines(conn, schemas, FunctionKind, cancellationToken); var functionComments = await QueryRoutineComments(conn, schemas, FunctionKind, cancellationToken); var procedures = await QueryRoutines(conn, schemas, ProcedureKind, cancellationToken); @@ -62,7 +65,7 @@ public async ValueTask GetSchema(string[]? schemas = null, Cance schemaComments, tableComments, columnComments, indexComments, constraintComments, schemaGrants, tableGrants, views, viewComments, viewDependencies, enums, enumComments, sequences, sequenceComments, - domains, domainChecks, + domains, domainChecks, compositeTypes, compositeFields, functions, functionComments, procedures, procedureComments ); } @@ -528,6 +531,87 @@ AND n.nspname NOT LIKE 'pg\_temp%' ESCAPE '\' return rows; } + private static async Task> QueryCompositeTypes(NpgsqlConnection conn, string[]? schemas, CancellationToken ct) + { + var rows = new List(); + await using var cmd = conn.CreateCommand(); + // Standalone composite types only: every table/view/sequence also has a composite type in pg_type (its row + // type), so the backing pg_class is joined and filtered to relkind 'c' — a free-standing CREATE TYPE — to + // exclude those relation row types. + cmd.CommandText = """ + SELECT n.nspname AS schema_name, + t.typname AS type_name, + obj_description(t.oid, 'pg_type') AS comment + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + JOIN pg_class c ON c.oid = t.typrelid + WHERE t.typtype = 'c' AND c.relkind = 'c' + AND (@schemas::text[] IS NULL OR n.nspname = ANY(@schemas)) + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + AND n.nspname NOT LIKE 'pg\_toast%' ESCAPE '\' + AND n.nspname NOT LIKE 'pg\_temp%' ESCAPE '\' + ORDER BY n.nspname, t.typname + """; + AddSchemasParameter(cmd, schemas); + + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + rows.Add(new CompositeTypeRow( + Schema: reader.GetString(0), + Name: reader.GetString(1), + Comment: reader.IsDBNull(2) ? null : reader.GetString(2) + )); + } + + return rows; + } + + private static async Task> QueryCompositeFields(NpgsqlConnection conn, string[]? schemas, CancellationToken ct) + { + var rows = new List(); + await using var cmd = conn.CreateCommand(); + // information_schema.attributes lists composite-type fields in the same shape a column reports, so + // MapSqlType can be reused for the field type. + cmd.CommandText = """ + SELECT + a.udt_schema, + a.udt_name, + a.attribute_name, + a.ordinal_position, + a.data_type, + a.attribute_udt_name, + a.character_maximum_length, + a.numeric_precision, + a.numeric_scale + FROM information_schema.attributes a + WHERE (@schemas::text[] IS NULL OR a.udt_schema = ANY(@schemas)) + AND a.udt_schema NOT IN ('pg_catalog', 'information_schema') + AND a.udt_schema NOT LIKE 'pg\_toast%' ESCAPE '\' + AND a.udt_schema NOT LIKE 'pg\_temp%' ESCAPE '\' + ORDER BY a.udt_schema, a.udt_name, a.ordinal_position + """; + AddSchemasParameter(cmd, schemas); + + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + rows.Add(new CompositeFieldRow( + Schema: reader.GetString(0), + TypeName: reader.GetString(1), + FieldName: reader.GetString(2), + OrdinalPosition: reader.GetInt32(3), + DataType: reader.GetString(4), + UdtName: reader.GetString(5), + MaxLength: reader.IsDBNull(6) ? null : reader.GetInt32(6), + Precision: reader.IsDBNull(7) ? null : reader.GetInt32(7), + Scale: reader.IsDBNull(8) ? null : reader.GetInt32(8) + )); + } + + return rows; + } + private static async Task> QueryConstraintComments(NpgsqlConnection conn, string[]? schemas, CancellationToken ct) { var result = new Dictionary<(string, string, string), string?>(); @@ -1210,6 +1294,8 @@ private static DatabaseSchema Build( Dictionary<(string, string), string?> sequenceComments, List domains, List domainChecks, + List compositeTypes, + List compositeFields, List functions, Dictionary<(string, string), string?> functionComments, List procedures, @@ -1257,6 +1343,10 @@ private static DatabaseSchema Build( .GroupBy(d => d.Schema) .ToDictionary(g => g.Key, g => g.Select(d => MapDomain(d, domainChecks)).ToList()); + var compositeTypesBySchema = compositeTypes + .GroupBy(c => c.Schema) + .ToDictionary(g => g.Key, g => g.Select(c => MapCompositeType(c, compositeFields)).ToList()); + // Drive schema list from what actually exists in the database, not from what was requested. var existingSchemas = schemaComments.Keys .Union(bySchema.Keys) @@ -1265,6 +1355,7 @@ private static DatabaseSchema Build( .Union(sequencesBySchema.Keys) .Union(routinesBySchema.Keys) .Union(domainsBySchema.Keys) + .Union(compositeTypesBySchema.Keys) .Union(schemaGrants.Select(g => g.SchemaName)) .Distinct(StringComparer.OrdinalIgnoreCase); @@ -1285,7 +1376,9 @@ private static DatabaseSchema Build( Routines: routinesBySchema.GetValueOrDefault(name, []), DroppedRoutines: [], Domains: domainsBySchema.GetValueOrDefault(name, []), - DroppedDomains: []); + DroppedDomains: [], + CompositeTypes: compositeTypesBySchema.GetValueOrDefault(name, []), + DroppedCompositeTypes: []); }) .ToList(); @@ -1427,6 +1520,16 @@ List allTableGrants ); } + private static CompositeType MapCompositeType(CompositeTypeRow row, List allFields) + { + var fields = allFields + .Where(f => f.Schema == row.Schema && f.TypeName == row.Name) + .OrderBy(f => f.OrdinalPosition) + .Select(f => new CompositeField(f.FieldName, MapSqlType(f.DataType, f.UdtName, domainSchema: null, domainName: null, f.MaxLength, f.Precision, f.Scale))) + .ToList(); + return new CompositeType(row.Name, fields, OldName: null, Comment: row.Comment); + } + private static Domain MapDomain(DomainRow row, List allChecks) { var type = MapSqlType(row.DataType, row.UdtName, domainSchema: null, domainName: null, row.MaxLength, row.Precision, row.Scale); diff --git a/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs b/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs index 665cf4a..823bce2 100644 --- a/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs +++ b/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs @@ -1,6 +1,7 @@ using System.Text; using NSchema.Plan.Model; using NSchema.Plan.Model.Columns; +using NSchema.Plan.Model.CompositeTypes; using NSchema.Plan.Model.Constraints; using NSchema.Plan.Model.Domains; using NSchema.Plan.Model.Enums; @@ -11,6 +12,7 @@ using NSchema.Plan.Model.Tables; using NSchema.Plan.Model.Views; using NSchema.Schema.Model.Columns; +using NSchema.Schema.Model.CompositeTypes; using NSchema.Schema.Model.Constraints; using NSchema.Schema.Model.Domains; using NSchema.Schema.Model.Indexes; @@ -107,6 +109,15 @@ public SqlPlan Generate(MigrationPlan plan) SetDomainComment x => x.NewComment is null ? $"""COMMENT ON DOMAIN "{x.SchemaName}"."{x.DomainName}" IS NULL""" : $"""COMMENT ON DOMAIN "{x.SchemaName}"."{x.DomainName}" IS $comment${x.NewComment}$comment$""", + CreateCompositeType x => BuildCreateCompositeType(x), + DropCompositeType x => $"DROP TYPE \"{x.SchemaName}\".\"{x.TypeName}\"", + RenameCompositeType x => $"ALTER TYPE \"{x.SchemaName}\".\"{x.OldName}\" RENAME TO \"{x.NewName}\"", + SetCompositeTypeComment x => x.NewComment is null + ? $"""COMMENT ON TYPE "{x.SchemaName}"."{x.TypeName}" IS NULL""" + : $"""COMMENT ON TYPE "{x.SchemaName}"."{x.TypeName}" IS $comment${x.NewComment}$comment$""", + AddCompositeField x => $"""ALTER TYPE "{x.SchemaName}"."{x.TypeName}" ADD ATTRIBUTE "{x.Field.Name}" {ToPostgresType(x.Field.DataType)}""", + DropCompositeField x => $"ALTER TYPE \"{x.SchemaName}\".\"{x.TypeName}\" DROP ATTRIBUTE \"{x.FieldName}\"", + AlterCompositeFieldType x => $"""ALTER TYPE "{x.SchemaName}"."{x.TypeName}" ALTER ATTRIBUTE "{x.FieldName}" TYPE {ToPostgresType(x.NewType)}""", CreateSequence x => BuildCreateSequence(x), DropSequence x => $"DROP SEQUENCE \"{x.SchemaName}\".\"{x.SequenceName}\"", RenameSequence x => $"ALTER SEQUENCE \"{x.SchemaName}\".\"{x.OldName}\" RENAME TO \"{x.NewName}\"", @@ -415,6 +426,12 @@ private static IEnumerable BuildRecreateRoutine(string kind, strin $"""Cannot make existing column "{x.SchemaName}"."{x.TableName}"."{x.ColumnName}" generated in place; PostgreSQL has no ADD GENERATED — drop and re-add the column."""), }; + private static string BuildCreateCompositeType(CreateCompositeType x) + { + var fields = string.Join(", ", x.CompositeType.Fields.Select(f => $"\"{f.Name}\" {ToPostgresType(f.DataType)}")); + return $"""CREATE TYPE "{x.SchemaName}"."{x.CompositeType.Name}" AS ({fields})"""; + } + // CREATE DOMAIN name AS type [DEFAULT expr] [NOT NULL] [CONSTRAINT n CHECK (expr)]… private static string BuildCreateDomain(string schema, Domain domain) { diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs index 1d1f470..1c130b0 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs @@ -1,5 +1,6 @@ using NSchema.Plan.Model; using NSchema.Plan.Model.Columns; +using NSchema.Plan.Model.CompositeTypes; using NSchema.Plan.Model.Constraints; using NSchema.Plan.Model.Domains; using NSchema.Plan.Model.Enums; @@ -11,6 +12,7 @@ using NSchema.Plan.Model.Views; using NSchema.Postgres.Sql; using NSchema.Schema.Model.Columns; +using NSchema.Schema.Model.CompositeTypes; using NSchema.Schema.Model.Constraints; using NSchema.Schema.Model.Domains; using NSchema.Schema.Model.Enums; @@ -173,6 +175,19 @@ public Task EnumOperations() => VerifyPlan( new SetEnumComment("public", "order_status", "Order lifecycle", null), new DropEnum("public", "order_status")); + // ── Composite types ────────────────────────────────────────────────────── + + [Fact] + public Task CompositeTypeOperations() => VerifyPlan( + new CreateCompositeType("public", new CompositeType("address", + [new CompositeField("street", SqlType.Text), new CompositeField("zip", SqlType.Int)])), + new AddCompositeField("public", "address", new CompositeField("country", SqlType.Text)), + new AlterCompositeFieldType("public", "address", "zip", SqlType.Int, SqlType.VarChar(10)), + new DropCompositeField("public", "address", "country"), + new RenameCompositeType("public", "old_address", "address"), + new SetCompositeTypeComment("public", "address", null, "a postal address"), + new DropCompositeType("public", "address")); + // ── Domains ──────────────────────────────────────────────────────────────── [Fact] diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs index ef10dc5..f37b92a 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs @@ -1,6 +1,7 @@ using Npgsql; using NSchema.Plan.Model; using NSchema.Plan.Model.Columns; +using NSchema.Plan.Model.CompositeTypes; using NSchema.Plan.Model.Constraints; using NSchema.Plan.Model.Domains; using NSchema.Plan.Model.Enums; @@ -13,6 +14,7 @@ using NSchema.Postgres.Sql; using NSchema.Postgres.Tests.Fixtures; using NSchema.Schema.Model.Columns; +using NSchema.Schema.Model.CompositeTypes; using NSchema.Schema.Model.Constraints; using NSchema.Schema.Model.Domains; using NSchema.Schema.Model.Enums; @@ -763,6 +765,28 @@ await _executor.Execute(_generator.Generate(new MigrationPlan( index.Columns.ShouldHaveSingleItem().Expression.ShouldBe("id"); } + // ── Composite types ────────────────────────────────────────────────────── + + [Fact] + public async Task RoundTrip_CompositeType_IntrospectsWithFields() + { + // Arrange + var type = new CompositeType("address", [new CompositeField("street", SqlType.Text), new CompositeField("zip", SqlType.Int)]); + + // Act — create, then exercise an in-place field add (ALTER TYPE … ADD ATTRIBUTE). + await _executor.Execute(_generator.Generate(new MigrationPlan([new CreateCompositeType(_schema, type)], [], [])), TestContext.Current.CancellationToken); + await _executor.Execute(_generator.Generate(new MigrationPlan( + [new AddCompositeField(_schema, "address", new CompositeField("country", SqlType.Text))], [], [])), TestContext.Current.CancellationToken); + + // Assert + var provider = new PostgresSchemaProvider(_dataSource); + var introspected = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken)) + .Schemas[0].CompositeTypes.ShouldHaveSingleItem(); + introspected.Name.ShouldBe("address"); + introspected.Fields.Select(f => (f.Name, f.DataType)).ShouldBe( + [("street", SqlType.Text), ("zip", SqlType.Int), ("country", SqlType.Text)]); + } + // ── Domains ─────────────────────────────────────────────────────────────── [Fact] diff --git a/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.CompositeTypeOperations.verified.txt b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.CompositeTypeOperations.verified.txt new file mode 100644 index 0000000..ab71c18 --- /dev/null +++ b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.CompositeTypeOperations.verified.txt @@ -0,0 +1,33 @@ +{ + Statements: [ + { + Sql: CREATE TYPE "public"."address" AS ("street" text, "zip" integer), + RunOutsideTransaction: false + }, + { + Sql: ALTER TYPE "public"."address" ADD ATTRIBUTE "country" text, + RunOutsideTransaction: false + }, + { + Sql: ALTER TYPE "public"."address" ALTER ATTRIBUTE "zip" TYPE character varying(10), + RunOutsideTransaction: false + }, + { + Sql: ALTER TYPE "public"."address" DROP ATTRIBUTE "country", + RunOutsideTransaction: false + }, + { + Sql: ALTER TYPE "public"."old_address" RENAME TO "address", + RunOutsideTransaction: false + }, + { + Sql: COMMENT ON TYPE "public"."address" IS $comment$a postal address$comment$, + RunOutsideTransaction: false + }, + { + Sql: DROP TYPE "public"."address", + RunOutsideTransaction: false + } + ], + IsEmpty: false +} \ No newline at end of file From dd31e70deaf7562fbf6bc05a60fc7215a4347028 Mon Sep 17 00:00:00 2001 From: Tom Wolfe Date: Fri, 19 Jun 2026 00:15:11 +0100 Subject: [PATCH 10/12] feat: add trigger support. --- src/NSchema.Postgres/Models/TriggerRow.cs | 16 ++++ .../Sql/PostgresSchemaProvider.cs | 94 ++++++++++++++++++- .../Sql/PostgresSqlGenerator.cs | 52 ++++++++++ .../Sql/PostgresSqlGeneratorSnapshotTests.cs | 14 +++ .../Sql/PostgresSqlGeneratorTests.cs | 31 ++++++ ...apshotTests.TriggerOperations.verified.txt | 21 +++++ 6 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 src/NSchema.Postgres/Models/TriggerRow.cs create mode 100644 tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.TriggerOperations.verified.txt diff --git a/src/NSchema.Postgres/Models/TriggerRow.cs b/src/NSchema.Postgres/Models/TriggerRow.cs new file mode 100644 index 0000000..d6bed2a --- /dev/null +++ b/src/NSchema.Postgres/Models/TriggerRow.cs @@ -0,0 +1,16 @@ +namespace NSchema.Postgres.Models; + +/// +/// A row of trigger metadata. is the raw pg_trigger.tgtype bitmask (timing/level/events), +/// decoded into the model's enums when mapped. +/// +internal sealed record TriggerRow( + string TableSchema, + string TableName, + string Name, + int TgType, + string Function, + string? When, + string[] UpdateOfColumns, + string? FunctionArguments, + string? Comment); diff --git a/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs b/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs index 337aeb3..648c28b 100644 --- a/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs +++ b/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs @@ -13,6 +13,7 @@ using NSchema.Schema.Model.Schemas; using NSchema.Schema.Model.Sequences; using NSchema.Schema.Model.Tables; +using NSchema.Schema.Model.Triggers; using NSchema.Schema.Model.Views; namespace NSchema.Postgres.Sql; @@ -37,6 +38,7 @@ public async ValueTask GetSchema(string[]? schemas = null, Cance var checkConstraints = await QueryCheckConstraints(conn, schemas, cancellationToken); var exclusionConstraints = await QueryExclusionConstraints(conn, schemas, cancellationToken); var indexes = await QueryIndexes(conn, schemas, cancellationToken); + var triggers = await QueryTriggers(conn, schemas, cancellationToken); var schemaComments = await QuerySchemaComments(conn, schemas, cancellationToken); var tableComments = await QueryTableComments(conn, schemas, cancellationToken); var columnComments = await QueryColumnComments(conn, schemas, cancellationToken); @@ -61,7 +63,7 @@ public async ValueTask GetSchema(string[]? schemas = null, Cance var procedureComments = await QueryRoutineComments(conn, schemas, ProcedureKind, cancellationToken); return Build( - tables, columns, primaryKeys, foreignKeys, uniqueConstraints, checkConstraints, exclusionConstraints, indexes, + tables, columns, primaryKeys, foreignKeys, uniqueConstraints, checkConstraints, exclusionConstraints, indexes, triggers, schemaComments, tableComments, columnComments, indexComments, constraintComments, schemaGrants, tableGrants, views, viewComments, viewDependencies, enums, enumComments, sequences, sequenceComments, @@ -612,6 +614,64 @@ AND a.udt_schema NOT LIKE 'pg\_temp%' ESCAPE '\' return rows; } + private static async Task> QueryTriggers(NpgsqlConnection conn, string[]? schemas, CancellationToken ct) + { + var rows = new List(); + await using var cmd = conn.CreateCommand(); + // User triggers only — NOT tgisinternal excludes the system triggers that enforce foreign keys and other + // constraints. tgtype is a bitmask (timing/level/events) decoded when mapped; the UPDATE OF column set comes + // from tgattr. The WHEN condition and the function arguments are both pulled out of pg_get_triggerdef (its + // canonical form): pg_get_expr cannot render a WHEN that references both OLD and NEW, and there is no clean + // catalog column for the decoded arguments. + cmd.CommandText = """ + SELECT + n.nspname AS table_schema, + c.relname AS table_name, + t.tgname AS trigger_name, + t.tgtype AS tg_type, + fn.nspname || '.' || p.proname AS function, + substring(td.def FROM 'WHEN \((.*)\) EXECUTE (?:FUNCTION|PROCEDURE)') AS when_expr, + COALESCE( + (SELECT array_agg(a.attname ORDER BY k.ord) + FROM unnest(string_to_array(NULLIF(t.tgattr::text, ''), ' ')::int[]) WITH ORDINALITY AS k(attnum, ord) + JOIN pg_attribute a ON a.attrelid = t.tgrelid AND a.attnum = k.attnum), + ARRAY[]::text[]) AS update_of_columns, + substring(td.def FROM 'EXECUTE (?:FUNCTION|PROCEDURE) [^(]+\((.*)\)$') AS function_args, + obj_description(t.oid, 'pg_trigger') AS comment + FROM pg_trigger t + JOIN pg_class c ON c.oid = t.tgrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_proc p ON p.oid = t.tgfoid + JOIN pg_namespace fn ON fn.oid = p.pronamespace + CROSS JOIN LATERAL (SELECT pg_get_triggerdef(t.oid) AS def) td + WHERE NOT t.tgisinternal + AND (@schemas::text[] IS NULL OR n.nspname = ANY(@schemas)) + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + AND n.nspname NOT LIKE 'pg\_toast%' ESCAPE '\' + AND n.nspname NOT LIKE 'pg\_temp%' ESCAPE '\' + ORDER BY n.nspname, c.relname, t.tgname + """; + AddSchemasParameter(cmd, schemas); + + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + rows.Add(new TriggerRow( + TableSchema: reader.GetString(0), + TableName: reader.GetString(1), + Name: reader.GetString(2), + TgType: reader.GetInt16(3), + Function: reader.GetString(4), + When: reader.IsDBNull(5) ? null : StripEnclosingParens(reader.GetString(5)), + UpdateOfColumns: reader.GetFieldValue(6), + FunctionArguments: reader.IsDBNull(7) || reader.GetString(7).Length == 0 ? null : reader.GetString(7), + Comment: reader.IsDBNull(8) ? null : reader.GetString(8) + )); + } + + return rows; + } + private static async Task> QueryConstraintComments(NpgsqlConnection conn, string[]? schemas, CancellationToken ct) { var result = new Dictionary<(string, string, string), string?>(); @@ -1278,6 +1338,7 @@ private static DatabaseSchema Build( List checkConstraints, List exclusionConstraints, List indexes, + List triggers, Dictionary schemaComments, Dictionary<(string, string), string?> tableComments, Dictionary<(string, string, string), string?> columnComments, @@ -1306,7 +1367,7 @@ private static DatabaseSchema Build( .GroupBy(t => t.Schema) .ToDictionary( g => g.Key, - g => g.Select(t => BuildTable(t, columns, primaryKeys, foreignKeys, uniqueConstraints, checkConstraints, exclusionConstraints, indexes, tableComments, columnComments, indexComments, constraintComments, tableGrants)).ToList()); + g => g.Select(t => BuildTable(t, columns, primaryKeys, foreignKeys, uniqueConstraints, checkConstraints, exclusionConstraints, indexes, triggers, tableComments, columnComments, indexComments, constraintComments, tableGrants)).ToList()); var viewsBySchema = views .GroupBy(v => v.Schema) @@ -1451,6 +1512,7 @@ private static Table BuildTable( List allCheckConstraints, List allExclusionConstraints, List allIndexes, + List allTriggers, Dictionary<(string, string), string?> tableComments, Dictionary<(string, string, string), string?> columnComments, Dictionary<(string, string), string?> indexComments, @@ -1497,6 +1559,11 @@ List allTableGrants .Select(i => MapIndex(i, indexComments.GetValueOrDefault((tableRow.Schema, i.IndexName)))) .ToList(); + var triggers = allTriggers + .Where(t => t.TableSchema == tableRow.Schema && t.TableName == tableRow.Name) + .Select(MapTrigger) + .ToList(); + tableComments.TryGetValue((tableRow.Schema, tableRow.Name), out var tableComment); var grants = allTableGrants @@ -1516,10 +1583,31 @@ List allTableGrants CheckConstraints: checks, ExclusionConstraints: exclusions, Indexes: idxs, - Grants: grants + Grants: grants, + Triggers: triggers ); } + // tgtype is a bitmask: bit 0 = ROW (else STATEMENT); bit 6 = INSTEAD OF, else bit 1 = BEFORE, else AFTER; + // bits 2/3/4/5 = INSERT/DELETE/UPDATE/TRUNCATE. The model's TriggerEvent flags differ from these bit values, + // so the events are translated rather than copied. + private static Trigger MapTrigger(TriggerRow row) + { + var timing = (row.TgType & 64) != 0 ? TriggerTiming.InsteadOf + : (row.TgType & 2) != 0 ? TriggerTiming.Before + : TriggerTiming.After; + var level = (row.TgType & 1) != 0 ? TriggerLevel.Row : TriggerLevel.Statement; + + var events = TriggerEvent.None; + if ((row.TgType & 4) != 0) events |= TriggerEvent.Insert; + if ((row.TgType & 8) != 0) events |= TriggerEvent.Delete; + if ((row.TgType & 16) != 0) events |= TriggerEvent.Update; + if ((row.TgType & 32) != 0) events |= TriggerEvent.Truncate; + + return new Trigger(row.Name, timing, events, row.Function, level, + UpdateOfColumns: row.UpdateOfColumns, When: row.When, FunctionArguments: row.FunctionArguments, Comment: row.Comment); + } + private static CompositeType MapCompositeType(CompositeTypeRow row, List allFields) { var fields = allFields diff --git a/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs b/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs index 823bce2..aefd5ef 100644 --- a/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs +++ b/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs @@ -10,6 +10,7 @@ using NSchema.Plan.Model.Schemas; using NSchema.Plan.Model.Sequence; using NSchema.Plan.Model.Tables; +using NSchema.Plan.Model.Triggers; using NSchema.Plan.Model.Views; using NSchema.Schema.Model.Columns; using NSchema.Schema.Model.CompositeTypes; @@ -19,6 +20,7 @@ using NSchema.Schema.Model.Routines; using NSchema.Schema.Model.Sequences; using NSchema.Schema.Model.Tables; +using NSchema.Schema.Model.Triggers; using NSchema.Sql; using NSchema.Sql.Model; @@ -78,6 +80,11 @@ public SqlPlan Generate(MigrationPlan plan) DropExclusionConstraint x => $"ALTER TABLE \"{x.SchemaName}\".\"{x.TableName}\" DROP CONSTRAINT \"{x.ConstraintName}\"", CreateIndex x => BuildCreateIndex(x), DropIndex x => $"DROP INDEX \"{x.SchemaName}\".\"{x.IndexName}\"", + CreateTrigger x => BuildCreateTrigger(x), + DropTrigger x => $"DROP TRIGGER \"{x.TriggerName}\" ON \"{x.SchemaName}\".\"{x.TableName}\"", + SetTriggerComment x => x.NewComment is null + ? $"""COMMENT ON TRIGGER "{x.TriggerName}" ON "{x.SchemaName}"."{x.TableName}" IS NULL""" + : $"""COMMENT ON TRIGGER "{x.TriggerName}" ON "{x.SchemaName}"."{x.TableName}" IS $comment${x.NewComment}$comment$""", // A view Add and a body Modify both arrive as CreateView; CREATE OR REPLACE serves both. An incompatible // output-column change (rename/drop/retype/reorder) is rejected loudly by Postgres rather than silently // dropping dependents — see CLAUDE.md / the core view-body decision. @@ -463,6 +470,51 @@ private static IEnumerable BuildRecreateDomain(RecreateDomain x) } } + // CREATE TRIGGER name {BEFORE|AFTER|INSTEAD OF} {event [OR …]} ON s.t FOR EACH {ROW|STATEMENT} + // [WHEN (cond)] EXECUTE FUNCTION fn(args) + private static string BuildCreateTrigger(CreateTrigger x) + { + var t = x.Trigger; + var sb = new StringBuilder( + $"""CREATE TRIGGER "{t.Name}" {TriggerTimingText(t.Timing)} {TriggerEventsText(t)} ON "{x.SchemaName}"."{x.TableName}" FOR EACH {(t.Level == TriggerLevel.Row ? "ROW" : "STATEMENT")}"""); + if (t.When is { } when) + { + sb.Append($" WHEN ({when})"); + } + sb.Append($" EXECUTE FUNCTION {t.Function}({t.FunctionArguments ?? string.Empty})"); + return sb.ToString(); + } + + private static string TriggerTimingText(TriggerTiming timing) => timing switch + { + TriggerTiming.Before => "BEFORE", + TriggerTiming.After => "AFTER", + TriggerTiming.InsteadOf => "INSTEAD OF", + _ => throw new ArgumentOutOfRangeException(nameof(timing), timing, "Unknown trigger timing."), + }; + + private static string TriggerEventsText(Trigger trigger) + { + var parts = new List(4); + if (trigger.Events.HasFlag(TriggerEvent.Insert)) + { + parts.Add("INSERT"); + } + if (trigger.Events.HasFlag(TriggerEvent.Update)) + { + parts.Add(trigger.UpdateOfColumns.Count > 0 ? $"UPDATE OF {ColList(trigger.UpdateOfColumns)}" : "UPDATE"); + } + if (trigger.Events.HasFlag(TriggerEvent.Delete)) + { + parts.Add("DELETE"); + } + if (trigger.Events.HasFlag(TriggerEvent.Truncate)) + { + parts.Add("TRUNCATE"); + } + return string.Join(" OR ", parts); + } + private static string ViewKind(bool isMaterialized) => isMaterialized ? "MATERIALIZED VIEW" : "VIEW"; private static string RoutineKeyword(RoutineKind kind) => kind == RoutineKind.Procedure ? "PROCEDURE" : "FUNCTION"; diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs index 1c130b0..f60a6a7 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs @@ -9,6 +9,7 @@ using NSchema.Plan.Model.Schemas; using NSchema.Plan.Model.Sequence; using NSchema.Plan.Model.Tables; +using NSchema.Plan.Model.Triggers; using NSchema.Plan.Model.Views; using NSchema.Postgres.Sql; using NSchema.Schema.Model.Columns; @@ -21,6 +22,7 @@ using NSchema.Schema.Model.Scripts; using NSchema.Schema.Model.Sequences; using NSchema.Schema.Model.Tables; +using NSchema.Schema.Model.Triggers; using NSchema.Schema.Model.Views; using NSchema.Sql; @@ -142,6 +144,18 @@ public Task ExclusionConstraintOperations() => VerifyPlan( [new ExclusionElement("tstzrange(starts, ends)", "&&", IsExpression: true)], Method: "gist")), new DropExclusionConstraint("public", "bookings", "no_overlap")); + // ── Triggers ────────────────────────────────────────────────────────────── + + [Fact] + public Task TriggerOperations() => VerifyPlan( + new CreateTrigger("public", "users", new Trigger("users_audit", TriggerTiming.After, + TriggerEvent.Insert | TriggerEvent.Update, "public.log_change", TriggerLevel.Row, + UpdateOfColumns: ["email", "name"], When: "new.active", FunctionArguments: "'audit'")), + new CreateTrigger("public", "logs", new Trigger("logs_truncate", TriggerTiming.Before, + TriggerEvent.Truncate, "public.on_truncate", TriggerLevel.Statement)), + new SetTriggerComment("public", "users", "users_audit", null, "audit changes"), + new DropTrigger("public", "users", "users_audit")); + // ── Views ───────────────────────────────────────────────────────────────── [Fact] diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs index f37b92a..edc2aa8 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs @@ -10,6 +10,7 @@ using NSchema.Plan.Model.Schemas; using NSchema.Plan.Model.Sequence; using NSchema.Plan.Model.Tables; +using NSchema.Plan.Model.Triggers; using NSchema.Plan.Model.Views; using NSchema.Postgres.Sql; using NSchema.Postgres.Tests.Fixtures; @@ -23,6 +24,7 @@ using NSchema.Schema.Model.Scripts; using NSchema.Schema.Model.Sequences; using NSchema.Schema.Model.Tables; +using NSchema.Schema.Model.Triggers; using NSchema.Schema.Model.Views; using NSchema.Sql.Model; @@ -765,6 +767,35 @@ await _executor.Execute(_generator.Generate(new MigrationPlan( index.Columns.ShouldHaveSingleItem().Expression.ShouldBe("id"); } + // ── Triggers ────────────────────────────────────────────────────────────── + + [Fact] + public async Task RoundTrip_Trigger_IntrospectsWithDecodedAttributes() + { + // Arrange — a trigger function, then a row-level AFTER trigger with UPDATE OF and a WHEN condition. + await Exec($"""CREATE TABLE "{_schema}".users (id int, email text, active boolean)"""); + await Exec($"""CREATE FUNCTION "{_schema}".audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$"""); + var trigger = new Trigger("users_audit", TriggerTiming.After, TriggerEvent.Insert | TriggerEvent.Update, + $"{_schema}.audit", TriggerLevel.Row, UpdateOfColumns: ["email"], When: "new.active"); + + // Act + await _executor.Execute(_generator.Generate(new MigrationPlan([new CreateTrigger(_schema, "users", trigger)], [], [])), TestContext.Current.CancellationToken); + + // Assert — the tgtype bitmask decodes back to the same timing/level/events. + var provider = new PostgresSchemaProvider(_dataSource); + var introspected = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken)) + .Schemas[0].Tables[0].Triggers.ShouldHaveSingleItem(); + introspected.Name.ShouldBe("users_audit"); + introspected.Timing.ShouldBe(TriggerTiming.After); + introspected.Level.ShouldBe(TriggerLevel.Row); + introspected.Events.ShouldBe(TriggerEvent.Insert | TriggerEvent.Update); + introspected.UpdateOfColumns.ShouldBe(["email"]); + introspected.Function.ShouldBe($"{_schema}.audit"); + introspected.When.ShouldNotBeNull(); + introspected.When!.ShouldContain("active"); + introspected.FunctionArguments.ShouldBeNull(); + } + // ── Composite types ────────────────────────────────────────────────────── [Fact] diff --git a/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.TriggerOperations.verified.txt b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.TriggerOperations.verified.txt new file mode 100644 index 0000000..d55192a --- /dev/null +++ b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.TriggerOperations.verified.txt @@ -0,0 +1,21 @@ +{ + Statements: [ + { + Sql: CREATE TRIGGER "users_audit" AFTER INSERT OR UPDATE OF "email", "name" ON "public"."users" FOR EACH ROW WHEN (new.active) EXECUTE FUNCTION public.log_change('audit'), + RunOutsideTransaction: false + }, + { + Sql: CREATE TRIGGER "logs_truncate" BEFORE TRUNCATE ON "public"."logs" FOR EACH STATEMENT EXECUTE FUNCTION public.on_truncate(), + RunOutsideTransaction: false + }, + { + Sql: COMMENT ON TRIGGER "users_audit" ON "public"."users" IS $comment$audit changes$comment$, + RunOutsideTransaction: false + }, + { + Sql: DROP TRIGGER "users_audit" ON "public"."users", + RunOutsideTransaction: false + } + ], + IsEmpty: false +} \ No newline at end of file From bba04794826c577654307fc9c8ff766277a281df Mon Sep 17 00:00:00 2001 From: Tom Wolfe Date: Fri, 19 Jun 2026 00:24:51 +0100 Subject: [PATCH 11/12] feat: support extensions --- src/NSchema.Postgres/Models/ExtensionRow.cs | 6 +++ .../Sql/PostgresSchemaProvider.cs | 39 +++++++++++++++++-- .../Sql/PostgresSqlGenerator.cs | 16 ++++++++ .../Sql/PostgresSchemaProviderTests.cs | 15 +++++++ .../Sql/PostgresSqlGeneratorSnapshotTests.cs | 14 +++++++ .../Sql/PostgresSqlGeneratorTests.cs | 18 +++++++++ ...shotTests.ExtensionOperations.verified.txt | 29 ++++++++++++++ 7 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 src/NSchema.Postgres/Models/ExtensionRow.cs create mode 100644 tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.ExtensionOperations.verified.txt diff --git a/src/NSchema.Postgres/Models/ExtensionRow.cs b/src/NSchema.Postgres/Models/ExtensionRow.cs new file mode 100644 index 0000000..3b12230 --- /dev/null +++ b/src/NSchema.Postgres/Models/ExtensionRow.cs @@ -0,0 +1,6 @@ +namespace NSchema.Postgres.Models; + +internal sealed record ExtensionRow( + string Name, + string? Version, + string? Comment); diff --git a/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs b/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs index 648c28b..9135b10 100644 --- a/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs +++ b/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs @@ -8,6 +8,7 @@ using NSchema.Schema.Model.Constraints; using NSchema.Schema.Model.Domains; using NSchema.Schema.Model.Enums; +using NSchema.Schema.Model.Extensions; using NSchema.Schema.Model.Indexes; using NSchema.Schema.Model.Routines; using NSchema.Schema.Model.Schemas; @@ -39,6 +40,7 @@ public async ValueTask GetSchema(string[]? schemas = null, Cance var exclusionConstraints = await QueryExclusionConstraints(conn, schemas, cancellationToken); var indexes = await QueryIndexes(conn, schemas, cancellationToken); var triggers = await QueryTriggers(conn, schemas, cancellationToken); + var extensions = await QueryExtensions(conn, cancellationToken); var schemaComments = await QuerySchemaComments(conn, schemas, cancellationToken); var tableComments = await QueryTableComments(conn, schemas, cancellationToken); var columnComments = await QueryColumnComments(conn, schemas, cancellationToken); @@ -68,7 +70,8 @@ public async ValueTask GetSchema(string[]? schemas = null, Cance schemaGrants, tableGrants, views, viewComments, viewDependencies, enums, enumComments, sequences, sequenceComments, domains, domainChecks, compositeTypes, compositeFields, - functions, functionComments, procedures, procedureComments + functions, functionComments, procedures, procedureComments, + extensions ); } @@ -614,6 +617,34 @@ AND a.udt_schema NOT LIKE 'pg\_temp%' ESCAPE '\' return rows; } + private static async Task> QueryExtensions(NpgsqlConnection conn, CancellationToken ct) + { + var rows = new List(); + await using var cmd = conn.CreateCommand(); + // Extensions are database-global (not schema-scoped), so they are not filtered by the schema list. plpgsql + // is the always-installed procedural language and never part of a declared schema, so it is excluded. + cmd.CommandText = """ + SELECT e.extname AS name, + e.extversion AS version, + obj_description(e.oid, 'pg_extension') AS comment + FROM pg_extension e + WHERE e.extname <> 'plpgsql' + ORDER BY e.extname + """; + + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + rows.Add(new ExtensionRow( + Name: reader.GetString(0), + Version: reader.IsDBNull(1) ? null : reader.GetString(1), + Comment: reader.IsDBNull(2) ? null : reader.GetString(2) + )); + } + + return rows; + } + private static async Task> QueryTriggers(NpgsqlConnection conn, string[]? schemas, CancellationToken ct) { var rows = new List(); @@ -1360,7 +1391,8 @@ private static DatabaseSchema Build( List functions, Dictionary<(string, string), string?> functionComments, List procedures, - Dictionary<(string, string), string?> procedureComments + Dictionary<(string, string), string?> procedureComments, + List extensions ) { var bySchema = tables @@ -1443,7 +1475,8 @@ private static DatabaseSchema Build( }) .ToList(); - return new DatabaseSchema(dbSchemas, []); + var dbExtensions = extensions.Select(e => new Extension(e.Name, e.Version, e.Comment)).ToList(); + return new DatabaseSchema(dbSchemas, [], dbExtensions, []); } // Postgres engine defaults are folded to null so a bare "CREATE SEQUENCE" round-trips to an all-null diff --git a/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs b/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs index aefd5ef..ee297c9 100644 --- a/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs +++ b/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs @@ -5,6 +5,7 @@ using NSchema.Plan.Model.Constraints; using NSchema.Plan.Model.Domains; using NSchema.Plan.Model.Enums; +using NSchema.Plan.Model.Extensions; using NSchema.Plan.Model.Indexes; using NSchema.Plan.Model.Routines; using NSchema.Plan.Model.Schemas; @@ -16,6 +17,7 @@ using NSchema.Schema.Model.CompositeTypes; using NSchema.Schema.Model.Constraints; using NSchema.Schema.Model.Domains; +using NSchema.Schema.Model.Extensions; using NSchema.Schema.Model.Indexes; using NSchema.Schema.Model.Routines; using NSchema.Schema.Model.Sequences; @@ -158,6 +160,14 @@ public SqlPlan Generate(MigrationPlan plan) SetConstraintComment x => x.NewComment is null ? $"""COMMENT ON CONSTRAINT "{x.ConstraintName}" ON "{x.SchemaName}"."{x.TableName}" IS NULL""" : $"""COMMENT ON CONSTRAINT "{x.ConstraintName}" ON "{x.SchemaName}"."{x.TableName}" IS $comment${x.NewComment}$comment$""", + CreateExtension x => BuildCreateExtension(x), + // A version change updates in place; with no target version, UPDATE moves to the default (latest) version. + AlterExtension { NewVersion: { } v } x => $"""ALTER EXTENSION "{x.ExtensionName}" UPDATE TO '{EscapeLiteral(v)}'""", + AlterExtension x => $"""ALTER EXTENSION "{x.ExtensionName}" UPDATE""", + DropExtension x => $"DROP EXTENSION \"{x.ExtensionName}\"", + SetExtensionComment x => x.NewComment is null + ? $"""COMMENT ON EXTENSION "{x.ExtensionName}" IS NULL""" + : $"""COMMENT ON EXTENSION "{x.ExtensionName}" IS $comment${x.NewComment}$comment$""", GrantSchemaUsage x => $"""GRANT USAGE ON SCHEMA "{x.SchemaName}" TO {x.Role}""", RevokeSchemaUsage x => $"""REVOKE USAGE ON SCHEMA "{x.SchemaName}" FROM {x.Role}""", GrantTablePrivileges x => $"""GRANT {PrivilegeList(x.Privileges)} ON TABLE "{x.SchemaName}"."{x.TableName}" TO {x.Role}""", @@ -515,6 +525,12 @@ private static string TriggerEventsText(Trigger trigger) return string.Join(" OR ", parts); } + private static string BuildCreateExtension(CreateExtension x) + { + var sql = $"CREATE EXTENSION IF NOT EXISTS \"{x.Extension.Name}\""; + return x.Extension.Version is { } version ? $"{sql} VERSION '{EscapeLiteral(version)}'" : sql; + } + private static string ViewKind(bool isMaterialized) => isMaterialized ? "MATERIALIZED VIEW" : "VIEW"; private static string RoutineKeyword(RoutineKind kind) => kind == RoutineKind.Procedure ? "PROCEDURE" : "FUNCTION"; diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSchemaProviderTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSchemaProviderTests.cs index 87f49c5..7d4c5aa 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSchemaProviderTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSchemaProviderTests.cs @@ -1027,4 +1027,19 @@ public async Task GetSchema_Aggregate_IsNotReturnedAsFunction() // Assert schema.Routines.ShouldBeEmpty(); } + + [Fact] + public async Task GetSchema_Extensions_AreReportedAtRootWithVersion() + { + // Arrange — the fixture enables citext database-wide; extensions are global, so a schema-scoped read still + // surfaces them at the root. plpgsql (the always-present default) is excluded. + + // Act + var schema = await _sut.GetSchema([_schema], TestContext.Current.CancellationToken); + + // Assert + var citext = schema.Extensions.Single(e => e.Name == "citext"); + citext.Version.ShouldNotBeNull(); + schema.Extensions.ShouldNotContain(e => e.Name == "plpgsql"); + } } diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs index f60a6a7..5c42216 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs @@ -4,6 +4,7 @@ using NSchema.Plan.Model.Constraints; using NSchema.Plan.Model.Domains; using NSchema.Plan.Model.Enums; +using NSchema.Plan.Model.Extensions; using NSchema.Plan.Model.Indexes; using NSchema.Plan.Model.Routines; using NSchema.Plan.Model.Schemas; @@ -17,6 +18,7 @@ using NSchema.Schema.Model.Constraints; using NSchema.Schema.Model.Domains; using NSchema.Schema.Model.Enums; +using NSchema.Schema.Model.Extensions; using NSchema.Schema.Model.Indexes; using NSchema.Schema.Model.Routines; using NSchema.Schema.Model.Scripts; @@ -235,6 +237,18 @@ public Task SequenceOperations() => VerifyPlan( new SetSequenceComment("public", "invoice_id", "Invoice numbers", null), new DropSequence("public", "invoice_id")); + // ── Extensions ──────────────────────────────────────────────────────────── + + [Fact] + public Task ExtensionOperations() => VerifyPlan( + new CreateExtension(new Extension("citext")), + new CreateExtension(new Extension("postgis", Version: "3.4")), + // A hyphenated name must be quoted. + new CreateExtension(new Extension("uuid-ossp")), + new AlterExtension("postgis", "3.4", "3.5"), + new SetExtensionComment("citext", null, "case-insensitive text"), + new DropExtension("postgis")); + // ── Functions ───────────────────────────────────────────────────────────── [Fact] diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs index edc2aa8..6190091 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs @@ -5,6 +5,7 @@ using NSchema.Plan.Model.Constraints; using NSchema.Plan.Model.Domains; using NSchema.Plan.Model.Enums; +using NSchema.Plan.Model.Extensions; using NSchema.Plan.Model.Indexes; using NSchema.Plan.Model.Routines; using NSchema.Plan.Model.Schemas; @@ -19,6 +20,7 @@ using NSchema.Schema.Model.Constraints; using NSchema.Schema.Model.Domains; using NSchema.Schema.Model.Enums; +using NSchema.Schema.Model.Extensions; using NSchema.Schema.Model.Indexes; using NSchema.Schema.Model.Routines; using NSchema.Schema.Model.Scripts; @@ -796,6 +798,22 @@ public async Task RoundTrip_Trigger_IntrospectsWithDecodedAttributes() introspected.FunctionArguments.ShouldBeNull(); } + // ── Extensions ──────────────────────────────────────────────────────────── + + [Fact] + public async Task CreateExtension_ThenIntrospect_ReportsExtension() + { + // Act — create a contrib extension via the generator (extensions are database-global). + await _executor.Execute(_generator.Generate(new MigrationPlan([new CreateExtension(new Extension("hstore"))], [], [])), TestContext.Current.CancellationToken); + + // Assert — it surfaces as a root-level extension with a version; plpgsql is excluded. + var provider = new PostgresSchemaProvider(_dataSource); + var schema = await provider.GetSchema([_schema], TestContext.Current.CancellationToken); + var hstore = schema.Extensions.Single(e => e.Name == "hstore"); + hstore.Version.ShouldNotBeNull(); + schema.Extensions.ShouldNotContain(e => e.Name == "plpgsql"); + } + // ── Composite types ────────────────────────────────────────────────────── [Fact] diff --git a/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.ExtensionOperations.verified.txt b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.ExtensionOperations.verified.txt new file mode 100644 index 0000000..554c49a --- /dev/null +++ b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.ExtensionOperations.verified.txt @@ -0,0 +1,29 @@ +{ + Statements: [ + { + Sql: CREATE EXTENSION IF NOT EXISTS "citext", + RunOutsideTransaction: false + }, + { + Sql: CREATE EXTENSION IF NOT EXISTS "postgis" VERSION '3.4', + RunOutsideTransaction: false + }, + { + Sql: CREATE EXTENSION IF NOT EXISTS "uuid-ossp", + RunOutsideTransaction: false + }, + { + Sql: ALTER EXTENSION "postgis" UPDATE TO '3.5', + RunOutsideTransaction: false + }, + { + Sql: COMMENT ON EXTENSION "citext" IS $comment$case-insensitive text$comment$, + RunOutsideTransaction: false + }, + { + Sql: DROP EXTENSION "postgis", + RunOutsideTransaction: false + } + ], + IsEmpty: false +} \ No newline at end of file From 7996424b4bbfa8e158573f5004a53d65346f2516 Mon Sep 17 00:00:00 2001 From: Tom Wolfe Date: Fri, 19 Jun 2026 00:26:27 +0100 Subject: [PATCH 12/12] chore: formatting --- .../Sql/PostgresSchemaProvider.cs | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs b/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs index 9135b10..3d0b3f4 100644 --- a/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs +++ b/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs @@ -1632,10 +1632,25 @@ private static Trigger MapTrigger(TriggerRow row) var level = (row.TgType & 1) != 0 ? TriggerLevel.Row : TriggerLevel.Statement; var events = TriggerEvent.None; - if ((row.TgType & 4) != 0) events |= TriggerEvent.Insert; - if ((row.TgType & 8) != 0) events |= TriggerEvent.Delete; - if ((row.TgType & 16) != 0) events |= TriggerEvent.Update; - if ((row.TgType & 32) != 0) events |= TriggerEvent.Truncate; + if ((row.TgType & 4) != 0) + { + events |= TriggerEvent.Insert; + } + + if ((row.TgType & 8) != 0) + { + events |= TriggerEvent.Delete; + } + + if ((row.TgType & 16) != 0) + { + events |= TriggerEvent.Update; + } + + if ((row.TgType & 32) != 0) + { + events |= TriggerEvent.Truncate; + } return new Trigger(row.Name, timing, events, row.Function, level, UpdateOfColumns: row.UpdateOfColumns, When: row.When, FunctionArguments: row.FunctionArguments, Comment: row.Comment);