velocity-python 0.0.109__py3-none-any.whl → 0.0.161__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +251 -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 +48 -13
  27. velocity/db/core/engine.py +187 -840
  28. velocity/db/core/result.py +33 -25
  29. velocity/db/core/row.py +15 -3
  30. velocity/db/core/table.py +493 -50
  31. velocity/db/core/transaction.py +28 -15
  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 +270 -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 +129 -51
  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.161.dist-info}/METADATA +2 -2
  110. velocity_python-0.0.161.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.161.dist-info}/WHEEL +0 -0
  119. {velocity_python-0.0.109.dist-info → velocity_python-0.0.161.dist-info}/licenses/LICENSE +0 -0
  120. {velocity_python-0.0.109.dist-info → velocity_python-0.0.161.dist-info}/top_level.txt +0 -0
@@ -1,10 +1,10 @@
1
1
  import inspect
2
- import sys
3
2
  import re
4
- import traceback
3
+ from contextlib import contextmanager
5
4
  from functools import wraps
6
5
  from velocity.db import exceptions
7
6
  from velocity.db.core.transaction import Transaction
7
+ from velocity.db.utils import mask_config_for_display
8
8
 
9
9
  import logging
10
10
 
@@ -19,46 +19,16 @@ class Engine:
19
19
 
20
20
  MAX_RETRIES = 100
21
21
 
22
- def __init__(self, driver, config, sql, connect_timeout=5):
22
+ def __init__(self, driver, config, sql, connect_timeout=5, schema_locked=False):
23
23
  self.__config = config
24
24
  self.__sql = sql
25
25
  self.__driver = driver
26
26
  self.__connect_timeout = connect_timeout
27
-
28
- # Set up error code mappings from the SQL class
29
- self._setup_error_mappings()
30
-
31
- def _setup_error_mappings(self):
32
- """
33
- Set up error code to exception class mappings from the SQL driver.
34
- """
35
- self.error_codes = {}
36
-
37
- # Map error codes to exception class names
38
- sql_attrs = [
39
- ('ApplicationErrorCodes', 'DbApplicationError'),
40
- ('DatabaseMissingErrorCodes', 'DbDatabaseMissingError'),
41
- ('TableMissingErrorCodes', 'DbTableMissingError'),
42
- ('ColumnMissingErrorCodes', 'DbColumnMissingError'),
43
- ('ForeignKeyMissingErrorCodes', 'DbForeignKeyMissingError'),
44
- ('ConnectionErrorCodes', 'DbConnectionError'),
45
- ('DuplicateKeyErrorCodes', 'DbDuplicateKeyError'),
46
- ('RetryTransactionCodes', 'DbRetryTransaction'),
47
- ('TruncationErrorCodes', 'DbTruncationError'),
48
- ('LockTimeoutErrorCodes', 'DbLockTimeoutError'),
49
- ('DatabaseObjectExistsErrorCodes', 'DbObjectExistsError'),
50
- ('DataIntegrityErrorCodes', 'DbDataIntegrityError')
51
- ]
52
-
53
- for attr_name, exception_class in sql_attrs:
54
- if hasattr(self.sql, attr_name):
55
- codes = getattr(self.sql, attr_name)
56
- if codes: # Only add non-empty lists
57
- for code in codes:
58
- self.error_codes[str(code)] = exception_class
27
+ self.__schema_locked = schema_locked
59
28
 
60
29
  def __str__(self):
61
- return f"[{self.sql.server}] engine({self.config})"
30
+ safe_config = mask_config_for_display(self.config)
31
+ return f"[{self.sql.server}] engine({safe_config})"
62
32
 
63
33
  def connect(self):
64
34
  """
@@ -208,18 +178,18 @@ class Engine:
208
178
  while True:
209
179
  try:
210
180
  return function(*args, **kwds)
211
- except exceptions.DbRetryTransaction as e:
181
+ except exceptions.DbRetryTransaction:
212
182
  retry_count += 1
213
183
  if retry_count > self.MAX_RETRIES:
214
184
  raise
215
185
  _tx.rollback()
216
- except exceptions.DbLockTimeoutError as e:
186
+ except exceptions.DbLockTimeoutError:
217
187
  lock_timeout_count += 1
218
188
  if lock_timeout_count > self.MAX_RETRIES:
219
189
  raise
220
190
  _tx.rollback()
221
191
  continue
222
- except:
192
+ except Exception:
223
193
  raise
224
194
  finally:
225
195
  setattr(_tx, "_exec_function_depth", depth)
@@ -239,6 +209,29 @@ class Engine:
239
209
  def sql(self):
240
210
  return self.__sql
241
211
 
212
+ @property
213
+ def schema_locked(self):
214
+ """Returns True if schema modifications are locked."""
215
+ return self.__schema_locked
216
+
217
+ def lock_schema(self):
218
+ """Lock schema to prevent automatic modifications."""
219
+ self.__schema_locked = True
220
+
221
+ def unlock_schema(self):
222
+ """Unlock schema to allow automatic modifications."""
223
+ self.__schema_locked = False
224
+
225
+ @contextmanager
226
+ def unlocked_schema(self):
227
+ """Temporarily unlock schema for automatic creation."""
228
+ original_state = self.__schema_locked
229
+ self.__schema_locked = False
230
+ try:
231
+ yield self
232
+ finally:
233
+ self.__schema_locked = original_state
234
+
242
235
  @property
243
236
  def version(self):
244
237
  """
@@ -359,833 +352,187 @@ class Engine:
359
352
 
360
353
  def process_error(self, exception, sql=None, parameters=None):
361
354
  """
362
- Process database errors and raise appropriate velocity exceptions.
363
- Enhanced for robustness with exception chaining and comprehensive error handling.
364
-
365
- Args:
366
- exception: The original exception from the database driver
367
- sql: The SQL statement that caused the error (optional)
368
- parameters: The parameters passed to the SQL statement (optional)
369
-
370
- Raises:
371
- The appropriate velocity exception with proper chaining
355
+ Central method to parse driver exceptions and re-raise them as our custom exceptions.
372
356
  """
