diff --git a/ir/inspector.go b/ir/inspector.go index 8688f4ca..012e7ade 100644 --- a/ir/inspector.go +++ b/ir/inspector.go @@ -1169,6 +1169,73 @@ func (i *Inspector) buildFunctions(ctx context.Context, schema *IR, targetSchema return nil } +// splitParameterString splits a parameter string by commas, but respects quotes, +// parentheses, and brackets. This handles complex defaults like '{1,2,3}' or '{"key": "value"}' +func splitParameterString(signature string) []string { + var params []string + var current strings.Builder + depth := 0 // Track nesting depth of (), [], {} + inQuote := false // Track if we're inside a string literal + + i := 0 + for i < len(signature) { + ch := rune(signature[i]) + + switch ch { + case '\'': + // Toggle quote state, but handle escaped quotes + if !inQuote { + inQuote = true + current.WriteRune(ch) + i++ + } else { + // Check if this is an escaped quote (two single quotes) + if i+1 < len(signature) && signature[i+1] == '\'' { + current.WriteRune(ch) + current.WriteRune('\'') + i += 2 // Skip both quotes + } else { + inQuote = false + current.WriteRune(ch) + i++ + } + } + case '(', '[', '{': + if !inQuote { + depth++ + } + current.WriteRune(ch) + i++ + case ')', ']', '}': + if !inQuote { + depth-- + } + current.WriteRune(ch) + i++ + case ',': + if !inQuote && depth == 0 { + // This comma is a parameter separator + params = append(params, strings.TrimSpace(current.String())) + current.Reset() + } else { + // This comma is inside quotes or nested structure + current.WriteRune(ch) + } + i++ + default: + current.WriteRune(ch) + i++ + } + } + + // Add the last parameter + if current.Len() > 0 { + params = append(params, strings.TrimSpace(current.String())) + } + + return params +} + // parseParametersFromSignature parses function signature string into Parameter structs // Example signature: "order_id integer, discount_percent numeric DEFAULT 0" // Or with modes: "IN order_id integer, OUT result integer" @@ -1180,8 +1247,8 @@ func (i *Inspector) parseParametersFromSignature(signature string) []*Parameter var parameters []*Parameter position := 1 - // Split by comma to get individual parameters - paramStrings := strings.Split(signature, ",") + // Split by comma to get individual parameters (smart split that respects quotes/brackets) + paramStrings := splitParameterString(signature) for _, paramStr := range paramStrings { paramStr = strings.TrimSpace(paramStr) if paramStr == "" { diff --git a/ir/normalize.go b/ir/normalize.go index 10e06700..5cdbd61f 100644 --- a/ir/normalize.go +++ b/ir/normalize.go @@ -113,21 +113,40 @@ func normalizeDefaultValue(value string) string { } // Handle type casting - remove explicit type casts that are semantically equivalent - // Use regex to properly handle type casts within complex expressions - // Pattern: 'literal'::type -> 'literal' (removes redundant casts from string literals) if strings.Contains(value, "::") { - // Use regex to match and remove type casts from string literals - // This handles: 'text'::text, 'utc'::text, '{}'::jsonb, '{}'::text[], etc. - // Also handles multi-word types like 'value'::character varying + // Handle NULL::type -> NULL + // Example: NULL::text -> NULL + re := regexp.MustCompile(`\bNULL::(?:[a-zA-Z_][\w\s.]*)(?:\[\])?`) + value = re.ReplaceAllString(value, "NULL") + + // Handle numeric literals with type casts + // Example: '-1'::integer -> -1 + // Example: '100'::bigint -> 100 + // Note: PostgreSQL sometimes casts numeric literals to different types, e.g., -1::integer stored as numeric + re = regexp.MustCompile(`'(-?\d+(?:\.\d+)?)'::(?:integer|bigint|smallint|numeric|decimal|real|double precision|int2|int4|int8|float4|float8)`) + value = re.ReplaceAllString(value, "$1") + + // Handle string literals with type casts (including escaped quotes) + // Example: 'text'::text -> 'text' + // Example: 'O''Brien'::text -> 'O''Brien' + // Example: '{}'::jsonb -> '{}' + // Example: '{1,2,3}'::integer[] -> '{1,2,3}' // Pattern explanation: - // '([^']*)' - matches a quoted string literal (capturing the content) + // '(?:[^']|'')*' - matches a quoted string literal, handling escaped quotes '' // ::[a-zA-Z_][\w\s.]* - matches ::typename - // [a-zA-Z_] - type name must start with letter or underscore - // [\w\s.]* - followed by word chars, spaces, or dots (for "character varying" or "pg_catalog.text") - // (?:\[\])? - optionally followed by [] for array types (non-capturing group) - // (?:\b|(?=\[)|$) - followed by word boundary, opening bracket, or end of string - re := regexp.MustCompile(`'([^']*)'::(?:[a-zA-Z_][\w\s.]*)(?:\[\])?`) - value = re.ReplaceAllString(value, "'$1'") + // (?:\[\])? - optionally followed by [] for array types + re = regexp.MustCompile(`('(?:[^']|'')*')::(?:[a-zA-Z_][\w\s.]*)(?:\[\])?`) + value = re.ReplaceAllString(value, "$1") + + // Handle date/timestamp literals with type casts + // Example: '2024-01-01'::date -> '2024-01-01' + // Already handled by the string literal pattern above + + // Handle parenthesized expressions with type casts - remove outer parentheses + // Example: (100)::bigint -> 100::bigint + // Pattern captures the number and the type cast separately + re = regexp.MustCompile(`\((\d+)\)(::(?:bigint|integer|smallint|numeric|decimal))`) + value = re.ReplaceAllString(value, "$1$2") } return value diff --git a/testdata/diff/create_function/add_function/diff.sql b/testdata/diff/create_function/add_function/diff.sql index b0129734..fd556b37 100644 --- a/testdata/diff/create_function/add_function/diff.sql +++ b/testdata/diff/create_function/add_function/diff.sql @@ -1,7 +1,11 @@ CREATE OR REPLACE FUNCTION process_order( order_id integer, discount_percent numeric DEFAULT 0, - note varchar DEFAULT '' + priority_level integer DEFAULT 1, + note varchar DEFAULT '', + status text DEFAULT 'pending', + apply_tax boolean DEFAULT true, + is_priority boolean DEFAULT false ) RETURNS numeric LANGUAGE plpgsql diff --git a/testdata/diff/create_function/add_function/new.sql b/testdata/diff/create_function/add_function/new.sql index a430daee..460749fc 100644 --- a/testdata/diff/create_function/add_function/new.sql +++ b/testdata/diff/create_function/add_function/new.sql @@ -1,7 +1,14 @@ CREATE FUNCTION process_order( order_id integer, + -- Simple numeric defaults discount_percent numeric DEFAULT 0, - note varchar DEFAULT '' + priority_level integer DEFAULT 1, + -- String defaults + note varchar DEFAULT '', + status text DEFAULT 'pending', + -- Boolean defaults + apply_tax boolean DEFAULT true, + is_priority boolean DEFAULT false ) RETURNS numeric LANGUAGE plpgsql diff --git a/testdata/diff/create_function/add_function/plan.json b/testdata/diff/create_function/add_function/plan.json index 1fc13b93..831f7fd5 100644 --- a/testdata/diff/create_function/add_function/plan.json +++ b/testdata/diff/create_function/add_function/plan.json @@ -9,7 +9,7 @@ { "steps": [ { - "sql": "CREATE OR REPLACE FUNCTION process_order(\n order_id integer,\n discount_percent numeric DEFAULT 0,\n note varchar DEFAULT ''\n)\nRETURNS numeric\nLANGUAGE plpgsql\nSECURITY DEFINER\nVOLATILE\nSTRICT\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)\nRETURNS numeric\nLANGUAGE plpgsql\nSECURITY DEFINER\nVOLATILE\nSTRICT\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 b0129734..fd556b37 100644 --- a/testdata/diff/create_function/add_function/plan.sql +++ b/testdata/diff/create_function/add_function/plan.sql @@ -1,7 +1,11 @@ CREATE OR REPLACE FUNCTION process_order( order_id integer, discount_percent numeric DEFAULT 0, - note varchar DEFAULT '' + priority_level integer DEFAULT 1, + note varchar DEFAULT '', + status text DEFAULT 'pending', + apply_tax boolean DEFAULT true, + is_priority boolean DEFAULT false ) RETURNS numeric LANGUAGE plpgsql diff --git a/testdata/diff/create_function/add_function/plan.txt b/testdata/diff/create_function/add_function/plan.txt index eca4bd93..3b64a999 100644 --- a/testdata/diff/create_function/add_function/plan.txt +++ b/testdata/diff/create_function/add_function/plan.txt @@ -12,7 +12,11 @@ DDL to be executed: CREATE OR REPLACE FUNCTION process_order( order_id integer, discount_percent numeric DEFAULT 0, - note varchar DEFAULT '' + priority_level integer DEFAULT 1, + note varchar DEFAULT '', + status text DEFAULT 'pending', + apply_tax boolean DEFAULT true, + is_priority boolean DEFAULT false ) RETURNS numeric LANGUAGE plpgsql