Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 93 additions & 16 deletions internal/diff/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 14 additions & 3 deletions ir/normalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand All @@ -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
}
Expand Down Expand Up @@ -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'")
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
Original file line number Diff line number Diff line change
@@ -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));
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
-- Empty schema (testing domain with function dependency in CHECK constraint)
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
}
Original file line number Diff line number Diff line change
@@ -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));
Original file line number Diff line number Diff line change
@@ -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));
Original file line number Diff line number Diff line change
@@ -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)
);
Original file line number Diff line number Diff line change
@@ -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
);
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
-- Empty schema (testing full dependency chain: function -> domain -> table)
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
}
Original file line number Diff line number Diff line change
@@ -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)
);
Loading
Loading