373
357
  logger = logging.getLogger(__name__)
374
-
375
- # Enhanced logging with context - more readable format
376
- sql_preview = sql[:100] + "..." if sql and len(sql) > 100 else sql or "None"
377
-
378
- logger.error(
379
- f"🔴 Database Error Detected\n"
380
- f" Exception Type: {type(exception).__name__}\n"
381
- f" SQL Statement: {sql_preview}\n"
382
- f" Processing error for classification..."
383
- )
384
-
385
- # Safely get error code and message with fallbacks
386
- try:
387
- # Try PostgreSQL-specific error code first, then use SQL driver's get_error method
388
- error_code = getattr(exception, 'pgcode', None)
389
- if not error_code and hasattr(self.sql, 'get_error'):
390
- try:
391
- error_code, error_message_from_driver = self.sql.get_error(exception)
392
- if error_message_from_driver:
393
- error_message = error_message_from_driver
394
- except Exception as get_error_exception:
395
- logger.warning(f"⚠️ SQL driver get_error failed: {get_error_exception}")
396
- error_code = None
397
- except Exception as e:
398
- logger.warning(f"⚠️ Unable to extract database error code: {e}")
399
- error_code = None
400
-
358
+
359
+ # If it's already a velocity exception, just re-raise it
360
+ if isinstance(exception, exceptions.DbException):
361
+ raise exception
362
+
363
+ # Get error code and message from the SQL driver
401
364
  try:
