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
velocity/db/core/table.py CHANGED
@@ -1,4 +1,6 @@
1
+ import re
1
2
  import sqlparse
3
+ from collections.abc import Iterable, Mapping
2
4
  from velocity.db import exceptions
3
5
  from velocity.db.core.row import Row
4
6
  from velocity.db.core.result import Result
@@ -23,7 +25,121 @@ class Query:
23
25
  return self.sql
24
26
 
25
27
 
28
+ SYSTEM_COLUMN_NAMES = (
29
+ "sys_id",
30
+ "sys_created",
31
+ "sys_modified",
32
+ "sys_modified_by",
33
+ "sys_modified_row",
34
+ "sys_modified_count",
35
+ "sys_dirty",
36
+ "sys_table",
37
+ "description",
38
+ )
39
+
40
+ _SYSTEM_COLUMN_SET = {name.lower() for name in SYSTEM_COLUMN_NAMES}
41
+
42
+ _NULLABLE_TRUE = {"YES", "TRUE", "T", "1", "Y"}
43
+ _NULLABLE_FALSE = {"NO", "FALSE", "F", "0", "N"}
44
+
45
+
46
+ def _normalize_sql_type(value):
47
+ """Return a simplified SQL type identifier for comparison purposes."""
48
+
49
+ if value is None:
50
+ return None
51
+
52
+ normalized = re.sub(r"\s+", " ", str(value).strip()).upper()
53
+
54
+ if not normalized:
55
+ return None
56
+
57
+ if normalized.startswith("CHARACTER VARYING") or normalized.startswith("VARCHAR"):
58
+ return "TEXT"
59
+ if normalized.startswith("CHAR(") or normalized == "CHARACTER" or normalized == "BPCHAR":
60
+ return "TEXT"
61
+ if normalized.startswith("NUMERIC(") or normalized.startswith("DECIMAL("):
62
+ return "NUMERIC"
63
+ if normalized == "TIMESTAMP":
64
+ return "TIMESTAMP WITHOUT TIME ZONE"
65
+ if normalized.startswith("TIMESTAMP WITHOUT TIME ZONE"):
66
+ return "TIMESTAMP WITHOUT TIME ZONE"
67
+ if normalized in {"TIMESTAMPTZ", "TIMESTAMP WITH TIME ZONE"}:
68
+ return "TIMESTAMP WITH TIME ZONE"
69
+ if normalized.startswith("TIME WITHOUT TIME ZONE"):
70
+ return "TIME WITHOUT TIME ZONE"
71
+ if normalized in {"TIME WITH TIME ZONE", "TIMETZ"}:
72
+ return "TIME WITH TIME ZONE"
73
+ if normalized == "BOOL":
74
+ return "BOOLEAN"
75
+ return normalized
76
+
77
+
78
+ def _types_equivalent(current, expected):
79
+ return _normalize_sql_type(current) == _normalize_sql_type(expected)
80
+
81
+
82
+ def _is_nullable_flag(value):
83
+ if isinstance(value, bool):
84
+ return value
85
+ if value is None:
86
+ return None
87
+ text = str(value).strip().upper()
88
+ if text in _NULLABLE_TRUE:
89
+ return True
90
+ if text in _NULLABLE_FALSE:
91
+ return False
92
+ return None
93
+
94
+
95
+ def _parse_column_spec(spec, default_nullable):
96
+ """Normalize user-provided column specification into a common structure."""
97
+
98
+ nullable = default_nullable
99
+ nullable_specified = False
100
+ alter_sql = None
101
+ using_expression = None
102
+
103
+ base = spec
104
+ override_options = {}
105
+
106
+ if isinstance(spec, tuple) and len(spec) == 2 and isinstance(spec[1], Mapping):
107
+ base, override_options = spec
108
+
109
+ options = {}
110
+ if isinstance(base, Mapping):
111
+ options.update(base)
112
+ base = options.get("type", options.get("value"))
113
+
114
+ if override_options:
115
+ options.update(override_options)
116
+
117
+ type_hint = options.get("type", base)
118
+ if type_hint is None and "value" in options:
119
+ type_hint = options["value"]
120
+
121
+ add_value = options.get("add_value", options.get("value", type_hint))
122
+
123
+ if "nullable" in options:
124
+ nullable = bool(options["nullable"])
125
+ nullable_specified = True
126
+
127
+ alter_sql = options.get("sql")
128
+ using_expression = options.get("using")
129
+
130
+ return {
131
+ "type_hint": type_hint,
132
+ "add_value": add_value,
133
+ "nullable": nullable,
134
+ "nullable_specified": nullable_specified,
135
+ "alter_sql": alter_sql,
136
+ "using": using_expression,
137
+ }
138
+
139
+
26
140
  class Table:
