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.
- velocity/__init__.py +3 -1
- velocity/app/orders.py +3 -4
- velocity/app/tests/__init__.py +1 -0
- velocity/app/tests/test_email_processing.py +112 -0
- velocity/app/tests/test_payment_profile_sorting.py +191 -0
- velocity/app/tests/test_spreadsheet_functions.py +124 -0
- velocity/aws/__init__.py +3 -0
- velocity/aws/amplify.py +10 -6
- velocity/aws/handlers/__init__.py +2 -0
- velocity/aws/handlers/base_handler.py +248 -0
- velocity/aws/handlers/context.py +251 -2
- velocity/aws/handlers/exceptions.py +16 -0
- velocity/aws/handlers/lambda_handler.py +24 -85
- velocity/aws/handlers/mixins/__init__.py +16 -0
- velocity/aws/handlers/mixins/activity_tracker.py +181 -0
- velocity/aws/handlers/mixins/aws_session_mixin.py +192 -0
- velocity/aws/handlers/mixins/error_handler.py +192 -0
- velocity/aws/handlers/mixins/legacy_mixin.py +53 -0
- velocity/aws/handlers/mixins/standard_mixin.py +73 -0
- velocity/aws/handlers/response.py +1 -1
- velocity/aws/handlers/sqs_handler.py +28 -143
- velocity/aws/tests/__init__.py +1 -0
- velocity/aws/tests/test_lambda_handler_json_serialization.py +120 -0
- velocity/aws/tests/test_response.py +163 -0
- velocity/db/__init__.py +16 -4
- velocity/db/core/decorators.py +48 -13
- velocity/db/core/engine.py +187 -840
- velocity/db/core/result.py +33 -25
- velocity/db/core/row.py +15 -3
- velocity/db/core/table.py +493 -50
- velocity/db/core/transaction.py +28 -15
- velocity/db/exceptions.py +42 -18
- velocity/db/servers/base/__init__.py +9 -0
- velocity/db/servers/base/initializer.py +70 -0
- velocity/db/servers/base/operators.py +98 -0
- velocity/db/servers/base/sql.py +503 -0
- velocity/db/servers/base/types.py +135 -0
- velocity/db/servers/mysql/__init__.py +73 -0
- velocity/db/servers/mysql/operators.py +54 -0
- velocity/db/servers/{mysql_reserved.py → mysql/reserved.py} +2 -14
- velocity/db/servers/mysql/sql.py +718 -0
- velocity/db/servers/mysql/types.py +107 -0
- velocity/db/servers/postgres/__init__.py +59 -11
- velocity/db/servers/postgres/operators.py +34 -0
- velocity/db/servers/postgres/sql.py +474 -120
- velocity/db/servers/postgres/types.py +88 -2
- velocity/db/servers/sqlite/__init__.py +61 -0
- velocity/db/servers/sqlite/operators.py +52 -0
- velocity/db/servers/sqlite/reserved.py +20 -0
- velocity/db/servers/sqlite/sql.py +677 -0
- velocity/db/servers/sqlite/types.py +92 -0
- velocity/db/servers/sqlserver/__init__.py +73 -0
- velocity/db/servers/sqlserver/operators.py +47 -0
- velocity/db/servers/sqlserver/reserved.py +32 -0
- velocity/db/servers/sqlserver/sql.py +805 -0
- velocity/db/servers/sqlserver/types.py +114 -0
- velocity/db/servers/tablehelper.py +117 -91
- velocity/db/tests/__init__.py +1 -0
- velocity/db/tests/common_db_test.py +0 -0
- velocity/db/tests/postgres/__init__.py +1 -0
- velocity/db/tests/postgres/common.py +49 -0
- velocity/db/tests/postgres/test_column.py +29 -0
- velocity/db/tests/postgres/test_connections.py +25 -0
- velocity/db/tests/postgres/test_database.py +21 -0
- velocity/db/tests/postgres/test_engine.py +205 -0
- velocity/db/tests/postgres/test_general_usage.py +88 -0
- velocity/db/tests/postgres/test_imports.py +8 -0
- velocity/db/tests/postgres/test_result.py +19 -0
- velocity/db/tests/postgres/test_row.py +137 -0
- velocity/db/tests/postgres/test_row_comprehensive.py +720 -0
- velocity/db/tests/postgres/test_schema_locking.py +335 -0
- velocity/db/tests/postgres/test_schema_locking_unit.py +115 -0
- velocity/db/tests/postgres/test_sequence.py +34 -0
- velocity/db/tests/postgres/test_sql_comprehensive.py +462 -0
- velocity/db/tests/postgres/test_table.py +101 -0
- velocity/db/tests/postgres/test_table_comprehensive.py +646 -0
- velocity/db/tests/postgres/test_transaction.py +106 -0
- velocity/db/tests/sql/__init__.py +1 -0
- velocity/db/tests/sql/common.py +177 -0
- velocity/db/tests/sql/test_postgres_select_advanced.py +285 -0
- velocity/db/tests/sql/test_postgres_select_variances.py +517 -0
- velocity/db/tests/test_cursor_rowcount_fix.py +150 -0
- velocity/db/tests/test_db_utils.py +270 -0
- velocity/db/tests/test_postgres.py +448 -0
- velocity/db/tests/test_postgres_unchanged.py +81 -0
- velocity/db/tests/test_process_error_robustness.py +292 -0
- velocity/db/tests/test_result_caching.py +279 -0
- velocity/db/tests/test_result_sql_aware.py +117 -0
- velocity/db/tests/test_row_get_missing_column.py +72 -0
- velocity/db/tests/test_schema_locking_initializers.py +226 -0
- velocity/db/tests/test_schema_locking_simple.py +97 -0
- velocity/db/tests/test_sql_builder.py +165 -0
- velocity/db/tests/test_tablehelper.py +486 -0
- velocity/db/utils.py +129 -51
- velocity/misc/conv/__init__.py +2 -0
- velocity/misc/conv/iconv.py +5 -4
- velocity/misc/export.py +1 -4
- velocity/misc/merge.py +1 -1
- velocity/misc/tests/__init__.py +1 -0
- velocity/misc/tests/test_db.py +90 -0
- velocity/misc/tests/test_fix.py +78 -0
- velocity/misc/tests/test_format.py +64 -0
- velocity/misc/tests/test_iconv.py +203 -0
- velocity/misc/tests/test_merge.py +82 -0
- velocity/misc/tests/test_oconv.py +144 -0
- velocity/misc/tests/test_original_error.py +52 -0
- velocity/misc/tests/test_timer.py +74 -0
- velocity/misc/tools.py +0 -1
- {velocity_python-0.0.109.dist-info → velocity_python-0.0.161.dist-info}/METADATA +2 -2
- velocity_python-0.0.161.dist-info/RECORD +129 -0
- velocity/db/core/exceptions.py +0 -70
- velocity/db/servers/mysql.py +0 -641
- velocity/db/servers/sqlite.py +0 -968
- velocity/db/servers/sqlite_reserved.py +0 -208
- velocity/db/servers/sqlserver.py +0 -921
- velocity/db/servers/sqlserver_reserved.py +0 -314
- velocity_python-0.0.109.dist-info/RECORD +0 -56
- {velocity_python-0.0.109.dist-info → velocity_python-0.0.161.dist-info}/WHEEL +0 -0
- {velocity_python-0.0.109.dist-info → velocity_python-0.0.161.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
212
|
+
Returns non-system column names.
|
|
97
213
|
"""
|
|
98
|
-
return [col for col in self.sys_columns() if not
|
|
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
|
-
|
|
250
|
-
|
|
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=
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
276
|
-
|
|
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=
|
|
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,
|
|
528
|
+
return Row(self, lookup, lock=lock)
|
|
289
529
|
return Row(self, result[0]["sys_id"], lock=lock)
|
|
290
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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,
|
|
890
|
-
differences.append(
|
|
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."
|