diff --git a/internal/postgres/desired_state.go b/internal/postgres/desired_state.go index 393ff04d..dd2c008a 100644 --- a/internal/postgres/desired_state.go +++ b/internal/postgres/desired_state.go @@ -104,6 +104,11 @@ func stripSchemaQualifications(sql string, schemaName string) string { // Schema qualifiers inside function/procedure bodies (dollar-quoted blocks) // must be preserved — the user may need them when search_path doesn't include // the function's schema (e.g., SET search_path = ''). (Issue #354) + // + // To avoid type-identity mismatches between stripped parameter types and + // unstripped body references (Issue #399), callers should disable function + // body validation with SET check_function_bodies = off before executing + // the resulting SQL. segments := splitDollarQuotedSegments(sql) var result strings.Builder result.Grow(len(sql)) diff --git a/internal/postgres/embedded.go b/internal/postgres/embedded.go index 377cb219..7fea4c48 100644 --- a/internal/postgres/embedded.go +++ b/internal/postgres/embedded.go @@ -220,6 +220,15 @@ func (ep *EmbeddedPostgres) ApplySchema(ctx context.Context, schema string, sql return fmt.Errorf("failed to set search_path: %w", err) } + // Disable function body validation to avoid type-identity mismatches (issue #399). + // Schema qualifications inside dollar-quoted function bodies are preserved (issue #354), + // but parameter types are stripped. For SQL-language functions, PostgreSQL validates the + // body at creation time, which can fail when body references use the original schema's + // types while parameters reference the temporary schema's types. + if _, err := util.ExecContextWithLogging(ctx, conn, "SET check_function_bodies = off", "disable function body validation for desired state"); err != nil { + return fmt.Errorf("failed to disable check_function_bodies: %w", err) + } + // Strip schema qualifications from SQL before applying to temporary schema // This ensures that objects are created in the temporary schema via search_path // rather than being explicitly qualified with the original schema name diff --git a/internal/postgres/external.go b/internal/postgres/external.go index 3df5343a..62f34a18 100644 --- a/internal/postgres/external.go +++ b/internal/postgres/external.go @@ -131,6 +131,15 @@ func (ed *ExternalDatabase) ApplySchema(ctx context.Context, schema string, sql return fmt.Errorf("failed to set search_path: %w", err) } + // Disable function body validation to avoid type-identity mismatches (issue #399). + // Schema qualifications inside dollar-quoted function bodies are preserved (issue #354), + // but parameter types are stripped. For SQL-language functions, PostgreSQL validates the + // body at creation time, which can fail when body references use the original schema's + // types while parameters reference the temporary schema's types. + if _, err := util.ExecContextWithLogging(ctx, conn, "SET check_function_bodies = off", "disable function body validation for desired state"); err != nil { + return fmt.Errorf("failed to disable check_function_bodies: %w", err) + } + // Strip schema qualifications from SQL before applying to temporary schema // This ensures that objects are created in the temporary schema via search_path // rather than being explicitly qualified with the original schema name diff --git a/testdata/diff/create_function/issue_399_schema_qualified_body/diff.sql b/testdata/diff/create_function/issue_399_schema_qualified_body/diff.sql new file mode 100644 index 00000000..87275787 --- /dev/null +++ b/testdata/diff/create_function/issue_399_schema_qualified_body/diff.sql @@ -0,0 +1,15 @@ +CREATE OR REPLACE FUNCTION role_has_cap( + p_role role_type, + p_cap text +) +RETURNS boolean +LANGUAGE sql +STABLE +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public.role_caps rc + WHERE rc.role = p_role + AND rc.capability = p_cap + ); +$$; diff --git a/testdata/diff/create_function/issue_399_schema_qualified_body/new.sql b/testdata/diff/create_function/issue_399_schema_qualified_body/new.sql new file mode 100644 index 00000000..4c2feb9d --- /dev/null +++ b/testdata/diff/create_function/issue_399_schema_qualified_body/new.sql @@ -0,0 +1,22 @@ +CREATE TYPE public.role_type AS ENUM ('OWNER', 'MEMBER'); + +CREATE TABLE public.role_caps ( + role public.role_type NOT NULL, + capability text NOT NULL, + PRIMARY KEY (role, capability) +); + +CREATE OR REPLACE FUNCTION public.role_has_cap( + p_role public.role_type, + p_cap text +) RETURNS boolean +LANGUAGE sql +STABLE +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public.role_caps rc + WHERE rc.role = p_role + AND rc.capability = p_cap + ); +$$; diff --git a/testdata/diff/create_function/issue_399_schema_qualified_body/old.sql b/testdata/diff/create_function/issue_399_schema_qualified_body/old.sql new file mode 100644 index 00000000..e40a8cdc --- /dev/null +++ b/testdata/diff/create_function/issue_399_schema_qualified_body/old.sql @@ -0,0 +1,7 @@ +CREATE TYPE role_type AS ENUM ('OWNER', 'MEMBER'); + +CREATE TABLE role_caps ( + role role_type NOT NULL, + capability text NOT NULL, + PRIMARY KEY (role, capability) +); diff --git a/testdata/diff/create_function/issue_399_schema_qualified_body/plan.json b/testdata/diff/create_function/issue_399_schema_qualified_body/plan.json new file mode 100644 index 00000000..eb68c191 --- /dev/null +++ b/testdata/diff/create_function/issue_399_schema_qualified_body/plan.json @@ -0,0 +1,20 @@ +{ + "version": "1.0.0", + "pgschema_version": "1.9.0", + "created_at": "1970-01-01T00:00:00Z", + "source_fingerprint": { + "hash": "eb148b37b7b6325bdd5f0c1c120dfe0bd71a062ce69951aa946c452aff2dc662" + }, + "groups": [ + { + "steps": [ + { + "sql": "CREATE OR REPLACE FUNCTION role_has_cap(\n p_role role_type,\n p_cap text\n)\nRETURNS boolean\nLANGUAGE sql\nSTABLE\nAS $$\n SELECT EXISTS (\n SELECT 1\n FROM public.role_caps rc\n WHERE rc.role = p_role\n AND rc.capability = p_cap\n );\n$$;", + "type": "function", + "operation": "create", + "path": "public.role_has_cap" + } + ] + } + ] +} diff --git a/testdata/diff/create_function/issue_399_schema_qualified_body/plan.sql b/testdata/diff/create_function/issue_399_schema_qualified_body/plan.sql new file mode 100644 index 00000000..87275787 --- /dev/null +++ b/testdata/diff/create_function/issue_399_schema_qualified_body/plan.sql @@ -0,0 +1,15 @@ +CREATE OR REPLACE FUNCTION role_has_cap( + p_role role_type, + p_cap text +) +RETURNS boolean +LANGUAGE sql +STABLE +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public.role_caps rc + WHERE rc.role = p_role + AND rc.capability = p_cap + ); +$$; diff --git a/testdata/diff/create_function/issue_399_schema_qualified_body/plan.txt b/testdata/diff/create_function/issue_399_schema_qualified_body/plan.txt new file mode 100644 index 00000000..9cf4b324 --- /dev/null +++ b/testdata/diff/create_function/issue_399_schema_qualified_body/plan.txt @@ -0,0 +1,26 @@ +Plan: 1 to add. + +Summary by type: + functions: 1 to add + +Functions: + + role_has_cap + +DDL to be executed: +-------------------------------------------------- + +CREATE OR REPLACE FUNCTION role_has_cap( + p_role role_type, + p_cap text +) +RETURNS boolean +LANGUAGE sql +STABLE +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public.role_caps rc + WHERE rc.role = p_role + AND rc.capability = p_cap + ); +$$;