402
- error_message = str(exception)
403
- except Exception as e:
404
- logger.warning(f"⚠️ Unable to convert exception to string: {e}")
405
- error_message = f"<Error converting exception: {type(exception).__name__}>"
406
-
407
- # Primary error classification by error code
408
- if error_code and hasattr(self, 'error_codes') and str(error_code) in self.error_codes:
409
- error_class = self.error_codes[str(error_code)]
410
- logger.info(f"✅ Successfully classified error: {error_code} → {error_class}")
411
- try:
412
- raise self._create_exception_with_chaining(
413
- error_class, error_message, exception, sql, parameters
414
- )
415
- except Exception as creation_error:
416
- logger.error(f" Failed to create {error_class} exception: {creation_error}")
417
- # Fall through to regex classification
418
-
419
- # Secondary error classification by message patterns (regex fallback)
420
- error_message_lower = error_message.lower()
421
-
422
- # Enhanced connection error patterns
423
- connection_patterns = [
424
- r'connection.*refused|could not connect',
425
- r'network.*unreachable|network.*down',
426
- r'broken pipe|connection.*broken',
427
- r'timeout.*connection|connection.*timeout',
428
- r'server.*closed.*connection|connection.*lost',
429
- r'no route to host|host.*unreachable',
430
- r'connection.*reset|reset.*connection'
431
- ]
432
-
433
- # Enhanced duplicate key patterns
434
- duplicate_patterns = [
435
- r'duplicate.*key.*value|unique.*constraint.*violated',
436
- r'duplicate.*entry|key.*already.*exists',
437
- r'violates.*unique.*constraint',
438
- r'unique.*violation|constraint.*unique'
439
- ]
440
-
441
- # Enhanced permission/authorization patterns
442
- permission_patterns = [
443
- r'permission.*denied|access.*denied|authorization.*failed',
444
- r'insufficient.*privileges|privilege.*denied',
445
- r'not.*authorized|unauthorized.*access',
446
- r'authentication.*failed|login.*failed'
447
- ]
448
-
449
- # Enhanced database/table not found patterns
450
- not_found_patterns = [
451
- r'database.*does.*not.*exist|unknown.*database',
452
- r'table.*does.*not.*exist|relation.*does.*not.*exist',
453
- r'no.*such.*database|database.*not.*found',
454
- r'schema.*does.*not.*exist|unknown.*table'
455
- ]
456
-
457
- # Enhanced column missing patterns
458
- column_missing_patterns = [
459
- r'column.*does.*not.*exist',
460
- r'unknown.*column|column.*not.*found',
461
- r'no.*such.*column|invalid.*column.*name'
462
- ]
463
-
464
- # Enhanced syntax error patterns
465
- syntax_patterns = [
466
- r'syntax.*error|invalid.*syntax',
467
- r'malformed.*query|bad.*sql.*grammar',
468
- r'unexpected.*token|parse.*error'
469
- ]
470
-
471
- # Enhanced deadlock/timeout patterns
472
- deadlock_patterns = [
473
- r'deadlock.*detected|lock.*timeout',
474
- r'timeout.*waiting.*for.*lock|query.*timeout',
475
- r'lock.*wait.*timeout|deadlock.*found'
476
- ]
477
-
478
- # Comprehensive pattern matching with error class mapping
479
- pattern_mappings = [
480
- (connection_patterns, 'DbConnectionError'),
481
- (duplicate_patterns, 'DbDuplicateKeyError'),
482
- (permission_patterns, 'DbPermissionError'),
483
- (not_found_patterns, 'DbTableMissingError'),
484
- (column_missing_patterns, 'DbColumnMissingError'),
485
- (syntax_patterns, 'DbSyntaxError'),
486
- (deadlock_patterns, 'DbDeadlockError')
487
- ]
488
-
489
- # Apply pattern matching
490
- for patterns, error_class in pattern_mappings:
491
- for pattern in patterns:
492
- try:
493
- if re.search(pattern, error_message_lower):
494
- logger.info(f"✅ Classified error by pattern match: '{pattern}' → {error_class}")
495
- raise self._create_exception_with_chaining(
496
- error_class, error_message, exception, sql, parameters
497
- )
498
- except re.error as regex_error:
499
- logger.warning(f"⚠️ Regex pattern error for '{pattern}': {regex_error}")
500
- continue
501
- except Exception as pattern_error:
502
- logger.error(f"❌ Error applying pattern '{pattern}': {pattern_error}")
503
- continue
504
-
505
- # Fallback: return generic database error with full context
506
- available_codes = list(getattr(self, 'error_codes', {}).keys()) if hasattr(self, 'error_codes') else []
507
- logger.warning(
508
- f"⚠️ Unable to classify database error automatically\n"
509
- f" → Falling back to generic DatabaseError\n"
510
- f" → Error Code: {error_code or 'Unknown'}\n"
511
- f" → Available Classifications: {available_codes or 'None configured'}"
512
- )
513
-
514
- raise self._create_exception_with_chaining(
515
- 'DatabaseError', error_message, exception, sql, parameters
516
- )
517
-
518
- def _format_human_readable_error(self, error_class, message, original_exception, sql=None, parameters=None, format_type='console'):
519
- """
520
- Format a human-readable error message with proper context and formatting.
521
-
522
- Args:
523
- error_class: The name of the exception class
524
- message: The raw error message
525
- original_exception: The original exception
526
- sql: The SQL statement (optional)
527
- parameters: The SQL parameters (optional)
528
- format_type: 'console' for terminal output, 'email' for HTML email format
529
-
530
- Returns:
531
- A nicely formatted, human-readable error message
532
- """
533
- if format_type == 'email':
534
- return self._format_email_error(error_class, message, original_exception, sql, parameters)
535
- else:
536
- return self._format_console_error(error_class, message, original_exception, sql, parameters)
537
-
538
- def _format_console_error(self, error_class, message, original_exception, sql=None, parameters=None):
539
- """
540
- Format error message for console/terminal output with Unicode box drawing.
541
- """
542
- # Map error classes to user-friendly descriptions
543
- error_descriptions = {
544
- 'DbColumnMissingError': 'Column Not Found',
545
- 'DbTableMissingError': 'Table Not Found',
546
- 'DbDatabaseMissingError': 'Database Not Found',
547
- 'DbForeignKeyMissingError': 'Foreign Key Constraint Violation',
548
- 'DbDuplicateKeyError': 'Duplicate Key Violation',
549
- 'DbConnectionError': 'Database Connection Failed',
550
- 'DbDataIntegrityError': 'Data Integrity Violation',
551
- 'DbQueryError': 'Query Execution Error',
552
- 'DbTransactionError': 'Transaction Error',
553
- 'DbTruncationError': 'Data Truncation Error',
554
- 'DatabaseError': 'Database Error'
555
- }
556
-
557
- # Get user-friendly error type
558
- friendly_type = error_descriptions.get(error_class, error_class.replace('Db', '').replace('Error', ' Error'))
559
-
560
- # Clean up the original message
561
- clean_message = str(message).strip()
562
-
563
- # Extract specific details from PostgreSQL errors
564
- details = self._extract_error_details(original_exception, clean_message)
565
-
566
- # Build the formatted message
567
- lines = []
568
- lines.append(f"╭─ {friendly_type} ─" + "─" * max(0, 60 - len(friendly_type)))
569
- lines.append("│")
570
-
571
- # Add the main error description
572
- if details.get('description'):
573
- lines.append(f"│ {details['description']}")
574
- else:
575
- lines.append(f"│ {clean_message}")
576
- lines.append("│")
577
-
578
- # Add error code if available
579
- error_code = getattr(original_exception, 'pgcode', None)
580
- if error_code:
581
- lines.append(f"│ Error Code: {error_code}")
582
-
583
- # Add specific details if available
584
- if details.get('column'):
585
- lines.append(f"│ Column: {details['column']}")
586
- if details.get('table'):
587
- lines.append(f"│ Table: {details['table']}")
588
- if details.get('constraint'):
589
- lines.append(f"│ Constraint: {details['constraint']}")
590
- if details.get('hint'):
591
- lines.append(f"│ Hint: {details['hint']}")
365
+ error_code, error_message = self.sql.get_error(exception)
366
+ except Exception:
367
+ error_code, error_message = None, str(exception)
368
+
369
+ msg = str(exception).strip().lower()
370
+
371
+ # Create enhanced error message with SQL query and context
372
+ enhanced_message = str(exception)
373
+
374
+ # Add specific guidance for common WHERE clause errors
375
+ exception_str_lower = str(exception).lower()
376
+ if "argument of where must be type boolean" in exception_str_lower:
377
+ enhanced_message += (
378
+ "\n\n*** WHERE CLAUSE ERROR ***\n"
379
+ "This error typically occurs when a WHERE clause contains a bare value "
380
+ "instead of a proper boolean expression.\n"
381
+ "Common fixes:\n"
382
+ " - Change WHERE 1001 to WHERE sys_id = 1001\n"
383
+ " - Change WHERE {'column': value} format in dictionaries\n"
384
+ " - Ensure string WHERE clauses are complete SQL expressions"
385
+ )
592
386
 
593
- # Add SQL context if available
594
387
  if sql:
