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