Skip to content
Open
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
92 changes: 90 additions & 2 deletions cmd/dump/dump_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,90 @@ func TestDumpCommand_Issue252FunctionSchemaQualifier(t *testing.T) {
runExactMatchTest(t, "issue_252_function_schema_qualifier")
}

func TestDumpCommand_Issue296NonPublicSchemaSearchPath(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}

// Setup PostgreSQL
embeddedPG := testutil.SetupPostgres(t)
defer embeddedPG.Stop()

// Connect to database
conn, host, port, dbname, user, password := testutil.ConnectToPostgres(t, embeddedPG)
defer conn.Close()

// Create a non-public schema with a simple table
schemaName := "vehicle"
_, err := conn.Exec(fmt.Sprintf("CREATE SCHEMA %s", schemaName))
if err != nil {
t.Fatalf("Failed to create schema %s: %v", schemaName, err)
}

_, err = conn.Exec(fmt.Sprintf("SET search_path TO %s", schemaName))
if err != nil {
t.Fatalf("Failed to set search_path: %v", err)
}

_, err = conn.Exec(`
CREATE TABLE vehicle_config (
id serial PRIMARY KEY,
enabled boolean DEFAULT false NOT NULL
)
`)
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}

// Dump the non-public schema
config := &DumpConfig{
Host: host,
Port: port,
DB: dbname,
User: user,
Password: password,
Schema: schemaName,
}

output, err := ExecuteDump(config)
if err != nil {
t.Fatalf("Dump command failed: %v", err)
}

// Verify SET search_path is present for non-public schema
expectedSearchPath := fmt.Sprintf("SET search_path TO %s, public;", ir.QuoteIdentifier(schemaName))
if !strings.Contains(output, expectedSearchPath) {
t.Errorf("Dump output for non-public schema %q should contain %q\nActual output:\n%s",
schemaName, expectedSearchPath, output)
}

// Dump the public schema (reset search_path first)
_, err = conn.Exec("SET search_path TO public")
if err != nil {
t.Fatalf("Failed to reset search_path: %v", err)
}

publicConfig := &DumpConfig{
Host: host,
Port: port,
DB: dbname,
User: user,
Password: password,
Schema: "public",
}

publicOutput, err := ExecuteDump(publicConfig)
if err != nil {
t.Fatalf("Dump command failed for public schema: %v", err)
}

// Verify SET search_path is NOT present for public schema
if strings.Contains(publicOutput, "SET search_path") {
t.Errorf("Dump output for public schema should NOT contain SET search_path\nActual output:\n%s",
publicOutput)
}
}
Comment on lines +112 to +194
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

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

Consider adding an integration test that verifies the complete workflow: dump a non-public schema (which should include SET search_path), then use the plan command to apply that dump to verify that stripSetSearchPath correctly prevents the dump header from overriding the temporary schema's search_path. This would provide more comprehensive test coverage for the fix beyond just verifying the dump output format.

Copilot uses AI. Check for mistakes.

func runExactMatchTest(t *testing.T, testDataDir string) {
runExactMatchTestWithContext(t, context.Background(), testDataDir)
}
Expand Down Expand Up @@ -270,8 +354,8 @@ func runTenantSchemaTest(t *testing.T, testDataDir string) {
}
}