141
+ SYSTEM_COLUMNS = SYSTEM_COLUMN_NAMES
142
+
27
143
  """
28
144
  Provides an interface for performing CRUD and metadata operations on a DB table.
29
145
  """
@@ -54,13 +170,13 @@ class Table:
54
170
  """
55
171
  try:
56
172
  self._cursor.close()
57
- except:
173
+ except Exception:
58
174
  pass
59
175
 
60
176
  def cursor(self):
61
177
  try:
62
178
  return self._cursor
63
- except:
179
+ except AttributeError:
64
180
  pass
65
181
  self._cursor = self.tx.cursor()
66
182
  return self._cursor
@@ -93,9 +209,15 @@ class Table:
93
209
 
94
210
  def columns(self):
95
211
  """
96
- Returns column names, excluding columns that start with 'sys_'.
212
+ Returns non-system column names.
97
213
  """
98
- return [col for col in self.sys_columns() if not col.startswith("sys_")]
214
+ return [col for col in self.sys_columns() if not self.is_system_column(col)]
215
+
216
+ @staticmethod
217
+ def is_system_column(column_name):
218
+ if not column_name:
219
+ return False
220
+ return column_name.lower() in _SYSTEM_COLUMN_SET or column_name.lower().startswith("sys_")
99
221
 
100
222
  @return_default(None, (exceptions.DbObjectExistsError,))
101
223
  def create_index(
@@ -119,6 +241,59 @@ class Table:
119
241
  return sql, vals
120
242
  self.tx.execute(sql, vals, cursor=self.cursor())
121
243
 
244
+ def create_indexes(self, indexes, **kwds):
245
+ """
246
+ Convenience wrapper to create multiple indexes in order.
247
+
248
+ Accepts an iterable of definitions. Each definition may be either:
249
+ - Mapping with a required "columns" entry plus optional "unique",
250
+ "direction", "where", and "lower" keys.
251
+ - A simple sequence/string of columns, in which case defaults apply.
252
+
253
+ When sql_only=True, a list of (sql, params) tuples is returned.
254
+ """
255
+
256
+ if indexes is None:
257
+ return [] if kwds.get("sql_only", False) else None
258
+
259
+ if not isinstance(indexes, Iterable) or isinstance(indexes, (str, bytes)):
260
+ raise TypeError("indexes must be an iterable of index definitions")
261
+
262
+ sql_only = kwds.get("sql_only", False)
263
+ statements = []
264
+
265
+ for definition in indexes:
266
+ if isinstance(definition, Mapping):
267
+ columns = definition.get("columns")
268
+ if not columns:
269
+ raise ValueError("Index definition requires a non-empty 'columns' entry")
270
+ params = {
271
+ "unique": definition.get("unique", False),
272
+ "direction": definition.get("direction"),
273
+ "where": definition.get("where"),
274
+ "lower": definition.get("lower"),
275
+ }
276
+ else:
277
+ columns = definition
278
+ params = {
279
+ "unique": False,
280
+ "direction": None,
281
+ "where": None,
282
+ "lower": None,
283
+ }
284
+
285
+ if isinstance(columns, str):
286
+ columns = columns.split(",")
287
+
288
+ if not columns:
289
+ raise ValueError("Index columns cannot be empty")
290
+
291
+ result = self.create_index(columns, **params, **kwds)
292
+ if sql_only:
293
+ statements.append(result)
294
+
295
+ return statements if sql_only else None
296
+
122
297
  @return_default(None)
123
298
  def drop_index(self, columns, **kwds):
124
299
  """
@@ -163,6 +338,34 @@ class Table:
163
338
  return self.name in [f"{x[0]}.{x[1]}" for x in result.as_tuple()]
164
339
  return self.name in [x[1] for x in result.as_tuple()]
165
340
 
