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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ v5.0 tracks the NSchema.Core 5.0 rearchitecture. The provider's behaviour is unc
- Revoking table privileges now revokes the specific privileges the plan names rather than `ALL PRIVILEGES`.
- `ON DELETE NO ACTION` / `ON UPDATE NO ACTION` are no longer spelled out on added foreign keys (they are the engine default).
- Schema-qualified user-defined type names are now quoted in generated SQL: a column typed as, say, `app.order_status` renders as `"app"."order_status"` rather than bare (an unqualified type name is still emitted as-is).
- A user-defined type (enum, composite, …) in a non-default schema is now read back schema-qualified during introspection instead of losing its schema, so it no longer produces a spurious diff against a schema-qualified declaration. A type in `public` still reads back unqualified.
- A user-defined type (enum, composite, …) is now read back schema-qualified during introspection instead of losing its schema, so it no longer produces a spurious diff against a schema-qualified declaration.
- **Postgres equivalence rules.** `UsePostgres` now also registers `PostgresSqlEquivalence` (standalone: `UsePostgresEquivalence`) into the core's comparison seam, so spellings the catalog and a project may legitimately disagree on compare equal in either direction. Introspection still captures exactly what the catalog reports.

## [4.3.0] - 2026-07-09

Expand Down
2 changes: 1 addition & 1 deletion 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.301" />
<PackageVersion Include="Npgsql.DependencyInjection" Version="10.0.3" />
<PackageVersion Include="NSchema.Core" Version="5.0.0-alpha.8" />
<PackageVersion Include="NSchema.Core" Version="5.0.0-alpha.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="Npgsql" Version="10.0.3" />
<PackageVersion Include="NSubstitute" Version="6.0.0" />
Expand Down
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.7</Version>
<Version>5.0.0-alpha.8</Version>
<AssemblyVersion>$(Version.Split('-')[0])</AssemblyVersion>
<FileVersion>$(Version.Split('-')[0])</FileVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
Expand Down
11 changes: 9 additions & 2 deletions src/NSchema.Postgres/NSchemaApplicationBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,24 @@ public NSchemaApplicationBuilder UsePostgres(Action<IServiceProvider, NpgsqlData
}

/// <summary>
/// Configures NSchema to use PostgreSQL as the database provider by registering the introspector and SQL dialect.
/// Configures NSchema to use PostgreSQL as the database provider by registering the introspector, SQL dialect and equivalence rules.
/// </summary>
/// <returns>The <see cref="NSchemaApplicationBuilder"/> instance, allowing for method chaining.</returns>
public NSchemaApplicationBuilder UsePostgres() => builder
.UseDatabaseIntrospector<PostgresDatabaseIntrospector>()
.UsePostgresDialect();
.UsePostgresDialect()
.UsePostgresEquivalence();

/// <summary>
/// Configures the NSchema application to render SQL with the PostgreSQL dialect.
/// </summary>
/// <returns>The <see cref="NSchemaApplicationBuilder"/> instance, allowing for method chaining.</returns>
public NSchemaApplicationBuilder UsePostgresDialect() => builder.UseSqlDialect<PostgresSqlDialect>();

/// <summary>
/// Configures the NSchema application to compare schemas with the PostgreSQL equivalence rules.
/// </summary>
/// <returns>The <see cref="NSchemaApplicationBuilder"/> instance, allowing for method chaining.</returns>
public NSchemaApplicationBuilder UsePostgresEquivalence() => builder.UseSqlEquivalence<PostgresSqlEquivalence>();
}
}
9 changes: 4 additions & 5 deletions src/NSchema.Postgres/Sql/PostgresDatabaseIntrospector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1807,7 +1807,7 @@ private static SqlType MapSqlType(string dataType, string udtName, string? udtSc
// round-trips faithfully.
if (domainName is not null)
{
return domainSchema is null or "public"
return domainSchema is null
? SqlType.Custom(domainName)
: SqlType.Custom(domainSchema, domainName);
}
Expand All @@ -1830,10 +1830,9 @@ private static SqlType MapSqlType(string dataType, string udtName, string? udtSc
"timestamp with time zone" => SqlType.DateTimeOffset,
"uuid" => SqlType.Guid,
"bytea" => SqlType.VarBinary(),
// A user-defined type (enum, composite, …): preserve its schema so the type round-trips, collapsing
// the default schema the same way a domain does. Built-ins outside the switch (jsonb, inet, …)
// also land here with udt_schema = pg_catalog, so collapse that too.
_ => udtSchema is null or "public" or "pg_catalog" ? SqlType.Custom(udtName) : SqlType.Custom(udtSchema, udtName),
// A user-defined type (enum, composite, …) or a built-in outside the switch (jsonb, inet, …):
// captured verbatim, qualifier included — PostgresSqlEquivalence decides what a qualifier means.
_ => udtSchema is null ? SqlType.Custom(udtName) : SqlType.Custom(udtSchema, udtName),
};
}

