diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7ee7e33..1fd785e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/Directory.Packages.props b/Directory.Packages.props
index beac33f..3077509 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -7,7 +7,7 @@
-
+
diff --git a/src/NSchema.Postgres/NSchema.Postgres.csproj b/src/NSchema.Postgres/NSchema.Postgres.csproj
index c8ed1c2..ce75d0f 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.7
+ 5.0.0-alpha.8
$(Version.Split('-')[0])
$(Version.Split('-')[0])
true
diff --git a/src/NSchema.Postgres/NSchemaApplicationBuilderExtensions.cs b/src/NSchema.Postgres/NSchemaApplicationBuilderExtensions.cs
index 476122f..f7908ff 100644
--- a/src/NSchema.Postgres/NSchemaApplicationBuilderExtensions.cs
+++ b/src/NSchema.Postgres/NSchemaApplicationBuilderExtensions.cs
@@ -45,17 +45,24 @@ public NSchemaApplicationBuilder UsePostgres(Action
- /// 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.
///
/// The instance, allowing for method chaining.
public NSchemaApplicationBuilder UsePostgres() => builder
.UseDatabaseIntrospector()
- .UsePostgresDialect();
+ .UsePostgresDialect()
+ .UsePostgresEquivalence();
///
/// Configures the NSchema application to render SQL with the PostgreSQL dialect.
///
/// The instance, allowing for method chaining.
public NSchemaApplicationBuilder UsePostgresDialect() => builder.UseSqlDialect();
+
+ ///
+ /// Configures the NSchema application to compare schemas with the PostgreSQL equivalence rules.
+ ///
+ /// The instance, allowing for method chaining.
+ public NSchemaApplicationBuilder UsePostgresEquivalence() => builder.UseSqlEquivalence();
}
}
diff --git a/src/NSchema.Postgres/Sql/PostgresDatabaseIntrospector.cs b/src/NSchema.Postgres/Sql/PostgresDatabaseIntrospector.cs
index 6640369..afd6c88 100644
--- a/src/NSchema.Postgres/Sql/PostgresDatabaseIntrospector.cs
+++ b/src/NSchema.Postgres/Sql/PostgresDatabaseIntrospector.cs
@@ -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);
}
@@ -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),
};
}
diff --git a/src/NSchema.Postgres/Sql/PostgresSqlEquivalence.cs b/src/NSchema.Postgres/Sql/PostgresSqlEquivalence.cs
new file mode 100644
index 0000000..f5dc17f
--- /dev/null
+++ b/src/NSchema.Postgres/Sql/PostgresSqlEquivalence.cs
@@ -0,0 +1,109 @@
+using System.Globalization;
+using NSchema.Diff.Backends;
+using NSchema.Model;
+using NSchema.Model.Columns;
+
+namespace NSchema.Postgres.Sql;
+
+///
+/// Postgres equivalence rules so spellings the catalog and a project may disagree on compare as equal.
+///
+public sealed class PostgresSqlEquivalence : SqlEquivalence
+{
+ ///
+ ///
+ /// Postgres stores a literal default with an explicit cast — DEFAULT 'internal' on a text column
+ /// reads back as 'internal'::text, DEFAULT -1 as '-1'::integer — so both sides fold
+ /// the cast away (unquoting the literal when the cast target is numeric) before the cosmetic comparison.
+ ///
+ public override IEqualityComparer Defaults { get; } = new DefaultEquality();
+
+ ///
+ ///
+ /// A type in public or pg_catalog is addressable bare, so the qualifier folds away;
+ /// a type in any other schema keeps it.
+ ///
+ public override IEqualityComparer Types { get; } = new TypeEquality();
+
+ ///
+ /// 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.
+ ///
+ 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 "::" 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
+ {
+ 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
+ {
+ 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;
+ }
+}
diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresDatabaseIntrospectorTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresDatabaseIntrospectorTests.cs
index 5bfd766..a0fbd44 100644
--- a/tests/NSchema.Postgres.Tests/Sql/PostgresDatabaseIntrospectorTests.cs
+++ b/tests/NSchema.Postgres.Tests/Sql/PostgresDatabaseIntrospectorTests.cs
@@ -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 ───────────────────────────────────────────────────
@@ -821,10 +822,10 @@ 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
@@ -832,7 +833,40 @@ public async Task GetDatabase_BuiltInFallthroughColumn_MappedUnqualified()
.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 ─────────────────────────────────────────────────────────────
diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlEquivalenceTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlEquivalenceTests.cs
new file mode 100644
index 0000000..eca2f2b
--- /dev/null
+++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlEquivalenceTests.cs
@@ -0,0 +1,110 @@
+using NSchema.Model;
+using NSchema.Model.Columns;
+using NSchema.Postgres.Sql;
+
+namespace NSchema.Postgres.Tests.Sql;
+
+///
+/// Pins : the spellings Postgres and a project may legitimately disagree
+/// on — a stored literal's cast, a public/pg_catalog type qualifier — compare equal in either
+/// direction, while real differences survive. Pure unit tests — no Docker.
+///
+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));
+ }
+}