341
+ def ensure_system_columns(self, **kwds):
342
+ """Ensure Velocity system columns and triggers exist for this table."""
343
+ force = kwds.get("force", False)
344
+
345
+ try:
346
+ columns = [col.lower() for col in self.sys_columns()]
347
+ except Exception:
348
+ columns = []
349
+
350
+ sql_method = getattr(self.sql, "ensure_system_columns", None)
351
+
352
+ if sql_method is None:
353
+ raise AttributeError(
354
+ f"{self.sql.__class__.__name__} does not implement ensure_system_columns"
355
+ )
356
+
357
+ result = sql_method(
358
+ self.name, existing_columns=columns, force=force
359
+ )
360
+
361
+ if not result:
362
+ return
363
+
364
+ sql, vals = result
365
+ if kwds.get("sql_only", False):
366
+ return sql, vals
367
+ self.tx.execute(sql, vals, cursor=self.cursor())
368
+
166
369
  def column(self, name):
167
370
  """
168
371
  Returns a Column object for the given column name.
@@ -241,53 +444,91 @@ class Table:
241
444
  sys_id = self.tx.execute(sql, vals).scalar()
242
445
  return self.row(sys_id, lock=lock)
243
446
 
447
+ def _normalize_lookup_where(self, where):
448
+ if where is None:
449
+ raise Exception("None is not allowed as a primary key.")
450
+ if isinstance(where, Row):
451
+ return dict(where.pk)
452
+ if isinstance(where, int):
453
+ return {"sys_id": where}
454
+ if not isinstance(where, Mapping):
455
+ raise TypeError(
456
+ "Lookup criteria must be an int, Row, or mapping of column -> value."
457
+ )
458
+ return dict(where)
459
+
460
+ def _select_sys_ids(
461
+ self,
462
+ where,
463
+ *,
464
+ lock=None,
465
+ orderby=None,
466
+ skip_locked=None,
467
+ limit=2,
468
+ ):
469
+ select_kwargs = {
470
+ "where": where,
471
+ "lock": lock,
472
+ "orderby": orderby,
473
+ "skip_locked": skip_locked,
474
+ }
475
+ if limit is not None:
476
+ select_kwargs["qty"] = limit
477
+ return self.select("sys_id", **select_kwargs).all()
478
+
479
+ def _clean_where_for_insert(self, where):
480
+ clean = {}
481
+ for key, val in where.items():
482
+ if not isinstance(key, str):
483
+ continue
484
+ if set("<>!=%").intersection(key):
485
+ continue
486
+ clean.setdefault(key, val)
487
+ return clean
488
+
244
489
  def get(self, where, lock=None, use_where=False):
245
490
  """
246
491
  Gets or creates a row matching `where`. If multiple rows match, raises DuplicateRowsFoundError.
247
492
  If none match, a new row is created with the non-operator aspects of `where`.
248
493
  """
249
- if where is None:
250
- raise Exception("None is not allowed as a primary key.")
251
- if isinstance(where, int):
252
- where = {"sys_id": where}
253
- result = self.select("sys_id", where=where, lock=lock).all()
494
+ lookup = self._normalize_lookup_where(where)
495
+ result = self._select_sys_ids(lookup, lock=lock, limit=2)
254
496
  if len(result) > 1:
255
- sql = self.select("sys_id", sql_only=True, where=where, lock=lock)
497
+ sql = self.select("sys_id", sql_only=True, where=lookup, lock=lock)
256
498
  raise exceptions.DuplicateRowsFoundError(
257
499
  f"More than one entry found. {sql}"
258
500
  )
259
501
  if not result:
260
- new_data = where.copy()
261
- for k in list(new_data.keys()):
262
- if set("<>!=%").intersection(k):
263
- new_data.pop(k)
502
+ new_data = self._clean_where_for_insert(lookup)
264
503
  return self.new(new_data, lock=lock)
265
504
  if use_where:
266
- return Row(self, where, lock=lock)
505
+ return Row(self, lookup, lock=lock)
267
506
  return Row(self, result[0]["sys_id"], lock=lock)
268
507
 
269
508
  @return_default(None)
270
- def find(self, where, lock=None, use_where=False):
509
+ def find(self, where, lock=None, use_where=False, raise_if_missing=False):
271
510
  """
