diff --git a/internal/diff/diff.go b/internal/diff/diff.go index ec1e7c42..2c5b6559 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -1459,8 +1459,31 @@ func (d *ddlDiff) generatePreDropMaterializedViewsSQL(targetSchema string, colle func (d *ddlDiff) generateCreateSQL(targetSchema string, collector *diffCollector) { // Note: Schema creation is out of scope for schema-level comparisons - // Create types - generateCreateTypesSQL(d.addedTypes, targetSchema, collector) + // Build function lookup early - needed for both domain and table dependency checks + newFunctionLookup := buildFunctionLookup(d.addedFunctions) + + // Separate types into domains with/without function dependencies + // Domains with function deps (e.g., CHECK constraints referencing functions) must be created after functions + typesWithoutFunctionDeps := []*ir.Type{} + domainsWithFunctionDeps := []*ir.Type{} + deferredDomainLookup := make(map[string]struct{}) + + for _, typeObj := range d.addedTypes { + if typeObj.Kind == ir.TypeKindDomain && domainReferencesNewFunction(typeObj, newFunctionLookup) { + domainsWithFunctionDeps = append(domainsWithFunctionDeps, typeObj) + // Track deferred domains so we can defer tables that use them + deferredDomainLookup[strings.ToLower(typeObj.Name)] = struct{}{} + if typeObj.Schema != "" { + qualified := fmt.Sprintf("%s.%s", strings.ToLower(typeObj.Schema), strings.ToLower(typeObj.Name)) + deferredDomainLookup[qualified] = struct{}{} + } + } else { + typesWithoutFunctionDeps = append(typesWithoutFunctionDeps, typeObj) + } + } + + // Create types WITHOUT function dependencies (enum, composite, and domains without function deps) + generateCreateTypesSQL(typesWithoutFunctionDeps, targetSchema, collector) // Create sequences generateCreateSequencesSQL(d.addedSequences, targetSchema, collector) @@ -1471,8 +1494,6 @@ func (d *ddlDiff) generateCreateSQL(targetSchema string, collector *diffCollecto key := fmt.Sprintf("%s.%s", tableDiff.Table.Schema, tableDiff.Table.Name) existingTables[key] = true } - - newFunctionLookup := buildFunctionLookup(d.addedFunctions) var shouldDeferPolicy func(*ir.RLSPolicy) bool if len(newFunctionLookup) > 0 { shouldDeferPolicy = func(policy *ir.RLSPolicy) bool { @@ -1483,30 +1504,34 @@ func (d *ddlDiff) generateCreateSQL(targetSchema string, collector *diffCollecto // Create default privileges BEFORE tables so auto-grants apply to new tables generateCreateDefaultPrivilegesSQL(d.addedDefaultPrivileges, targetSchema, collector) - // Separate tables into those that depend on new functions and those that don't - // This ensures we create functions before tables that use them in defaults/checks - tablesWithoutFunctionDeps := []*ir.Table{} - tablesWithFunctionDeps := []*ir.Table{} + // Separate tables into those that depend on new functions/deferred domains and those that don't + // This ensures we create functions and domains before tables that use them + tablesWithoutDeps := []*ir.Table{} + tablesWithDeps := []*ir.Table{} for _, table := range d.addedTables { - if tableReferencesNewFunction(table, newFunctionLookup) { - tablesWithFunctionDeps = append(tablesWithFunctionDeps, table) + if tableReferencesNewFunction(table, newFunctionLookup) || tableUsesDeferredDomain(table, deferredDomainLookup) { + tablesWithDeps = append(tablesWithDeps, table) } else { - tablesWithoutFunctionDeps = append(tablesWithoutFunctionDeps, table) + tablesWithoutDeps = append(tablesWithoutDeps, table) } } - // Create tables WITHOUT function dependencies first (functions may reference these) - deferredPolicies1, deferredConstraints1 := generateCreateTablesSQL(tablesWithoutFunctionDeps, targetSchema, collector, existingTables, shouldDeferPolicy) + // Create tables WITHOUT function/domain dependencies first (functions may reference these) + deferredPolicies1, deferredConstraints1 := generateCreateTablesSQL(tablesWithoutDeps, targetSchema, collector, existingTables, shouldDeferPolicy) // Create functions (functions may depend on tables created above) generateCreateFunctionsSQL(d.addedFunctions, targetSchema, collector) - // Create procedures (procedures may depend on tables) + // Create domains WITH function dependencies (now that functions exist) + // These domains have CHECK constraints that reference functions + generateCreateTypesSQL(domainsWithFunctionDeps, targetSchema, collector) + + // Create procedures (procedures may depend on tables and domains) generateCreateProceduresSQL(d.addedProcedures, targetSchema, collector) - // Create tables WITH function dependencies (now that functions exist) - deferredPolicies2, deferredConstraints2 := generateCreateTablesSQL(tablesWithFunctionDeps, targetSchema, collector, existingTables, shouldDeferPolicy) + // Create tables WITH function/domain dependencies (now that functions and deferred domains exist) + deferredPolicies2, deferredConstraints2 := generateCreateTablesSQL(tablesWithDeps, targetSchema, collector, existingTables, shouldDeferPolicy) // Add deferred foreign key constraints from BOTH batches AFTER all tables are created // This ensures FK references to tables in the second batch (function-dependent tables) work correctly @@ -1831,6 +1856,58 @@ func policyReferencesNewFunction(policy *ir.RLSPolicy, newFunctions map[string]s return false } +// tableUsesDeferredDomain determines if a table uses any deferred domain types in its columns. +func tableUsesDeferredDomain(table *ir.Table, deferredDomains map[string]struct{}) bool { + if len(deferredDomains) == 0 || table == nil { + return false + } + + for _, col := range table.Columns { + if col.DataType == "" { + continue + } + // Normalize the type name for lookup + typeName := strings.ToLower(col.DataType) + if _, ok := deferredDomains[typeName]; ok { + return true + } + // Try with table's schema prefix + if table.Schema != "" && !strings.Contains(typeName, ".") { + qualified := fmt.Sprintf("%s.%s", strings.ToLower(table.Schema), typeName) + if _, ok := deferredDomains[qualified]; ok { + return true + } + } + } + return false +} + +// domainReferencesNewFunction determines if a domain references any newly added functions +// in its CHECK constraints or default value. +func domainReferencesNewFunction(typeObj *ir.Type, newFunctions map[string]struct{}) bool { + if len(newFunctions) == 0 || typeObj == nil || typeObj.Kind != ir.TypeKindDomain { + return false + } + + // Check default value + if typeObj.Default != "" { + if referencesNewFunction(typeObj.Default, typeObj.Schema, newFunctions) { + return true + } + } + + // Check CHECK constraints + for _, constraint := range typeObj.Constraints { + if constraint.Definition != "" { + if referencesNewFunction(constraint.Definition, typeObj.Schema, newFunctions) { + return true + } + } + } + + return false +} + func referencesNewFunction(expr, defaultSchema string, newFunctions map[string]struct{}) bool { if expr == "" || len(newFunctions) == 0 { return false diff --git a/ir/normalize.go b/ir/normalize.go index 2cc068bc..62e045af 100644 --- a/ir/normalize.go +++ b/ir/normalize.go @@ -651,9 +651,9 @@ func normalizeType(typeObj *Type) { typeObj.Default = normalizeDomainDefault(typeObj.Default) } - // Normalize domain constraints + // Normalize domain constraints (pass schema for stripping same-schema qualifiers) for _, constraint := range typeObj.Constraints { - normalizeDomainConstraint(constraint) + normalizeDomainConstraint(constraint, typeObj.Schema) } } @@ -671,7 +671,8 @@ func normalizeDomainDefault(defaultValue string) string { } // normalizeDomainConstraint normalizes domain constraint definitions -func normalizeDomainConstraint(constraint *DomainConstraint) { +// domainSchema is used to strip same-schema qualifiers from function calls +func normalizeDomainConstraint(constraint *DomainConstraint, domainSchema string) { if constraint == nil || constraint.Definition == "" { return } @@ -700,6 +701,16 @@ func normalizeDomainConstraint(constraint *DomainConstraint) { } } + // Strip same-schema qualifiers from function calls (similar to normalizePolicyExpression) + // This matches PostgreSQL's behavior where pg_get_constraintdef includes schema qualifiers + // but the source SQL may not include them + // Example: public.validate_custom_id(VALUE) -> validate_custom_id(VALUE) (when domainSchema is "public") + if domainSchema != "" && strings.Contains(expr, domainSchema+".") { + prefix := domainSchema + "." + pattern := regexp.MustCompile(regexp.QuoteMeta(prefix) + `([a-zA-Z_][a-zA-Z0-9_]*)\(`) + expr = pattern.ReplaceAllString(expr, `${1}(`) + } + // Remove redundant type casts // e.g., '...'::text -> '...' expr = regexp.MustCompile(`'([^']+)'::text\b`).ReplaceAllString(expr, "'$1'") diff --git a/testdata/diff/create_domain/domain_function_check_dependency/diff.sql b/testdata/diff/create_domain/domain_function_check_dependency/diff.sql new file mode 100644 index 00000000..1534afb0 --- /dev/null +++ b/testdata/diff/create_domain/domain_function_check_dependency/diff.sql @@ -0,0 +1,15 @@ +CREATE OR REPLACE FUNCTION validate_custom_id( + val text +) +RETURNS boolean +LANGUAGE plpgsql +IMMUTABLE +AS $$ +BEGIN + -- Simple validation: must start with 'id_' and be at least 5 characters + RETURN val IS NOT NULL AND val LIKE 'id_%' AND length(val) >= 5; +END +$$; + +CREATE DOMAIN custom_id AS text + CONSTRAINT custom_id_check CHECK (validate_custom_id(VALUE)); diff --git a/testdata/diff/create_domain/domain_function_check_dependency/new.sql b/testdata/diff/create_domain/domain_function_check_dependency/new.sql new file mode 100644 index 00000000..f9b7966f --- /dev/null +++ b/testdata/diff/create_domain/domain_function_check_dependency/new.sql @@ -0,0 +1,15 @@ +-- Function that validates a custom ID format +CREATE OR REPLACE FUNCTION validate_custom_id(val text) +RETURNS boolean +LANGUAGE plpgsql +IMMUTABLE +AS $$ +BEGIN + -- Simple validation: must start with 'id_' and be at least 5 characters + RETURN val IS NOT NULL AND val LIKE 'id_%' AND length(val) >= 5; +END +$$; + +-- Domain that uses the function in its CHECK constraint +CREATE DOMAIN custom_id AS text + CHECK (validate_custom_id(VALUE)); diff --git a/testdata/diff/create_domain/domain_function_check_dependency/old.sql b/testdata/diff/create_domain/domain_function_check_dependency/old.sql new file mode 100644 index 00000000..011542b1 --- /dev/null +++ b/testdata/diff/create_domain/domain_function_check_dependency/old.sql @@ -0,0 +1 @@ +-- Empty schema (testing domain with function dependency in CHECK constraint) diff --git a/testdata/diff/create_domain/domain_function_check_dependency/plan.json b/testdata/diff/create_domain/domain_function_check_dependency/plan.json new file mode 100644 index 00000000..02035807 --- /dev/null +++ b/testdata/diff/create_domain/domain_function_check_dependency/plan.json @@ -0,0 +1,26 @@ +{ + "version": "1.0.0", + "pgschema_version": "1.6.1", + "created_at": "1970-01-01T00:00:00Z", + "source_fingerprint": { + "hash": "965b1131737c955e24c7f827c55bd78e4cb49a75adfd04229e0ba297376f5085" + }, + "groups": [ + { + "steps": [ + { + "sql": "CREATE OR REPLACE FUNCTION validate_custom_id(\n val text\n)\nRETURNS boolean\nLANGUAGE plpgsql\nIMMUTABLE\nAS $$\nBEGIN\n -- Simple validation: must start with 'id_' and be at least 5 characters\n RETURN val IS NOT NULL AND val LIKE 'id_%' AND length(val) >= 5;\nEND\n$$;", + "type": "function", + "operation": "create", + "path": "public.validate_custom_id" + }, + { + "sql": "CREATE DOMAIN custom_id AS text\n CONSTRAINT custom_id_check CHECK (validate_custom_id(VALUE));", + "type": "domain", + "operation": "create", + "path": "public.custom_id" + } + ] + } + ] +} diff --git a/testdata/diff/create_domain/domain_function_check_dependency/plan.sql b/testdata/diff/create_domain/domain_function_check_dependency/plan.sql new file mode 100644 index 00000000..1534afb0 --- /dev/null +++ b/testdata/diff/create_domain/domain_function_check_dependency/plan.sql @@ -0,0 +1,15 @@ +CREATE OR REPLACE FUNCTION validate_custom_id( + val text +) +RETURNS boolean +LANGUAGE plpgsql +IMMUTABLE +AS $$ +BEGIN + -- Simple validation: must start with 'id_' and be at least 5 characters + RETURN val IS NOT NULL AND val LIKE 'id_%' AND length(val) >= 5; +END +$$; + +CREATE DOMAIN custom_id AS text + CONSTRAINT custom_id_check CHECK (validate_custom_id(VALUE)); diff --git a/testdata/diff/create_domain/domain_function_check_dependency/plan.txt b/testdata/diff/create_domain/domain_function_check_dependency/plan.txt new file mode 100644 index 00000000..fe997dd1 --- /dev/null +++ b/testdata/diff/create_domain/domain_function_check_dependency/plan.txt @@ -0,0 +1,26 @@ +Plan: 2 to add. + +Summary by type: + functions: 1 to add + +Functions: + + validate_custom_id + +DDL to be executed: +-------------------------------------------------- + +CREATE OR REPLACE FUNCTION validate_custom_id( + val text +) +RETURNS boolean +LANGUAGE plpgsql +IMMUTABLE +AS $$ +BEGIN + -- Simple validation: must start with 'id_' and be at least 5 characters + RETURN val IS NOT NULL AND val LIKE 'id_%' AND length(val) >= 5; +END +$$; + +CREATE DOMAIN custom_id AS text + CONSTRAINT custom_id_check CHECK (validate_custom_id(VALUE)); diff --git a/testdata/diff/create_domain/domain_function_table_dependency/diff.sql b/testdata/diff/create_domain/domain_function_table_dependency/diff.sql new file mode 100644 index 00000000..bd161fac --- /dev/null +++ b/testdata/diff/create_domain/domain_function_table_dependency/diff.sql @@ -0,0 +1,19 @@ +CREATE OR REPLACE FUNCTION validate_custom_id( + val text +) +RETURNS boolean +LANGUAGE plpgsql +IMMUTABLE +AS $$ +BEGIN + RETURN val IS NOT NULL AND val LIKE 'id_%' AND length(val) >= 5; +END +$$; + +CREATE DOMAIN custom_id AS text + CONSTRAINT custom_id_check CHECK (validate_custom_id(VALUE)); + +CREATE TABLE IF NOT EXISTS example ( + id custom_id, + CONSTRAINT example_pkey PRIMARY KEY (id) +); diff --git a/testdata/diff/create_domain/domain_function_table_dependency/new.sql b/testdata/diff/create_domain/domain_function_table_dependency/new.sql new file mode 100644 index 00000000..4b3809f0 --- /dev/null +++ b/testdata/diff/create_domain/domain_function_table_dependency/new.sql @@ -0,0 +1,19 @@ +-- Function that validates a custom ID format +CREATE OR REPLACE FUNCTION validate_custom_id(val text) +RETURNS boolean +LANGUAGE plpgsql +IMMUTABLE +AS $$ +BEGIN + RETURN val IS NOT NULL AND val LIKE 'id_%' AND length(val) >= 5; +END +$$; + +-- Domain that uses the function in its CHECK constraint +CREATE DOMAIN custom_id AS text + CHECK (validate_custom_id(VALUE)); + +-- Table that uses the domain as a column type +CREATE TABLE example ( + id custom_id NOT NULL PRIMARY KEY +); diff --git a/testdata/diff/create_domain/domain_function_table_dependency/old.sql b/testdata/diff/create_domain/domain_function_table_dependency/old.sql new file mode 100644 index 00000000..e56b7797 --- /dev/null +++ b/testdata/diff/create_domain/domain_function_table_dependency/old.sql @@ -0,0 +1 @@ +-- Empty schema (testing full dependency chain: function -> domain -> table) diff --git a/testdata/diff/create_domain/domain_function_table_dependency/plan.json b/testdata/diff/create_domain/domain_function_table_dependency/plan.json new file mode 100644 index 00000000..32858900 --- /dev/null +++ b/testdata/diff/create_domain/domain_function_table_dependency/plan.json @@ -0,0 +1,32 @@ +{ + "version": "1.0.0", + "pgschema_version": "1.6.1", + "created_at": "1970-01-01T00:00:00Z", + "source_fingerprint": { + "hash": "965b1131737c955e24c7f827c55bd78e4cb49a75adfd04229e0ba297376f5085" + }, + "groups": [ + { + "steps": [ + { + "sql": "CREATE OR REPLACE FUNCTION validate_custom_id(\n val text\n)\nRETURNS boolean\nLANGUAGE plpgsql\nIMMUTABLE\nAS $$\nBEGIN\n RETURN val IS NOT NULL AND val LIKE 'id_%' AND length(val) >= 5;\nEND\n$$;", + "type": "function", + "operation": "create", + "path": "public.validate_custom_id" + }, + { + "sql": "CREATE DOMAIN custom_id AS text\n CONSTRAINT custom_id_check CHECK (validate_custom_id(VALUE));", + "type": "domain", + "operation": "create", + "path": "public.custom_id" + }, + { + "sql": "CREATE TABLE IF NOT EXISTS example (\n id custom_id,\n CONSTRAINT example_pkey PRIMARY KEY (id)\n);", + "type": "table", + "operation": "create", + "path": "public.example" + } + ] + } + ] +} diff --git a/testdata/diff/create_domain/domain_function_table_dependency/plan.sql b/testdata/diff/create_domain/domain_function_table_dependency/plan.sql new file mode 100644 index 00000000..bd161fac --- /dev/null +++ b/testdata/diff/create_domain/domain_function_table_dependency/plan.sql @@ -0,0 +1,19 @@ +CREATE OR REPLACE FUNCTION validate_custom_id( + val text +) +RETURNS boolean +LANGUAGE plpgsql +IMMUTABLE +AS $$ +BEGIN + RETURN val IS NOT NULL AND val LIKE 'id_%' AND length(val) >= 5; +END +$$; + +CREATE DOMAIN custom_id AS text + CONSTRAINT custom_id_check CHECK (validate_custom_id(VALUE)); + +CREATE TABLE IF NOT EXISTS example ( + id custom_id, + CONSTRAINT example_pkey PRIMARY KEY (id) +); diff --git a/testdata/diff/create_domain/domain_function_table_dependency/plan.txt b/testdata/diff/create_domain/domain_function_table_dependency/plan.txt new file mode 100644 index 00000000..1d84d1af --- /dev/null +++ b/testdata/diff/create_domain/domain_function_table_dependency/plan.txt @@ -0,0 +1,34 @@ +Plan: 3 to add. + +Summary by type: + functions: 1 to add + tables: 1 to add + +Functions: + + validate_custom_id + +Tables: + + example + +DDL to be executed: +-------------------------------------------------- + +CREATE OR REPLACE FUNCTION validate_custom_id( + val text +) +RETURNS boolean +LANGUAGE plpgsql +IMMUTABLE +AS $$ +BEGIN + RETURN val IS NOT NULL AND val LIKE 'id_%' AND length(val) >= 5; +END +$$; + +CREATE DOMAIN custom_id AS text + CONSTRAINT custom_id_check CHECK (validate_custom_id(VALUE)); + +CREATE TABLE IF NOT EXISTS example ( + id custom_id, + CONSTRAINT example_pkey PRIMARY KEY (id) +);