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/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
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