272
- Finds a single row matching `where`, or returns None if none found.
273
- Raises DuplicateRowsFoundError if multiple rows match.
511
+ Finds a single row matching `where`, or returns None if none found unless
512
+ ``raise_if_missing`` is True. Raises DuplicateRowsFoundError if multiple rows match.
274
513
  """
275
- if where is None:
276
- raise Exception("None is not allowed as a primary key.")
277
- if isinstance(where, int):
278
- where = {"sys_id": where}
279
- result = self.select("sys_id", where=where, lock=lock).all()
514
+ lookup = self._normalize_lookup_where(where)
515
+ result = self._select_sys_ids(lookup, lock=lock, limit=2)
280
516
  if not result:
517
+ if raise_if_missing:
518
+ raise LookupError(
519
+ f"No rows found in `{self.name}` for criteria: {lookup!r}"
520
+ )
281
521
  return None
282
522
  if len(result) > 1:
283
- sql = self.select("sys_id", sql_only=True, where=where, lock=lock)
523
+ sql = self.select("sys_id", sql_only=True, where=lookup, lock=lock)
284
524
  raise exceptions.DuplicateRowsFoundError(
285
525
  f"More than one entry found. {sql}"
286
526
  )
287
527
  if use_where:
288
- return Row(self, where, lock=lock)
528
+ return Row(self, lookup, lock=lock)
289
529
  return Row(self, result[0]["sys_id"], lock=lock)
290
- one=find
530
+
531
+ one = one_or_none = find
291
532
 
292
533
  @return_default(None)
293
534
  def first(
@@ -302,23 +543,21 @@ class Table:
302
543
  """
303
544
  Finds the first matching row (by `orderby`) or creates one if `create_new=True` and none found.
304
545
  """
305
- if where is None:
306
- raise Exception("None is not allowed as a where clause.")
307
- if isinstance(where, int):
308
- where = {"sys_id": where}
309
- results = self.select(
310
- "sys_id", where=where, orderby=orderby, skip_locked=skip_locked
311
- ).all()
546
+ lookup = self._normalize_lookup_where(where)
547
+ results = self._select_sys_ids(
548
+ lookup,
549
+ lock=lock,
550
+ orderby=orderby,
551
+ skip_locked=skip_locked,
552
+ limit=1,
553
+ )
312
554
  if not results:
313
555
  if create_new:
314
- new_data = where.copy()
315
- for k in list(new_data.keys()):
316
- if set("<>!=%").intersection(k):
317
- new_data.pop(k)
556
+ new_data = self._clean_where_for_insert(lookup)
318
557
  return self.new(new_data, lock=lock)
319
558
  return None
320
559
  if use_where:
321
- return Row(self, where, lock=lock)
560
+ return Row(self, lookup, lock=lock)
322
561
  return Row(self, results[0]["sys_id"], lock=lock)
323
562
 
324
563
  def primary_keys(self):
@@ -394,22 +633,128 @@ class Table:
394
633
  @create_missing
395
634
  def alter(self, columns, **kwds):
396
635
  """
