Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.300" />
<PackageVersion Include="Npgsql.DependencyInjection" Version="10.0.3" />
<PackageVersion Include="NSchema.Core" Version="5.0.0-alpha.1" />
<PackageVersion Include="NSchema.Core" Version="5.0.0-alpha.2" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageVersion Include="Npgsql" Version="10.0.3" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
Expand All @@ -17,4 +17,4 @@
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
</ItemGroup>
</Project>
</Project>
2 changes: 1 addition & 1 deletion src/NSchema.Postgres/NSchema.Postgres.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<Version>5.0.0-alpha.1</Version>
<Version>5.0.0-alpha.2</Version>
<AssemblyVersion>$(Version.Split('-')[0])</AssemblyVersion>
<FileVersion>$(Version.Split('-')[0])</FileVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
Expand Down
67 changes: 22 additions & 45 deletions src/NSchema.Postgres/PostgresPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ public sealed class PostgresPlugin : INSchemaDatabasePlugin
private const string EnvUsername = "NSCHEMA_POSTGRES_USERNAME";
private const string EnvPassword = "NSCHEMA_POSTGRES_PASSWORD";

/// <summary>The options a DATABASE statement binds onto.</summary>
private sealed class PostgresOptions
{
public string? ConnectionString { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
public int? CommandTimeout { get; set; }
}

/// <inheritdoc />
public string GetScaffoldTemplate(ScaffoldContext context) =>
$"""
Expand Down Expand Up @@ -41,61 +50,29 @@ CONSTRAINT widgets_pkey PRIMARY KEY (id)
/// <inheritdoc />
public Result Configure(NSchemaApplicationBuilder builder, PluginConfig config)
{
var errors = new List<Diagnostic>();
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<PostgresOptions>();
var diagnostics = new List<Diagnostic>(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 =>
Expand All @@ -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);
}
}
17 changes: 10 additions & 7 deletions src/NSchema.Postgres/Sql/PostgresSqlDialect.Columns.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ internal sealed partial class PostgresSqlDialect
protected override Result<IReadOnlyList<SqlStatement>> AddColumn(AddColumn action) =>
Statement($"ALTER TABLE {Qualify(action.Table)} ADD COLUMN {BuildColumnDef(action.Column)}");

protected override Result<IReadOnlyList<SqlStatement>> AlterColumnType(AlterColumnType action) =>
Statement($"ALTER TABLE {Qualify(action.Column.Owner)} ALTER COLUMN {Quote(action.Column.Member)} TYPE {ToPostgresType(action.NewType)}");

protected override Result<IReadOnlyList<SqlStatement>> 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<IReadOnlyList<SqlStatement>> 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<IReadOnlyList<SqlStatement>> AlterIdentitySequence(AlterIdentitySequence action)
{
Expand Down
15 changes: 9 additions & 6 deletions src/NSchema.Postgres/Sql/PostgresSqlDialect.Constraints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,21 @@ internal sealed partial class PostgresSqlDialect
{
// ── Constraints ───────────────────────────────────────────────────────────

protected override Result<IReadOnlyList<SqlStatement>> AddExclusionConstraint(AddExclusionConstraint action)
protected override Result<IReadOnlyList<SqlStatement>> AddExclusionConstraint(AddExclusionConstraint action) =>
Statement($"ALTER TABLE {Qualify(action.Table)} ADD {ExclusionConstraintClause(action.ExclusionConstraint)}");

protected override Result<IReadOnlyList<SqlStatement>> 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<IReadOnlyList<SqlStatement>> DropExclusionConstraint(DropExclusionConstraint action) =>
Statement($"ALTER TABLE {Qualify(action.Constraint.Owner)} DROP CONSTRAINT {Quote(action.Constraint.Member)}");

protected override Result<IReadOnlyList<SqlStatement>> SetConstraintComment(SetConstraintComment action) =>
Comment($"CONSTRAINT {Quote(action.Constraint.Member)} ON {Qualify(action.Constraint.Owner)}", action.NewComment);

Expand Down
14 changes: 6 additions & 8 deletions src/NSchema.Postgres/Sql/PostgresSqlDialect.Tables.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,12 @@ internal sealed partial class PostgresSqlDialect

protected override Result<IReadOnlyList<SqlStatement>> 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)} (
Expand Down
4 changes: 2 additions & 2 deletions tests/NSchema.Postgres.Tests/PostgresPluginTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using NSchema.Diff.Model;
using NSchema.Model;
using NSchema.Model.Columns;
using NSchema.Model.CompositeTypes;
Expand Down Expand Up @@ -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"),
Expand All @@ -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 }));
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────────────────

Expand All @@ -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,
Expand Down
13 changes: 7 additions & 6 deletions tests/NSchema.Postgres.Tests/Sql/PostgresSqlDialectTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Npgsql;
using NSchema.Diff.Model;
using NSchema.Model;
using NSchema.Model.Columns;
using NSchema.Model.CompositeTypes;
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
]