Expand Down
109 changes: 109 additions & 0 deletions src/NSchema.Postgres/Sql/PostgresSqlEquivalence.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using System.Globalization;
using NSchema.Diff.Backends;
using NSchema.Model;
using NSchema.Model.Columns;

namespace NSchema.Postgres.Sql;

/// <summary>
/// Postgres equivalence rules so spellings the catalog and a project may disagree on compare as equal.
/// </summary>
public sealed class PostgresSqlEquivalence : SqlEquivalence
{
/// <inheritdoc/>
/// <remarks>
/// Postgres stores a literal default with an explicit cast — <c>DEFAULT 'internal'</c> on a text column
/// reads back as <c>'internal'::text</c>, <c>DEFAULT -1</c> as <c>'-1'::integer</c> — so both sides fold
/// the cast away (unquoting the literal when the cast target is numeric) before the cosmetic comparison.
/// </remarks>
public override IEqualityComparer<SqlDefaultExpression> Defaults { get; } = new DefaultEquality();

/// <inheritdoc/>
/// <remarks>
/// A type in <c>public</c> or <c>pg_catalog</c> is addressable bare, so the qualifier folds away;
/// a type in any other schema keeps it.
/// </remarks>
public override IEqualityComparer<SqlType> Types { get; } = new TypeEquality();

/// <summary>
/// Folds the cast Postgres adds when it stores a literal default: the whole expression must be a single
/// quoted literal cast to a type name; anything larger is left untouched.
/// </summary>
private static string? FoldDefaultExpression(string? expression)
{
if (expression is null || !expression.StartsWith('\''))
{
return expression;
}

var end = FindLiteralEnd(expression);
if (end < 0 || !IsCastToTypeName(expression, end + 1, out var target))
{
return expression;
}

var literal = expression[..(end + 1)];
return IsNumericType(target) && IsNumericLiteral(literal[1..^1]) ? literal[1..^1] : literal;
}

// Index of the literal's closing quote, honouring '' escapes; -1 if unterminated.
private static int FindLiteralEnd(string expression)
{
for (var i = 1; i < expression.Length; i++)
{
if (expression[i] != '\'')
{
continue;
}
if (i + 1 < expression.Length && expression[i + 1] == '\'')
{
i++;
continue;
}
return i;
}
return -1;
}

// True when the remainder is "::<type name>" and nothing else — an operator or second literal means the
// cast is part of a larger expression, which is left untouched.
private static bool IsCastToTypeName(string expression, int start, out string target)
{
target = "";
if (start + 2 >= expression.Length || expression[start] != ':' || expression[start + 1] != ':')
{
return false;
}
target = expression[(start + 2)..];
return target.All(c => char.IsLetterOrDigit(c) || c is '_' or ' ' or '.' or '"' or '[' or ']' or '(' or ')' or ',');
}

private static bool IsNumericType(string target) =>
target is "smallint" or "integer" or "bigint" or "real" or "double precision"
|| target.StartsWith("numeric", StringComparison.Ordinal);

private static bool IsNumericLiteral(string content) =>
decimal.TryParse(content, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out _);

private sealed class DefaultEquality : IEqualityComparer<SqlDefaultExpression>
{
public bool Equals(SqlDefaultExpression? x, SqlDefaultExpression? y) =>
SqlDefaultExpression.CosmeticComparer.Equals(Fold(x), Fold(y));

public int GetHashCode(SqlDefaultExpression obj) =>
SqlDefaultExpression.CosmeticComparer.GetHashCode(Fold(obj)!);

private static SqlDefaultExpression? Fold(SqlDefaultExpression? value) =>
value is null ? null : new SqlDefaultExpression(FoldDefaultExpression(value.Value)!);
}

private sealed class TypeEquality : IEqualityComparer<SqlType>
{
public bool Equals(SqlType? x, SqlType? y) => object.Equals(Fold(x), Fold(y));

public int GetHashCode(SqlType obj) => Fold(obj)!.GetHashCode();

private static SqlType? Fold(SqlType? type) =>
type?.Schema?.Value is "public" or "pg_catalog" ? type with { Schema = null } : type;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,9 @@ email CITEXT NOT NULL
var emailCol = (await Introspect(_schema))
.Schemas[0].Tables[0].Columns.Single(c => c.Name == "email");

// Assert
emailCol.Type.ShouldBe(SqlType.Custom("citext"));
// Assert — captured with the extension's public qualifier, equivalent to the declared bare name.
emailCol.Type.ShouldBe(SqlType.Custom("public", "citext"));
new PostgresSqlEquivalence().Types.Equals(emailCol.Type, SqlType.Custom("citext")).ShouldBeTrue();
}

// ── Identity & defaults ───────────────────────────────────────────────────
Expand Down Expand Up @@ -821,18 +822,51 @@ await Exec($"""
}

[Fact]
public async Task GetDatabase_BuiltInFallthroughColumn_MappedUnqualified()
public async Task GetDatabase_BuiltInFallthroughColumn_CapturedQualified_AndEquivalentToBareName()
{
// Arrange — jsonb also hits MapSqlType's fall-through, but its pg_catalog udt_schema
// must collapse so the type reads as declared.
// Arrange — jsonb hits MapSqlType's fall-through: captured verbatim with its pg_catalog qualifier,
// which the equivalence rules fold when comparing against the declared bare name.
await Exec($"""CREATE TABLE "{_schema}".events (payload jsonb NOT NULL);""");

// Act
var column = (await Introspect(_schema))
.Schemas[0].Tables.ShouldHaveSingleItem().Columns.ShouldHaveSingleItem();

// Assert
column.Type.ShouldBe(SqlType.Custom("jsonb"));
column.Type.ShouldBe(SqlType.Custom("pg_catalog", "jsonb"));
new PostgresSqlEquivalence().Types.Equals(column.Type, SqlType.Custom("jsonb")).ShouldBeTrue();
}

[Fact]
public async Task GetDatabase_LiteralDefaults_CapturedVerbatim_AndEquivalentToDeclared()
{
// Arrange — Postgres stores literal defaults with explicit casts; the capture keeps the catalog's
// form and the equivalence rules make it compare equal to the declared form, so a plan after
// apply converges.
await Exec($$"""
CREATE TABLE "{{_schema}}".profiles (
scope_type text NOT NULL DEFAULT 'internal',
priority integer NOT NULL DEFAULT -1,
payload jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
""");

// Act
var columns = (await Introspect(_schema))
.Schemas[0].Tables.ShouldHaveSingleItem().Columns;

// Assert — captured exactly as the catalog reports…
columns.Single(c => c.Name == "scope_type").DefaultExpression!.Value.ShouldBe("'internal'::text");
columns.Single(c => c.Name == "priority").DefaultExpression!.Value.ShouldBe("'-1'::integer");
columns.Single(c => c.Name == "payload").DefaultExpression!.Value.ShouldBe("'{}'::jsonb");
columns.Single(c => c.Name == "created_at").DefaultExpression!.Value.ShouldBe("now()");

// …and equivalent to what the project declares.
var defaults = new PostgresSqlEquivalence().Defaults;
defaults.Equals(columns.Single(c => c.Name == "scope_type").DefaultExpression, new SqlDefaultExpression("'internal'")).ShouldBeTrue();
defaults.Equals(columns.Single(c => c.Name == "priority").DefaultExpression, new SqlDefaultExpression("-1")).ShouldBeTrue();
defaults.Equals(columns.Single(c => c.Name == "payload").DefaultExpression, new SqlDefaultExpression("'{}'")).ShouldBeTrue();
}

// ── Sequences ─────────────────────────────────────────────────────────────
Expand Down
110 changes: 110 additions & 0 deletions tests/NSchema.Postgres.Tests/Sql/PostgresSqlEquivalenceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using NSchema.Model;
using NSchema.Model.Columns;
using NSchema.Postgres.Sql;

namespace NSchema.Postgres.Tests.Sql;

/// <summary>
/// Pins <see cref="PostgresSqlEquivalence"/>: the spellings Postgres and a project may legitimately disagree
/// on — a stored literal's cast, a <c>public</c>/<c>pg_catalog</c> type qualifier — compare equal in either
/// direction, while real differences survive. Pure unit tests — no Docker.
/// </summary>
public sealed class PostgresSqlEquivalenceTests
{
private readonly PostgresSqlEquivalence _sut = new();

// ── Defaults ──────────────────────────────────────────────────────────────

[Fact]
public void Defaults_StringLiteralCast_MatchesBareLiteral()
=> AssertDefaultsEqual("'internal'::text", "'internal'");

[Fact]
public void Defaults_MultiWordTypeCast_MatchesBareLiteral()
=> AssertDefaultsEqual("'internal'::character varying", "'internal'");

[Fact]
public void Defaults_QualifiedEnumCast_MatchesBareLiteral()
=> AssertDefaultsEqual("'draft'::identity.scope_type", "'draft'");

[Fact]
public void Defaults_ArrayTypeCast_MatchesBareLiteral()
=> AssertDefaultsEqual("'{}'::text[]", "'{}'");

[Fact]
public void Defaults_EscapedQuoteInLiteral_MatchesBareLiteral()
=> AssertDefaultsEqual("'it''s'::text", "'it''s'");

[Fact]
public void Defaults_NumericCast_MatchesBareNumber()
// DEFAULT -1 round-trips as '-1'::integer.
=> AssertDefaultsEqual("'-1'::integer", "-1");

[Fact]
public void Defaults_DecimalCast_MatchesBareNumber()
=> AssertDefaultsEqual("'0.5'::numeric", "0.5");

[Fact]
public void Defaults_BothSidesSpellTheCast_Match()
=> AssertDefaultsEqual("'internal'::text", "'internal'::text");

[Fact]
public void Defaults_NumericLookingTextDefault_DoesNotMatchBareNumber()
// '5'::text is a string default that happens to look numeric — the quotes are real.
=> _sut.Defaults.Equals(new SqlDefaultExpression("'5'::text"), new SqlDefaultExpression("5"))
.ShouldBeFalse();

[Fact]
public void Defaults_CastInsideLargerExpression_IsNotFolded()
=> _sut.Defaults.Equals(new SqlDefaultExpression("'a'::text || 'b'::text"), new SqlDefaultExpression("'a' || 'b'"))
.ShouldBeFalse();

[Fact]
public void Defaults_DifferentLiterals_DoNotMatch()
=> _sut.Defaults.Equals(new SqlDefaultExpression("'internal'::text"), new SqlDefaultExpression("'external'"))
.ShouldBeFalse();

[Fact]
public void Defaults_TrailingTerminator_StillFolds()
// The cosmetic baseline still applies beneath the cast folding.
=> AssertDefaultsEqual("now() ;", "now()");

// ── Types ─────────────────────────────────────────────────────────────────

[Fact]
public void Types_PgCatalogQualifier_MatchesBareName()
=> AssertTypesEqual(SqlType.Custom("pg_catalog", "jsonb"), SqlType.Custom("jsonb"));

[Fact]
public void Types_PublicQualifier_MatchesBareName()
=> AssertTypesEqual(SqlType.Custom("public", "order_status"), SqlType.Custom("order_status"));

[Fact]
public void Types_UserSchemaQualifier_IsSignificant()
=> _sut.Types.Equals(SqlType.Custom("app", "order_status"), SqlType.Custom("order_status"))
.ShouldBeFalse();

[Fact]
public void Types_DifferentNames_DoNotMatch()
=> _sut.Types.Equals(SqlType.Custom("pg_catalog", "jsonb"), SqlType.Custom("json"))
.ShouldBeFalse();

[Fact]
public void Types_BuiltIn_MatchesItself()
=> AssertTypesEqual(SqlType.VarChar(255), SqlType.VarChar(255));

private void AssertDefaultsEqual(string x, string y)
{
// Equivalence is symmetric — neither side's spelling is the sanctioned one — and equal values hash equal.
_sut.Defaults.Equals(new SqlDefaultExpression(x), new SqlDefaultExpression(y)).ShouldBeTrue();
_sut.Defaults.Equals(new SqlDefaultExpression(y), new SqlDefaultExpression(x)).ShouldBeTrue();
_sut.Defaults.GetHashCode(new SqlDefaultExpression(x)).ShouldBe(_sut.Defaults.GetHashCode(new SqlDefaultExpression(y)));
}

private void AssertTypesEqual(SqlType x, SqlType y)
{
_sut.Types.Equals(x, y).ShouldBeTrue();
_sut.Types.Equals(y, x).ShouldBeTrue();
_sut.Types.GetHashCode(x).ShouldBe(_sut.Types.GetHashCode(y));
}
}