velocity-python 0.0.109__py3-none-any.whl → 0.0.161__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.
Files changed (120) hide show
  1. velocity/__init__.py +3 -1
  2. velocity/app/orders.py +3 -4
  3. velocity/app/tests/__init__.py +1 -0
  4. velocity/app/tests/test_email_processing.py +112 -0
  5. velocity/app/tests/test_payment_profile_sorting.py +191 -0
  6. velocity/app/tests/test_spreadsheet_functions.py +124 -0
  7. velocity/aws/__init__.py +3 -0
  8. velocity/aws/amplify.py +10 -6
  9. velocity/aws/handlers/__init__.py +2 -0
  10. velocity/aws/handlers/base_handler.py +248 -0
  11. velocity/aws/handlers/context.py +251 -2
  12. velocity/aws/handlers/exceptions.py +16 -0
  13. velocity/aws/handlers/lambda_handler.py +24 -85
  14. velocity/aws/handlers/mixins/__init__.py +16 -0
  15. velocity/aws/handlers/mixins/activity_tracker.py +181 -0
  16. velocity/aws/handlers/mixins/aws_session_mixin.py +192 -0
  17. velocity/aws/handlers/mixins/error_handler.py +192 -0
  18. velocity/aws/handlers/mixins/legacy_mixin.py +53 -0
  19. velocity/aws/handlers/mixins/standard_mixin.py +73 -0
  20. velocity/aws/handlers/response.py +1 -1
  21. velocity/aws/handlers/sqs_handler.py +28 -143
  22. velocity/aws/tests/__init__.py +1 -0
  23. velocity/aws/tests/test_lambda_handler_json_serialization.py +120 -0
  24. velocity/aws/tests/test_response.py +163 -0
  25. velocity/db/__init__.py +16 -4
  26. velocity/db/core/decorators.py +48 -13
  27. velocity/db/core/engine.py +187 -840
  28. velocity/db/core/result.py +33 -25
  29. velocity/db/core/row.py +15 -3
  30. velocity/db/core/table.py +493 -50
  31. velocity/db/core/transaction.py +28 -15
  32. velocity/db/exceptions.py +42 -18
  33. velocity/db/servers/base/__init__.py +9 -0
  34. velocity/db/servers/base/initializer.py +70 -0
  35. velocity/db/servers/base/operators.py +98 -0
  36. velocity/db/servers/base/sql.py +503 -0
  37. velocity/db/servers/base/types.py +135 -0
  38. velocity/db/servers/mysql/__init__.py +73 -0
  39. velocity/db/servers/mysql/operators.py +54 -0
  40. velocity/db/servers/{mysql_reserved.py → mysql/reserved.py} +2 -14
  41. velocity/db/servers/mysql/sql.py +718 -0
  42. velocity/db/servers/mysql/types.py +107 -0
  43. velocity/db/servers/postgres/__init__.py +59 -11
  44. velocity/db/servers/postgres/operators.py +34 -0
  45. velocity/db/servers/postgres/sql.py +474 -120
  46. velocity/db/servers/postgres/types.py +88 -2
  47. velocity/db/servers/sqlite/__init__.py +61 -0
  48. velocity/db/servers/sqlite/operators.py +52 -0
  49. velocity/db/servers/sqlite/reserved.py +20 -0
  50. velocity/db/servers/sqlite/sql.py +677 -0
  51. velocity/db/servers/sqlite/types.py +92 -0
  52. velocity/db/servers/sqlserver/__init__.py +73 -0
  53. velocity/db/servers/sqlserver/operators.py +47 -0
  54. velocity/db/servers/sqlserver/reserved.py +32 -0
  55. velocity/db/servers/sqlserver/sql.py +805 -0
  56. velocity/db/servers/sqlserver/types.py +114 -0
  57. velocity/db/servers/tablehelper.py +117 -91
  58. velocity/db/tests/__init__.py +1 -0
  59. velocity/db/tests/common_db_test.py +0 -0
  60. velocity/db/tests/postgres/__init__.py +1 -0
  61. velocity/db/tests/postgres/common.py +49 -0
  62. velocity/db/tests/postgres/test_column.py +29 -0
  63. velocity/db/tests/postgres/test_connections.py +25 -0
  64. velocity/db/tests/postgres/test_database.py +21 -0
  65. velocity/db/tests/postgres/test_engine.py +205 -0
  66. velocity/db/tests/postgres/test_general_usage.py +88 -0
  67. velocity/db/tests/postgres/test_imports.py +8 -0
  68. velocity/db/tests/postgres/test_result.py +19 -0
  69. velocity/db/tests/postgres/test_row.py +137 -0
  70. velocity/db/tests/postgres/test_row_comprehensive.py +720 -0
  71. velocity/db/tests/postgres/test_schema_locking.py +335 -0
  72. velocity/db/tests/postgres/test_schema_locking_unit.py +115 -0
  73. velocity/db/tests/postgres/test_sequence.py +34 -0
  74. velocity/db/tests/postgres/test_sql_comprehensive.py +462 -0
  75. velocity/db/tests/postgres/test_table.py +101 -0
  76. velocity/db/tests/postgres/test_table_comprehensive.py +646 -0
  77. velocity/db/tests/postgres/test_transaction.py +106 -0
  78. velocity/db/tests/sql/__init__.py +1 -0
  79. velocity/db/tests/sql/common.py +177 -0
  80. velocity/db/tests/sql/test_postgres_select_advanced.py +285 -0
  81. velocity/db/tests/sql/test_postgres_select_variances.py +517 -0
  82. velocity/db/tests/test_cursor_rowcount_fix.py +150 -0
  83. velocity/db/tests/test_db_utils.py +270 -0
  84. velocity/db/tests/test_postgres.py +448 -0
  85. velocity/db/tests/test_postgres_unchanged.py +81 -0
  86. velocity/db/tests/test_process_error_robustness.py +292 -0
  87. velocity/db/tests/test_result_caching.py +279 -0
  88. velocity/db/tests/test_result_sql_aware.py +117 -0
  89. velocity/db/tests/test_row_get_missing_column.py +72 -0
  90. velocity/db/tests/test_schema_locking_initializers.py +226 -0
  91. velocity/db/tests/test_schema_locking_simple.py +97 -0
  92. velocity/db/tests/test_sql_builder.py +165 -0
  93. velocity/db/tests/test_tablehelper.py +486 -0
  94. velocity/db/utils.py +129 -51
  95. velocity/misc/conv/__init__.py +2 -0
  96. velocity/misc/conv/iconv.py +5 -4
  97. velocity/misc/export.py +1 -4
  98. velocity/misc/merge.py +1 -1
  99. velocity/misc/tests/__init__.py +1 -0
  100. velocity/misc/tests/test_db.py +90 -0
  101. velocity/misc/tests/test_fix.py +78 -0
  102. velocity/misc/tests/test_format.py +64 -0
  103. velocity/misc/tests/test_iconv.py +203 -0
  104. velocity/misc/tests/test_merge.py +82 -0
  105. velocity/misc/tests/test_oconv.py +144 -0
  106. velocity/misc/tests/test_original_error.py +52 -0
  107. velocity/misc/tests/test_timer.py +74 -0
  108. velocity/misc/tools.py +0 -1
  109. {velocity_python-0.0.109.dist-info → velocity_python-0.0.161.dist-info}/METADATA +2 -2
  110. velocity_python-0.0.161.dist-info/RECORD +129 -0
  111. velocity/db/core/exceptions.py +0 -70
  112. velocity/db/servers/mysql.py +0 -641
  113. velocity/db/servers/sqlite.py +0 -968
  114. velocity/db/servers/sqlite_reserved.py +0 -208
  115. velocity/db/servers/sqlserver.py +0 -921
  116. velocity/db/servers/sqlserver_reserved.py +0 -314
  117. velocity_python-0.0.109.dist-info/RECORD +0 -56
  118. {velocity_python-0.0.109.dist-info → velocity_python-0.0.161.dist-info}/WHEEL +0 -0
  119. {velocity_python-0.0.109.dist-info → velocity_python-0.0.161.dist-info}/licenses/LICENSE +0 -0
  120. {velocity_python-0.0.109.dist-info → velocity_python-0.0.161.dist-info}/top_level.txt +0 -0
