From 4f2d287b809700122f926625d7fdba7eabba911c Mon Sep 17 00:00:00 2001 From: Tom Wolfe Date: Tue, 21 Jul 2026 01:41:17 +0100 Subject: [PATCH 1/2] fix: quote types. --- CHANGELOG.md | 2 ++ src/NSchema.Postgres/Models/ColumnRow.cs | 1 + .../Sql/PostgresDatabaseIntrospector.cs | 16 ++++++++++------ src/NSchema.Postgres/Sql/PostgresSqlDialect.cs | 8 ++++---- .../Sql/PostgresDatabaseIntrospectorTests.cs | 7 ++++--- ...ts.TypeMapping_CoversAllSqlTypes.verified.txt | 2 +- 6 files changed, 22 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 661cf68..7ee7e33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ v5.0 tracks the NSchema.Core 5.0 rearchitecture. The provider's behaviour is unc - Identifiers everywhere in generated SQL are quoted per the core's case-sensitive identifier model; this now includes role names and trigger function references. - 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. ## [4.3.0] - 2026-07-09 diff --git a/src/NSchema.Postgres/Models/ColumnRow.cs b/src/NSchema.Postgres/Models/ColumnRow.cs index 52df046..3ab4014 100644 --- a/src/NSchema.Postgres/Models/ColumnRow.cs +++ b/src/NSchema.Postgres/Models/ColumnRow.cs @@ -6,6 +6,7 @@ internal sealed record ColumnRow( string ColumnName, string DataType, string UdtName, + string? UdtSchema, string? DomainSchema, string? DomainName, int? MaxLength, diff --git a/src/NSchema.Postgres/Sql/PostgresDatabaseIntrospector.cs b/src/NSchema.Postgres/Sql/PostgresDatabaseIntrospector.cs index 6ebb34f..4cfa37f 100644 --- a/src/NSchema.Postgres/Sql/PostgresDatabaseIntrospector.cs +++ b/src/NSchema.Postgres/Sql/PostgresDatabaseIntrospector.cs @@ -127,7 +127,8 @@ private static async Task> QueryColumns(NpgsqlConnection conn, s seq.seqstart AS identity_start, seq.seqmin AS identity_min_value, seq.seqincrement AS identity_increment, - CASE WHEN c.is_generated = 'ALWAYS' THEN c.generation_expression END AS generation_expression + CASE WHEN c.is_generated = 'ALWAYS' THEN c.generation_expression END AS generation_expression, + c.udt_schema FROM information_schema.columns c LEFT JOIN pg_namespace n ON n.nspname = c.table_schema LEFT JOIN pg_class t ON t.relname = c.table_name @@ -158,6 +159,7 @@ AND c.table_schema NOT LIKE 'pg\_temp%' ESCAPE '\' ColumnName: reader.GetString(2), DataType: reader.GetString(3), UdtName: reader.GetString(4), + UdtSchema: reader.IsDBNull(17) ? null : reader.GetString(17), DomainSchema: reader.IsDBNull(5) ? null : reader.GetString(5), DomainName: reader.IsDBNull(6) ? null : reader.GetString(6), MaxLength: reader.IsDBNull(7) ? null : reader.GetInt32(7), @@ -1691,7 +1693,7 @@ private static CompositeType MapCompositeType(CompositeTypeRow row, List f.Schema == row.Schema && f.TypeName == row.Name) .OrderBy(f => f.OrdinalPosition) - .Select(f => new CompositeField(f.FieldName, MapSqlType(f.DataType, f.UdtName, domainSchema: null, domainName: null, f.MaxLength, f.Precision, f.Scale))) + .Select(f => new CompositeField(f.FieldName, MapSqlType(f.DataType, f.UdtName, udtSchema: null, domainSchema: null, domainName: null, f.MaxLength, f.Precision, f.Scale))) .ToList(); return new CompositeType { Name = row.Name, Fields = fields, Comment = row.Comment }; } @@ -1704,7 +1706,7 @@ private static DomainType MapDomain(DomainRow row, List allCheck return new DomainType { Name = row.Name, - DataType = MapSqlType(row.DataType, row.UdtName, domainSchema: null, domainName: null, row.MaxLength, row.Precision, row.Scale), + DataType = MapSqlType(row.DataType, row.UdtName, udtSchema: null, domainSchema: null, domainName: null, row.MaxLength, row.Precision, row.Scale), Default = row.Default, NotNull = row.NotNull, Checks = [.. checks], @@ -1784,7 +1786,7 @@ private static (IndexSort Sort, IndexNulls Nulls) DecodeIndexOption(int option) private static Column MapColumn(ColumnRow row, Dictionary<(string, string, string), string?> columnComments) => new() { Name = row.ColumnName, - Type = MapSqlType(row.DataType, row.UdtName, row.DomainSchema, row.DomainName, row.MaxLength, row.NumericPrecision, row.NumericScale), + Type = MapSqlType(row.DataType, row.UdtName, row.UdtSchema, row.DomainSchema, row.DomainName, row.MaxLength, row.NumericPrecision, row.NumericScale), IsNullable = row.IsNullable, IsIdentity = row.IsIdentity, DefaultExpression = row.DefaultExpression, @@ -1795,7 +1797,7 @@ private static (IndexSort Sort, IndexNulls Nulls) DecodeIndexOption(int option) Comment = columnComments.GetValueOrDefault((row.TableSchema, row.TableName, row.ColumnName)), }; - private static SqlType MapSqlType(string dataType, string udtName, string? domainSchema, string? domainName, int? maxLength, int? precision, int? scale) + private static SqlType MapSqlType(string dataType, string udtName, string? udtSchema, string? domainSchema, string? domainName, int? maxLength, int? precision, int? scale) { // For a column declared against a domain, information_schema returns the domain's // base type in data_type (e.g. "text"). Preserve the domain name so the schema @@ -1825,7 +1827,9 @@ private static SqlType MapSqlType(string dataType, string udtName, string? domai "timestamp with time zone" => SqlType.DateTimeOffset, "uuid" => SqlType.Guid, "bytea" => SqlType.VarBinary(), - _ => SqlType.Custom(udtName), + // A user-defined type (enum, composite, …): preserve its schema so the type round-trips, collapsing + // the default schema the same way a domain does. + _ => udtSchema is null or "public" ? SqlType.Custom(udtName) : SqlType.Custom(udtSchema, udtName), }; } diff --git a/src/NSchema.Postgres/Sql/PostgresSqlDialect.cs b/src/NSchema.Postgres/Sql/PostgresSqlDialect.cs index 3341d53..0d043b2 100644 --- a/src/NSchema.Postgres/Sql/PostgresSqlDialect.cs +++ b/src/NSchema.Postgres/Sql/PostgresSqlDialect.cs @@ -56,7 +56,7 @@ private static string BuildIdentityClause(IdentityOptions? options) // ── Type mapping ────────────────────────────────────────────────────────── - private static string ToPostgresType(SqlType type) => type.Name.Value switch + private string ToPostgresType(SqlType type) => type.Name.Value switch { "boolean" => "boolean", "tinyint" => "smallint", @@ -75,8 +75,8 @@ private static string BuildIdentityClause(IdentityOptions? options) "datetimeoffset" => "timestamptz", "guid" => "uuid", "binary" or "varbinary" => "bytea", - // Any other name is a database-specific or user-defined type (e.g. citext, jsonb, a domain); - // emit it verbatim, qualified when the model carries a schema. - _ => type.Schema is { } schema ? $"{schema.Value}.{type.Name.Value}" : type.Name.Value, + // Any other name is a database-specific or user-defined type (e.g. citext, jsonb, a domain); a + // schema-qualified type is quoted (a user-defined type in another schema), an unqualified one emitted bare. + _ => type.Schema is { } schema ? $"{Quote(schema)}.{Quote(type.Name)}" : type.Name.Value, }; } diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresDatabaseIntrospectorTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresDatabaseIntrospectorTests.cs index 12a4540..53fdeb3 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresDatabaseIntrospectorTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresDatabaseIntrospectorTests.cs @@ -803,9 +803,10 @@ await Exec($""" } [Fact] - public async Task GetDatabase_EnumColumn_MappedAsCustomType() + public async Task GetDatabase_EnumColumn_MappedAsCustomType_PreservingSchema() { - // Arrange — a column typed as a user-defined enum comes back through MapSqlType's fall-through. + // Arrange — a column typed as a user-defined enum comes back through MapSqlType's fall-through; its + // udt_schema is preserved so a type in another schema round-trips. await Exec($""" CREATE TYPE "{_schema}".order_status AS ENUM ('draft', 'active'); CREATE TABLE "{_schema}".orders (status "{_schema}".order_status NOT NULL); @@ -816,7 +817,7 @@ await Exec($""" .Schemas[0].Tables.ShouldHaveSingleItem().Columns.ShouldHaveSingleItem(); // Assert - column.Type.ShouldBe(SqlType.Custom("order_status")); + column.Type.ShouldBe(SqlType.Custom(_schema, "order_status")); } // ── Sequences ───────────────────────────────────────────────────────────── diff --git a/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlDialectSnapshotTests.TypeMapping_CoversAllSqlTypes.verified.txt b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlDialectSnapshotTests.TypeMapping_CoversAllSqlTypes.verified.txt index f403a52..d4a8b50 100644 --- a/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlDialectSnapshotTests.TypeMapping_CoversAllSqlTypes.verified.txt +++ b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlDialectSnapshotTests.TypeMapping_CoversAllSqlTypes.verified.txt @@ -92,7 +92,7 @@ RunOutsideTransaction: false }, { - Sql: ALTER TABLE "public"."t" ALTER COLUMN "c" TYPE app.order_status, + Sql: ALTER TABLE "public"."t" ALTER COLUMN "c" TYPE "app"."order_status", RunOutsideTransaction: false } ] \ No newline at end of file From 26dc94f2a83d493d764ff5a3ccf3fbcb26de273b Mon Sep 17 00:00:00 2001 From: Tom Wolfe Date: Tue, 21 Jul 2026 01:41:35 +0100 Subject: [PATCH 2/2] build: bump version. --- src/NSchema.Postgres/NSchema.Postgres.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NSchema.Postgres/NSchema.Postgres.csproj b/src/NSchema.Postgres/NSchema.Postgres.csproj index e6b6fa5..408d1c6 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.2 + 5.0.0-alpha.3 $(Version.Split('-')[0]) $(Version.Split('-')[0]) true