595
- lines.append("│")
596
- lines.append("│ SQL Statement:")
597
- # Show complete SQL without truncation for debugging
598
- for line in sql.split('\n'):
599
- lines.append(f"│ {line.strip()}")
600
-
601
- # Add parameters if available
602
- if parameters is not None:
603
- lines.append("│")
604
- lines.append(f"│ Parameters: {parameters}")
605
-
606
- # Add debugging section with copy-paste ready format
607
- if sql or parameters is not None:
608
- lines.append("│")
609
- lines.append("│ ┌─ DEBUG COPY-PASTE SECTION ─────────────────────────────")
610
-
611
- if sql and parameters is not None:
612
- # Format for direct copy-paste into database console
613
- lines.append("│ │")
614
- lines.append("│ │ Complete SQL with Parameters:")
615
- lines.append("│ │ " + "─" * 45)
616
-
617
- # Show the raw SQL
618
- lines.append("│ │ Raw SQL:")
619
- for line in sql.split('\n'):
620
- lines.append(f"│ │ {line}")
621
-
622
- lines.append("│ │")
623
- lines.append(f"│ │ Raw Parameters: {parameters}")
624
-
625
- # Try to create an executable version
626
- lines.append("│ │")
627
- lines.append("│ │ Executable Format (for PostgreSQL):")
628
- lines.append("│ │ " + "─" * 35)
629
-
630
- try:
631
- # Create a version with parameters substituted for testing
632
- executable_sql = self._format_executable_sql(sql, parameters)
633
- for line in executable_sql.split('\n'):
634
- lines.append(f"│ │ {line}")
635
- except Exception:
636
- lines.append("│ │ [Unable to format executable SQL]")
637
- for line in sql.split('\n'):
638
- lines.append(f"│ │ {line}")
639
- lines.append(f"│ │ -- Parameters: {parameters}")
640
-
641
- elif sql:
642
- lines.append("│ │")
643
- lines.append("│ │ SQL Statement (no parameters):")
644
- lines.append("│ │ " + "─" * 30)
645
- for line in sql.split('\n'):
646
- lines.append(f"│ │ {line}")
647
-
648
- lines.append("│ │")
649
- lines.append("│ └─────────────────────────────────────────────────────────")
650
-
651
- # Add detailed call stack information for debugging
652
- stack_info = self._extract_call_stack_info()
653
- if stack_info:
654
- lines.append("│")
655
- lines.append("│ ┌─ CALL STACK ANALYSIS ──────────────────────────────────")
656
- lines.append("│ │")
657
-
658
- if stack_info.get('top_level_call'):
659
- lines.append("│ │ Top-Level Function (most helpful for debugging):")
660
- lines.append("│ │ " + "─" * 48)
661
- call = stack_info['top_level_call']
662
- lines.append(f"│ │ Function: {call['function']}")
663
- lines.append(f"│ │ File: {call['file']}")
664
- lines.append(f"│ │ Line: {call['line']}")
665
- if call.get('code'):
666
- lines.append(f"│ │ Code: {call['code'].strip()}")
667
-
668
- if stack_info.get('relevant_calls'):
669
- lines.append("│ │")
670
- lines.append("│ │ Relevant Call Chain (excluding middleware):")
671
- lines.append("│ │ " + "─" * 44)
672
- for i, call in enumerate(stack_info['relevant_calls'][:5], 1): # Show top 5
673
- lines.append(f"│ │ {i}. {call['function']} in {call['file']}:{call['line']}")
674
- if call.get('code'):
675
- lines.append(f"│ │ → {call['code'].strip()}")
676
-
677
- if stack_info.get('lambda_context'):
678
- lines.append("│ │")
679
- lines.append("│ │ AWS Lambda Context:")
680
- lines.append("│ │ " + "─" * 19)
681
- for key, value in stack_info['lambda_context'].items():
682
- lines.append(f"│ │ {key}: {value}")
388
+ enhanced_message += (
389
+ f"\n\nSQL Query:\n{self._format_sql_with_params(sql, parameters)}"
390
+ )
683
391
 
684
- lines.append("│ │")
685
- lines.append("│ └─────────────────────────────────────────────────────────")
686
-
687
- lines.append("│")
688
- lines.append("╰" + "─" * 70)
689
-
690
- return '\n'.join(lines)
392
+ # Add call stack context for better debugging
393
+ import traceback
394
+ stack_trace = traceback.format_stack()
395
+ # Get the last few frames that aren't in the error handling itself
396
+ relevant_frames = [frame for frame in stack_trace if 'process_error' not in frame and 'logging' not in frame][-3:]
397
+ if relevant_frames:
398
+ enhanced_message += "\n\nCall Context:\n" + "".join(relevant_frames)
399
+
400
+ # Format SQL for logging
401
+ formatted_sql_info = ""
402
+ if sql:
403
+ formatted_sql_info = f" sql={self._format_sql_with_params(sql, parameters)}"
404
+
405
+ # logger.warning(
406
+ # "Database error caught. Attempting to transform: code=%s message=%s%s",
407
+ # error_code,
408
+ # error_message,
409
+ # formatted_sql_info,
410
+ # )
411
+
412
+ # Direct error code mapping
413
+ if error_code in self.sql.ApplicationErrorCodes:
414
+ raise exceptions.DbApplicationError(enhanced_message) from exception
415
+ if error_code in self.sql.ColumnMissingErrorCodes:
416
+ raise exceptions.DbColumnMissingError(enhanced_message) from exception
417
+ if error_code in self.sql.TableMissingErrorCodes:
418
+ raise exceptions.DbTableMissingError(enhanced_message) from exception
419
+ if error_code in self.sql.DatabaseMissingErrorCodes:
420
+ raise exceptions.DbDatabaseMissingError(enhanced_message) from exception
421
+ if error_code in self.sql.ForeignKeyMissingErrorCodes:
422
+ raise exceptions.DbForeignKeyMissingError(enhanced_message) from exception
423
+ if error_code in self.sql.TruncationErrorCodes:
424
+ raise exceptions.DbTruncationError(enhanced_message) from exception
425
+ if error_code in self.sql.DataIntegrityErrorCodes:
426
+ raise exceptions.DbDataIntegrityError(enhanced_message) from exception
427
+ if error_code in self.sql.ConnectionErrorCodes:
428
+ raise exceptions.DbConnectionError(enhanced_message) from exception
429
+ if error_code in self.sql.DuplicateKeyErrorCodes:
430
+ raise exceptions.DbDuplicateKeyError(enhanced_message) from exception
431
+ if error_code in self.sql.DatabaseObjectExistsErrorCodes:
432
+ raise exceptions.DbObjectExistsError(enhanced_message) from exception
433
+ if error_code in self.sql.LockTimeoutErrorCodes:
434
+ raise exceptions.DbLockTimeoutError(enhanced_message) from exception
435
+ if error_code in self.sql.RetryTransactionCodes:
436
+ raise exceptions.DbRetryTransaction(enhanced_message) from exception
437
+
438
+ # Regex-based fallback patterns
439
+ if re.search(r"key \(sys_id\)=\(\d+\) already exists.", msg, re.M):
440
+ raise exceptions.DbDuplicateKeyError(enhanced_message) from exception
441
+ if re.findall(r"database.*does not exist", msg, re.M):
442
+ raise exceptions.DbDatabaseMissingError(enhanced_message) from exception
443
+ if re.findall(r"no such database", msg, re.M):
444
+ raise exceptions.DbDatabaseMissingError(enhanced_message) from exception
445
+ if re.findall(r"already exists", msg, re.M):
446
+ raise exceptions.DbObjectExistsError(enhanced_message) from exception
447
+ if re.findall(r"server closed the connection unexpectedly", msg, re.M):
448
+ raise exceptions.DbConnectionError(enhanced_message) from exception
449
+ if re.findall(r"no connection to the server", msg, re.M):
450
+ raise exceptions.DbConnectionError(enhanced_message) from exception
451
+ if re.findall(r"connection timed out", msg, re.M):
452
+ raise exceptions.DbConnectionError(enhanced_message) from exception
453
+ if re.findall(r"could not connect to server", msg, re.M):
454
+ raise exceptions.DbConnectionError(enhanced_message) from exception
455
+ if re.findall(r"cannot connect to server", msg, re.M):
456
+ raise exceptions.DbConnectionError(enhanced_message) from exception
457
+ if re.findall(r"connection already closed", msg, re.M):
458
+ raise exceptions.DbConnectionError(enhanced_message) from exception
459
+ if re.findall(r"cursor already closed", msg, re.M):
460
+ raise exceptions.DbConnectionError(enhanced_message) from exception
461
+ if "no such table:" in msg:
462
+ raise exceptions.DbTableMissingError(enhanced_message) from exception
691
463
 
