velocity-python 0.0.88__py3-none-any.whl → 0.0.90__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 CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = version = "0.0.88"
1
+ __version__ = version = "0.0.90"
2
2
 
3
3
  from . import aws
4
4
  from . import db
velocity/aws/amplify.py CHANGED
@@ -325,7 +325,6 @@ class AmplifyProject:
325
325
  "ec2:AttachNetworkInterface",
326
326
  "ec2:DescribeNetworkInterfaces",
327
327
  "ec2:DeleteNetworkInterface",
328
- "autoscaling:CompleteLifecycleAction",
329
328
  "cognito-idp:*",
330
329
  ],
331
330
  }
@@ -12,10 +12,44 @@ from ..tablehelper import TableHelper
12
12
  from collections.abc import Mapping, Sequence
13
13
 
14
14
 
15
+ # Configure TableHelper for PostgreSQL
15
16
  TableHelper.reserved = reserved_words
16
17
  TableHelper.operators = OPERATORS
17
18
 
18
19
 
20
+ def _get_table_helper(tx, table):
21
+ """
22
+ Utility function to create a TableHelper instance.
23
+ Ensures consistent configuration across all SQL methods.
24
+ """
25
+ return TableHelper(tx, table)
26
+
27
+
28
+ def _validate_table_name(table):
29
+ """Validate table name format."""
30
+ if not table or not isinstance(table, str):
31
+ raise ValueError("Table name must be a non-empty string")
32
+ # Add more validation as needed
33
+ return table.strip()
34
+
35
+
36
+ def _handle_predicate_errors(predicates, operation="WHERE"):
37
+ """Process a list of predicates with error handling."""
38
+ sql_parts = []
39
+ vals = []
40
+
41
+ for pred, val in predicates:
42
+ sql_parts.append(pred)
43
+ if val is None:
44
+ pass
45
+ elif isinstance(val, tuple):
46
+ vals.extend(val)
47
+ else:
48
+ vals.append(val)
49
+
50
+ return sql_parts, vals
51
+
52
+
19
53
  system_fields = [
20
54
  "sys_id",
21
55
  "sys_created",
@@ -72,9 +106,17 @@ class SQL:
72
106
  lock=None,
73
107
  skip_locked=None,
74
108
  ):
75
-
109
+ """
110
+ Generate a PostgreSQL SELECT statement with proper table helper integration.
111
+ """
76
112
  if not table:
77
113
  raise ValueError("Table name is required.")
114
+
115
+ # Validate pagination parameters
116
+ if start is not None and not isinstance(start, int):
117
+ raise ValueError("Start (OFFSET) must be an integer.")
118
+ if qty is not None and not isinstance(qty, int):
119
+ raise ValueError("Qty (FETCH) must be an integer.")
78
120
 
