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
15 changes: 12 additions & 3 deletions internal/diff/function.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ func functionsEqualExceptAttributes(old, new *ir.Function) bool {
if old.Name != new.Name {
return false
}
if old.Definition != new.Definition {
if !definitionsEqualIgnoringSchema(old.Definition, new.Definition, old.Schema) {
return false
}
if old.ReturnType != new.ReturnType {
Expand Down Expand Up @@ -409,7 +409,7 @@ func functionsEqual(old, new *ir.Function) bool {
if old.Name != new.Name {
return false
}
if old.Definition != new.Definition {
if !definitionsEqualIgnoringSchema(old.Definition, new.Definition, old.Schema) {
return false
}
if old.ReturnType != new.ReturnType {
Expand Down Expand Up @@ -458,7 +458,7 @@ func functionsEqualExceptComment(old, new *ir.Function) bool {
if old.Name != new.Name {
return false
}
if old.Definition != new.Definition {
if !definitionsEqualIgnoringSchema(old.Definition, new.Definition, old.Schema) {
return false
}
if old.ReturnType != new.ReturnType {
Expand Down Expand Up @@ -522,6 +522,15 @@ func functionRequiresRecreate(old, new *ir.Function) bool {
return false
}

// definitionsEqualIgnoringSchema compares two function/procedure definitions,
// stripping the given schema qualifier from both before comparing. This allows
// definitions that differ only in schema qualification (e.g., "public.test" vs "test")
// to be treated as equal, while preserving the original qualifiers in the IR for
// correct DDL generation. (Issue #354)
func definitionsEqualIgnoringSchema(a, b, schema string) bool {
return ir.StripSchemaPrefixFromBody(a, schema) == ir.StripSchemaPrefixFromBody(b, schema)
}

// filterNonTableParameters filters out TABLE mode parameters
// TABLE parameters are output columns in RETURNS TABLE() and shouldn't be compared as input parameters
func filterNonTableParameters(params []*ir.Parameter) []*ir.Parameter {
Expand Down
4 changes: 2 additions & 2 deletions internal/diff/procedure.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ func proceduresEqual(old, new *ir.Procedure) bool {
if old.Name != new.Name {
return false
}
if old.Definition != new.Definition {
if !definitionsEqualIgnoringSchema(old.Definition, new.Definition, old.Schema) {
return false
}
if old.Language != new.Language {
Expand Down Expand Up @@ -296,7 +296,7 @@ func proceduresEqualExceptComment(old, new *ir.Procedure) bool {
if old.Name != new.Name {
return false
}
if old.Definition != new.Definition {
if !definitionsEqualIgnoringSchema(old.Definition, new.Definition, old.Schema) {
return false
}
if old.Language != new.Language {
Expand Down
73 changes: 72 additions & 1 deletion internal/postgres/desired_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,77 @@ func stripSchemaQualifications(sql string, schemaName string) string {
return sql
}

// Split SQL into dollar-quoted and non-dollar-quoted segments.
// 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)
segments := splitDollarQuotedSegments(sql)
var result strings.Builder
result.Grow(len(sql))
for _, seg := range segments {
if seg.quoted {
// Preserve dollar-quoted content as-is
result.WriteString(seg.text)
} else {
result.WriteString(stripSchemaQualificationsFromText(seg.text, schemaName))
}
}
return result.String()
Comment on lines +99 to +114
}

// dollarQuotedSegment represents a segment of SQL text, either inside or outside a dollar-quoted block.
type dollarQuotedSegment struct {
text string
quoted bool // true if this segment is inside dollar quotes (including the delimiters)
}

// splitDollarQuotedSegments splits SQL text into segments that are either inside or outside
// dollar-quoted blocks ($$...$$, $tag$...$tag$, etc.). This allows callers to process
// only the non-quoted parts while preserving function/procedure bodies verbatim.
// dollarQuoteRe matches PostgreSQL dollar-quote tags: $$ or $identifier$ where the
// identifier must start with a letter or underscore (not a digit). This avoids
// false positives on $1, $2 etc. parameter references.
var dollarQuoteRe = regexp.MustCompile(`\$(?:[a-zA-Z_][a-zA-Z0-9_]*)?\$`)

func splitDollarQuotedSegments(sql string) []dollarQuotedSegment {
var segments []dollarQuotedSegment

pos := 0
for pos < len(sql) {
// Find the next dollar-quote opening tag
loc := dollarQuoteRe.FindStringIndex(sql[pos:])
if loc == nil {
// No more dollar quotes — rest is unquoted
segments = append(segments, dollarQuotedSegment{text: sql[pos:], quoted: false})
break
}

openStart := pos + loc[0]
openEnd := pos + loc[1]
tag := sql[openStart:openEnd]

// Add the unquoted segment before this tag
if openStart > pos {
segments = append(segments, dollarQuotedSegment{text: sql[pos:openStart], quoted: false})
}

// Find the matching closing tag
closeIdx := strings.Index(sql[openEnd:], tag)
if closeIdx == -1 {
// No closing tag — treat rest as quoted (unterminated)
segments = append(segments, dollarQuotedSegment{text: sql[openStart:], quoted: true})
pos = len(sql)
} else {
closeEnd := openEnd + closeIdx + len(tag)
segments = append(segments, dollarQuotedSegment{text: sql[openStart:closeEnd], quoted: true})
pos = closeEnd
}
}
return segments
}

// stripSchemaQualificationsFromText performs the actual schema qualification stripping on a text segment.
func stripSchemaQualificationsFromText(text string, schemaName string) string {
// Escape the schema name for use in regex
escapedSchema := regexp.QuoteMeta(schemaName)

Expand Down Expand Up @@ -133,7 +204,7 @@ func stripSchemaQualifications(sql string, schemaName string) string {
pattern4 := fmt.Sprintf(`(?:^|[^"])%s\.([a-zA-Z_][a-zA-Z0-9_$]*)`, escapedSchema)
re4 := regexp.MustCompile(pattern4)

result := sql
result := text
// Apply in order: quoted schema first to avoid double-matching
result = re1.ReplaceAllString(result, "$1")
result = re2.ReplaceAllString(result, "$1")
Comment on lines 204 to 210
Expand Down
73 changes: 73 additions & 0 deletions internal/postgres/desired_state_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,82 @@
package postgres

import (
"reflect"
"testing"
)

func TestSplitDollarQuotedSegments(t *testing.T) {
tests := []struct {
name string
sql string
expected []dollarQuotedSegment
}{
{
name: "no dollar quotes",
sql: "SELECT 1 FROM public.users;",
expected: []dollarQuotedSegment{{text: "SELECT 1 FROM public.users;", quoted: false}},
},
{
name: "simple dollar-quoted body",
sql: "CREATE FUNCTION f() AS $$body$$ LANGUAGE sql;",
expected: []dollarQuotedSegment{
{text: "CREATE FUNCTION f() AS ", quoted: false},
{text: "$$body$$", quoted: true},
{text: " LANGUAGE sql;", quoted: false},
},
},
{
name: "named dollar-quote tag",
sql: "AS $func$body$func$ LANGUAGE sql;",
expected: []dollarQuotedSegment{
{text: "AS ", quoted: false},
{text: "$func$body$func$", quoted: true},
{text: " LANGUAGE sql;", quoted: false},
},
},
{
name: "parameter references not treated as dollar quotes",
sql: "SELECT $1 + $2 FROM t;",
expected: []dollarQuotedSegment{
{text: "SELECT $1 + $2 FROM t;", quoted: false},
},
},
{
name: "multiple dollar-quoted blocks",
sql: "AS $$body1$$; AS $f$body2$f$;",
expected: []dollarQuotedSegment{
{text: "AS ", quoted: false},
{text: "$$body1$$", quoted: true},
{text: "; AS ", quoted: false},
{text: "$f$body2$f$", quoted: true},
{text: ";", quoted: false},
},
},
{
name: "unterminated dollar quote",
sql: "AS $$body without end",
expected: []dollarQuotedSegment{
{text: "AS ", quoted: false},
{text: "$$body without end", quoted: true},
},
},
{
name: "empty input",
sql: "",
expected: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := splitDollarQuotedSegments(tt.sql)
if !reflect.DeepEqual(result, tt.expected) {
t.Errorf("splitDollarQuotedSegments(%q)\n got: %+v\n want: %+v", tt.sql, result, tt.expected)
}
})
}
}

func TestReplaceSchemaInSearchPath(t *testing.T) {
tests := []struct {
name string
Expand Down
16 changes: 9 additions & 7 deletions ir/normalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ func normalizeView(view *View) {

// Strip same-schema qualifiers from view definition for consistent comparison.
// This uses the same logic as function/procedure body normalization.
view.Definition = stripSchemaPrefixFromBody(view.Definition, view.Schema)
view.Definition = StripSchemaPrefixFromBody(view.Definition, view.Schema)

// Normalize triggers on the view (e.g., INSTEAD OF triggers)
for _, trigger := range view.Triggers {
Expand Down Expand Up @@ -321,8 +321,10 @@ func normalizeFunction(function *Function) {
}
// Normalize function body to handle whitespace differences
function.Definition = normalizeFunctionDefinition(function.Definition)
// Strip current schema qualifier from function body for consistent unqualified output
function.Definition = stripSchemaPrefixFromBody(function.Definition, function.Schema)
// Note: We intentionally do NOT strip schema qualifiers from function bodies here.
// Functions may have SET search_path that excludes their own schema, making
// qualified references (e.g., public.test) necessary. Stripping is done at
// comparison time in the diff package instead. (Issue #354)
}

// normalizeFunctionDefinition normalizes function body whitespace
Expand All @@ -344,10 +346,10 @@ func normalizeFunctionDefinition(def string) string {
return strings.Join(normalized, "\n")
}

// stripSchemaPrefixFromBody removes the current schema qualifier from identifiers
// StripSchemaPrefixFromBody removes the current schema qualifier from identifiers
// in a function or procedure body. For example, "public.users" becomes "users".
// It skips single-quoted string literals to avoid modifying string constants.
func stripSchemaPrefixFromBody(body, schema string) string {
func StripSchemaPrefixFromBody(body, schema string) string {
if body == "" || schema == "" {
return body
}
Expand Down Expand Up @@ -445,8 +447,8 @@ func normalizeProcedure(procedure *Procedure) {
}
}

// Strip current schema qualifier from procedure body for consistent unqualified output
procedure.Definition = stripSchemaPrefixFromBody(procedure.Definition, procedure.Schema)
// Note: We intentionally do NOT strip schema qualifiers from procedure bodies here.
// Same rationale as functions — see normalizeFunction. (Issue #354)
}

// normalizeFunctionReturnType normalizes function return types, especially TABLE types
Expand Down
4 changes: 2 additions & 2 deletions ir/normalize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,9 @@ func TestStripSchemaPrefixFromBody(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := stripSchemaPrefixFromBody(tt.body, tt.schema)
result := StripSchemaPrefixFromBody(tt.body, tt.schema)
if result != tt.expected {
t.Errorf("stripSchemaPrefixFromBody(%q, %q) = %q, want %q", tt.body, tt.schema, result, tt.expected)
t.Errorf("StripSchemaPrefixFromBody(%q, %q) = %q, want %q", tt.body, tt.schema, result, tt.expected)
}
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ VOLATILE
SET search_path = ''
AS $$
BEGIN
INSERT INTO test (title) VALUES (p_title);
INSERT INTO public.test (title) VALUES (p_title);
END;
$$;
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
{
"steps": [
{
"sql": "CREATE OR REPLACE FUNCTION create_hello(\n p_title text\n)\nRETURNS void\nLANGUAGE plpgsql\nVOLATILE\nSET search_path = ''\nAS $$\nBEGIN\n INSERT INTO test (title) VALUES (p_title);\nEND;\n$$;",
"sql": "CREATE OR REPLACE FUNCTION create_hello(\n p_title text\n)\nRETURNS void\nLANGUAGE plpgsql\nVOLATILE\nSET search_path = ''\nAS $$\nBEGIN\n INSERT INTO public.test (title) VALUES (p_title);\nEND;\n$$;",
"type": "function",
"operation": "create",
"path": "public.create_hello"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ VOLATILE
SET search_path = ''
AS $$
BEGIN
INSERT INTO test (title) VALUES (p_title);
INSERT INTO public.test (title) VALUES (p_title);
END;
$$;
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ VOLATILE
SET search_path = ''
AS $$
BEGIN
INSERT INTO test (title) VALUES (p_title);
INSERT INTO public.test (title) VALUES (p_title);
END;
$$;
2 changes: 1 addition & 1 deletion testdata/diff/create_trigger/add_trigger/plan.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"pgschema_version": "1.7.4",
"created_at": "1970-01-01T00:00:00Z",
"source_fingerprint": {
"hash": "f709d6c4a0ded1f2bbfaa619c74df2f415695c7caecbaab0760a81d802cae7d0"
"hash": "a099356e267a2e28a40672b8d007db5082d1399d9239faa7fcdc194842027381"
},
"groups": [
{
Expand Down
2 changes: 1 addition & 1 deletion testdata/diff/dependency/table_to_function/diff.sql
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ LANGUAGE plpgsql
VOLATILE
AS $$
BEGIN
RETURN (SELECT COUNT(*) FROM documents);
RETURN (SELECT COUNT(*) FROM public.documents);
END;
$$;
2 changes: 1 addition & 1 deletion testdata/diff/dependency/table_to_function/plan.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"path": "public.documents"
},
{
"sql": "CREATE OR REPLACE FUNCTION get_document_count()\nRETURNS integer\nLANGUAGE plpgsql\nVOLATILE\nAS $$\nBEGIN\n RETURN (SELECT COUNT(*) FROM documents);\nEND;\n$$;",
"sql": "CREATE OR REPLACE FUNCTION get_document_count()\nRETURNS integer\nLANGUAGE plpgsql\nVOLATILE\nAS $$\nBEGIN\n RETURN (SELECT COUNT(*) FROM public.documents);\nEND;\n$$;",
"type": "function",
"operation": "create",
"path": "public.get_document_count"
Expand Down
2 changes: 1 addition & 1 deletion testdata/diff/dependency/table_to_function/plan.sql
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ LANGUAGE plpgsql
VOLATILE
AS $$
BEGIN
RETURN (SELECT COUNT(*) FROM documents);
RETURN (SELECT COUNT(*) FROM public.documents);
END;
$$;
2 changes: 1 addition & 1 deletion testdata/diff/dependency/table_to_function/plan.txt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ LANGUAGE plpgsql
VOLATILE
AS $$
BEGIN
RETURN (SELECT COUNT(*) FROM documents);
RETURN (SELECT COUNT(*) FROM public.documents);
END;
$$;
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ LANGUAGE plpgsql
VOLATILE
AS $$
BEGIN
RETURN QUERY SELECT u.id, u.name, u.email FROM users u WHERE u.name = p_name;
RETURN QUERY SELECT u.id, u.name, u.email FROM public.users u WHERE u.name = p_name;
END;
$$;

Expand All @@ -57,7 +57,7 @@ LANGUAGE plpgsql
VOLATILE
AS $$
BEGIN
RETURN (SELECT count(*)::integer FROM users);
RETURN (SELECT count(*)::integer FROM public.users);
END;
$$;

Expand All @@ -72,7 +72,7 @@ CREATE OR REPLACE PROCEDURE insert_user(
LANGUAGE plpgsql
AS $$
BEGIN
INSERT INTO users (name, email) VALUES (p_name, p_email);
INSERT INTO public.users (name, email) VALUES (p_name, p_email);
END;
$$;

Loading
Loading