692
- def _format_email_error(self, error_class, message, original_exception, sql=None, parameters=None):
693
- """
694
- Format error message for email delivery with HTML formatting.
695
- """
696
- # Map error classes to user-friendly descriptions
697
- error_descriptions = {
698
- 'DbColumnMissingError': 'Column Not Found',
699
- 'DbTableMissingError': 'Table Not Found',
700
- 'DbDatabaseMissingError': 'Database Not Found',
701
- 'DbForeignKeyMissingError': 'Foreign Key Constraint Violation',
702
- 'DbDuplicateKeyError': 'Duplicate Key Violation',
703
- 'DbConnectionError': 'Database Connection Failed',
704
- 'DbDataIntegrityError': 'Data Integrity Violation',
705
- 'DbQueryError': 'Query Execution Error',
706
- 'DbTransactionError': 'Transaction Error',
707
- 'DbTruncationError': 'Data Truncation Error',
708
- 'DatabaseError': 'Database Error'
709
- }
710
-
711
- # Get user-friendly error type
712
- friendly_type = error_descriptions.get(error_class, error_class.replace('Db', '').replace('Error', ' Error'))
713
-
714
- # Clean up the original message
715
- clean_message = str(message).strip()
716
-
717
- # Extract specific details from PostgreSQL errors
718
- details = self._extract_error_details(original_exception, clean_message)
719
-
720
- # Get error code
721
- error_code = getattr(original_exception, 'pgcode', None)
722
-
723
- # Get stack info
724
- stack_info = self._extract_call_stack_info()
725
-
726
- # Build HTML email format
727
- html_parts = []
728
-
729
- # Email header
730
- html_parts.append("""
731
- <html>
732
- <head>
733
- <style>
734
- body { font-family: 'Courier New', monospace; margin: 20px; }
735
- .error-container { border: 2px solid #dc3545; border-radius: 8px; padding: 20px; background-color: #f8f9fa; }
736
- .error-header { background-color: #dc3545; color: white; padding: 10px; border-radius: 5px; font-weight: bold; font-size: 16px; margin-bottom: 15px; }
737
- .error-section { margin: 15px 0; padding: 10px; background-color: #ffffff; border-left: 4px solid #007bff; }
738
- .section-title { font-weight: bold; color: #007bff; margin-bottom: 8px; }
739
- .code-block { background-color: #f1f3f4; padding: 10px; border-radius: 4px; font-family: 'Courier New', monospace; margin: 5px 0; white-space: pre-wrap; }
740
- .highlight { background-color: #fff3cd; padding: 2px 4px; border-radius: 3px; }
741
- .stack-call { margin: 5px 0; padding: 5px; background-color: #e9ecef; border-radius: 3px; }
742
- .copy-section { background-color: #d1ecf1; border: 1px solid #bee5eb; padding: 15px; border-radius: 5px; margin: 10px 0; }
743
- </style>
744
- </head>
745
- <body>
746
- <div class="error-container">
747
- """)
748
-
749
- # Error header
750
- html_parts.append(f' <div class="error-header">🚨 {friendly_type}</div>')
751
-
752
- # Main error description
753
- description = details.get('description', clean_message)
754
- html_parts.append(f' <div class="error-section"><strong>{description}</strong></div>')
755
-
756
- # Error details section
757
- if error_code or details.get('column') or details.get('table') or details.get('constraint'):
758
- html_parts.append(' <div class="error-section">')
759
- html_parts.append(' <div class="section-title">Error Details:</div>')
760
- if error_code:
761
- html_parts.append(f' <div><strong>Error Code:</strong> <span class="highlight">{error_code}</span></div>')
762
- if details.get('column'):
763
- html_parts.append(f' <div><strong>Column:</strong> <span class="highlight">{details["column"]}</span></div>')
764
- if details.get('table'):
765
- html_parts.append(f' <div><strong>Table:</strong> <span class="highlight">{details["table"]}</span></div>')
766
- if details.get('constraint'):
767
- html_parts.append(f' <div><strong>Constraint:</strong> <span class="highlight">{details["constraint"]}</span></div>')
768
- if details.get('hint'):
769
- html_parts.append(f' <div><strong>Hint:</strong> {details["hint"]}</div>')
770
- html_parts.append(' </div>')
771
-
772
- # SQL and Parameters section
773
- if sql or parameters is not None:
774
- html_parts.append(' <div class="copy-section">')
775
- html_parts.append(' <div class="section-title">📋 Debug Information (Copy-Paste Ready)</div>')
776
-
777
- if sql:
778
- html_parts.append(' <div><strong>SQL Statement:</strong></div>')
779
- html_parts.append(f' <div class="code-block">{self._html_escape(sql)}</div>')
780
-
781
- if parameters is not None:
782
- html_parts.append(f' <div><strong>Parameters:</strong> <code>{self._html_escape(str(parameters))}</code></div>')
783
-
784
- # Executable SQL
785
- if sql and parameters is not None:
786
- try:
787
- executable_sql = self._format_executable_sql(sql, parameters)
788
- html_parts.append(' <div><strong>Executable SQL (for testing):</strong></div>')
789
- html_parts.append(f' <div class="code-block">{self._html_escape(executable_sql)}</div>')
790
- except Exception:
791
- pass
792
-
793
- html_parts.append(' </div>')
794
-
795
- # Call stack section
796
- if stack_info and stack_info.get('top_level_call'):
797
- html_parts.append(' <div class="error-section">')
798
- html_parts.append(' <div class="section-title">🔍 Source Code Location</div>')
799
-
800
- call = stack_info['top_level_call']
801
- html_parts.append(' <div class="stack-call">')
802
- html_parts.append(f' <strong>Function:</strong> {call["function"]}<br>')
803
- html_parts.append(f' <strong>File:</strong> {call["file"]}<br>')
804
- html_parts.append(f' <strong>Line:</strong> {call["line"]}')
805
- if call.get('code'):
806
- html_parts.append(f'<br> <strong>Code:</strong> <code>{self._html_escape(call["code"].strip())}</code>')
807
- html_parts.append(' </div>')
808
-
809
- # Show relevant call chain
810
- if stack_info.get('relevant_calls') and len(stack_info['relevant_calls']) > 1:
811
- html_parts.append(' <div><strong>Call Chain:</strong></div>')
812
- for i, call in enumerate(stack_info['relevant_calls'][:4], 1):
813
- html_parts.append(' <div class="stack-call">')
814
- html_parts.append(f' {i}. <strong>{call["function"]}</strong> in {call["file"]}:{call["line"]}')
815
- html_parts.append(' </div>')
816
-
817
- html_parts.append(' </div>')
818
-
819
- # Lambda context
820
- if stack_info and stack_info.get('lambda_context'):
821
- html_parts.append(' <div class="error-section">')
822
- html_parts.append(' <div class="section-title">⚡ AWS Lambda Context</div>')
823
- for key, value in stack_info['lambda_context'].items():
824
- html_parts.append(f' <div><strong>{key.title()}:</strong> {value}</div>')
825
- html_parts.append(' </div>')
826
-
827
- # Email footer
828
- html_parts.append("""
829
- </div>
830
- </body>
831
- </html>
832
- """)
833
-
834
- return ''.join(html_parts)
835
-
836
- def _html_escape(self, text):
837
- """Escape HTML special characters."""
838
- if not text:
839
- return ""
840
- return (str(text)
841
- .replace('&', '&amp;')
842
- .replace('<', '&lt;')
843
- .replace('>', '&gt;')
844
- .replace('"', '&quot;')
845
- .replace("'", '&#x27;'))
846
-
847
- def _format_executable_sql(self, sql, parameters):
464
+ logger.error(
465
+ "Unhandled/Unknown Error in engine.process_error",
466
+ exc_info=True,
467
+ extra={
468
+ "error_code": error_code,
469
+ "error_msg": error_message,
470
+ "sql_stmt": sql,
471
+ "sql_params": parameters,
472
+ },
473
+ )
474
+
475
+ # If we can't classify it, re-raise with enhanced message
476
+ raise type(exception)(enhanced_message) from exception
477
+
478
+ def _format_sql_with_params(self, sql, parameters):
848
479
  """
849
- Format SQL with parameters substituted for easy copy-paste debugging.
850
-
851
- Args:
852
- sql: The SQL statement with placeholders
853
- parameters: The parameters to substitute
854
-
855
- Returns:
856
- SQL statement with parameters properly formatted for execution
480
+ Format SQL query with parameters merged for easy copy-paste debugging.
857
481
  """
