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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/NSchema.Postgres/Models/ColumnRow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ internal sealed record ColumnRow(
string ColumnName,
string DataType,
string UdtName,
string? UdtSchema,
string? DomainSchema,
string? DomainName,
int? MaxLength,
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.2</Version>
<Version>5.0.0-alpha.3</Version>
<AssemblyVersion>$(Version.Split('-')[0])</AssemblyVersion>
<FileVersion>$(Version.Split('-')[0])</FileVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
Expand Down
16 changes: 10 additions & 6 deletions src/NSchema.Postgres/Sql/PostgresDatabaseIntrospector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@ private static async Task<List<ColumnRow>> 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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -1691,7 +1693,7 @@ private static CompositeType MapCompositeType(CompositeTypeRow row, List<Composi
var fields = allFields
.Where(f => 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 };
}
Expand All @@ -1704,7 +1706,7 @@ private static DomainType MapDomain(DomainRow row, List<DomainCheckRow> 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],
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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),
};
}

Expand Down
8 changes: 4 additions & 4 deletions src/NSchema.Postgres/Sql/PostgresSqlDialect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 ─────────────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]