From 7f80384bc4accee63cfff1eb290c214b9e4f4878 Mon Sep 17 00:00:00 2001 From: tianzhou Date: Mon, 5 Jan 2026 02:03:11 -0800 Subject: [PATCH 1/3] feat: support SET search_path for functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for the SET search_path clause in function definitions, commonly used with SECURITY DEFINER for security hardening. - Add SearchPath field to Function IR struct - Extract search_path from pg_proc.proconfig in inspector query - Generate SET search_path = 'value' clause in DDL output - Include SearchPath in function equality comparisons 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/diff/function.go | 11 +++++++++++ ir/inspector.go | 7 +++++++ ir/ir.go | 1 + ir/queries/queries.sql | 3 ++- ir/queries/queries.sql.go | 5 ++++- testdata/diff/create_function/add_function/diff.sql | 1 + testdata/diff/create_function/add_function/new.sql | 1 + testdata/diff/create_function/add_function/plan.json | 2 +- testdata/diff/create_function/add_function/plan.sql | 1 + testdata/diff/create_function/add_function/plan.txt | 1 + 10 files changed, 30 insertions(+), 3 deletions(-) diff --git a/internal/diff/function.go b/internal/diff/function.go index 1b7112fa..c4f401f9 100644 --- a/internal/diff/function.go +++ b/internal/diff/function.go @@ -223,6 +223,11 @@ func generateFunctionSQL(function *ir.Function, targetSchema string) string { } // Note: Don't output PARALLEL UNSAFE (it's the default) + // Add SET search_path if specified + if function.SearchPath != "" { + stmt.WriteString(fmt.Sprintf("\nSET search_path = '%s'", function.SearchPath)) + } + // Add the function body if function.Definition != "" { // Check if this uses RETURN clause syntax (PG14+) @@ -393,6 +398,9 @@ func functionsEqual(old, new *ir.Function) bool { if old.Parallel != new.Parallel { return false } + if old.SearchPath != new.SearchPath { + return false + } if old.Comment != new.Comment { return false } @@ -439,6 +447,9 @@ func functionsEqualExceptComment(old, new *ir.Function) bool { if old.Parallel != new.Parallel { return false } + if old.SearchPath != new.SearchPath { + return false + } // Note: We intentionally do NOT compare Comment here oldInputParams := filterNonTableParameters(old.Parameters) diff --git a/ir/inspector.go b/ir/inspector.go index cc532a50..64f9507a 100644 --- a/ir/inspector.go +++ b/ir/inspector.go @@ -934,6 +934,12 @@ func (i *Inspector) buildFunctions(ctx context.Context, schema *IR, targetSchema // This signature includes all parameter information including modes, names, types, and defaults parameters := i.parseParametersFromSignature(signature, schemaName) + // Handle search_path + searchPath := "" + if fn.SearchPath.Valid { + searchPath = fn.SearchPath.String + } + function := &Function{ Schema: schemaName, Name: functionName, @@ -947,6 +953,7 @@ func (i *Inspector) buildFunctions(ctx context.Context, schema *IR, targetSchema IsSecurityDefiner: isSecurityDefiner, IsLeakproof: isLeakproof, Parallel: parallelMode, + SearchPath: searchPath, } // Use name(arguments) as key to support function overloading diff --git a/ir/ir.go b/ir/ir.go index 242fa65f..05d613d3 100644 --- a/ir/ir.go +++ b/ir/ir.go @@ -136,6 +136,7 @@ type Function struct { IsSecurityDefiner bool `json:"is_security_definer,omitempty"` // SECURITY DEFINER IsLeakproof bool `json:"is_leakproof,omitempty"` // LEAKPROOF Parallel string `json:"parallel,omitempty"` // SAFE, UNSAFE, RESTRICTED + SearchPath string `json:"search_path,omitempty"` // SET search_path value } // GetArguments returns the function arguments string (types only) for function identification. diff --git a/ir/queries/queries.sql b/ir/queries/queries.sql index fe2164ab..d331421a 100644 --- a/ir/queries/queries.sql +++ b/ir/queries/queries.sql @@ -979,7 +979,8 @@ SELECT p.proisstrict AS is_strict, p.prosecdef AS is_security_definer, p.proleakproof AS is_leakproof, - p.proparallel AS parallel_mode + p.proparallel AS parallel_mode, + (SELECT substring(cfg FROM 'search_path=(.*)') FROM unnest(p.proconfig) AS cfg WHERE cfg LIKE 'search_path=%') AS search_path FROM information_schema.routines r LEFT JOIN pg_proc p ON p.proname = r.routine_name AND p.pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = r.routine_schema) diff --git a/ir/queries/queries.sql.go b/ir/queries/queries.sql.go index 8fe65638..8e5f00f8 100644 --- a/ir/queries/queries.sql.go +++ b/ir/queries/queries.sql.go @@ -1335,7 +1335,8 @@ SELECT p.proisstrict AS is_strict, p.prosecdef AS is_security_definer, p.proleakproof AS is_leakproof, - p.proparallel AS parallel_mode + p.proparallel AS parallel_mode, + (SELECT substring(cfg FROM 'search_path=(.*)') FROM unnest(p.proconfig) AS cfg WHERE cfg LIKE 'search_path=%') AS search_path FROM information_schema.routines r LEFT JOIN pg_proc p ON p.proname = r.routine_name AND p.pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = r.routine_schema) @@ -1362,6 +1363,7 @@ type GetFunctionsForSchemaRow struct { IsSecurityDefiner bool `db:"is_security_definer" json:"is_security_definer"` IsLeakproof bool `db:"is_leakproof" json:"is_leakproof"` ParallelMode interface{} `db:"parallel_mode" json:"parallel_mode"` + SearchPath sql.NullString `db:"search_path" json:"search_path"` } // GetFunctionsForSchema retrieves all user-defined functions for a specific schema @@ -1389,6 +1391,7 @@ func (q *Queries) GetFunctionsForSchema(ctx context.Context, dollar_1 sql.NullSt &i.IsSecurityDefiner, &i.IsLeakproof, &i.ParallelMode, + &i.SearchPath, ); err != nil { return nil, err } diff --git a/testdata/diff/create_function/add_function/diff.sql b/testdata/diff/create_function/add_function/diff.sql index 84ab1350..23ffa68b 100644 --- a/testdata/diff/create_function/add_function/diff.sql +++ b/testdata/diff/create_function/add_function/diff.sql @@ -38,6 +38,7 @@ STRICT SECURITY DEFINER LEAKPROOF PARALLEL RESTRICTED +SET search_path = 'pg_catalog' AS $$ DECLARE total numeric; diff --git a/testdata/diff/create_function/add_function/new.sql b/testdata/diff/create_function/add_function/new.sql index d5f4177d..69c89678 100644 --- a/testdata/diff/create_function/add_function/new.sql +++ b/testdata/diff/create_function/add_function/new.sql @@ -20,6 +20,7 @@ STRICT SECURITY DEFINER LEAKPROOF PARALLEL RESTRICTED +SET search_path = pg_catalog AS $$ DECLARE total numeric; diff --git a/testdata/diff/create_function/add_function/plan.json b/testdata/diff/create_function/add_function/plan.json index 20778f44..ef89c309 100644 --- a/testdata/diff/create_function/add_function/plan.json +++ b/testdata/diff/create_function/add_function/plan.json @@ -21,7 +21,7 @@ "path": "public.mask_sensitive_data" }, { - "sql": "CREATE OR REPLACE FUNCTION process_order(\n order_id integer,\n discount_percent numeric DEFAULT 0,\n priority_level integer DEFAULT 1,\n note varchar DEFAULT '',\n status text DEFAULT 'pending',\n apply_tax boolean DEFAULT true,\n is_priority boolean DEFAULT false,\n expiry_date date DEFAULT (CURRENT_DATE + '1 year'::interval)\n)\nRETURNS numeric\nLANGUAGE plpgsql\nVOLATILE\nSTRICT\nSECURITY DEFINER\nLEAKPROOF\nPARALLEL RESTRICTED\nAS $$\nDECLARE\n total numeric;\nBEGIN\n SELECT amount INTO total FROM orders WHERE id = order_id;\n RETURN total - (total * discount_percent / 100);\nEND;\n$$;", + "sql": "CREATE OR REPLACE FUNCTION process_order(\n order_id integer,\n discount_percent numeric DEFAULT 0,\n priority_level integer DEFAULT 1,\n note varchar DEFAULT '',\n status text DEFAULT 'pending',\n apply_tax boolean DEFAULT true,\n is_priority boolean DEFAULT false,\n expiry_date date DEFAULT (CURRENT_DATE + '1 year'::interval)\n)\nRETURNS numeric\nLANGUAGE plpgsql\nVOLATILE\nSTRICT\nSECURITY DEFINER\nLEAKPROOF\nPARALLEL RESTRICTED\nSET search_path = 'pg_catalog'\nAS $$\nDECLARE\n total numeric;\nBEGIN\n SELECT amount INTO total FROM orders WHERE id = order_id;\n RETURN total - (total * discount_percent / 100);\nEND;\n$$;", "type": "function", "operation": "create", "path": "public.process_order" diff --git a/testdata/diff/create_function/add_function/plan.sql b/testdata/diff/create_function/add_function/plan.sql index 84ab1350..23ffa68b 100644 --- a/testdata/diff/create_function/add_function/plan.sql +++ b/testdata/diff/create_function/add_function/plan.sql @@ -38,6 +38,7 @@ STRICT SECURITY DEFINER LEAKPROOF PARALLEL RESTRICTED +SET search_path = 'pg_catalog' AS $$ DECLARE total numeric; diff --git a/testdata/diff/create_function/add_function/plan.txt b/testdata/diff/create_function/add_function/plan.txt index 40b91823..cab8a684 100644 --- a/testdata/diff/create_function/add_function/plan.txt +++ b/testdata/diff/create_function/add_function/plan.txt @@ -51,6 +51,7 @@ STRICT SECURITY DEFINER LEAKPROOF PARALLEL RESTRICTED +SET search_path = 'pg_catalog' AS $$ DECLARE total numeric; From 824d48284014a510802e7ceb42ac8f4b656cfecd Mon Sep 17 00:00:00 2001 From: tianzhou Date: Mon, 5 Jan 2026 02:16:37 -0800 Subject: [PATCH 2/3] fix: remove outer quotes from SET search_path output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-schema search paths require unquoted format: SET search_path = pg_catalog, public ✓ (two schemas) SET search_path = 'pg_catalog, public' ✗ (one schema named "pg_catalog, public") 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/diff/function.go | 4 +++- testdata/diff/create_function/add_function/diff.sql | 2 +- testdata/diff/create_function/add_function/plan.json | 2 +- testdata/diff/create_function/add_function/plan.sql | 2 +- testdata/diff/create_function/add_function/plan.txt | 2 +- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/diff/function.go b/internal/diff/function.go index c4f401f9..01c86659 100644 --- a/internal/diff/function.go +++ b/internal/diff/function.go @@ -224,8 +224,10 @@ func generateFunctionSQL(function *ir.Function, targetSchema string) string { // Note: Don't output PARALLEL UNSAFE (it's the default) // Add SET search_path if specified + // Note: Output without outer quotes to handle multi-schema paths correctly + // e.g., "SET search_path = pg_catalog, public" not "SET search_path = 'pg_catalog, public'" if function.SearchPath != "" { - stmt.WriteString(fmt.Sprintf("\nSET search_path = '%s'", function.SearchPath)) + stmt.WriteString(fmt.Sprintf("\nSET search_path = %s", function.SearchPath)) } // Add the function body diff --git a/testdata/diff/create_function/add_function/diff.sql b/testdata/diff/create_function/add_function/diff.sql index 23ffa68b..a956bc4f 100644 --- a/testdata/diff/create_function/add_function/diff.sql +++ b/testdata/diff/create_function/add_function/diff.sql @@ -38,7 +38,7 @@ STRICT SECURITY DEFINER LEAKPROOF PARALLEL RESTRICTED -SET search_path = 'pg_catalog' +SET search_path = pg_catalog AS $$ DECLARE total numeric; diff --git a/testdata/diff/create_function/add_function/plan.json b/testdata/diff/create_function/add_function/plan.json index ef89c309..11b80bf4 100644 --- a/testdata/diff/create_function/add_function/plan.json +++ b/testdata/diff/create_function/add_function/plan.json @@ -21,7 +21,7 @@ "path": "public.mask_sensitive_data" }, { - "sql": "CREATE OR REPLACE FUNCTION process_order(\n order_id integer,\n discount_percent numeric DEFAULT 0,\n priority_level integer DEFAULT 1,\n note varchar DEFAULT '',\n status text DEFAULT 'pending',\n apply_tax boolean DEFAULT true,\n is_priority boolean DEFAULT false,\n expiry_date date DEFAULT (CURRENT_DATE + '1 year'::interval)\n)\nRETURNS numeric\nLANGUAGE plpgsql\nVOLATILE\nSTRICT\nSECURITY DEFINER\nLEAKPROOF\nPARALLEL RESTRICTED\nSET search_path = 'pg_catalog'\nAS $$\nDECLARE\n total numeric;\nBEGIN\n SELECT amount INTO total FROM orders WHERE id = order_id;\n RETURN total - (total * discount_percent / 100);\nEND;\n$$;", + "sql": "CREATE OR REPLACE FUNCTION process_order(\n order_id integer,\n discount_percent numeric DEFAULT 0,\n priority_level integer DEFAULT 1,\n note varchar DEFAULT '',\n status text DEFAULT 'pending',\n apply_tax boolean DEFAULT true,\n is_priority boolean DEFAULT false,\n expiry_date date DEFAULT (CURRENT_DATE + '1 year'::interval)\n)\nRETURNS numeric\nLANGUAGE plpgsql\nVOLATILE\nSTRICT\nSECURITY DEFINER\nLEAKPROOF\nPARALLEL RESTRICTED\nSET search_path = pg_catalog\nAS $$\nDECLARE\n total numeric;\nBEGIN\n SELECT amount INTO total FROM orders WHERE id = order_id;\n RETURN total - (total * discount_percent / 100);\nEND;\n$$;", "type": "function", "operation": "create", "path": "public.process_order" diff --git a/testdata/diff/create_function/add_function/plan.sql b/testdata/diff/create_function/add_function/plan.sql index 23ffa68b..a956bc4f 100644 --- a/testdata/diff/create_function/add_function/plan.sql +++ b/testdata/diff/create_function/add_function/plan.sql @@ -38,7 +38,7 @@ STRICT SECURITY DEFINER LEAKPROOF PARALLEL RESTRICTED -SET search_path = 'pg_catalog' +SET search_path = pg_catalog AS $$ DECLARE total numeric; diff --git a/testdata/diff/create_function/add_function/plan.txt b/testdata/diff/create_function/add_function/plan.txt index cab8a684..523df934 100644 --- a/testdata/diff/create_function/add_function/plan.txt +++ b/testdata/diff/create_function/add_function/plan.txt @@ -51,7 +51,7 @@ STRICT SECURITY DEFINER LEAKPROOF PARALLEL RESTRICTED -SET search_path = 'pg_catalog' +SET search_path = pg_catalog AS $$ DECLARE total numeric; From cfadf259eb76f11b549b440b15397fe9a39fe967 Mon Sep 17 00:00:00 2001 From: tianzhou Date: Mon, 5 Jan 2026 02:20:26 -0800 Subject: [PATCH 3/3] test: use multi-schema search_path in test case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Better validates that comma-separated schema lists work correctly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- testdata/diff/create_function/add_function/diff.sql | 2 +- testdata/diff/create_function/add_function/new.sql | 2 +- testdata/diff/create_function/add_function/plan.json | 2 +- testdata/diff/create_function/add_function/plan.sql | 2 +- testdata/diff/create_function/add_function/plan.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/testdata/diff/create_function/add_function/diff.sql b/testdata/diff/create_function/add_function/diff.sql index a956bc4f..b7c08968 100644 --- a/testdata/diff/create_function/add_function/diff.sql +++ b/testdata/diff/create_function/add_function/diff.sql @@ -38,7 +38,7 @@ STRICT SECURITY DEFINER LEAKPROOF PARALLEL RESTRICTED -SET search_path = pg_catalog +SET search_path = pg_catalog, public AS $$ DECLARE total numeric; diff --git a/testdata/diff/create_function/add_function/new.sql b/testdata/diff/create_function/add_function/new.sql index 69c89678..8b5dd545 100644 --- a/testdata/diff/create_function/add_function/new.sql +++ b/testdata/diff/create_function/add_function/new.sql @@ -20,7 +20,7 @@ STRICT SECURITY DEFINER LEAKPROOF PARALLEL RESTRICTED -SET search_path = pg_catalog +SET search_path = pg_catalog, public AS $$ DECLARE total numeric; diff --git a/testdata/diff/create_function/add_function/plan.json b/testdata/diff/create_function/add_function/plan.json index 11b80bf4..e3f52ebe 100644 --- a/testdata/diff/create_function/add_function/plan.json +++ b/testdata/diff/create_function/add_function/plan.json @@ -21,7 +21,7 @@ "path": "public.mask_sensitive_data" }, { - "sql": "CREATE OR REPLACE FUNCTION process_order(\n order_id integer,\n discount_percent numeric DEFAULT 0,\n priority_level integer DEFAULT 1,\n note varchar DEFAULT '',\n status text DEFAULT 'pending',\n apply_tax boolean DEFAULT true,\n is_priority boolean DEFAULT false,\n expiry_date date DEFAULT (CURRENT_DATE + '1 year'::interval)\n)\nRETURNS numeric\nLANGUAGE plpgsql\nVOLATILE\nSTRICT\nSECURITY DEFINER\nLEAKPROOF\nPARALLEL RESTRICTED\nSET search_path = pg_catalog\nAS $$\nDECLARE\n total numeric;\nBEGIN\n SELECT amount INTO total FROM orders WHERE id = order_id;\n RETURN total - (total * discount_percent / 100);\nEND;\n$$;", + "sql": "CREATE OR REPLACE FUNCTION process_order(\n order_id integer,\n discount_percent numeric DEFAULT 0,\n priority_level integer DEFAULT 1,\n note varchar DEFAULT '',\n status text DEFAULT 'pending',\n apply_tax boolean DEFAULT true,\n is_priority boolean DEFAULT false,\n expiry_date date DEFAULT (CURRENT_DATE + '1 year'::interval)\n)\nRETURNS numeric\nLANGUAGE plpgsql\nVOLATILE\nSTRICT\nSECURITY DEFINER\nLEAKPROOF\nPARALLEL RESTRICTED\nSET search_path = pg_catalog, public\nAS $$\nDECLARE\n total numeric;\nBEGIN\n SELECT amount INTO total FROM orders WHERE id = order_id;\n RETURN total - (total * discount_percent / 100);\nEND;\n$$;", "type": "function", "operation": "create", "path": "public.process_order" diff --git a/testdata/diff/create_function/add_function/plan.sql b/testdata/diff/create_function/add_function/plan.sql index a956bc4f..b7c08968 100644 --- a/testdata/diff/create_function/add_function/plan.sql +++ b/testdata/diff/create_function/add_function/plan.sql @@ -38,7 +38,7 @@ STRICT SECURITY DEFINER LEAKPROOF PARALLEL RESTRICTED -SET search_path = pg_catalog +SET search_path = pg_catalog, public AS $$ DECLARE total numeric; diff --git a/testdata/diff/create_function/add_function/plan.txt b/testdata/diff/create_function/add_function/plan.txt index 523df934..25da216f 100644 --- a/testdata/diff/create_function/add_function/plan.txt +++ b/testdata/diff/create_function/add_function/plan.txt @@ -51,7 +51,7 @@ STRICT SECURITY DEFINER LEAKPROOF PARALLEL RESTRICTED -SET search_path = pg_catalog +SET search_path = pg_catalog, public AS $$ DECLARE total numeric;