482
+ if not sql:
483
+ return "No SQL provided"
484
+
858
485
  if not parameters:
859
486
  return sql
860
-
487
+
861
488
  try:
862
489
  # Handle different parameter formats
863
490
  if isinstance(parameters, (list, tuple)):
864
- # For positional parameters (%s style)
865
- formatted_sql = sql
866
-
867
- # Replace %s placeholders with properly formatted values
491
+ # Convert parameters to strings and handle None values
492
+ formatted_params = []
868
493
  for param in parameters:
869
- if isinstance(param, str):
494
+ if param is None:
495
+ formatted_params.append("NULL")
496
+ elif isinstance(param, str):
870
497
  # Escape single quotes and wrap in quotes
871
- formatted_param = f"'{param.replace(chr(39), chr(39)+chr(39))}'"
872
- elif isinstance(param, (int, float)):
873
- formatted_param = str(param)
874
- elif param is None:
875
- formatted_param = 'NULL'
498
+ escaped = param.replace("'", "''")
499
+ formatted_params.append(f"'{escaped}'")
876
500
  elif isinstance(param, bool):
877
- formatted_param = 'TRUE' if param else 'FALSE'
501
+ formatted_params.append("TRUE" if param else "FALSE")
878
502
  else:
879
- # For other types, try to convert to string and quote
880
- formatted_param = f"'{str(param).replace(chr(39), chr(39)+chr(39))}'"
881
-
882
- # Replace first occurrence of %s
883
- formatted_sql = formatted_sql.replace('%s', formatted_param, 1)
884
-
503
+ formatted_params.append(str(param))
504
+
505
+ # Replace %s placeholders with actual values
506
+ formatted_sql = sql
507
+ for param in formatted_params:
508
+ formatted_sql = formatted_sql.replace("%s", param, 1)
509
+
885
510
  return formatted_sql
886
-
511
+
887
512
  elif isinstance(parameters, dict):
888
- # For named parameters (%(name)s style)
513
+ # Handle named parameters
889
514
  formatted_sql = sql
890
515
  for key, value in parameters.items():
