diff --git a/pkg/common/sql_helpers.go b/pkg/common/sql_helpers.go index 47305de..e68b736 100644 --- a/pkg/common/sql_helpers.go +++ b/pkg/common/sql_helpers.go @@ -446,18 +446,36 @@ func containsTopLevelOR(clause string) bool { return false } -// splitByAND splits a WHERE clause by AND operators (case-insensitive) -// This is parenthesis-aware and won't split on AND operators inside subqueries +// splitByAND splits a WHERE clause by AND operators (case-insensitive). +// It is parenthesis-aware (won't split inside subqueries), quote-aware +// (won't split on AND inside single-quoted strings), and BETWEEN-aware +// (won't split on the AND that separates the two operands of BETWEEN x AND y). func splitByAND(where string) []string { conditions := []string{} currentCondition := strings.Builder{} - depth := 0 // Track parenthesis depth + depth := 0 // parenthesis nesting depth + inSingleQuote := false + afterBetween := false // true after seeing BETWEEN at depth 0; next AND belongs to it i := 0 for i < len(where) { ch := where[i] - // Track parenthesis depth + // Track single-quote state so we never split on AND inside string literals. + if ch == '\'' { + inSingleQuote = !inSingleQuote + currentCondition.WriteByte(ch) + i++ + continue + } + + if inSingleQuote { + currentCondition.WriteByte(ch) + i++ + continue + } + + // Track parenthesis depth (outside quotes only). if ch == '(' { depth++ currentCondition.WriteByte(ch) @@ -470,32 +488,39 @@ func splitByAND(where string) []string { continue } - // Only look for AND operators at depth 0 (not inside parentheses) + // All keyword checks only apply at depth 0 (not inside subqueries). if depth == 0 { - // Check if we're at an AND operator (case-insensitive) - // We need at least " AND " (5 chars) or " and " (5 chars) - if i+5 <= len(where) { - substring := where[i : i+5] - lowerSubstring := strings.ToLower(substring) + // Detect " BETWEEN " (9 chars, case-insensitive) so the very next + // top-level AND is recognised as part of the BETWEEN syntax. + if i+9 <= len(where) && strings.ToLower(where[i:i+9]) == " between " { + afterBetween = true + currentCondition.WriteString(where[i : i+9]) + i += 9 + continue + } - if lowerSubstring == " and " { - // Found an AND operator at the top level - // Add the current condition to the list - conditions = append(conditions, currentCondition.String()) - currentCondition.Reset() - // Skip past the AND operator + // Detect " AND " (5 chars, case-insensitive). + if i+5 <= len(where) && strings.ToLower(where[i:i+5]) == " and " { + if afterBetween { + // This AND closes a BETWEEN expression — do NOT split. + afterBetween = false + currentCondition.WriteString(where[i : i+5]) i += 5 continue } + // Regular conjunction — split here. + conditions = append(conditions, currentCondition.String()) + currentCondition.Reset() + i += 5 + continue } } - // Not an AND operator or we're inside parentheses, just add the character currentCondition.WriteByte(ch) i++ } - // Add the last condition + // Add the last condition. if currentCondition.Len() > 0 { conditions = append(conditions, currentCondition.String()) } diff --git a/pkg/common/sql_helpers_test.go b/pkg/common/sql_helpers_test.go index 96e0e5a..c745b2d 100644 --- a/pkg/common/sql_helpers_test.go +++ b/pkg/common/sql_helpers_test.go @@ -520,6 +520,38 @@ func TestSplitByAND(t *testing.T) { input: "a = 1 AND b = 2 AND c = 3 and (select s from generate_series(1,10) s where s < 10 and s > 0 offset 2 limit 1) = 3", expected: []string{"a = 1", "b = 2", "c = 3", "(select s from generate_series(1,10) s where s < 10 and s > 0 offset 2 limit 1) = 3"}, }, + // BETWEEN-aware cases: the AND inside BETWEEN x AND y must not cause a split. + { + name: "BETWEEN does not split on its AND", + input: "col between '2025-08-31' and '1970-01-01'", + expected: []string{"col between '2025-08-31' and '1970-01-01'"}, + }, + { + name: "BETWEEN uppercase AND", + input: "col BETWEEN '2025-08-31' AND '1970-01-01'", + expected: []string{"col BETWEEN '2025-08-31' AND '1970-01-01'"}, + }, + { + name: "BETWEEN followed by a regular AND conjunction", + input: "col between 1 and 5 and other = 'x'", + expected: []string{"col between 1 and 5", "other = 'x'"}, + }, + { + name: "two BETWEEN conditions joined by AND", + input: "col1 between 1 and 5 and col2 between 10 and 20", + expected: []string{"col1 between 1 and 5", "col2 between 10 and 20"}, + }, + { + name: "complex OR block with multiple BETWEENs (real-world case)", + input: "tbl.applicationdate between '2025-08-31' and '1970-01-01'\n or tbl.capturedate between '2025-08-31' and '1970-01-01'\n or tbl.startdate between '2025-08-31' AND '1970-01-01'", + expected: []string{"tbl.applicationdate between '2025-08-31' and '1970-01-01'\n or tbl.capturedate between '2025-08-31' and '1970-01-01'\n or tbl.startdate between '2025-08-31' AND '1970-01-01'"}, + }, + // Quote-aware cases: AND inside a string literal must not split. + { + name: "AND inside single-quoted string is not a split point", + input: "comment = 'this and that' and status = 'active'", + expected: []string{"comment = 'this and that'", "status = 'active'"}, + }, } for _, tt := range tests { @@ -917,6 +949,25 @@ where: "(true AND status = 'active')", tableName: "unregistered_table", expected: "(true AND unregistered_table.status = 'active')", }, +// BETWEEN regression: date literals inside BETWEEN must not be prefixed as columns. +{ +name: "BETWEEN date range - second date must not be prefixed", +where: "applicationdate between '2025-08-31' and '1970-01-01'", +tableName: "unregistered_table", +expected: "unregistered_table.applicationdate between '2025-08-31' and '1970-01-01'", +}, +{ +name: "Already-prefixed BETWEEN column - unchanged", +where: `"v_webui_clients".applicationdate between '2025-08-31' and '1970-01-01'`, +tableName: "v_webui_clients", +expected: `"v_webui_clients".applicationdate between '2025-08-31' and '1970-01-01'`, +}, +{ +name: "Complex OR block with multiple BETWEENs - date values must not be prefixed", +where: `("v_webui_clients".applicationdate between '2025-08-31' and '1970-01-01' or "v_webui_clients".clientcapturedate between '2025-08-31' and '1970-01-01' or "v_webui_clients".startdate between '2025-08-31' AND '1970-01-01')`, +tableName: "v_webui_clients", +expected: `("v_webui_clients".applicationdate between '2025-08-31' and '1970-01-01' or "v_webui_clients".clientcapturedate between '2025-08-31' and '1970-01-01' or "v_webui_clients".startdate between '2025-08-31' AND '1970-01-01')`, +}, } for _, tt := range tests {