velocity-python 0.0.89__py3-none-any.whl → 0.0.92__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of velocity-python might be problematic. Click here for more details.
- velocity/__init__.py +1 -1
- velocity/db/servers/postgres/sql.py +107 -34
- velocity/db/servers/tablehelper.py +329 -75
- velocity_python-0.0.92.dist-info/METADATA +409 -0
- {velocity_python-0.0.89.dist-info → velocity_python-0.0.92.dist-info}/RECORD +8 -8
- velocity_python-0.0.89.dist-info/METADATA +0 -186
- {velocity_python-0.0.89.dist-info → velocity_python-0.0.92.dist-info}/WHEEL +0 -0
- {velocity_python-0.0.89.dist-info → velocity_python-0.0.92.dist-info}/licenses/LICENSE +0 -0
- {velocity_python-0.0.89.dist-info → velocity_python-0.0.92.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import pprint
|
|
2
2
|
import re
|
|
3
3
|
from collections.abc import Mapping
|
|
4
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
4
5
|
from ..core.table import Query
|
|
5
6
|
|
|
6
7
|
|
|
@@ -8,14 +9,27 @@ class TableHelper:
|
|
|
8
9
|
"""
|
|
9
10
|
A helper class used to build SQL queries with joined/aliased tables,
|
|
10
11
|
including foreign key expansions, pointer syntax, etc.
|
|
12
|
+
|
|
13
|
+
This class is database-agnostic. Database-specific reserved words and operators
|
|
14
|
+
should be set as class attributes by the database implementation modules.
|
|
11
15
|
"""
|
|
12
16
|
|
|
17
|
+
# Reserved words that need quoting - set by database implementation
|
|
13
18
|
reserved = []
|
|
19
|
+
|
|
20
|
+
# SQL operators with their symbols - set by database implementation
|
|
14
21
|
operators = {}
|
|
15
22
|
|
|
16
|
-
def __init__(self, tx, table):
|
|
23
|
+
def __init__(self, tx, table: str):
|
|
24
|
+
"""
|
|
25
|
+
Initialize TableHelper.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
tx: Database transaction object
|
|
29
|
+
table: The main table name for this query
|
|
30
|
+
"""
|
|
17
31
|
self.tx = tx
|
|
18
|
-
self.letter = 65
|
|
32
|
+
self.letter = 65 # Start with 'A' for table aliases
|
|
19
33
|
self.table_aliases = {}
|
|
20
34
|
self.foreign_keys = {}
|
|
21
35
|
self.current_table = table
|
|
@@ -27,16 +41,28 @@ class TableHelper:
|
|
|
27
41
|
f"{key}: {pprint.pformat(value)}" for key, value in vars(self).items()
|
|
28
42
|
)
|
|
29
43
|
|
|
30
|
-
def split_columns(self, query):
|
|
44
|
+
def split_columns(self, query: str) -> List[str]:
|
|
31
45
|
"""
|
|
32
46
|
Splits a string of comma-separated column expressions into a list, keeping parentheses balanced.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
query: Comma-separated column expression string
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
List of individual column expressions
|
|
33
53
|
"""
|
|
54
|
+
if not isinstance(query, str):
|
|
55
|
+
raise TypeError(f"Query must be a string, got {type(query)}")
|
|
56
|
+
|
|
34
57
|
columns = []
|
|
35
58
|
balance = 0
|
|
36
59
|
current = []
|
|
60
|
+
|
|
37
61
|
for char in query:
|
|
38
62
|
if char == "," and balance == 0:
|
|
39
|
-
|
|
63
|
+
column = "".join(current).strip()
|
|
64
|
+
if column: # Don't add empty columns
|
|
65
|
+
columns.append(column)
|
|
40
66
|
current = []
|
|
41
67
|
else:
|
|
42
68
|
if char == "(":
|
|
@@ -44,36 +70,81 @@ class TableHelper:
|
|
|
44
70
|
elif char == ")":
|
|
45
71
|
balance -= 1
|
|
46
72
|
current.append(char)
|
|
73
|
+
|
|
74
|
+
# Add the last column
|
|
47
75
|
if current:
|
|
48
|
-
|
|
76
|
+
column = "".join(current).strip()
|
|
77
|
+
if column:
|
|
78
|
+
columns.append(column)
|
|
79
|
+
|
|
49
80
|
return columns
|
|
50
81
|
|
|
51
|
-
def requires_joins(self):
|
|
82
|
+
def requires_joins(self) -> bool:
|
|
83
|
+
"""Check if this query requires table joins."""
|
|
52
84
|
return len(self.table_aliases) > 1
|
|
53
85
|
|
|
54
|
-
def has_pointer(self, column):
|
|
86
|
+
def has_pointer(self, column: str) -> bool:
|
|
55
87
|
"""
|
|
56
88
|
Checks if there's an '>' in the column that indicates a pointer reference, e.g. 'local_column>foreign_column'.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
column: The column string to check
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
bool: True if column contains pointer syntax
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
ValueError: If column format is invalid
|
|
57
98
|
"""
|
|
58
|
-
if not isinstance(column, str)
|
|
59
|
-
raise
|
|
99
|
+
if not isinstance(column, str):
|
|
100
|
+
raise ValueError(f"Column must be a string, got {type(column)}")
|
|
101
|
+
|
|
102
|
+
if not re.search(r"^[a-zA-Z0-9_>*]", column):
|
|
103
|
+
raise ValueError(f"Invalid column specified: {column}")
|
|
104
|
+
|
|
60
105
|
return bool(re.search(r"[a-zA-Z0-9_]+>[a-zA-Z0-9_]+", column))
|
|
61
106
|
|
|
62
|
-
def __fetch_foreign_data(self, key):
|
|
107
|
+
def __fetch_foreign_data(self, key: str) -> Dict[str, Any]:
|
|
108
|
+
"""
|
|
109
|
+
Fetch foreign key information for a given key.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
key: The foreign key string in format 'local_column>foreign_column'
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Dict containing foreign key metadata
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
ValueError: If foreign key is not properly defined
|
|
119
|
+
"""
|
|
63
120
|
if key in self.foreign_keys:
|
|
64
121
|
return self.foreign_keys[key]
|
|
65
|
-
|
|
66
|
-
|
|
122
|
+
|
|
123
|
+
if ">" not in key:
|
|
124
|
+
raise ValueError(f"Invalid foreign key format: {key}. Expected 'local>foreign'")
|
|
125
|
+
|
|
126
|
+
local_column, foreign_column = key.split(">", 1) # Split only on first >
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
foreign = self.tx.table(self.current_table).foreign_key_info(local_column)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
raise ValueError(f"Error fetching foreign key info for {local_column}: {e}")
|
|
132
|
+
|
|
67
133
|
if not foreign:
|
|
68
|
-
raise
|
|
134
|
+
raise ValueError(
|
|
69
135
|
f"Foreign key `{self.current_table}.{local_column}>{foreign_column}` not defined."
|
|
70
136
|
)
|
|
137
|
+
|
|
71
138
|
ref_table = foreign["referenced_table_name"]
|
|
72
139
|
ref_schema = foreign["referenced_table_schema"]
|
|
73
140
|
ref_column = foreign["referenced_column_name"]
|
|
141
|
+
|
|
74
142
|
if ref_table not in self.table_aliases:
|
|
143
|
+
if self.letter > 90: # Z is ASCII 90
|
|
144
|
+
raise ValueError("Too many table aliases - limit of 26 tables exceeded")
|
|
75
145
|
self.table_aliases[ref_table] = chr(self.letter)
|
|
76
146
|
self.letter += 1
|
|
147
|
+
|
|
77
148
|
alias = self.table_aliases[ref_table]
|
|
78
149
|
data = {
|
|
79
150
|
"alias": alias,
|
|
@@ -85,59 +156,133 @@ class TableHelper:
|
|
|
85
156
|
self.foreign_keys[key] = data
|
|
86
157
|
return data
|
|
87
158
|
|
|
88
|
-
def resolve_references(self, key, options=None):
|
|
159
|
+
def resolve_references(self, key: str, options: Optional[Dict[str, Any]] = None) -> str:
|
|
89
160
|
"""
|
|
90
161
|
Resolves pointer syntax or table alias references.
|
|
91
|
-
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
key: The column key that may contain pointer syntax (e.g., 'local>foreign')
|
|
165
|
+
options: Dictionary controlling aliasing behavior:
|
|
166
|
+
- alias_column: Whether to add column aliases
|
|
167
|
+
- alias_table: Whether to prefix with table aliases
|
|
168
|
+
- alias_only: Whether to return only the alias name
|
|
169
|
+
- bypass_on_error: Whether to return original key on errors
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Resolved column reference with appropriate aliasing
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
ValueError: If key is invalid and bypass_on_error is False
|
|
92
176
|
"""
|
|
93
|
-
if not key:
|
|
94
|
-
|
|
177
|
+
if not key or not isinstance(key, str):
|
|
178
|
+
if options and options.get("bypass_on_error"):
|
|
179
|
+
return key or ""
|
|
180
|
+
raise ValueError(f"Invalid key: {key}")
|
|
181
|
+
|
|
95
182
|
if options is None:
|
|
96
183
|
options = {"alias_column": True, "alias_table": False, "alias_only": False}
|
|
184
|
+
|
|
97
185
|
column = self.extract_column_name(key)
|
|
98
|
-
|
|
99
186
|
if not column:
|
|
100
187
|
if options.get("bypass_on_error"):
|
|
101
188
|
return key
|
|
102
|
-
raise
|
|
189
|
+
raise ValueError(f"Could not extract column name from: {key}")
|
|
103
190
|
|
|
104
191
|
alias = self.get_table_alias("current_table")
|
|
105
192
|
if not self.has_pointer(column):
|
|
106
|
-
# Standard column
|
|
107
|
-
if options.get("alias_table") and alias != "A":
|
|
108
|
-
name = alias
|
|
193
|
+
# Standard column - no pointer syntax
|
|
194
|
+
if options.get("alias_table") and alias and alias != self.table_aliases.get("current_table", "A"):
|
|
195
|
+
name = f"{alias}.{self.quote(column)}"
|
|
109
196
|
else:
|
|
110
197
|
name = self.quote(column)
|
|
111
|
-
|
|
198
|
+
# Safely replace only the column part, preserving operators
|
|
199
|
+
return self.remove_operator(key).replace(column, name, 1)
|
|
112
200
|
|
|
113
|
-
|
|
201
|
+
# Handle pointer syntax (local_column>foreign_column)
|
|
202
|
+
pointer_parts = column.split(">", 1) # Split only on first >
|
|
203
|
+
if len(pointer_parts) != 2:
|
|
204
|
+
if options.get("bypass_on_error"):
|
|
205
|
+
return key
|
|
206
|
+
raise ValueError(f"Invalid pointer syntax in column: {column}")
|
|
207
|
+
|
|
208
|
+
local_column, foreign_column = pointer_parts
|
|
209
|
+
local_column = local_column.strip()
|
|
210
|
+
foreign_column = foreign_column.strip()
|
|
211
|
+
|
|
212
|
+
if not local_column or not foreign_column:
|
|
213
|
+
if options.get("bypass_on_error"):
|
|
214
|
+
return key
|
|
215
|
+
raise ValueError(f"Invalid pointer syntax - empty parts in: {column}")
|
|
216
|
+
|
|
114
217
|
if options.get("alias_only"):
|
|
115
218
|
return f"{local_column}_{foreign_column}"
|
|
116
|
-
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
data = self.__fetch_foreign_data(column)
|
|
222
|
+
except Exception as e:
|
|
223
|
+
if options.get("bypass_on_error"):
|
|
224
|
+
return key
|
|
225
|
+
raise ValueError(f"Failed to resolve foreign key reference '{column}': {e}")
|
|
226
|
+
|
|
227
|
+
# Build the foreign table reference
|
|
117
228
|
if options.get("alias_table"):
|
|
118
|
-
|
|
229
|
+
foreign_alias = self.get_table_alias(data['ref_table'])
|
|
230
|
+
if not foreign_alias:
|
|
231
|
+
if options.get("bypass_on_error"):
|
|
232
|
+
return key
|
|
233
|
+
raise ValueError(f"No alias found for foreign table: {data['ref_table']}")
|
|
234
|
+
name = f"{foreign_alias}.{self.quote(foreign_column)}"
|
|
119
235
|
else:
|
|
120
236
|
name = f"{data['ref_table']}.{self.quote(foreign_column)}"
|
|
121
237
|
|
|
122
|
-
|
|
238
|
+
# Replace the column part and add alias if requested
|
|
239
|
+
result = self.remove_operator(key).replace(column, name, 1)
|
|
123
240
|
if options.get("alias_column"):
|
|
124
|
-
result += f"
|
|
241
|
+
result += f" AS {local_column}_{foreign_column}"
|
|
242
|
+
|
|
125
243
|
return result
|
|
126
244
|
|
|
127
|
-
def get_operator(self, key, val):
|
|
245
|
+
def get_operator(self, key: str, val: Any) -> str:
|
|
128
246
|
"""
|
|
129
247
|
Determines the SQL operator from the start of `key` or defaults to '='.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
key: The key string that may contain an operator prefix
|
|
251
|
+
val: The value (used for context in operator determination)
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
str: The SQL operator to use
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
ValueError: If key is invalid or operator is unsafe
|
|
130
258
|
"""
|
|
131
|
-
|
|
259
|
+
if not isinstance(key, str):
|
|
260
|
+
raise ValueError(f"Key must be a string, got {type(key)}")
|
|
261
|
+
|
|
262
|
+
# Sanitize the key to prevent injection
|
|
263
|
+
sanitized_key = " ".join(key.replace('"', "").split())
|
|
264
|
+
|
|
132
265
|
for symbol, operator in self.operators.items():
|
|
133
|
-
if
|
|
266
|
+
if sanitized_key.startswith(symbol):
|
|
267
|
+
# Basic validation that the operator is safe
|
|
268
|
+
if not re.match(r'^[A-Z\s<>=!]+$', operator):
|
|
269
|
+
raise ValueError(f"Unsafe operator detected: {operator}")
|
|
134
270
|
return operator
|
|
135
271
|
return "="
|
|
136
272
|
|
|
137
|
-
def remove_operator(self, key):
|
|
273
|
+
def remove_operator(self, key: str) -> str:
|
|
138
274
|
"""
|
|
139
275
|
Strips recognized operator symbols from the start of `key`.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
key: The key string that may contain an operator prefix
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
Key with operator prefix removed
|
|
140
282
|
"""
|
|
283
|
+
if not isinstance(key, str):
|
|
284
|
+
return key
|
|
285
|
+
|
|
141
286
|
for symbol in self.operators.keys():
|
|
142
287
|
if key.startswith(symbol):
|
|
143
288
|
return key.replace(symbol, "", 1)
|
|
@@ -199,14 +344,21 @@ class TableHelper:
|
|
|
199
344
|
)
|
|
200
345
|
return match.group(1) if match else None
|
|
201
346
|
|
|
202
|
-
def are_parentheses_balanced(self, expression):
|
|
347
|
+
def are_parentheses_balanced(self, expression: str) -> bool:
|
|
203
348
|
"""
|
|
204
349
|
Checks if parentheses in `expression` are balanced.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
expression: The expression to check
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
bool: True if parentheses are balanced
|
|
205
356
|
"""
|
|
206
357
|
stack = []
|
|
207
358
|
opening = "({["
|
|
208
359
|
closing = ")}]"
|
|
209
360
|
matching = {")": "(", "}": "{", "]": "["}
|
|
361
|
+
|
|
210
362
|
for char in expression:
|
|
211
363
|
if char in opening:
|
|
212
364
|
stack.append(char)
|
|
@@ -215,22 +367,39 @@ class TableHelper:
|
|
|
215
367
|
return False
|
|
216
368
|
return not stack
|
|
217
369
|
|
|
218
|
-
def get_table_alias(self, table):
|
|
370
|
+
def get_table_alias(self, table: str) -> Optional[str]:
|
|
371
|
+
"""
|
|
372
|
+
Get the alias for a table.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
table: The table name
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
The table alias or None if not found
|
|
379
|
+
"""
|
|
219
380
|
return self.table_aliases.get(table)
|
|
220
381
|
|
|
221
|
-
def make_predicate(self, key, val, options=None):
|
|
382
|
+
def make_predicate(self, key: str, val: Any, options: Optional[Dict[str, Any]] = None) -> Tuple[str, Any]:
|
|
222
383
|
"""
|
|
223
384
|
Builds a piece of SQL and corresponding parameters for a WHERE/HAVING predicate based on `key`, `val`.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
key: The column key (may include operator prefix)
|
|
388
|
+
val: The value to compare against
|
|
389
|
+
options: Dictionary of options for reference resolution
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
Tuple of (sql_string, parameters)
|
|
224
393
|
"""
|
|
225
394
|
if options is None:
|
|
226
395
|
options = {"alias_table": True, "alias_column": False}
|
|
227
|
-
|
|
396
|
+
|
|
228
397
|
column = self.resolve_references(key, options=options)
|
|
229
398
|
op = self.get_operator(key, val)
|
|
230
399
|
|
|
231
400
|
# Subquery?
|
|
232
401
|
if isinstance(val, Query):
|
|
233
|
-
if op in ("<>"):
|
|
402
|
+
if op in ("<>", "NOT"):
|
|
234
403
|
return f"{column} NOT IN ({val})", val.params or None
|
|
235
404
|
return f"{column} IN ({val})", val.params or None
|
|
236
405
|
|
|
@@ -246,9 +415,23 @@ class TableHelper:
|
|
|
246
415
|
|
|
247
416
|
# Lists / tuples => IN / NOT IN
|
|
248
417
|
if isinstance(val, (list, tuple)) and "><" not in key:
|
|
249
|
-
# Convert to
|
|
418
|
+
# Convert string numbers to integers if all values are numeric strings
|
|
419
|
+
if val and all(isinstance(v, str) and v.isdigit() for v in val):
|
|
420
|
+
try:
|
|
421
|
+
val = tuple(int(v) for v in val)
|
|
422
|
+
except ValueError:
|
|
423
|
+
pass # Keep as strings if conversion fails
|
|
424
|
+
|
|
425
|
+
# Convert to tuple for better parameter handling
|
|
250
426
|
val_tuple = tuple(val)
|
|
251
|
-
|
|
427
|
+
|
|
428
|
+
if not val_tuple: # Empty list/tuple
|
|
429
|
+
if "!" in key:
|
|
430
|
+
return "1=1", None # Empty NOT IN is always true
|
|
431
|
+
else:
|
|
432
|
+
return "1=0", None # Empty IN is always false
|
|
433
|
+
|
|
434
|
+
# Use IN/NOT IN for better type compatibility
|
|
252
435
|
if "!" in key:
|
|
253
436
|
placeholders = ",".join(["%s"] * len(val_tuple))
|
|
254
437
|
return f"{column} NOT IN ({placeholders})", val_tuple
|
|
@@ -262,62 +445,133 @@ class TableHelper:
|
|
|
262
445
|
|
|
263
446
|
# Between operators
|
|
264
447
|
if op in ["BETWEEN", "NOT BETWEEN"]:
|
|
448
|
+
if not isinstance(val, (list, tuple)) or len(val) != 2:
|
|
449
|
+
raise ValueError(f"BETWEEN operator requires exactly 2 values, got {val}")
|
|
265
450
|
return f"{column} {op} %s and %s", tuple(val)
|
|
266
451
|
|
|
267
|
-
if case:
|
|
268
|
-
return f"{case}({column}) {op} {case}(%s)", val
|
|
269
|
-
|
|
270
452
|
# Default single-parameter predicate
|
|
271
453
|
return f"{column} {op} %s", val
|
|
272
454
|
|
|
273
|
-
def make_where(self, where):
|
|
455
|
+
def make_where(self, where: Union[Dict[str, Any], List[Tuple[str, Any]], str, None]) -> Tuple[str, Tuple[Any, ...]]:
|
|
274
456
|
"""
|
|
275
|
-
Converts
|
|
457
|
+
Converts various WHERE clause formats into SQL string and parameter values.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
where: WHERE conditions in one of these formats:
|
|
461
|
+
- Dict: {column: value} pairs that become "column = value" predicates
|
|
462
|
+
- List of tuples: [(predicate_sql, params), ...] for pre-built predicates
|
|
463
|
+
- String: Raw SQL WHERE clause (parameters not extracted)
|
|
464
|
+
- None: No WHERE clause
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
Tuple of (sql_string, parameter_tuple):
|
|
468
|
+
- sql_string: Complete WHERE clause including "WHERE" keyword, or empty string
|
|
469
|
+
- parameter_tuple: Tuple of parameter values for placeholder substitution
|
|
470
|
+
|
|
471
|
+
Raises:
|
|
472
|
+
ValueError: If where format is invalid or predicate generation fails
|
|
473
|
+
TypeError: If where is an unsupported type
|
|
276
474
|
"""
|
|
475
|
+
if not where:
|
|
476
|
+
return "", tuple()
|
|
477
|
+
|
|
478
|
+
# Convert dict to list of predicates
|
|
277
479
|
if isinstance(where, Mapping):
|
|
278
|
-
|
|
480
|
+
if not where: # Empty dict
|
|
481
|
+
return "", tuple()
|
|
482
|
+
|
|
483
|
+
predicate_list = []
|
|
279
484
|
for key, val in where.items():
|
|
280
|
-
|
|
281
|
-
|
|
485
|
+
if not isinstance(key, str):
|
|
486
|
+
raise ValueError(f"WHERE clause keys must be strings, got {type(key)}: {key}")
|
|
487
|
+
try:
|
|
488
|
+
predicate_list.append(self.make_predicate(key, val))
|
|
489
|
+
except Exception as e:
|
|
490
|
+
raise ValueError(f"Failed to create predicate for '{key}': {e}")
|
|
491
|
+
where = predicate_list
|
|
492
|
+
|
|
493
|
+
# Handle string WHERE clause (pass through as-is)
|
|
494
|
+
elif isinstance(where, str):
|
|
495
|
+
where_clause = where.strip()
|
|
496
|
+
if not where_clause:
|
|
497
|
+
return "", tuple()
|
|
498
|
+
# Add WHERE keyword if not present
|
|
499
|
+
if not where_clause.upper().startswith("WHERE"):
|
|
500
|
+
where_clause = f"WHERE {where_clause}"
|
|
501
|
+
return where_clause, tuple()
|
|
502
|
+
|
|
503
|
+
# Validate list format
|
|
504
|
+
elif isinstance(where, (list, tuple)):
|
|
505
|
+
if not where: # Empty list
|
|
506
|
+
return "", tuple()
|
|
507
|
+
|
|
508
|
+
# Validate each predicate tuple
|
|
509
|
+
for i, item in enumerate(where):
|
|
510
|
+
if not isinstance(item, (list, tuple)) or len(item) != 2:
|
|
511
|
+
raise ValueError(f"WHERE predicate {i} must be a 2-element tuple (sql, params), got: {item}")
|
|
512
|
+
sql_part, params = item
|
|
513
|
+
if not isinstance(sql_part, str):
|
|
514
|
+
raise ValueError(f"WHERE predicate {i} SQL must be a string, got {type(sql_part)}: {sql_part}")
|
|
515
|
+
else:
|
|
516
|
+
raise TypeError(f"WHERE clause must be dict, list, string, or None, got {type(where)}: {where}")
|
|
282
517
|
|
|
283
|
-
|
|
518
|
+
# Build final SQL and collect parameters
|
|
519
|
+
sql_parts = ["WHERE"]
|
|
284
520
|
vals = []
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
def quote(
|
|
521
|
+
|
|
522
|
+
for i, (pred_sql, pred_val) in enumerate(where):
|
|
523
|
+
if i > 0: # Add AND between predicates
|
|
524
|
+
sql_parts.append("AND")
|
|
525
|
+
|
|
526
|
+
sql_parts.append(pred_sql)
|
|
527
|
+
|
|
528
|
+
# Handle parameter values
|
|
529
|
+
if pred_val is not None:
|
|
530
|
+
if isinstance(pred_val, tuple):
|
|
531
|
+
vals.extend(pred_val)
|
|
532
|
+
else:
|
|
533
|
+
vals.append(pred_val)
|
|
534
|
+
|
|
535
|
+
return " ".join(sql_parts), tuple(vals)
|
|
536
|
+
|
|
537
|
+
def quote(self, data: Union[str, List[str]]) -> Union[str, List[str]]:
|
|
302
538
|
"""
|
|
303
539
|
Quotes identifiers (columns/tables) if needed, especially if they match reserved words or contain special chars.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
data: String identifier or list of identifiers to quote
|
|
543
|
+
|
|
544
|
+
Returns:
|
|
545
|
+
Quoted identifier(s)
|
|
304
546
|
"""
|
|
305
547
|
if isinstance(data, list):
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
548
|
+
return [self.quote(item) for item in data]
|
|
549
|
+
|
|
550
|
+
if not isinstance(data, str):
|
|
551
|
+
raise ValueError(f"Data must be string or list, got {type(data)}")
|
|
552
|
+
|
|
553
|
+
# Handle special markers
|
|
554
|
+
if data.startswith("@@"):
|
|
555
|
+
return data[2:]
|
|
313
556
|
|
|
314
557
|
parts = data.split(".")
|
|
315
558
|
quoted_parts = []
|
|
559
|
+
|
|
316
560
|
for part in parts:
|
|
317
|
-
if
|
|
561
|
+
if not part: # Skip empty parts
|
|
562
|
+
continue
|
|
563
|
+
|
|
564
|
+
# Skip if already quoted
|
|
565
|
+
if part.startswith('"') and part.endswith('"'):
|
|
318
566
|
quoted_parts.append(part)
|
|
319
|
-
|
|
320
|
-
|
|
567
|
+
# Quote if reserved word, contains special chars, or starts with digit
|
|
568
|
+
elif (part.upper() in self.reserved or
|
|
569
|
+
re.search(r'[/\-\s]', part) or
|
|
570
|
+
(part and part[0].isdigit())):
|
|
571
|
+
# Escape any existing quotes in the identifier
|
|
572
|
+
escaped_part = part.replace('"', '""')
|
|
573
|
+
quoted_parts.append(f'"{escaped_part}"')
|
|
321
574
|
else:
|
|
322
575
|
quoted_parts.append(part)
|
|
576
|
+
|
|
323
577
|
return ".".join(quoted_parts)
|