fix(sql_helpers): enhance splitByAND to handle BETWEEN and quotes

* Add support for BETWEEN-aware AND detection
* Ensure AND inside single-quoted strings does not cause splits
* Update tests to cover new BETWEEN and quote scenarios
This commit is contained in:
Hein
2026-06-23 09:41:27 +02:00
parent 4c512acf25
commit 48b72a7631
2 changed files with 94 additions and 18 deletions
+42 -17
View File
@@ -446,18 +446,36 @@ func containsTopLevelOR(clause string) bool {
return false return false
} }
// splitByAND splits a WHERE clause by AND operators (case-insensitive) // splitByAND splits a WHERE clause by AND operators (case-insensitive).
// This is parenthesis-aware and won't split on AND operators inside subqueries // 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 { func splitByAND(where string) []string {
conditions := []string{} conditions := []string{}
currentCondition := strings.Builder{} 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 i := 0
for i < len(where) { for i < len(where) {
ch := where[i] 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 == '(' { if ch == '(' {
depth++ depth++
currentCondition.WriteByte(ch) currentCondition.WriteByte(ch)
@@ -470,32 +488,39 @@ func splitByAND(where string) []string {
continue 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 { if depth == 0 {
// Check if we're at an AND operator (case-insensitive) // Detect " BETWEEN " (9 chars, case-insensitive) so the very next
// We need at least " AND " (5 chars) or " and " (5 chars) // top-level AND is recognised as part of the BETWEEN syntax.
if i+5 <= len(where) { if i+9 <= len(where) && strings.ToLower(where[i:i+9]) == " between " {
substring := where[i : i+5] afterBetween = true
lowerSubstring := strings.ToLower(substring) currentCondition.WriteString(where[i : i+9])
i += 9
continue
}
if lowerSubstring == " and " { // Detect " AND " (5 chars, case-insensitive).
// Found an AND operator at the top level if i+5 <= len(where) && strings.ToLower(where[i:i+5]) == " and " {
// Add the current condition to the list 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()) conditions = append(conditions, currentCondition.String())
currentCondition.Reset() currentCondition.Reset()
// Skip past the AND operator
i += 5 i += 5
continue continue
} }
} }
}
// Not an AND operator or we're inside parentheses, just add the character
currentCondition.WriteByte(ch) currentCondition.WriteByte(ch)
i++ i++
} }
// Add the last condition // Add the last condition.
if currentCondition.Len() > 0 { if currentCondition.Len() > 0 {
conditions = append(conditions, currentCondition.String()) conditions = append(conditions, currentCondition.String())
} }
+51
View File
@@ -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", 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"}, 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 { for _, tt := range tests {
@@ -917,6 +949,25 @@ where: "(true AND status = 'active')",
tableName: "unregistered_table", tableName: "unregistered_table",
expected: "(true AND unregistered_table.status = 'active')", 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 { for _, tt := range tests {