@@ -1,13 +1,14 @@
1
1
  import re
2
2
  import hashlib
3
3
  import sqlparse
4
- from psycopg2 import sql
4
+ from psycopg2 import sql as psycopg2_sql
5
5
 
6
6
  from velocity.db import exceptions
7
+ from ..base.sql import BaseSQLDialect
7
8
 
8
9
  from .reserved import reserved_words
9
10
  from .types import TYPES
10
- from .operators import OPERATORS
11
+ from .operators import OPERATORS, PostgreSQLOperators
11
12
  from ..tablehelper import TableHelper
12
13
  from collections.abc import Mapping, Sequence
13
14
 
@@ -17,66 +18,46 @@ TableHelper.reserved = reserved_words
17
18
  TableHelper.operators = OPERATORS
18
19
 
19
20
 
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
-
53
21
  system_fields = [
54
22
  "sys_id",
55
23
  "sys_created",
56
24
  "sys_modified",
57
25
  "sys_modified_by",
26
+ "sys_modified_row",
27
+ "sys_modified_count",
58
28
  "sys_dirty",
59
29
  "sys_table",
60
30
  "description",
61
31
  ]
62
32
 
63
33
 
64
- class SQL:
34
+ class SQL(BaseSQLDialect):
65
35
  server = "PostGreSQL"
66
36
  type_column_identifier = "data_type"
67
37
  is_nullable = "is_nullable"
68
38
 
69
39
  default_schema = "public"
70
40
 
71
- ApplicationErrorCodes = ["22P02", "42883", "42501", "42601", "25P01", "25P02"]
41
+ ApplicationErrorCodes = ["22P02", "42883", "42501", "42601", "25P01", "25P02", "42804"] # Added 42804 for datatype mismatch
72
42
 
73
43
  DatabaseMissingErrorCodes = ["3D000"]
74
44
  TableMissingErrorCodes = ["42P01"]
75
45
  ColumnMissingErrorCodes = ["42703"]
76
46
  ForeignKeyMissingErrorCodes = ["42704"]
77
47
 