// normalizeSchemaOutput removes version-specific lines for comparison.
// This allows comparing dumps across different PostgreSQL versions.
// normalizeSchemaOutput removes version-specific and schema-specific header lines for comparison.
// This allows comparing dumps across different PostgreSQL versions and different schemas.
func normalizeSchemaOutput(output string) string {
lines := strings.Split(output, "\n")
var normalizedLines []string
Expand All @@ -282,6 +366,10 @@ func normalizeSchemaOutput(output string) string {
strings.Contains(line, "-- Dumped from database version") {
continue
}
// Skip SET search_path lines (added for non-public schemas)
if strings.HasPrefix(line, "SET search_path TO ") {
continue
}
normalizedLines = append(normalizedLines, line)
}

Expand Down
9 changes: 9 additions & 0 deletions internal/dump/formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,15 @@ func (f *DumpFormatter) generateDumpHeader() string {
header.WriteString(fmt.Sprintf("-- Dumped from database version %s\n", f.dbVersion))
header.WriteString(fmt.Sprintf("-- Dumped by pgschema version %s\n", version.App()))
header.WriteString("\n")

// For non-public schemas, add SET search_path so the dump is self-contained.
// This ensures objects are created in the correct schema when the SQL is applied
// directly (e.g., via psql), matching pg_dump conventions.
if f.targetSchema != "" && f.targetSchema != "public" {
quotedSchema := ir.QuoteIdentifier(f.targetSchema)
header.WriteString(fmt.Sprintf("SET search_path TO %s, public;\n", quotedSchema))
}

header.WriteString("\n")
return header.String()
}
Expand Down
11 changes: 11 additions & 0 deletions internal/postgres/desired_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ func GenerateTempSchemaName() string {
return fmt.Sprintf("pgschema_tmp_%s_%s", timestamp, randomSuffix)
}

// stripSetSearchPath removes SET search_path statements from SQL.
// This is needed because pgschema dump includes SET search_path for non-public schemas
// to make the dump self-contained. When applying desired state SQL to a temporary schema
// during plan generation, the SET search_path would override the temp schema's search_path,
// causing objects to be created in the wrong schema.
func stripSetSearchPath(sql string) string {
// Match SET search_path TO ... ; with optional whitespace and newlines
re := regexp.MustCompile(`(?im)^\s*SET\s+search_path\s+TO\s+[^;]+;\s*\n?`)
return re.ReplaceAllString(sql, "")
Comment on lines +65 to +68
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

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

The regex pattern is compiled on every function call to stripSetSearchPath. For better performance, consider compiling the regex once at the package level as a global variable, similar to how functionCallRegex is defined in internal/diff/diff.go. While this function isn't called in a hot path, this would follow the performance best practice pattern used elsewhere in the codebase.

Suggested change
func stripSetSearchPath(sql string) string {
// Match SET search_path TO ... ; with optional whitespace and newlines
re := regexp.MustCompile(`(?im)^\s*SET\s+search_path\s+TO\s+[^;]+;\s*\n?`)
return re.ReplaceAllString(sql, "")
// setSearchPathRegex matches "SET search_path TO ...;" with optional whitespace and newlines.
var setSearchPathRegex = regexp.MustCompile(`(?im)^\s*SET\s+search_path\s+TO\s+[^;]+;\s*\n?`)
func stripSetSearchPath(sql string) string {
return setSearchPathRegex.ReplaceAllString(sql, "")

Copilot uses AI. Check for mistakes.
}

// stripSchemaQualifications removes schema qualifications from SQL statements for the specified target schema.
//
// Purpose:
Expand Down
6 changes: 5 additions & 1 deletion internal/postgres/embedded.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,10 +206,14 @@ func (ep *EmbeddedPostgres) ApplySchema(ctx context.Context, schema string, sql
return fmt.Errorf("failed to set search_path: %w", err)
}

// Strip SET search_path statements from SQL to prevent overriding the temp schema's search_path.
// pgschema dump includes SET search_path for non-public schemas to make dumps self-contained.
cleanedSQL := stripSetSearchPath(sql)

// 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
schemaAgnosticSQL := stripSchemaQualifications(sql, schema)
schemaAgnosticSQL := stripSchemaQualifications(cleanedSQL, schema)

// Replace schema names in ALTER DEFAULT PRIVILEGES statements
// These use "IN SCHEMA <schema>" syntax which isn't handled by stripSchemaQualifications
Expand Down
6 changes: 5 additions & 1 deletion internal/postgres/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,14 @@ func (ed *ExternalDatabase) ApplySchema(ctx context.Context, schema string, sql
return fmt.Errorf("failed to set search_path: %w", err)
}

// Strip SET search_path statements from SQL to prevent overriding the temp schema's search_path.
// pgschema dump includes SET search_path for non-public schemas to make dumps self-contained.
cleanedSQL := stripSetSearchPath(sql)

// 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
schemaAgnosticSQL := stripSchemaQualifications(sql, schema)
schemaAgnosticSQL := stripSchemaQualifications(cleanedSQL, schema)

// Replace schema names in ALTER DEFAULT PRIVILEGES statements
// These use "IN SCHEMA <schema>" syntax which isn't handled by stripSchemaQualifications
Expand Down