Skip to content

fix: rewrite SET search_path in function definitions for temp schema (#335)#336

Merged
tianzhou merged 4 commits intomainfrom
fix/issue-335-search-path-rewrite
Mar 3, 2026
Merged

fix: rewrite SET search_path in function definitions for temp schema (#335)#336
tianzhou merged 4 commits intomainfrom
fix/issue-335-search-path-rewrite

Conversation

@tianzhou
Copy link
Contributor

@tianzhou tianzhou commented Mar 3, 2026

Summary

Fixes #335

When using --file mode with functions that have SET search_path = public, pg_temp (or similar), pgschema would fail with relation "xxx" does not exist when applying the desired state SQL to the temporary schema.

Root cause: PostgreSQL validates SQL-language function bodies at creation time using the function's own SET search_path, not the session's search_path. When pgschema applies SQL to a temporary schema (pgschema_tmp_...), the session search_path points to the temp schema, but the function's SET search_path = public overrides it — causing table references in the function body to fail because the tables exist in the temp schema, not in public.

Fix (two parts):

  • Add replaceSchemaInSearchPath() in desired_state.go to rewrite the target schema name in SET search_path clauses when applying SQL to the temporary schema (analogous to existing replaceSchemaInDefaultPrivileges)
  • Add fn.SearchPath normalization in normalizeSchemaNames() to convert temp schema names back to the target schema in the generated migration DDL

Test plan

  • Added diff test case create_function/issue_335_search_path_rewrite with SQL function + SET search_path = public, pg_temp referencing a table
  • Test fails before fix (reproduces the exact error from the issue)
  • Test passes after fix
  • All existing function tests pass (9/9)
  • Dependency tests pass (13/13)
  • Default privilege tests pass (9/9)
  • Dump tests pass (18/18)
# Run the specific test
PGSCHEMA_TEST_FILTER="create_function/issue_335" go test -v ./cmd -run TestPlanAndApply

# Run all function tests
PGSCHEMA_TEST_FILTER="create_function/" go test -v ./cmd -run TestPlanAndApply

…335)

When applying desired state SQL to a temporary schema, functions with
SET search_path = public (or similar) would fail because PostgreSQL
validates SQL-language function bodies at creation time using the
function's own search_path, not the session's search_path.

Two changes:
1. Add replaceSchemaInSearchPath() to rewrite the target schema in
   SET search_path clauses when applying SQL to the temp schema
2. Normalize fn.SearchPath in normalizeSchemaNames() to convert temp
   schema names back to the target schema in the generated migration DDL

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 3, 2026 02:43
@greptile-apps
Copy link

greptile-apps bot commented Mar 3, 2026

Greptile Summary

This PR fixes a regression where --file mode would fail with relation "xxx" does not exist for SQL-language functions that use SET search_path = public, pg_temp (or similar). The fix has two complementary parts: a new replaceSchemaInSearchPath() utility in desired_state.go that rewrites the target schema name inside SET search_path clauses when applying SQL to the temporary schema, and a single-line addition to normalizeSchemaNames() in plan.go to convert the temp schema name back to the target schema in fn.SearchPath when building the migration DDL.

Key changes:

  • internal/postgres/desired_state.go: New replaceSchemaInSearchPath() function that uses a two-pass regex (quoted then unquoted schema name) to rewrite SET search_path values, analogous to the pre-existing replaceSchemaInDefaultPrivileges() approach.
  • internal/postgres/embedded.go + external.go: Both ApplySchema implementations call the new function after replaceSchemaInDefaultPrivileges, keeping the embedded and external code paths in sync.
  • cmd/plan/plan.go: normalizeSchemaNames() now includes fn.SearchPath = replaceString(fn.SearchPath) so the temp schema name is properly reversed to the target schema in the generated DDL output.
  • Regression test: A complete testdata/diff/create_function/issue_335_search_path_rewrite test case is added covering CREATE FUNCTION … SET search_path = public, pg_temp.

One concern worth reviewing: replaceSchemaInSearchPath uses a broad regex that matches any SET search_path in the SQL string, not exclusively function-level options. This means a PL/pgSQL function body containing dynamic SQL such as EXECUTE 'SET search_path = public' would have that string literal's content incorrectly rewritten. No unit tests exist for the new function; edge cases (quoted schema name, TO syntax, schema absent from the search path list) are only covered indirectly by the integration test.

Confidence Score: 3/5

  • Safe to merge for the targeted use case, but the broad regex scope could silently corrupt dynamic SQL inside function bodies in edge cases.
  • The fix correctly addresses the reported issue and is well-tested for the common case (SQL-language functions with SET search_path = public, pg_temp). However, the replaceSchemaInSearchPath regex is not scoped to function-level options — it matches SET search_path anywhere in the SQL, including inside $$-quoted bodies. A PL/pgSQL function that constructs or executes dynamic SQL containing SET search_path = public would have that string literal silently modified. There are also no unit tests for the new function to guard against regression on edge cases.
  • internal/postgres/desired_state.go — the regex in replaceSchemaInSearchPath needs closer review for its handling of SET search_path inside dollar-quoted function bodies.

Important Files Changed

Filename Overview
internal/postgres/desired_state.go Adds replaceSchemaInSearchPath — a new regex-based function that rewrites SET search_path clauses when applying desired-state SQL to the temp schema. The broad regex scope (matches any SET search_path, including inside $$-quoted function bodies) and lack of unit tests are the main concerns.
internal/postgres/embedded.go Calls the new replaceSchemaInSearchPath after replaceSchemaInDefaultPrivileges in the SQL pre-processing pipeline; consistent with existing pattern and correctly ordered.
internal/postgres/external.go Mirrors the same replaceSchemaInSearchPath call added in embedded.go, keeping embedded and external code paths in sync.
cmd/plan/plan.go Adds fn.SearchPath = replaceString(fn.SearchPath) to normalizeSchemaNames, correctly reversing the temp-schema substitution back to the target schema when building the migration DDL.
testdata/diff/create_function/issue_335_search_path_rewrite/new.sql Desired-state SQL for the regression test: SQL-language function with SET search_path = public, pg_temp referencing a table — directly reproduces issue #335.
testdata/diff/create_function/issue_335_search_path_rewrite/plan.json Expected plan output for the regression test; confirms the generated DDL preserves the original SET search_path = public, pg_temp (not the temp schema name).

Sequence Diagram

sequenceDiagram
    participant User as User SQL File
    participant ApplySchema as ApplySchema()
    participant Strip as stripSchemaQualifications()
    participant DefPriv as replaceSchemaInDefaultPrivileges()
    participant SearchPath as replaceSchemaInSearchPath() [NEW]
    participant TempDB as Temp Schema (pgschema_tmp_*)
    participant Inspector as IR Inspector
    participant Normalize as normalizeSchemaNames() [UPDATED]

    User->>ApplySchema: desired state SQL
    ApplySchema->>Strip: strip public.table → table
    Strip->>DefPriv: replace IN SCHEMA public → IN SCHEMA pgschema_tmp_*
    DefPriv->>SearchPath: replace SET search_path = public → SET search_path = "pgschema_tmp_*"
    SearchPath->>TempDB: execute schema-agnostic SQL
    TempDB->>Inspector: inspect objects in pgschema_tmp_*
    Inspector->>Normalize: IR with fn.SearchPath = "pgschema_tmp_*"
    Normalize->>Normalize: fn.SearchPath = replaceString(fn.SearchPath)
    Note over Normalize: pgschema_tmp_* → public
    Normalize-->>User: IR with fn.SearchPath = "public" (correct DDL)
Loading

Last reviewed commit: f3418ce

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

10 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +203 to +224
searchPathPattern := regexp.MustCompile(`(?i)(SET\s+search_path\s*(?:=|TO)\s*)([^\n;]+)`)

// Precompile replacement patterns
// Handle quoted: "public" -> "pgschema_tmp_xxx"
quotedPattern := regexp.MustCompile(fmt.Sprintf(`"%s"`, escapedTarget))
// Handle unquoted: public -> "pgschema_tmp_xxx"
// Use word boundary to avoid partial matches (e.g., don't match "public_data")
unquotedPattern := regexp.MustCompile(fmt.Sprintf(`\b%s\b`, escapedTarget))

return searchPathPattern.ReplaceAllStringFunc(sql, func(match string) string {
loc := searchPathPattern.FindStringSubmatchIndex(match)
if loc == nil {
return match
}
prefix := match[loc[2]:loc[3]]
value := match[loc[4]:loc[5]]

newValue := quotedPattern.ReplaceAllString(value, replacement)
newValue = unquotedPattern.ReplaceAllString(newValue, replacement)

return prefix + newValue
})
Copy link

Choose a reason for hiding this comment

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

SET search_path inside function body string literals

The searchPathPattern regex matches any SET search_path occurrence in the SQL, including those appearing as string content inside $$-quoted function bodies. For example, a PL/pgSQL function that uses dynamic SQL like EXECUTE 'SET search_path = public' would have the string literal's content incorrectly rewritten to EXECUTE 'SET search_path = "pgschema_tmp_xxx"'.

The issue arises because the regex [^\n;]+ captures up to the end of the line, and it will happily match inside $$…$$ bodies. While the common use case (function-level SET search_path = public, pg_temp as a function option) is handled correctly, any SET search_path that appears as part of a quoted string literal within a function body will also be modified, potentially corrupting dynamic SQL.

Consider guarding against this by only matching outside dollar-quoted blocks, or at minimum documenting this known limitation explicitly in the function's comment.

Comment on lines +193 to +225
func replaceSchemaInSearchPath(sql string, targetSchema, tempSchema string) string {
if targetSchema == "" || tempSchema == "" {
return sql
}

escapedTarget := regexp.QuoteMeta(targetSchema)
replacement := fmt.Sprintf(`"%s"`, tempSchema)

// Pattern: SET search_path = ... or SET search_path TO ...
// We match the entire SET search_path clause and replace the target schema within it.
searchPathPattern := regexp.MustCompile(`(?i)(SET\s+search_path\s*(?:=|TO)\s*)([^\n;]+)`)

// Precompile replacement patterns
// Handle quoted: "public" -> "pgschema_tmp_xxx"
quotedPattern := regexp.MustCompile(fmt.Sprintf(`"%s"`, escapedTarget))
// Handle unquoted: public -> "pgschema_tmp_xxx"
// Use word boundary to avoid partial matches (e.g., don't match "public_data")
unquotedPattern := regexp.MustCompile(fmt.Sprintf(`\b%s\b`, escapedTarget))

return searchPathPattern.ReplaceAllStringFunc(sql, func(match string) string {
loc := searchPathPattern.FindStringSubmatchIndex(match)
if loc == nil {
return match
}
prefix := match[loc[2]:loc[3]]
value := match[loc[4]:loc[5]]

newValue := quotedPattern.ReplaceAllString(value, replacement)
newValue = unquotedPattern.ReplaceAllString(newValue, replacement)

return prefix + newValue
})
}
Copy link

Choose a reason for hiding this comment

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

No unit tests for replaceSchemaInSearchPath

The new replaceSchemaInSearchPath function has no dedicated unit tests. The only coverage comes from the integration test at testdata/diff/create_function/issue_335_search_path_rewrite/. Neither stripSchemaQualifications nor replaceSchemaInDefaultPrivileges have unit tests either, but given the regex complexity here (two-pass quoted/unquoted replacement inside a ReplaceAllStringFunc callback), a targeted table-driven test would help catch edge cases such as:

  • SET search_path TO public (the TO syntax)
  • SET search_path = "public", pg_temp (quoted target schema)
  • Multiple functions in the same SQL file
  • Schema name absent from the search path (should be a no-op)
  • Empty targetSchema or tempSchema (already guarded, but worth asserting)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes issue #335 where applying desired-state SQL to a temporary schema fails for SQL-language functions that specify SET search_path, because PostgreSQL validates SQL function bodies using the function’s own search_path at creation time.

Changes:

  • Rewrite target schema names inside SET search_path clauses when applying desired-state SQL to the temp schema.
  • Normalize fn.SearchPath during IR schema-name normalization so generated migration DDL uses the real target schema (not the temp schema).
  • Add a regression diff test case covering SET search_path = public, pg_temp with a table reference in a SQL-language function.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
internal/postgres/external.go Applies search_path rewriting before executing desired-state SQL against the temp schema.
internal/postgres/embedded.go Applies search_path rewriting before executing desired-state SQL against the temp schema.
internal/postgres/desired_state.go Adds replaceSchemaInSearchPath() to rewrite target schema tokens in SET search_path clauses.
cmd/plan/plan.go Normalizes Function.SearchPath when converting temp-schema IR back to the target schema.
testdata/diff/create_function/issue_335_search_path_rewrite/plan.txt Snapshot for the new regression plan output.
testdata/diff/create_function/issue_335_search_path_rewrite/plan.sql Snapshot for the new regression planned SQL.
testdata/diff/create_function/issue_335_search_path_rewrite/plan.json Snapshot for the new regression planned JSON output.
testdata/diff/create_function/issue_335_search_path_rewrite/old.sql Baseline “empty schema” input for the diff test.
testdata/diff/create_function/issue_335_search_path_rewrite/new.sql New schema input reproducing #335 (SQL function with SET search_path).
testdata/diff/create_function/issue_335_search_path_rewrite/diff.sql Expected migration diff SQL for the regression case.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +203 to +222
searchPathPattern := regexp.MustCompile(`(?i)(SET\s+search_path\s*(?:=|TO)\s*)([^\n;]+)`)

// Precompile replacement patterns
// Handle quoted: "public" -> "pgschema_tmp_xxx"
quotedPattern := regexp.MustCompile(fmt.Sprintf(`"%s"`, escapedTarget))
// Handle unquoted: public -> "pgschema_tmp_xxx"
// Use word boundary to avoid partial matches (e.g., don't match "public_data")
unquotedPattern := regexp.MustCompile(fmt.Sprintf(`\b%s\b`, escapedTarget))

return searchPathPattern.ReplaceAllStringFunc(sql, func(match string) string {
loc := searchPathPattern.FindStringSubmatchIndex(match)
if loc == nil {
return match
}
prefix := match[loc[2]:loc[3]]
value := match[loc[4]:loc[5]]

newValue := quotedPattern.ReplaceAllString(value, replacement)
newValue = unquotedPattern.ReplaceAllString(newValue, replacement)

Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

replaceSchemaInSearchPath does schema token replacement case-sensitively (both the quoted and unquoted patterns). For unquoted identifiers, PostgreSQL folds case, so inputs like SET search_path = PUBLIC, pg_temp (or Public) still refer to public but won’t be rewritten and can reintroduce the same temp-schema validation failure. Consider making the schema match case-insensitive for unquoted tokens (and avoid matching inside double quotes), or parse the comma-separated search_path list and rewrite tokens by comparing normalized identifier names.

Copilot uses AI. Check for mistakes.
- Make unquoted schema matching case-insensitive to handle PostgreSQL's
  identifier folding (e.g., PUBLIC, Public -> public)
- Add documentation about $$-quoted body limitation (pre-existing
  pattern shared with stripSchemaQualifications and
  replaceSchemaInDefaultPrivileges)
- Add table-driven unit tests (12 cases) covering: = and TO syntax,
  quoted/unquoted schemas, case insensitivity, multiple functions,
  no-op cases, partial name protection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +228 to +230
newValue := quotedPattern.ReplaceAllString(value, replacement)
newValue = unquotedPattern.ReplaceAllString(newValue, replacement)

Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

unquotedPattern can match target schema names inside quoted identifiers. For example, with SET search_path = "PUBLIC", pg_temp and targetSchema=public, quotedPattern won’t match but unquotedPattern will replace PUBLIC inside the quotes, producing invalid SQL like ""pgschema_tmp_xxx"". Consider restricting the unquoted replacement to tokens that are not inside double quotes (e.g., match only when preceded/followed by start/comma/space, or tokenize the search_path list and replace only whole schema entries), and add a regression test for this case.

Suggested change
newValue := quotedPattern.ReplaceAllString(value, replacement)
newValue = unquotedPattern.ReplaceAllString(newValue, replacement)
// First replace quoted occurrences: "public" -> "pgschema_tmp_xxx"
afterQuoted := quotedPattern.ReplaceAllString(value, replacement)
// Then replace unquoted occurrences only outside of double-quoted identifiers.
// Split on double quotes so that even indices are outside quotes and odd indices are inside.
parts := strings.Split(afterQuoted, `"`)
for i := 0; i < len(parts); i += 2 {
parts[i] = unquotedPattern.ReplaceAllString(parts[i], replacement)
}
newValue := strings.Join(parts, `"`)

Copilot uses AI. Check for mistakes.
targetSchema: "public",
tempSchema: "pgschema_tmp_xxx",
expected: "SET search_path = public_data, pg_temp",
},
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

Current unit tests don’t cover the case where the search_path list contains a quoted schema name whose case doesn’t exactly match targetSchema (e.g., SET search_path = "PUBLIC", pg_temp with targetSchema=public). Adding a test for this scenario would prevent regressions where replacements occur inside quoted identifiers and accidentally generate invalid SQL (double quotes) or change semantics.

Suggested change
},
},
{
name: "does not replace quoted schema with different case",
sql: `SET search_path = "PUBLIC", pg_temp`,
targetSchema: "public",
tempSchema: "pgschema_tmp_xxx",
expected: `SET search_path = "PUBLIC", pg_temp`,
},