397
- Adds any missing columns (based on the provided dict) to the table.
636
+ Create or update columns so they match the supplied specification.
398
637
  """
638
+
399
639
  if not isinstance(columns, dict):
400
640
  raise Exception("Columns must be a dict.")
641
+
642
+ mode = kwds.pop("mode", "smart")
643
+ mode = (mode or "smart").lower()
644
+ if mode not in {"smart", "add"}:
645
+ raise ValueError(f"Unsupported alter mode: {mode}")
646
+
647
+ sql_only = kwds.get("sql_only", False)
648
+ default_nullable = kwds.get("null_allowed", True)
649
+
401
650
  columns = self.lower_keys(columns)
402
- diff = []
403
- for k in columns.keys():
404
- if k not in self.sys_columns():
405
- diff.append(k)
406
- if diff:
407
- newcols = {key: columns[key] for key in diff}
408
- sql, vals = self.sql.alter_add(self.name, newcols)
409
- if kwds.get("sql_only", False):
410
- return sql, vals
651
+ existing_columns = {col.lower() for col in self.sys_columns()}
652
+
653
+ to_add = {}
654
+ add_specs = {}
655
+ statements = []
656
+ null_statements = []
657
+ type_statements = []
658
+ custom_statements = []
659
+
660
+ for column_name, raw_spec in columns.items():
661
+ spec = _parse_column_spec(raw_spec, default_nullable)
662
+
663
+ if column_name not in existing_columns:
664
+ to_add[column_name] = spec["add_value"]
665
+ add_specs[column_name] = spec
666
+ continue
667
+
668
+ if mode == "add":
669
+ continue
670
+
671
+ column_obj = self.column(column_name)
672
+ current_type = column_obj.sql_type
673
+ type_hint = spec["type_hint"]
674
+
675
+ if not (isinstance(type_hint, str) and type_hint.startswith("@@")) and type_hint is not None:
676
+ expected_type = self.sql.types.get_type(type_hint)
677
+ if expected_type and not _types_equivalent(current_type, expected_type):
678
+ if spec["using"]:
679
+ clause = f"TYPE {expected_type} USING {spec['using']}"
680
+ sql, vals = self.sql.alter_column_by_sql(self.name, column_name, clause)
681
+ else:
682
+ nullable_flag = _is_nullable_flag(column_obj.is_nullable)
683
+ if nullable_flag is None:
684
+ nullable_flag = True
685
+ server_name = getattr(self.sql, "server", "").lower()
686
+ if server_name.startswith("postgre"):
687
+ type_argument = type_hint
688
+ else:
689
+ type_argument = expected_type or type_hint
690
+ sql, vals = self.sql.alter_column_by_type(
691
+ self.name,
692
+ column_name,
693
+ type_argument,
694
+ nullable_flag,
695
+ )
696
+ type_statements.append((sql, vals))
697
+
698
+ if spec["alter_sql"]:
699
+ sql, vals = self.sql.alter_column_by_sql(self.name, column_name, spec["alter_sql"])
700
+ custom_statements.append((sql, vals))
701
+
702
+ if spec["nullable_specified"]:
703
+ desired_nullable = spec["nullable"]
704
+ current_nullable = _is_nullable_flag(column_obj.is_nullable)
705
+ if desired_nullable and current_nullable is False:
706
+ sql, vals = self.sql.alter_column_by_sql(
707
+ self.name, column_name, "DROP NOT NULL"
708
+ )
709
+ null_statements.append((sql, vals))
710
+ elif not desired_nullable and current_nullable is True:
711
+ sql, vals = self.sql.alter_column_by_sql(
712
+ self.name, column_name, "SET NOT NULL"
713
+ )
714
+ null_statements.append((sql, vals))
715
+
716
+ if to_add:
717
+ sql, vals = self.sql.alter_add(self.name, to_add, default_nullable)
718
+ statements.append((sql, vals))
719
+
720
+ for column_name, spec in add_specs.items():
721
+ if spec["nullable_specified"] and not spec["nullable"] and default_nullable:
722
+ sql, vals = self.sql.alter_column_by_sql(
723
+ self.name, column_name, "SET NOT NULL"
724
+ )
725
+ null_statements.append((sql, vals))
726
+ elif spec["nullable_specified"] and spec["nullable"] and not default_nullable:
727
+ sql, vals = self.sql.alter_column_by_sql(
728
+ self.name, column_name, "DROP NOT NULL"
729
+ )
730
+ null_statements.append((sql, vals))
731
+
732
+ statements.extend(type_statements)
733
+ statements.extend(custom_statements)
734
+ statements.extend(null_statements)
735
+
736
+ if not statements:
737
+ return None
738
+
739
+ if sql_only:
740
+ if len(statements) == 1:
741
+ return statements[0]
742
+ return statements
743
+
744
+ for sql, vals in statements:
745
+ if not sql:
746
+ continue
411
747
  self.tx.execute(sql, vals, cursor=self.cursor())
412
748
 
749
+ def alter_add(self, columns, **kwds):
750
+ """
751
+ Add missing columns without modifying existing column definitions.
752
+ """
753
+
754
+ kwds = dict(kwds)
755
+ kwds["mode"] = "add"
756
+ return self.alter(columns, **kwds)
757
+
413
758
  @create_missing
414
759
  def alter_type(self, column, type_or_value, nullable=True, **kwds):
415
760
  """
@@ -464,6 +809,104 @@ class Table:
464
809
  result = self.tx.execute(sql, vals, cursor=self.cursor())
465
810
  return result.cursor.rowcount if result.cursor else 0
466
811
 
