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.

@@ -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
- columns.append("".join(current).strip())
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
- columns.append("".join(current).strip())
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) or not re.search(r"^[a-zA-Z0-9_>*]", column):
59
- raise Exception(f"Invalid column specified: {column}")
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
- local_column, foreign_column = key.split(">")
66
- foreign = self.tx.table(self.current_table).foreign_key_info(local_column)
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 Exception(
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
- `options` can control whether to alias columns and/or tables.
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
- raise Exception(f"Invalid key={key}")
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 Exception(f"Invalid column={column}")
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 + "." + self.quote(column)
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
- return self.remove_operator(key).replace(column, name)
198
+ # Safely replace only the column part, preserving operators
199
+ return self.remove_operator(key).replace(column, name, 1)
112
200
 
113
- local_column, foreign_column = column.split(">")
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
- data = self.__fetch_foreign_data(column)
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
- name = f"{self.get_table_alias(data['ref_table'])}.{self.quote(foreign_column)}"
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
- result = self.remove_operator(key).replace(column, name)
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" as {local_column}_{foreign_column}"
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
- key = " ".join(key.replace('"', "").split())
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 key.startswith(symbol):
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
- case = None
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 tuple for better PostgreSQL parameter handling
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
- # Use IN/NOT IN instead of ANY for better type compatibility
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 a dict-based WHERE into a list of predicate strings + param values.
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
- new_where = []
480
+ if not where: # Empty dict
481
+ return "", tuple()
482
+
483
+ predicate_list = []
279
484
  for key, val in where.items():
280
- new_where.append(self.make_predicate(key, val))
281
- where = new_where
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
- sql = []
518
+ # Build final SQL and collect parameters
519
+ sql_parts = ["WHERE"]
284
520
  vals = []
285
- if where:
286
- sql.append("WHERE")
287
- join = ""
288
- for pred, val in where:
289
- if join:
290
- sql.append(join)
291
- sql.append(pred)
292
- join = "AND"
293
- if val is not None:
294
- if isinstance(val, tuple):
295
- vals.extend(val)
296
- else:
297
- vals.append(val)
298
- return " ".join(sql), tuple(vals)
299
-
300
- @classmethod
301
- def quote(cls, data):
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
- new_list = []
307
- for item in data:
308
- if item.startswith("@@"):
309
- new_list.append(item[2:])
310
- else:
311
- new_list.append(cls.quote(item))
312
- return new_list
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 '"' in part:
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
- elif part.upper() in cls.reserved or re.findall(r"[/]", part):
320
- quoted_parts.append(f'"{part}"')
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)