Copilot uses AI. Check for mistakes.
…ifiers

Replace regex-based schema replacement with proper tokenization of the
comma-separated search_path value. This prevents incorrectly matching
schema names inside double-quoted identifiers (e.g., "PUBLIC" should
not be rewritten when targetSchema is "public", since quoted identifiers
are case-sensitive in PostgreSQL).

Also handles trailing AS clause when SET search_path and function body
are on the same line.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +205 to +254
replacement := fmt.Sprintf(`"%s"`, tempSchema)

// Pattern: SET search_path = ... or SET search_path TO ...
// We match the entire SET search_path clause and replace the target schema within it.
searchPathPattern := regexp.MustCompile(`(?i)(SET\s+search_path\s*(?:=|TO)\s*)([^\n;]+)`)

// Pattern to detect trailing AS clause (start of function body) in the captured value.
// When SET search_path and AS are on the same line, the value regex captures both.
asPattern := regexp.MustCompile(`(?i)\s+AS\s`)

return searchPathPattern.ReplaceAllStringFunc(sql, func(match string) string {
loc := searchPathPattern.FindStringSubmatchIndex(match)
if loc == nil {
return match
}
prefix := match[loc[2]:loc[3]]
value := match[loc[4]:loc[5]]

// Separate the search_path value from any trailing AS clause
suffix := ""
if asLoc := asPattern.FindStringIndex(value); asLoc != nil {
suffix = value[asLoc[0]:]
value = value[:asLoc[0]]
}

// Tokenize the comma-separated search_path list and replace matching schemas.
// This avoids regex pitfalls with quoted identifiers (e.g., "PUBLIC" should not
// be matched by a case-insensitive unquoted pattern for "public").
tokens := strings.Split(value, ",")
for i, token := range tokens {
trimmed := strings.TrimSpace(token)
if strings.HasPrefix(trimmed, `"`) && strings.HasSuffix(trimmed, `"`) {
// Quoted identifier: case-sensitive exact match.
// "public" matches targetSchema "public", but "PUBLIC" does not
// (in PostgreSQL, quoted identifiers preserve case).
inner := trimmed[1 : len(trimmed)-1]
if inner == targetSchema {
tokens[i] = strings.Replace(token, trimmed, replacement, 1)
}
} else {
// Unquoted identifier: case-insensitive match.
// PostgreSQL folds unquoted identifiers to lowercase.
if strings.EqualFold(trimmed, targetSchema) {
tokens[i] = strings.Replace(token, trimmed, replacement, 1)
}
}
}

return prefix + strings.Join(tokens, ",") + suffix
})
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