79
121
  sql_parts = {
80
122
  "SELECT": [],
@@ -88,8 +130,8 @@ class SQL:
88
130
  sql = []
89
131
  vals = []
90
132
 
91
- # Assume these helpers and functions exist externally
92
- th = TableHelper(tx, table)
133
+ # Create table helper instance
134
+ th = _get_table_helper(tx, table)
93
135
 
94
136
  # Handle columns and DISTINCT before aliasing
95
137
  if columns is None:
@@ -97,25 +139,28 @@ class SQL:
97
139
  columns = ["*"]
98
140
  elif isinstance(columns, str):
99
141
  columns = th.split_columns(columns)
100
-
101
- if not isinstance(columns, Sequence):
102
- raise Exception(
103
- f"variable `columns` must be a sequence, but {type(columns)} was found"
142
+ elif not isinstance(columns, Sequence):
143
+ raise TypeError(
144
+ f"Columns must be a string, sequence, or None, but {type(columns)} was found"
104
145
  )
105
146
 
106
- columns = [c.strip() for c in columns] # Preserve original case
147
+ # Clean and validate columns
148
+ columns = [c.strip() for c in columns if c.strip()] # Remove empty columns
149
+ if not columns:
150
+ raise ValueError("No valid columns specified")
151
+
107
152
  distinct = False
108
153
 
109
- if any(
110
- "distinct" in c.lower() for c in columns
111
- ): # Check if "distinct" exists in any entry
154
+ # Check for DISTINCT keyword in any column
155
+ if any("distinct" in c.lower() for c in columns):
112
156
  distinct = True
113
157
  columns = [re.sub(r"(?i)\bdistinct\b", "", c).strip() for c in columns]
114
158
 
159
+ # Process column references
115
160
  processed_columns = []
116
161
  for col in columns:
117
- processed_columns.append(
118
- th.resolve_references(
162
+ try:
163
+ processed_col = th.resolve_references(
119
164
  col,
120
165
  options={
121
166
  "alias_column": True,
@@ -123,40 +168,65 @@ class SQL:
123
168
  "bypass_on_error": True,
124
169
  },
125
170
  )
126
- )
171
+ processed_columns.append(processed_col)
172
+ except Exception as e:
173
+ raise ValueError(f"Error processing column '{col}': {e}")
127
174
 
128
175
  columns = processed_columns
129
176
 
130
- # Handle WHERE conditions
177
+ # Handle WHERE conditions with better error handling
131
178
  if isinstance(where, Mapping):
132
179
  new_where = []
133
180
  for key, val in where.items():
134
- new_where.append(th.make_predicate(key, val))
181
+ try:
182
+ new_where.append(th.make_predicate(key, val))
183
+ except Exception as e:
184
+ raise ValueError(f"Error processing WHERE condition '{key}': {e}")
135
185
  where = new_where
136
186
 
187
+ # Handle ORDER BY with improved validation
137
188
  new_orderby = []
138
189
  if isinstance(orderby, str):
139
190
  orderby = th.split_columns(orderby)
191
+
140
192
  # Handle orderby references
141
- if isinstance(orderby, (Sequence)):
193
+ if isinstance(orderby, Sequence):
142
194
  for column in orderby:
143
- if " " in column:
144
- col_name, direction = column.split(" ", 1)
145
- col_name = th.resolve_references(
146
- col_name, options={"alias_only": True}
147
- )
148
- new_orderby.append(f"{col_name} {direction}")
149
- else:
150
- new_orderby.append(
151
- th.resolve_references(
195
+ try:
196
+ if " " in column:
197
+ parts = column.split(" ", 1)
198
+ if len(parts) == 2:
199
+ col_name, direction = parts
200
+ # Validate direction
201
+ direction = direction.upper()
202
+ if direction not in ("ASC", "DESC"):
203
+ raise ValueError(f"Invalid ORDER BY direction: {direction}")
204
+ col_name = th.resolve_references(
205
+ col_name.strip(), options={"alias_only": True}
206
+ )
207
+ new_orderby.append(f"{col_name} {direction}")
208
+ else:
209
+ raise ValueError(f"Invalid ORDER BY format: {column}")
210
+ else:
211
+ resolved_col = th.resolve_references(
152
212
  column.strip(), options={"alias_only": True}
153
213
  )
154
- )
214
+ new_orderby.append(resolved_col)
215
+ except Exception as e:
216
+ raise ValueError(f"Error processing ORDER BY column '{column}': {e}")
155
217
 
156
- if isinstance(orderby, Mapping):
218
+ elif isinstance(orderby, Mapping):
157
219
  for key, val in orderby.items():
158
- parsed_key = th.resolve_references(key, options={"alias_only": True})
159
- new_orderby.append(f"{parsed_key} {val}")
220
+ try:
221
+ # Validate direction
222
+ direction = str(val).upper()
223
+ if direction not in ("ASC", "DESC"):
224
+ raise ValueError(f"Invalid ORDER BY direction: {direction}")
225
+ parsed_key = th.resolve_references(key, options={"alias_only": True})
226
+ new_orderby.append(f"{parsed_key} {direction}")
227
+ except Exception as e:
228
+ raise ValueError(f"Error processing ORDER BY key '{key}': {e}")
229
+
160
230
  orderby = new_orderby
161
231
 
162
232
  # Handle groupby
@@ -308,7 +378,7 @@ class SQL:
308
378
  if not isinstance(data, Mapping) or not data:
309
379
  raise ValueError("data must be a non-empty mapping of column-value pairs.")
310
380
 
311
- th = TableHelper(tx, table)
381
+ th = _get_table_helper(tx, table)
312
382
  set_clauses = []
313
383
  vals = []
314
384
 
@@ -390,12 +460,15 @@ class SQL:
390
460
  """
391
461
  Generate an INSERT statement.
392
462
  """
393
-
463
+ # Create a temporary TableHelper instance for quoting
464
+ # Note: We pass None for tx since we only need quoting functionality
465
+ temp_helper = TableHelper(None, table)
466
+
394
467
  keys = []
395
468
  vals_placeholders = []
396
469
  args = []
397
470
  for key, val in data.items():
398
- keys.append(TableHelper.quote(key.lower()))
471
+ keys.append(temp_helper.quote(key.lower()))
399
472
  if isinstance(val, str) and len(val) > 2 and val[:2] == "@@" and val[2:]:
400
473
  vals_placeholders.append(val[2:])
401
474
  else:
@@ -404,7 +477,7 @@ class SQL:
404
477
 
405
478
  sql_parts = []
406
479
  sql_parts.append("INSERT INTO")
407
- sql_parts.append(TableHelper.quote(table))
480
+ sql_parts.append(temp_helper.quote(table))
408
481
  sql_parts.append("(")
409
482
  sql_parts.append(",".join(keys))
410
483
  sql_parts.append(")")
@@ -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,29 @@ class TableHelper:
246
415
 
247
416
  # Lists / tuples => IN / NOT IN
248
417
  if isinstance(val, (list, tuple)) and "><" not in key:
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
426
+ val_tuple = tuple(val)
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
249
435
  if "!" in key:
250
- return f"{column} != ANY(%s)", list(val)
251
- return f"{column} = ANY(%s)", list(val)
436
+ placeholders = ",".join(["%s"] * len(val_tuple))
437
+ return f"{column} NOT IN ({placeholders})", val_tuple
438
+ else:
439
+ placeholders = ",".join(["%s"] * len(val_tuple))
440
+ return f"{column} IN ({placeholders})", val_tuple
252
441
 
253
442
  # "@@" => pass as literal
254
443
  if isinstance(val, str) and val.startswith("@@") and val[2:]:
@@ -256,62 +445,133 @@ class TableHelper:
256
445
 
257
446
  # Between operators
258
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}")
259
450
  return f"{column} {op} %s and %s", tuple(val)
260
451
 
261
- if case:
262
- return f"{case}({column}) {op} {case}(%s)", val
263
-
264
452
  # Default single-parameter predicate
265
453
  return f"{column} {op} %s", val
266
454
 
267
- 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, ...]]:
268
456
  """
269
- 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
270
474
  """
475
+ if not where:
476
+ return "", tuple()
477
+
478
+ # Convert dict to list of predicates
271
479
  if isinstance(where, Mapping):
272
- new_where = []
480
+ if not where: # Empty dict
481
+ return "", tuple()
482
+
483
+ predicate_list = []
273
484
  for key, val in where.items():
274
- new_where.append(self.make_predicate(key, val))
275
- 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}")
276
517
 
