diff --git a/Directory.Packages.props b/Directory.Packages.props
index 13358cf..c2742e6 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -7,7 +7,7 @@
-
+
diff --git a/src/NSchema.Postgres/Models/ColumnRow.cs b/src/NSchema.Postgres/Models/ColumnRow.cs
index 847a507..52df046 100644
--- a/src/NSchema.Postgres/Models/ColumnRow.cs
+++ b/src/NSchema.Postgres/Models/ColumnRow.cs
@@ -16,4 +16,5 @@ internal sealed record ColumnRow(
bool IsIdentity,
long? IdentityStart = null,
long? IdentityMinValue = null,
- long? IdentityIncrement = null);
+ long? IdentityIncrement = null,
+ string? GeneratedExpression = null);
diff --git a/src/NSchema.Postgres/Models/CompositeFieldRow.cs b/src/NSchema.Postgres/Models/CompositeFieldRow.cs
new file mode 100644
index 0000000..65106a3
--- /dev/null
+++ b/src/NSchema.Postgres/Models/CompositeFieldRow.cs
@@ -0,0 +1,12 @@
+namespace NSchema.Postgres.Models;
+
+internal sealed record CompositeFieldRow(
+ string Schema,
+ string TypeName,
+ string FieldName,
+ int OrdinalPosition,
+ string DataType,
+ string UdtName,
+ int? MaxLength,
+ int? Precision,
+ int? Scale);
diff --git a/src/NSchema.Postgres/Models/CompositeTypeRow.cs b/src/NSchema.Postgres/Models/CompositeTypeRow.cs
new file mode 100644
index 0000000..5d913e5
--- /dev/null
+++ b/src/NSchema.Postgres/Models/CompositeTypeRow.cs
@@ -0,0 +1,6 @@
+namespace NSchema.Postgres.Models;
+
+internal sealed record CompositeTypeRow(
+ string Schema,
+ string Name,
+ string? Comment);
diff --git a/src/NSchema.Postgres/Models/DomainCheckRow.cs b/src/NSchema.Postgres/Models/DomainCheckRow.cs
new file mode 100644
index 0000000..8897812
--- /dev/null
+++ b/src/NSchema.Postgres/Models/DomainCheckRow.cs
@@ -0,0 +1,7 @@
+namespace NSchema.Postgres.Models;
+
+internal sealed record DomainCheckRow(
+ string Schema,
+ string DomainName,
+ string CheckName,
+ string Expression);
diff --git a/src/NSchema.Postgres/Models/DomainRow.cs b/src/NSchema.Postgres/Models/DomainRow.cs
new file mode 100644
index 0000000..3346ff6
--- /dev/null
+++ b/src/NSchema.Postgres/Models/DomainRow.cs
@@ -0,0 +1,13 @@
+namespace NSchema.Postgres.Models;
+
+internal sealed record DomainRow(
+ string Schema,
+ string Name,
+ string DataType,
+ string UdtName,
+ int? MaxLength,
+ int? Precision,
+ int? Scale,
+ bool NotNull,
+ string? Default,
+ string? Comment);
diff --git a/src/NSchema.Postgres/Models/ExclusionConstraintRow.cs b/src/NSchema.Postgres/Models/ExclusionConstraintRow.cs
new file mode 100644
index 0000000..12a94b0
--- /dev/null
+++ b/src/NSchema.Postgres/Models/ExclusionConstraintRow.cs
@@ -0,0 +1,15 @@
+namespace NSchema.Postgres.Models;
+
+///
+/// A row of exclusion-constraint metadata. Elements are carried positionally: each is a column name or an
+/// expression (per ) paired with the operator in .
+///
+internal sealed record ExclusionConstraintRow(
+ string TableSchema,
+ string TableName,
+ string ConstraintName,
+ string? Method,
+ string? Predicate,
+ string[] ElementTexts,
+ bool[] IsExpressions,
+ string[] Operators);
diff --git a/src/NSchema.Postgres/Models/ExtensionRow.cs b/src/NSchema.Postgres/Models/ExtensionRow.cs
new file mode 100644
index 0000000..3b12230
--- /dev/null
+++ b/src/NSchema.Postgres/Models/ExtensionRow.cs
@@ -0,0 +1,6 @@
+namespace NSchema.Postgres.Models;
+
+internal sealed record ExtensionRow(
+ string Name,
+ string? Version,
+ string? Comment);
diff --git a/src/NSchema.Postgres/Models/IndexRow.cs b/src/NSchema.Postgres/Models/IndexRow.cs
index e08f5f6..2d5fdb3 100644
--- a/src/NSchema.Postgres/Models/IndexRow.cs
+++ b/src/NSchema.Postgres/Models/IndexRow.cs
@@ -1,9 +1,18 @@
namespace NSchema.Postgres.Models;
+///
+/// A row of index metadata. Columns are carried positionally: the first entries are the
+/// index keys (a column name or an expression, per , with ordering in
+/// ); the remainder are covering INCLUDE columns.
+///
internal sealed record IndexRow(
string SchemaName,
string TableName,
string IndexName,
bool IsUnique,
- string[] ColumnNames,
- string? Predicate);
+ string? Method,
+ int NumKeyAtts,
+ string? Predicate,
+ string[] ColumnTexts,
+ bool[] IsExpressions,
+ int[] Options);
diff --git a/src/NSchema.Postgres/Models/TriggerRow.cs b/src/NSchema.Postgres/Models/TriggerRow.cs
new file mode 100644
index 0000000..d6bed2a
--- /dev/null
+++ b/src/NSchema.Postgres/Models/TriggerRow.cs
@@ -0,0 +1,16 @@
+namespace NSchema.Postgres.Models;
+
+///
+/// A row of trigger metadata. is the raw pg_trigger.tgtype bitmask (timing/level/events),
+/// decoded into the model's enums when mapped.
+///
+internal sealed record TriggerRow(
+ string TableSchema,
+ string TableName,
+ string Name,
+ int TgType,
+ string Function,
+ string? When,
+ string[] UpdateOfColumns,
+ string? FunctionArguments,
+ string? Comment);
diff --git a/src/NSchema.Postgres/Models/ViewRow.cs b/src/NSchema.Postgres/Models/ViewRow.cs
index dffc346..e7f05a9 100644
--- a/src/NSchema.Postgres/Models/ViewRow.cs
+++ b/src/NSchema.Postgres/Models/ViewRow.cs
@@ -3,4 +3,5 @@ namespace NSchema.Postgres.Models;
internal sealed record ViewRow(
string Schema,
string Name,
- string Definition);
+ string Definition,
+ bool IsMaterialized);
diff --git a/src/NSchema.Postgres/NSchema.Postgres.csproj b/src/NSchema.Postgres/NSchema.Postgres.csproj
index 78afa8c..b019ac7 100644
--- a/src/NSchema.Postgres/NSchema.Postgres.csproj
+++ b/src/NSchema.Postgres/NSchema.Postgres.csproj
@@ -20,7 +20,7 @@
true
true
snupkg
- 3.0.0-alpha.11
+ 3.0.0-alpha.12
$(Version.Split('-')[0])
$(Version.Split('-')[0])
true
diff --git a/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs b/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs
index 565aff8..3d0b3f4 100644
--- a/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs
+++ b/src/NSchema.Postgres/Sql/PostgresSchemaProvider.cs
@@ -3,6 +3,19 @@
using NSchema.Postgres.Models;
using NSchema.Schema;
using NSchema.Schema.Model;
+using NSchema.Schema.Model.Columns;
+using NSchema.Schema.Model.CompositeTypes;
+using NSchema.Schema.Model.Constraints;
+using NSchema.Schema.Model.Domains;
+using NSchema.Schema.Model.Enums;
+using NSchema.Schema.Model.Extensions;
+using NSchema.Schema.Model.Indexes;
+using NSchema.Schema.Model.Routines;
+using NSchema.Schema.Model.Schemas;
+using NSchema.Schema.Model.Sequences;
+using NSchema.Schema.Model.Tables;
+using NSchema.Schema.Model.Triggers;
+using NSchema.Schema.Model.Views;
namespace NSchema.Postgres.Sql;
@@ -24,7 +37,10 @@ public async ValueTask GetSchema(string[]? schemas = null, Cance
var foreignKeys = await QueryForeignKeys(conn, schemas, cancellationToken);
var uniqueConstraints = await QueryUniqueConstraints(conn, schemas, cancellationToken);
var checkConstraints = await QueryCheckConstraints(conn, schemas, cancellationToken);
+ var exclusionConstraints = await QueryExclusionConstraints(conn, schemas, cancellationToken);
var indexes = await QueryIndexes(conn, schemas, cancellationToken);
+ var triggers = await QueryTriggers(conn, schemas, cancellationToken);
+ var extensions = await QueryExtensions(conn, cancellationToken);
var schemaComments = await QuerySchemaComments(conn, schemas, cancellationToken);
var tableComments = await QueryTableComments(conn, schemas, cancellationToken);
var columnComments = await QueryColumnComments(conn, schemas, cancellationToken);
@@ -39,17 +55,23 @@ public async ValueTask GetSchema(string[]? schemas = null, Cance
var enumComments = await QueryEnumComments(conn, schemas, cancellationToken);
var sequences = await QuerySequences(conn, schemas, cancellationToken);
var sequenceComments = await QuerySequenceComments(conn, schemas, cancellationToken);
+ var domains = await QueryDomains(conn, schemas, cancellationToken);
+ var domainChecks = await QueryDomainChecks(conn, schemas, cancellationToken);
+ var compositeTypes = await QueryCompositeTypes(conn, schemas, cancellationToken);
+ var compositeFields = await QueryCompositeFields(conn, schemas, cancellationToken);
var functions = await QueryRoutines(conn, schemas, FunctionKind, cancellationToken);
var functionComments = await QueryRoutineComments(conn, schemas, FunctionKind, cancellationToken);
var procedures = await QueryRoutines(conn, schemas, ProcedureKind, cancellationToken);
var procedureComments = await QueryRoutineComments(conn, schemas, ProcedureKind, cancellationToken);
return Build(
- tables, columns, primaryKeys, foreignKeys, uniqueConstraints, checkConstraints, indexes,
+ tables, columns, primaryKeys, foreignKeys, uniqueConstraints, checkConstraints, exclusionConstraints, indexes, triggers,
schemaComments, tableComments, columnComments, indexComments, constraintComments,
schemaGrants, tableGrants, views, viewComments, viewDependencies,
enums, enumComments, sequences, sequenceComments,
- functions, functionComments, procedures, procedureComments
+ domains, domainChecks, compositeTypes, compositeFields,
+ functions, functionComments, procedures, procedureComments,
+ extensions
);
}
@@ -107,7 +129,8 @@ private static async Task> QueryColumns(NpgsqlConnection conn, s
c.is_identity,
seq.seqstart AS identity_start,
seq.seqmin AS identity_min_value,
- seq.seqincrement AS identity_increment
+ seq.seqincrement AS identity_increment,
+ CASE WHEN c.is_generated = 'ALWAYS' THEN c.generation_expression END AS generation_expression
FROM information_schema.columns c
LEFT JOIN pg_class t ON t.relname = c.table_name
AND t.relkind = 'r'
@@ -148,7 +171,8 @@ AND c.table_schema NOT LIKE 'pg\_temp%' ESCAPE '\'
IsIdentity: reader.GetString(12) == "YES",
IdentityStart: reader.IsDBNull(13) ? null : reader.GetInt64(13),
IdentityMinValue: reader.IsDBNull(14) ? null : reader.GetInt64(14),
- IdentityIncrement: reader.IsDBNull(15) ? null : reader.GetInt64(15)
+ IdentityIncrement: reader.IsDBNull(15) ? null : reader.GetInt64(15),
+ GeneratedExpression: reader.IsDBNull(16) ? null : reader.GetString(16)
));
}
@@ -367,6 +391,318 @@ private static string StripEnclosingParens(string expression)
return expression[1..^1].Trim();
}
+ private static async Task> QueryExclusionConstraints(NpgsqlConnection conn, string[]? schemas, CancellationToken ct)
+ {
+ var rows = new List();
+ await using var cmd = conn.CreateCommand();
+ // Exclusion constraints (contype = 'x'). Each is backed by an index (conindid): the index supplies the
+ // access method and the per-element column/expression text (read the same way as a plain index), while
+ // conexclop supplies the parallel operator for each element. The conexclop unnest joins on position, so it
+ // naturally restricts to the key elements (any INCLUDE columns have no operator and drop out). The access
+ // method is btree-folded to null so an EXCLUDE without USING round-trips clean.
+ cmd.CommandText = """
+ SELECT
+ n.nspname AS table_schema,
+ t.relname AS table_name,
+ c.conname AS constraint_name,
+ NULLIF(am.amname, 'btree') AS method,
+ pg_get_expr(ix.indpred, ix.indrelid) AS predicate,
+ array_agg(CASE WHEN k.attnum = 0 THEN pg_get_indexdef(c.conindid, k.ordinality::int, true) ELSE a.attname END ORDER BY k.ordinality) AS element_texts,
+ array_agg(k.attnum = 0 ORDER BY k.ordinality) AS is_expressions,
+ array_agg(op.oprname ORDER BY k.ordinality) AS operators
+ FROM pg_constraint c
+ JOIN pg_class t ON t.oid = c.conrelid
+ JOIN pg_namespace n ON n.oid = t.relnamespace
+ JOIN pg_index ix ON ix.indexrelid = c.conindid
+ JOIN pg_class i ON i.oid = c.conindid
+ JOIN pg_am am ON am.oid = i.relam
+ JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ordinality) ON TRUE
+ LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
+ JOIN LATERAL unnest(c.conexclop) WITH ORDINALITY AS e(opoid, eord) ON e.eord = k.ordinality
+ JOIN pg_operator op ON op.oid = e.opoid
+ WHERE c.contype = 'x'
+ AND (@schemas::text[] IS NULL OR n.nspname = ANY(@schemas))
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
+ AND n.nspname NOT LIKE 'pg\_toast%' ESCAPE '\'
+ AND n.nspname NOT LIKE 'pg\_temp%' ESCAPE '\'
+ GROUP BY n.nspname, t.relname, c.conname, am.amname, ix.indpred, ix.indrelid, c.conindid
+ ORDER BY n.nspname, t.relname, c.conname
+ """;
+ AddSchemasParameter(cmd, schemas);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ while (await reader.ReadAsync(ct))
+ {
+ rows.Add(new ExclusionConstraintRow(
+ TableSchema: reader.GetString(0),
+ TableName: reader.GetString(1),
+ ConstraintName: reader.GetString(2),
+ Method: reader.IsDBNull(3) ? null : reader.GetString(3),
+ Predicate: reader.IsDBNull(4) ? null : reader.GetString(4),
+ ElementTexts: reader.GetFieldValue(5),
+ IsExpressions: reader.GetFieldValue(6),
+ Operators: reader.GetFieldValue(7)
+ ));
+ }
+
+ return rows;
+ }
+
+ private static async Task> QueryDomains(NpgsqlConnection conn, string[]? schemas, CancellationToken ct)
+ {
+ var rows = new List();
+ await using var cmd = conn.CreateCommand();
+ // The base type (and its length/precision/scale) come from information_schema.domains — the same shape a
+ // column reports — so MapSqlType can be reused. NOT NULL is not exposed there, so it is read from
+ // pg_type.typnotnull; the comment is on the domain's type entry.
+ cmd.CommandText = """
+ SELECT
+ d.domain_schema,
+ d.domain_name,
+ d.data_type,
+ d.udt_name,
+ d.character_maximum_length,
+ d.numeric_precision,
+ d.numeric_scale,
+ t.typnotnull AS not_null,
+ d.domain_default,
+ obj_description(t.oid, 'pg_type') AS comment
+ FROM information_schema.domains d
+ JOIN pg_namespace n ON n.nspname = d.domain_schema
+ JOIN pg_type t ON t.typname = d.domain_name AND t.typnamespace = n.oid AND t.typtype = 'd'
+ WHERE (@schemas::text[] IS NULL OR d.domain_schema = ANY(@schemas))
+ AND d.domain_schema NOT IN ('pg_catalog', 'information_schema')
+ AND d.domain_schema NOT LIKE 'pg\_toast%' ESCAPE '\'
+ AND d.domain_schema NOT LIKE 'pg\_temp%' ESCAPE '\'
+ ORDER BY d.domain_schema, d.domain_name
+ """;
+ AddSchemasParameter(cmd, schemas);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ while (await reader.ReadAsync(ct))
+ {
+ rows.Add(new DomainRow(
+ Schema: reader.GetString(0),
+ Name: reader.GetString(1),
+ DataType: reader.GetString(2),
+ UdtName: reader.GetString(3),
+ MaxLength: reader.IsDBNull(4) ? null : reader.GetInt32(4),
+ Precision: reader.IsDBNull(5) ? null : reader.GetInt32(5),
+ Scale: reader.IsDBNull(6) ? null : reader.GetInt32(6),
+ NotNull: reader.GetBoolean(7),
+ Default: reader.IsDBNull(8) ? null : reader.GetString(8),
+ Comment: reader.IsDBNull(9) ? null : reader.GetString(9)
+ ));
+ }
+
+ return rows;
+ }
+
+ private static async Task> QueryDomainChecks(NpgsqlConnection conn, string[]? schemas, CancellationToken ct)
+ {
+ var rows = new List();
+ await using var cmd = conn.CreateCommand();
+ // Domain check constraints are pg_constraint rows attached to the type (contypid), not a table (conrelid).
+ // pg_get_constraintdef renders "CHECK (expr)" — strip the wrapper, as for table checks.
+ cmd.CommandText = """
+ SELECT
+ n.nspname AS schema_name,
+ t.typname AS domain_name,
+ c.conname AS check_name,
+ pg_get_constraintdef(c.oid) AS definition
+ FROM pg_constraint c
+ JOIN pg_type t ON t.oid = c.contypid
+ JOIN pg_namespace n ON n.oid = t.typnamespace
+ WHERE c.contype = 'c' AND c.contypid <> 0
+ AND (@schemas::text[] IS NULL OR n.nspname = ANY(@schemas))
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
+ AND n.nspname NOT LIKE 'pg\_toast%' ESCAPE '\'
+ AND n.nspname NOT LIKE 'pg\_temp%' ESCAPE '\'
+ ORDER BY n.nspname, t.typname, c.conname
+ """;
+ AddSchemasParameter(cmd, schemas);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ while (await reader.ReadAsync(ct))
+ {
+ rows.Add(new DomainCheckRow(
+ Schema: reader.GetString(0),
+ DomainName: reader.GetString(1),
+ CheckName: reader.GetString(2),
+ Expression: StripCheckWrapper(reader.GetString(3))
+ ));
+ }
+
+ return rows;
+ }
+
+ private static async Task> QueryCompositeTypes(NpgsqlConnection conn, string[]? schemas, CancellationToken ct)
+ {
+ var rows = new List();
+ await using var cmd = conn.CreateCommand();
+ // Standalone composite types only: every table/view/sequence also has a composite type in pg_type (its row
+ // type), so the backing pg_class is joined and filtered to relkind 'c' — a free-standing CREATE TYPE — to
+ // exclude those relation row types.
+ cmd.CommandText = """
+ SELECT n.nspname AS schema_name,
+ t.typname AS type_name,
+ obj_description(t.oid, 'pg_type') AS comment
+ FROM pg_type t
+ JOIN pg_namespace n ON n.oid = t.typnamespace
+ JOIN pg_class c ON c.oid = t.typrelid
+ WHERE t.typtype = 'c' AND c.relkind = 'c'
+ AND (@schemas::text[] IS NULL OR n.nspname = ANY(@schemas))
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
+ AND n.nspname NOT LIKE 'pg\_toast%' ESCAPE '\'
+ AND n.nspname NOT LIKE 'pg\_temp%' ESCAPE '\'
+ ORDER BY n.nspname, t.typname
+ """;
+ AddSchemasParameter(cmd, schemas);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ while (await reader.ReadAsync(ct))
+ {
+ rows.Add(new CompositeTypeRow(
+ Schema: reader.GetString(0),
+ Name: reader.GetString(1),
+ Comment: reader.IsDBNull(2) ? null : reader.GetString(2)
+ ));
+ }
+
+ return rows;
+ }
+
+ private static async Task> QueryCompositeFields(NpgsqlConnection conn, string[]? schemas, CancellationToken ct)
+ {
+ var rows = new List();
+ await using var cmd = conn.CreateCommand();
+ // information_schema.attributes lists composite-type fields in the same shape a column reports, so
+ // MapSqlType can be reused for the field type.
+ cmd.CommandText = """
+ SELECT
+ a.udt_schema,
+ a.udt_name,
+ a.attribute_name,
+ a.ordinal_position,
+ a.data_type,
+ a.attribute_udt_name,
+ a.character_maximum_length,
+ a.numeric_precision,
+ a.numeric_scale
+ FROM information_schema.attributes a
+ WHERE (@schemas::text[] IS NULL OR a.udt_schema = ANY(@schemas))
+ AND a.udt_schema NOT IN ('pg_catalog', 'information_schema')
+ AND a.udt_schema NOT LIKE 'pg\_toast%' ESCAPE '\'
+ AND a.udt_schema NOT LIKE 'pg\_temp%' ESCAPE '\'
+ ORDER BY a.udt_schema, a.udt_name, a.ordinal_position
+ """;
+ AddSchemasParameter(cmd, schemas);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ while (await reader.ReadAsync(ct))
+ {
+ rows.Add(new CompositeFieldRow(
+ Schema: reader.GetString(0),
+ TypeName: reader.GetString(1),
+ FieldName: reader.GetString(2),
+ OrdinalPosition: reader.GetInt32(3),
+ DataType: reader.GetString(4),
+ UdtName: reader.GetString(5),
+ MaxLength: reader.IsDBNull(6) ? null : reader.GetInt32(6),
+ Precision: reader.IsDBNull(7) ? null : reader.GetInt32(7),
+ Scale: reader.IsDBNull(8) ? null : reader.GetInt32(8)
+ ));
+ }
+
+ return rows;
+ }
+
+ private static async Task> QueryExtensions(NpgsqlConnection conn, CancellationToken ct)
+ {
+ var rows = new List();
+ await using var cmd = conn.CreateCommand();
+ // Extensions are database-global (not schema-scoped), so they are not filtered by the schema list. plpgsql
+ // is the always-installed procedural language and never part of a declared schema, so it is excluded.
+ cmd.CommandText = """
+ SELECT e.extname AS name,
+ e.extversion AS version,
+ obj_description(e.oid, 'pg_extension') AS comment
+ FROM pg_extension e
+ WHERE e.extname <> 'plpgsql'
+ ORDER BY e.extname
+ """;
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ while (await reader.ReadAsync(ct))
+ {
+ rows.Add(new ExtensionRow(
+ Name: reader.GetString(0),
+ Version: reader.IsDBNull(1) ? null : reader.GetString(1),
+ Comment: reader.IsDBNull(2) ? null : reader.GetString(2)
+ ));
+ }
+
+ return rows;
+ }
+
+ private static async Task> QueryTriggers(NpgsqlConnection conn, string[]? schemas, CancellationToken ct)
+ {
+ var rows = new List();
+ await using var cmd = conn.CreateCommand();
+ // User triggers only — NOT tgisinternal excludes the system triggers that enforce foreign keys and other
+ // constraints. tgtype is a bitmask (timing/level/events) decoded when mapped; the UPDATE OF column set comes
+ // from tgattr. The WHEN condition and the function arguments are both pulled out of pg_get_triggerdef (its
+ // canonical form): pg_get_expr cannot render a WHEN that references both OLD and NEW, and there is no clean
+ // catalog column for the decoded arguments.
+ cmd.CommandText = """
+ SELECT
+ n.nspname AS table_schema,
+ c.relname AS table_name,
+ t.tgname AS trigger_name,
+ t.tgtype AS tg_type,
+ fn.nspname || '.' || p.proname AS function,
+ substring(td.def FROM 'WHEN \((.*)\) EXECUTE (?:FUNCTION|PROCEDURE)') AS when_expr,
+ COALESCE(
+ (SELECT array_agg(a.attname ORDER BY k.ord)
+ FROM unnest(string_to_array(NULLIF(t.tgattr::text, ''), ' ')::int[]) WITH ORDINALITY AS k(attnum, ord)
+ JOIN pg_attribute a ON a.attrelid = t.tgrelid AND a.attnum = k.attnum),
+ ARRAY[]::text[]) AS update_of_columns,
+ substring(td.def FROM 'EXECUTE (?:FUNCTION|PROCEDURE) [^(]+\((.*)\)$') AS function_args,
+ obj_description(t.oid, 'pg_trigger') AS comment
+ FROM pg_trigger t
+ JOIN pg_class c ON c.oid = t.tgrelid
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ JOIN pg_proc p ON p.oid = t.tgfoid
+ JOIN pg_namespace fn ON fn.oid = p.pronamespace
+ CROSS JOIN LATERAL (SELECT pg_get_triggerdef(t.oid) AS def) td
+ WHERE NOT t.tgisinternal
+ AND (@schemas::text[] IS NULL OR n.nspname = ANY(@schemas))
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
+ AND n.nspname NOT LIKE 'pg\_toast%' ESCAPE '\'
+ AND n.nspname NOT LIKE 'pg\_temp%' ESCAPE '\'
+ ORDER BY n.nspname, c.relname, t.tgname
+ """;
+ AddSchemasParameter(cmd, schemas);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ while (await reader.ReadAsync(ct))
+ {
+ rows.Add(new TriggerRow(
+ TableSchema: reader.GetString(0),
+ TableName: reader.GetString(1),
+ Name: reader.GetString(2),
+ TgType: reader.GetInt16(3),
+ Function: reader.GetString(4),
+ When: reader.IsDBNull(5) ? null : StripEnclosingParens(reader.GetString(5)),
+ UpdateOfColumns: reader.GetFieldValue(6),
+ FunctionArguments: reader.IsDBNull(7) || reader.GetString(7).Length == 0 ? null : reader.GetString(7),
+ Comment: reader.IsDBNull(8) ? null : reader.GetString(8)
+ ));
+ }
+
+ return rows;
+ }
+
private static async Task> QueryConstraintComments(NpgsqlConnection conn, string[]? schemas, CancellationToken ct)
{
var result = new Dictionary<(string, string, string), string?>();
@@ -402,29 +738,38 @@ private static async Task> QueryIndexes(NpgsqlConnection conn, st
await using var cmd = conn.CreateCommand();
// Exclude primary-key indexes and any index that backs a constraint (a UNIQUE constraint's implicit index
// surfaces as a UniqueConstraint, not a TableIndex). Standalone CREATE UNIQUE INDEXes have no backing
- // constraint, so they are still returned. Exclude expression indexes (attnum = 0).
+ // constraint, so they are still returned.
+ //
+ // Columns are read positionally (indkey order, all indnatts of them — keys then INCLUDE): a plain column
+ // is its attname; an expression key (attnum = 0) is its pg_get_indexdef text. indnkeyatts marks the
+ // key/INCLUDE split, indoption carries per-key ASC/DESC + NULLS bits, and the access method is btree-folded
+ // to null (the default) so a plain index round-trips clean.
cmd.CommandText = """
SELECT
n.nspname AS schema_name,
t.relname AS table_name,
i.relname AS index_name,
ix.indisunique AS is_unique,
- array_agg(a.attname ORDER BY k.ordinality) AS column_names,
- pg_get_expr(ix.indpred, ix.indrelid) AS predicate
+ NULLIF(am.amname, 'btree') AS method,
+ ix.indnkeyatts AS num_key_atts,
+ pg_get_expr(ix.indpred, ix.indrelid) AS predicate,
+ array_agg(CASE WHEN k.attnum = 0 THEN pg_get_indexdef(ix.indexrelid, k.ordinality::int, true) ELSE a.attname END ORDER BY k.ordinality) AS column_texts,
+ array_agg(k.attnum = 0 ORDER BY k.ordinality) AS is_expressions,
+ array_agg(COALESCE((string_to_array(ix.indoption::text, ' '))[k.ordinality]::int, 0) ORDER BY k.ordinality) AS options
FROM pg_index ix
JOIN pg_class t ON t.oid = ix.indrelid
JOIN pg_class i ON i.oid = ix.indexrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
+ JOIN pg_am am ON am.oid = i.relam
JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ordinality) ON TRUE
- JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
+ LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
WHERE (@schemas::text[] IS NULL OR n.nspname = ANY(@schemas))
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
AND n.nspname NOT LIKE 'pg\_toast%' ESCAPE '\'
AND n.nspname NOT LIKE 'pg\_temp%' ESCAPE '\'
AND NOT ix.indisprimary
AND NOT EXISTS (SELECT 1 FROM pg_constraint con WHERE con.conindid = i.oid)
- AND k.attnum > 0
- GROUP BY n.nspname, t.relname, i.relname, ix.indisunique, ix.indpred, ix.indrelid
+ GROUP BY n.nspname, t.relname, i.relname, ix.indisunique, am.amname, ix.indnkeyatts, ix.indpred, ix.indrelid, ix.indexrelid
ORDER BY n.nspname, t.relname, i.relname
""";
AddSchemasParameter(cmd, schemas);
@@ -437,8 +782,12 @@ AND k.attnum > 0
TableName: reader.GetString(1),
IndexName: reader.GetString(2),
IsUnique: reader.GetBoolean(3),
- ColumnNames: reader.GetFieldValue(4),
- Predicate: reader.IsDBNull(5) ? null : reader.GetString(5)
+ Method: reader.IsDBNull(4) ? null : reader.GetString(4),
+ NumKeyAtts: reader.GetInt16(5),
+ Predicate: reader.IsDBNull(6) ? null : reader.GetString(6),
+ ColumnTexts: reader.GetFieldValue(7),
+ IsExpressions: reader.GetFieldValue(8),
+ Options: reader.GetFieldValue(9)
));
}
@@ -627,15 +976,17 @@ private static async Task> QueryViews(NpgsqlConnection conn, strin
{
var rows = new List();
await using var cmd = conn.CreateCommand();
- // relkind 'v' = plain views (materialized views, 'm', are not part of the model). pg_get_viewdef returns the
- // canonical definition — this is what makes apply → plan round-trip cleanly: state captures the DB's own form.
+ // relkind 'v' = plain views, 'm' = materialized views (one model, distinguished by the flag). pg_get_viewdef
+ // returns the canonical definition for both — this is what makes apply → plan round-trip cleanly: state
+ // captures the DB's own form.
cmd.CommandText = """
SELECT n.nspname AS schema_name,
c.relname AS view_name,
- pg_get_viewdef(c.oid) AS definition
+ pg_get_viewdef(c.oid) AS definition,
+ c.relkind = 'm' AS is_materialized
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
- WHERE c.relkind = 'v'
+ WHERE c.relkind IN ('v', 'm')
AND (@schemas::text[] IS NULL OR n.nspname = ANY(@schemas))
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
AND n.nspname NOT LIKE 'pg\_toast%' ESCAPE '\'
@@ -647,7 +998,7 @@ AND n.nspname NOT LIKE 'pg\_temp%' ESCAPE '\'
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
- rows.Add(new ViewRow(reader.GetString(0), reader.GetString(1), CleanViewBody(reader.GetString(2))));
+ rows.Add(new ViewRow(reader.GetString(0), reader.GetString(1), CleanViewBody(reader.GetString(2)), reader.GetBoolean(3)));
}
return rows;
@@ -711,7 +1062,7 @@ FROM pg_rewrite r
AND dep.refclassid = 'pg_class'::regclass
JOIN pg_class d ON d.oid = dep.refobjid
JOIN pg_namespace dn ON dn.oid = d.relnamespace
- WHERE v.relkind = 'v'
+ WHERE v.relkind IN ('v', 'm')
AND d.relkind IN ('r', 'v', 'm')
AND d.oid <> v.oid
AND (@schemas::text[] IS NULL OR vn.nspname = ANY(@schemas))
@@ -1016,7 +1367,9 @@ private static DatabaseSchema Build(
List foreignKeys,
List uniqueConstraints,
List checkConstraints,
+ List exclusionConstraints,
List indexes,
+ List triggers,
Dictionary schemaComments,
Dictionary<(string, string), string?> tableComments,
Dictionary<(string, string, string), string?> columnComments,
@@ -1031,23 +1384,28 @@ private static DatabaseSchema Build(
Dictionary<(string, string), string?> enumComments,
List sequences,
Dictionary<(string, string), string?> sequenceComments,
+ List domains,
+ List domainChecks,
+ List compositeTypes,
+ List compositeFields,
List functions,
Dictionary<(string, string), string?> functionComments,
List procedures,
- Dictionary<(string, string), string?> procedureComments
+ Dictionary<(string, string), string?> procedureComments,
+ List extensions
)
{
var bySchema = tables
.GroupBy(t => t.Schema)
.ToDictionary(
g => g.Key,
- g => g.Select(t => BuildTable(t, columns, primaryKeys, foreignKeys, uniqueConstraints, checkConstraints, indexes, tableComments, columnComments, indexComments, constraintComments, tableGrants)).ToList());
+ g => g.Select(t => BuildTable(t, columns, primaryKeys, foreignKeys, uniqueConstraints, checkConstraints, exclusionConstraints, indexes, triggers, tableComments, columnComments, indexComments, constraintComments, tableGrants)).ToList());
var viewsBySchema = views
.GroupBy(v => v.Schema)
.ToDictionary(
g => g.Key,
- g => g.Select(v => BuildView(v, viewComments, viewDependencies)).ToList());
+ g => g.Select(v => BuildView(v, viewComments, viewDependencies, indexes, indexComments)).ToList());
var enumsBySchema = enums
.GroupBy(e => e.Schema)
@@ -1063,19 +1421,24 @@ private static DatabaseSchema Build(
g => g.Select(s => new Sequence(s.Name, NormalizeSequenceOptions(s), OldName: null,
Comment: sequenceComments.GetValueOrDefault((s.Schema, s.Name)))).ToList());
- var functionsBySchema = functions
- .GroupBy(f => f.Schema)
- .ToDictionary(
- g => g.Key,
- g => g.Select(f => new Function(f.Name, f.Arguments, f.Definition, OldName: null,
- Comment: functionComments.GetValueOrDefault((f.Schema, f.Name)))).ToList());
-
- var proceduresBySchema = procedures
- .GroupBy(p => p.Schema)
- .ToDictionary(
- g => g.Key,
- g => g.Select(p => new Procedure(p.Name, p.Arguments, p.Definition, OldName: null,
- Comment: procedureComments.GetValueOrDefault((p.Schema, p.Name)))).ToList());
+ // Functions and procedures are one model (Routine) distinguished by Kind, sharing a single name space, so
+ // the two query results merge into one routine list per schema.
+ var routinesBySchema = functions
+ .Select(f => (f.Schema, Routine: new Routine(f.Name, RoutineKind.Function, f.Arguments, f.Definition,
+ OldName: null, Comment: functionComments.GetValueOrDefault((f.Schema, f.Name)))))
+ .Concat(procedures
+ .Select(p => (p.Schema, Routine: new Routine(p.Name, RoutineKind.Procedure, p.Arguments, p.Definition,
+ OldName: null, Comment: procedureComments.GetValueOrDefault((p.Schema, p.Name))))))
+ .GroupBy(x => x.Schema)
+ .ToDictionary(g => g.Key, g => g.Select(x => x.Routine).ToList());
+
+ var domainsBySchema = domains
+ .GroupBy(d => d.Schema)
+ .ToDictionary(g => g.Key, g => g.Select(d => MapDomain(d, domainChecks)).ToList());
+
+ var compositeTypesBySchema = compositeTypes
+ .GroupBy(c => c.Schema)
+ .ToDictionary(g => g.Key, g => g.Select(c => MapCompositeType(c, compositeFields)).ToList());
// Drive schema list from what actually exists in the database, not from what was requested.
var existingSchemas = schemaComments.Keys
@@ -1083,8 +1446,9 @@ private static DatabaseSchema Build(
.Union(viewsBySchema.Keys)
.Union(enumsBySchema.Keys)
.Union(sequencesBySchema.Keys)
- .Union(functionsBySchema.Keys)
- .Union(proceduresBySchema.Keys)
+ .Union(routinesBySchema.Keys)
+ .Union(domainsBySchema.Keys)
+ .Union(compositeTypesBySchema.Keys)
.Union(schemaGrants.Select(g => g.SchemaName))
.Distinct(StringComparer.OrdinalIgnoreCase);
@@ -1102,14 +1466,17 @@ private static DatabaseSchema Build(
DroppedEnums: [],
Sequences: sequencesBySchema.GetValueOrDefault(name, []),
DroppedSequences: [],
- Functions: functionsBySchema.GetValueOrDefault(name, []),
- DroppedFunctions: [],
- Procedures: proceduresBySchema.GetValueOrDefault(name, []),
- DroppedProcedures: []);
+ Routines: routinesBySchema.GetValueOrDefault(name, []),
+ DroppedRoutines: [],
+ Domains: domainsBySchema.GetValueOrDefault(name, []),
+ DroppedDomains: [],
+ CompositeTypes: compositeTypesBySchema.GetValueOrDefault(name, []),
+ DroppedCompositeTypes: []);
})
.ToList();
- return new DatabaseSchema(dbSchemas, []);
+ var dbExtensions = extensions.Select(e => new Extension(e.Name, e.Version, e.Comment)).ToList();
+ return new DatabaseSchema(dbSchemas, [], dbExtensions, []);
}
// Postgres engine defaults are folded to null so a bare "CREATE SEQUENCE" round-trips to an all-null
@@ -1145,14 +1512,28 @@ internal static SequenceOptions NormalizeSequenceOptions(SequenceRow row)
private static View BuildView(
ViewRow row,
Dictionary<(string, string), string?> viewComments,
- List viewDependencies)
+ List viewDependencies,
+ List allIndexes,
+ Dictionary<(string, string), string?> indexComments)
{
var dependsOn = viewDependencies
.Where(d => d.ViewSchema == row.Schema && d.ViewName == row.Name)
.Select(d => new ViewDependency(d.RefSchema, d.RefName))
.ToList();
viewComments.TryGetValue((row.Schema, row.Name), out var comment);
- return new View(row.Name, row.Definition, OldName: null, Comment: comment, DependsOn: dependsOn);
+
+ // Only a materialized view can carry indexes; the same index rows that a table would consume are routed
+ // here when the relation they sit on is this matview (relation names are unique per schema, so there is no
+ // overlap with a table's indexes).
+ var indexes = row.IsMaterialized
+ ? allIndexes
+ .Where(i => i.SchemaName == row.Schema && i.TableName == row.Name)
+ .Select(i => MapIndex(i, indexComments.GetValueOrDefault((row.Schema, i.IndexName))))
+ .ToList()
+ : [];
+
+ return new View(row.Name, row.Definition, OldName: null, Comment: comment, DependsOn: dependsOn,
+ IsMaterialized: row.IsMaterialized, Indexes: indexes);
}
private static Table BuildTable(
@@ -1162,7 +1543,9 @@ private static Table BuildTable(
List allForeignKeys,
List allUniqueConstraints,
List allCheckConstraints,
+ List allExclusionConstraints,
List allIndexes,
+ List allTriggers,
Dictionary<(string, string), string?> tableComments,
Dictionary<(string, string, string), string?> columnComments,
Dictionary<(string, string), string?> indexComments,
@@ -1199,12 +1582,19 @@ List allTableGrants
constraintComments.GetValueOrDefault((tableRow.Schema, tableRow.Name, c.ConstraintName))))
.ToList();
+ var exclusions = allExclusionConstraints
+ .Where(e => e.TableSchema == tableRow.Schema && e.TableName == tableRow.Name)
+ .Select(e => MapExclusionConstraint(e, constraintComments.GetValueOrDefault((tableRow.Schema, tableRow.Name, e.ConstraintName))))
+ .ToList();
+
var idxs = allIndexes
.Where(i => i.SchemaName == tableRow.Schema && i.TableName == tableRow.Name)
- .Select(i => new TableIndex(
- i.IndexName, i.ColumnNames, i.IsUnique,
- indexComments.GetValueOrDefault((tableRow.Schema, i.IndexName)),
- i.Predicate))
+ .Select(i => MapIndex(i, indexComments.GetValueOrDefault((tableRow.Schema, i.IndexName))))
+ .ToList();
+
+ var triggers = allTriggers
+ .Where(t => t.TableSchema == tableRow.Schema && t.TableName == tableRow.Name)
+ .Select(MapTrigger)
.ToList();
tableComments.TryGetValue((tableRow.Schema, tableRow.Name), out var tableComment);
@@ -1224,13 +1614,117 @@ List allTableGrants
ForeignKeys: fks,
UniqueConstraints: uniques,
CheckConstraints: checks,
+ ExclusionConstraints: exclusions,
Indexes: idxs,
- Grants: grants
+ Grants: grants,
+ Triggers: triggers
);
}
+ // tgtype is a bitmask: bit 0 = ROW (else STATEMENT); bit 6 = INSTEAD OF, else bit 1 = BEFORE, else AFTER;
+ // bits 2/3/4/5 = INSERT/DELETE/UPDATE/TRUNCATE. The model's TriggerEvent flags differ from these bit values,
+ // so the events are translated rather than copied.
+ private static Trigger MapTrigger(TriggerRow row)
+ {
+ var timing = (row.TgType & 64) != 0 ? TriggerTiming.InsteadOf
+ : (row.TgType & 2) != 0 ? TriggerTiming.Before
+ : TriggerTiming.After;
+ var level = (row.TgType & 1) != 0 ? TriggerLevel.Row : TriggerLevel.Statement;
+
+ var events = TriggerEvent.None;
+ if ((row.TgType & 4) != 0)
+ {
+ events |= TriggerEvent.Insert;
+ }
+
+ if ((row.TgType & 8) != 0)
+ {
+ events |= TriggerEvent.Delete;
+ }
+
+ if ((row.TgType & 16) != 0)
+ {
+ events |= TriggerEvent.Update;
+ }
+
+ if ((row.TgType & 32) != 0)
+ {
+ events |= TriggerEvent.Truncate;
+ }
+
+ return new Trigger(row.Name, timing, events, row.Function, level,
+ UpdateOfColumns: row.UpdateOfColumns, When: row.When, FunctionArguments: row.FunctionArguments, Comment: row.Comment);
+ }
+
+ private static CompositeType MapCompositeType(CompositeTypeRow row, List allFields)
+ {
+ 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)))
+ .ToList();
+ return new CompositeType(row.Name, fields, OldName: null, Comment: row.Comment);
+ }
+
+ private static Domain MapDomain(DomainRow row, List allChecks)
+ {
+ var type = MapSqlType(row.DataType, row.UdtName, domainSchema: null, domainName: null, row.MaxLength, row.Precision, row.Scale);
+ var checks = allChecks
+ .Where(c => c.Schema == row.Schema && c.DomainName == row.Name)
+ .Select(c => new CheckConstraint(c.CheckName, c.Expression))
+ .ToList();
+ return new Domain(row.Name, type, row.Default, row.NotNull, checks, OldName: null, Comment: row.Comment);
+ }
+
+ private static ExclusionConstraint MapExclusionConstraint(ExclusionConstraintRow row, string? comment)
+ {
+ var elements = new List();
+ for (var i = 0; i < row.ElementTexts.Length; i++)
+ {
+ elements.Add(new ExclusionElement(row.ElementTexts[i], row.Operators[i], row.IsExpressions[i]));
+ }
+
+ return new ExclusionConstraint(row.ConstraintName, elements, row.Method, row.Predicate, comment);
+ }
+
// ── Mapping ───────────────────────────────────────────────────────────────
+ private static TableIndex MapIndex(IndexRow row, string? comment)
+ {
+ var keys = new List();
+ var include = new List();
+ for (var i = 0; i < row.ColumnTexts.Length; i++)
+ {
+ if (i < row.NumKeyAtts)
+ {
+ var (sort, nulls) = DecodeIndexOption(row.Options[i]);
+ keys.Add(new IndexColumn(row.ColumnTexts[i], row.IsExpressions[i], sort, nulls));
+ }
+ else
+ {
+ // INCLUDE columns are always plain columns and carry no ordering.
+ include.Add(row.ColumnTexts[i]);
+ }
+ }
+
+ return new TableIndex(row.IndexName, keys, row.IsUnique, comment, row.Predicate, row.Method, include);
+ }
+
+ // indoption packs two bits per key: 0x01 = DESC, 0x02 = NULLS FIRST. The engine default is NULLS LAST for an
+ // ascending key and NULLS FIRST for a descending one (i.e. the default of the NULLS-FIRST bit equals the DESC
+ // bit), so a key matching that default normalizes to Default/Default and a plain index round-trips without
+ // phantom drift — only an explicitly non-default ordering surfaces.
+ private static (IndexSort Sort, IndexNulls Nulls) DecodeIndexOption(int option)
+ {
+ var descending = (option & 1) != 0;
+ var nullsFirst = (option & 2) != 0;
+ var sort = descending ? IndexSort.Descending : IndexSort.Default;
+ var nulls = nullsFirst == descending
+ ? IndexNulls.Default
+ : nullsFirst ? IndexNulls.First : IndexNulls.Last;
+ return (sort, nulls);
+ }
+
private static Column MapColumn(ColumnRow row, Dictionary<(string, string, string), string?> columnComments)
{
var type = MapSqlType(row.DataType, row.UdtName, row.DomainSchema, row.DomainName, row.MaxLength, row.NumericPrecision, row.NumericScale);
@@ -1238,7 +1732,7 @@ private static Column MapColumn(ColumnRow row, Dictionary<(string, string, strin
var identityOptions = row.IsIdentity
? new IdentityOptions(row.IdentityStart, row.IdentityMinValue, row.IdentityIncrement)
: null;
- return new Column(row.ColumnName, type, row.IsNullable, row.IsIdentity, row.DefaultExpression, null, comment, identityOptions);
+ return new Column(row.ColumnName, type, row.IsNullable, row.IsIdentity, row.DefaultExpression, null, comment, identityOptions, row.GeneratedExpression);
}
private static SqlType MapSqlType(string dataType, string udtName, string? domainSchema, string? domainName, int? maxLength, int? precision, int? scale)
diff --git a/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs b/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs
index 00931b4..ee297c9 100644
--- a/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs
+++ b/src/NSchema.Postgres/Sql/PostgresSqlGenerator.cs
@@ -1,5 +1,28 @@
+using System.Text;
using NSchema.Plan.Model;
-using NSchema.Schema.Model;
+using NSchema.Plan.Model.Columns;
+using NSchema.Plan.Model.CompositeTypes;
+using NSchema.Plan.Model.Constraints;
+using NSchema.Plan.Model.Domains;
+using NSchema.Plan.Model.Enums;
+using NSchema.Plan.Model.Extensions;
+using NSchema.Plan.Model.Indexes;
+using NSchema.Plan.Model.Routines;
+using NSchema.Plan.Model.Schemas;
+using NSchema.Plan.Model.Sequence;
+using NSchema.Plan.Model.Tables;
+using NSchema.Plan.Model.Triggers;
+using NSchema.Plan.Model.Views;
+using NSchema.Schema.Model.Columns;
+using NSchema.Schema.Model.CompositeTypes;
+using NSchema.Schema.Model.Constraints;
+using NSchema.Schema.Model.Domains;
+using NSchema.Schema.Model.Extensions;
+using NSchema.Schema.Model.Indexes;
+using NSchema.Schema.Model.Routines;
+using NSchema.Schema.Model.Sequences;
+using NSchema.Schema.Model.Tables;
+using NSchema.Schema.Model.Triggers;
using NSchema.Sql;
using NSchema.Sql.Model;
@@ -24,8 +47,8 @@ public SqlPlan Generate(MigrationPlan plan)
// the statement is carved out of the surrounding transaction. The executor commits the pending segment,
// runs it alone, and resumes — ordering relative to later statements that use the value is preserved.
AddEnumValue x => [new SqlStatement(BuildAddEnumValue(x), RunOutsideTransaction: true)],
- RecreateFunction x => BuildRecreateRoutine("FUNCTION", x.SchemaName, x.Function.Name, x.Function.Arguments, x.Function.Definition, x.Function.Comment),
- RecreateProcedure x => BuildRecreateRoutine("PROCEDURE", x.SchemaName, x.Procedure.Name, x.Procedure.Arguments, x.Procedure.Definition, x.Procedure.Comment),
+ RecreateRoutine x => BuildRecreateRoutine(RoutineKeyword(x.Routine.Kind), x.SchemaName, x.Routine.Name, x.Routine.Arguments, x.Routine.Definition, x.Routine.Comment),
+ RecreateDomain x => BuildRecreateDomain(x),
_ => [new SqlStatement(GenerateSql(action))],
};
@@ -46,6 +69,7 @@ public SqlPlan Generate(MigrationPlan plan)
AlterIdentitySequence x => BuildAlterIdentitySequence(x),
SetColumnDefault { NewDefault: null } x => $"""ALTER TABLE "{x.SchemaName}"."{x.TableName}" ALTER COLUMN "{x.ColumnName}" DROP DEFAULT""",
SetColumnDefault x => $"""ALTER TABLE "{x.SchemaName}"."{x.TableName}" ALTER COLUMN "{x.ColumnName}" SET DEFAULT {x.NewDefault}""",
+ SetColumnGenerated x => BuildSetColumnGenerated(x),
AddPrimaryKey x => $"""ALTER TABLE "{x.SchemaName}"."{x.TableName}" ADD CONSTRAINT "{x.PrimaryKey.Name}" PRIMARY KEY ({ColList(x.PrimaryKey.ColumnNames)})""",
DropPrimaryKey x => $"ALTER TABLE \"{x.SchemaName}\".\"{x.TableName}\" DROP CONSTRAINT \"{x.PrimaryKeyName}\"",
AddForeignKey x => BuildAddForeignKey(x),
@@ -54,23 +78,55 @@ public SqlPlan Generate(MigrationPlan plan)
DropUniqueConstraint x => $"ALTER TABLE \"{x.SchemaName}\".\"{x.TableName}\" DROP CONSTRAINT \"{x.ConstraintName}\"",
AddCheckConstraint x => $"""ALTER TABLE "{x.SchemaName}"."{x.TableName}" ADD CONSTRAINT "{x.CheckConstraint.Name}" CHECK ({x.CheckConstraint.Expression})""",
DropCheckConstraint x => $"ALTER TABLE \"{x.SchemaName}\".\"{x.TableName}\" DROP CONSTRAINT \"{x.ConstraintName}\"",
+ AddExclusionConstraint x => BuildAddExclusionConstraint(x),
+ DropExclusionConstraint x => $"ALTER TABLE \"{x.SchemaName}\".\"{x.TableName}\" DROP CONSTRAINT \"{x.ConstraintName}\"",
CreateIndex x => BuildCreateIndex(x),
DropIndex x => $"DROP INDEX \"{x.SchemaName}\".\"{x.IndexName}\"",
+ CreateTrigger x => BuildCreateTrigger(x),
+ DropTrigger x => $"DROP TRIGGER \"{x.TriggerName}\" ON \"{x.SchemaName}\".\"{x.TableName}\"",
+ SetTriggerComment x => x.NewComment is null
+ ? $"""COMMENT ON TRIGGER "{x.TriggerName}" ON "{x.SchemaName}"."{x.TableName}" IS NULL"""
+ : $"""COMMENT ON TRIGGER "{x.TriggerName}" ON "{x.SchemaName}"."{x.TableName}" IS $comment${x.NewComment}$comment$""",
// A view Add and a body Modify both arrive as CreateView; CREATE OR REPLACE serves both. An incompatible
// output-column change (rename/drop/retype/reorder) is rejected loudly by Postgres rather than silently
// dropping dependents — see CLAUDE.md / the core view-body decision.
+ // A materialized view has no CREATE OR REPLACE form, so the core plans a body change as drop + recreate;
+ // CreateView for a matview is therefore always a fresh CREATE MATERIALIZED VIEW. A plain view's body
+ // change is an in-place CREATE OR REPLACE.
+ CreateView { View.IsMaterialized: true } x => $"""CREATE MATERIALIZED VIEW "{x.SchemaName}"."{x.View.Name}" AS {x.View.Body}""",
CreateView x => $"""CREATE OR REPLACE VIEW "{x.SchemaName}"."{x.View.Name}" AS {x.View.Body}""",
- DropView x => $"DROP VIEW \"{x.SchemaName}\".\"{x.ViewName}\"",
- RenameView x => $"ALTER VIEW \"{x.SchemaName}\".\"{x.OldName}\" RENAME TO \"{x.NewName}\"",
+ DropView x => $"DROP {ViewKind(x.IsMaterialized)} \"{x.SchemaName}\".\"{x.ViewName}\"",
+ RenameView x => $"ALTER {ViewKind(x.IsMaterialized)} \"{x.SchemaName}\".\"{x.OldName}\" RENAME TO \"{x.NewName}\"",
SetViewComment x => x.NewComment is null
- ? $"""COMMENT ON VIEW "{x.SchemaName}"."{x.ViewName}" IS NULL"""
- : $"""COMMENT ON VIEW "{x.SchemaName}"."{x.ViewName}" IS $comment${x.NewComment}$comment$""",
+ ? $"""COMMENT ON {ViewKind(x.IsMaterialized)} "{x.SchemaName}"."{x.ViewName}" IS NULL"""
+ : $"""COMMENT ON {ViewKind(x.IsMaterialized)} "{x.SchemaName}"."{x.ViewName}" IS $comment${x.NewComment}$comment$""",
CreateEnum x => BuildCreateEnum(x),
DropEnum x => $"DROP TYPE \"{x.SchemaName}\".\"{x.EnumName}\"",
RenameEnum x => $"ALTER TYPE \"{x.SchemaName}\".\"{x.OldName}\" RENAME TO \"{x.NewName}\"",
SetEnumComment x => x.NewComment is null
? $"""COMMENT ON TYPE "{x.SchemaName}"."{x.EnumName}" IS NULL"""
: $"""COMMENT ON TYPE "{x.SchemaName}"."{x.EnumName}" IS $comment${x.NewComment}$comment$""",
+ CreateDomain x => BuildCreateDomain(x.SchemaName, x.Domain),
+ DropDomain x => $"DROP DOMAIN \"{x.SchemaName}\".\"{x.DomainName}\"",
+ RenameDomain x => $"ALTER DOMAIN \"{x.SchemaName}\".\"{x.OldName}\" RENAME TO \"{x.NewName}\"",
+ AlterDomainDefault { NewDefault: null } x => $"""ALTER DOMAIN "{x.SchemaName}"."{x.DomainName}" DROP DEFAULT""",
+ AlterDomainDefault x => $"""ALTER DOMAIN "{x.SchemaName}"."{x.DomainName}" SET DEFAULT {x.NewDefault}""",
+ AlterDomainNotNull { NotNull: true } x => $"""ALTER DOMAIN "{x.SchemaName}"."{x.DomainName}" SET NOT NULL""",
+ AlterDomainNotNull x => $"""ALTER DOMAIN "{x.SchemaName}"."{x.DomainName}" DROP NOT NULL""",
+ AddDomainCheck x => $"""ALTER DOMAIN "{x.SchemaName}"."{x.DomainName}" ADD CONSTRAINT "{x.Check.Name}" CHECK ({x.Check.Expression})""",
+ DropDomainCheck x => $"ALTER DOMAIN \"{x.SchemaName}\".\"{x.DomainName}\" DROP CONSTRAINT \"{x.CheckName}\"",
+ SetDomainComment x => x.NewComment is null
+ ? $"""COMMENT ON DOMAIN "{x.SchemaName}"."{x.DomainName}" IS NULL"""
+ : $"""COMMENT ON DOMAIN "{x.SchemaName}"."{x.DomainName}" IS $comment${x.NewComment}$comment$""",
+ CreateCompositeType x => BuildCreateCompositeType(x),
+ DropCompositeType x => $"DROP TYPE \"{x.SchemaName}\".\"{x.TypeName}\"",
+ RenameCompositeType x => $"ALTER TYPE \"{x.SchemaName}\".\"{x.OldName}\" RENAME TO \"{x.NewName}\"",
+ SetCompositeTypeComment x => x.NewComment is null
+ ? $"""COMMENT ON TYPE "{x.SchemaName}"."{x.TypeName}" IS NULL"""
+ : $"""COMMENT ON TYPE "{x.SchemaName}"."{x.TypeName}" IS $comment${x.NewComment}$comment$""",
+ AddCompositeField x => $"""ALTER TYPE "{x.SchemaName}"."{x.TypeName}" ADD ATTRIBUTE "{x.Field.Name}" {ToPostgresType(x.Field.DataType)}""",
+ DropCompositeField x => $"ALTER TYPE \"{x.SchemaName}\".\"{x.TypeName}\" DROP ATTRIBUTE \"{x.FieldName}\"",
+ AlterCompositeFieldType x => $"""ALTER TYPE "{x.SchemaName}"."{x.TypeName}" ALTER ATTRIBUTE "{x.FieldName}" TYPE {ToPostgresType(x.NewType)}""",
CreateSequence x => BuildCreateSequence(x),
DropSequence x => $"DROP SEQUENCE \"{x.SchemaName}\".\"{x.SequenceName}\"",
RenameSequence x => $"ALTER SEQUENCE \"{x.SchemaName}\".\"{x.OldName}\" RENAME TO \"{x.NewName}\"",
@@ -78,21 +134,17 @@ public SqlPlan Generate(MigrationPlan plan)
SetSequenceComment x => x.NewComment is null
? $"""COMMENT ON SEQUENCE "{x.SchemaName}"."{x.SequenceName}" IS NULL"""
: $"""COMMENT ON SEQUENCE "{x.SchemaName}"."{x.SequenceName}" IS $comment${x.NewComment}$comment$""",
- // A routine Add and a definition-only Modify both arrive as Create; CREATE OR REPLACE serves both. The model
- // has no overloading (one routine per name), so drops, renames and comments omit the signature — Postgres
- // resolves the bare name, and rejects it loudly if an out-of-model overload makes it ambiguous.
- CreateFunction x => $"""CREATE OR REPLACE FUNCTION "{x.SchemaName}"."{x.Function.Name}"({x.Function.Arguments}) {x.Function.Definition}""",
- DropFunction x => $"DROP FUNCTION \"{x.SchemaName}\".\"{x.FunctionName}\"",
- RenameFunction x => $"ALTER FUNCTION \"{x.SchemaName}\".\"{x.OldName}\" RENAME TO \"{x.NewName}\"",
- SetFunctionComment x => x.NewComment is null
- ? $"""COMMENT ON FUNCTION "{x.SchemaName}"."{x.FunctionName}" IS NULL"""
- : $"""COMMENT ON FUNCTION "{x.SchemaName}"."{x.FunctionName}" IS $comment${x.NewComment}$comment$""",
- CreateProcedure x => $"""CREATE OR REPLACE PROCEDURE "{x.SchemaName}"."{x.Procedure.Name}"({x.Procedure.Arguments}) {x.Procedure.Definition}""",
- DropProcedure x => $"DROP PROCEDURE \"{x.SchemaName}\".\"{x.ProcedureName}\"",
- RenameProcedure x => $"ALTER PROCEDURE \"{x.SchemaName}\".\"{x.OldName}\" RENAME TO \"{x.NewName}\"",
- SetProcedureComment x => x.NewComment is null
- ? $"""COMMENT ON PROCEDURE "{x.SchemaName}"."{x.ProcedureName}" IS NULL"""
- : $"""COMMENT ON PROCEDURE "{x.SchemaName}"."{x.ProcedureName}" IS $comment${x.NewComment}$comment$""",
+ // A routine Add and a definition-only Modify both arrive as CreateRoutine; CREATE OR REPLACE serves both.
+ // Functions and procedures are one model distinguished by Kind, so a single set of actions carries the
+ // keyword. The model has no overloading (one routine per name), so drops, renames and comments omit the
+ // signature — Postgres resolves the bare name, and rejects it loudly if an out-of-model overload makes it
+ // ambiguous.
+ CreateRoutine x => $"""CREATE OR REPLACE {RoutineKeyword(x.Routine.Kind)} "{x.SchemaName}"."{x.Routine.Name}"({x.Routine.Arguments}) {x.Routine.Definition}""",
+ DropRoutine x => $"DROP {RoutineKeyword(x.Kind)} \"{x.SchemaName}\".\"{x.RoutineName}\"",
+ RenameRoutine x => $"ALTER {RoutineKeyword(x.Kind)} \"{x.SchemaName}\".\"{x.OldName}\" RENAME TO \"{x.NewName}\"",
+ SetRoutineComment x => x.NewComment is null
+ ? $"""COMMENT ON {RoutineKeyword(x.Kind)} "{x.SchemaName}"."{x.RoutineName}" IS NULL"""
+ : $"""COMMENT ON {RoutineKeyword(x.Kind)} "{x.SchemaName}"."{x.RoutineName}" IS $comment${x.NewComment}$comment$""",
SetSchemaComment x => x.NewComment is null
? $"""COMMENT ON SCHEMA "{x.SchemaName}" IS NULL"""
: $"""COMMENT ON SCHEMA "{x.SchemaName}" IS $comment${x.NewComment}$comment$""",
@@ -108,6 +160,14 @@ public SqlPlan Generate(MigrationPlan plan)
SetConstraintComment x => x.NewComment is null
? $"""COMMENT ON CONSTRAINT "{x.ConstraintName}" ON "{x.SchemaName}"."{x.TableName}" IS NULL"""
: $"""COMMENT ON CONSTRAINT "{x.ConstraintName}" ON "{x.SchemaName}"."{x.TableName}" IS $comment${x.NewComment}$comment$""",
+ CreateExtension x => BuildCreateExtension(x),
+ // A version change updates in place; with no target version, UPDATE moves to the default (latest) version.
+ AlterExtension { NewVersion: { } v } x => $"""ALTER EXTENSION "{x.ExtensionName}" UPDATE TO '{EscapeLiteral(v)}'""",
+ AlterExtension x => $"""ALTER EXTENSION "{x.ExtensionName}" UPDATE""",
+ DropExtension x => $"DROP EXTENSION \"{x.ExtensionName}\"",
+ SetExtensionComment x => x.NewComment is null
+ ? $"""COMMENT ON EXTENSION "{x.ExtensionName}" IS NULL"""
+ : $"""COMMENT ON EXTENSION "{x.ExtensionName}" IS $comment${x.NewComment}$comment$""",
GrantSchemaUsage x => $"""GRANT USAGE ON SCHEMA "{x.SchemaName}" TO {x.Role}""",
RevokeSchemaUsage x => $"""REVOKE USAGE ON SCHEMA "{x.SchemaName}" FROM {x.Role}""",
GrantTablePrivileges x => $"""GRANT {PrivilegeList(x.Privileges)} ON TABLE "{x.SchemaName}"."{x.TableName}" TO {x.Role}""",
@@ -133,6 +193,23 @@ private static string BuildCreateTable(CreateTable x)
""";
}
+ private static string BuildAddExclusionConstraint(AddExclusionConstraint x)
+ {
+ var ex = x.ExclusionConstraint;
+ var method = ex.Method is { } m ? $" USING {m}" : "";
+ var elements = string.Join(", ", ex.Elements.Select(ExclusionElementText));
+ var where = ex.Predicate is { } p ? $" WHERE ({p})" : "";
+ return $"""ALTER TABLE "{x.SchemaName}"."{x.TableName}" ADD CONSTRAINT "{ex.Name}" EXCLUDE{method} ({elements}){where}""";
+ }
+
+ // A plain column element is quoted; an expression element is parenthesised and verbatim. The operator follows
+ // WITH (e.g. =, &&) and needs no quoting.
+ private static string ExclusionElementText(ExclusionElement element)
+ {
+ var target = element.IsExpression ? $"({element.Expression})" : $"\"{element.Expression}\"";
+ return $"{target} WITH {element.Operator}";
+ }
+
private static string BuildAddForeignKey(AddForeignKey x)
{
var fk = x.ForeignKey;
@@ -143,8 +220,33 @@ private static string BuildAddForeignKey(AddForeignKey x)
private static string BuildCreateIndex(CreateIndex x)
{
- var sql = $"""CREATE {(x.Index.IsUnique ? "UNIQUE " : "")}INDEX "{x.Index.Name}" ON "{x.SchemaName}"."{x.TableName}" ({ColList(x.Index.ColumnNames)})""";
- return x.Index.Predicate is { } pred ? $"{sql} WHERE {pred}" : sql;
+ var idx = x.Index;
+ var method = idx.Method is { } m ? $" USING {m}" : "";
+ var keys = string.Join(", ", idx.Columns.Select(IndexKeyText));
+ var include = idx.Include.Count > 0 ? $" INCLUDE ({ColList(idx.Include)})" : "";
+ var sql = $"""CREATE {(idx.IsUnique ? "UNIQUE " : "")}INDEX "{idx.Name}" ON "{x.SchemaName}"."{x.TableName}"{method} ({keys}){include}""";
+ return idx.Predicate is { } pred ? $"{sql} WHERE {pred}" : sql;
+ }
+
+ // A plain column key is quoted; an expression key is emitted parenthesised and verbatim. ASC/DESC and
+ // NULLS FIRST/LAST are rendered only when explicit (IndexSort/IndexNulls.Default omits them, letting the
+ // engine default stand so the index introspects back without drift).
+ private static string IndexKeyText(IndexColumn col)
+ {
+ var key = col.IsExpression ? $"({col.Expression})" : $"\"{col.Expression}\"";
+ var sort = col.Sort switch
+ {
+ IndexSort.Ascending => " ASC",
+ IndexSort.Descending => " DESC",
+ _ => "",
+ };
+ var nulls = col.Nulls switch
+ {
+ IndexNulls.First => " NULLS FIRST",
+ IndexNulls.Last => " NULLS LAST",
+ _ => "",
+ };
+ return $"{key}{sort}{nulls}";
}
private static string BuildColumnDef(Column col)
@@ -153,7 +255,9 @@ private static string BuildColumnDef(Column col)
var nullable = col.IsNullable ? "" : " NOT NULL";
var identity = col.IsIdentity ? BuildIdentityClause(col.IdentityOptions) : "";
var def = col is { DefaultExpression: { } d, IsIdentity: false } ? $" DEFAULT {d}" : "";
- return $"\"{col.Name}\" {type}{nullable}{identity}{def}";
+ // A generated column is mutually exclusive with a default (the core's structural policy enforces this).
+ var generated = col.GeneratedExpression is { } g ? $" GENERATED ALWAYS AS ({g}) STORED" : "";
+ return $"\"{col.Name}\" {type}{nullable}{identity}{def}{generated}";
}
private static string BuildIdentityClause(IdentityOptions? options)
@@ -328,6 +432,109 @@ private static IEnumerable BuildRecreateRoutine(string kind, strin
}
}
+ // Changing a column's generation expression in place: PG 17+ replaces it with SET EXPRESSION, and a generated
+ // column is converted back to a plain one with DROP EXPRESSION (data is kept). PostgreSQL has no in-place way
+ // to make an existing plain column generated, so that transition is rejected — the column must be re-added.
+ private static string BuildSetColumnGenerated(SetColumnGenerated x) => x switch
+ {
+ { NewExpression: null } => $"""ALTER TABLE "{x.SchemaName}"."{x.TableName}" ALTER COLUMN "{x.ColumnName}" DROP EXPRESSION""",
+ { OldExpression: not null, NewExpression: { } expr } => $"""ALTER TABLE "{x.SchemaName}"."{x.TableName}" ALTER COLUMN "{x.ColumnName}" SET EXPRESSION AS ({expr})""",
+ _ => throw new NotSupportedException(
+ $"""Cannot make existing column "{x.SchemaName}"."{x.TableName}"."{x.ColumnName}" generated in place; PostgreSQL has no ADD GENERATED — drop and re-add the column."""),
+ };
+
+ private static string BuildCreateCompositeType(CreateCompositeType x)
+ {
+ var fields = string.Join(", ", x.CompositeType.Fields.Select(f => $"\"{f.Name}\" {ToPostgresType(f.DataType)}"));
+ return $"""CREATE TYPE "{x.SchemaName}"."{x.CompositeType.Name}" AS ({fields})""";
+ }
+
+ // CREATE DOMAIN name AS type [DEFAULT expr] [NOT NULL] [CONSTRAINT n CHECK (expr)]…
+ private static string BuildCreateDomain(string schema, Domain domain)
+ {
+ var sb = new StringBuilder($"""CREATE DOMAIN "{schema}"."{domain.Name}" AS {ToPostgresType(domain.DataType)}""");
+ if (domain.Default is { } def)
+ {
+ sb.Append($" DEFAULT {def}");
+ }
+ if (domain.NotNull)
+ {
+ sb.Append(" NOT NULL");
+ }
+ foreach (var check in domain.Checks)
+ {
+ sb.Append($""" CONSTRAINT "{check.Name}" CHECK ({check.Expression})""");
+ }
+ return sb.ToString();
+ }
+
+ // A domain's base type cannot be altered in place (Postgres has no ALTER DOMAIN … TYPE), so a base-type change
+ // drops + recreates — re-issuing the comment the drop discarded. Fails loudly if a column still uses the domain.
+ private static IEnumerable BuildRecreateDomain(RecreateDomain x)
+ {
+ yield return new SqlStatement($"DROP DOMAIN \"{x.SchemaName}\".\"{x.Domain.Name}\"");
+ yield return new SqlStatement(BuildCreateDomain(x.SchemaName, x.Domain));
+ if (x.Domain.Comment is { } comment)
+ {
+ yield return new SqlStatement($"""COMMENT ON DOMAIN "{x.SchemaName}"."{x.Domain.Name}" IS $comment${comment}$comment$""");
+ }
+ }
+
+ // CREATE TRIGGER name {BEFORE|AFTER|INSTEAD OF} {event [OR …]} ON s.t FOR EACH {ROW|STATEMENT}
+ // [WHEN (cond)] EXECUTE FUNCTION fn(args)
+ private static string BuildCreateTrigger(CreateTrigger x)
+ {
+ var t = x.Trigger;
+ var sb = new StringBuilder(
+ $"""CREATE TRIGGER "{t.Name}" {TriggerTimingText(t.Timing)} {TriggerEventsText(t)} ON "{x.SchemaName}"."{x.TableName}" FOR EACH {(t.Level == TriggerLevel.Row ? "ROW" : "STATEMENT")}""");
+ if (t.When is { } when)
+ {
+ sb.Append($" WHEN ({when})");
+ }
+ sb.Append($" EXECUTE FUNCTION {t.Function}({t.FunctionArguments ?? string.Empty})");
+ return sb.ToString();
+ }
+
+ private static string TriggerTimingText(TriggerTiming timing) => timing switch
+ {
+ TriggerTiming.Before => "BEFORE",
+ TriggerTiming.After => "AFTER",
+ TriggerTiming.InsteadOf => "INSTEAD OF",
+ _ => throw new ArgumentOutOfRangeException(nameof(timing), timing, "Unknown trigger timing."),
+ };
+
+ private static string TriggerEventsText(Trigger trigger)
+ {
+ var parts = new List(4);
+ if (trigger.Events.HasFlag(TriggerEvent.Insert))
+ {
+ parts.Add("INSERT");
+ }
+ if (trigger.Events.HasFlag(TriggerEvent.Update))
+ {
+ parts.Add(trigger.UpdateOfColumns.Count > 0 ? $"UPDATE OF {ColList(trigger.UpdateOfColumns)}" : "UPDATE");
+ }
+ if (trigger.Events.HasFlag(TriggerEvent.Delete))
+ {
+ parts.Add("DELETE");
+ }
+ if (trigger.Events.HasFlag(TriggerEvent.Truncate))
+ {
+ parts.Add("TRUNCATE");
+ }
+ return string.Join(" OR ", parts);
+ }
+
+ private static string BuildCreateExtension(CreateExtension x)
+ {
+ var sql = $"CREATE EXTENSION IF NOT EXISTS \"{x.Extension.Name}\"";
+ return x.Extension.Version is { } version ? $"{sql} VERSION '{EscapeLiteral(version)}'" : sql;
+ }
+
+ private static string ViewKind(bool isMaterialized) => isMaterialized ? "MATERIALIZED VIEW" : "VIEW";
+
+ private static string RoutineKeyword(RoutineKind kind) => kind == RoutineKind.Procedure ? "PROCEDURE" : "FUNCTION";
+
private static long DefaultStart(SequenceOptions options) =>
(options.IncrementBy ?? 1) > 0 ? options.MinValue ?? 1 : options.MaxValue ?? -1;
diff --git a/src/NSchema.Postgres/SqlTypePostgresExtensions.cs b/src/NSchema.Postgres/SqlTypePostgresExtensions.cs
index 4aed8fe..6ce592d 100644
--- a/src/NSchema.Postgres/SqlTypePostgresExtensions.cs
+++ b/src/NSchema.Postgres/SqlTypePostgresExtensions.cs
@@ -1,4 +1,4 @@
-using NSchema.Schema.Model;
+using NSchema.Schema.Model.Columns;
namespace NSchema.Postgres;
diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSchemaProviderTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSchemaProviderTests.cs
index d541e5a..7d4c5aa 100644
--- a/tests/NSchema.Postgres.Tests/Sql/PostgresSchemaProviderTests.cs
+++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSchemaProviderTests.cs
@@ -1,7 +1,11 @@
using Npgsql;
using NSchema.Postgres.Sql;
using NSchema.Postgres.Tests.Fixtures;
-using NSchema.Schema.Model;
+using NSchema.Schema.Model.Columns;
+using NSchema.Schema.Model.Routines;
+using NSchema.Schema.Model.Sequences;
+using NSchema.Schema.Model.Tables;
+using NSchema.Schema.Model.Views;
namespace NSchema.Postgres.Tests.Sql;
@@ -359,7 +363,7 @@ email TEXT NOT NULL
// Assert
idx.Name.ShouldBe("ix_users_email");
- idx.ColumnNames.ShouldBe(["email"]);
+ idx.Columns.Select(c => c.Expression).ShouldBe(["email"]);
idx.IsUnique.ShouldBeFalse();
}
@@ -401,7 +405,7 @@ happened TIMESTAMP NOT NULL
.Schemas[0].Tables[0].Indexes.Single();
// Assert
- idx.ColumnNames.ShouldBe(["user_id", "happened"]);
+ idx.Columns.Select(c => c.Expression).ShouldBe(["user_id", "happened"]);
}
[Fact]
@@ -897,7 +901,7 @@ RETURNS integer LANGUAGE sql AS $$ SELECT a + b $$
// Act
var function = (await _sut.GetSchema([_schema], TestContext.Current.CancellationToken))
- .Schemas[0].Functions.ShouldHaveSingleItem();
+ .Schemas[0].Routines.ShouldHaveSingleItem();
// Assert — both parts are the DB's canonical form: the argument list as pg_get_function_arguments renders
// it, and the definition starting right after the CREATE header (at RETURNS).
@@ -920,7 +924,7 @@ RETURNS text LANGUAGE sql AS $$ SELECT value $$
// Act
var function = (await _sut.GetSchema([_schema], TestContext.Current.CancellationToken))
- .Schemas[0].Functions.ShouldHaveSingleItem();
+ .Schemas[0].Routines.ShouldHaveSingleItem();
// Assert
function.Arguments.ShouldStartWith("value text DEFAULT repeat(");
@@ -935,7 +939,7 @@ public async Task GetSchema_QuotedFunctionName_HeaderStripSurvives()
// Act
var function = (await _sut.GetSchema([_schema], TestContext.Current.CancellationToken))
- .Schemas[0].Functions.ShouldHaveSingleItem();
+ .Schemas[0].Routines.ShouldHaveSingleItem();
// Assert
function.Name.ShouldBe("GetAnswer");
@@ -954,7 +958,7 @@ await Exec($"""
// Act
var function = (await _sut.GetSchema([_schema], TestContext.Current.CancellationToken))
- .Schemas[0].Functions.ShouldHaveSingleItem();
+ .Schemas[0].Routines.ShouldHaveSingleItem();
// Assert
function.Comment.ShouldBe("the answer");
@@ -969,9 +973,9 @@ public async Task GetSchema_Procedure_ReturnedAsProcedureNotFunction()
// Act
var schema = (await _sut.GetSchema([_schema], TestContext.Current.CancellationToken)).Schemas[0];
- // Assert — prokind separates the two sets; a procedure must not leak into Functions.
- schema.Functions.ShouldBeEmpty();
- var procedure = schema.Procedures.ShouldHaveSingleItem();
+ // Assert — prokind is carried as Routine.Kind; a procedure must be tagged Procedure, not Function.
+ var procedure = schema.Routines.ShouldHaveSingleItem();
+ procedure.Kind.ShouldBe(RoutineKind.Procedure);
procedure.Name.ShouldBe("noop");
procedure.Arguments.ShouldBe("a integer");
procedure.Definition.ShouldStartWith("LANGUAGE sql");
@@ -989,9 +993,10 @@ await Exec($"""
// Act
var procedure = (await _sut.GetSchema([_schema], TestContext.Current.CancellationToken))
- .Schemas[0].Procedures.ShouldHaveSingleItem();
+ .Schemas[0].Routines.ShouldHaveSingleItem();
// Assert
+ procedure.Kind.ShouldBe(RoutineKind.Procedure);
procedure.Comment.ShouldBe("does nothing");
}
@@ -1007,8 +1012,7 @@ public async Task GetSchema_ExtensionFunctions_AreExcluded()
.Schemas.Single(s => s.Name == "public");
// Assert
- publicSchema.Functions.ShouldBeEmpty();
- publicSchema.Procedures.ShouldBeEmpty();
+ publicSchema.Routines.ShouldBeEmpty();
}
[Fact]
@@ -1021,7 +1025,21 @@ public async Task GetSchema_Aggregate_IsNotReturnedAsFunction()
var schema = (await _sut.GetSchema([_schema], TestContext.Current.CancellationToken)).Schemas[0];
// Assert
- schema.Functions.ShouldBeEmpty();
- schema.Procedures.ShouldBeEmpty();
+ schema.Routines.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task GetSchema_Extensions_AreReportedAtRootWithVersion()
+ {
+ // Arrange — the fixture enables citext database-wide; extensions are global, so a schema-scoped read still
+ // surfaces them at the root. plpgsql (the always-present default) is excluded.
+
+ // Act
+ var schema = await _sut.GetSchema([_schema], TestContext.Current.CancellationToken);
+
+ // Assert
+ var citext = schema.Extensions.Single(e => e.Name == "citext");
+ citext.Version.ShouldNotBeNull();
+ schema.Extensions.ShouldNotContain(e => e.Name == "plpgsql");
}
}
diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs
index 27929da..5c42216 100644
--- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs
+++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorSnapshotTests.cs
@@ -1,6 +1,31 @@
using NSchema.Plan.Model;
+using NSchema.Plan.Model.Columns;
+using NSchema.Plan.Model.CompositeTypes;
+using NSchema.Plan.Model.Constraints;
+using NSchema.Plan.Model.Domains;
+using NSchema.Plan.Model.Enums;
+using NSchema.Plan.Model.Extensions;
+using NSchema.Plan.Model.Indexes;
+using NSchema.Plan.Model.Routines;
+using NSchema.Plan.Model.Schemas;
+using NSchema.Plan.Model.Sequence;
+using NSchema.Plan.Model.Tables;
+using NSchema.Plan.Model.Triggers;
+using NSchema.Plan.Model.Views;
using NSchema.Postgres.Sql;
-using NSchema.Schema.Model;
+using NSchema.Schema.Model.Columns;
+using NSchema.Schema.Model.CompositeTypes;
+using NSchema.Schema.Model.Constraints;
+using NSchema.Schema.Model.Domains;
+using NSchema.Schema.Model.Enums;
+using NSchema.Schema.Model.Extensions;
+using NSchema.Schema.Model.Indexes;
+using NSchema.Schema.Model.Routines;
+using NSchema.Schema.Model.Scripts;
+using NSchema.Schema.Model.Sequences;
+using NSchema.Schema.Model.Tables;
+using NSchema.Schema.Model.Triggers;
+using NSchema.Schema.Model.Views;
using NSchema.Sql;
namespace NSchema.Postgres.Tests.Sql;
@@ -73,6 +98,20 @@ public Task AlterIdentitySequence() => VerifyPlan(
OldOptions: new IdentityOptions(StartWith: 1, MinValue: 1, IncrementBy: 1),
NewOptions: new IdentityOptions(StartWith: 500, MinValue: 100, IncrementBy: 2)));
+ [Fact]
+ public Task GeneratedColumnOperations() => VerifyPlan(
+ new CreateTable("public", new Table("boxes",
+ Columns:
+ [
+ new Column("w", SqlType.Int, IsNullable: false),
+ new Column("h", SqlType.Int, IsNullable: false),
+ new Column("area", SqlType.Int, GeneratedExpression: "w * h"),
+ ])),
+ new AddColumn("public", "boxes", new Column("perimeter", SqlType.Int, GeneratedExpression: "2 * (w + h)")),
+ // Change the expression in place (SET EXPRESSION), then drop the generation (DROP EXPRESSION).
+ new SetColumnGenerated("public", "boxes", "area", "w * h", "w * h * 2"),
+ new SetColumnGenerated("public", "boxes", "area", "w * h * 2", null));
+
// ── Keys, indexes and constraints ───────────────────────────────────────────
[Fact]
@@ -91,8 +130,34 @@ public Task ForeignKeyOperations() => VerifyPlan(
public Task IndexOperations() => VerifyPlan(
new CreateIndex("public", "users", new TableIndex("idx_users_email", ["email"], IsUnique: true)),
new CreateIndex("public", "users", new TableIndex("idx_users_active", ["created_at"], Predicate: "notes IS NOT NULL")),
+ // An access method (USING), a covering INCLUDE, descending / nulls ordering, and an expression key.
+ new CreateIndex("public", "users", new TableIndex("idx_users_tags", ["tags"], Method: "gin")),
+ new CreateIndex("public", "users", new TableIndex("idx_users_recent",
+ [new IndexColumn("created_at", Sort: IndexSort.Descending, Nulls: IndexNulls.Last), new IndexColumn("lower(email)", IsExpression: true)],
+ Include: ["id", "notes"])),
new DropIndex("public", "users", "idx_users_email"));
+ [Fact]
+ public Task ExclusionConstraintOperations() => VerifyPlan(
+ new AddExclusionConstraint("public", "bookings", new ExclusionConstraint("no_overlap",
+ [new ExclusionElement("room", "="), new ExclusionElement("during", "&&")], Method: "gist", Predicate: "room > 0")),
+ // An expression element is parenthesised.
+ new AddExclusionConstraint("public", "events", new ExclusionConstraint("no_clash",
+ [new ExclusionElement("tstzrange(starts, ends)", "&&", IsExpression: true)], Method: "gist")),
+ new DropExclusionConstraint("public", "bookings", "no_overlap"));
+
+ // ── Triggers ──────────────────────────────────────────────────────────────
+
+ [Fact]
+ public Task TriggerOperations() => VerifyPlan(
+ new CreateTrigger("public", "users", new Trigger("users_audit", TriggerTiming.After,
+ TriggerEvent.Insert | TriggerEvent.Update, "public.log_change", TriggerLevel.Row,
+ UpdateOfColumns: ["email", "name"], When: "new.active", FunctionArguments: "'audit'")),
+ new CreateTrigger("public", "logs", new Trigger("logs_truncate", TriggerTiming.Before,
+ TriggerEvent.Truncate, "public.on_truncate", TriggerLevel.Statement)),
+ new SetTriggerComment("public", "users", "users_audit", null, "audit changes"),
+ new DropTrigger("public", "users", "users_audit"));
+
// ── Views ─────────────────────────────────────────────────────────────────
[Fact]
@@ -103,6 +168,16 @@ public Task ViewOperations() => VerifyPlan(
new SetViewComment("public", "active_users", "Active users only", null),
new DropView("public", "active_users"));
+ [Fact]
+ public Task MaterializedViewOperations() => VerifyPlan(
+ // A materialized view: CREATE MATERIALIZED VIEW (never CREATE OR REPLACE), an index on it (a plain
+ // CreateIndex), and the MATERIALIZED variants of rename/comment/drop.
+ new CreateView("public", new View("daily_totals", "SELECT date, sum(amount) AS total FROM public.sales GROUP BY date", IsMaterialized: true)),
+ new CreateIndex("public", "daily_totals", new TableIndex("idx_daily_totals_date", ["date"], IsUnique: true)),
+ new RenameView("public", "legacy_totals", "daily_totals", IsMaterialized: true),
+ new SetViewComment("public", "daily_totals", null, "Daily rollup", IsMaterialized: true),
+ new DropView("public", "daily_totals", IsMaterialized: true));
+
// ── Enums ──────────────────────────────────────────────────────────────────
[Fact]
@@ -116,6 +191,36 @@ public Task EnumOperations() => VerifyPlan(
new SetEnumComment("public", "order_status", "Order lifecycle", null),
new DropEnum("public", "order_status"));
+ // ── Composite types ──────────────────────────────────────────────────────
+
+ [Fact]
+ public Task CompositeTypeOperations() => VerifyPlan(
+ new CreateCompositeType("public", new CompositeType("address",
+ [new CompositeField("street", SqlType.Text), new CompositeField("zip", SqlType.Int)])),
+ new AddCompositeField("public", "address", new CompositeField("country", SqlType.Text)),
+ new AlterCompositeFieldType("public", "address", "zip", SqlType.Int, SqlType.VarChar(10)),
+ new DropCompositeField("public", "address", "country"),
+ new RenameCompositeType("public", "old_address", "address"),
+ new SetCompositeTypeComment("public", "address", null, "a postal address"),
+ new DropCompositeType("public", "address"));
+
+ // ── Domains ────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public Task DomainOperations() => VerifyPlan(
+ new CreateDomain("public", new Domain("email", SqlType.Text, Default: "'n/a'", NotNull: true,
+ Checks: [new CheckConstraint("email_fmt", "VALUE ~ '@'")])),
+ new AlterDomainDefault("public", "email", "'n/a'", "'unknown'"),
+ new AlterDomainDefault("public", "email", "'unknown'", null),
+ new AlterDomainNotNull("public", "email", false),
+ new AddDomainCheck("public", "email", new CheckConstraint("email_len", "length(VALUE) > 3")),
+ new DropDomainCheck("public", "email", "email_fmt"),
+ // A base-type change recreates (drop + create, re-issuing the comment).
+ new RecreateDomain("public", new Domain("code", SqlType.VarChar(8), Comment: "a code")),
+ new RenameDomain("public", "old_code", "code"),
+ new SetDomainComment("public", "email", null, "an email"),
+ new DropDomain("public", "email"));
+
// ── Sequences ──────────────────────────────────────────────────────────────
[Fact]
@@ -132,34 +237,46 @@ public Task SequenceOperations() => VerifyPlan(
new SetSequenceComment("public", "invoice_id", "Invoice numbers", null),
new DropSequence("public", "invoice_id"));
+ // ── Extensions ────────────────────────────────────────────────────────────
+
+ [Fact]
+ public Task ExtensionOperations() => VerifyPlan(
+ new CreateExtension(new Extension("citext")),
+ new CreateExtension(new Extension("postgis", Version: "3.4")),
+ // A hyphenated name must be quoted.
+ new CreateExtension(new Extension("uuid-ossp")),
+ new AlterExtension("postgis", "3.4", "3.5"),
+ new SetExtensionComment("citext", null, "case-insensitive text"),
+ new DropExtension("postgis"));
+
// ── Functions ─────────────────────────────────────────────────────────────
[Fact]
public Task FunctionOperations() => VerifyPlan(
- new CreateFunction("public", new Function("active_user_count", "",
+ new CreateRoutine("public", new Routine("active_user_count", RoutineKind.Function, "",
"RETURNS integer LANGUAGE sql AS $$ SELECT count(*) FROM public.users WHERE active $$")),
- new RenameFunction("public", "user_count", "active_user_count"),
+ new RenameRoutine("public", "user_count", "active_user_count", RoutineKind.Function),
// A signature change: drop + recreate, re-issuing the comment the drop discarded.
- new RecreateFunction("public", new Function("add_numbers", "a integer, b integer, c integer DEFAULT 0",
+ new RecreateRoutine("public", new Routine("add_numbers", RoutineKind.Function, "a integer, b integer, c integer DEFAULT 0",
"RETURNS integer LANGUAGE sql AS $$ SELECT a + b + c $$", Comment: "Adds numbers")),
- new RecreateFunction("public", new Function("subtract_numbers", "a integer, b integer",
+ new RecreateRoutine("public", new Routine("subtract_numbers", RoutineKind.Function, "a integer, b integer",
"RETURNS integer LANGUAGE sql AS $$ SELECT a - b $$")),
- new SetFunctionComment("public", "active_user_count", null, "Count of active users"),
- new SetFunctionComment("public", "active_user_count", "Count of active users", null),
- new DropFunction("public", "active_user_count"));
+ new SetRoutineComment("public", "active_user_count", null, "Count of active users", RoutineKind.Function),
+ new SetRoutineComment("public", "active_user_count", "Count of active users", null, RoutineKind.Function),
+ new DropRoutine("public", "active_user_count", RoutineKind.Function));
// ── Procedures ────────────────────────────────────────────────────────────
[Fact]
public Task ProcedureOperations() => VerifyPlan(
- new CreateProcedure("public", new Procedure("archive_users", "cutoff date",
+ new CreateRoutine("public", new Routine("archive_users", RoutineKind.Procedure, "cutoff date",
"LANGUAGE sql AS $$ DELETE FROM public.users WHERE created_at < cutoff $$")),
- new RenameProcedure("public", "purge_users", "archive_users"),
- new RecreateProcedure("public", new Procedure("archive_users", "cutoff timestamptz",
+ new RenameRoutine("public", "purge_users", "archive_users", RoutineKind.Procedure),
+ new RecreateRoutine("public", new Routine("archive_users", RoutineKind.Procedure, "cutoff timestamptz",
"LANGUAGE sql AS $$ DELETE FROM public.users WHERE created_at < cutoff $$", Comment: "Archives stale users")),
- new SetProcedureComment("public", "archive_users", null, "Archive job"),
- new SetProcedureComment("public", "archive_users", "Archive job", null),
- new DropProcedure("public", "archive_users"));
+ new SetRoutineComment("public", "archive_users", null, "Archive job", RoutineKind.Procedure),
+ new SetRoutineComment("public", "archive_users", "Archive job", null, RoutineKind.Procedure),
+ new DropRoutine("public", "archive_users", RoutineKind.Procedure));
// ── Comments ────────────────────────────────────────────────────────────────
diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs
index 2d10d5b..6190091 100644
--- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs
+++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlGeneratorTests.cs
@@ -1,8 +1,33 @@
using Npgsql;
using NSchema.Plan.Model;
+using NSchema.Plan.Model.Columns;
+using NSchema.Plan.Model.CompositeTypes;
+using NSchema.Plan.Model.Constraints;
+using NSchema.Plan.Model.Domains;
+using NSchema.Plan.Model.Enums;
+using NSchema.Plan.Model.Extensions;
+using NSchema.Plan.Model.Indexes;
+using NSchema.Plan.Model.Routines;
+using NSchema.Plan.Model.Schemas;
+using NSchema.Plan.Model.Sequence;
+using NSchema.Plan.Model.Tables;
+using NSchema.Plan.Model.Triggers;
+using NSchema.Plan.Model.Views;
using NSchema.Postgres.Sql;
using NSchema.Postgres.Tests.Fixtures;
-using NSchema.Schema.Model;
+using NSchema.Schema.Model.Columns;
+using NSchema.Schema.Model.CompositeTypes;
+using NSchema.Schema.Model.Constraints;
+using NSchema.Schema.Model.Domains;
+using NSchema.Schema.Model.Enums;
+using NSchema.Schema.Model.Extensions;
+using NSchema.Schema.Model.Indexes;
+using NSchema.Schema.Model.Routines;
+using NSchema.Schema.Model.Scripts;
+using NSchema.Schema.Model.Sequences;
+using NSchema.Schema.Model.Tables;
+using NSchema.Schema.Model.Triggers;
+using NSchema.Schema.Model.Views;
using NSchema.Sql.Model;
namespace NSchema.Postgres.Tests.Sql;
@@ -280,6 +305,54 @@ public async Task SetColumnDefault_DropsDefaultExpression()
hasDefault.ShouldBeFalse();
}
+ [Fact]
+ public async Task RoundTrip_GeneratedColumn_IntrospectsAsGeneratedNotDefault()
+ {
+ // Arrange — a stored generated column applied via CREATE TABLE must read back as generated, with the
+ // expression in GeneratedExpression and no DefaultExpression (the two are mutually exclusive).
+ var table = new Table("boxes", Columns:
+ [
+ new Column("w", SqlType.Int, IsNullable: false),
+ new Column("h", SqlType.Int, IsNullable: false),
+ new Column("area", SqlType.Int, GeneratedExpression: "w * h"),
+ ]);
+
+ // Act
+ await _executor.Execute(_generator.Generate(new MigrationPlan([new CreateTable(_schema, table)], [], [])), TestContext.Current.CancellationToken);
+
+ // Assert
+ var provider = new PostgresSchemaProvider(_dataSource);
+ var area = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken))
+ .Schemas[0].Tables[0].Columns.Single(c => c.Name == "area");
+ area.GeneratedExpression.ShouldNotBeNull();
+ area.GeneratedExpression!.ShouldContain("w * h");
+ area.DefaultExpression.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task SetColumnGenerated_ChangesAndDropsExpression()
+ {
+ // Arrange
+ await Exec($"""CREATE TABLE "{_schema}".boxes (w int, h int, area int GENERATED ALWAYS AS (w * h) STORED)""");
+ var provider = new PostgresSchemaProvider(_dataSource);
+
+ // Act — change the expression (SET EXPRESSION)...
+ await _executor.Execute(_generator.Generate(new MigrationPlan(
+ [new SetColumnGenerated(_schema, "boxes", "area", "w * h", "w + h")], [], [])), TestContext.Current.CancellationToken);
+ var changed = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken))
+ .Schemas[0].Tables[0].Columns.Single(c => c.Name == "area");
+
+ // ...then drop it (DROP EXPRESSION), making it a plain column.
+ await _executor.Execute(_generator.Generate(new MigrationPlan(
+ [new SetColumnGenerated(_schema, "boxes", "area", "w + h", null)], [], [])), TestContext.Current.CancellationToken);
+ var dropped = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken))
+ .Schemas[0].Tables[0].Columns.Single(c => c.Name == "area");
+
+ // Assert
+ changed.GeneratedExpression!.ShouldContain("w + h");
+ dropped.GeneratedExpression.ShouldBeNull();
+ }
+
// ── Primary key operations ────────────────────────────────────────────────
[Fact]
@@ -413,6 +486,55 @@ public async Task DropCheckConstraint_RemovesConstraintFromTable()
exists.ShouldBeFalse();
}
+ // ── Exclusion constraint operations ───────────────────────────────────────
+
+ [Fact]
+ public async Task AddExclusionConstraint_MultiElement_RoundTripsThroughIntrospection()
+ {
+ // Arrange — the canonical "no overlapping booking of the same room": a scalar `=` plus a range `&&`.
+ // The scalar element in a gist index needs btree_gist (a contrib extension shipped with the image).
+ await Exec("CREATE EXTENSION IF NOT EXISTS btree_gist");
+ await Exec($"""CREATE TABLE "{_schema}".bookings (room integer, during tstzrange)""");
+ var exclusion = new ExclusionConstraint("no_overlap",
+ [new ExclusionElement("room", "="), new ExclusionElement("during", "&&")], Method: "gist", Predicate: "room > 0");
+
+ // Act
+ await _executor.Execute(_generator.Generate(new MigrationPlan(
+ [new AddExclusionConstraint(_schema, "bookings", exclusion)], [], [])), TestContext.Current.CancellationToken);
+
+ // Assert
+ var provider = new PostgresSchemaProvider(_dataSource);
+ var introspected = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken))
+ .Schemas[0].Tables[0].ExclusionConstraints.ShouldHaveSingleItem();
+ introspected.Name.ShouldBe("no_overlap");
+ introspected.Method.ShouldBe("gist");
+ introspected.Predicate.ShouldNotBeNull();
+ introspected.Predicate!.ShouldContain("room > 0");
+ introspected.Elements.Select(e => (e.Expression, e.Operator, e.IsExpression))
+ .ShouldBe([("room", "=", false), ("during", "&&", false)]);
+ }
+
+ [Fact]
+ public async Task AddExclusionConstraint_ExpressionElement_RoundTripsThroughIntrospection()
+ {
+ // Arrange — an expression element (a computed range) excluded with &&. No btree_gist needed.
+ await Exec($"""CREATE TABLE "{_schema}".events (starts timestamptz, ends timestamptz)""");
+ var exclusion = new ExclusionConstraint("no_clash",
+ [new ExclusionElement("tstzrange(starts, ends)", "&&", IsExpression: true)], Method: "gist");
+
+ // Act
+ await _executor.Execute(_generator.Generate(new MigrationPlan(
+ [new AddExclusionConstraint(_schema, "events", exclusion)], [], [])), TestContext.Current.CancellationToken);
+
+ // Assert
+ var provider = new PostgresSchemaProvider(_dataSource);
+ var element = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken))
+ .Schemas[0].Tables[0].ExclusionConstraints.ShouldHaveSingleItem().Elements.ShouldHaveSingleItem();
+ element.IsExpression.ShouldBeTrue();
+ element.Expression.ShouldContain("tstzrange");
+ element.Operator.ShouldBe("&&");
+ }
+
// ── Constraint comments ───────────────────────────────────────────────────
[Fact]
@@ -480,6 +602,49 @@ public async Task CreateIndex_Unique_CreatesUniqueIndexOnTable()
isUnique.ShouldBeTrue();
}
+ [Fact]
+ public async Task CreateIndex_RichIndex_RoundTripsThroughIntrospection()
+ {
+ // Arrange — a covering index with a descending key, an explicit non-default null ordering, and an
+ // expression key. What is applied must introspect back to the same shape (no phantom drift).
+ await Exec($"""CREATE TABLE "{_schema}"."items" (id integer, name text, qty integer)""");
+ var index = new TableIndex("idx_items_rich",
+ [new IndexColumn("id", Sort: IndexSort.Descending, Nulls: IndexNulls.Last), new IndexColumn("lower(name)", IsExpression: true)],
+ Include: ["qty"]);
+
+ // Act
+ await _executor.Execute(_generator.Generate(new MigrationPlan([new CreateIndex(_schema, "items", index)], [], [])), TestContext.Current.CancellationToken);
+
+ // Assert
+ var provider = new PostgresSchemaProvider(_dataSource);
+ var introspected = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken))
+ .Schemas[0].Tables[0].Indexes.ShouldHaveSingleItem();
+ introspected.Method.ShouldBeNull(); // btree folds to null
+ introspected.Include.ShouldBe(["qty"]);
+ introspected.Columns.Count.ShouldBe(2);
+ introspected.Columns[0].ShouldBe(new IndexColumn("id", IsExpression: false, Sort: IndexSort.Descending, Nulls: IndexNulls.Last));
+ introspected.Columns[1].IsExpression.ShouldBeTrue();
+ introspected.Columns[1].Expression.ShouldContain("lower");
+ }
+
+ [Fact]
+ public async Task CreateIndex_GinMethod_RoundTripsPreservingMethod()
+ {
+ // Arrange — a non-btree access method must survive introspection (it does not fold to null).
+ await Exec($"""CREATE TABLE "{_schema}"."docs" (tags text[])""");
+ var index = new TableIndex("idx_docs_tags", ["tags"], Method: "gin");
+
+ // Act
+ await _executor.Execute(_generator.Generate(new MigrationPlan([new CreateIndex(_schema, "docs", index)], [], [])), TestContext.Current.CancellationToken);
+
+ // Assert
+ var provider = new PostgresSchemaProvider(_dataSource);
+ var introspected = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken))
+ .Schemas[0].Tables[0].Indexes.ShouldHaveSingleItem();
+ introspected.Method.ShouldBe("gin");
+ introspected.Columns.ShouldHaveSingleItem().Expression.ShouldBe("tags");
+ }
+
[Fact]
public async Task DropIndex_RemovesIndexFromTable()
{
@@ -579,6 +744,122 @@ public async Task SetViewComment_SetsComment()
comment.ShouldBe("the view");
}
+ [Fact]
+ public async Task RoundTrip_MaterializedView_IntrospectsAsMaterializedWithIndex()
+ {
+ // Arrange — a materialized view over a base table, plus a unique index on it.
+ await Exec($"""CREATE TABLE "{_schema}".sales (id integer, amount integer)""");
+ var view = new View("totals", $"""SELECT id, sum(amount) AS total FROM "{_schema}".sales GROUP BY id""", IsMaterialized: true);
+
+ // Act
+ await _executor.Execute(_generator.Generate(new MigrationPlan([new CreateView(_schema, view)], [], [])), TestContext.Current.CancellationToken);
+ await _executor.Execute(_generator.Generate(new MigrationPlan(
+ [new CreateIndex(_schema, "totals", new TableIndex("idx_totals_id", ["id"], IsUnique: true))], [], [])), TestContext.Current.CancellationToken);
+
+ // Assert
+ var provider = new PostgresSchemaProvider(_dataSource);
+ var introspected = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken))
+ .Schemas[0].Views.ShouldHaveSingleItem();
+ introspected.IsMaterialized.ShouldBeTrue();
+ introspected.Body.ShouldContain("sum");
+ introspected.DependsOn.ShouldContain(d => d.Name == "sales");
+ var index = introspected.Indexes.ShouldHaveSingleItem();
+ index.Name.ShouldBe("idx_totals_id");
+ index.IsUnique.ShouldBeTrue();
+ index.Columns.ShouldHaveSingleItem().Expression.ShouldBe("id");
+ }
+
+ // ── Triggers ──────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task RoundTrip_Trigger_IntrospectsWithDecodedAttributes()
+ {
+ // Arrange — a trigger function, then a row-level AFTER trigger with UPDATE OF and a WHEN condition.
+ await Exec($"""CREATE TABLE "{_schema}".users (id int, email text, active boolean)""");
+ await Exec($"""CREATE FUNCTION "{_schema}".audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$""");
+ var trigger = new Trigger("users_audit", TriggerTiming.After, TriggerEvent.Insert | TriggerEvent.Update,
+ $"{_schema}.audit", TriggerLevel.Row, UpdateOfColumns: ["email"], When: "new.active");
+
+ // Act
+ await _executor.Execute(_generator.Generate(new MigrationPlan([new CreateTrigger(_schema, "users", trigger)], [], [])), TestContext.Current.CancellationToken);
+
+ // Assert — the tgtype bitmask decodes back to the same timing/level/events.
+ var provider = new PostgresSchemaProvider(_dataSource);
+ var introspected = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken))
+ .Schemas[0].Tables[0].Triggers.ShouldHaveSingleItem();
+ introspected.Name.ShouldBe("users_audit");
+ introspected.Timing.ShouldBe(TriggerTiming.After);
+ introspected.Level.ShouldBe(TriggerLevel.Row);
+ introspected.Events.ShouldBe(TriggerEvent.Insert | TriggerEvent.Update);
+ introspected.UpdateOfColumns.ShouldBe(["email"]);
+ introspected.Function.ShouldBe($"{_schema}.audit");
+ introspected.When.ShouldNotBeNull();
+ introspected.When!.ShouldContain("active");
+ introspected.FunctionArguments.ShouldBeNull();
+ }
+
+ // ── Extensions ────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task CreateExtension_ThenIntrospect_ReportsExtension()
+ {
+ // Act — create a contrib extension via the generator (extensions are database-global).
+ await _executor.Execute(_generator.Generate(new MigrationPlan([new CreateExtension(new Extension("hstore"))], [], [])), TestContext.Current.CancellationToken);
+
+ // Assert — it surfaces as a root-level extension with a version; plpgsql is excluded.
+ var provider = new PostgresSchemaProvider(_dataSource);
+ var schema = await provider.GetSchema([_schema], TestContext.Current.CancellationToken);
+ var hstore = schema.Extensions.Single(e => e.Name == "hstore");
+ hstore.Version.ShouldNotBeNull();
+ schema.Extensions.ShouldNotContain(e => e.Name == "plpgsql");
+ }
+
+ // ── Composite types ──────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task RoundTrip_CompositeType_IntrospectsWithFields()
+ {
+ // Arrange
+ var type = new CompositeType("address", [new CompositeField("street", SqlType.Text), new CompositeField("zip", SqlType.Int)]);
+
+ // Act — create, then exercise an in-place field add (ALTER TYPE … ADD ATTRIBUTE).
+ await _executor.Execute(_generator.Generate(new MigrationPlan([new CreateCompositeType(_schema, type)], [], [])), TestContext.Current.CancellationToken);
+ await _executor.Execute(_generator.Generate(new MigrationPlan(
+ [new AddCompositeField(_schema, "address", new CompositeField("country", SqlType.Text))], [], [])), TestContext.Current.CancellationToken);
+
+ // Assert
+ var provider = new PostgresSchemaProvider(_dataSource);
+ var introspected = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken))
+ .Schemas[0].CompositeTypes.ShouldHaveSingleItem();
+ introspected.Name.ShouldBe("address");
+ introspected.Fields.Select(f => (f.Name, f.DataType)).ShouldBe(
+ [("street", SqlType.Text), ("zip", SqlType.Int), ("country", SqlType.Text)]);
+ }
+
+ // ── Domains ───────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task RoundTrip_Domain_IntrospectsWithAllFacets()
+ {
+ // Arrange — a domain over text with a default, NOT NULL, and a named check.
+ var domain = new Domain("us_postal", SqlType.Text, Default: "'00000'", NotNull: true,
+ Checks: [new CheckConstraint("us_postal_fmt", "VALUE ~ '^[0-9]{5}$'")]);
+
+ // Act
+ await _executor.Execute(_generator.Generate(new MigrationPlan([new CreateDomain(_schema, domain)], [], [])), TestContext.Current.CancellationToken);
+
+ // Assert
+ var provider = new PostgresSchemaProvider(_dataSource);
+ var introspected = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken))
+ .Schemas[0].Domains.ShouldHaveSingleItem();
+ introspected.Name.ShouldBe("us_postal");
+ introspected.DataType.ShouldBe(SqlType.Text);
+ introspected.NotNull.ShouldBeTrue();
+ introspected.Default.ShouldNotBeNull();
+ introspected.Default!.ShouldContain("00000");
+ introspected.Checks.ShouldHaveSingleItem().Name.ShouldBe("us_postal_fmt");
+ }
+
// ── Deployment scripts ────────────────────────────────────────────────────
[Fact]
@@ -847,12 +1128,12 @@ SELECT COUNT(*) > 0 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespac
public async Task CreateFunction_CreatesFunctionInDatabase()
{
// Arrange
- var function = new Function("add_numbers", "a integer, b integer",
+ var function = new Routine("add_numbers", RoutineKind.Function, "a integer, b integer",
"RETURNS integer LANGUAGE sql AS $$ SELECT a + b $$");
// Act
await _executor.Execute(_generator.Generate(new MigrationPlan(
- [new CreateFunction(_schema, function)], [], [])), TestContext.Current.CancellationToken);
+ [new CreateRoutine(_schema, function)], [], [])), TestContext.Current.CancellationToken);
// Assert
(await ScalarString($"""SELECT "{_schema}".add_numbers(2, 3)::text""")).ShouldBe("5");
@@ -863,11 +1144,11 @@ public async Task CreateFunction_OnExistingFunction_ReplacesDefinition()
{
// Arrange — CreateFunction serves both add and definition-only modify; the second create must replace.
await Exec($"""CREATE FUNCTION "{_schema}".answer() RETURNS integer LANGUAGE sql AS $$ SELECT 1 $$""");
- var replacement = new Function("answer", "", "RETURNS integer LANGUAGE sql AS $$ SELECT 42 $$");
+ var replacement = new Routine("answer", RoutineKind.Function, "", "RETURNS integer LANGUAGE sql AS $$ SELECT 42 $$");
// Act
await _executor.Execute(_generator.Generate(new MigrationPlan(
- [new CreateFunction(_schema, replacement)], [], [])), TestContext.Current.CancellationToken);
+ [new CreateRoutine(_schema, replacement)], [], [])), TestContext.Current.CancellationToken);
// Assert
(await ScalarString($"""SELECT "{_schema}".answer()::text""")).ShouldBe("42");
@@ -882,12 +1163,12 @@ await Exec($"""
CREATE FUNCTION "{_schema}".add_numbers(a integer, b integer) RETURNS integer LANGUAGE sql AS $$ SELECT a + b $$;
COMMENT ON FUNCTION "{_schema}".add_numbers IS 'Adds numbers';
""");
- var desired = new Function("add_numbers", "a integer, b integer, c integer",
+ var desired = new Routine("add_numbers", RoutineKind.Function, "a integer, b integer, c integer",
"RETURNS integer LANGUAGE sql AS $$ SELECT a + b + c $$", Comment: "Adds numbers");
// Act
await _executor.Execute(_generator.Generate(new MigrationPlan(
- [new RecreateFunction(_schema, desired)], [], [])), TestContext.Current.CancellationToken);
+ [new RecreateRoutine(_schema, desired)], [], [])), TestContext.Current.CancellationToken);
// Assert — exactly one routine remains, under the new signature, with the comment restored.
var count = await ScalarString($"""
@@ -907,7 +1188,7 @@ public async Task RenameFunction_RenamesFunction()
// Act
await _executor.Execute(_generator.Generate(new MigrationPlan(
- [new RenameFunction(_schema, "old_answer", "answer")], [], [])), TestContext.Current.CancellationToken);
+ [new RenameRoutine(_schema, "old_answer", "answer", RoutineKind.Function)], [], [])), TestContext.Current.CancellationToken);
// Assert
(await ScalarString($"""SELECT "{_schema}".answer()::text""")).ShouldBe("42");
@@ -922,12 +1203,12 @@ public async Task SetFunctionComment_SetsAndClearsComment()
// Act + Assert — set...
await _executor.Execute(_generator.Generate(new MigrationPlan(
- [new SetFunctionComment(_schema, "answer", null, "the answer")], [], [])), TestContext.Current.CancellationToken);
+ [new SetRoutineComment(_schema, "answer", null, "the answer", RoutineKind.Function)], [], [])), TestContext.Current.CancellationToken);
(await ScalarString(commentSql)).ShouldBe("the answer");
// ...and clear.
await _executor.Execute(_generator.Generate(new MigrationPlan(
- [new SetFunctionComment(_schema, "answer", "the answer", null)], [], [])), TestContext.Current.CancellationToken);
+ [new SetRoutineComment(_schema, "answer", "the answer", null, RoutineKind.Function)], [], [])), TestContext.Current.CancellationToken);
(await ScalarBool($"SELECT ({commentSql}) IS NULL")).ShouldBeTrue();
}
@@ -939,7 +1220,7 @@ public async Task DropFunction_RemovesFunction()
// Act
await _executor.Execute(_generator.Generate(new MigrationPlan(
- [new DropFunction(_schema, "answer")], [], [])), TestContext.Current.CancellationToken);
+ [new DropRoutine(_schema, "answer", RoutineKind.Function)], [], [])), TestContext.Current.CancellationToken);
// Assert
var exists = await ScalarBool($"""
@@ -956,12 +1237,12 @@ public async Task CreateProcedure_CreatesProcedureInDatabase()
{
// Arrange
await Exec($"""CREATE TABLE "{_schema}".audit (entry text)""");
- var procedure = new Procedure("log_entry", "message text",
+ var procedure = new Routine("log_entry", RoutineKind.Procedure, "message text",
$"""LANGUAGE sql AS $$ INSERT INTO "{_schema}".audit (entry) VALUES (message) $$""");
// Act
await _executor.Execute(_generator.Generate(new MigrationPlan(
- [new CreateProcedure(_schema, procedure)], [], [])), TestContext.Current.CancellationToken);
+ [new CreateRoutine(_schema, procedure)], [], [])), TestContext.Current.CancellationToken);
// Assert — the procedure exists and is callable.
await Exec($"""CALL "{_schema}".log_entry('hello')""");
@@ -976,12 +1257,12 @@ await Exec($"""
CREATE PROCEDURE "{_schema}".noop(a integer) LANGUAGE sql AS $$ SELECT 1 $$;
COMMENT ON PROCEDURE "{_schema}".noop IS 'does nothing';
""");
- var desired = new Procedure("noop", "a integer, b integer",
+ var desired = new Routine("noop", RoutineKind.Procedure, "a integer, b integer",
"LANGUAGE sql AS $$ SELECT 1 $$", Comment: "does nothing");
// Act
await _executor.Execute(_generator.Generate(new MigrationPlan(
- [new RecreateProcedure(_schema, desired)], [], [])), TestContext.Current.CancellationToken);
+ [new RecreateRoutine(_schema, desired)], [], [])), TestContext.Current.CancellationToken);
// Assert
var count = await ScalarString($"""
@@ -1001,7 +1282,7 @@ public async Task RenameProcedure_RenamesProcedure()
// Act
await _executor.Execute(_generator.Generate(new MigrationPlan(
- [new RenameProcedure(_schema, "old_noop", "noop")], [], [])), TestContext.Current.CancellationToken);
+ [new RenameRoutine(_schema, "old_noop", "noop", RoutineKind.Procedure)], [], [])), TestContext.Current.CancellationToken);
// Assert
await Exec($"""CALL "{_schema}".noop()""");
@@ -1016,12 +1297,12 @@ public async Task SetProcedureComment_SetsAndClearsComment()
// Act + Assert — set...
await _executor.Execute(_generator.Generate(new MigrationPlan(
- [new SetProcedureComment(_schema, "noop", null, "does nothing")], [], [])), TestContext.Current.CancellationToken);
+ [new SetRoutineComment(_schema, "noop", null, "does nothing", RoutineKind.Procedure)], [], [])), TestContext.Current.CancellationToken);
(await ScalarString(commentSql)).ShouldBe("does nothing");
// ...and clear.
await _executor.Execute(_generator.Generate(new MigrationPlan(
- [new SetProcedureComment(_schema, "noop", "does nothing", null)], [], [])), TestContext.Current.CancellationToken);
+ [new SetRoutineComment(_schema, "noop", "does nothing", null, RoutineKind.Procedure)], [], [])), TestContext.Current.CancellationToken);
(await ScalarBool($"SELECT ({commentSql}) IS NULL")).ShouldBeTrue();
}
@@ -1033,7 +1314,7 @@ public async Task DropProcedure_RemovesProcedure()
// Act
await _executor.Execute(_generator.Generate(new MigrationPlan(
- [new DropProcedure(_schema, "noop")], [], [])), TestContext.Current.CancellationToken);
+ [new DropRoutine(_schema, "noop", RoutineKind.Procedure)], [], [])), TestContext.Current.CancellationToken);
// Assert
var exists = await ScalarBool($"""
@@ -1103,17 +1384,17 @@ public async Task RoundTrip_Function_IntrospectsWithSameArguments()
// Arrange — the argument list is the recreate trigger, so what was applied must read back verbatim.
// (The definition reads back in the DB's canonical form — $function$ quoting, qualified names — which the
// core reconciles by storing the DB-reported form, as with view bodies.)
- var function = new Function("add_numbers", "a integer, b integer",
+ var function = new Routine("add_numbers", RoutineKind.Function, "a integer, b integer",
"RETURNS integer LANGUAGE sql AS $$ SELECT a + b $$");
// Act
await _executor.Execute(_generator.Generate(new MigrationPlan(
- [new CreateFunction(_schema, function)], [], [])), TestContext.Current.CancellationToken);
+ [new CreateRoutine(_schema, function)], [], [])), TestContext.Current.CancellationToken);
// Assert
var provider = new PostgresSchemaProvider(_dataSource);
var introspected = (await provider.GetSchema([_schema], TestContext.Current.CancellationToken))
- .Schemas[0].Functions.ShouldHaveSingleItem();
+ .Schemas[0].Routines.ShouldHaveSingleItem();
introspected.Name.ShouldBe("add_numbers");
introspected.Arguments.ShouldBe("a integer, b integer");
introspected.Definition.ShouldContain("SELECT a + b");
diff --git a/tests/NSchema.Postgres.Tests/Sql/SequenceOptionsNormalizationTests.cs b/tests/NSchema.Postgres.Tests/Sql/SequenceOptionsNormalizationTests.cs
index 5120997..2a26b72 100644
--- a/tests/NSchema.Postgres.Tests/Sql/SequenceOptionsNormalizationTests.cs
+++ b/tests/NSchema.Postgres.Tests/Sql/SequenceOptionsNormalizationTests.cs
@@ -1,6 +1,7 @@
using NSchema.Postgres.Models;
using NSchema.Postgres.Sql;
-using NSchema.Schema.Model;
+using NSchema.Schema.Model.Columns;
+using NSchema.Schema.Model.Sequences;
namespace NSchema.Postgres.Tests.Sql;
diff --git a/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.CompositeTypeOperations.verified.txt b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.CompositeTypeOperations.verified.txt
new file mode 100644
index 0000000..ab71c18
--- /dev/null
+++ b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.CompositeTypeOperations.verified.txt
@@ -0,0 +1,33 @@
+{
+ Statements: [
+ {
+ Sql: CREATE TYPE "public"."address" AS ("street" text, "zip" integer),
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: ALTER TYPE "public"."address" ADD ATTRIBUTE "country" text,
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: ALTER TYPE "public"."address" ALTER ATTRIBUTE "zip" TYPE character varying(10),
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: ALTER TYPE "public"."address" DROP ATTRIBUTE "country",
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: ALTER TYPE "public"."old_address" RENAME TO "address",
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: COMMENT ON TYPE "public"."address" IS $comment$a postal address$comment$,
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: DROP TYPE "public"."address",
+ RunOutsideTransaction: false
+ }
+ ],
+ IsEmpty: false
+}
\ No newline at end of file
diff --git a/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.DomainOperations.verified.txt b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.DomainOperations.verified.txt
new file mode 100644
index 0000000..757f2c0
--- /dev/null
+++ b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.DomainOperations.verified.txt
@@ -0,0 +1,53 @@
+{
+ Statements: [
+ {
+ Sql: CREATE DOMAIN "public"."email" AS text DEFAULT 'n/a' NOT NULL CONSTRAINT "email_fmt" CHECK (VALUE ~ '@'),
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: ALTER DOMAIN "public"."email" SET DEFAULT 'unknown',
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: ALTER DOMAIN "public"."email" DROP DEFAULT,
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: ALTER DOMAIN "public"."email" DROP NOT NULL,
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: ALTER DOMAIN "public"."email" ADD CONSTRAINT "email_len" CHECK (length(VALUE) > 3),
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: ALTER DOMAIN "public"."email" DROP CONSTRAINT "email_fmt",
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: DROP DOMAIN "public"."code",
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: CREATE DOMAIN "public"."code" AS character varying(8),
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: COMMENT ON DOMAIN "public"."code" IS $comment$a code$comment$,
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: ALTER DOMAIN "public"."old_code" RENAME TO "code",
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: COMMENT ON DOMAIN "public"."email" IS $comment$an email$comment$,
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: DROP DOMAIN "public"."email",
+ RunOutsideTransaction: false
+ }
+ ],
+ IsEmpty: false
+}
\ No newline at end of file
diff --git a/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.ExclusionConstraintOperations.verified.txt b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.ExclusionConstraintOperations.verified.txt
new file mode 100644
index 0000000..1434b3e
--- /dev/null
+++ b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.ExclusionConstraintOperations.verified.txt
@@ -0,0 +1,17 @@
+{
+ Statements: [
+ {
+ Sql: ALTER TABLE "public"."bookings" ADD CONSTRAINT "no_overlap" EXCLUDE USING gist ("room" WITH =, "during" WITH &&) WHERE (room > 0),
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: ALTER TABLE "public"."events" ADD CONSTRAINT "no_clash" EXCLUDE USING gist ((tstzrange(starts, ends)) WITH &&),
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: ALTER TABLE "public"."bookings" DROP CONSTRAINT "no_overlap",
+ RunOutsideTransaction: false
+ }
+ ],
+ IsEmpty: false
+}
\ No newline at end of file
diff --git a/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.ExtensionOperations.verified.txt b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.ExtensionOperations.verified.txt
new file mode 100644
index 0000000..554c49a
--- /dev/null
+++ b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.ExtensionOperations.verified.txt
@@ -0,0 +1,29 @@
+{
+ Statements: [
+ {
+ Sql: CREATE EXTENSION IF NOT EXISTS "citext",
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: CREATE EXTENSION IF NOT EXISTS "postgis" VERSION '3.4',
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: CREATE EXTENSION IF NOT EXISTS "uuid-ossp",
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: ALTER EXTENSION "postgis" UPDATE TO '3.5',
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: COMMENT ON EXTENSION "citext" IS $comment$case-insensitive text$comment$,
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: DROP EXTENSION "postgis",
+ RunOutsideTransaction: false
+ }
+ ],
+ IsEmpty: false
+}
\ No newline at end of file
diff --git a/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.GeneratedColumnOperations.verified.txt b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.GeneratedColumnOperations.verified.txt
new file mode 100644
index 0000000..68ef75f
--- /dev/null
+++ b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.GeneratedColumnOperations.verified.txt
@@ -0,0 +1,26 @@
+{
+ Statements: [
+ {
+ Sql:
+CREATE TABLE "public"."boxes" (
+ "w" integer NOT NULL,
+ "h" integer NOT NULL,
+ "area" integer NOT NULL GENERATED ALWAYS AS (w * h) STORED
+),
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: ALTER TABLE "public"."boxes" ADD COLUMN "perimeter" integer NOT NULL GENERATED ALWAYS AS (2 * (w + h)) STORED,
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: ALTER TABLE "public"."boxes" ALTER COLUMN "area" SET EXPRESSION AS (w * h * 2),
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: ALTER TABLE "public"."boxes" ALTER COLUMN "area" DROP EXPRESSION,
+ RunOutsideTransaction: false
+ }
+ ],
+ IsEmpty: false
+}
\ No newline at end of file
diff --git a/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.IndexOperations.verified.txt b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.IndexOperations.verified.txt
index cfed93e..a167a37 100644
--- a/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.IndexOperations.verified.txt
+++ b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.IndexOperations.verified.txt
@@ -8,10 +8,18 @@
Sql: CREATE INDEX "idx_users_active" ON "public"."users" ("created_at") WHERE notes IS NOT NULL,
RunOutsideTransaction: false
},
+ {
+ Sql: CREATE INDEX "idx_users_tags" ON "public"."users" USING gin ("tags"),
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: CREATE INDEX "idx_users_recent" ON "public"."users" ("created_at" DESC NULLS LAST, (lower(email))) INCLUDE ("id", "notes"),
+ RunOutsideTransaction: false
+ },
{
Sql: DROP INDEX "public"."idx_users_email",
RunOutsideTransaction: false
}
],
IsEmpty: false
-}
+}
\ No newline at end of file
diff --git a/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.MaterializedViewOperations.verified.txt b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.MaterializedViewOperations.verified.txt
new file mode 100644
index 0000000..7d2fd7b
--- /dev/null
+++ b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.MaterializedViewOperations.verified.txt
@@ -0,0 +1,25 @@
+{
+ Statements: [
+ {
+ Sql: CREATE MATERIALIZED VIEW "public"."daily_totals" AS SELECT date, sum(amount) AS total FROM public.sales GROUP BY date,
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: CREATE UNIQUE INDEX "idx_daily_totals_date" ON "public"."daily_totals" ("date"),
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: ALTER MATERIALIZED VIEW "public"."legacy_totals" RENAME TO "daily_totals",
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: COMMENT ON MATERIALIZED VIEW "public"."daily_totals" IS $comment$Daily rollup$comment$,
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: DROP MATERIALIZED VIEW "public"."daily_totals",
+ RunOutsideTransaction: false
+ }
+ ],
+ IsEmpty: false
+}
\ No newline at end of file
diff --git a/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.TriggerOperations.verified.txt b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.TriggerOperations.verified.txt
new file mode 100644
index 0000000..d55192a
--- /dev/null
+++ b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlGeneratorSnapshotTests.TriggerOperations.verified.txt
@@ -0,0 +1,21 @@
+{
+ Statements: [
+ {
+ Sql: CREATE TRIGGER "users_audit" AFTER INSERT OR UPDATE OF "email", "name" ON "public"."users" FOR EACH ROW WHEN (new.active) EXECUTE FUNCTION public.log_change('audit'),
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: CREATE TRIGGER "logs_truncate" BEFORE TRUNCATE ON "public"."logs" FOR EACH STATEMENT EXECUTE FUNCTION public.on_truncate(),
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: COMMENT ON TRIGGER "users_audit" ON "public"."users" IS $comment$audit changes$comment$,
+ RunOutsideTransaction: false
+ },
+ {
+ Sql: DROP TRIGGER "users_audit" ON "public"."users",
+ RunOutsideTransaction: false
+ }
+ ],
+ IsEmpty: false
+}
\ No newline at end of file