891
- placeholder = f'%({key})s'
892
- if isinstance(value, str):
893
- formatted_value = f"'{value.replace(chr(39), chr(39)+chr(39))}'"
894
- elif isinstance(value, (int, float)):
895
- formatted_value = str(value)
896
- elif value is None:
897
- formatted_value = 'NULL'
516
+ if value is None:
517
+ replacement = "NULL"
518
+ elif isinstance(value, str):
519
+ escaped = value.replace("'", "''")
520
+ replacement = f"'{escaped}'"
898
521
  elif isinstance(value, bool):
899
- formatted_value = 'TRUE' if value else 'FALSE'
522
+ replacement = "TRUE" if value else "FALSE"
900
523
  else:
901
- formatted_value = f"'{str(value).replace(chr(39), chr(39)+chr(39))}'"
902
-
903
- formatted_sql = formatted_sql.replace(placeholder, formatted_value)
904
-
524
+ replacement = str(value)
525
+
526
+ # Replace %(key)s or :key patterns
527
+ formatted_sql = formatted_sql.replace(f"%({key})s", replacement)
528
+ formatted_sql = formatted_sql.replace(f":{key}", replacement)
529
+
905
530
  return formatted_sql
906
-
907
531
  else:
908
- # Fallback: just append parameters as comment
909
532
  return f"{sql}\n-- Parameters: {parameters}"
910
-
911
- except Exception:
912
- # If formatting fails, return original with parameters as comment
913
- return f"{sql}\n-- Parameters: {parameters}"
914
533
 
