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)); + } +}