When targetSchema == "public", replacing the matching entry with the temp schema removes public from the function's search_path (e.g. public, pg_temp"pgschema_tmp...", pg_temp). This can break function creation/validation in the temp schema if the function body relies on extension objects installed in public (the session search_path explicitly keeps public as a fallback for this reason). Consider preserving public as a later fallback (while still prioritizing the temp schema), and then normalizing/de-duplicating the resulting search_path so the generated migration DDL remains unchanged.

Copilot uses AI. Check for mistakes.
Comment on lines +57 to +61
{
name: "multiple functions in same SQL",
sql: "CREATE FUNCTION f1() RETURNS void LANGUAGE sql SET search_path = public AS $$ SELECT 1; $$;\nCREATE FUNCTION f2() RETURNS void LANGUAGE sql SET search_path = public, pg_temp AS $$ SELECT 2; $$;",
targetSchema: "public",
tempSchema: "pgschema_tmp_xxx",
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

Tests cover AS $$...$$ bodies (e.g., the "multiple functions" case), but there isn't coverage for SQL-standard bodies (RETURN ... / BEGIN ATOMIC ... END) where there is no AS keyword. Adding a test for a one-line SQL-standard body combined with SET search_path would help ensure the rewrite works for both function body syntaxes.

Copilot uses AI. Check for mistakes.
- Extend body-start detection to match BEGIN ATOMIC (SQL-standard
  function body syntax, PG14+) in addition to AS (dollar-quoted)
- Add test case for single-line BEGIN ATOMIC function definition
- Document limitation: replacing "public" in search_path removes
  fallback for extension objects installed in public schema (rarely
  an issue since most extensions are in pg_catalog)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +200 to +209
// - When targetSchema is "public", replacing it removes "public" from the function's
// search_path. If the function body references extension objects installed in "public"
// (e.g., citext), they may not be found. Most extension objects (uuid, jsonb, etc.) live
// in pg_catalog which is always searched, so this is rarely an issue in practice.
func replaceSchemaInSearchPath(sql string, targetSchema, tempSchema string) string {
if targetSchema == "" || tempSchema == "" {
return sql
}

replacement := fmt.Sprintf(`"%s"`, tempSchema)
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

replaceSchemaInSearchPath() replaces the target schema token with only the temp schema (e.g., public -> "pgschema_tmp..."). For functions whose original SET search_path intentionally included public to resolve extension objects installed there (see the session-level SET search_path TO "temp", public added for issue #197), this rewrite can cause function creation/validation to fail because public is no longer on the function’s search_path. Consider preserving public as a fallback when the matched token is public (e.g., rewrite public -> "temp", public), or otherwise ensure extension objects remain resolvable during validation without masking temp-schema table resolution.

Suggested change
// - When targetSchema is "public", replacing it removes "public" from the function's
// search_path. If the function body references extension objects installed in "public"
// (e.g., citext), they may not be found. Most extension objects (uuid, jsonb, etc.) live
// in pg_catalog which is always searched, so this is rarely an issue in practice.
func replaceSchemaInSearchPath(sql string, targetSchema, tempSchema string) string {
if targetSchema == "" || tempSchema == "" {
return sql
}
replacement := fmt.Sprintf(`"%s"`, tempSchema)
// - When targetSchema is "public", we rewrite it to include both the temp schema and
// "public" (e.g., public -> "pgschema_tmp_xxx", public). This keeps the temp schema
// first for table resolution while preserving "public" as a fallback so extension
// objects installed there remain resolvable during function validation.
func replaceSchemaInSearchPath(sql string, targetSchema, tempSchema string) string {
if targetSchema == "" || tempSchema == "" {
return sql
}
replacement := fmt.Sprintf(`"%s"`, tempSchema)
if strings.EqualFold(targetSchema, "public") {
// Preserve "public" as a fallback so that extension objects installed in the
// public schema remain visible during function creation/validation.
replacement = fmt.Sprintf(`"%s", public`, tempSchema)
}

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +239 to +258
for i, token := range tokens {
trimmed := strings.TrimSpace(token)
if strings.HasPrefix(trimmed, `"`) && strings.HasSuffix(trimmed, `"`) {
// Quoted identifier: case-sensitive exact match.
// "public" matches targetSchema "public", but "PUBLIC" does not
// (in PostgreSQL, quoted identifiers preserve case).
inner := trimmed[1 : len(trimmed)-1]
if inner == targetSchema {
tokens[i] = strings.Replace(token, trimmed, replacement, 1)
}
} else {
// Unquoted identifier: case-insensitive match.
// PostgreSQL folds unquoted identifiers to lowercase.
if strings.EqualFold(trimmed, targetSchema) {
tokens[i] = strings.Replace(token, trimmed, replacement, 1)
}
}
}

return prefix + strings.Join(tokens, ",") + suffix
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

This logic replaces the target schema token with the temp schema, which means the target schema is removed from the function’s search_path entirely. That can break creation-time validation for functions that rely on objects that exist only in the target schema (commonly extensions installed into public, e.g. gen_random_uuid() from pgcrypto). Consider rewriting to include the temp schema (e.g., insert it before the target schema) while keeping the original schemas as fallbacks, and then normalize/dedupe fn.SearchPath so generated DDL matches the original intent.

Suggested change
for i, token := range tokens {
trimmed := strings.TrimSpace(token)
if strings.HasPrefix(trimmed, `"`) && strings.HasSuffix(trimmed, `"`) {
// Quoted identifier: case-sensitive exact match.
// "public" matches targetSchema "public", but "PUBLIC" does not
// (in PostgreSQL, quoted identifiers preserve case).
inner := trimmed[1 : len(trimmed)-1]
if inner == targetSchema {
tokens[i] = strings.Replace(token, trimmed, replacement, 1)
}
} else {
// Unquoted identifier: case-insensitive match.
// PostgreSQL folds unquoted identifiers to lowercase.
if strings.EqualFold(trimmed, targetSchema) {
tokens[i] = strings.Replace(token, trimmed, replacement, 1)
}
}
}
return prefix + strings.Join(tokens, ",") + suffix
var newTokens []string
for _, token := range tokens {
trimmed := strings.TrimSpace(token)
matched := false
if strings.HasPrefix(trimmed, `"`) && strings.HasSuffix(trimmed, `"`) {
// Quoted identifier: case-sensitive exact match.
// "public" matches targetSchema "public", but "PUBLIC" does not
// (in PostgreSQL, quoted identifiers preserve case).
inner := trimmed[1 : len(trimmed)-1]
if inner == targetSchema {
newTokens = append(newTokens, replacement)
matched = true
}
} else {
// Unquoted identifier: case-insensitive match.
// PostgreSQL folds unquoted identifiers to lowercase.
if strings.EqualFold(trimmed, targetSchema) {
newTokens = append(newTokens, replacement)
matched = true
}
}
// Always keep the original token to preserve the original search_path semantics.
newTokens = append(newTokens, token)
}
return prefix + strings.Join(newTokens, ",") + suffix

Copilot uses AI. Check for mistakes.
Comment on lines +197 to +203
// inside a $$-quoted function body (e.g., dynamic SQL) would also be rewritten. In
// practice this is not an issue because such usage is extremely rare, and the round-trip
// through database inspection and normalizeSchemaNames restores the original schema name.
// - When targetSchema is "public", replacing it removes "public" from the function's
// search_path. If the function body references extension objects installed in "public"
// (e.g., citext), they may not be found. Most extension objects (uuid, jsonb, etc.) live
// in pg_catalog which is always searched, so this is rarely an issue in practice.
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

The comment about extension objects is misleading: uuid/jsonb are built-in types (not extension objects), and many extension-provided functions/types are commonly installed in the target schema (often public). Since this rewrite can remove the target schema from the function’s search_path, the comment’s implication that this is “rarely an issue” may not hold. Please adjust the comment (and ideally the rewrite behavior) to reflect the actual risk.

Suggested change
// inside a $$-quoted function body (e.g., dynamic SQL) would also be rewritten. In
// practice this is not an issue because such usage is extremely rare, and the round-trip
// through database inspection and normalizeSchemaNames restores the original schema name.
// - When targetSchema is "public", replacing it removes "public" from the function's
// search_path. If the function body references extension objects installed in "public"
// (e.g., citext), they may not be found. Most extension objects (uuid, jsonb, etc.) live
// in pg_catalog which is always searched, so this is rarely an issue in practice.
// inside a $$-quoted function body (e.g., dynamic SQL) would also be rewritten. While
// this usage is expected to be uncommon, it can lead to unintended rewrites; the
// round-trip through database inspection and normalizeSchemaNames will typically restore
// the original schema name but cannot guarantee that no behavioral change occurs.
// - When targetSchema is "public", replacing it removes "public" from the function's
// effective search_path within the function definition. Any unqualified references to
// objects that reside in "public" (including many extension-provided types and functions
// that are installed into the target schema) may no longer resolve after rewriting.
// Only objects in pg_catalog (for example, built-in types such as uuid and jsonb) remain
// reliably visible via the implicit search_path. Callers must ensure that functions whose
// bodies depend on objects in the target schema either qualify those references or opt
// out of search_path rewriting for those objects.

Copilot uses AI. Check for mistakes.
@tianzhou tianzhou merged commit 51e4d70 into main Mar 3, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Table/Function dependency order

2 participants