velocity-python 0.0.142__py3-none-any.whl → 0.0.152__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of velocity-python might be problematic. Click here for more details.
- velocity/__init__.py +1 -1
- velocity/app/orders.py +1 -3
- velocity/aws/handlers/mixins/aws_session_mixin.py +192 -0
- velocity/db/core/row.py +12 -2
- velocity/db/core/table.py +158 -2
- velocity/db/core/transaction.py +6 -10
- velocity/db/servers/mysql/sql.py +1 -1
- velocity/db/servers/postgres/sql.py +193 -84
- velocity/db/servers/sqlite/sql.py +1 -1
- velocity/db/servers/sqlserver/sql.py +11 -4
- velocity/db/tests/test_postgres.py +189 -0
- {velocity_python-0.0.142.dist-info → velocity_python-0.0.152.dist-info}/METADATA +2 -2
- {velocity_python-0.0.142.dist-info → velocity_python-0.0.152.dist-info}/RECORD +16 -15
- {velocity_python-0.0.142.dist-info → velocity_python-0.0.152.dist-info}/WHEEL +0 -0
- {velocity_python-0.0.142.dist-info → velocity_python-0.0.152.dist-info}/licenses/LICENSE +0 -0
- {velocity_python-0.0.142.dist-info → velocity_python-0.0.152.dist-info}/top_level.txt +0 -0
velocity/__init__.py
CHANGED
velocity/app/orders.py
CHANGED
|
@@ -115,9 +115,7 @@ class Order:
|
|
|
115
115
|
for key, default in defaults.items():
|
|
116
116
|
if key not in target:
|
|
117
117
|
target[key] = default() if callable(default) else default
|
|
118
|
-
|
|
119
|
-
# Always update updated_at if present
|
|
120
|
-
target[key] = default() if callable(default) else default
|
|
118
|
+
|
|
121
119
|
|
|
122
120
|
def _validate(self):
|
|
123
121
|
self._apply_defaults()
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Error Handler Mixin for Lambda Handlers.
|
|
3
|
+
|
|
4
|
+
Provides standardized error handling, logging, and notification functionality
|
|
5
|
+
for Lambda handlers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import copy
|
|
9
|
+
import os
|
|
10
|
+
import pprint
|
|
11
|
+
import time
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
from typing import Dict, Any, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AwsSessionMixin(ABC):
|
|
17
|
+
"""
|
|
18
|
+
Mixin class providing standardized error handling for Lambda handlers.
|
|
19
|
+
|
|
20
|
+
Handles error logging to sys_log table, email notifications to administrators,
|
|
21
|
+
and error metrics collection.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def handle_standard_error(self, tx, context, exception: Exception, tb_string: str):
|
|
25
|
+
"""Handle errors with consistent logging and notification patterns"""
|
|
26
|
+
|
|
27
|
+
# Log to sys_log for centralized logging
|
|
28
|
+
self.log_error_to_system(tx, context, exception, tb_string)
|
|
29
|
+
|
|
30
|
+
# Determine if this error requires notification
|
|
31
|
+
if self._should_notify_error(exception):
|
|
32
|
+
self.send_error_notification(tx, context, exception, tb_string)
|
|
33
|
+
|
|
34
|
+
# Log error metrics for monitoring
|
|
35
|
+
self.log_error_metrics(tx, context, exception)
|
|
36
|
+
|
|
37
|
+
def log_error_to_system(self, tx, context, exception: Exception, tb_string: str):
|
|
38
|
+
"""Log error to sys_log table"""
|
|
39
|
+
error_data = {
|
|
40
|
+
"level": "ERROR",
|
|
41
|
+
"message": str(exception),
|
|
42
|
+
"function": f"{self.__class__.__name__}.{context.action()}",
|
|
43
|
+
"traceback": tb_string,
|
|
44
|
+
"exception_type": exception.__class__.__name__,
|
|
45
|
+
"handler_name": self.__class__.__name__,
|
|
46
|
+
"action": context.action(),
|
|
47
|
+
"user_branch": os.environ.get("USER_BRANCH", "Unknown"),
|
|
48
|
+
"function_name": os.environ.get("AWS_LAMBDA_FUNCTION_NAME", "Unknown"),
|
|
49
|
+
"app_name": os.environ.get("ProjectName", "Unknown"),
|
|
50
|
+
"user_agent": "AWS Lambda",
|
|
51
|
+
"device_type": "Lambda",
|
|
52
|
+
"sys_modified_by": "Lambda",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Add user context if available
|
|
56
|
+
try:
|
|
57
|
+
if hasattr(self, 'current_user') and self.current_user:
|
|
58
|
+
error_data["user_email"] = self.current_user.get("email_address")
|
|
59
|
+
except:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
tx.table("sys_log").insert(error_data)
|
|
63
|
+
|
|
64
|
+
def send_error_notification(self, tx, context, exception: Exception, tb_string: str):
|
|
65
|
+
"""Send error notification email to administrators"""
|
|
66
|
+
try:
|
|
67
|
+
# Import here to avoid circular dependency
|
|
68
|
+
from support.app import helpers
|
|
69
|
+
|
|
70
|
+
environment = os.environ.get('USER_BRANCH', 'Unknown').title()
|
|
71
|
+
function_name = os.environ.get('AWS_LAMBDA_FUNCTION_NAME', 'Unknown')
|
|
72
|
+
|
|
73
|
+
subject = f"{environment} Lambda Error - {function_name}"
|
|
74
|
+
|
|
75
|
+
body = f"""
|
|
76
|
+
Error Details:
|
|
77
|
+
- Handler: {self.__class__.__name__}
|
|
78
|
+
- Action: {context.action()}
|
|
79
|
+
- Exception: {exception.__class__.__name__}
|
|
80
|
+
- Message: {str(exception)}
|
|
81
|
+
- Environment: {environment}
|
|
82
|
+
- Function: {function_name}
|
|
83
|
+
|
|
84
|
+
Full Traceback:
|
|
85
|
+
{tb_string}
|
|
86
|
+
|
|
87
|
+
Request Details:
|
|
88
|
+
{self._get_error_context(context)}
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
sender = self._get_error_notification_sender()
|
|
92
|
+
recipients = self._get_error_notification_recipients()
|
|
93
|
+
|
|
94
|
+
helpers.sendmail(
|
|
95
|
+
tx,
|
|
96
|
+
subject=subject,
|
|
97
|
+
body=body,
|
|
98
|
+
html=None,
|
|
99
|
+
sender=sender,
|
|
100
|
+
recipient=recipients[0],
|
|
101
|
+
cc=recipients[1:] if len(recipients) > 1 else None,
|
|
102
|
+
bcc=None,
|
|
103
|
+
email_settings_id=1001,
|
|
104
|
+
)
|
|
105
|
+
except Exception as email_error:
|
|
106
|
+
print(f"Failed to send error notification email: {email_error}")
|
|
107
|
+
|
|
108
|
+
def _should_notify_error(self, exception: Exception) -> bool:
|
|
109
|
+
"""Determine if an error should trigger email notifications"""
|
|
110
|
+
# Don't notify for user authentication errors or validation errors
|
|
111
|
+
non_notification_types = [
|
|
112
|
+
"AuthenticationError",
|
|
113
|
+
"ValidationError",
|
|
114
|
+
"ValueError",
|
|
115
|
+
"AlertError"
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
exception_name = exception.__class__.__name__
|
|
119
|
+
|
|
120
|
+
# Check for authentication-related exceptions
|
|
121
|
+
if "Authentication" in exception_name or "Auth" in exception_name:
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
return exception_name not in non_notification_types
|
|
125
|
+
|
|
126
|
+
@abstractmethod
|
|
127
|
+
def _get_error_notification_recipients(self) -> list:
|
|
128
|
+
"""
|
|
129
|
+
Get list of email recipients for error notifications.
|
|
130
|
+
|
|
131
|
+
Must be implemented by the handler class.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
List of email addresses to notify when errors occur
|
|
135
|
+
|
|
136
|
+
Example:
|
|
137
|
+
return ["admin@company.com", "devops@company.com"]
|
|
138
|
+
"""
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
@abstractmethod
|
|
142
|
+
def _get_error_notification_sender(self) -> str:
|
|
143
|
+
"""
|
|
144
|
+
Get email sender for error notifications.
|
|
145
|
+
|
|
146
|
+
Must be implemented by the handler class.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Email address to use as sender for error notifications
|
|
150
|
+
|
|
151
|
+
Example:
|
|
152
|
+
return "no-reply@company.com"
|
|
153
|
+
"""
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
def _get_error_context(self, context) -> str:
|
|
157
|
+
"""Get sanitized request context for error reporting"""
|
|
158
|
+
try:
|
|
159
|
+
postdata = context.postdata()
|
|
160
|
+
sanitized = copy.deepcopy(postdata)
|
|
161
|
+
|
|
162
|
+
# Remove sensitive data
|
|
163
|
+
if "payload" in sanitized and isinstance(sanitized["payload"], dict):
|
|
164
|
+
sanitized["payload"].pop("cognito_user", None)
|
|
165
|
+
|
|
166
|
+
return pprint.pformat(sanitized)
|
|
167
|
+
except:
|
|
168
|
+
return "Unable to retrieve request context"
|
|
169
|
+
|
|
170
|
+
def log_error_metrics(self, tx, context, exception: Exception):
|
|
171
|
+
"""Log error metrics for monitoring and alerting"""
|
|
172
|
+
try:
|
|
173
|
+
metrics_data = {
|
|
174
|
+
"metric_type": "error_count",
|
|
175
|
+
"handler_name": self.__class__.__name__,
|
|
176
|
+
"action": context.action(),
|
|
177
|
+
"exception_type": exception.__class__.__name__,
|
|
178
|
+
"environment": os.environ.get("USER_BRANCH", "Unknown"),
|
|
179
|
+
"function_name": os.environ.get("AWS_LAMBDA_FUNCTION_NAME", "Unknown"),
|
|
180
|
+
"timestamp": time.time(),
|
|
181
|
+
"sys_modified_by": "Lambda"
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
# Try to insert into metrics table if it exists
|
|
185
|
+
try:
|
|
186
|
+
tx.table("lambda_metrics").insert(metrics_data)
|
|
187
|
+
except:
|
|
188
|
+
# Metrics table might not exist yet, don't fail error handler
|
|
189
|
+
pass
|
|
190
|
+
except:
|
|
191
|
+
# Don't fail the error handler if metrics logging fails
|
|
192
|
+
pass
|
velocity/db/core/row.py
CHANGED
|
@@ -44,7 +44,12 @@ class Row:
|
|
|
44
44
|
def __setitem__(self, key, val):
|
|
45
45
|
if key in self.pk:
|
|
46
46
|
raise Exception("Cannot update a primary key.")
|
|
47
|
-
self.table
|
|
47
|
+
if hasattr(self.table, "updins"):
|
|
48
|
+
self.table.updins({key: val}, pk=self.pk)
|
|
49
|
+
elif hasattr(self.table, "upsert"):
|
|
50
|
+
self.table.upsert({key: val}, pk=self.pk)
|
|
51
|
+
else:
|
|
52
|
+
self.table.update({key: val}, pk=self.pk)
|
|
48
53
|
|
|
49
54
|
def __delitem__(self, key):
|
|
50
55
|
if key in self.pk:
|
|
@@ -121,7 +126,12 @@ class Row:
|
|
|
121
126
|
if kwds:
|
|
122
127
|
data.update(kwds)
|
|
123
128
|
if data:
|
|
124
|
-
self.table
|
|
129
|
+
if hasattr(self.table, "updins"):
|
|
130
|
+
self.table.updins(data, pk=self.pk)
|
|
131
|
+
elif hasattr(self.table, "upsert"):
|
|
132
|
+
self.table.upsert(data, pk=self.pk)
|
|
133
|
+
else:
|
|
134
|
+
self.table.update(data, pk=self.pk)
|
|
125
135
|
return self
|
|
126
136
|
|
|
127
137
|
def __cmp__(self, other):
|
velocity/db/core/table.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import sqlparse
|
|
2
|
+
from collections.abc import Iterable, Mapping
|
|
2
3
|
from velocity.db import exceptions
|
|
3
4
|
from velocity.db.core.row import Row
|
|
4
5
|
from velocity.db.core.result import Result
|
|
@@ -119,6 +120,59 @@ class Table:
|
|
|
119
120
|
return sql, vals
|
|
120
121
|
self.tx.execute(sql, vals, cursor=self.cursor())
|
|
121
122
|
|
|
123
|
+
def create_indexes(self, indexes, **kwds):
|
|
124
|
+
"""
|
|
125
|
+
Convenience wrapper to create multiple indexes in order.
|
|
126
|
+
|
|
127
|
+
Accepts an iterable of definitions. Each definition may be either:
|
|
128
|
+
- Mapping with a required "columns" entry plus optional "unique",
|
|
129
|
+
"direction", "where", and "lower" keys.
|
|
130
|
+
- A simple sequence/string of columns, in which case defaults apply.
|
|
131
|
+
|
|
132
|
+
When sql_only=True, a list of (sql, params) tuples is returned.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
if indexes is None:
|
|
136
|
+
return [] if kwds.get("sql_only", False) else None
|
|
137
|
+
|
|
138
|
+
if not isinstance(indexes, Iterable) or isinstance(indexes, (str, bytes)):
|
|
139
|
+
raise TypeError("indexes must be an iterable of index definitions")
|
|
140
|
+
|
|
141
|
+
sql_only = kwds.get("sql_only", False)
|
|
142
|
+
statements = []
|
|
143
|
+
|
|
144
|
+
for definition in indexes:
|
|
145
|
+
if isinstance(definition, Mapping):
|
|
146
|
+
columns = definition.get("columns")
|
|
147
|
+
if not columns:
|
|
148
|
+
raise ValueError("Index definition requires a non-empty 'columns' entry")
|
|
149
|
+
params = {
|
|
150
|
+
"unique": definition.get("unique", False),
|
|
151
|
+
"direction": definition.get("direction"),
|
|
152
|
+
"where": definition.get("where"),
|
|
153
|
+
"lower": definition.get("lower"),
|
|
154
|
+
}
|
|
155
|
+
else:
|
|
156
|
+
columns = definition
|
|
157
|
+
params = {
|
|
158
|
+
"unique": False,
|
|
159
|
+
"direction": None,
|
|
160
|
+
"where": None,
|
|
161
|
+
"lower": None,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if isinstance(columns, str):
|
|
165
|
+
columns = columns.split(",")
|
|
166
|
+
|
|
167
|
+
if not columns:
|
|
168
|
+
raise ValueError("Index columns cannot be empty")
|
|
169
|
+
|
|
170
|
+
result = self.create_index(columns, **params, **kwds)
|
|
171
|
+
if sql_only:
|
|
172
|
+
statements.append(result)
|
|
173
|
+
|
|
174
|
+
return statements if sql_only else None
|
|
175
|
+
|
|
122
176
|
@return_default(None)
|
|
123
177
|
def drop_index(self, columns, **kwds):
|
|
124
178
|
"""
|
|
@@ -177,10 +231,14 @@ class Table:
|
|
|
177
231
|
columns = []
|
|
178
232
|
|
|
179
233
|
has_column = "sys_modified_count" in columns
|
|
180
|
-
|
|
234
|
+
has_row_column = "sys_modified_row" in columns
|
|
235
|
+
|
|
236
|
+
if has_column and has_row_column and not force:
|
|
181
237
|
return
|
|
182
238
|
|
|
183
|
-
sql, vals = self.sql.ensure_sys_modified_count(
|
|
239
|
+
sql, vals = self.sql.ensure_sys_modified_count(
|
|
240
|
+
self.name, has_column=has_column, has_row_column=has_row_column
|
|
241
|
+
)
|
|
184
242
|
if kwds.get("sql_only", False):
|
|
185
243
|
return sql, vals
|
|
186
244
|
self.tx.execute(sql, vals, cursor=self.cursor())
|
|
@@ -487,6 +545,104 @@ class Table:
|
|
|
487
545
|
result = self.tx.execute(sql, vals, cursor=self.cursor())
|
|
488
546
|
return result.cursor.rowcount if result.cursor else 0
|
|
489
547
|
|
|
548
|
+
@create_missing
|
|
549
|
+
def update_or_insert(self, update_data, insert_data=None, where=None, pk=None, **kwds):
|
|
550
|
+
"""
|
|
551
|
+
Attempts an UPDATE first; if no rows change, performs an INSERT guarded by NOT EXISTS.
|
|
552
|
+
|
|
553
|
+
:param update_data: Mapping of columns to update.
|
|
554
|
+
:param insert_data: Optional mapping used for the INSERT. When omitted, values are
|
|
555
|
+
derived from update_data combined with simple equality predicates
|
|
556
|
+
from ``where`` and primary key values.
|
|
557
|
+
:param where: Criteria for the UPDATE and existence check.
|
|
558
|
+
:param pk: Optional primary key mapping for UPDATE (merged into WHERE) and INSERT.
|
|
559
|
+
:param sql_only: When True, return the SQL/parameter tuples for both phases instead of executing.
|
|
560
|
+
:return: Number of rows affected, or a dict with ``update``/``insert`` entries when sql_only=True.
|
|
561
|
+
"""
|
|
562
|
+
sql_only = kwds.get("sql_only", False)
|
|
563
|
+
if not isinstance(update_data, Mapping) or not update_data:
|
|
564
|
+
raise ValueError("update_data must be a non-empty mapping of column-value pairs.")
|
|
565
|
+
if where is None and pk is None:
|
|
566
|
+
raise ValueError("Either where or pk must be provided for update_or_insert.")
|
|
567
|
+
|
|
568
|
+
update_stmt = None
|
|
569
|
+
if sql_only:
|
|
570
|
+
update_stmt = self.update(update_data, where=where, pk=pk, sql_only=True)
|
|
571
|
+
else:
|
|
572
|
+
updated = self.update(update_data, where=where, pk=pk)
|
|
573
|
+
if updated:
|
|
574
|
+
return updated
|
|
575
|
+
|
|
576
|
+
if insert_data is not None:
|
|
577
|
+
if not isinstance(insert_data, Mapping):
|
|
578
|
+
raise ValueError("insert_data must be a mapping when provided.")
|
|
579
|
+
insert_payload = dict(insert_data)
|
|
580
|
+
else:
|
|
581
|
+
insert_payload = dict(update_data)
|
|
582
|
+
if isinstance(where, Mapping):
|
|
583
|
+
for key, val in where.items():
|
|
584
|
+
if not isinstance(key, str):
|
|
585
|
+
continue
|
|
586
|
+
if set("<>!=%").intersection(key):
|
|
587
|
+
continue
|
|
588
|
+
insert_payload.setdefault(key, val)
|
|
589
|
+
if isinstance(pk, Mapping):
|
|
590
|
+
for key, val in pk.items():
|
|
591
|
+
insert_payload.setdefault(key, val)
|
|
592
|
+
|
|
593
|
+
if not insert_payload:
|
|
594
|
+
raise ValueError("Unable to derive insert payload for update_or_insert.")
|
|
595
|
+
|
|
596
|
+
exists_where = None
|
|
597
|
+
if where is not None and pk is not None:
|
|
598
|
+
if isinstance(where, Mapping) and isinstance(pk, Mapping):
|
|
599
|
+
combined = dict(where)
|
|
600
|
+
combined.update(pk)
|
|
601
|
+
exists_where = combined
|
|
602
|
+
else:
|
|
603
|
+
exists_where = where
|
|
604
|
+
elif where is not None:
|
|
605
|
+
exists_where = where
|
|
606
|
+
else:
|
|
607
|
+
exists_where = pk
|
|
608
|
+
|
|
609
|
+
ins_builder = getattr(self.sql, "insnx", None) or getattr(
|
|
610
|
+
self.sql, "insert_if_not_exists", None
|
|
611
|
+
)
|
|
612
|
+
if ins_builder is None:
|
|
613
|
+
raise NotImplementedError(
|
|
614
|
+
"Current SQL dialect does not support insert-if-not-exists operations."
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
sql, vals = ins_builder(self.tx, self.name, insert_payload, exists_where)
|
|
618
|
+
if sql_only:
|
|
619
|
+
return {"update": update_stmt, "insert": (sql, vals)}
|
|
620
|
+
result = self.tx.execute(sql, vals, cursor=self.cursor())
|
|
621
|
+
return result.cursor.rowcount if result.cursor else 0
|
|
622
|
+
|
|
623
|
+
updins = update_or_insert
|
|
624
|
+
|
|
625
|
+
@create_missing
|
|
626
|
+
def insert_if_not_exists(self, data, where=None, **kwds):
|
|
627
|
+
"""
|
|
628
|
+
Inserts `data` into the table only if the existence check (`where`) does not match any rows.
|
|
629
|
+
|
|
630
|
+
Usage:
|
|
631
|
+
table.insert_if_not_exists({'key_col': 'k', 'value': 'v'}, where={'key_col': 'k'})
|
|
632
|
+
|
|
633
|
+
:param data: dict of column -> value for insert
|
|
634
|
+
:param where: mapping/list/str used for the EXISTS check; if None primary keys are used and
|
|
635
|
+
must be present in `data`.
|
|
636
|
+
:return: rowcount (0 or 1) or (sql, params) when sql_only=True
|
|
637
|
+
"""
|
|
638
|
+
sql, vals = self.sql.insert_if_not_exists(self.tx, self.name, data, where)
|
|
639
|
+
if kwds.get("sql_only", False):
|
|
640
|
+
return sql, vals
|
|
641
|
+
result = self.tx.execute(sql, vals, cursor=self.cursor())
|
|
642
|
+
return result.cursor.rowcount if result.cursor else 0
|
|
643
|
+
|
|
644
|
+
insnx = insert_if_not_exists
|
|
645
|
+
|
|
490
646
|
upsert = merge
|
|
491
647
|
indate = merge
|
|
492
648
|
|
velocity/db/core/transaction.py
CHANGED
|
@@ -164,17 +164,13 @@ class Transaction:
|
|
|
164
164
|
"""
|
|
165
165
|
return Row(self.table(tablename), pk, lock=lock)
|
|
166
166
|
|
|
167
|
-
def get(self, tablename, where, lock=None):
|
|
168
|
-
"""
|
|
169
|
-
|
|
170
|
-
"""
|
|
171
|
-
return self.table(tablename).get(where, lock=lock)
|
|
167
|
+
def get(self, tablename, where, lock=None, use_where=False):
|
|
168
|
+
"""Shortcut to table.get() with optional ``use_where`` passthrough."""
|
|
169
|
+
return self.table(tablename).get(where, lock=lock, use_where=use_where)
|
|
172
170
|
|
|
173
|
-
def find(self, tablename, where, lock=None):
|
|
174
|
-
"""
|
|
175
|
-
|
|
176
|
-
"""
|
|
177
|
-
return self.table(tablename).find(where, lock=lock)
|
|
171
|
+
def find(self, tablename, where, lock=None, use_where=False):
|
|
172
|
+
"""Shortcut to table.find() with optional ``use_where`` passthrough."""
|
|
173
|
+
return self.table(tablename).find(where, lock=lock, use_where=use_where)
|
|
178
174
|
|
|
179
175
|
def column(self, tablename, colname):
|
|
180
176
|
"""
|
velocity/db/servers/mysql/sql.py
CHANGED
|
@@ -450,7 +450,7 @@ END;
|
|
|
450
450
|
return "\n".join(statements), tuple()
|
|
451
451
|
|
|
452
452
|
@classmethod
|
|
453
|
-
def ensure_sys_modified_count(cls, name):
|
|
453
|
+
def ensure_sys_modified_count(cls, name, has_column=False, has_row_column=False):
|
|
454
454
|
"""Ensure sys_modified_count column and associated triggers exist for the table."""
|
|
455
455
|
table_identifier = quote(name)
|
|
456
456
|
base_name = name.split(".")[-1].replace("`", "")
|
|
@@ -18,39 +18,6 @@ TableHelper.reserved = reserved_words
|
|
|
18
18
|
TableHelper.operators = OPERATORS
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def _get_table_helper(tx, table):
|
|
22
|
-
"""
|
|
23
|
-
Utility function to create a TableHelper instance.
|
|
24
|
-
Ensures consistent configuration across all SQL methods.
|
|
25
|
-
"""
|
|
26
|
-
return TableHelper(tx, table)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _validate_table_name(table):
|
|
30
|
-
"""Validate table name format."""
|
|
31
|
-
if not table or not isinstance(table, str):
|
|
32
|
-
raise ValueError("Table name must be a non-empty string")
|
|
33
|
-
# Add more validation as needed
|
|
34
|
-
return table.strip()
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def _handle_predicate_errors(predicates, operation="WHERE"):
|
|
38
|
-
"""Process a list of predicates with error handling."""
|
|
39
|
-
sql_parts = []
|
|
40
|
-
vals = []
|
|
41
|
-
|
|
42
|
-
for pred, val in predicates:
|
|
43
|
-
sql_parts.append(pred)
|
|
44
|
-
if val is None:
|
|
45
|
-
pass
|
|
46
|
-
elif isinstance(val, tuple):
|
|
47
|
-
vals.extend(val)
|
|
48
|
-
else:
|
|
49
|
-
vals.append(val)
|
|
50
|
-
|
|
51
|
-
return sql_parts, vals
|
|
52
|
-
|
|
53
|
-
|
|
54
21
|
system_fields = [
|
|
55
22
|
"sys_id",
|
|
56
23
|
"sys_created",
|
|
@@ -143,7 +110,7 @@ class SQL(BaseSQLDialect):
|
|
|
143
110
|
vals = []
|
|
144
111
|
|
|
145
112
|
# Create table helper instance
|
|
146
|
-
th =
|
|
113
|
+
th = TableHelper(tx, table)
|
|
147
114
|
|
|
148
115
|
# Handle columns and DISTINCT before aliasing
|
|
149
116
|
if columns is None:
|
|
@@ -447,7 +414,7 @@ class SQL(BaseSQLDialect):
|
|
|
447
414
|
if not isinstance(data, Mapping) or not data:
|
|
448
415
|
raise ValueError("data must be a non-empty mapping of column-value pairs.")
|
|
449
416
|
|
|
450
|
-
th =
|
|
417
|
+
th = TableHelper(tx, table)
|
|
451
418
|
set_clauses = []
|
|
452
419
|
vals = []
|
|
453
420
|
|
|
@@ -595,51 +562,164 @@ class SQL(BaseSQLDialect):
|
|
|
595
562
|
|
|
596
563
|
@classmethod
|
|
597
564
|
def merge(cls, tx, table, data, pk, on_conflict_do_nothing, on_conflict_update):
|
|
565
|
+
if not isinstance(data, Mapping) or not data:
|
|
566
|
+
raise ValueError("data must be a non-empty mapping of column-value pairs.")
|
|
567
|
+
|
|
568
|
+
table_helper = TableHelper(tx, table)
|
|
569
|
+
data = dict(data) # work with a copy to avoid mutating the caller's dict
|
|
570
|
+
|
|
598
571
|
if pk is None:
|
|
599
572
|
pkeys = tx.table(table).primary_keys()
|
|
600
573
|
if not pkeys:
|
|
601
574
|
raise ValueError("Primary key required for merge.")
|
|
602
|
-
|
|
603
|
-
if
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
575
|
+
missing = [key for key in pkeys if key not in data]
|
|
576
|
+
if missing:
|
|
577
|
+
missing_cols = ", ".join(missing)
|
|
578
|
+
raise ValueError(
|
|
579
|
+
"Primary key values missing from data for merge: "
|
|
580
|
+
f"{missing_cols}. Provide pk=... or include the key values in data."
|
|
581
|
+
)
|
|
582
|
+
pk = {key: data[key] for key in pkeys}
|
|
583
|
+
else:
|
|
584
|
+
pk = dict(pk)
|
|
585
|
+
for key, value in pk.items():
|
|
586
|
+
if key in data and data[key] != value:
|
|
587
|
+
raise ValueError(
|
|
588
|
+
f"Conflicting values for primary key '{key}' between data and pk arguments."
|
|
589
|
+
)
|
|
609
590
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
full_data.update(data)
|
|
613
|
-
full_data.update(pk)
|
|
591
|
+
insert_data = dict(data)
|
|
592
|
+
insert_data.update(pk)
|
|
614
593
|
|
|
615
|
-
|
|
616
|
-
sql = [sql]
|
|
617
|
-
vals = list(vals) # Convert to a mutable list
|
|
594
|
+
update_data = {k: v for k, v in data.items() if k not in pk}
|
|
618
595
|
|
|
619
|
-
if
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
if on_conflict_do_nothing:
|
|
626
|
-
sql.append("NOTHING")
|
|
627
|
-
elif on_conflict_update:
|
|
628
|
-
# Call update() with excluded=True to produce the SET clause for the upsert.
|
|
629
|
-
sql_update, vals_update = cls.update(tx, table, data, pk, excluded=True)
|
|
630
|
-
sql.append(sql_update)
|
|
631
|
-
# Use list.extend to add the update values to vals.
|
|
632
|
-
vals.extend(vals_update)
|
|
633
|
-
else:
|
|
596
|
+
if not update_data and on_conflict_update:
|
|
597
|
+
# Nothing to update, fall back to a no-op on conflict resolution.
|
|
598
|
+
on_conflict_do_nothing = True
|
|
599
|
+
on_conflict_update = False
|
|
600
|
+
|
|
601
|
+
if on_conflict_do_nothing == on_conflict_update:
|
|
634
602
|
raise Exception(
|
|
635
603
|
"Update on conflict must have one and only one option to complete on conflict."
|
|
636
604
|
)
|
|
637
605
|
|
|
606
|
+
sql, vals = cls.insert(table, insert_data)
|
|
607
|
+
sql = [sql]
|
|
608
|
+
vals = list(vals) # Convert to a mutable list
|
|
609
|
+
|
|
610
|
+
sql.append("ON CONFLICT")
|
|
611
|
+
conflict_columns = [TableHelper.quote(column) for column in pk.keys()]
|
|
612
|
+
sql.append("(")
|
|
613
|
+
sql.append(", ".join(conflict_columns))
|
|
614
|
+
sql.append(")")
|
|
615
|
+
sql.append("DO")
|
|
616
|
+
if on_conflict_do_nothing:
|
|
617
|
+
sql.append("NOTHING")
|
|
618
|
+
elif on_conflict_update:
|
|
619
|
+
sql_update, vals_update = cls.update(
|
|
620
|
+
tx, table, update_data, pk, excluded=True
|
|
621
|
+
)
|
|
622
|
+
sql.append(sql_update)
|
|
623
|
+
vals.extend(vals_update)
|
|
624
|
+
|
|
638
625
|
import sqlparse
|
|
639
626
|
|
|
640
627
|
final_sql = sqlparse.format(" ".join(sql), reindent=True, keyword_case="upper")
|
|
641
628
|
return final_sql, tuple(vals)
|
|
642
629
|
|
|
630
|
+
@classmethod
|
|
631
|
+
def insnx(cls, tx, table, data, where=None):
|
|
632
|
+
"""Insert only when the supplied predicate finds no existing row."""
|
|
633
|
+
if not table:
|
|
634
|
+
raise ValueError("Table name is required.")
|
|
635
|
+
if not isinstance(data, Mapping) or not data:
|
|
636
|
+
raise ValueError("data must be a non-empty mapping of column-value pairs.")
|
|
637
|
+
|
|
638
|
+
# Helper used for quoting and foreign key resolution
|
|
639
|
+
th = TableHelper(tx, table)
|
|
640
|
+
quote_helper = TableHelper(None, table)
|
|
641
|
+
|
|
642
|
+
columns_sql = []
|
|
643
|
+
select_parts = []
|
|
644
|
+
vals = []
|
|
645
|
+
|
|
646
|
+
for key, val in data.items():
|
|
647
|
+
columns_sql.append(quote_helper.quote(key.lower()))
|
|
648
|
+
if isinstance(val, str) and len(val) > 2 and val.startswith("@@") and val[2:]:
|
|
649
|
+
select_parts.append(val[2:])
|
|
650
|
+
else:
|
|
651
|
+
select_parts.append("%s")
|
|
652
|
+
vals.append(val)
|
|
653
|
+
|
|
654
|
+
if not select_parts:
|
|
655
|
+
raise ValueError("At least one column is required for insert.")
|
|
656
|
+
|
|
657
|
+
if where is None:
|
|
658
|
+
if tx is None:
|
|
659
|
+
raise ValueError(
|
|
660
|
+
"A transaction context is required when deriving WHERE from primary keys."
|
|
661
|
+
)
|
|
662
|
+
pk_cols = tx.table(table).primary_keys()
|
|
663
|
+
if not pk_cols:
|
|
664
|
+
raise ValueError("Primary key required to derive WHERE clause.")
|
|
665
|
+
missing = [pk for pk in pk_cols if pk not in data]
|
|
666
|
+
if missing:
|
|
667
|
+
raise ValueError(
|
|
668
|
+
"Missing primary key value(s) for insert condition: " + ", ".join(missing)
|
|
669
|
+
)
|
|
670
|
+
where = {pk: data[pk] for pk in pk_cols}
|
|
671
|
+
|
|
672
|
+
where_clauses = []
|
|
673
|
+
where_vals = []
|
|
674
|
+
|
|
675
|
+
if isinstance(where, Mapping):
|
|
676
|
+
compiled = []
|
|
677
|
+
for key, val in where.items():
|
|
678
|
+
compiled.append(th.make_predicate(key, val))
|
|
679
|
+
where = compiled
|
|
680
|
+
|
|
681
|
+
if isinstance(where, str):
|
|
682
|
+
where_clauses.append(where)
|
|
683
|
+
else:
|
|
684
|
+
try:
|
|
685
|
+
for predicate, value in where:
|
|
686
|
+
where_clauses.append(predicate)
|
|
687
|
+
if value is None:
|
|
688
|
+
continue
|
|
689
|
+
if isinstance(value, tuple):
|
|
690
|
+
where_vals.extend(value)
|
|
691
|
+
else:
|
|
692
|
+
where_vals.append(value)
|
|
693
|
+
except (TypeError, ValueError) as exc:
|
|
694
|
+
raise ValueError(
|
|
695
|
+
"Invalid WHERE clause format. Expected mapping, SQL string, or iterable of predicate/value pairs."
|
|
696
|
+
) from exc
|
|
697
|
+
|
|
698
|
+
vals.extend(where_vals)
|
|
699
|
+
|
|
700
|
+
exists_sql = [
|
|
701
|
+
"SELECT 1 FROM",
|
|
702
|
+
TableHelper.quote(table),
|
|
703
|
+
]
|
|
704
|
+
if where_clauses:
|
|
705
|
+
exists_sql.append("WHERE " + " AND ".join(where_clauses))
|
|
706
|
+
|
|
707
|
+
sql_parts = [
|
|
708
|
+
"INSERT INTO",
|
|
709
|
+
TableHelper.quote(table),
|
|
710
|
+
f"({','.join(columns_sql)})",
|
|
711
|
+
"SELECT",
|
|
712
|
+
", ".join(select_parts),
|
|
713
|
+
"WHERE NOT EXISTS (",
|
|
714
|
+
" ".join(exists_sql),
|
|
715
|
+
")",
|
|
716
|
+
]
|
|
717
|
+
|
|
718
|
+
final_sql = sqlparse.format(" ".join(sql_parts), reindent=True, keyword_case="upper")
|
|
719
|
+
return final_sql, tuple(vals)
|
|
720
|
+
|
|
721
|
+
insert_if_not_exists = insnx
|
|
722
|
+
|
|
643
723
|
@classmethod
|
|
644
724
|
def version(cls):
|
|
645
725
|
return "select version()", tuple()
|
|
@@ -737,6 +817,7 @@ class SQL(BaseSQLDialect):
|
|
|
737
817
|
sys_created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
738
818
|
sys_modified TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
739
819
|
sys_modified_by TEXT NOT NULL DEFAULT 'SYSTEM',
|
|
820
|
+
sys_modified_row TIMESTAMP NOT NULL DEFAULT CLOCK_TIMESTAMP(),
|
|
740
821
|
sys_modified_count INTEGER NOT NULL DEFAULT 0,
|
|
741
822
|
sys_dirty BOOLEAN NOT NULL DEFAULT FALSE,
|
|
742
823
|
sys_table TEXT NOT NULL,
|
|
@@ -751,8 +832,9 @@ class SQL(BaseSQLDialect):
|
|
|
751
832
|
BEGIN
|
|
752
833
|
IF (TG_OP = 'INSERT') THEN
|
|
753
834
|
NEW.sys_table := TG_TABLE_NAME;
|
|
754
|
-
NEW.sys_created :=
|
|
755
|
-
NEW.sys_modified :=
|
|
835
|
+
NEW.sys_created := transaction_timestamp();
|
|
836
|
+
NEW.sys_modified := transaction_timestamp();
|
|
837
|
+
NEW.sys_modified_row := clock_timestamp();
|
|
756
838
|
NEW.sys_modified_count := 0;
|
|
757
839
|
ELSIF (TG_OP = 'UPDATE') THEN
|
|
758
840
|
NEW.sys_table := TG_TABLE_NAME;
|
|
@@ -764,7 +846,7 @@ class SQL(BaseSQLDialect):
|
|
|
764
846
|
ELSE
|
|
765
847
|
NEW.sys_dirty := TRUE;
|
|
766
848
|
END IF;
|
|
767
|
-
NEW.sys_modified :=
|
|
849
|
+
NEW.sys_modified := transaction_timestamp();
|
|
768
850
|
NEW.sys_modified_count := COALESCE(OLD.sys_modified_count, 0) + 1;
|
|
769
851
|
END IF;
|
|
770
852
|
END IF;
|
|
@@ -794,28 +876,52 @@ class SQL(BaseSQLDialect):
|
|
|
794
876
|
return sql, tuple()
|
|
795
877
|
|
|
796
878
|
@classmethod
|
|
797
|
-
def ensure_sys_modified_count(
|
|
798
|
-
|
|
879
|
+
def ensure_sys_modified_count(
|
|
880
|
+
cls, name, has_column=False, has_row_column=False
|
|
881
|
+
):
|
|
882
|
+
"""Return SQL to backfill sys_modified_count/sys_modified_row and refresh the on_sys_modified trigger."""
|
|
799
883
|
if "." in name:
|
|
800
|
-
|
|
884
|
+
schema_name, table_name = name.split(".", 1)
|
|
801
885
|
else:
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
886
|
+
schema_name = cls.default_schema
|
|
887
|
+
table_name = name
|
|
888
|
+
|
|
889
|
+
schema_identifier = TableHelper.quote(schema_name)
|
|
890
|
+
table_identifier = TableHelper.quote(table_name)
|
|
891
|
+
fqtn = f"{schema_identifier}.{table_identifier}"
|
|
892
|
+
|
|
893
|
+
trigger_name = (
|
|
894
|
+
f"on_update_row_{schema_name}_{table_name}"
|
|
895
|
+
.replace(".", "_")
|
|
896
|
+
.replace('"', "")
|
|
897
|
+
)
|
|
898
|
+
trigger_identifier = TableHelper.quote(trigger_name)
|
|
805
899
|
column_name = TableHelper.quote("sys_modified_count")
|
|
900
|
+
row_column_name = TableHelper.quote("sys_modified_row")
|
|
806
901
|
|
|
807
|
-
|
|
808
|
-
|
|
902
|
+
statements = []
|
|
903
|
+
if not has_column:
|
|
904
|
+
statements.append(
|
|
905
|
+
f"ALTER TABLE {fqtn} ADD COLUMN {column_name} INTEGER NOT NULL DEFAULT 0;"
|
|
906
|
+
)
|
|
907
|
+
if not has_row_column:
|
|
908
|
+
statements.append(
|
|
909
|
+
f"ALTER TABLE {fqtn} ADD COLUMN {row_column_name} TIMESTAMPTZ;"
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
statements.extend([
|
|
809
913
|
f"UPDATE {fqtn} SET {column_name} = 0 WHERE {column_name} IS NULL;",
|
|
914
|
+
f"UPDATE {fqtn} SET {row_column_name} = COALESCE({row_column_name}, clock_timestamp());",
|
|
810
915
|
f"""
|
|
811
|
-
CREATE OR REPLACE FUNCTION {
|
|
916
|
+
CREATE OR REPLACE FUNCTION {schema_identifier}.on_sys_modified()
|
|
812
917
|
RETURNS TRIGGER AS
|
|
813
918
|
$BODY$
|
|
814
919
|
BEGIN
|
|
815
920
|
IF (TG_OP = 'INSERT') THEN
|
|
816
921
|
NEW.sys_table := TG_TABLE_NAME;
|
|
817
|
-
NEW.sys_created :=
|
|
818
|
-
NEW.sys_modified :=
|
|
922
|
+
NEW.sys_created := transaction_timestamp();
|
|
923
|
+
NEW.sys_modified := transaction_timestamp();
|
|
924
|
+
NEW.sys_modified_row := clock_timestamp();
|
|
819
925
|
NEW.sys_modified_count := 0;
|
|
820
926
|
ELSIF (TG_OP = 'UPDATE') THEN
|
|
821
927
|
NEW.sys_table := TG_TABLE_NAME;
|
|
@@ -827,7 +933,8 @@ class SQL(BaseSQLDialect):
|
|
|
827
933
|
ELSE
|
|
828
934
|
NEW.sys_dirty := TRUE;
|
|
829
935
|
END IF;
|
|
830
|
-
NEW.sys_modified :=
|
|
936
|
+
NEW.sys_modified := transaction_timestamp();
|
|
937
|
+
NEW.sys_modified_row := clock_timestamp();
|
|
831
938
|
NEW.sys_modified_count := COALESCE(OLD.sys_modified_count, 0) + 1;
|
|
832
939
|
END IF;
|
|
833
940
|
END IF;
|
|
@@ -837,15 +944,17 @@ class SQL(BaseSQLDialect):
|
|
|
837
944
|
LANGUAGE plpgsql VOLATILE
|
|
838
945
|
COST 100;
|
|
839
946
|
""",
|
|
840
|
-
f"DROP TRIGGER IF EXISTS {
|
|
947
|
+
f"DROP TRIGGER IF EXISTS {trigger_identifier} ON {fqtn};",
|
|
841
948
|
f"""
|
|
842
|
-
CREATE TRIGGER {
|
|
949
|
+
CREATE TRIGGER {trigger_identifier}
|
|
843
950
|
BEFORE INSERT OR UPDATE ON {fqtn}
|
|
844
|
-
FOR EACH ROW EXECUTE PROCEDURE {
|
|
951
|
+
FOR EACH ROW EXECUTE PROCEDURE {schema_identifier}.on_sys_modified();
|
|
845
952
|
""",
|
|
846
|
-
|
|
953
|
+
])
|
|
847
954
|
|
|
848
|
-
sql = sqlparse.format(
|
|
955
|
+
sql = sqlparse.format(
|
|
956
|
+
" ".join(statements), reindent=True, keyword_case="upper"
|
|
957
|
+
)
|
|
849
958
|
return sql, tuple()
|
|
850
959
|
|
|
851
960
|
@classmethod
|
|
@@ -431,7 +431,7 @@ END;
|
|
|
431
431
|
return "\n".join(statements), tuple()
|
|
432
432
|
|
|
433
433
|
@classmethod
|
|
434
|
-
def ensure_sys_modified_count(cls, name):
|
|
434
|
+
def ensure_sys_modified_count(cls, name, has_column=False, has_row_column=False):
|
|
435
435
|
"""Ensure sys_modified_count exists for SQLite tables."""
|
|
436
436
|
table_identifier = quote(name)
|
|
437
437
|
base_name = name.split(".")[-1].replace('"', "")
|
|
@@ -485,7 +485,9 @@ END;
|
|
|
485
485
|
return "\n".join(statements), tuple()
|
|
486
486
|
|
|
487
487
|
@classmethod
|
|
488
|
-
def ensure_sys_modified_count(
|
|
488
|
+
def ensure_sys_modified_count(
|
|
489
|
+
cls, name, has_column=False, has_row_column=False
|
|
490
|
+
):
|
|
489
491
|
"""Ensure sys_modified_count exists for SQL Server tables along with maintenance triggers."""
|
|
490
492
|
if "." in name:
|
|
491
493
|
schema, table_name = name.split(".", 1)
|
|
@@ -499,8 +501,13 @@ END;
|
|
|
499
501
|
table_name_sql = table_name.replace("'", "''")
|
|
500
502
|
trigger_prefix = re.sub(r"[^0-9A-Za-z_]+", "_", f"CC_SYS_MOD_{table_name}")
|
|
501
503
|
|
|
502
|
-
statements = [
|
|
503
|
-
|
|
504
|
+
statements = []
|
|
505
|
+
if not has_column:
|
|
506
|
+
statements.append(
|
|
507
|
+
f"IF COL_LENGTH(N'{object_name}', 'sys_modified_count') IS NULL BEGIN ALTER TABLE {table_identifier} ADD sys_modified_count INT NOT NULL CONSTRAINT DF_{trigger_prefix}_COUNT DEFAULT (0); END;"
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
statements.extend([
|
|
504
511
|
f"UPDATE {table_identifier} SET sys_modified_count = 0 WHERE sys_modified_count IS NULL;",
|
|
505
512
|
f"IF OBJECT_ID(N'{schema_identifier}.{trigger_prefix}_insert', N'TR') IS NOT NULL DROP TRIGGER {schema_identifier}.{trigger_prefix}_insert;",
|
|
506
513
|
f"IF OBJECT_ID(N'{schema_identifier}.{trigger_prefix}_update', N'TR') IS NOT NULL DROP TRIGGER {schema_identifier}.{trigger_prefix}_update;",
|
|
@@ -539,7 +546,7 @@ BEGIN
|
|
|
539
546
|
INNER JOIN deleted AS d ON d.sys_id = i.sys_id;
|
|
540
547
|
END;
|
|
541
548
|
""".strip(),
|
|
542
|
-
]
|
|
549
|
+
])
|
|
543
550
|
|
|
544
551
|
return "\n".join(statements), tuple()
|
|
545
552
|
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import unittest
|
|
2
2
|
import decimal
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
from unittest import mock
|
|
3
5
|
from velocity.db.servers.postgres.sql import SQL
|
|
4
6
|
from velocity.db.servers.tablehelper import TableHelper
|
|
5
7
|
from velocity.db.servers.postgres.types import TYPES
|
|
8
|
+
from velocity.db.core.table import Table
|
|
6
9
|
|
|
7
10
|
|
|
8
11
|
class MockTx:
|
|
@@ -21,6 +24,51 @@ class MockTable:
|
|
|
21
24
|
def column(self, column_name):
|
|
22
25
|
return MockColumn()
|
|
23
26
|
|
|
27
|
+
def primary_keys(self):
|
|
28
|
+
return ["id"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DummyCursor:
|
|
32
|
+
def __init__(self, rowcount=0):
|
|
33
|
+
self.rowcount = rowcount
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DummyResult:
|
|
37
|
+
def __init__(self, rowcount=0):
|
|
38
|
+
self.cursor = DummyCursor(rowcount)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DummyTx:
|
|
42
|
+
def __init__(self):
|
|
43
|
+
self.engine = SimpleNamespace(sql=SimpleNamespace(), schema_locked=False)
|
|
44
|
+
self.executed = []
|
|
45
|
+
self.next_results = []
|
|
46
|
+
|
|
47
|
+
def cursor(self):
|
|
48
|
+
return DummyCursor()
|
|
49
|
+
|
|
50
|
+
def create_savepoint(self, cursor=None):
|
|
51
|
+
sp_id = f"sp_{len(self.executed)}"
|
|
52
|
+
return sp_id
|
|
53
|
+
|
|
54
|
+
def release_savepoint(self, sp, cursor=None):
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def rollback_savepoint(self, sp, cursor=None):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
def execute(self, sql, params, cursor=None):
|
|
61
|
+
self.executed.append((sql, params))
|
|
62
|
+
if self.next_results:
|
|
63
|
+
return self.next_results.pop(0)
|
|
64
|
+
return DummyResult(0)
|
|
65
|
+
|
|
66
|
+
def table(self, table_name):
|
|
67
|
+
return MockTable()
|
|
68
|
+
|
|
69
|
+
def primary_keys(self):
|
|
70
|
+
return ["id"]
|
|
71
|
+
|
|
24
72
|
class MockColumn:
|
|
25
73
|
def __init__(self):
|
|
26
74
|
self.py_type = str
|
|
@@ -218,6 +266,147 @@ class TestSQLModule(unittest.TestCase):
|
|
|
218
266
|
self.assertIn("SET", sql_query)
|
|
219
267
|
self.assertEqual(params, ("value1", 1))
|
|
220
268
|
|
|
269
|
+
def test_sql_insnx_with_explicit_where(self):
|
|
270
|
+
mock_tx = MockTx()
|
|
271
|
+
sql_query, params = SQL.insnx(
|
|
272
|
+
mock_tx,
|
|
273
|
+
table="my_table",
|
|
274
|
+
data={"id": 1, "column1": "value1"},
|
|
275
|
+
where={"column1": "value1"},
|
|
276
|
+
)
|
|
277
|
+
self.assertIn("INSERT INTO", sql_query)
|
|
278
|
+
self.assertIn("WHERE NOT EXISTS", sql_query)
|
|
279
|
+
self.assertIn("SELECT 1 FROM my_table", sql_query)
|
|
280
|
+
self.assertEqual(params, (1, "value1", "value1"))
|
|
281
|
+
|
|
282
|
+
def test_sql_insert_if_not_exists_alias(self):
|
|
283
|
+
mock_tx = MockTx()
|
|
284
|
+
sql_alias, params_alias = SQL.insert_if_not_exists(
|
|
285
|
+
mock_tx,
|
|
286
|
+
table="my_table",
|
|
287
|
+
data={"id": 1, "column1": "value1"},
|
|
288
|
+
where={"column1": "value1"},
|
|
289
|
+
)
|
|
290
|
+
sql_main, params_main = SQL.insnx(
|
|
291
|
+
mock_tx,
|
|
292
|
+
table="my_table",
|
|
293
|
+
data={"id": 1, "column1": "value1"},
|
|
294
|
+
where={"column1": "value1"},
|
|
295
|
+
)
|
|
296
|
+
self.assertEqual(sql_alias, sql_main)
|
|
297
|
+
self.assertEqual(params_alias, params_main)
|
|
298
|
+
|
|
299
|
+
def test_table_update_or_insert_updates_only(self):
|
|
300
|
+
tx = DummyTx()
|
|
301
|
+
table = Table(tx, "my_table")
|
|
302
|
+
table.cursor = mock.MagicMock(return_value=None)
|
|
303
|
+
table.update = mock.MagicMock(return_value=1)
|
|
304
|
+
ins_builder = mock.MagicMock()
|
|
305
|
+
table.sql = SimpleNamespace(insnx=ins_builder, insert_if_not_exists=ins_builder)
|
|
306
|
+
|
|
307
|
+
affected = table.update_or_insert(
|
|
308
|
+
update_data={"value": "new"},
|
|
309
|
+
insert_data={"id": 1, "value": "new"},
|
|
310
|
+
where={"id": 1},
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
self.assertEqual(affected, 1)
|
|
314
|
+
table.update.assert_called_once()
|
|
315
|
+
ins_builder.assert_not_called()
|
|
316
|
+
|
|
317
|
+
def test_table_update_or_insert_falls_back_to_insert(self):
|
|
318
|
+
tx = DummyTx()
|
|
319
|
+
table = Table(tx, "my_table")
|
|
320
|
+
table.cursor = mock.MagicMock(return_value=None)
|
|
321
|
+
table.update = mock.MagicMock(return_value=0)
|
|
322
|
+
|
|
323
|
+
captured = {}
|
|
324
|
+
|
|
325
|
+
def fake_insnx(tx_ctx, table_name, data, where):
|
|
326
|
+
captured["tx"] = tx_ctx
|
|
327
|
+
captured["table"] = table_name
|
|
328
|
+
captured["data"] = dict(data)
|
|
329
|
+
captured["where"] = where
|
|
330
|
+
return ("INSERT", ("a", "b"))
|
|
331
|
+
|
|
332
|
+
ins_builder = mock.MagicMock(side_effect=fake_insnx)
|
|
333
|
+
table.sql = SimpleNamespace(insnx=ins_builder, insert_if_not_exists=ins_builder)
|
|
334
|
+
tx.next_results.append(DummyResult(1))
|
|
335
|
+
|
|
336
|
+
affected = table.update_or_insert(
|
|
337
|
+
update_data={"value": "new"},
|
|
338
|
+
where={"id": 1},
|
|
339
|
+
pk={"id": 1},
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
self.assertEqual(affected, 1)
|
|
343
|
+
table.update.assert_called_once()
|
|
344
|
+
ins_builder.assert_called_once()
|
|
345
|
+
self.assertEqual(captured["table"], "my_table")
|
|
346
|
+
self.assertEqual(captured["data"], {"value": "new", "id": 1})
|
|
347
|
+
self.assertEqual(captured["where"], {"id": 1})
|
|
348
|
+
|
|
349
|
+
def test_table_update_or_insert_sql_only(self):
|
|
350
|
+
tx = DummyTx()
|
|
351
|
+
table = Table(tx, "my_table")
|
|
352
|
+
table.cursor = mock.MagicMock(return_value=None)
|
|
353
|
+
table.update = mock.MagicMock(return_value=("UPDATE sql", ("u",)))
|
|
354
|
+
|
|
355
|
+
ins_builder = mock.MagicMock(return_value=("INSERT sql", ("i",)))
|
|
356
|
+
table.sql = SimpleNamespace(insnx=ins_builder, insert_if_not_exists=ins_builder)
|
|
357
|
+
|
|
358
|
+
result = table.update_or_insert(
|
|
359
|
+
update_data={"value": "new"},
|
|
360
|
+
where={"id": 1},
|
|
361
|
+
pk={"id": 1},
|
|
362
|
+
sql_only=True,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
self.assertEqual(result["update"], ("UPDATE sql", ("u",)))
|
|
366
|
+
self.assertEqual(result["insert"], ("INSERT sql", ("i",)))
|
|
367
|
+
table.update.assert_called_once_with({"value": "new"}, where={"id": 1}, pk={"id": 1}, sql_only=True)
|
|
368
|
+
ins_builder.assert_called_once()
|
|
369
|
+
|
|
370
|
+
def test_sql_merge_conflict_columns_are_quoted(self):
|
|
371
|
+
mock_tx = MockTx()
|
|
372
|
+
sql_query, _ = SQL.merge(
|
|
373
|
+
mock_tx,
|
|
374
|
+
table="my_table",
|
|
375
|
+
data={"payload": "value"},
|
|
376
|
+
pk={"select": 1},
|
|
377
|
+
on_conflict_do_nothing=False,
|
|
378
|
+
on_conflict_update=True,
|
|
379
|
+
)
|
|
380
|
+
self.assertIn('on conflict ("select")'.upper(), sql_query.upper())
|
|
381
|
+
|
|
382
|
+
def test_sql_merge_missing_auto_pk_values(self):
|
|
383
|
+
mock_tx = MockTx()
|
|
384
|
+
with self.assertRaisesRegex(
|
|
385
|
+
ValueError, "Primary key values missing from data for merge"
|
|
386
|
+
):
|
|
387
|
+
SQL.merge(
|
|
388
|
+
mock_tx,
|
|
389
|
+
table="my_table",
|
|
390
|
+
data={"column1": "value1"},
|
|
391
|
+
pk=None,
|
|
392
|
+
on_conflict_do_nothing=False,
|
|
393
|
+
on_conflict_update=True,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
def test_sql_merge_auto_pk_without_update_columns_falls_back_to_do_nothing(self):
|
|
397
|
+
mock_tx = MockTx()
|
|
398
|
+
sql_query, params = SQL.merge(
|
|
399
|
+
mock_tx,
|
|
400
|
+
table="my_table",
|
|
401
|
+
data={"id": 1},
|
|
402
|
+
pk=None,
|
|
403
|
+
on_conflict_do_nothing=False,
|
|
404
|
+
on_conflict_update=True,
|
|
405
|
+
)
|
|
406
|
+
self.assertIn("DO NOTHING", sql_query)
|
|
407
|
+
self.assertNotIn(" DO UPDATE", sql_query)
|
|
408
|
+
self.assertEqual(params, (1,))
|
|
409
|
+
|
|
221
410
|
def test_get_type_mapping(self):
|
|
222
411
|
self.assertEqual(TYPES.get_type("string"), "TEXT")
|
|
223
412
|
self.assertEqual(TYPES.get_type(123), "BIGINT")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: velocity-python
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.152
|
|
4
4
|
Summary: A rapid application development library for interfacing with data storage
|
|
5
5
|
Author-email: Velocity Team <info@codeclubs.org>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -497,7 +497,7 @@ def update_user(tx):
|
|
|
497
497
|
# Find and update using dictionary syntax
|
|
498
498
|
user = users.find(123) # Returns a row that behaves like a dict
|
|
499
499
|
user['name'] = 'Updated Name' # Direct assignment like a dict
|
|
500
|
-
user['
|
|
500
|
+
user['important_date'] = datetime.now() # No special methods needed
|
|
501
501
|
|
|
502
502
|
# Check if columns exist before updating
|
|
503
503
|
if 'phone' in user:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
velocity/__init__.py,sha256=
|
|
1
|
+
velocity/__init__.py,sha256=RyxrnzBhzGp5qsnb1PU0iJPVNsvSTg5ZIlnrnL5P3m4,147
|
|
2
2
|
velocity/app/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
3
|
velocity/app/invoices.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
-
velocity/app/orders.py,sha256=
|
|
4
|
+
velocity/app/orders.py,sha256=C7ewngMpO8nD3ul_82o4FhZBdRkWvJtnuEbEJUKDCno,6151
|
|
5
5
|
velocity/app/payments.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
6
|
velocity/app/purchase_orders.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
7
|
velocity/app/tests/__init__.py,sha256=mqbNes8CjWTYLgCEgmu3EZudF6HZj671friAsAa4m_E,19
|
|
@@ -19,6 +19,7 @@ velocity/aws/handlers/response.py,sha256=s2Kw7yv5zAir1mEmfv6yBVIvRcRQ__xyryf1Srv
|
|
|
19
19
|
velocity/aws/handlers/sqs_handler.py,sha256=azuV8DrFOh0hM13EnPzyYVBS-3fLe2fn9OPc4ho7sGc,3375
|
|
20
20
|
velocity/aws/handlers/mixins/__init__.py,sha256=_zyEpsnKikF7D7X-F0GA4cyIrQ6wBq7k5j6Vhp17vaQ,623
|
|
21
21
|
velocity/aws/handlers/mixins/activity_tracker.py,sha256=vyQ_8kpSprjzLoALDv7g2rVkfstn89Tbsg6Zb9GmVOk,6579
|
|
22
|
+
velocity/aws/handlers/mixins/aws_session_mixin.py,sha256=yTa2-n4zgv23wbW3uZUp-L4CUJy8vSL8IMMNjMlYFVg,6806
|
|
22
23
|
velocity/aws/handlers/mixins/error_handler.py,sha256=uN2YF9v-3LzS3o_HdVpO-XMcPy3sS7SHjUg_LfbsG7Q,6803
|
|
23
24
|
velocity/aws/handlers/mixins/legacy_mixin.py,sha256=_YhiPU-zzXQjGNSAKhoUwfTFlnczmU-3BkwNFqr0hYE,2117
|
|
24
25
|
velocity/aws/handlers/mixins/standard_mixin.py,sha256=-wBX0PFlZAnxQsaMDEWr-xmU8TcRbQ4BZD3wmAKR2d0,2489
|
|
@@ -34,10 +35,10 @@ velocity/db/core/database.py,sha256=3zNGItklu9tZCKsbx2T2vCcU1so8AL9PPL0DLjvaz6s,
|
|
|
34
35
|
velocity/db/core/decorators.py,sha256=quhjMoEmK_l2jF7jXyL5Fgv8uisIpBz34Au5d3U6UHs,5276
|
|
35
36
|
velocity/db/core/engine.py,sha256=mNlaFPruHO935phKPVrsxZprGYUvxW-zp2sBcBZ-KCg,20666
|
|
36
37
|
velocity/db/core/result.py,sha256=b0ie3yZAOj9S57x32uFFGKZ95zhImmZ0iXl0X1qYszc,12813
|
|
37
|
-
velocity/db/core/row.py,sha256=
|
|
38
|
+
velocity/db/core/row.py,sha256=GOWm-HEBPCBwdqMHMBRc41m0Hoht4vRVQLkvdogX1fU,7729
|
|
38
39
|
velocity/db/core/sequence.py,sha256=VMBc0ZjGnOaWTwKW6xMNTdP8rZ2umQ8ml4fHTTwuGq4,3904
|
|
39
|
-
velocity/db/core/table.py,sha256=
|
|
40
|
-
velocity/db/core/transaction.py,sha256=
|
|
40
|
+
velocity/db/core/table.py,sha256=GyD4quWUGKXOiyZyE_bSESVscSGiCCQOVtixf7snfZ0,41940
|
|
41
|
+
velocity/db/core/transaction.py,sha256=VbB6GSdTT1Puy_j1tQnx9Ia3L3GZZFWaGw4xYWzpKAg,6733
|
|
41
42
|
velocity/db/servers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
42
43
|
velocity/db/servers/tablehelper.py,sha256=Q48ObN5KD_U2sBP0GUcjaQjKeE4Hr351sPQirwQ0_1s,22163
|
|
43
44
|
velocity/db/servers/base/__init__.py,sha256=5--XJUeEAm7O6Ns2C_ODCr5TjFhdAge-zApZCT0LGTQ,285
|
|
@@ -48,28 +49,28 @@ velocity/db/servers/base/types.py,sha256=3LBxFCD35eeIsIqftpAJh0JjUVonDYemz2n6AMt
|
|
|
48
49
|
velocity/db/servers/mysql/__init__.py,sha256=mASO5JB0xkzYngwx2X79yyKifYRqxIdfKFWutIHuw7k,2661
|
|
49
50
|
velocity/db/servers/mysql/operators.py,sha256=wHmVSPxlPGbOdvQEmsfKhD25H8djovSbNcmacLHDVkI,1273
|
|
50
51
|
velocity/db/servers/mysql/reserved.py,sha256=s-aFMwYJpZ_1FBcCMU8fOdhml2ET58-59ZnUm7iw5OU,3312
|
|
51
|
-
velocity/db/servers/mysql/sql.py,sha256=
|
|
52
|
+
velocity/db/servers/mysql/sql.py,sha256=CxskGe86I-8idLNZmcG7IPqlc-BQM9cpJU6WS5KdCA0,22210
|
|
52
53
|
velocity/db/servers/mysql/types.py,sha256=BMQf4TpsRo1JN-yOl1nSItTO-Juu2piSTNy5o_djBeM,3486
|
|
53
54
|
velocity/db/servers/postgres/__init__.py,sha256=6YcTLXposmsrEaJgdUAM_QgD1TZDSILQrGcwWZ-dibk,2457
|
|
54
55
|
velocity/db/servers/postgres/operators.py,sha256=y9k6enReeR5hJxU_lYYR2epoaw4qCxEqmYJJ5jjaVWA,1166
|
|
55
56
|
velocity/db/servers/postgres/reserved.py,sha256=5tKLaqFV-HrWRj-nsrxl5KGbmeM3ukn_bPZK36XEu8M,3648
|
|
56
|
-
velocity/db/servers/postgres/sql.py,sha256=
|
|
57
|
+
velocity/db/servers/postgres/sql.py,sha256=oF0Bll75sTOrRQhBNo3dklRpUFhLixil4i09eVC9Y8Y,54450
|
|
57
58
|
velocity/db/servers/postgres/types.py,sha256=W71x8iRx-IIJkQSjb29k-KGkqp-QS6SxB0BHYXd4k8w,6955
|
|
58
59
|
velocity/db/servers/sqlite/__init__.py,sha256=EIx09YN1-Vm-4CXVcEf9DBgvd8FhIN9rEqIaSRrEcIk,2293
|
|
59
60
|
velocity/db/servers/sqlite/operators.py,sha256=VzZgph8RrnHkIVqqWGqnJwcafgBzc_8ZQp-M8tMl-mw,1221
|
|
60
61
|
velocity/db/servers/sqlite/reserved.py,sha256=4vOI06bjt8wg9KxdzDTF-iOd-ewY23NvSzthpdty2fA,1298
|
|
61
|
-
velocity/db/servers/sqlite/sql.py,sha256=
|
|
62
|
+
velocity/db/servers/sqlite/sql.py,sha256=iAENHbN8mfVsQHoqnEppynVMP_PdqXJX8jZQDNzr0ro,20948
|
|
62
63
|
velocity/db/servers/sqlite/types.py,sha256=jpCJeV25x4Iytf6D6GXgK3hVYFAAFV4WKJC-d-m4kdU,3102
|
|
63
64
|
velocity/db/servers/sqlserver/__init__.py,sha256=LN8OycN7W8da_ZPRYnPQ-O3Bv_xjret9qV1ZCitZlOU,2708
|
|
64
65
|
velocity/db/servers/sqlserver/operators.py,sha256=xK8_doDLssS38SRs1NoAI7XTO0-gNGMDS76nTVru4kE,1104
|
|
65
66
|
velocity/db/servers/sqlserver/reserved.py,sha256=Gn5n9DjxcjM-7PsIZPYigD6XLvMAYGnz1IrPuN7Dp2Y,2120
|
|
66
|
-
velocity/db/servers/sqlserver/sql.py,sha256=
|
|
67
|
+
velocity/db/servers/sqlserver/sql.py,sha256=h4fnVuNWaQE2c2sEEkLSIlBGf3xZP-lDtwILhF2-g3c,26368
|
|
67
68
|
velocity/db/servers/sqlserver/types.py,sha256=FAODYEO137m-WugpM89f9bQN9q6S2cjjUaz0a9gfE6M,3745
|
|
68
69
|
velocity/db/tests/__init__.py,sha256=7-hilWb43cKnSnCeXcjFG-6LpziN5k443IpsIvuevP0,24
|
|
69
70
|
velocity/db/tests/common_db_test.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
70
71
|
velocity/db/tests/test_cursor_rowcount_fix.py,sha256=mZRL1SBb9Knh67CSFyvfwj_LAarE_ilfVwpQHW18Yy8,5507
|
|
71
72
|
velocity/db/tests/test_db_utils.py,sha256=mSbEQXYKpWidX1FEnjrmt3q3K4ra0YTtQclrS46ufEE,8426
|
|
72
|
-
velocity/db/tests/test_postgres.py,sha256=
|
|
73
|
+
velocity/db/tests/test_postgres.py,sha256=NoBydNkGmXn8olXwva4C4sYV3cKzERd6Df0wHixxoyE,15554
|
|
73
74
|
velocity/db/tests/test_postgres_unchanged.py,sha256=rNcy7S_HXazi_MjU8QjRZO4q8dULMeG4tg6eN-rPPz8,2998
|
|
74
75
|
velocity/db/tests/test_process_error_robustness.py,sha256=CZr_co_o6PK7dejOr_gwdn0iKTzjWPTY5k-PwJ6oh9s,11361
|
|
75
76
|
velocity/db/tests/test_result_caching.py,sha256=DgsGXWL4G79MZOslCjq_t8qtdhCcXkHjQqV5zsF6i6M,8960
|
|
@@ -121,8 +122,8 @@ velocity/misc/tests/test_merge.py,sha256=Vm5_jY5cVczw0hZF-3TYzmxFw81heJOJB-dvhCg
|
|
|
121
122
|
velocity/misc/tests/test_oconv.py,sha256=fy4DwWGn_v486r2d_3ACpuBD-K1oOngNq1HJCGH7X-M,4694
|
|
122
123
|
velocity/misc/tests/test_original_error.py,sha256=iWSd18tckOA54LoPQOGV5j9LAz2W-3_ZOwmyZ8-4YQc,1742
|
|
123
124
|
velocity/misc/tests/test_timer.py,sha256=l9nrF84kHaFofvQYKInJmfoqC01wBhsUB18lVBgXCoo,2758
|
|
124
|
-
velocity_python-0.0.
|
|
125
|
-
velocity_python-0.0.
|
|
126
|
-
velocity_python-0.0.
|
|
127
|
-
velocity_python-0.0.
|
|
128
|
-
velocity_python-0.0.
|
|
125
|
+
velocity_python-0.0.152.dist-info/licenses/LICENSE,sha256=aoN245GG8s9oRUU89KNiGTU4_4OtnNmVi4hQeChg6rM,1076
|
|
126
|
+
velocity_python-0.0.152.dist-info/METADATA,sha256=eMxsOZlWyZS4ziuZ2_P0T-vC6vr5lcBEXRHA8Yk8w_M,34266
|
|
127
|
+
velocity_python-0.0.152.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
128
|
+
velocity_python-0.0.152.dist-info/top_level.txt,sha256=JW2vJPmodgdgSz7H6yoZvnxF8S3fTMIv-YJWCT1sNW0,9
|
|
129
|
+
velocity_python-0.0.152.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|