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
133 changes: 119 additions & 14 deletions internal/diff/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -1541,8 +1541,26 @@ func (d *ddlDiff) generateCreateSQL(targetSchema string, collector *diffCollecto
// 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)
// Build view lookup - needed for detecting functions that depend on views
newViewLookup := buildViewLookup(d.addedViews)

// Separate functions into those with/without view dependencies
// Functions that reference views in their return type or parameters must be created after views
functionsWithoutViewDeps := d.addedFunctions
var functionsWithViewDeps []*ir.Function
if len(newViewLookup) > 0 {
functionsWithoutViewDeps = nil
for _, fn := range d.addedFunctions {
if functionReferencesNewView(fn, newViewLookup) {
functionsWithViewDeps = append(functionsWithViewDeps, fn)
} else {
functionsWithoutViewDeps = append(functionsWithoutViewDeps, fn)
}
}
}

// Create functions WITHOUT view dependencies (functions may depend on tables created above)
generateCreateFunctionsSQL(functionsWithoutViewDeps, targetSchema, collector)

// Create domains WITH function dependencies (now that functions exist)
// These domains have CHECK constraints that reference functions
Expand Down Expand Up @@ -1572,6 +1590,10 @@ func (d *ddlDiff) generateCreateSQL(targetSchema string, collector *diffCollecto
// Create views
generateCreateViewsSQL(d.addedViews, targetSchema, collector)

// Create functions WITH view dependencies (now that views exist)
// These functions reference views in their return type or parameter types (issue #300)
generateCreateFunctionsSQL(functionsWithViewDeps, targetSchema, collector)

// Revoke default grants on new tables that the user explicitly didn't include
// This must happen AFTER tables are created but BEFORE explicit grants
// See https://github.com/pgplex/pgschema/issues/253
Expand Down Expand Up @@ -1828,30 +1850,113 @@ func columnExistsInTables(tables map[string]*ir.Table, schema, tableName, column
return false
}

// buildFunctionLookup returns case-insensitive lookup keys for newly added functions.
// Keys include both unqualified (function name only) and schema-qualified identifiers.
func buildFunctionLookup(functions []*ir.Function) map[string]struct{} {
if len(functions) == 0 {
// buildSchemaNameLookup builds a case-insensitive lookup map from schema/name pairs.
// Keys include both unqualified (name only) and schema-qualified identifiers.
func buildSchemaNameLookup(names []struct{ schema, name string }) map[string]struct{} {
if len(names) == 0 {
return nil
}

lookup := make(map[string]struct{}, len(functions)*2)
for _, fn := range functions {
if fn == nil || fn.Name == "" {
lookup := make(map[string]struct{}, len(names)*2)
for _, n := range names {
name := strings.ToLower(n.name)
if name == "" {
continue
}

name := strings.ToLower(fn.Name)
lookup[name] = struct{}{}

if fn.Schema != "" {
qualified := fmt.Sprintf("%s.%s", strings.ToLower(fn.Schema), name)
lookup[qualified] = struct{}{}
if n.schema != "" {
lookup[strings.ToLower(n.schema)+"."+name] = struct{}{}
}
}
return lookup
}

// buildFunctionLookup returns case-insensitive lookup keys for newly added functions.
func buildFunctionLookup(functions []*ir.Function) map[string]struct{} {
names := make([]struct{ schema, name string }, len(functions))
for i, fn := range functions {
names[i] = struct{ schema, name string }{fn.Schema, fn.Name}
Comment on lines +1877 to +1879
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refactored buildFunctionLookup has lost the nil check that existed in the previous implementation. The old code checked if fn == nil || fn.Name == "" before accessing fn.Name. While the current implementation may work if functions are never nil in practice, this removes a defensive programming safeguard that was previously in place. Consider adding a nil check in buildSchemaNameLookup or in the callers before constructing the names slice.

Suggested change
names := make([]struct{ schema, name string }, len(functions))
for i, fn := range functions {
names[i] = struct{ schema, name string }{fn.Schema, fn.Name}
names := make([]struct{ schema, name string }, 0, len(functions))
for _, fn := range functions {
if fn == nil || fn.Name == "" {
continue
}
names = append(names, struct{ schema, name string }{fn.Schema, fn.Name})

Copilot uses AI. Check for mistakes.
}
return buildSchemaNameLookup(names)
}

// buildViewLookup returns case-insensitive lookup keys for newly added views.
func buildViewLookup(views []*ir.View) map[string]struct{} {
names := make([]struct{ schema, name string }, len(views))
for i, v := range views {
names[i] = struct{ schema, name string }{v.Schema, v.Name}
}
return buildSchemaNameLookup(names)
}
Comment on lines +1884 to +1891
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to buildFunctionLookup, this function should handle potential nil views defensively. Consider adding a nil check before accessing v.Schema and v.Name, or ensure that views in the slice are never nil.

Copilot uses AI. Check for mistakes.

// functionReferencesNewView determines if a function references any newly added views
// in its return type or parameter types. This handles cases where functions use
// view composite types (e.g., RETURNS SETOF view_name or parameter of view_name type).
func functionReferencesNewView(fn *ir.Function, newViews map[string]struct{}) bool {
if len(newViews) == 0 || fn == nil {
return false
}

// Check return type (e.g., "SETOF public.actor", "actor", "SETOF actor")
if fn.ReturnType != "" {
typeName := extractBaseTypeName(fn.ReturnType)
if typeMatchesLookup(typeName, fn.Schema, newViews) {
return true
}
}

// Check parameter types
for _, param := range fn.Parameters {
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing nil check for parameter. If a parameter in fn.Parameters is nil, accessing param.DataType will cause a panic. Consider adding a nil check before accessing param.DataType.

Suggested change
for _, param := range fn.Parameters {
for _, param := range fn.Parameters {
if param == nil {
continue
}

Copilot uses AI. Check for mistakes.
if param.DataType != "" {
typeName := extractBaseTypeName(param.DataType)
if typeMatchesLookup(typeName, fn.Schema, newViews) {
return true
}
}
}

return false
}

// extractBaseTypeName extracts the base type name from a type expression,
// stripping SETOF prefix and array notation.
func extractBaseTypeName(typeExpr string) string {
t := strings.TrimSpace(typeExpr)
// Strip SETOF prefix (case-insensitive)
if len(t) > 6 && strings.EqualFold(t[:6], "setof ") {
t = strings.TrimSpace(t[6:])
}
// Strip array notation
for len(t) > 2 && t[len(t)-2:] == "[]" {
t = t[:len(t)-2]
}
return t
}

// typeMatchesLookup checks if a type name matches any entry in a lookup map,
// trying both unqualified and schema-qualified forms.
func typeMatchesLookup(typeName, defaultSchema string, lookup map[string]struct{}) bool {
if typeName == "" || len(lookup) == 0 {
return false
}

lower := strings.ToLower(typeName)
if _, ok := lookup[lower]; ok {
return true
}

// If unqualified, try with default schema
if !strings.Contains(lower, ".") && defaultSchema != "" {
qualified := fmt.Sprintf("%s.%s", strings.ToLower(defaultSchema), lower)
if _, ok := lookup[qualified]; ok {
return true
}
}

return false
}

var functionCallRegex = regexp.MustCompile(`(?i)([a-z_][a-z0-9_$]*(?:\.[a-z_][a-z0-9_$]*)*)\s*\(`)

// tableReferencesNewFunction determines if a table references any newly added functions
Expand Down
50 changes: 50 additions & 0 deletions ir/normalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,10 @@ func normalizeFunction(function *Function) {
function.Language = strings.ToLower(function.Language)
// Normalize return type to handle PostgreSQL-specific formats
function.ReturnType = normalizeFunctionReturnType(function.ReturnType)
// Strip current schema qualifier from return type for consistent comparison.
// pg_get_function_result may or may not qualify types in the current schema
// depending on search_path (e.g., "SETOF public.actor" vs "SETOF actor").
function.ReturnType = stripSchemaFromReturnType(function.ReturnType, function.Schema)
// Normalize parameter types, modes, and default values
for _, param := range function.Parameters {
if param != nil {
Expand Down Expand Up @@ -468,6 +472,52 @@ func normalizeFunctionReturnType(returnType string) string {
return normalizePostgreSQLType(returnType)
}

// stripSchemaFromReturnType strips the current schema qualifier from a function return type.
// This handles SETOF and array types, e.g., "SETOF public.actor" → "SETOF actor"
// when the function is in the public schema.
func stripSchemaFromReturnType(returnType, schema string) string {
if returnType == "" || schema == "" {
return returnType
}

prefix := schema + "."

// Handle SETOF prefix
if len(returnType) > 6 && strings.EqualFold(returnType[:6], "SETOF ") {
rest := strings.TrimSpace(returnType[6:])
stripped := stripSchemaPrefix(rest, prefix)
if stripped != rest {
return returnType[:6] + stripped
}
return returnType
}

// Handle TABLE(...) return types - strip schema from individual column types
if strings.HasPrefix(returnType, "TABLE(") {
return returnType // TABLE types are already handled by normalizeFunctionReturnType
}

// Direct type name
return stripSchemaPrefix(returnType, prefix)
}

// stripSchemaPrefix removes a schema prefix from a type name, preserving array notation.
func stripSchemaPrefix(typeName, prefix string) string {
// Separate base type from array suffix (e.g., "public.mytype[]" → "public.mytype" + "[]")
base := typeName
arrayStart := strings.Index(base, "[]")
arraySuffix := ""
if arrayStart >= 0 {
arraySuffix = base[arrayStart:]
base = base[:arrayStart]
}

if strings.HasPrefix(base, prefix) {
return base[len(prefix):] + arraySuffix
}
return typeName
}

// normalizeTrigger normalizes trigger representation
func normalizeTrigger(trigger *Trigger) {
if trigger == nil {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS activity (
id uuid,
author_id uuid,
CONSTRAINT activity_pkey PRIMARY KEY (id)
);

CREATE TABLE IF NOT EXISTS contact (
id uuid,
name text NOT NULL,
CONSTRAINT contact_pkey PRIMARY KEY (id)
);

CREATE OR REPLACE VIEW actor AS
SELECT id,
name
FROM contact;

CREATE OR REPLACE FUNCTION get_actor(
activity activity
)
RETURNS SETOF actor
LANGUAGE sql
STABLE
AS $$ SELECT actor.* FROM actor WHERE actor.id = activity.author_id
$$;
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- Table whose composite type is used as a function parameter
CREATE TABLE public.activity (
id uuid PRIMARY KEY,
author_id uuid
);

-- Table used by the view
CREATE TABLE public.contact (
id uuid PRIMARY KEY,
name text NOT NULL
);

-- View referenced in the function's return type
CREATE OR REPLACE VIEW public.actor AS
SELECT id, name FROM public.contact;

-- Function that uses the table composite type as a parameter
-- and references a view in its return type.
-- This function must be created AFTER:
-- 1. The activity table (for the composite type parameter)
-- 2. The actor view (for RETURNS SETOF actor)
CREATE OR REPLACE FUNCTION public.get_actor(activity activity)
RETURNS SETOF actor ROWS 1
LANGUAGE sql STABLE
AS $$ SELECT actor.* FROM actor WHERE actor.id = activity.author_id $$;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
-- Empty schema (no objects)
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"version": "1.0.0",
"pgschema_version": "1.7.1",
"created_at": "1970-01-01T00:00:00Z",
"source_fingerprint": {
"hash": "965b1131737c955e24c7f827c55bd78e4cb49a75adfd04229e0ba297376f5085"
},
"groups": [
{
"steps": [
{
"sql": "CREATE TABLE IF NOT EXISTS activity (\n id uuid,\n author_id uuid,\n CONSTRAINT activity_pkey PRIMARY KEY (id)\n);",
"type": "table",
"operation": "create",
"path": "public.activity"
},
{
"sql": "CREATE TABLE IF NOT EXISTS contact (\n id uuid,\n name text NOT NULL,\n CONSTRAINT contact_pkey PRIMARY KEY (id)\n);",
"type": "table",
"operation": "create",
"path": "public.contact"
},
{
"sql": "CREATE OR REPLACE VIEW actor AS\n SELECT id,\n name\n FROM contact;",
"type": "view",
"operation": "create",
"path": "public.actor"
},
{
"sql": "CREATE OR REPLACE FUNCTION get_actor(\n activity activity\n)\nRETURNS SETOF actor\nLANGUAGE sql\nSTABLE\nAS $$ SELECT actor.* FROM actor WHERE actor.id = activity.author_id\n$$;",
"type": "function",
"operation": "create",
"path": "public.get_actor"
}
]
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS activity (
id uuid,
author_id uuid,
CONSTRAINT activity_pkey PRIMARY KEY (id)
);

CREATE TABLE IF NOT EXISTS contact (
id uuid,
name text NOT NULL,
CONSTRAINT contact_pkey PRIMARY KEY (id)
);

CREATE OR REPLACE VIEW actor AS
SELECT id,
name
FROM contact;

CREATE OR REPLACE FUNCTION get_actor(
activity activity
)
RETURNS SETOF actor
LANGUAGE sql
STABLE
AS $$ SELECT actor.* FROM actor WHERE actor.id = activity.author_id
$$;
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
Plan: 4 to add.

Summary by type:
functions: 1 to add
tables: 2 to add
views: 1 to add

Functions:
+ get_actor

Tables:
+ activity
+ contact

Views:
+ actor

DDL to be executed:
--------------------------------------------------

CREATE TABLE IF NOT EXISTS activity (
id uuid,
author_id uuid,
CONSTRAINT activity_pkey PRIMARY KEY (id)
);

CREATE TABLE IF NOT EXISTS contact (
id uuid,
name text NOT NULL,
CONSTRAINT contact_pkey PRIMARY KEY (id)
);

CREATE OR REPLACE VIEW actor AS
SELECT id,
name
FROM contact;

CREATE OR REPLACE FUNCTION get_actor(
activity activity
)
RETURNS SETOF actor
LANGUAGE sql
STABLE
AS $$ SELECT actor.* FROM actor WHERE actor.id = activity.author_id
$$;
Loading