812
+ @create_missing
813
+ def update_or_insert(self, update_data, insert_data=None, where=None, pk=None, **kwds):
814
+ """
815
+ Attempts an UPDATE first; if no rows change, performs an INSERT guarded by NOT EXISTS.
816
+
817
+ :param update_data: Mapping of columns to update.
818
+ :param insert_data: Optional mapping used for the INSERT. When omitted, values are
819
+ derived from update_data combined with simple equality predicates
820
+ from ``where`` and primary key values.
821
+ :param where: Criteria for the UPDATE and existence check.
822
+ :param pk: Optional primary key mapping for UPDATE (merged into WHERE) and INSERT.
823
+ :param sql_only: When True, return the SQL/parameter tuples for both phases instead of executing.
824
+ :return: Number of rows affected, or a dict with ``update``/``insert`` entries when sql_only=True.
825
+ """
826
+ sql_only = kwds.get("sql_only", False)
827
+ if not isinstance(update_data, Mapping) or not update_data:
828
+ raise ValueError("update_data must be a non-empty mapping of column-value pairs.")
829
+ if where is None and pk is None:
830
+ raise ValueError("Either where or pk must be provided for update_or_insert.")
831
+
832
+ update_stmt = None
833
+ if sql_only:
834
+ update_stmt = self.update(update_data, where=where, pk=pk, sql_only=True)
835
+ else:
836
+ updated = self.update(update_data, where=where, pk=pk)
837
+ if updated:
838
+ return updated
839
+
840
+ if insert_data is not None:
841
+ if not isinstance(insert_data, Mapping):
842
+ raise ValueError("insert_data must be a mapping when provided.")
843
+ insert_payload = dict(insert_data)
844
+ else:
845
+ insert_payload = dict(update_data)
846
+ if isinstance(where, Mapping):
847
+ for key, val in where.items():
848
+ if not isinstance(key, str):
849
+ continue
850
+ if set("<>!=%").intersection(key):
851
+ continue
852
+ insert_payload.setdefault(key, val)
853
+ if isinstance(pk, Mapping):
854
+ for key, val in pk.items():
855
+ insert_payload.setdefault(key, val)
856
+
857
+ if not insert_payload:
858
+ raise ValueError("Unable to derive insert payload for update_or_insert.")
859
+
860
+ exists_where = None
861
+ if where is not None and pk is not None:
862
+ if isinstance(where, Mapping) and isinstance(pk, Mapping):
863
+ combined = dict(where)
864
+ combined.update(pk)
865
+ exists_where = combined
866
+ else:
867
+ exists_where = where
868
+ elif where is not None:
869
+ exists_where = where
870
+ else:
871
+ exists_where = pk
872
+
873
+ ins_builder = getattr(self.sql, "insnx", None) or getattr(
874
+ self.sql, "insert_if_not_exists", None
875
+ )
876
+ if ins_builder is None:
877
+ raise NotImplementedError(
878
+ "Current SQL dialect does not support insert-if-not-exists operations."
879
+ )
880
+
881
+ sql, vals = ins_builder(self.tx, self.name, insert_payload, exists_where)
882
+ if sql_only:
883
+ return {"update": update_stmt, "insert": (sql, vals)}
884
+ result = self.tx.execute(sql, vals, cursor=self.cursor())
885
+ return result.cursor.rowcount if result.cursor else 0
886
+
887
+ updins = update_or_insert
888
+
889
+ @create_missing
890
+ def insert_if_not_exists(self, data, where=None, **kwds):
891
+ """
892
+ Inserts `data` into the table only if the existence check (`where`) does not match any rows.
893
+
894
+ Usage:
895
+ table.insert_if_not_exists({'key_col': 'k', 'value': 'v'}, where={'key_col': 'k'})
896
+
897
+ :param data: dict of column -> value for insert
898
+ :param where: mapping/list/str used for the EXISTS check; if None primary keys are used and
899
+ must be present in `data`.
900
+ :return: rowcount (0 or 1) or (sql, params) when sql_only=True
901
+ """
902
+ sql, vals = self.sql.insert_if_not_exists(self.tx, self.name, data, where)
903
+ if kwds.get("sql_only", False):
904
+ return sql, vals
905
+ result = self.tx.execute(sql, vals, cursor=self.cursor())
906
+ return result.cursor.rowcount if result.cursor else 0
907
+
908
+ insnx = insert_if_not_exists
909
+
467
910
  upsert = merge
468
911
  indate = merge
469
912
 
@@ -886,8 +1329,8 @@ class Table:
886
1329
  # Return a descriptive string of differences.
887
1330
  if differences:
888
1331
  differences.insert(0, f"Comparing {self.name}: {pk1} vs {pk2}")
889
- differences.insert(0, f"--------------------------------------")
890
- differences.append(f"--------------------------------------")
1332
+ differences.insert(0, "--------------------------------------")
1333
+ differences.append("--------------------------------------")
891
1334
  return "\n".join(differences)
892
1335
  else:
893
1336
  return f"{self.name} rows {pk1} and {pk2} are identical."