78
- ConnectionErrorCodes = ["08001", "08S01", "57P03", "08006", "53300", "08003", "08004", "08P01"]
79
- DuplicateKeyErrorCodes = ["23505"] # unique_violation - no longer relying only on regex
48
+ ConnectionErrorCodes = [
49
+ "08001",
50
+ "08S01",
51
+ "57P03",
52
+ "08006",
53
+ "53300",
54
+ "08003",
55
+ "08004",
56
+ "08P01",
57
+ ]
58
+ DuplicateKeyErrorCodes = [
59
+ "23505"
60
+ ] # unique_violation - no longer relying only on regex
80
61
  RetryTransactionCodes = ["40001", "40P01", "40002"]
81
62
  TruncationErrorCodes = ["22001"]
82
63
  LockTimeoutErrorCodes = ["55P03"]
@@ -84,7 +65,7 @@ class SQL:
84
65
  DataIntegrityErrorCodes = ["23503", "23502", "23514", "23P01", "22003"]
85
66
 
86
67
  @classmethod
87
- def get_error(self, e):
68
+ def get_error(cls, e):
88
69
  error_code = getattr(e, "pgcode", None)
89
70
  error_mesg = getattr(e, "pgerror", None)
90
71
  return error_code, error_mesg
@@ -111,7 +92,7 @@ class SQL:
111
92
  """
112
93
  if not table:
113
94
  raise ValueError("Table name is required.")
114
-
95
+
115
96
  # Validate pagination parameters
116
97
  if start is not None and not isinstance(start, int):
117
98
  raise ValueError("Start (OFFSET) must be an integer.")
@@ -131,7 +112,7 @@ class SQL:
131
112
  vals = []
132
113
 
133
114
  # Create table helper instance
134
- th = _get_table_helper(tx, table)
115
+ th = TableHelper(tx, table)
135
116
 
136
117
  # Handle columns and DISTINCT before aliasing
137
118
  if columns is None:
@@ -148,7 +129,7 @@ class SQL:
148
129
  columns = [c.strip() for c in columns if c.strip()] # Remove empty columns
149
130
  if not columns:
150
131
  raise ValueError("No valid columns specified")
151
-
132
+
152
133
  distinct = False
153
134
 
154
135
  # Check for DISTINCT keyword in any column
@@ -188,7 +169,7 @@ class SQL:
188
169
  new_orderby = []
189
170
  if isinstance(orderby, str):
190
171
  orderby = th.split_columns(orderby)
191
-
172
+
192
173
  # Handle orderby references
193
174
  if isinstance(orderby, Sequence):
194
175
  for column in orderby:
@@ -200,7 +181,9 @@ class SQL:
200
181
  # Validate direction
201
182
  direction = direction.upper()
202
183
  if direction not in ("ASC", "DESC"):
203
- raise ValueError(f"Invalid ORDER BY direction: {direction}")
184
+ raise ValueError(
185
+ f"Invalid ORDER BY direction: {direction}"
186
+ )
204
187
  col_name = th.resolve_references(
205
188
  col_name.strip(), options={"alias_only": True}
206
189
  )
@@ -213,7 +196,9 @@ class SQL:
213
196
  )
214
197
  new_orderby.append(resolved_col)
215
198
  except Exception as e:
216
- raise ValueError(f"Error processing ORDER BY column '{column}': {e}")
199
+ raise ValueError(
200
+ f"Error processing ORDER BY column '{column}': {e}"
201
+ )
217
202
 
218
203
  elif isinstance(orderby, Mapping):
219
204
  for key, val in orderby.items():
@@ -222,11 +207,13 @@ class SQL:
222
207
  direction = str(val).upper()
223
208
  if direction not in ("ASC", "DESC"):
224
209
  raise ValueError(f"Invalid ORDER BY direction: {direction}")
225
- parsed_key = th.resolve_references(key, options={"alias_only": True})
210
+ parsed_key = th.resolve_references(
211
+ key, options={"alias_only": True}
212
+ )
226
213
  new_orderby.append(f"{parsed_key} {direction}")
227
214
  except Exception as e:
228
215
  raise ValueError(f"Error processing ORDER BY key '{key}': {e}")
229
-
216
+
230
217
  orderby = new_orderby
231
218
 
232
219
  # Handle groupby
@@ -256,7 +243,9 @@ class SQL:
256
243
 
257
244
  # FROM clause
258
245
  if th.foreign_keys:
259
- sql_parts["FROM"].append(f"{TableHelper.quote(table)} AS {TableHelper.quote(alias)}")
246
+ sql_parts["FROM"].append(
247
+ f"{TableHelper.quote(table)} AS {TableHelper.quote(alias)}"
248
+ )
260
249
  # Handle joins
261
250
  done = []
262
251
  for key, ref_info in th.foreign_keys.items():
@@ -276,11 +265,44 @@ class SQL:
276
265
  else:
277
266
  sql_parts["FROM"].append(TableHelper.quote(table))
278
267
 
279
- # WHERE
268
+ # WHERE - Enhanced validation to prevent malformed SQL
280
269
  if where:
281
270
  if isinstance(where, str):
271
+ # Validate string WHERE clauses to prevent malformed SQL
272
+ where_stripped = where.strip()
273
+ if not where_stripped:
274
+ raise ValueError("WHERE clause cannot be empty string.")
275
+ # Check for boolean literals first (includes '1' and '0')
276
+ if where_stripped in ('True', 'False', '1', '0'):
277
+ raise ValueError(
278
+ f"Invalid WHERE clause: '{where}'. "
279
+ "Boolean literals alone are not valid WHERE clauses. "
280
+ "Use complete SQL expressions like 'sys_active = true' instead."
281
+ )
282
+ # Then check for other numeric values (excluding '1' and '0' already handled above)
283
+ elif where_stripped.isdigit():
284
+ raise ValueError(
285
+ f"Invalid WHERE clause: '{where}'. "
286
+ "Bare integers are not valid WHERE clauses. "
287
+ "Use a dictionary like {{'sys_id': {where_stripped}}} or "
288
+ f"a complete SQL expression like 'sys_id = {where_stripped}' instead."
289
+ )
282
290
  sql_parts["WHERE"].append(where)
283
- else:
291
+ elif isinstance(where, (int, float, bool)):
292
+ # Handle primitive types that should be converted to proper WHERE clauses
293
+ suggested_fix = "{'sys_id': " + str(where) + "}" if isinstance(where, int) else "complete SQL expression"
294
+ raise ValueError(
295
+ f"Invalid WHERE clause: {where} (type: {type(where).__name__}). "
296
+ f"Primitive values cannot be WHERE clauses directly. "
297
+ f"Use a dictionary like {suggested_fix} or a complete SQL string instead. "
298
+ f"This error prevents PostgreSQL 'argument of WHERE must be type boolean' errors."
299
+ )
300
+ elif isinstance(where, Mapping):
301
+ # Convert dictionary to predicate list
302
+ new_where = []
303
+ for key, val in where.items():
304
+ new_where.append(th.make_predicate(key, val))
305
+ where = new_where
284
306
  for pred, val in where:
285
307
  sql_parts["WHERE"].append(pred)
286
308
  if val is None:
@@ -289,6 +311,22 @@ class SQL:
289
311
  vals.extend(val)
290
312
  else:
291
313
  vals.append(val)
314
+ else:
315
+ # Handle list of tuples or other iterable
316
+ try:
317
+ for pred, val in where:
318
+ sql_parts["WHERE"].append(pred)
319
+ if val is None:
320
+ pass
321
+ elif isinstance(val, tuple):
322
+ vals.extend(val)
323
+ else:
324
+ vals.append(val)
325
+ except (TypeError, ValueError) as e:
326
+ raise ValueError(
327
+ f"Invalid WHERE clause format: {where}. "
328
+ "Expected dictionary, list of (predicate, value) tuples, or SQL string."
329
+ ) from e
292
330
 
293
331
  # GROUP BY
294
332
  if groupby:
@@ -378,7 +416,7 @@ class SQL:
378
416
  if not isinstance(data, Mapping) or not data:
379
417
  raise ValueError("data must be a non-empty mapping of column-value pairs.")
380
418
 
381
- th = _get_table_helper(tx, table)
419
+ th = TableHelper(tx, table)
382
420
  set_clauses = []
383
421
  vals = []
384
422
 
@@ -417,17 +455,53 @@ class SQL:
417
455
  for key, val in where.items():
418
456
  new_where.append(th.make_predicate(key, val))
419
457
  where = new_where
420
- if isinstance(where, str):
458
+ elif isinstance(where, str):
459
+ # Enhanced validation for string WHERE clauses
460
+ where_stripped = where.strip()
461
+ if not where_stripped:
462
+ raise ValueError("WHERE clause cannot be empty string.")
463
+ # Check for boolean literals first (includes '1' and '0')
464
+ if where_stripped in ('True', 'False', '1', '0'):
465
+ raise ValueError(
466
+ f"Invalid WHERE clause: '{where}'. "
467
+ "Boolean literals alone are not valid WHERE clauses. "
468
+ "Use complete SQL expressions like 'sys_active = true' instead."
469
+ )
470
+ # Then check for other numeric values (excluding '1' and '0' already handled above)
471
+ elif where_stripped.isdigit():
472
+ raise ValueError(
473
+ f"Invalid WHERE clause: '{where}'. "
474
+ "Bare integers are not valid WHERE clauses. "
475
+ f"Use a dictionary like {{'sys_id': {where_stripped}}} or "
476
+ f"a complete SQL expression like 'sys_id = {where_stripped}' instead."
477
+ )
421
478
  where_clauses.append(where)
422
- else:
423
- for pred, value in where:
424
- where_clauses.append(pred)
425
- if value is None:
426
- pass
427
- elif isinstance(value, tuple):
428
- vals.extend(value)
429
- else:
430
- vals.append(value)
479
+ elif isinstance(where, (int, float, bool)):
480
+ # Handle primitive types that should be converted to proper WHERE clauses
481
+ suggested_fix = "{'sys_id': " + str(where) + "}" if isinstance(where, int) else "complete SQL expression"
482
+ raise ValueError(
483
+ f"Invalid WHERE clause: {where} (type: {type(where).__name__}). "
484
+ f"Primitive values cannot be WHERE clauses directly. "
485
+ f"Use a dictionary like {suggested_fix} or a complete SQL string instead. "
486
+ f"This error prevents PostgreSQL 'argument of WHERE must be type boolean' errors."
487
+ )
488
+
489
+ # Process the where clause if it's a list of tuples
490
+ if not isinstance(where, str):
491
+ try:
492
+ for pred, value in where:
493
+ where_clauses.append(pred)
494
+ if value is None:
495
+ pass
496
+ elif isinstance(value, tuple):
497
+ vals.extend(value)
498
+ else:
499
+ vals.append(value)
500
+ except (TypeError, ValueError) as e:
501
+ raise ValueError(
502
+ f"Invalid WHERE clause format: {where}. "
503
+ "Expected dictionary, list of (predicate, value) tuples, or SQL string."
504
+ ) from e
431
505
  if not where_clauses:
432
506
  raise ValueError(
433
507
  "No WHERE clause could be constructed. Update would affect all rows."
@@ -463,7 +537,7 @@ class SQL:
463
537
  # Create a temporary TableHelper instance for quoting
464
538
  # Note: We pass None for tx since we only need quoting functionality
465
539
  temp_helper = TableHelper(None, table)
466
-
540
+
467
541
  keys = []
468
542
  vals_placeholders = []
469
543
  args = []
@@ -490,51 +564,164 @@ class SQL:
490
564
 
491
565
  @classmethod
492
566
  def merge(cls, tx, table, data, pk, on_conflict_do_nothing, on_conflict_update):
567
+ if not isinstance(data, Mapping) or not data:
568
+ raise ValueError("data must be a non-empty mapping of column-value pairs.")
569
+
570
+ table_helper = TableHelper(tx, table)
571
+ data = dict(data) # work with a copy to avoid mutating the caller's dict
572
+
493
573
  if pk is None:
494
574
  pkeys = tx.table(table).primary_keys()
495
575
  if not pkeys:
496
576
  raise ValueError("Primary key required for merge.")
497
- # If there are multiple primary keys, use all of them
498
- if len(pkeys) > 1:
499
- pk = {pk: data[pk] for pk in pkeys}
500
- else:
501
- pk = {pkeys[0]: data[pkeys[0]]}
502
- # Remove primary keys from data; they will be used in the conflict target
503
- data = {k: v for k, v in data.items() if k not in pk}
577
+ missing = [key for key in pkeys if key not in data]
578
+ if missing:
579
+ missing_cols = ", ".join(missing)
580
+ raise ValueError(
581
+ "Primary key values missing from data for merge: "
582
+ f"{missing_cols}. Provide pk=... or include the key values in data."
583
+ )
584
+ pk = {key: data[key] for key in pkeys}
585
+ else:
586
+ pk = dict(pk)
587
+ for key, value in pk.items():
588
+ if key in data and data[key] != value:
589
+ raise ValueError(
590
+ f"Conflicting values for primary key '{key}' between data and pk arguments."
591
+ )
504
592
 
505
- # Create a merged dictionary for insert (data + primary key columns)
506
- full_data = {}
507
- full_data.update(data)
508
- full_data.update(pk)
593
+ insert_data = dict(data)
594
+ insert_data.update(pk)
509
595
 
510
- sql, vals = cls.insert(table, full_data)
511
- sql = [sql]
512
- vals = list(vals) # Convert to a mutable list
596
+ update_data = {k: v for k, v in data.items() if k not in pk}
513
597
 
514
- if on_conflict_do_nothing != on_conflict_update:
515
- sql.append("ON CONFLICT")
516
- sql.append("(")
517
- sql.append(",".join(pk.keys()))
518
- sql.append(")")
519
- sql.append("DO")
520
- if on_conflict_do_nothing:
521
- sql.append("NOTHING")
522
- elif on_conflict_update:
523
- # Call update() with excluded=True to produce the SET clause for the upsert.
524
- sql_update, vals_update = cls.update(tx, table, data, pk, excluded=True)
525
- sql.append(sql_update)
526
- # Use list.extend to add the update values to vals.
527
- vals.extend(vals_update)
528
- else:
598
+ if not update_data and on_conflict_update:
599
+ # Nothing to update, fall back to a no-op on conflict resolution.
600
+ on_conflict_do_nothing = True
601
+ on_conflict_update = False
602
+
603
+ if on_conflict_do_nothing == on_conflict_update:
529
604
  raise Exception(
530
605
  "Update on conflict must have one and only one option to complete on conflict."
531
606
  )
532
607
 
608
+ sql, vals = cls.insert(table, insert_data)
609
+ sql = [sql]
610
+ vals = list(vals) # Convert to a mutable list
611
+
612
+ sql.append("ON CONFLICT")
613
+ conflict_columns = [TableHelper.quote(column) for column in pk.keys()]
614
+ sql.append("(")
615
+ sql.append(", ".join(conflict_columns))
616
+ sql.append(")")
617
+ sql.append("DO")
618
+ if on_conflict_do_nothing:
619
+ sql.append("NOTHING")
620
+ elif on_conflict_update:
621
+ sql_update, vals_update = cls.update(
622
+ tx, table, update_data, pk, excluded=True
623
+ )
624
+ sql.append(sql_update)
625
+ vals.extend(vals_update)
626
+
533
627
  import sqlparse
534
628
 
535
629
  final_sql = sqlparse.format(" ".join(sql), reindent=True, keyword_case="upper")
536
630
  return final_sql, tuple(vals)
537
631
 
632
+ @classmethod
633
+ def insnx(cls, tx, table, data, where=None):
634
+ """Insert only when the supplied predicate finds no existing row."""
635
+ if not table:
636
+ raise ValueError("Table name is required.")
637
+ if not isinstance(data, Mapping) or not data:
638
+ raise ValueError("data must be a non-empty mapping of column-value pairs.")
639
+
640
+ # Helper used for quoting and foreign key resolution
641
+ th = TableHelper(tx, table)
642
+ quote_helper = TableHelper(None, table)
643
+
644
+ columns_sql = []
645
+ select_parts = []
646
+ vals = []
647
+
648
+ for key, val in data.items():
649
+ columns_sql.append(quote_helper.quote(key.lower()))
650
+ if isinstance(val, str) and len(val) > 2 and val.startswith("@@") and val[2:]:
651
+ select_parts.append(val[2:])
652
+ else:
653
+ select_parts.append("%s")
654
+ vals.append(val)
655
+
656
+ if not select_parts:
657
+ raise ValueError("At least one column is required for insert.")
658
+
659
+ if where is None:
660
+ if tx is None:
661
+ raise ValueError(
662
+ "A transaction context is required when deriving WHERE from primary keys."
663
+ )
664
+ pk_cols = tx.table(table).primary_keys()
665
+ if not pk_cols:
666
+ raise ValueError("Primary key required to derive WHERE clause.")
667
+ missing = [pk for pk in pk_cols if pk not in data]
668
+ if missing:
669
+ raise ValueError(
670
+ "Missing primary key value(s) for insert condition: " + ", ".join(missing)
671
+ )
672
+ where = {pk: data[pk] for pk in pk_cols}
673
+
674
+ where_clauses = []
675
+ where_vals = []
676
+
677
+ if isinstance(where, Mapping):
678
+ compiled = []
679
+ for key, val in where.items():
680
+ compiled.append(th.make_predicate(key, val))
681
+ where = compiled
682
+
683
+ if isinstance(where, str):
684
+ where_clauses.append(where)
685
+ else:
686
+ try:
687
+ for predicate, value in where:
688
+ where_clauses.append(predicate)
689
+ if value is None:
690
+ continue
691
+ if isinstance(value, tuple):
692
+ where_vals.extend(value)
693
+ else:
694
+ where_vals.append(value)
695
+ except (TypeError, ValueError) as exc:
696
+ raise ValueError(
697
+ "Invalid WHERE clause format. Expected mapping, SQL string, or iterable of predicate/value pairs."
698
+ ) from exc
699
+
700
+ vals.extend(where_vals)
701
+
702
+ exists_sql = [
703
+ "SELECT 1 FROM",
704
+ TableHelper.quote(table),
705
+ ]
706
+ if where_clauses:
707
+ exists_sql.append("WHERE " + " AND ".join(where_clauses))
708
+
709
+ sql_parts = [
710
+ "INSERT INTO",
711
+ TableHelper.quote(table),
712
+ f"({','.join(columns_sql)})",
713
+ "SELECT",
714
+ ", ".join(select_parts),
715
+ "WHERE NOT EXISTS (",
716
+ " ".join(exists_sql),
717
+ ")",
718
+ ]
719
+
720
+ final_sql = sqlparse.format(" ".join(sql_parts), reindent=True, keyword_case="upper")
721
+ return final_sql, tuple(vals)
722
+
723
+ insert_if_not_exists = insnx
724
+
538
725
  @classmethod
539
726
  def version(cls):
540
727
  return "select version()", tuple()
@@ -614,14 +801,56 @@ class SQL:
614
801
  def drop_database(cls, name):
615
802
  return f"drop database if exists {name}", tuple()
616
803
 
804
+ @staticmethod
805
+ def _sys_modified_function_sql(schema_identifier):
806
+ return f"""
807
+ CREATE OR REPLACE FUNCTION {schema_identifier}.on_sys_modified()
808
+ RETURNS TRIGGER AS
809
+ $BODY$
810
+ BEGIN
811
+ IF (TG_OP = 'INSERT') THEN
812
+ NEW.sys_table := TG_TABLE_NAME;
813
+ NEW.sys_created := transaction_timestamp();
814
+ NEW.sys_modified := transaction_timestamp();
815
+ NEW.sys_modified_row := clock_timestamp();
816
+ NEW.sys_modified_count := 0;
817
+ ELSIF (TG_OP = 'UPDATE') THEN
818
+ NEW.sys_table := TG_TABLE_NAME;
819
+ NEW.sys_created := OLD.sys_created;
820
+ NEW.sys_modified_count := COALESCE(OLD.sys_modified_count, 0);
821
+ IF ROW(NEW.*) IS DISTINCT FROM ROW(OLD.*) THEN
822
+ IF OLD.sys_dirty IS TRUE AND NEW.sys_dirty IS FALSE THEN
823
+ NEW.sys_dirty := FALSE;
824
+ ELSE
825
+ NEW.sys_dirty := TRUE;
826
+ END IF;
827
+ NEW.sys_modified := transaction_timestamp();
828
+ NEW.sys_modified_row := clock_timestamp();
829
+ NEW.sys_modified_count := COALESCE(OLD.sys_modified_count, 0) + 1;
830
+ END IF;
831
+ END IF;
832
+ RETURN NEW;
833
+ END;
834
+ $BODY$
835
+ LANGUAGE plpgsql VOLATILE
836
+ COST 100;
837
+ """
838
+
617
839
  @classmethod
618
840
  def create_table(cls, name, columns={}, drop=False):
619
841
  if "." in name:
620
842
  fqtn = TableHelper.quote(name)
621
843
  else:
622
844
  fqtn = f"public.{TableHelper.quote(name)}"
845
+
623
846
  schema, table = fqtn.split(".")
624
- name = fqtn.replace(".", "_")
847
+ schema_unquoted = schema.replace('"', "")
848
+ table_unquoted = table.replace('"', "")
849
+ trigger_name = (
850
+ f"on_update_row_{schema_unquoted}_{table_unquoted}".replace(".", "_")
851
+ )
852
+ trigger_identifier = TableHelper.quote(trigger_name)
853
+ schema_identifier = TableHelper.quote(schema_unquoted)
625
854
  sql = []
626
855
  if drop:
627
856
  sql.append(cls.drop_table(fqtn)[0])
@@ -629,39 +858,25 @@ class SQL:
629
858
  f"""
