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,677 @@
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, SQLiteOperators
13
+ from ..tablehelper import TableHelper
14
+
15
+
16
+ # Configure TableHelper for SQLite
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 SQLite 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 = "SQLite3"
54
+ type_column_identifier = "type"
55
+ is_nullable = "notnull"
56
+
57
+ default_schema = ""
58
+
59
+ # SQLite error codes (numeric)
60
+ ApplicationErrorCodes = []
61
+ DatabaseMissingErrorCodes = [] # SQLite creates databases on demand
62
+ TableMissingErrorCodes = [] # Detected by error message
63
+ ColumnMissingErrorCodes = [] # Detected by error message
64
+ ForeignKeyMissingErrorCodes = []
65
+ ConnectionErrorCodes = []
66
+ DuplicateKeyErrorCodes = [] # Detected by error message
67
+ RetryTransactionCodes = [] # SQLITE_BUSY
68
+ TruncationErrorCodes = []
69
+ LockTimeoutErrorCodes = [] # SQLITE_BUSY
70
+ DatabaseObjectExistsErrorCodes = []
71
+ DataIntegrityErrorCodes = []
72
+
73
+ types = TYPES
74
+
75
+ @classmethod
76
+ def get_error(cls, e):
77
+ """Extract error information from SQLite exception."""
78
+ # SQLite exceptions don't have error codes like other databases
79
+ return None, str(e)
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 SQLite 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 and OFFSET (SQLite syntax)
150
+ if qty is not None:
151
+ sql_parts.append(f"LIMIT {qty}")
152
+ if start is not None:
153
+ sql_parts.append(f"OFFSET {start}")
154
+
155
+ # Note: SQLite doesn't support row-level locking like FOR UPDATE
156
+ if lock:
157
+ pass # Ignored for SQLite
158
+
159
+ return " ".join(sql_parts), vals
160
+
161
+ @classmethod
162
+ def _build_where(cls, where):
163
+ """Build WHERE clause for SQLite."""
164
+ if isinstance(where, str):
165
+ return where, []
166
+
167
+ if isinstance(where, dict):
168
+ where = list(where.items())
169
+
170
+ if not isinstance(where, (list, tuple)):
171
+ raise ValueError("WHERE clause must be string, dict, or list")
172
+
173
+ conditions = []
174
+ vals = []
175
+
176
+ for key, val in where:
177
+ if val is None:
178
+ if "!" in key:
179
+ key = key.replace("!", "")
180
+ conditions.append(f"{quote(key)} IS NOT NULL")
181
+ else:
182
+ conditions.append(f"{quote(key)} IS NULL")
183
+ elif isinstance(val, (list, tuple)):
184
+ if "!" in key:
185
+ key = key.replace("!", "")
186
+ conditions.append(f"{quote(key)} NOT IN ({', '.join(['?'] * len(val))})")
187
+ else:
188
+ conditions.append(f"{quote(key)} IN ({', '.join(['?'] * len(val))})")
189
+ vals.extend(val)
190
+ else:
191
+ # Handle operators
192
+ op = "="
193
+ if "<>" in key:
194
+ key = key.replace("<>", "")
195
+ op = "<>"
196
+ elif "!=" in key:
197
+ key = key.replace("!=", "")
198
+ op = "<>"
199
+ elif "%" in key:
200
+ key = key.replace("%", "")
201
+ op = "LIKE"
202
+ elif "!" in key:
203
+ key = key.replace("!", "")
204
+ op = "<>"
205
+
206
+ conditions.append(f"{quote(key)} {op} ?")
207
+ vals.append(val)
208
+
209
+ return " AND ".join(conditions), vals
210
+
211
+ @classmethod
212
+ def insert(cls, table, data):
213
+ """Generate an INSERT statement for SQLite."""
214
+ if not data:
215
+ raise ValueError("Data cannot be empty")
216
+
217
+ columns = list(data.keys())
218
+ values = list(data.values())
219
+
220
+ sql_parts = [
221
+ "INSERT INTO",
222
+ quote(table),
223
+ f"({', '.join(quote(col) for col in columns)})",
224
+ "VALUES",
225
+ f"({', '.join(['?'] * len(values))})" # SQLite uses ? placeholders
226
+ ]
227
+
228
+ return " ".join(sql_parts), values
229
+
230
+ @classmethod
231
+ def update(cls, tx, table, data, where=None, pk=None, excluded=False):
232
+ """Generate an UPDATE statement for SQLite."""
233
+ if not data:
234
+ raise ValueError("Data cannot be empty")
235
+
236
+ if not where and not pk:
237
+ raise ValueError("Either WHERE clause or primary key must be provided")
238
+
239
+ # Build SET clause
240
+ set_clauses = []
241
+ vals = []
242
+
243
+ for col, val in data.items():
244
+ set_clauses.append(f"{quote(col)} = ?")
245
+ vals.append(val)
246
+
247
+ # Build WHERE clause
248
+ if pk:
249
+ if where:
250
+ # Merge pk into where
251
+ if isinstance(where, dict):
252
+ where.update(pk)
253
+ else:
254
+ # Convert to dict for merging
255
+ where_dict = dict(where) if isinstance(where, (list, tuple)) else {}
256
+ where_dict.update(pk)
257
+ where = where_dict
258
+ else:
259
+ where = pk
260
+
261
+ where_sql, where_vals = cls._build_where(where) if where else ("", [])
262
+
263
+ sql_parts = [
264
+ "UPDATE",
265
+ quote(table),
266
+ "SET",
267
+ ", ".join(set_clauses)
268
+ ]
269
+
270
+ if where_sql:
271
+ sql_parts.extend(["WHERE", where_sql])
272
+ vals.extend(where_vals)
273
+
274
+ return " ".join(sql_parts), vals
275
+
276
+ @classmethod
277
+ def delete(cls, tx, table, where):
278
+ """Generate a DELETE statement for SQLite."""
279
+ if not where:
280
+ raise ValueError("WHERE clause is required for DELETE")
281
+
282
+ where_sql, where_vals = cls._build_where(where)
283
+
284
+ sql_parts = [
285
+ "DELETE FROM",
286
+ quote(table),
287
+ "WHERE",
288
+ where_sql
289
+ ]
290
+
291
+ return " ".join(sql_parts), where_vals
292
+
293
+ @classmethod
294
+ def merge(cls, tx, table, data, pk, on_conflict_do_nothing, on_conflict_update):
295
+ """Generate an INSERT OR REPLACE/INSERT OR IGNORE statement for SQLite."""
296
+ if on_conflict_do_nothing:
297
+ # SQLite: INSERT OR IGNORE
298
+ insert_sql, insert_vals = cls.insert(table, data)
299
+ insert_sql = insert_sql.replace("INSERT INTO", "INSERT OR IGNORE INTO")
300
+ return insert_sql, insert_vals
301
+ elif on_conflict_update:
302
+ # SQLite: INSERT OR REPLACE (simple replacement)
303
+ insert_sql, insert_vals = cls.insert(table, data)
304
+ insert_sql = insert_sql.replace("INSERT INTO", "INSERT OR REPLACE INTO")
305
+ return insert_sql, insert_vals
306
+ else:
307
+ return cls.insert(table, data)
308
+
309
+ # Metadata queries
310
+ @classmethod
311
+ def version(cls):
312
+ return "SELECT sqlite_version()"
313
+
314
+ @classmethod
315
+ def timestamp(cls):
316
+ return "SELECT datetime('now')"
317
+
318
+ @classmethod
319
+ def user(cls):
320
+ return "SELECT 'sqlite_user'" # SQLite doesn't have users
321
+
322
+ @classmethod
323
+ def databases(cls):
324
+ return "PRAGMA database_list"
325
+
326
+ @classmethod
327
+ def schemas(cls):
328
+ return "PRAGMA database_list"
329
+
330
+ @classmethod
331
+ def current_schema(cls):
332
+ return "SELECT 'main'" # SQLite default schema
333
+
334
+ @classmethod
335
+ def current_database(cls):
336
+ return "SELECT 'main'"
337
+
338
+ @classmethod
339
+ def tables(cls, system=False):
340
+ if system:
341
+ return "SELECT name FROM sqlite_master WHERE type='table'"
342
+ else:
343
+ return "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
344
+
345
+ @classmethod
346
+ def views(cls, system=False):
347
+ return "SELECT name FROM sqlite_master WHERE type='view'"
348
+
349
+ @classmethod
350
+ def create_database(cls, name):
351
+ return f"-- SQLite databases are files: {name}"
352
+
353
+ @classmethod
354
+ def drop_database(cls, name):
355
+ return f"-- SQLite databases are files: {name}"
356
+
357
+ @classmethod
358
+ def create_table(cls, name, columns=None, drop=False):
359
+ if not name or not isinstance(name, str):
360
+ raise ValueError("Table name must be a non-empty string")
361
+
362
+ columns = columns or {}
363
+ table_identifier = quote(name)
364
+ base_name = name.split(".")[-1].replace('"', "")
365
+ base_name_sql = base_name.replace("'", "''")
366
+ trigger_prefix = re.sub(r"[^0-9A-Za-z_]+", "_", f"cc_sysmod_{base_name}")
367
+
368
+ statements = []
369
+ if drop:
370
+ statements.append(f"DROP TABLE IF EXISTS {table_identifier};")
371
+
372
+ statements.append(
373
+ f"""
374
+ CREATE TABLE {table_identifier} (
375
+ "sys_id" INTEGER PRIMARY KEY AUTOINCREMENT,
376
+ "sys_table" TEXT,
377
+ "sys_created" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
378
+ "sys_modified" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
379
+ "sys_modified_by" TEXT,
380
+ "sys_modified_count" INTEGER NOT NULL DEFAULT 0,
381
+ "sys_dirty" INTEGER NOT NULL DEFAULT 0,
382
+ "description" TEXT
383
+ );
384
+ """.strip()
385
+ )
386
+
387
+ for key, val in columns.items():
388
+ clean_key = re.sub("<>!=%", "", key)
389
+ if clean_key in system_fields:
390
+ continue
391
+ col_type = TYPES.get_type(val)
392
+ statements.append(
393
+ f"ALTER TABLE {table_identifier} ADD COLUMN {quote(clean_key)} {col_type};"
394
+ )
395
+
396
+ statements.extend(
397
+ [
398
+ f"DROP TRIGGER IF EXISTS {trigger_prefix}_ai;",
399
+ f"DROP TRIGGER IF EXISTS {trigger_prefix}_au;",
400
+ f"""
401
+ CREATE TRIGGER {trigger_prefix}_ai
402
+ AFTER INSERT ON {table_identifier}
403
+ FOR EACH ROW
404
+ BEGIN
405
+ UPDATE {table_identifier}
406
+ SET sys_created = COALESCE(NEW.sys_created, CURRENT_TIMESTAMP),
407
+ sys_modified = CURRENT_TIMESTAMP,
408
+ sys_modified_count = 0,
409
+ sys_dirty = COALESCE(NEW.sys_dirty, 0),
410
+ sys_table = '{base_name_sql}'
411
+ WHERE rowid = NEW.rowid;
412
+ END;
413
+ """.strip(),
414
+ f"""
415
+ CREATE TRIGGER {trigger_prefix}_au
416
+ AFTER UPDATE ON {table_identifier}
417
+ FOR EACH ROW
418
+ BEGIN
419
+ UPDATE {table_identifier}
420
+ SET sys_created = OLD.sys_created,
421
+ sys_modified = CURRENT_TIMESTAMP,
422
+ sys_table = '{base_name_sql}',
423
+ sys_dirty = CASE WHEN OLD.sys_dirty = 1 AND NEW.sys_dirty = 0 THEN 0 ELSE 1 END,
424
+ sys_modified_count = CASE WHEN OLD.sys_dirty = 1 AND NEW.sys_dirty = 0 THEN COALESCE(OLD.sys_modified_count, 0) ELSE COALESCE(OLD.sys_modified_count, 0) + 1 END
425
+ WHERE rowid = NEW.rowid;
426
+ END;
427
+ """.strip(),
428
+ ]
429
+ )
430
+
431
+ return "\n".join(statements), tuple()
432
+
433
+ @classmethod
434
+ def ensure_system_columns(cls, name, existing_columns=None, force=False):
435
+ """Ensure SQLite tables maintain the Velocity system triggers/columns."""
436
+ existing_columns = {col.lower() for col in existing_columns or []}
437
+
438
+ table_identifier = quote(name)
439
+ base_name = name.split(".")[-1].replace('"', "")
440
+ base_name_sql = base_name.replace("'", "''")
441
+ trigger_prefix = re.sub(r"[^0-9A-Za-z_]+", "_", f"cc_sysmod_{base_name}")
442
+
443
+ has_count = "sys_modified_count" in existing_columns
444
+
445
+ add_column = not has_count
446
+ recreate_triggers = force or add_column
447
+
448
+ if not recreate_triggers and not force:
449
+ return None
450
+
451
+ statements = []
452
+
453
+ if add_column:
454
+ statements.append(
455
+ f"ALTER TABLE {table_identifier} ADD COLUMN sys_modified_count INTEGER NOT NULL DEFAULT 0;"
456
+ )
457
+
458
+ statements.append(
459
+ f"UPDATE {table_identifier} SET sys_modified_count = 0 WHERE sys_modified_count IS NULL;"
460
+ )
461
+
462
+ statements.append(f"DROP TRIGGER IF EXISTS {trigger_prefix}_ai;")
463
+ statements.append(f"DROP TRIGGER IF EXISTS {trigger_prefix}_au;")
464
+
465
+ statements.extend(
466
+ [
467
+ f"""
468
+ CREATE TRIGGER {trigger_prefix}_ai
469
+ AFTER INSERT ON {table_identifier}
470
+ FOR EACH ROW
471
+ BEGIN
472
+ UPDATE {table_identifier}
473
+ SET sys_created = COALESCE(NEW.sys_created, CURRENT_TIMESTAMP),
474
+ sys_modified = CURRENT_TIMESTAMP,
475
+ sys_modified_count = 0,
476
+ sys_dirty = COALESCE(NEW.sys_dirty, 0),
477
+ sys_table = '{base_name_sql}'
478
+ WHERE rowid = NEW.rowid;
479
+ END;
480
+ """.strip(),
481
+ f"""
482
+ CREATE TRIGGER {trigger_prefix}_au
483
+ AFTER UPDATE ON {table_identifier}
484
+ FOR EACH ROW
485
+ BEGIN
486
+ UPDATE {table_identifier}
487
+ SET sys_created = OLD.sys_created,
488
+ sys_modified = CURRENT_TIMESTAMP,
489
+ sys_table = '{base_name_sql}',
490
+ sys_dirty = CASE WHEN OLD.sys_dirty = 1 AND NEW.sys_dirty = 0 THEN 0 ELSE 1 END,
491
+ sys_modified_count = CASE WHEN OLD.sys_dirty = 1 AND NEW.sys_dirty = 0 THEN COALESCE(OLD.sys_modified_count, 0) ELSE COALESCE(OLD.sys_modified_count, 0) + 1 END
492
+ WHERE rowid = NEW.rowid;
493
+ END;
494
+ """.strip(),
495
+ ]
496
+ )
497
+
498
+ return "\n".join(statements), tuple()
499
+
500
+ @classmethod
501
+ def drop_table(cls, name):
502
+ return f"DROP TABLE {quote(name)}"
503
+
504
+ @classmethod
505
+ def truncate(cls, table):
506
+ return f"DELETE FROM {quote(table)}" # SQLite doesn't have TRUNCATE
507
+
508
+ @classmethod
509
+ def columns(cls, name):
510
+ return f"PRAGMA table_info({quote(name)})"
511
+
512
+ @classmethod
513
+ def column_info(cls, table, name):
514
+ return f"PRAGMA table_info({quote(table)})"
515
+
516
+ @classmethod
517
+ def drop_column(cls, table, name, cascade=True):
518
+ # SQLite doesn't support DROP COLUMN directly
519
+ return f"-- SQLite doesn't support DROP COLUMN for {table}.{name}"
520
+
521
+ @classmethod
522
+ def alter_add(cls, table, columns, null_allowed=True):
523
+ alter_parts = []
524
+ for col, col_type in columns.items():
525
+ null_clause = "" if null_allowed else " NOT NULL"
526
+ alter_parts.append(f"ALTER TABLE {quote(table)} ADD COLUMN {quote(col)} {col_type}{null_clause}")
527
+
528
+ return "; ".join(alter_parts)
529
+
530
+ @classmethod
531
+ def alter_drop(cls, table, columns):
532
+ return f"-- SQLite doesn't support DROP COLUMN for {table}"
533
+
534
+ @classmethod
535
+ def alter_column_by_type(cls, table, column, value, nullable=True):
536
+ return f"-- SQLite doesn't support ALTER COLUMN for {table}.{column}"
537
+
538
+ @classmethod
539
+ def alter_column_by_sql(cls, table, column, value):
540
+ return f"-- SQLite doesn't support ALTER COLUMN for {table}.{column}"
541
+
542
+ @classmethod
543
+ def rename_column(cls, table, orig, new):
544
+ return f"ALTER TABLE {quote(table)} RENAME COLUMN {quote(orig)} TO {quote(new)}"
545
+
546
+ @classmethod
547
+ def rename_table(cls, table, new):
548
+ return f"ALTER TABLE {quote(table)} RENAME TO {quote(new)}"
549
+
550
+ @classmethod
551
+ def primary_keys(cls, table):
552
+ return f"PRAGMA table_info({quote(table)})"
553
+
554
+ @classmethod
555
+ def foreign_key_info(cls, table=None, column=None, schema=None):
556
+ if table:
557
+ return f"PRAGMA foreign_key_list({quote(table)})"
558
+ else:
559
+ return "-- SQLite foreign key info requires table name"
560
+
561
+ @classmethod
562
+ def create_foreign_key(cls, table, columns, key_to_table, key_to_columns, name=None, schema=None):
563
+ # SQLite foreign keys must be defined at table creation time
564
+ return f"-- SQLite foreign keys must be defined at table creation"
565
+
566
+ @classmethod
567
+ def drop_foreign_key(cls, table, columns, key_to_table=None, key_to_columns=None, name=None, schema=None):
568
+ return f"-- SQLite foreign keys must be dropped by recreating table"
569
+
570
+ @classmethod
571
+ def create_index(cls, tx, table=None, columns=None, unique=False, direction=None, where=None, name=None, schema=None, trigram=None, lower=None):
572
+ if name is None:
573
+ name = f"idx_{table}_{'_'.join(columns)}"
574
+
575
+ index_type = "UNIQUE INDEX" if unique else "INDEX"
576
+ col_list = ", ".join(quote(col) for col in columns)
577
+
578
+ sql = f"CREATE {index_type} {quote(name)} ON {quote(table)} ({col_list})"
579
+
580
+ if where:
581
+ sql += f" WHERE {where}"
582
+
583
+ return sql
584
+
585
+ @classmethod
586
+ def drop_index(cls, table=None, columns=None, name=None, schema=None, trigram=None):
587
+ if name is None:
588
+ name = f"idx_{table}_{'_'.join(columns)}"
589
+
590
+ return f"DROP INDEX {quote(name)}"
591
+
592
+ @classmethod
593
+ def indexes(cls, table):
594
+ return f"PRAGMA index_list({quote(table)})"
595
+
596
+ @classmethod
597
+ def create_savepoint(cls, sp):
598
+ return f"SAVEPOINT {sp}"
599
+
600
+ @classmethod
601
+ def release_savepoint(cls, sp):
602
+ return f"RELEASE SAVEPOINT {sp}"
603
+
604
+ @classmethod
605
+ def rollback_savepoint(cls, sp):
606
+ return f"ROLLBACK TO SAVEPOINT {sp}"
607
+
608
+ @classmethod
609
+ def create_view(cls, name, query, temp=False, silent=True):
610
+ temp_clause = "TEMPORARY " if temp else ""
611
+ return f"CREATE {temp_clause}VIEW {quote(name)} AS {query}"
612
+
613
+ @classmethod
614
+ def drop_view(cls, name, silent=True):
615
+ if silent:
616
+ return f"DROP VIEW IF EXISTS {quote(name)}"
617
+ else:
618
+ return f"DROP VIEW {quote(name)}"
619
+
620
+ @classmethod
621
+ def last_id(cls, table):
622
+ return "SELECT last_insert_rowid()"
623
+
624
+ @classmethod
625
+ def current_id(cls, table):
626
+ return f"SELECT seq FROM sqlite_sequence WHERE name = '{table}'"
627
+
628
+ @classmethod
629
+ def set_id(cls, table, start):
630
+ return f"UPDATE sqlite_sequence SET seq = {start} WHERE name = '{table}'"
631
+
632
+ @classmethod
633
+ def set_sequence(cls, table, next_value):
634
+ return f"UPDATE sqlite_sequence SET seq = {next_value} WHERE name = '{table}'"
635
+
636
+ @classmethod
637
+ def massage_data(cls, data):
638
+ """Massage data before insert/update operations."""
639
+ # SQLite-specific data transformations
640
+ massaged = {}
641
+ for key, value in data.items():
642
+ if isinstance(value, bool):
643
+ # Convert boolean to integer for SQLite
644
+ massaged[key] = 1 if value else 0
645
+ else:
646
+ massaged[key] = value
647
+ return massaged
648
+
649
+ @classmethod
650
+ def alter_trigger(cls, table, state="ENABLE", name="USER"):
651
+ return f"-- SQLite trigger management for {table}"
652
+
653
+ @classmethod
654
+ def missing(cls, tx, table, list_values, column="SYS_ID", where=None):
655
+ """Generate query to find missing values from a list."""
656
+ # SQLite version using WITH clause
657
+ value_list = ", ".join([f"({i}, ?)" for i in range(len(list_values))])
658
+
659
+ sql = f"""
660
+ WITH input_values(pos, val) AS (
661
+ VALUES {value_list}
662
+ )
663
+ SELECT val FROM input_values
664
+ WHERE val NOT IN (
665
+ SELECT {quote(column)} FROM {quote(table)}
666
+ """
667
+
668
+ vals = list_values
669
+
670
+ if where:
671
+ where_sql, where_vals = cls._build_where(where)
672
+ sql += f" WHERE {where_sql}"
673
+ vals.extend(where_vals)
674
+
675
+ sql += ") ORDER BY pos"
676
+
677
+ return sql, vals