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,718 @@
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, MySQLOperators
13
+ from ..tablehelper import TableHelper
14
+
15
+
16
+ # Configure TableHelper for MySQL
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 MySQL 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 = "MySQL"
54
+ type_column_identifier = "DATA_TYPE"
55
+ is_nullable = "IS_NULLABLE"
56
+
57
+ default_schema = ""
58
+
59
+ ApplicationErrorCodes = []
60
+ DatabaseMissingErrorCodes = ["1049"] # ER_BAD_DB_ERROR
61
+ TableMissingErrorCodes = ["1146"] # ER_NO_SUCH_TABLE
62
+ ColumnMissingErrorCodes = ["1054"] # ER_BAD_FIELD_ERROR
63
+ ForeignKeyMissingErrorCodes = ["1005"] # ER_CANT_CREATE_TABLE
64
+ ConnectionErrorCodes = ["2002", "2003", "2006"] # Connection errors
65
+ DuplicateKeyErrorCodes = ["1062"] # ER_DUP_ENTRY
66
+ RetryTransactionCodes = ["1213"] # ER_LOCK_DEADLOCK
67
+ TruncationErrorCodes = ["1406"] # ER_DATA_TOO_LONG
68
+ LockTimeoutErrorCodes = ["1205"] # ER_LOCK_WAIT_TIMEOUT
69
+ DatabaseObjectExistsErrorCodes = ["1050"] # ER_TABLE_EXISTS_ERROR
70
+ DataIntegrityErrorCodes = ["1452", "1048", "1364"] # Foreign key, null, no default
71
+
72
+ types = TYPES
73
+
74
+ @classmethod
75
+ def get_error(cls, e):
76
+ """Extract error information from MySQL exception."""
77
+ error_code = getattr(e, "errno", None)
78
+ error_msg = getattr(e, "msg", None)
79
+ return error_code, error_msg
80
+
81
+ @classmethod
82
+ def select(
83
+ cls,
84
+ tx,
85
+ columns=None,
86
+ table=None,
87
+ where=None,
88
+ orderby=None,
89
+ groupby=None,
90
+ having=None,
91
+ start=None,
92
+ qty=None,
93
+ lock=None,
94
+ skip_locked=None,
95
+ ):
96
+ """Generate a MySQL SELECT statement."""
97
+ if not table:
98
+ raise ValueError("Table name is required")
99
+
100
+ sql_parts = []
101
+ vals = []
102
+
103
+ # SELECT clause
104
+ if columns is None:
105
+ columns = ["*"]
106
+ elif isinstance(columns, str):
107
+ columns = [columns]
108
+
109
+ sql_parts.append("SELECT")
110
+ sql_parts.append(", ".join(columns))
111
+
112
+ # FROM clause
113
+ sql_parts.append("FROM")
114
+ sql_parts.append(quote(table))
115
+
116
+ # WHERE clause
117
+ if where:
118
+ where_sql, where_vals = cls._build_where(where)
119
+ sql_parts.append("WHERE")
120
+ sql_parts.append(where_sql)
121
+ vals.extend(where_vals)
122
+
123
+ # GROUP BY clause
124
+ if groupby:
125
+ if isinstance(groupby, str):
126
+ groupby = [groupby]
127
+ sql_parts.append("GROUP BY")
128
+ sql_parts.append(", ".join(quote(col) for col in groupby))
129
+
130
+ # HAVING clause
131
+ if having:
132
+ having_sql, having_vals = cls._build_where(having)
133
+ sql_parts.append("HAVING")
134
+ sql_parts.append(having_sql)
135
+ vals.extend(having_vals)
136
+
137
+ # ORDER BY clause
138
+ if orderby:
139
+ if isinstance(orderby, str):
140
+ orderby = [orderby]
141
+ elif isinstance(orderby, dict):
142
+ orderby_list = []
143
+ for col, direction in orderby.items():
144
+ orderby_list.append(f"{quote(col)} {direction.upper()}")
145
+ orderby = orderby_list
146
+ sql_parts.append("ORDER BY")
147
+ sql_parts.append(", ".join(orderby))
148
+
149
+ # LIMIT clause (MySQL uses LIMIT instead of OFFSET/FETCH)
150
+ if start is not None and qty is not None:
151
+ sql_parts.append(f"LIMIT {start}, {qty}")
152
+ elif qty is not None:
153
+ sql_parts.append(f"LIMIT {qty}")
154
+
155
+ # FOR UPDATE (lock)
156
+ if lock:
157
+ sql_parts.append("FOR UPDATE")
158
+ if skip_locked:
159
+ sql_parts.append("SKIP LOCKED")
160
+
161
+ return " ".join(sql_parts), vals
162
+
163
+ @classmethod
164
+ def _build_where(cls, where):
165
+ """Build WHERE clause for MySQL."""
166
+ if isinstance(where, str):
167
+ return where, []
168
+
169
+ if isinstance(where, dict):
170
+ where = list(where.items())
171
+
172
+ if not isinstance(where, (list, tuple)):
173
+ raise ValueError("WHERE clause must be string, dict, or list")
174
+
175
+ conditions = []
176
+ vals = []
177
+
178
+ for key, val in where:
179
+ if val is None:
180
+ if "!" in key:
181
+ key = key.replace("!", "")
182
+ conditions.append(f"{quote(key)} IS NOT NULL")
183
+ else:
184
+ conditions.append(f"{quote(key)} IS NULL")
185
+ elif isinstance(val, (list, tuple)):
186
+ if "!" in key:
187
+ key = key.replace("!", "")
188
+ conditions.append(f"{quote(key)} NOT IN ({', '.join(['%s'] * len(val))})")
189
+ else:
190
+ conditions.append(f"{quote(key)} IN ({', '.join(['%s'] * len(val))})")
191
+ vals.extend(val)
192
+ else:
193
+ # Handle operators
194
+ op = "="
195
+ if "<>" in key:
196
+ key = key.replace("<>", "")
197
+ op = "<>"
198
+ elif "!=" in key:
199
+ key = key.replace("!=", "")
200
+ op = "<>"
201
+ elif "%%" in key:
202
+ key = key.replace("%%", "")
203
+ op = "LIKE"
204
+ elif "%" in key:
205
+ key = key.replace("%", "")
206
+ op = "LIKE"
207
+ elif "!" in key:
208
+ key = key.replace("!", "")
209
+ op = "<>"
210
+
211
+ conditions.append(f"{quote(key)} {op} %s")
212
+ vals.append(val)
213
+
214
+ return " AND ".join(conditions), vals
215
+
216
+ @classmethod
217
+ def insert(cls, table, data):
218
+ """Generate an INSERT statement for MySQL."""
219
+ if not data:
220
+ raise ValueError("Data cannot be empty")
221
+
222
+ columns = list(data.keys())
223
+ values = list(data.values())
224
+
225
+ sql_parts = [
226
+ "INSERT INTO",
227
+ quote(table),
228
+ f"({', '.join(quote(col) for col in columns)})",
229
+ "VALUES",
230
+ f"({', '.join(['%s'] * len(values))})"
231
+ ]
232
+
233
+ return " ".join(sql_parts), values
234
+
235
+ @classmethod
236
+ def update(cls, tx, table, data, where=None, pk=None, excluded=False):
237
+ """Generate an UPDATE statement for MySQL."""
238
+ if not data:
239
+ raise ValueError("Data cannot be empty")
240
+
241
+ if not where and not pk:
242
+ raise ValueError("Either WHERE clause or primary key must be provided")
243
+
244
+ # Build SET clause
245
+ set_clauses = []
246
+ vals = []
247
+
248
+ for col, val in data.items():
249
+ if excluded:
250
+ # For ON DUPLICATE KEY UPDATE
251
+ set_clauses.append(f"{quote(col)} = VALUES({quote(col)})")
252
+ else:
253
+ set_clauses.append(f"{quote(col)} = %s")
254
+ vals.append(val)
255
+
256
+ # Build WHERE clause
257
+ if pk:
258
+ if where:
259
+ # Merge pk into where
260
+ if isinstance(where, dict):
261
+ where.update(pk)
262
+ else:
263
+ # Convert to dict for merging
264
+ where_dict = dict(where) if isinstance(where, (list, tuple)) else {}
265
+ where_dict.update(pk)
266
+ where = where_dict
267
+ else:
268
+ where = pk
269
+
270
+ where_sql, where_vals = cls._build_where(where) if where else ("", [])
271
+
272
+ sql_parts = [
273
+ "UPDATE",
274
+ quote(table),
275
+ "SET",
276
+ ", ".join(set_clauses)
277
+ ]
278
+
279
+ if where_sql:
280
+ sql_parts.extend(["WHERE", where_sql])
281
+ vals.extend(where_vals)
282
+
283
+ return " ".join(sql_parts), vals
284
+
285
+ @classmethod
286
+ def delete(cls, tx, table, where):
287
+ """Generate a DELETE statement for MySQL."""
288
+ if not where:
289
+ raise ValueError("WHERE clause is required for DELETE")
290
+
291
+ where_sql, where_vals = cls._build_where(where)
292
+
293
+ sql_parts = [
294
+ "DELETE FROM",
295
+ quote(table),
296
+ "WHERE",
297
+ where_sql
298
+ ]
299
+
300
+ return " ".join(sql_parts), where_vals
301
+
302
+ @classmethod
303
+ def merge(cls, tx, table, data, pk, on_conflict_do_nothing, on_conflict_update):
304
+ """Generate an INSERT ... ON DUPLICATE KEY UPDATE statement for MySQL."""
305
+ # First, create the INSERT part
306
+ insert_sql, insert_vals = cls.insert(table, data)
307
+
308
+ if on_conflict_do_nothing:
309
+ # MySQL: INSERT IGNORE
310
+ insert_sql = insert_sql.replace("INSERT INTO", "INSERT IGNORE INTO")
311
+ return insert_sql, insert_vals
312
+ elif on_conflict_update:
313
+ # MySQL: INSERT ... ON DUPLICATE KEY UPDATE
314
+ update_clauses = []
315
+ for col in data.keys():
316
+ if col not in pk: # Don't update primary key columns
317
+ update_clauses.append(f"{quote(col)} = VALUES({quote(col)})")
318
+
319
+ if update_clauses:
320
+ insert_sql += f" ON DUPLICATE KEY UPDATE {', '.join(update_clauses)}"
321
+
322
+ return insert_sql, insert_vals
323
+ else:
324
+ return insert_sql, insert_vals
325
+
326
+ # Metadata queries
327
+ @classmethod
328
+ def version(cls):
329
+ return "SELECT VERSION()"
330
+
331
+ @classmethod
332
+ def timestamp(cls):
333
+ return "SELECT NOW()"
334
+
335
+ @classmethod
336
+ def user(cls):
337
+ return "SELECT USER()"
338
+
339
+ @classmethod
340
+ def databases(cls):
341
+ return "SHOW DATABASES"
342
+
343
+ @classmethod
344
+ def schemas(cls):
345
+ return "SHOW DATABASES" # MySQL databases are schemas
346
+
347
+ @classmethod
348
+ def current_schema(cls):
349
+ return "SELECT DATABASE()"
350
+
351
+ @classmethod
352
+ def current_database(cls):
353
+ return "SELECT DATABASE()"
354
+
355
+ @classmethod
356
+ def tables(cls, system=False):
357
+ if system:
358
+ return "SHOW TABLES"
359
+ else:
360
+ return "SHOW TABLES"
361
+
362
+ @classmethod
363
+ def views(cls, system=False):
364
+ return "SHOW FULL TABLES WHERE Table_type = 'VIEW'"
365
+
366
+ @classmethod
367
+ def create_database(cls, name):
368
+ return f"CREATE DATABASE {quote(name)}"
369
+
370
+ @classmethod
371
+ def drop_database(cls, name):
372
+ return f"DROP DATABASE {quote(name)}"
373
+
374
+ @classmethod
375
+ def create_table(cls, name, columns=None, drop=False):
376
+ if not name or not isinstance(name, str):
377
+ raise ValueError("Table name must be a non-empty string")
378
+
379
+ columns = columns or {}
380
+ table_identifier = quote(name)
381
+ base_name = name.split(".")[-1].replace("`", "")
382
+ base_name_sql = base_name.replace("'", "''")
383
+ trigger_prefix = re.sub(r"[^0-9A-Za-z_]+", "_", f"cc_sysmod_{base_name}")
384
+
385
+ statements = []
386
+ if drop:
387
+ statements.append(f"DROP TABLE IF EXISTS {table_identifier};")
388
+
389
+ statements.append(
390
+ f"""
391
+ CREATE TABLE {table_identifier} (
392
+ `sys_id` BIGINT NOT NULL AUTO_INCREMENT,
393
+ `sys_table` TEXT,
394
+ `sys_created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
395
+ `sys_modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
396
+ `sys_modified_by` TEXT,
397
+ `sys_modified_count` INT NOT NULL DEFAULT 0,
398
+ `sys_dirty` TINYINT(1) NOT NULL DEFAULT 0,
399
+ `description` TEXT,
400
+ PRIMARY KEY (`sys_id`)
401
+ ) ENGINE=InnoDB;
402
+ """.strip()
403
+ )
404
+
405
+ for key, val in columns.items():
406
+ clean_key = re.sub("<>!=%", "", key)
407
+ if clean_key in system_fields:
408
+ continue
409
+ col_type = TYPES.get_type(val)
410
+ statements.append(
411
+ f"ALTER TABLE {table_identifier} ADD COLUMN {quote(clean_key)} {col_type};"
412
+ )
413
+
414
+ statements.extend(
415
+ [
416
+ f"DROP TRIGGER IF EXISTS {trigger_prefix}_bi;",
417
+ f"DROP TRIGGER IF EXISTS {trigger_prefix}_bu;",
418
+ f"""
419
+ CREATE TRIGGER {trigger_prefix}_bi
420
+ BEFORE INSERT ON {table_identifier}
421
+ FOR EACH ROW
422
+ BEGIN
423
+ SET NEW.sys_created = COALESCE(NEW.sys_created, NOW());
424
+ SET NEW.sys_modified = NOW();
425
+ SET NEW.sys_modified_count = 0;
426
+ SET NEW.sys_dirty = IFNULL(NEW.sys_dirty, 0);
427
+ SET NEW.sys_table = '{base_name_sql}';
428
+ END;
429
+ """.strip(),
430
+ f"""
431
+ CREATE TRIGGER {trigger_prefix}_bu
432
+ BEFORE UPDATE ON {table_identifier}
433
+ FOR EACH ROW
434
+ BEGIN
435
+ IF OLD.sys_dirty = TRUE AND NEW.sys_dirty = FALSE THEN
436
+ SET NEW.sys_dirty = 0;
437
+ SET NEW.sys_modified_count = IFNULL(OLD.sys_modified_count, 0);
438
+ ELSE
439
+ SET NEW.sys_dirty = 1;
440
+ SET NEW.sys_modified_count = IFNULL(OLD.sys_modified_count, 0) + 1;
441
+ END IF;
442
+ SET NEW.sys_created = OLD.sys_created;
443
+ SET NEW.sys_modified = NOW();
444
+ SET NEW.sys_table = '{base_name_sql}';
445
+ END;
446
+ """.strip(),
447
+ ]
448
+ )
449
+
450
+ return "\n".join(statements), tuple()
451
+
452
+ @classmethod
453
+ def ensure_system_columns(cls, name, existing_columns=None, force=False):
454
+ """Ensure MySQL tables maintain the Velocity system metadata."""
455
+ existing_columns = {col.lower() for col in existing_columns or []}
456
+
457
+ table_identifier = quote(name)
458
+ base_name = name.split(".")[-1].replace("`", "")
459
+ base_name_sql = base_name.replace("'", "''")
460
+ trigger_prefix = re.sub(r"[^0-9A-Za-z_]+", "_", f"cc_sysmod_{base_name}")
461
+
462
+ has_count = "sys_modified_count" in existing_columns
463
+
464
+ add_column = not has_count
465
+ recreate_triggers = force or add_column
466
+
467
+ if not recreate_triggers and not force:
468
+ return None
469
+
470
+ statements = []
471
+
472
+ if add_column:
473
+ statements.append(
474
+ f"ALTER TABLE {table_identifier} ADD COLUMN IF NOT EXISTS `sys_modified_count` INT NOT NULL DEFAULT 0;"
475
+ )
476
+
477
+ statements.append(
478
+ f"UPDATE {table_identifier} SET `sys_modified_count` = 0 WHERE `sys_modified_count` IS NULL;"
479
+ )
480
+
481
+ statements.append(f"DROP TRIGGER IF EXISTS {trigger_prefix}_bi;")
482
+ statements.append(f"DROP TRIGGER IF EXISTS {trigger_prefix}_bu;")
483
+
484
+ statements.extend(
485
+ [
486
+ f"""
487
+ CREATE TRIGGER {trigger_prefix}_bi
488
+ BEFORE INSERT ON {table_identifier}
489
+ FOR EACH ROW
490
+ BEGIN
491
+ SET NEW.sys_created = COALESCE(NEW.sys_created, NOW());
492
+ SET NEW.sys_modified = NOW();
493
+ SET NEW.sys_modified_count = 0;
494
+ SET NEW.sys_dirty = IFNULL(NEW.sys_dirty, 0);
495
+ SET NEW.sys_table = '{base_name_sql}';
496
+ END;
497
+ """.strip(),
498
+ f"""
499
+ CREATE TRIGGER {trigger_prefix}_bu
500
+ BEFORE UPDATE ON {table_identifier}
501
+ FOR EACH ROW
502
+ BEGIN
503
+ IF OLD.sys_dirty = TRUE AND NEW.sys_dirty = FALSE THEN
504
+ SET NEW.sys_dirty = 0;
505
+ SET NEW.sys_modified_count = IFNULL(OLD.sys_modified_count, 0);
506
+ ELSE
507
+ SET NEW.sys_dirty = 1;
508
+ SET NEW.sys_modified_count = IFNULL(OLD.sys_modified_count, 0) + 1;
509
+ END IF;
510
+ SET NEW.sys_created = OLD.sys_created;
511
+ SET NEW.sys_modified = NOW();
512
+ SET NEW.sys_table = '{base_name_sql}';
513
+ END;
514
+ """.strip(),
515
+ ]
516
+ )
517
+
518
+ return "\n".join(statements), tuple()
519
+
520
+ @classmethod
521
+ def drop_table(cls, name):
522
+ return f"DROP TABLE {quote(name)}"
523
+
524
+ @classmethod
525
+ def truncate(cls, table):
526
+ return f"TRUNCATE TABLE {quote(table)}"
527
+
528
+ @classmethod
529
+ def columns(cls, name):
530
+ return f"SHOW COLUMNS FROM {quote(name)}"
531
+
532
+ @classmethod
533
+ def column_info(cls, table, name):
534
+ return f"SHOW COLUMNS FROM {quote(table)} LIKE '{name}'"
535
+
536
+ @classmethod
537
+ def drop_column(cls, table, name, cascade=True):
538
+ return f"ALTER TABLE {quote(table)} DROP COLUMN {quote(name)}"
539
+
540
+ @classmethod
541
+ def alter_add(cls, table, columns, null_allowed=True):
542
+ alter_parts = []
543
+ for col, col_type in columns.items():
544
+ null_clause = "NULL" if null_allowed else "NOT NULL"
545
+ alter_parts.append(f"ADD COLUMN {quote(col)} {col_type} {null_clause}")
546
+
547
+ return f"ALTER TABLE {quote(table)} {', '.join(alter_parts)}"
548
+
549
+ @classmethod
550
+ def alter_drop(cls, table, columns):
551
+ drop_parts = [f"DROP COLUMN {quote(col)}" for col in columns]
552
+ return f"ALTER TABLE {quote(table)} {', '.join(drop_parts)}"
553
+
554
+ @classmethod
555
+ def alter_column_by_type(cls, table, column, value, nullable=True):
556
+ null_clause = "NULL" if nullable else "NOT NULL"
557
+ return f"ALTER TABLE {quote(table)} MODIFY COLUMN {quote(column)} {value} {null_clause}"
558
+
559
+ @classmethod
560
+ def alter_column_by_sql(cls, table, column, value):
561
+ return f"ALTER TABLE {quote(table)} MODIFY COLUMN {quote(column)} {value}"
562
+
563
+ @classmethod
564
+ def rename_column(cls, table, orig, new):
565
+ # MySQL requires the full column definition for CHANGE
566
+ return f"ALTER TABLE {quote(table)} CHANGE {quote(orig)} {quote(new)} /* TYPE_NEEDED */"
567
+
568
+ @classmethod
569
+ def rename_table(cls, table, new):
570
+ return f"RENAME TABLE {quote(table)} TO {quote(new)}"
571
+
572
+ @classmethod
573
+ def primary_keys(cls, table):
574
+ return f"SHOW KEYS FROM {quote(table)} WHERE Key_name = 'PRIMARY'"
575
+
576
+ @classmethod
577
+ def foreign_key_info(cls, table=None, column=None, schema=None):
578
+ sql = """
579
+ SELECT
580
+ TABLE_NAME,
581
+ COLUMN_NAME,
582
+ CONSTRAINT_NAME,
583
+ REFERENCED_TABLE_NAME,
584
+ REFERENCED_COLUMN_NAME
585
+ FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
586
+ WHERE REFERENCED_TABLE_NAME IS NOT NULL
587
+ """
588
+ if table:
589
+ sql += f" AND TABLE_NAME = '{table}'"
590
+ if column:
591
+ sql += f" AND COLUMN_NAME = '{column}'"
592
+ return sql
593
+
594
+ @classmethod
595
+ def create_foreign_key(cls, table, columns, key_to_table, key_to_columns, name=None, schema=None):
596
+ if name is None:
597
+ name = f"fk_{table}_{'_'.join(columns)}"
598
+
599
+ col_list = ", ".join(quote(col) for col in columns)
600
+ ref_col_list = ", ".join(quote(col) for col in key_to_columns)
601
+
602
+ return f"""
603
+ ALTER TABLE {quote(table)}
604
+ ADD CONSTRAINT {quote(name)}
605
+ FOREIGN KEY ({col_list})
606
+ REFERENCES {quote(key_to_table)} ({ref_col_list})
607
+ """
608
+
609
+ @classmethod
610
+ def drop_foreign_key(cls, table, columns, key_to_table=None, key_to_columns=None, name=None, schema=None):
611
+ if name is None:
612
+ name = f"fk_{table}_{'_'.join(columns)}"
613
+
614
+ return f"ALTER TABLE {quote(table)} DROP FOREIGN KEY {quote(name)}"
615
+
616
+ @classmethod
617
+ def create_index(cls, tx, table=None, columns=None, unique=False, direction=None, where=None, name=None, schema=None, trigram=None, lower=None):
618
+ if name is None:
619
+ name = f"idx_{table}_{'_'.join(columns)}"
620
+
621
+ index_type = "UNIQUE INDEX" if unique else "INDEX"
622
+ col_list = ", ".join(quote(col) for col in columns)
623
+
624
+ return f"CREATE {index_type} {quote(name)} ON {quote(table)} ({col_list})"
625
+
626
+ @classmethod
627
+ def drop_index(cls, table=None, columns=None, name=None, schema=None, trigram=None):
628
+ if name is None:
629
+ name = f"idx_{table}_{'_'.join(columns)}"
630
+
631
+ return f"DROP INDEX {quote(name)} ON {quote(table)}"
632
+
633
+ @classmethod
634
+ def indexes(cls, table):
635
+ return f"SHOW INDEX FROM {quote(table)}"
636
+
637
+ @classmethod
638
+ def create_savepoint(cls, sp):
639
+ return f"SAVEPOINT {sp}"
640
+
641
+ @classmethod
642
+ def release_savepoint(cls, sp):
643
+ return f"RELEASE SAVEPOINT {sp}"
644
+
645
+ @classmethod
646
+ def rollback_savepoint(cls, sp):
647
+ return f"ROLLBACK TO SAVEPOINT {sp}"
648
+
649
+ @classmethod
650
+ def create_view(cls, name, query, temp=False, silent=True):
651
+ if temp:
652
+ # MySQL doesn't support temporary views
653
+ temp_clause = ""
654
+ else:
655
+ temp_clause = ""
656
+
657
+ if silent:
658
+ return f"CREATE OR REPLACE VIEW {quote(name)} AS {query}"
659
+ else:
660
+ return f"CREATE VIEW {quote(name)} AS {query}"
661
+
662
+ @classmethod
663
+ def drop_view(cls, name, silent=True):
664
+ if silent:
665
+ return f"DROP VIEW IF EXISTS {quote(name)}"
666
+ else:
667
+ return f"DROP VIEW {quote(name)}"
668
+
669
+ @classmethod
670
+ def last_id(cls, table):
671
+ return "SELECT LAST_INSERT_ID()"
672
+
673
+ @classmethod
674
+ def current_id(cls, table):
675
+ return f"SELECT AUTO_INCREMENT FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = '{table}'"
676
+
677
+ @classmethod
678
+ def set_id(cls, table, start):
679
+ return f"ALTER TABLE {quote(table)} AUTO_INCREMENT = {start}"
680
+
681
+ @classmethod
682
+ def set_sequence(cls, table, next_value):
683
+ return f"ALTER TABLE {quote(table)} AUTO_INCREMENT = {next_value}"
684
+
685
+ @classmethod
686
+ def massage_data(cls, data):
687
+ """Massage data before insert/update operations."""
688
+ # MySQL-specific data transformations
689
+ return data
690
+
691
+ @classmethod
692
+ def alter_trigger(cls, table, state="ENABLE", name="USER"):
693
+ # MySQL has different trigger syntax
694
+ return f"-- MySQL trigger management for {table}"
695
+
696
+ @classmethod
697
+ def missing(cls, tx, table, list_values, column="SYS_ID", where=None):
698
+ """Generate query to find missing values from a list."""
699
+ placeholders = ", ".join(["%s"] * len(list_values))
700
+ sql = f"""
701
+ SELECT missing_val FROM (
702
+ SELECT %s AS missing_val
703
+ {f"UNION ALL SELECT %s " * (len(list_values) - 1) if len(list_values) > 1 else ""}
704
+ ) AS vals
705
+ WHERE missing_val NOT IN (
706
+ SELECT {quote(column)} FROM {quote(table)}
707
+ """
708
+
709
+ vals = list_values + list_values # Values appear twice in this query structure
710
+
711
+ if where:
712
+ where_sql, where_vals = cls._build_where(where)
713
+ sql += f" WHERE {where_sql}"
714
+ vals.extend(where_vals)
715
+
716
+ sql += ")"
717
+
718
+ return sql, vals