630
859
  CREATE TABLE {fqtn} (
631
860
  sys_id BIGSERIAL PRIMARY KEY,
632
- sys_modified TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
633
861
  sys_created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
634
- sys_modified_by TEXT,
862
+ sys_modified TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
863
+ sys_modified_by TEXT NOT NULL DEFAULT 'SYSTEM',
864
+ sys_modified_row TIMESTAMP NOT NULL DEFAULT CLOCK_TIMESTAMP(),
865
+ sys_modified_count INTEGER NOT NULL DEFAULT 0,
635
866
  sys_dirty BOOLEAN NOT NULL DEFAULT FALSE,
636
- sys_table TEXT,
867
+ sys_table TEXT NOT NULL,
637
868
  description TEXT
638
869
  );
639
870
 
640
871
  SELECT SETVAL(PG_GET_SERIAL_SEQUENCE('{fqtn}', 'sys_id'),1000,TRUE);
641
872
 
642
- CREATE OR REPLACE FUNCTION {schema}.on_sys_modified()
643
- RETURNS TRIGGER AS
644
- $BODY$
645
- BEGIN
646
- -- update sys_modified on each insert/update.
647
- NEW.sys_modified := now();
648
- if (TG_OP = 'INSERT') THEN
649
- NEW.sys_created :=now();
650
- ELSEIF (TG_OP = 'UDPATE') THEN
651
- -- Do not allow sys_created to be modified.
652
- NEW.sys_created := OLD.sys_created;
653
- END IF;
654
- -- Insert table name to row
655
- NEW.sys_table := TG_TABLE_NAME;
656
- RETURN NEW;
657
- END;
658
- $BODY$
659
- LANGUAGE plpgsql VOLATILE
660
- COST 100;
873
+ {cls._sys_modified_function_sql(schema_identifier)}
874
+
875
+ DROP TRIGGER IF EXISTS {trigger_identifier} ON {fqtn};
661
876
 