915
- def _extract_call_stack_info(self):
916
- """
917
- Extract relevant call stack information for debugging, filtering out
918
- middleware and focusing on the most useful frames.
919
-
920
- Returns:
921
- Dictionary with call stack analysis
922
- """
923
- import traceback
924
- import os
925
-
926
- try:
927
- # Get the current stack
928
- stack = traceback.extract_stack()
929
-
930
- # Common middleware/decorator patterns to filter out
931
- middleware_patterns = [
932
- 'decorator',
933
- 'wrapper',
934
- 'new_function',
935
- 'exec_function',
936
- '_execute',
937
- 'process_error',
938
- '_create_exception',
939
- '_format_human_readable',
940
- '__enter__',
941
- '__exit__',
942
- 'contextlib',
943
- 'functools'
944
- ]
945
-
946
- # Lambda/AWS specific patterns
947
- lambda_patterns = [
948
- 'lambda_handler',
949
- 'handler',
950
- 'bootstrap',
951
- 'runtime'
952
- ]
953
-
954
- relevant_calls = []
955
- top_level_call = None
956
- lambda_context = {}
957
-
958
- # Analyze stack frames from top to bottom (most recent first)
959
- for frame in reversed(stack[:-4]): # Skip the last few frames (this method, etc.)
960
- file_path = frame.filename
961
- function_name = frame.name
962
- line_number = frame.lineno
963
- code_line = frame.line or ""
964
-
965
- # Extract just the filename
966
- filename = os.path.basename(file_path)
967
-
968
- # Skip internal Python and library frames
969
- if any(skip in file_path.lower() for skip in [
970
- 'python3', 'site-packages', '/usr/', '/opt/python',
971
- 'psycopg2', 'boto3', 'botocore'
972
- ]):
973
- continue
974
-
975
- # Capture Lambda context if found
976
- if any(pattern in function_name.lower() for pattern in lambda_patterns):
977
- lambda_context.update({
978
- 'handler': function_name,
979
- 'file': filename,
980
- 'line': line_number
981
- })
982
-
983
- # Skip middleware/decorator frames but keep track of meaningful ones
984
- is_middleware = any(pattern in function_name.lower() for pattern in middleware_patterns)
985
-
986
- if not is_middleware:
987
- call_info = {
988
- 'function': function_name,
989
- 'file': filename,
990
- 'line': line_number,
991
- 'code': code_line,
992
- 'full_path': file_path
993
- }
994
-
995
- relevant_calls.append(call_info)
996
-
997
- # The first non-middleware call is likely the most important
998
- if not top_level_call:
999
- # Look for application-level calls (not in velocity internals)
1000
- if not any(internal in file_path for internal in [
1001
- 'velocity/db/', 'velocity/aws/', 'velocity/misc/'
1002
- ]):
1003
- top_level_call = call_info
1004
-
1005
- # If no clear top-level call found, use the first relevant call
1006
- if not top_level_call and relevant_calls:
1007
- top_level_call = relevant_calls[0]
1008
-
1009
- # Look for handler functions in the stack
1010
- handler_calls = [call for call in relevant_calls
1011
- if any(pattern in call['function'].lower()
1012
- for pattern in ['handler', 'main', 'process', 'action'])]
1013
-
1014
- if handler_calls and not top_level_call:
1015
- top_level_call = handler_calls[0]
1016
-
1017
- return {
1018
- 'top_level_call': top_level_call,
1019
- 'relevant_calls': relevant_calls,
1020
- 'lambda_context': lambda_context if lambda_context else None
1021
- }
1022
-
1023
- except Exception:
1024
- # If stack analysis fails, return minimal info
1025
- return None
1026
-
1027
- def _extract_error_details(self, exception, message):
1028
- """
1029
- Extract specific details from database errors for better formatting.
1030
-
1031
- Args:
1032
- exception: The original database exception
1033
- message: The error message string
1034
-
1035
- Returns:
1036
- Dictionary with extracted details
1037
- """
1038
- import re
1039
-
1040
- details = {}
1041
-
1042
- # PostgreSQL specific error parsing
1043
- if hasattr(exception, 'pgcode'):
1044
- # Column does not exist
1045
- if 'column' in message.lower() and 'does not exist' in message.lower():
1046
- match = re.search(r'column "([^"]+)" does not exist', message, re.IGNORECASE)
1047
- if match:
1048
- details['column'] = match.group(1)
1049
- details['description'] = f'The column "{match.group(1)}" was not found in the table.'
1050
-
1051
- # Table does not exist
1052
- elif 'relation' in message.lower() and 'does not exist' in message.lower():
1053
- match = re.search(r'relation "([^"]+)" does not exist', message, re.IGNORECASE)
1054
- if match:
1055
- details['table'] = match.group(1)
1056
- details['description'] = f'The table "{match.group(1)}" was not found in the database.'
1057
-
1058
- # Foreign key violation
1059
- elif 'foreign key constraint' in message.lower():
1060
- match = re.search(r'violates foreign key constraint "([^"]+)"', message, re.IGNORECASE)
1061
- if match:
1062
- details['constraint'] = match.group(1)
1063
- details['description'] = 'A foreign key constraint was violated.'
1064
- details['hint'] = 'Make sure the referenced record exists.'
1065
-
1066
- # Unique constraint violation
1067
- elif 'unique constraint' in message.lower() or 'duplicate key' in message.lower():
1068
- match = re.search(r'violates unique constraint "([^"]+)"', message, re.IGNORECASE)
1069
- if match:
1070
- details['constraint'] = match.group(1)
1071
- details['description'] = 'A unique constraint was violated (duplicate key).'
1072
- details['hint'] = 'The value you are trying to insert already exists.'
1073
-
1074
- # Connection errors
1075
- elif any(term in message.lower() for term in ['connection', 'connect', 'server']):
1076
- details['description'] = 'Failed to connect to the database server.'
1077
- details['hint'] = 'Check your database connection settings and network connectivity.'
1078
-
1079
- # Data type errors
1080
- elif 'invalid input syntax' in message.lower():
1081
- details['description'] = 'Invalid data format provided.'
1082
- details['hint'] = 'Check that your data matches the expected format for the column type.'
1083
-
1084
- return details
1085
-
1086
- def _create_exception_with_chaining(self, error_class, message, original_exception, sql=None, parameters=None, format_type=None):
1087
- """
1088
- Create a velocity exception with proper exception chaining and human-readable formatting.
1089
-
1090
- Args:
1091
- error_class: The name of the exception class to create
1092
- message: The error message
1093
- original_exception: The original exception to chain
1094
- sql: The SQL statement (optional)
1095
- parameters: The SQL parameters (optional)
1096
- format_type: 'console', 'email', or None (auto-detect)
1097
-
1098
- Returns:
1099
- The created exception with proper chaining and formatting
1100
- """
1101
- logger = logging.getLogger(__name__)
1102
-
1103
- try:
1104
- # Import the exception class dynamically
1105
- exception_module = __import__('velocity.db.exceptions', fromlist=[error_class])
1106
- ExceptionClass = getattr(exception_module, error_class)
1107
-
1108
- # Auto-detect format if not specified
1109
- if format_type is None:
1110
- format_type = self._detect_output_format()
1111
-
1112
- # Create human-readable, formatted message
1113
- formatted_message = self._format_human_readable_error(
1114
- error_class, message, original_exception, sql, parameters, format_type
534
+ except Exception as e:
535
+ # If formatting fails, return original SQL with parameters shown separately
536
+ return (
537
+ f"{sql}\n-- Parameters (formatting failed): {parameters}\n-- Error: {e}"
1115
538
  )
1116
-
1117
- # For email format, also create a console version for logging
1118
- if format_type == 'email':
1119
- console_message = self._format_human_readable_error(
1120
- error_class, message, original_exception, sql, parameters, 'console'
1121
- )
1122
- # Log the console version for server logs
1123
- logger.error(f"Database Error (Console Format):\n{console_message}")
1124
-
1125
- # Create custom exception with both formats
1126
- new_exception = ExceptionClass(formatted_message)
1127
- new_exception.console_format = console_message
1128
- new_exception.email_format = formatted_message
1129
- else:
1130
- new_exception = ExceptionClass(formatted_message)
1131
-
1132
- # Only set __cause__ if original_exception is not None and derives from BaseException
1133
- if isinstance(original_exception, BaseException):
1134
- new_exception.__cause__ = original_exception # Preserve exception chain
1135
-
1136
- return new_exception
1137
-
1138
- except (ImportError, AttributeError) as e:
1139
- logger.error(f"Could not import exception class {error_class}: {e}")
1140
- # Fallback to generic database error
1141
- try:
1142
- exception_module = __import__('velocity.db.exceptions', fromlist=['DatabaseError'])
1143
- DatabaseError = getattr(exception_module, 'DatabaseError')
1144
-
1145
- # Auto-detect format if not specified for fallback too
1146
- if format_type is None:
1147
- format_type = self._detect_output_format()
1148
-
1149
- # Still format the fallback nicely
1150
- formatted_message = self._format_human_readable_error(
1151
- 'DatabaseError', message, original_exception, sql, parameters, format_type
1152
- )
1153
-
1154
- fallback_exception = DatabaseError(formatted_message)
1155
- # Only set __cause__ if original_exception is not None and derives from BaseException
1156
- if isinstance(original_exception, BaseException):
1157
- fallback_exception.__cause__ = original_exception
1158
- return fallback_exception
1159
- except Exception as fallback_error:
1160
- logger.critical(f"Failed to create fallback exception: {fallback_error}")
1161
- # Last resort: return the original exception
1162
- return original_exception
1163
-
1164
- def _detect_output_format(self):
1165
- """
1166
- Detect whether we should use console or email formatting based on context.
1167
-
1168
- Returns:
1169
- 'email' if in email/notification context, 'console' otherwise
1170
- """
1171
- import inspect
1172
-
1173
- # Look at the call stack for email-related functions
1174
- stack = inspect.stack()
1175
-
1176
- email_indicators = [
1177
- 'email', 'mail', 'notification', 'alert', 'send', 'notify',
1178
- 'smtp', 'message', 'recipient', 'subject', 'body'
1179
- ]
1180
-
1181
- for frame_info in stack:
1182
- function_name = frame_info.function.lower()
1183
- filename = frame_info.filename.lower()
1184
-
1185
- # Check if we're in an email-related context
1186
- if any(indicator in function_name or indicator in filename
1187
- for indicator in email_indicators):
1188
- return 'email'
1189
-
1190
- # Default to console format
1191
- return 'console'