diff --git a/Directory.Packages.props b/Directory.Packages.props index c4d6f6f..2258eba 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,7 +7,7 @@ - + @@ -17,4 +17,4 @@ - \ No newline at end of file + diff --git a/src/NSchema.Postgres/NSchema.Postgres.csproj b/src/NSchema.Postgres/NSchema.Postgres.csproj index 3659e0e..e6b6fa5 100644 --- a/src/NSchema.Postgres/NSchema.Postgres.csproj +++ b/src/NSchema.Postgres/NSchema.Postgres.csproj @@ -20,7 +20,7 @@ true true snupkg - 5.0.0-alpha.1 + 5.0.0-alpha.2 $(Version.Split('-')[0]) $(Version.Split('-')[0]) true diff --git a/src/NSchema.Postgres/PostgresPlugin.cs b/src/NSchema.Postgres/PostgresPlugin.cs index bda16eb..322ae40 100644 --- a/src/NSchema.Postgres/PostgresPlugin.cs +++ b/src/NSchema.Postgres/PostgresPlugin.cs @@ -13,6 +13,15 @@ public sealed class PostgresPlugin : INSchemaDatabasePlugin private const string EnvUsername = "NSCHEMA_POSTGRES_USERNAME"; private const string EnvPassword = "NSCHEMA_POSTGRES_PASSWORD"; + /// The options a DATABASE statement binds onto. + private sealed class PostgresOptions + { + public string? ConnectionString { get; set; } + public string? Username { get; set; } + public string? Password { get; set; } + public int? CommandTimeout { get; set; } + } + /// public string GetScaffoldTemplate(ScaffoldContext context) => $""" @@ -41,61 +50,29 @@ CONSTRAINT widgets_pkey PRIMARY KEY (id) /// public Result Configure(NSchemaApplicationBuilder builder, PluginConfig config) { - var errors = new List(); - var connectionString = ""; - string? username = null; - string? password = null; - int? commandTimeout = null; - - foreach (var (key, value) in config.Attributes) - { - switch (key.Value.ToLowerInvariant()) - { - case "connection_string": - connectionString = value.AsString(); - break; - case "username": - username = value.AsString(); - break; - case "password": - password = value.AsString(); - break; - case "command_timeout": - if (value.Kind is ConfigValueKind.Integer) - { - commandTimeout = (int)value.AsInteger(); - } - else - { - errors.Add(Diagnostic.Error(DiagnosticSource, "DATABASE postgres: command_timeout must be an integer.")); - } - - break; - default: - errors.Add(Diagnostic.Error(DiagnosticSource, $"DATABASE postgres: unknown attribute '{key}'.")); - break; - } - } + var bound = config.Bind(); + var diagnostics = new List(bound.Diagnostics); + var options = bound.Value!; // Credentials may be supplied out of band (e.g. a secret store); the environment overrides the statement. - connectionString = Environment.GetEnvironmentVariable(EnvConnectionString) ?? connectionString; - username = Environment.GetEnvironmentVariable(EnvUsername) ?? username; - password = Environment.GetEnvironmentVariable(EnvPassword) ?? password; + var connectionString = Environment.GetEnvironmentVariable(EnvConnectionString) ?? options.ConnectionString; + var username = Environment.GetEnvironmentVariable(EnvUsername) ?? options.Username; + var password = Environment.GetEnvironmentVariable(EnvPassword) ?? options.Password; if (string.IsNullOrEmpty(connectionString)) { - errors.Add(Diagnostic.Error(DiagnosticSource, + diagnostics.Add(Diagnostic.Error(DiagnosticSource, $"DATABASE postgres: connection_string is required. Set it via the {EnvConnectionString} environment variable or the statement attribute.")); } - if (commandTimeout is < 0) + if (options.CommandTimeout is < 0) { - errors.Add(Diagnostic.Error(DiagnosticSource, "DATABASE postgres: command_timeout must not be negative.")); + diagnostics.Add(Diagnostic.Error(DiagnosticSource, "DATABASE postgres: command_timeout must not be negative.")); } - if (errors.Count > 0) + if (diagnostics.Any(d => d.Severity == DiagnosticSeverity.Error)) { - return Result.From(errors); + return Result.From(diagnostics); } builder.UsePostgres(dataSource => @@ -112,12 +89,12 @@ public Result Configure(NSchemaApplicationBuilder builder, PluginConfig config) dataSource.ConnectionStringBuilder.Password = password; } - if (commandTimeout is { } timeout) + if (options.CommandTimeout is { } timeout) { dataSource.ConnectionStringBuilder.CommandTimeout = timeout; } }); - return Result.Success(); + return Result.From(diagnostics); } } diff --git a/src/NSchema.Postgres/Sql/PostgresSqlDialect.Columns.cs b/src/NSchema.Postgres/Sql/PostgresSqlDialect.Columns.cs index b2155d6..c412503 100644 --- a/src/NSchema.Postgres/Sql/PostgresSqlDialect.Columns.cs +++ b/src/NSchema.Postgres/Sql/PostgresSqlDialect.Columns.cs @@ -10,13 +10,16 @@ internal sealed partial class PostgresSqlDialect protected override Result> AddColumn(AddColumn action) => Statement($"ALTER TABLE {Qualify(action.Table)} ADD COLUMN {BuildColumnDef(action.Column)}"); - protected override Result> AlterColumnType(AlterColumnType action) => - Statement($"ALTER TABLE {Qualify(action.Column.Owner)} ALTER COLUMN {Quote(action.Column.Member)} TYPE {ToPostgresType(action.NewType)}"); - - protected override Result> AlterColumnNullability(AlterColumnNullability action) => - Statement(action.NewNullable - ? $"ALTER TABLE {Qualify(action.Column.Owner)} ALTER COLUMN {Quote(action.Column.Member)} DROP NOT NULL" - : $"ALTER TABLE {Qualify(action.Column.Owner)} ALTER COLUMN {Quote(action.Column.Member)} SET NOT NULL"); + protected override Result> AlterColumn(AlterColumn action) => + (action.Type, action.Nullability) switch + { + ({ } type, { } nullability) => Statements( + new($"ALTER TABLE {Qualify(action.Table)} ALTER COLUMN {Quote(action.Column.Name)} TYPE {ToPostgresType(type.New!)}"), + new($"ALTER TABLE {Qualify(action.Table)} ALTER COLUMN {Quote(action.Column.Name)} {(nullability.New! ? "DROP" : "SET")} NOT NULL")), + ({ } type, null) => Statement($"ALTER TABLE {Qualify(action.Table)} ALTER COLUMN {Quote(action.Column.Name)} TYPE {ToPostgresType(type.New!)}"), + (null, { } nullability) => Statement($"ALTER TABLE {Qualify(action.Table)} ALTER COLUMN {Quote(action.Column.Name)} {(nullability.New! ? "DROP" : "SET")} NOT NULL"), + _ => Statements(), + }; protected override Result> AlterIdentitySequence(AlterIdentitySequence action) { diff --git a/src/NSchema.Postgres/Sql/PostgresSqlDialect.Constraints.cs b/src/NSchema.Postgres/Sql/PostgresSqlDialect.Constraints.cs index 4f16abf..464975e 100644 --- a/src/NSchema.Postgres/Sql/PostgresSqlDialect.Constraints.cs +++ b/src/NSchema.Postgres/Sql/PostgresSqlDialect.Constraints.cs @@ -8,18 +8,21 @@ internal sealed partial class PostgresSqlDialect { // ── Constraints ─────────────────────────────────────────────────────────── - protected override Result> AddExclusionConstraint(AddExclusionConstraint action) + protected override Result> AddExclusionConstraint(AddExclusionConstraint action) => + Statement($"ALTER TABLE {Qualify(action.Table)} ADD {ExclusionConstraintClause(action.ExclusionConstraint)}"); + + protected override Result> DropExclusionConstraint(DropExclusionConstraint action) => + Statement($"ALTER TABLE {Qualify(action.Constraint.Owner)} DROP CONSTRAINT {Quote(action.Constraint.Member)}"); + + // The CONSTRAINT … EXCLUDE (…) clause, used inline in a CREATE TABLE and by the ALTER add. + private string ExclusionConstraintClause(ExclusionConstraint exclusion) { - var exclusion = action.ExclusionConstraint; var method = exclusion.Method is { } m ? $" USING {m.Value}" : ""; var elements = string.Join(", ", exclusion.Elements.Select(ExclusionElementText)); var where = exclusion.Predicate is { } p ? $" WHERE ({p.Value})" : ""; - return Statement($"ALTER TABLE {Qualify(action.Table)} ADD CONSTRAINT {Quote(exclusion.Name)} EXCLUDE{method} ({elements}){where}"); + return $"CONSTRAINT {Quote(exclusion.Name)} EXCLUDE{method} ({elements}){where}"; } - protected override Result> DropExclusionConstraint(DropExclusionConstraint action) => - Statement($"ALTER TABLE {Qualify(action.Constraint.Owner)} DROP CONSTRAINT {Quote(action.Constraint.Member)}"); - protected override Result> SetConstraintComment(SetConstraintComment action) => Comment($"CONSTRAINT {Quote(action.Constraint.Member)} ON {Qualify(action.Constraint.Owner)}", action.NewComment); diff --git a/src/NSchema.Postgres/Sql/PostgresSqlDialect.Tables.cs b/src/NSchema.Postgres/Sql/PostgresSqlDialect.Tables.cs index 136ab32..b2521eb 100644 --- a/src/NSchema.Postgres/Sql/PostgresSqlDialect.Tables.cs +++ b/src/NSchema.Postgres/Sql/PostgresSqlDialect.Tables.cs @@ -9,14 +9,12 @@ internal sealed partial class PostgresSqlDialect protected override Result> CreateTable(CreateTable action) { - var parts = action.Table.Columns.Select(BuildColumnDef).ToList(); - - // Only the primary key is created inline; unique/check constraints, foreign keys and indexes arrive as - // separate ALTER TABLE actions from the linearizer. - if (action.Table.PrimaryKey is { } pk) - { - parts.Add($"CONSTRAINT {Quote(pk.Name)} PRIMARY KEY ({ColumnList(pk.ColumnNames)})"); - } + // Every constraint is created inline: the shared clauses (primary key, unique, check, foreign keys) plus + // Postgres's own exclusion constraints. Only indexes arrive as separate actions. + var parts = action.Table.Columns.Select(BuildColumnDef) + .Concat(InlineConstraintClauses(action.Table)) + .Concat(action.Table.ExclusionConstraints.Select(ExclusionConstraintClause)) + .ToList(); return Statement($""" CREATE TABLE {Qualify(action.SchemaName, action.Table.Name)} ( diff --git a/tests/NSchema.Postgres.Tests/PostgresPluginTests.cs b/tests/NSchema.Postgres.Tests/PostgresPluginTests.cs index eb0f23b..06c50fe 100644 --- a/tests/NSchema.Postgres.Tests/PostgresPluginTests.cs +++ b/tests/NSchema.Postgres.Tests/PostgresPluginTests.cs @@ -97,7 +97,7 @@ public void Configure_UnknownAttribute_Fails() // Assert result.IsFailure.ShouldBeTrue(); - result.Errors.ShouldContain(e => e.Message.Contains("unknown attribute 'nonsense'")); + result.Errors.ShouldContain(e => e.Message.Contains("nonsense")); } [Fact] @@ -114,7 +114,7 @@ public void Configure_NonIntegerCommandTimeout_Fails() // Assert result.IsFailure.ShouldBeTrue(); - result.Errors.ShouldContain(e => e.Message.Contains("command_timeout must be an integer")); + result.Errors.ShouldContain(e => e.Message.Contains("command_timeout")); } [Fact] diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlDialectSnapshotTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlDialectSnapshotTests.cs index e8ef1ca..f2491d8 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlDialectSnapshotTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlDialectSnapshotTests.cs @@ -1,3 +1,4 @@ +using NSchema.Diff.Model; using NSchema.Model; using NSchema.Model.Columns; using NSchema.Model.CompositeTypes; @@ -90,6 +91,41 @@ public Task CreateTable_WithIdentityOptions() => VerifyActions( ], })); + [Fact] + public Task CreateTable_WithInlineConstraints() => VerifyActions( + // A newly-created table carries every constraint inline: primary key, unique, check, foreign key, and + // Postgres's exclusion constraint — the linearizer folds these into CREATE TABLE rather than emitting adds. + new CreateTable("public", new Table + { + Name = "bookings", + PrimaryKey = new PrimaryKey { Name = "pk_bookings", ColumnNames = ["id"] }, + Columns = + [ + new Column { Name = "id", Type = SqlType.BigInt, IsNullable = false, IsIdentity = true }, + new Column { Name = "room_id", Type = SqlType.BigInt, IsNullable = false }, + new Column { Name = "code", Type = SqlType.VarChar(20), IsNullable = false }, + new Column { Name = "guests", Type = SqlType.Int, IsNullable = false }, + new Column { Name = "during", Type = new SqlType("tsrange"), IsNullable = false }, + ], + UniqueConstraints = [new UniqueConstraint { Name = "uq_bookings_code", ColumnNames = ["code"] }], + CheckConstraints = [new CheckConstraint { Name = "ck_bookings_guests", Expression = "guests > 0" }], + ForeignKeys = + [ + new ForeignKey + { + Name = "fk_bookings_room", + ColumnNames = ["room_id"], + References = new ObjectAddress("public", "rooms"), + ReferencedColumnNames = ["id"], + OnDelete = ReferentialAction.Cascade, + }, + ], + ExclusionConstraints = + [ + new ExclusionConstraint { Name = "no_overlap", Elements = [new ExclusionElement("&&", "during")], Method = "gist" }, + ], + })); + [Fact] public Task TableLifecycle() => VerifyActions( new RenameTable(new ObjectAddress("public", "old_users"), "users"), @@ -101,9 +137,8 @@ public Task TableLifecycle() => VerifyActions( public Task ColumnOperations() => VerifyActions( new AddColumn(new ObjectAddress("public", "users"), new Column { Name = "age", Type = SqlType.Int }), new RenameColumn(new MemberAddress("public", "users", "age"), "years"), - new AlterColumnType(new MemberAddress("public", "users", "years"), SqlType.Int, SqlType.BigInt), - new AlterColumnNullability(new MemberAddress("public", "users", "years"), OldNullable: true, NewNullable: false), - new AlterColumnNullability(new MemberAddress("public", "users", "notes"), OldNullable: false, NewNullable: true), + new AlterColumn(new ObjectAddress("public", "users"), new Column { Name = "years", Type = SqlType.BigInt }, Type: new(SqlType.Int, SqlType.BigInt), Nullability: new(true, false)), + new AlterColumn(new ObjectAddress("public", "users"), new Column { Name = "notes", Type = SqlType.Text, IsNullable = true }, Nullability: new(false, true)), new SetColumnDefault(new MemberAddress("public", "users", "years"), null, "0"), new SetColumnDefault(new MemberAddress("public", "users", "years"), "0", null), new DropColumn(new ObjectAddress("public", "users"), new Column { Name = "years", Type = SqlType.BigInt })); @@ -405,8 +440,8 @@ public Task TypeMapping_CoversAllSqlTypes() => VerifyActions( // A schema-qualified user-defined type (e.g. a domain outside the search path) renders qualified. Alter(SqlType.Custom("app", "order_status"))); - private static AlterColumnType Alter(SqlType type) => - new(new MemberAddress("public", "t", "c"), SqlType.Int, type); + private static AlterColumn Alter(SqlType type) => + new(new ObjectAddress("public", "t"), new Column { Name = "c", Type = type }, Type: new(SqlType.Int, type)); // ── Scripts ─────────────────────────────────────────────────────────────── @@ -418,7 +453,7 @@ public void ExecuteScript_SqlIsEmittedVerbatimAndRunOutsideTransactionPropagates // (e.g. CREATE INDEX CONCURRENTLY, which Postgres forbids inside a transaction). var ordinary = new ChangeScript("backfill", """UPDATE public."users" SET status = 'new' WHERE "status" IS NULL -- $body$ left alone""", - ScopeSchema: "public", ChangeTrigger.AddColumn, "users", "status"); + new ChangeTarget("public", "users", "status", ChangeTrigger.AddColumn)); var concurrent = new DeploymentScript("reindex", "CREATE INDEX CONCURRENTLY i ON s.t (c)", ScopeSchema: null, DeploymentPhase.Post) { RunOutsideTransaction = true, diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlDialectTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlDialectTests.cs index 17e10af..d0eba9f 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlDialectTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlDialectTests.cs @@ -1,4 +1,5 @@ using Npgsql; +using NSchema.Diff.Model; using NSchema.Model; using NSchema.Model.Columns; using NSchema.Model.CompositeTypes; @@ -256,13 +257,13 @@ public async Task RenameColumn_RenamesColumnInTable() } [Fact] - public async Task AlterColumnType_ChangesColumnDataType() + public async Task AlterColumn_ChangesColumnDataType() { // Arrange await Exec($"""CREATE TABLE "{_schema}"."items" (id integer, value integer)"""); // Act - await Run(new AlterColumnType(Member("items", "value"), SqlType.Int, SqlType.BigInt)); + await Run(new AlterColumn(Obj("items"), new Column { Name = "value", Type = SqlType.BigInt }, Type: new(SqlType.Int, SqlType.BigInt))); // Assert var dataType = await ScalarString( @@ -271,13 +272,13 @@ public async Task AlterColumnType_ChangesColumnDataType() } [Fact] - public async Task AlterColumnNullability_MakesColumnNotNull() + public async Task AlterColumn_MakesColumnNotNull() { // Arrange await Exec($"""CREATE TABLE "{_schema}"."items" (id integer, name text)"""); // Act - await Run(new AlterColumnNullability(Member("items", "name"), OldNullable: true, NewNullable: false)); + await Run(new AlterColumn(Obj("items"), new Column { Name = "name", Type = SqlType.Text }, Nullability: new(true, false))); // Assert var isNullable = await ScalarString( @@ -286,13 +287,13 @@ public async Task AlterColumnNullability_MakesColumnNotNull() } [Fact] - public async Task AlterColumnNullability_MakesColumnNullable() + public async Task AlterColumn_MakesColumnNullable() { // Arrange await Exec($"""CREATE TABLE "{_schema}"."items" (id integer, name text NOT NULL)"""); // Act - await Run(new AlterColumnNullability(Member("items", "name"), OldNullable: false, NewNullable: true)); + await Run(new AlterColumn(Obj("items"), new Column { Name = "name", Type = SqlType.Text, IsNullable = true }, Nullability: new(false, true))); // Assert var isNullable = await ScalarString( diff --git a/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlDialectSnapshotTests.CreateTable_WithInlineConstraints.verified.txt b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlDialectSnapshotTests.CreateTable_WithInlineConstraints.verified.txt new file mode 100644 index 0000000..0fd1cdd --- /dev/null +++ b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlDialectSnapshotTests.CreateTable_WithInlineConstraints.verified.txt @@ -0,0 +1,18 @@ +[ + { + Sql: +CREATE TABLE "public"."bookings" ( + "id" bigint NOT NULL GENERATED ALWAYS AS IDENTITY, + "room_id" bigint NOT NULL, + "code" character varying(20) NOT NULL, + "guests" integer NOT NULL, + "during" tsrange NOT NULL, + CONSTRAINT "pk_bookings" PRIMARY KEY ("id"), + CONSTRAINT "uq_bookings_code" UNIQUE ("code"), + CONSTRAINT "ck_bookings_guests" CHECK (guests > 0), + CONSTRAINT "fk_bookings_room" FOREIGN KEY ("room_id") REFERENCES "public"."rooms" ("id") ON DELETE CASCADE, + CONSTRAINT "no_overlap" EXCLUDE USING gist ("during" WITH &&) +), + RunOutsideTransaction: false + } +] \ No newline at end of file