662
- CREATE TRIGGER on_update_row_{fqtn.replace('.', '_')}
877
+ CREATE TRIGGER {trigger_identifier}
663
878
  BEFORE INSERT OR UPDATE ON {fqtn}
664
- FOR EACH ROW EXECUTE PROCEDURE {schema}.on_sys_modified();
879
+ FOR EACH ROW EXECUTE PROCEDURE {schema_identifier}.on_sys_modified();
665
880
 
666
881
  """
667
882
  )
@@ -677,6 +892,145 @@ class SQL:
677
892
  sql = sqlparse.format(" ".join(sql), reindent=True, keyword_case="upper")
678
893
  return sql, tuple()
679
894
 
895
+ @classmethod
896
+ def ensure_system_columns(cls, name, existing_columns=None, force=False):
897
+ """Ensure all Velocity system columns and triggers exist for the table."""
898
+ existing_columns = {
899
+ col.lower() for col in (existing_columns or [])
900
+ }
901
+
902
+ required_columns = [
903
+ "sys_id",
904
+ "sys_created",
905
+ "sys_modified",
906
+ "sys_modified_by",
907
+ "sys_modified_row",
908
+ "sys_modified_count",
909
+ "sys_dirty",
910
+ "sys_table",
911
+ "description",
912
+ ]
913
+
914
+ missing_columns = [
915
+ column for column in required_columns if column not in existing_columns
916
+ ]
917
+
918
+ if not missing_columns and not force:
919
+ return None
920
+
921
+ if "." in name:
922
+ schema_name, table_name = name.split(".", 1)
923
+ else:
924
+ schema_name = cls.default_schema
925
+ table_name = name
926
+
927
+ schema_unquoted = schema_name.replace('"', "")
928
+ table_unquoted = table_name.replace('"', "")
929
+
930
+ schema_identifier = TableHelper.quote(schema_unquoted)
931
+ table_identifier = TableHelper.quote(table_unquoted)
932
+ fqtn = f"{schema_identifier}.{table_identifier}"
933
+
934
+ trigger_name = (
935
+ f"on_update_row_{schema_unquoted}_{table_unquoted}".replace(".", "_")
936
+ )
937
+ trigger_identifier = TableHelper.quote(trigger_name)
938
+
939
+ statements = []
940
+ added_columns = set()
941
+ columns_after = set(existing_columns)
942
+
943
+ if "sys_id" in missing_columns:
944
+ statements.append(
945
+ f"ALTER TABLE {fqtn} ADD COLUMN {TableHelper.quote('sys_id')} BIGSERIAL PRIMARY KEY;"
946
+ )
947
+ added_columns.add("sys_id")
948
+ columns_after.add("sys_id")
949
+
950
+ column_definitions = {
951
+ "sys_created": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
952
+ "sys_modified": "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
953
+ "sys_modified_by": "TEXT NOT NULL DEFAULT 'SYSTEM'",
954
+ "sys_modified_row": "TIMESTAMP NOT NULL DEFAULT CLOCK_TIMESTAMP()",
955
+ "sys_modified_count": "INTEGER NOT NULL DEFAULT 0",
956
+ "sys_dirty": "BOOLEAN NOT NULL DEFAULT FALSE",
957
+ "sys_table": "TEXT",
958
+ "description": "TEXT",
959
+ }
960
+
961
+ for column, definition in column_definitions.items():
962
+ if column in missing_columns:
963
+ statements.append(
964
+ f"ALTER TABLE {fqtn} ADD COLUMN {TableHelper.quote(column)} {definition};"
965
+ )
966
+ added_columns.add(column)
967
+ columns_after.add(column)
968
+
969
+ default_map = {
970
+ "sys_created": "CURRENT_TIMESTAMP",
971
+ "sys_modified": "CURRENT_TIMESTAMP",
972
+ "sys_modified_by": "'SYSTEM'",
973
+ "sys_modified_row": "CLOCK_TIMESTAMP()",
974
+ "sys_modified_count": "0",
975
+ "sys_dirty": "FALSE",
976
+ }
977
+
978
+ for column, default_sql in default_map.items():
979
+ if column in columns_after and (force or column in added_columns):
980
+ quoted_column = TableHelper.quote(column)
981
+ statements.append(
982
+ f"UPDATE {fqtn} SET {quoted_column} = {default_sql} WHERE {quoted_column} IS NULL;"
983
+ )
984
+ statements.append(
985
+ f"ALTER TABLE {fqtn} ALTER COLUMN {quoted_column} SET DEFAULT {default_sql};"
986
+ )
987
+
988
+ if "sys_table" in columns_after and (force or "sys_table" in added_columns):
989
+ quoted_column = TableHelper.quote("sys_table")
990
+ table_literal = table_unquoted.replace("'", "''")
991
+ statements.append(
992
+ f"UPDATE {fqtn} SET {quoted_column} = COALESCE({quoted_column}, '{table_literal}') WHERE {quoted_column} IS NULL;"
993
+ )
994
+
995
+ not_null_columns = {
996
+ "sys_created",
997
+ "sys_modified",
998
+ "sys_modified_by",
999
+ "sys_modified_row",
1000
+ "sys_modified_count",
1001
+ "sys_dirty",
1002
+ "sys_table",
1003
+ }
1004
+
1005
+ for column in not_null_columns:
1006
+ if column in columns_after and (force or column in added_columns):
1007
+ statements.append(
1008
+ f"ALTER TABLE {fqtn} ALTER COLUMN {TableHelper.quote(column)} SET NOT NULL;"
1009
+ )
1010
+
1011
+ reset_trigger = force or bool(added_columns)
1012
+
1013
+ if reset_trigger:
1014
+ statements.append(
1015
+ f"DROP TRIGGER IF EXISTS {trigger_identifier} ON {fqtn};"
1016
+ )
1017
+ statements.append(cls._sys_modified_function_sql(schema_identifier))
1018
+ statements.append(
1019
+ f"""
1020
+ CREATE TRIGGER {trigger_identifier}
1021
+ BEFORE INSERT OR UPDATE ON {fqtn}
1022
+ FOR EACH ROW EXECUTE PROCEDURE {schema_identifier}.on_sys_modified();
1023
+ """
1024
+ )
1025
+
1026
+ if not statements:
1027
+ return None
1028
+
1029
+ sql = sqlparse.format(
1030
+ " ".join(statements), reindent=True, keyword_case="upper"
1031
+ )
1032
+ return sql, tuple()
1033
+
680
1034
  @classmethod
681
1035
  def drop_table(cls, name):
682
1036
  return f"drop table if exists {TableHelper.quote(name)} cascade;", tuple()
@@ -966,7 +1320,7 @@ class SQL:
966
1320
  columns = TableHelper.quote(columns)
967
1321
  sql = ["DROP"]
968
1322
  sql.append("INDEX IF EXISTS")
969
- tablename = TableHelper.quote(table)
1323
+ _tablename = TableHelper.quote(table)
970
1324
  if not name:
971
1325
  name = re.sub(
972
1326
  r"\([^)]*\)",
@@ -1136,9 +1490,9 @@ class SQL:
1136
1490
  @classmethod
1137
1491
  def missing(cls, tx, table, list, column="SYS_ID", where=None):
1138
1492
  sql = [
1139
- f"SELECT * FROM",
1493
+ "SELECT * FROM",
1140
1494
  f"UNNEST('{{{','.join([str(x) for x in list])}}}'::int[]) id",
1141
- f"EXCEPT ALL",
1495
+ "EXCEPT ALL",
1142
1496
  f"SELECT {column} FROM {table}",
1143
1497
  ]
1144
1498
  vals = []