277
- sql = []
518
+ # Build final SQL and collect parameters
519
+ sql_parts = ["WHERE"]
278
520
  vals = []
279
- if where:
280
- sql.append("WHERE")
281
- join = ""
282
- for pred, val in where:
283
- if join:
284
- sql.append(join)
285
- sql.append(pred)
286
- join = "AND"
287
- if val is not None:
288
- if isinstance(val, tuple):
289
- vals.extend(val)
290
- else:
291
- vals.append(val)
292
- return " ".join(sql), tuple(vals)
293
-
294
- @classmethod
295
- 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]]:
296
538
  """
297
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)
298
546
  """
299
547
  if isinstance(data, list):
300
- new_list = []
301
- for item in data:
302
- if item.startswith("@@"):
303
- new_list.append(item[2:])
304
- else:
305
- new_list.append(cls.quote(item))
306
- 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:]
307
556
 
308
557
  parts = data.split(".")
309
558
  quoted_parts = []
559
+
310
560
  for part in parts:
311
- 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('"'):
312
566
  quoted_parts.append(part)
313
- elif part.upper() in cls.reserved or re.findall(r"[/]", part):
314
- 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}"')
315
574
  else:
316
575
  quoted_parts.append(part)
576
+
317
577
  return ".".join(quoted_parts)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: velocity-python
3
- Version: 0.0.88
3
+ Version: 0.0.90
4
4
  Summary: A rapid application development library for interfacing with data storage
5
5
  Author-email: Paul Perez <pperez@codeclubs.org>
6
6
  Project-URL: Homepage, https://codeclubs.org/projects/velocity
@@ -1,11 +1,11 @@
1
- velocity/__init__.py,sha256=Li52KlOhqYiPsceaR_8oeXe58Sl2Sp95iPr6m5Z2JfM,106
1
+ velocity/__init__.py,sha256=U7du6kYqP8lRLJ0pYw7mqGVK4pzS9h5BibDnNkZsZm0,106
2
2
  velocity/app/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  velocity/app/invoices.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  velocity/app/orders.py,sha256=W-HAXEwY8-IFXbKh82HnMeRVZM7P-TWGEQOWtkLIzI4,6298
5
5
  velocity/app/payments.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  velocity/app/purchase_orders.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
7
  velocity/aws/__init__.py,sha256=tj9-NliYxRVPYLnnDuA4FMwBHbbH4ed8gtHgwRskNgY,647
8
- velocity/aws/amplify.py,sha256=PkFVmskwhm-ajAiwGg0QnSKlg1b_55d9WLdF-3-70aE,15102
8
+ velocity/aws/amplify.py,sha256=3W0o1eOzypLzzMezPRlGISyoGtc-anbMs9p9nVphHGc,15047
9
9
  velocity/aws/handlers/__init__.py,sha256=xnpFZJVlC2uoeeFW4zuPST8wA8ajaQDky5Y6iXZzi3A,172
