velocity-python 0.0.109__py3-none-any.whl → 0.0.155__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 +167 -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 +20 -4
  27. velocity/db/core/engine.py +185 -839
  28. velocity/db/core/result.py +30 -24
  29. velocity/db/core/row.py +15 -3
  30. velocity/db/core/table.py +279 -40
  31. velocity/db/core/transaction.py +19 -11
  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 +221 -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 +62 -47
  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.155.dist-info}/METADATA +2 -2
  110. velocity_python-0.0.155.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.155.dist-info}/WHEEL +0 -0
  119. {velocity_python-0.0.109.dist-info → velocity_python-0.0.155.dist-info}/licenses/LICENSE +0 -0
  120. {velocity_python-0.0.109.dist-info → velocity_python-0.0.155.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,805 @@
1
+ import re
2
+ import hashlib
3
+ import decimal
4
+ import datetime
5
+ from typing import Any, Dict, List, Optional, Tuple, Union
6
+ from collections.abc import Mapping, Sequence
7
+
8
+ from velocity.db import exceptions
9
+ from ..base.sql import BaseSQLDialect
10
+ from .reserved import reserved_words
11
+ from .types import TYPES
12
+ from .operators import OPERATORS, SQLServerOperators
13
+ from ..tablehelper import TableHelper
14
+
15
+
16
+ # Configure TableHelper for SQL Server
17
+ TableHelper.reserved = reserved_words
18
+ TableHelper.operators = OPERATORS
19
+
20
+
21
+ system_fields = [
22
+ "sys_id",
23
+ "sys_created",
24
+ "sys_modified",
25
+ "sys_modified_by",
26
+ "sys_dirty",
27
+ "sys_table",
28
+ "sys_modified_count",
29
+ "description",
30
+ ]
31
+
32
+
33
+ def quote(data):
34
+ """Quote SQL Server identifiers."""
35
+ if isinstance(data, list):
36
+ return [quote(item) for item in data]
37
+ else:
38
+ parts = data.split(".")
39
+ new = []
40
+ for part in parts:
41
+ if "[" in part:
42
+ new.append(part)
43
+ elif part.upper() in reserved_words:
44
+ new.append("[" + part + "]")
45
+ elif re.findall("[/]", part):
46
+ new.append("[" + part + "]")
47
+ else:
48
+ new.append(part)
49
+ return ".".join(new)
50
+
51
+
52
+ class SQL(BaseSQLDialect):
53
+ server = "SQL Server"
54
+ type_column_identifier = "DATA_TYPE"
55
+ is_nullable = "IS_NULLABLE"
56
+
57
+ default_schema = "dbo"
58
+
59
+ # SQL Server error numbers
60
+ ApplicationErrorCodes = []
61
+ DatabaseMissingErrorCodes = ["911"] # Database not found
62
+ TableMissingErrorCodes = ["208"] # Invalid object name
63
+ ColumnMissingErrorCodes = ["207"] # Invalid column name
64
+ ForeignKeyMissingErrorCodes = ["1759"] # Foreign key error
65
+ ConnectionErrorCodes = ["2", "53", "1326"] # Connection errors
66
+ DuplicateKeyErrorCodes = ["2627", "2601"] # Primary key / unique constraint
67
+ RetryTransactionCodes = ["1205"] # Deadlock
68
+ TruncationErrorCodes = ["8152"] # String truncation
69
+ LockTimeoutErrorCodes = ["1222"] # Lock request timeout
70
+ DatabaseObjectExistsErrorCodes = ["2714"] # Object already exists
71
+ DataIntegrityErrorCodes = ["547", "515"] # Foreign key, null constraint
72
+
73
+ types = TYPES
74
+
75
+ @classmethod
76
+ def get_error(cls, e):
77
+ """Extract error information from SQL Server exception."""
78
+ # pytds exceptions have different attributes
79
+ error_number = getattr(e, "number", None) or getattr(e, "msgno", None)
80
+ error_message = getattr(e, "message", None) or str(e)
81
+ return error_number, error_message
82
+
83
+ @classmethod
84
+ def select(
85
+ cls,
86
+ tx,
87
+ columns=None,
88
+ table=None,
89
+ where=None,
90
+ orderby=None,
91
+ groupby=None,
92
+ having=None,
93
+ start=None,
94
+ qty=None,
95
+ lock=None,
96
+ skip_locked=None,
97
+ ):
98
+ """Generate a SQL Server SELECT statement."""
99
+ if not table:
100
+ raise ValueError("Table name is required")
101
+
102
+ sql_parts = []
103
+ vals = []
104
+
105
+ # SELECT clause with TOP (SQL Server pagination)
106
+ sql_parts.append("SELECT")
107
+
108
+ # Handle TOP clause for SQL Server pagination
109
+ if qty is not None and start is None:
110
+ sql_parts.append(f"TOP {qty}")
111
+
112
+ # Column selection
113
+ if columns is None:
114
+ columns = ["*"]
115
+ elif isinstance(columns, str):
116
+ columns = [columns]
117
+
118
+ sql_parts.append(", ".join(columns))
119
+
120
+ # FROM clause
121
+ sql_parts.append("FROM")
122
+ sql_parts.append(quote(table))
123
+
124
+ # WHERE clause
125
+ if where:
126
+ where_sql, where_vals = cls._build_where(where)
127
+ sql_parts.append("WHERE")
128
+ sql_parts.append(where_sql)
129
+ vals.extend(where_vals)
130
+
131
+ # GROUP BY clause
132
+ if groupby:
133
+ if isinstance(groupby, str):
134
+ groupby = [groupby]
135
+ sql_parts.append("GROUP BY")
136
+ sql_parts.append(", ".join(quote(col) for col in groupby))
137
+
138
+ # HAVING clause
139
+ if having:
140
+ having_sql, having_vals = cls._build_where(having)
141
+ sql_parts.append("HAVING")
142
+ sql_parts.append(having_sql)
143
+ vals.extend(having_vals)
144
+
145
+ # ORDER BY clause (required for OFFSET/FETCH)
146
+ if orderby:
147
+ if isinstance(orderby, str):
148
+ orderby = [orderby]
149
+ elif isinstance(orderby, dict):
150
+ orderby_list = []
151
+ for col, direction in orderby.items():
152
+ orderby_list.append(f"{quote(col)} {direction.upper()}")
153
+ orderby = orderby_list
154
+ sql_parts.append("ORDER BY")
155
+ sql_parts.append(", ".join(orderby))
156
+ elif start is not None:
157
+ # ORDER BY is required for OFFSET/FETCH in SQL Server
158
+ sql_parts.append("ORDER BY")
159
+ sql_parts.append("(SELECT NULL)")
160
+
161
+ # OFFSET and FETCH (SQL Server 2012+)
162
+ if start is not None:
163
+ sql_parts.append(f"OFFSET {start} ROWS")
164
+ if qty is not None:
165
+ sql_parts.append(f"FETCH NEXT {qty} ROWS ONLY")
166
+
167
+ # Locking hints
168
+ if lock:
169
+ sql_parts.append("WITH (UPDLOCK)")
170
+ if skip_locked:
171
+ sql_parts.append("WITH (READPAST)")
172
+
173
+ return " ".join(sql_parts), vals
174
+
175
+ @classmethod
176
+ def _build_where(cls, where):
177
+ """Build WHERE clause for SQL Server."""
178
+ if isinstance(where, str):
179
+ return where, []
180
+
181
+ if isinstance(where, dict):
182
+ where = list(where.items())
183
+
184
+ if not isinstance(where, (list, tuple)):
185
+ raise ValueError("WHERE clause must be string, dict, or list")
186
+
187
+ conditions = []
188
+ vals = []
189
+
190
+ for key, val in where:
191
+ if val is None:
192
+ if "!" in key:
193
+ key = key.replace("!", "")
194
+ conditions.append(f"{quote(key)} IS NOT NULL")
195
+ else:
196
+ conditions.append(f"{quote(key)} IS NULL")
197
+ elif isinstance(val, (list, tuple)):
198
+ if "!" in key:
199
+ key = key.replace("!", "")
200
+ conditions.append(f"{quote(key)} NOT IN ({', '.join(['?'] * len(val))})")
201
+ else:
202
+ conditions.append(f"{quote(key)} IN ({', '.join(['?'] * len(val))})")
203
+ vals.extend(val)
204
+ else:
205
+ # Handle operators
206
+ op = "="
207
+ if "<>" in key:
208
+ key = key.replace("<>", "")
209
+ op = "<>"
210
+ elif "!=" in key:
211
+ key = key.replace("!=", "")
212
+ op = "<>"
213
+ elif "%" in key:
214
+ key = key.replace("%", "")
215
+ op = "LIKE"
216
+ elif "!" in key:
217
+ key = key.replace("!", "")
218
+ op = "<>"
219
+
220
+ conditions.append(f"{quote(key)} {op} ?")
221
+ vals.append(val)
222
+
223
+ return " AND ".join(conditions), vals
224
+
225
+ @classmethod
226
+ def insert(cls, table, data):
227
+ """Generate an INSERT statement for SQL Server."""
228
+ if not data:
229
+ raise ValueError("Data cannot be empty")
230
+
231
+ columns = list(data.keys())
232
+ values = list(data.values())
233
+
234
+ sql_parts = [
235
+ "INSERT INTO",
236
+ quote(table),
237
+ f"({', '.join(quote(col) for col in columns)})",
238
+ "VALUES",
239
+ f"({', '.join(['?'] * len(values))})" # SQL Server uses ? placeholders
240
+ ]
241
+
242
+ return " ".join(sql_parts), values
243
+
244
+ @classmethod
245
+ def update(cls, tx, table, data, where=None, pk=None, excluded=False):
246
+ """Generate an UPDATE statement for SQL Server."""
247
+ if not data:
248
+ raise ValueError("Data cannot be empty")
249
+
250
+ if not where and not pk:
251
+ raise ValueError("Either WHERE clause or primary key must be provided")
252
+
253
+ # Build SET clause
254
+ set_clauses = []
255
+ vals = []
256
+
257
+ for col, val in data.items():
258
+ set_clauses.append(f"{quote(col)} = ?")
259
+ vals.append(val)
260
+
261
+ # Build WHERE clause
262
+ if pk:
263
+ if where:
264
+ # Merge pk into where
265
+ if isinstance(where, dict):
266
+ where.update(pk)
267
+ else:
268
+ # Convert to dict for merging
269
+ where_dict = dict(where) if isinstance(where, (list, tuple)) else {}
270
+ where_dict.update(pk)
271
+ where = where_dict
272
+ else:
273
+ where = pk
274
+
275
+ where_sql, where_vals = cls._build_where(where) if where else ("", [])
276
+
277
+ sql_parts = [
278
+ "UPDATE",
279
+ quote(table),
280
+ "SET",
281
+ ", ".join(set_clauses)
282
+ ]
283
+
284
+ if where_sql:
285
+ sql_parts.extend(["WHERE", where_sql])
286
+ vals.extend(where_vals)
287
+
288
+ return " ".join(sql_parts), vals
289
+
290
+ @classmethod
291
+ def delete(cls, tx, table, where):
292
+ """Generate a DELETE statement for SQL Server."""
293
+ if not where:
294
+ raise ValueError("WHERE clause is required for DELETE")
295
+
296
+ where_sql, where_vals = cls._build_where(where)
297
+
298
+ sql_parts = [
299
+ "DELETE FROM",
300
+ quote(table),
301
+ "WHERE",
302
+ where_sql
303
+ ]
304
+
305
+ return " ".join(sql_parts), where_vals
306
+
307
+ @classmethod
308
+ def merge(cls, tx, table, data, pk, on_conflict_do_nothing, on_conflict_update):
309
+ """Generate a MERGE statement for SQL Server."""
310
+ # SQL Server MERGE is complex - simplified version
311
+ if on_conflict_do_nothing:
312
+ # Use IF NOT EXISTS pattern
313
+ pk_conditions = " AND ".join([f"{quote(k)} = ?" for k in pk.keys()])
314
+ pk_values = list(pk.values())
315
+
316
+ insert_sql, insert_vals = cls.insert(table, data)
317
+ wrapped_sql = f"""
318
+ IF NOT EXISTS (SELECT 1 FROM {quote(table)} WHERE {pk_conditions})
319
+ BEGIN
320
+ {insert_sql}
321
+ END
322
+ """
323
+ return wrapped_sql, pk_values + insert_vals
324
+ elif on_conflict_update:
325
+ # Use actual MERGE statement
326
+ pk_columns = list(pk.keys())
327
+ data_columns = [k for k in data.keys() if k not in pk_columns]
328
+
329
+ # Build MERGE statement
330
+ merge_parts = [
331
+ f"MERGE {quote(table)} AS target",
332
+ f"USING (SELECT {', '.join(['?' for _ in data])} AS ({', '.join(quote(k) for k in data.keys())})) AS source",
333
+ f"ON ({' AND '.join([f'target.{quote(k)} = source.{quote(k)}' for k in pk_columns])})",
334
+ "WHEN MATCHED THEN",
335
+ f"UPDATE SET {', '.join([f'{quote(k)} = source.{quote(k)}' for k in data_columns])}",
336
+ "WHEN NOT MATCHED THEN",
337
+ f"INSERT ({', '.join(quote(k) for k in data.keys())})",
338
+ f"VALUES ({', '.join([f'source.{quote(k)}' for k in data.keys()])});",
339
+ ]
340
+
341
+ return " ".join(merge_parts), list(data.values())
342
+ else:
343
+ return cls.insert(table, data)
344
+
345
+ # Metadata queries
346
+ @classmethod
347
+ def version(cls):
348
+ return "SELECT @@VERSION"
349
+
350
+ @classmethod
351
+ def timestamp(cls):
352
+ return "SELECT GETDATE()"
353
+
354
+ @classmethod
355
+ def user(cls):
356
+ return "SELECT SYSTEM_USER"
357
+
358
+ @classmethod
359
+ def databases(cls):
360
+ return "SELECT name FROM sys.databases WHERE database_id > 4"
361
+
362
+ @classmethod
363
+ def schemas(cls):
364
+ return "SELECT name FROM sys.schemas"
365
+
366
+ @classmethod
367
+ def current_schema(cls):
368
+ return "SELECT SCHEMA_NAME()"
369
+
370
+ @classmethod
371
+ def current_database(cls):
372
+ return "SELECT DB_NAME()"
373
+
374
+ @classmethod
375
+ def tables(cls, system=False):
376
+ if system:
377
+ return "SELECT name FROM sys.tables"
378
+ else:
379
+ return "SELECT name FROM sys.tables WHERE is_ms_shipped = 0"
380
+
381
+ @classmethod
382
+ def views(cls, system=False):
383
+ if system:
384
+ return "SELECT name FROM sys.views"
385
+ else:
386
+ return "SELECT name FROM sys.views WHERE is_ms_shipped = 0"
387
+
388
+ @classmethod
389
+ def create_database(cls, name):
390
+ return f"CREATE DATABASE {quote(name)}"
391
+
392
+ @classmethod
393
+ def drop_database(cls, name):
394
+ return f"DROP DATABASE {quote(name)}"
395
+
396
+ @classmethod
397
+ def create_table(cls, name, columns=None, drop=False):
398
+ if not name or not isinstance(name, str):
399
+ raise ValueError("Table name must be a non-empty string")
400
+
401
+ columns = columns or {}
402
+
403
+ if "." in name:
404
+ schema_part, table_part = name.split(".", 1)
405
+ else:
406
+ schema_part = cls.default_schema or "dbo"
407
+ table_part = name
408
+
409
+ schema_identifier = quote(schema_part)
410
+ table_identifier = quote(name if "." in name else f"{schema_part}.{table_part}")
411
+ base_name = table_part.replace("[", "").replace("]", "")
412
+ base_name_sql = base_name.replace("'", "''")
413
+ trigger_prefix = re.sub(r"[^0-9A-Za-z_]+", "_", f"CC_SYS_MOD_{base_name}")
414
+
415
+ statements = []
416
+ if drop:
417
+ statements.append(f"IF OBJECT_ID(N'{table_identifier}', N'U') IS NOT NULL DROP TABLE {table_identifier};")
418
+
419
+ statements.append(
420
+ f"""
421
+ CREATE TABLE {table_identifier} (
422
+ [sys_id] BIGINT IDENTITY(1,1) PRIMARY KEY,
423
+ [sys_table] NVARCHAR(255),
424
+ [sys_created] DATETIME2 NOT NULL DEFAULT SYSDATETIME(),
425
+ [sys_modified] DATETIME2 NOT NULL DEFAULT SYSDATETIME(),
426
+ [sys_modified_by] NVARCHAR(255),
427
+ [sys_modified_count] INT NOT NULL DEFAULT 0,
428
+ [sys_dirty] BIT NOT NULL DEFAULT 0,
429
+ [description] NVARCHAR(MAX)
430
+ );
431
+ """.strip()
432
+ )
433
+
434
+ for key, val in columns.items():
435
+ clean_key = re.sub("<>!=%", "", key)
436
+ if clean_key in system_fields:
437
+ continue
438
+ col_type = TYPES.get_type(val)
439
+ statements.append(
440
+ f"ALTER TABLE {table_identifier} ADD {quote(clean_key)} {col_type};"
441
+ )
442
+
443
+ statements.extend(
444
+ [
445
+ f"IF OBJECT_ID(N'{schema_identifier}.{trigger_prefix}_insert', N'TR') IS NOT NULL DROP TRIGGER {schema_identifier}.{trigger_prefix}_insert;",
446
+ f"IF OBJECT_ID(N'{schema_identifier}.{trigger_prefix}_update', N'TR') IS NOT NULL DROP TRIGGER {schema_identifier}.{trigger_prefix}_update;",
447
+ f"""
448
+ CREATE TRIGGER {schema_identifier}.{trigger_prefix}_insert
449
+ ON {table_identifier}
450
+ AFTER INSERT
451
+ AS
452
+ BEGIN
453
+ SET NOCOUNT ON;
454
+ UPDATE t
455
+ SET sys_created = ISNULL(i.sys_created, SYSDATETIME()),
456
+ sys_modified = SYSDATETIME(),
457
+ sys_modified_count = 0,
458
+ sys_dirty = ISNULL(i.sys_dirty, 0),
459
+ sys_table = '{base_name_sql}'
460
+ FROM {table_identifier} AS t
461
+ INNER JOIN inserted AS i ON t.sys_id = i.sys_id;
462
+ END;
463
+ """.strip(),
464
+ f"""
465
+ CREATE TRIGGER {schema_identifier}.{trigger_prefix}_update
466
+ ON {table_identifier}
467
+ AFTER UPDATE
468
+ AS
469
+ BEGIN
470
+ SET NOCOUNT ON;
471
+ UPDATE t
472
+ SET sys_created = d.sys_created,
473
+ sys_modified = SYSDATETIME(),
474
+ sys_table = '{base_name_sql}',
475
+ sys_dirty = CASE WHEN d.sys_dirty = 1 AND i.sys_dirty = 0 THEN 0 ELSE 1 END,
476
+ sys_modified_count = CASE WHEN d.sys_dirty = 1 AND i.sys_dirty = 0 THEN ISNULL(d.sys_modified_count, 0) ELSE ISNULL(d.sys_modified_count, 0) + 1 END
477
+ FROM {table_identifier} AS t
478
+ INNER JOIN inserted AS i ON t.sys_id = i.sys_id
479
+ INNER JOIN deleted AS d ON d.sys_id = i.sys_id;
480
+ END;
481
+ """.strip(),
482
+ ]
483
+ )
484
+
485
+ return "\n".join(statements), tuple()
486
+
487
+ @classmethod
488
+ def ensure_system_columns(cls, name, existing_columns=None, force=False):
489
+ """Ensure SQL Server tables maintain Velocity system metadata."""
490
+ existing_columns = {col.lower() for col in existing_columns or []}
491
+
492
+ if "." in name:
493
+ schema, table_name = name.split(".", 1)
494
+ else:
495
+ schema = cls.default_schema or "dbo"
496
+ table_name = name
497
+
498
+ schema_identifier = quote(schema)
499
+ table_identifier = quote(name if "." in name else f"{schema}.{table_name}")
500
+ object_name = f"[{schema}].[{table_name}]"
501
+ table_name_sql = table_name.replace("'", "''")
502
+ trigger_prefix = re.sub(r"[^0-9A-Za-z_]+", "_", f"CC_SYS_MOD_{table_name}")
503
+
504
+ has_count = "sys_modified_count" in existing_columns
505
+
506
+ add_column = not has_count
507
+ recreate_triggers = force or add_column
508
+
509
+ if not recreate_triggers and not force:
510
+ return None
511
+
512
+ statements = []
513
+
514
+ if add_column:
515
+ statements.append(
516
+ 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;"
517
+ )
518
+
519
+ statements.append(
520
+ f"UPDATE {table_identifier} SET sys_modified_count = 0 WHERE sys_modified_count IS NULL;"
521
+ )
522
+
523
+ statements.append(
524
+ f"IF OBJECT_ID(N'{schema_identifier}.{trigger_prefix}_insert', N'TR') IS NOT NULL DROP TRIGGER {schema_identifier}.{trigger_prefix}_insert;"
525
+ )
526
+ statements.append(
527
+ f"IF OBJECT_ID(N'{schema_identifier}.{trigger_prefix}_update', N'TR') IS NOT NULL DROP TRIGGER {schema_identifier}.{trigger_prefix}_update;"
528
+ )
529
+
530
+ statements.extend(
531
+ [
532
+ f"""
533
+ CREATE TRIGGER {schema_identifier}.{trigger_prefix}_insert
534
+ ON {table_identifier}
535
+ AFTER INSERT
536
+ AS
537
+ BEGIN
538
+ SET NOCOUNT ON;
539
+ UPDATE t
540
+ SET sys_created = ISNULL(i.sys_created, SYSDATETIME()),
541
+ sys_modified = SYSDATETIME(),
542
+ sys_modified_count = 0,
543
+ sys_dirty = ISNULL(i.sys_dirty, 0),
544
+ sys_table = '{table_name_sql}'
545
+ FROM {table_identifier} AS t
546
+ INNER JOIN inserted AS i ON t.sys_id = i.sys_id;
547
+ END;
548
+ """.strip(),
549
+ f"""
550
+ CREATE TRIGGER {schema_identifier}.{trigger_prefix}_update
551
+ ON {table_identifier}
552
+ AFTER UPDATE
553
+ AS
554
+ BEGIN
555
+ SET NOCOUNT ON;
556
+ UPDATE t
557
+ SET sys_created = d.sys_created,
558
+ sys_modified = SYSDATETIME(),
559
+ sys_table = '{table_name_sql}',
560
+ sys_dirty = CASE WHEN d.sys_dirty = 1 AND i.sys_dirty = 0 THEN 0 ELSE 1 END,
561
+ sys_modified_count = CASE WHEN d.sys_dirty = 1 AND i.sys_dirty = 0 THEN ISNULL(d.sys_modified_count, 0) ELSE ISNULL(d.sys_modified_count, 0) + 1 END
562
+ FROM {table_identifier} AS t
563
+ INNER JOIN inserted AS i ON t.sys_id = i.sys_id
564
+ INNER JOIN deleted AS d ON d.sys_id = i.sys_id;
565
+ END;
566
+ """.strip(),
567
+ ]
568
+ )
569
+
570
+ return "\n".join(statements), tuple()
571
+
572
+ @classmethod
573
+ def drop_table(cls, name):
574
+ return f"DROP TABLE {quote(name)}"
575
+
576
+ @classmethod
577
+ def truncate(cls, table):
578
+ return f"TRUNCATE TABLE {quote(table)}"
579
+
580
+ @classmethod
581
+ def columns(cls, name):
582
+ return f"""
583
+ SELECT
584
+ COLUMN_NAME,
585
+ DATA_TYPE,
586
+ IS_NULLABLE,
587
+ COLUMN_DEFAULT,
588
+ CHARACTER_MAXIMUM_LENGTH
589
+ FROM INFORMATION_SCHEMA.COLUMNS
590
+ WHERE TABLE_NAME = '{name}'
591
+ ORDER BY ORDINAL_POSITION
592
+ """
593
+
594
+ @classmethod
595
+ def column_info(cls, table, name):
596
+ return f"""
597
+ SELECT
598
+ COLUMN_NAME,
599
+ DATA_TYPE,
600
+ IS_NULLABLE,
601
+ COLUMN_DEFAULT,
602
+ CHARACTER_MAXIMUM_LENGTH
603
+ FROM INFORMATION_SCHEMA.COLUMNS
604
+ WHERE TABLE_NAME = '{table}' AND COLUMN_NAME = '{name}'
605
+ """
606
+
607
+ @classmethod
608
+ def drop_column(cls, table, name, cascade=True):
609
+ return f"ALTER TABLE {quote(table)} DROP COLUMN {quote(name)}"
610
+
611
+ @classmethod
612
+ def alter_add(cls, table, columns, null_allowed=True):
613
+ alter_parts = []
614
+ for col, col_type in columns.items():
615
+ null_clause = "NULL" if null_allowed else "NOT NULL"
616
+ alter_parts.append(f"ADD {quote(col)} {col_type} {null_clause}")
617
+
618
+ return f"ALTER TABLE {quote(table)} {', '.join(alter_parts)}"
619
+
620
+ @classmethod
621
+ def alter_drop(cls, table, columns):
622
+ drop_parts = [f"DROP COLUMN {quote(col)}" for col in columns]
623
+ return f"ALTER TABLE {quote(table)} {', '.join(drop_parts)}"
624
+
625
+ @classmethod
626
+ def alter_column_by_type(cls, table, column, value, nullable=True):
627
+ null_clause = "NULL" if nullable else "NOT NULL"
628
+ return f"ALTER TABLE {quote(table)} ALTER COLUMN {quote(column)} {value} {null_clause}"
629
+
630
+ @classmethod
631
+ def alter_column_by_sql(cls, table, column, value):
632
+ return f"ALTER TABLE {quote(table)} ALTER COLUMN {quote(column)} {value}"
633
+
634
+ @classmethod
635
+ def rename_column(cls, table, orig, new):
636
+ return f"EXEC sp_rename '{table}.{orig}', '{new}', 'COLUMN'"
637
+
638
+ @classmethod
639
+ def rename_table(cls, table, new):
640
+ return f"EXEC sp_rename '{table}', '{new}'"
641
+
642
+ @classmethod
643
+ def primary_keys(cls, table):
644
+ return f"""
645
+ SELECT COLUMN_NAME
646
+ FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
647
+ WHERE OBJECTPROPERTY(OBJECT_ID(CONSTRAINT_SCHEMA + '.' + CONSTRAINT_NAME), 'IsPrimaryKey') = 1
648
+ AND TABLE_NAME = '{table}'
649
+ """
650
+
651
+ @classmethod
652
+ def foreign_key_info(cls, table=None, column=None, schema=None):
653
+ sql = """
654
+ SELECT
655
+ FK.TABLE_NAME,
656
+ CU.COLUMN_NAME,
657
+ PK.TABLE_NAME AS REFERENCED_TABLE_NAME,
658
+ PT.COLUMN_NAME AS REFERENCED_COLUMN_NAME,
659
+ C.CONSTRAINT_NAME
660
+ FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS C
661
+ INNER JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS FK ON C.CONSTRAINT_NAME = FK.CONSTRAINT_NAME
662
+ INNER JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS PK ON C.UNIQUE_CONSTRAINT_NAME = PK.CONSTRAINT_NAME
663
+ INNER JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE CU ON C.CONSTRAINT_NAME = CU.CONSTRAINT_NAME
664
+ INNER JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE PT ON PK.CONSTRAINT_NAME = PT.CONSTRAINT_NAME
665
+ """
666
+ if table:
667
+ sql += f" WHERE FK.TABLE_NAME = '{table}'"
668
+ if column:
669
+ conjunction = " AND" if table else " WHERE"
670
+ sql += f"{conjunction} CU.COLUMN_NAME = '{column}'"
671
+ return sql
672
+
673
+ @classmethod
674
+ def create_foreign_key(cls, table, columns, key_to_table, key_to_columns, name=None, schema=None):
675
+ if name is None:
676
+ name = f"FK_{table}_{'_'.join(columns)}"
677
+
678
+ col_list = ", ".join(quote(col) for col in columns)
679
+ ref_col_list = ", ".join(quote(col) for col in key_to_columns)
680
+
681
+ return f"""
682
+ ALTER TABLE {quote(table)}
683
+ ADD CONSTRAINT {quote(name)}
684
+ FOREIGN KEY ({col_list})
685
+ REFERENCES {quote(key_to_table)} ({ref_col_list})
686
+ """
687
+
688
+ @classmethod
689
+ def drop_foreign_key(cls, table, columns, key_to_table=None, key_to_columns=None, name=None, schema=None):
690
+ if name is None:
691
+ name = f"FK_{table}_{'_'.join(columns)}"
692
+
693
+ return f"ALTER TABLE {quote(table)} DROP CONSTRAINT {quote(name)}"
694
+
695
+ @classmethod
696
+ def create_index(cls, tx, table=None, columns=None, unique=False, direction=None, where=None, name=None, schema=None, trigram=None, lower=None):
697
+ if name is None:
698
+ name = f"IX_{table}_{'_'.join(columns)}"
699
+
700
+ index_type = "UNIQUE INDEX" if unique else "INDEX"
701
+ col_list = ", ".join(quote(col) for col in columns)
702
+
703
+ sql = f"CREATE {index_type} {quote(name)} ON {quote(table)} ({col_list})"
704
+
705
+ if where:
706
+ sql += f" WHERE {where}"
707
+
708
+ return sql
709
+
710
+ @classmethod
711
+ def drop_index(cls, table=None, columns=None, name=None, schema=None, trigram=None):
712
+ if name is None:
713
+ name = f"IX_{table}_{'_'.join(columns)}"
714
+
715
+ return f"DROP INDEX {quote(name)} ON {quote(table)}"
716
+
717
+ @classmethod
718
+ def indexes(cls, table):
719
+ return f"""
720
+ SELECT
721
+ i.name AS index_name,
722
+ c.name AS column_name,
723
+ i.is_unique
724
+ FROM sys.indexes i
725
+ INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id
726
+ INNER JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id
727
+ WHERE i.object_id = OBJECT_ID('{table}')
728
+ ORDER BY i.name, ic.key_ordinal
729
+ """
730
+
731
+ @classmethod
732
+ def create_savepoint(cls, sp):
733
+ return f"SAVE TRANSACTION {sp}"
734
+
735
+ @classmethod
736
+ def release_savepoint(cls, sp):
737
+ return f"-- SQL Server doesn't support RELEASE SAVEPOINT {sp}"
738
+
739
+ @classmethod
740
+ def rollback_savepoint(cls, sp):
741
+ return f"ROLLBACK TRANSACTION {sp}"
742
+
743
+ @classmethod
744
+ def create_view(cls, name, query, temp=False, silent=True):
745
+ # SQL Server doesn't support temporary views in the same way
746
+ return f"CREATE VIEW {quote(name)} AS {query}"
747
+
748
+ @classmethod
749
+ def drop_view(cls, name, silent=True):
750
+ if silent:
751
+ return f"DROP VIEW IF EXISTS {quote(name)}"
752
+ else:
753
+ return f"DROP VIEW {quote(name)}"
754
+
755
+ @classmethod
756
+ def last_id(cls, table):
757
+ return "SELECT @@IDENTITY"
758
+
759
+ @classmethod
760
+ def current_id(cls, table):
761
+ return f"SELECT IDENT_CURRENT('{table}')"
762
+
763
+ @classmethod
764
+ def set_id(cls, table, start):
765
+ return f"DBCC CHECKIDENT('{table}', RESEED, {start})"
766
+
767
+ @classmethod
768
+ def set_sequence(cls, table, next_value):
769
+ return f"DBCC CHECKIDENT('{table}', RESEED, {next_value})"
770
+
771
+ @classmethod
772
+ def massage_data(cls, data):
773
+ """Massage data before insert/update operations."""
774
+ # SQL Server-specific data transformations
775
+ return data
776
+
777
+ @classmethod
778
+ def alter_trigger(cls, table, state="ENABLE", name="USER"):
779
+ state_cmd = "ENABLE" if state.upper() == "ENABLE" else "DISABLE"
780
+ return f"ALTER TABLE {quote(table)} {state_cmd} TRIGGER ALL"
781
+
782
+ @classmethod
783
+ def missing(cls, tx, table, list_values, column="SYS_ID", where=None):
784
+ """Generate query to find missing values from a list."""
785
+ # SQL Server version using VALUES clause
786
+ value_rows = ", ".join([f"(?)" for _ in list_values])
787
+
788
+ sql = f"""
789
+ SELECT value_column FROM (
790
+ VALUES {value_rows}
791
+ ) AS input_values(value_column)
792
+ WHERE value_column NOT IN (
793
+ SELECT {quote(column)} FROM {quote(table)}
794
+ """
795
+
796
+ vals = list_values
797
+
798
+ if where:
799
+ where_sql, where_vals = cls._build_where(where)
800
+ sql += f" WHERE {where_sql}"
801
+ vals.extend(where_vals)
802
+
803
+ sql += ")"
804
+
805
+ return sql, vals