10
10
  velocity/aws/handlers/context.py,sha256=UIjNR83y2NSIyK8HMPX8t5tpJHFNabiZvNgmmdQL3HA,1822
11
11
  velocity/aws/handlers/lambda_handler.py,sha256=0KrT6UIxDILzBRpoRSvwDgHpQ-vWfubcZFOCbJsewDc,6516
@@ -30,11 +30,11 @@ velocity/db/servers/sqlite.py,sha256=X210a5pENT9PiVK7f16fxXzFwEsq8fSe58Vouv2xqlk
30
30
  velocity/db/servers/sqlite_reserved.py,sha256=-xmjl-Hgu6lKqkCAXq_6U8_aJX6gvaMgLMLdCt-Ej7o,3006
31
31
  velocity/db/servers/sqlserver.py,sha256=0uGLEWRXiUhrOVTpEA1zvaKq1mcfiaCDp9r7gX-N71g,29914
32
32
  velocity/db/servers/sqlserver_reserved.py,sha256=3LGQYU0qfvk6AbKety96gbzzfLbZ0dNHDPLxKGvvi4Q,4596
33
- velocity/db/servers/tablehelper.py,sha256=t_4Z0j3NT26dCe88ydAskipMoaQyrhgyrs_3hmQ6tDU,11161
33
+ velocity/db/servers/tablehelper.py,sha256=IW17pedialg2xNORv2uAqNA9SB3aomDtB1HRxAEvBmA,21936
34
34
  velocity/db/servers/postgres/__init__.py,sha256=Z0zJib46hz1zfEAMG0rQBlJ6lp47LW7t0_63xMS0zrI,528
35
35
  velocity/db/servers/postgres/operators.py,sha256=A2T1qFwhzPl0fdXVhLZJhh5Qfx-qF8oZsDnxnq2n_V8,389
36
36
  velocity/db/servers/postgres/reserved.py,sha256=5tKLaqFV-HrWRj-nsrxl5KGbmeM3ukn_bPZK36XEu8M,3648
37
- velocity/db/servers/postgres/sql.py,sha256=o2hnu0SLbQdU4-e7R8_t9T9mjY7RiaLadD0unWNqfXA,38188
37
+ velocity/db/servers/postgres/sql.py,sha256=OoVctgZ3C2sUu6PeMFVCbmQHyfWY-KNmBlCX2PkvaGA,41326
38
38
  velocity/db/servers/postgres/types.py,sha256=Wa45ppVf_pdWul-jYWFRGMl6IdSq8dAp10SKnhL7osQ,3757
39
39
  velocity/misc/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
40
40
  velocity/misc/db.py,sha256=MPgt-kkukKR_Wh_S_5W-MyDgaeoZ4YLoDJ54wU2ppm4,2830
@@ -47,8 +47,8 @@ velocity/misc/tools.py,sha256=_bGneHHA_BV-kUonzw5H3hdJ5AOJRCKfzhgpkFbGqIo,1502
47
47
  velocity/misc/conv/__init__.py,sha256=MLYF58QHjzfDSxb1rdnmLnuEQCa3gnhzzZ30CwZVvQo,40
48
48
  velocity/misc/conv/iconv.py,sha256=d4_BucW8HTIkGNurJ7GWrtuptqUf-9t79ObzjJ5N76U,10603
49
49
  velocity/misc/conv/oconv.py,sha256=h5Lo05DqOQnxoD3y6Px_MQP_V-pBbWf8Hkgkb9Xp1jk,6032
50
- velocity_python-0.0.88.dist-info/licenses/LICENSE,sha256=aoN245GG8s9oRUU89KNiGTU4_4OtnNmVi4hQeChg6rM,1076
51
- velocity_python-0.0.88.dist-info/METADATA,sha256=1x5TcQnlwtyyr1igts32LMFZrrMb9VEU68UGr5P00fA,8586
52
- velocity_python-0.0.88.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
53
- velocity_python-0.0.88.dist-info/top_level.txt,sha256=JW2vJPmodgdgSz7H6yoZvnxF8S3fTMIv-YJWCT1sNW0,9
54
- velocity_python-0.0.88.dist-info/RECORD,,
50
+ velocity_python-0.0.90.dist-info/licenses/LICENSE,sha256=aoN245GG8s9oRUU89KNiGTU4_4OtnNmVi4hQeChg6rM,1076
51
+ velocity_python-0.0.90.dist-info/METADATA,sha256=BZNOsGUDcsWp14XaR0iH3WNY9SLaNrcDwA5Hx4NiTOw,8586
52
+ velocity_python-0.0.90.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
53
+ velocity_python-0.0.90.dist-info/top_level.txt,sha256=JW2vJPmodgdgSz7H6yoZvnxF8S3fTMIv-YJWCT1sNW0,9
54
+ velocity_python-0.0.90.dist-info/RECORD,,