feat(Production): Complete production deployment infrastructure

- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Exception;
use App\Framework\Database\ValueObjects\SqlState;
use App\Framework\Exception\Core\DatabaseErrorCode;
use App\Framework\Exception\ExceptionContext;
/**
* Connection Failed Exception
*
* Thrown when database connection cannot be established or is lost.
*
* This exception represents SQLSTATE class 08 (Connection Exception) errors:
* - 08001: Cannot connect to database
* - 08003: Connection does not exist
* - 08004: Server rejected connection
* - 08006: Connection failure during transaction
* - 08007: Transaction resolution unknown
*/
final class ConnectionFailedException extends DatabaseException
{
/**
* Create exception for general connection failure
*
* @param string $host Database host
* @param string $database Database name
* @param SqlState $sqlState The SQLSTATE error code
* @param string|null $errorMessage Optional database error message
* @param \Throwable|null $previous Previous exception
*/
public static function cannotConnect(
string $host,
string $database,
SqlState $sqlState,
?string $errorMessage = null,
?\Throwable $previous = null
): self {
$message = "Failed to connect to database '{$database}' on '{$host}': {$errorMessage}";
$context = ExceptionContext::forOperation('connection.establish', 'DatabaseConnection')
->withData([
'host' => $host,
'database' => $database,
'sqlstate' => $sqlState->code,
'error_category' => 'Connection Exception',
])
->withDebug([
'sqlstate_class' => $sqlState->getClass(),
'sqlstate_subclass' => $sqlState->getSubclass(),
]);
return self::fromContext($message, $context, DatabaseErrorCode::CONNECTION_FAILED, $previous, 500, $sqlState);
}
/**
* Create exception when connection does not exist
*
* @param SqlState $sqlState The SQLSTATE error code (typically 08003)
* @param string|null $connectionId Optional connection identifier
*/
public static function connectionDoesNotExist(
SqlState $sqlState,
?string $connectionId = null
): self {
$message = $connectionId
? "Database connection '{$connectionId}' does not exist or has been closed"
: 'Database connection does not exist or has been closed';
$context = ExceptionContext::forOperation('connection.use', 'DatabaseConnection')
->withData([
'sqlstate' => $sqlState->code,
'error_category' => 'Connection Exception',
]);
if ($connectionId !== null) {
$context = $context->withData(['connection_id' => $connectionId]);
}
return self::fromContext($message, $context, DatabaseErrorCode::CONNECTION_FAILED, null, 500, $sqlState);
}
/**
* Create exception when server rejects connection
*
* @param string $host Database host
* @param string $username Database username
* @param SqlState $sqlState The SQLSTATE error code (typically 08004)
* @param string|null $rejectionReason Optional rejection reason
*/
public static function serverRejectedConnection(
string $host,
string $username,
SqlState $sqlState,
?string $rejectionReason = null
): self {
$message = "Database server at '{$host}' rejected connection for user '{$username}'";
if ($rejectionReason !== null) {
$message .= ": {$rejectionReason}";
}
$context = ExceptionContext::forOperation('connection.authenticate', 'DatabaseConnection')
->withData([
'host' => $host,
'username' => $username,
'sqlstate' => $sqlState->code,
'error_category' => 'Connection Exception',
]);
if ($rejectionReason !== null) {
$context = $context->withDebug(['rejection_reason' => $rejectionReason]);
}
return self::fromContext($message, $context, DatabaseErrorCode::CONNECTION_FAILED, null, 500, $sqlState);
}
/**
* Create exception for connection failure during transaction
*
* @param SqlState $sqlState The SQLSTATE error code (typically 08006)
* @param string|null $transactionId Optional transaction identifier
*/
public static function connectionFailedDuringTransaction(
SqlState $sqlState,
?string $transactionId = null
): self {
$message = 'Database connection failed during transaction - transaction has been rolled back';
$context = ExceptionContext::forOperation('connection.transaction', 'DatabaseConnection')
->withData([
'sqlstate' => $sqlState->code,
'error_category' => 'Connection Exception',
'transaction_rolled_back' => true,
]);
if ($transactionId !== null) {
$context = $context->withData(['transaction_id' => $transactionId]);
}
return self::fromContext($message, $context, DatabaseErrorCode::CONNECTION_FAILED, null, 500, $sqlState);
}
/**
* Create exception for connection timeout
*
* @param string $host Database host
* @param int $timeoutSeconds Timeout duration in seconds
* @param SqlState $sqlState The SQLSTATE error code
*/
public static function connectionTimeout(
string $host,
int $timeoutSeconds,
SqlState $sqlState
): self {
$message = "Connection to database at '{$host}' timed out after {$timeoutSeconds} seconds";
$context = ExceptionContext::forOperation('connection.establish', 'DatabaseConnection')
->withData([
'host' => $host,
'timeout_seconds' => $timeoutSeconds,
'sqlstate' => $sqlState->code,
'error_category' => 'Connection Exception',
]);
return self::fromContext($message, $context, DatabaseErrorCode::CONNECTION_FAILED, null, 500, $sqlState);
}
/**
* Create exception for connection lost
*
* @param SqlState $sqlState The SQLSTATE error code
* @param string|null $lastQuery Optional last executed query
*/
public static function connectionLost(
SqlState $sqlState,
?string $lastQuery = null
): self {
$message = 'Database connection lost unexpectedly';
$context = ExceptionContext::forOperation('connection.lost', 'DatabaseConnection')
->withData([
'sqlstate' => $sqlState->code,
'error_category' => 'Connection Exception',
]);
if ($lastQuery !== null) {
$context = $context->withDebug([
'last_query' => self::truncateQuery($lastQuery),
]);
}
return self::fromContext($message, $context, DatabaseErrorCode::CONNECTION_FAILED, null, 500, $sqlState);
}
/**
* Create exception when too many connections
*
* @param string $host Database host
* @param int $maxConnections Maximum allowed connections
* @param SqlState $sqlState The SQLSTATE error code
*/
public static function tooManyConnections(
string $host,
int $maxConnections,
SqlState $sqlState
): self {
$message = "Too many connections to database at '{$host}' - maximum {$maxConnections} connections allowed";
$context = ExceptionContext::forOperation('connection.establish', 'DatabaseConnection')
->withData([
'host' => $host,
'max_connections' => $maxConnections,
'sqlstate' => $sqlState->code,
'error_category' => 'Connection Exception',
]);
return self::fromContext($message, $context, DatabaseErrorCode::POOL_EXHAUSTED, null, 500, $sqlState);
}
/**
* Create exception for SSL/TLS connection failure
*
* @param string $host Database host
* @param SqlState $sqlState The SQLSTATE error code
* @param string|null $sslError Optional SSL error message
*/
public static function sslConnectionFailed(
string $host,
SqlState $sqlState,
?string $sslError = null
): self {
$message = "SSL/TLS connection to database at '{$host}' failed";
if ($sslError !== null) {
$message .= ": {$sslError}";
}
$context = ExceptionContext::forOperation('connection.ssl', 'DatabaseConnection')
->withData([
'host' => $host,
'sqlstate' => $sqlState->code,
'error_category' => 'Connection Exception',
]);
if ($sslError !== null) {
$context = $context->withDebug(['ssl_error' => $sslError]);
}
return self::fromContext($message, $context, DatabaseErrorCode::CONNECTION_FAILED, null, 500, $sqlState);
}
/**
* Truncate query for safe logging
*
* @param string $query The SQL query
* @param int $maxLength Maximum length
* @return string Truncated query
*/
private static function truncateQuery(string $query, int $maxLength = 200): string
{
if (strlen($query) <= $maxLength) {
return $query;
}
return substr($query, 0, $maxLength) . '... (truncated)';
}
}

View File

@@ -0,0 +1,316 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Exception;
use App\Framework\Database\ValueObjects\SqlState;
use App\Framework\Exception\Core\DatabaseErrorCode;
use App\Framework\Exception\ExceptionContext;
/**
* Constraint Violation Exception
*
* Thrown when a database integrity constraint is violated.
*
* This exception represents SQLSTATE class 23 (Integrity Constraint Violation) errors:
* - 23000: Generic integrity constraint violation
* - 23502: Not null violation
* - 23503: Foreign key violation
* - 23505: Unique constraint violation
* - 23514: Check constraint violation
*/
final class ConstraintViolationException extends DatabaseException
{
/**
* Create exception for generic constraint violation
*
* @param string $constraintName Name of violated constraint
* @param SqlState $sqlState The SQLSTATE error code
* @param string|null $errorMessage Optional database error message
* @param \Throwable|null $previous Previous exception
*/
public static function constraintViolated(
string $constraintName,
SqlState $sqlState,
?string $errorMessage = null,
?\Throwable $previous = null
): self {
$message = "Integrity constraint '{$constraintName}' violated";
if ($errorMessage !== null) {
$message .= ": {$errorMessage}";
}
$context = ExceptionContext::forOperation('constraint.validate', 'Database')
->withData([
'constraint_name' => $constraintName,
'sqlstate' => $sqlState->code,
'error_category' => 'Integrity Constraint Violation',
]);
return self::fromContext($message, $context, DatabaseErrorCode::CONSTRAINT_VIOLATION, $previous, 500, $sqlState);
}
/**
* Create exception for unique constraint violation
*
* @param string $table Table name
* @param string $column Column name or constraint name
* @param mixed $value The duplicate value
* @param SqlState $sqlState The SQLSTATE error code (typically 23505)
*/
public static function uniqueViolation(
string $table,
string $column,
mixed $value,
SqlState $sqlState
): self {
$message = "Unique constraint violation on '{$table}.{$column}': value already exists";
$context = ExceptionContext::forOperation('constraint.unique', 'Database')
->withData([
'table' => $table,
'column' => $column,
'sqlstate' => $sqlState->code,
'error_category' => 'Integrity Constraint Violation',
'constraint_type' => 'unique',
])
->withDebug([
'duplicate_value' => self::sanitizeValue($value),
]);
return self::fromContext($message, $context, DatabaseErrorCode::CONSTRAINT_VIOLATION, null, 500, $sqlState);
}
/**
* Create exception for foreign key violation
*
* @param string $table Table name
* @param string $foreignKey Foreign key column
* @param string $referencedTable Referenced table name
* @param mixed $value The violating value
* @param SqlState $sqlState The SQLSTATE error code (typically 23503)
*/
public static function foreignKeyViolation(
string $table,
string $foreignKey,
string $referencedTable,
mixed $value,
SqlState $sqlState
): self {
$message = "Foreign key constraint violation on '{$table}.{$foreignKey}': " .
"referenced record in '{$referencedTable}' does not exist";
$context = ExceptionContext::forOperation('constraint.foreign_key', 'Database')
->withData([
'table' => $table,
'foreign_key' => $foreignKey,
'referenced_table' => $referencedTable,
'sqlstate' => $sqlState->code,
'error_category' => 'Integrity Constraint Violation',
'constraint_type' => 'foreign_key',
])
->withDebug([
'violating_value' => self::sanitizeValue($value),
]);
return self::fromContext($message, $context, DatabaseErrorCode::CONSTRAINT_VIOLATION, null, 500, $sqlState);
}
/**
* Create exception for foreign key violation on delete
*
* @param string $table Table name
* @param mixed $deletedId ID being deleted
* @param string $referencingTable Table that references the record
* @param SqlState $sqlState The SQLSTATE error code (typically 23503)
*/
public static function foreignKeyViolationOnDelete(
string $table,
mixed $deletedId,
string $referencingTable,
SqlState $sqlState
): self {
$message = "Cannot delete record from '{$table}': " .
"referenced by records in '{$referencingTable}'";
$context = ExceptionContext::forOperation('constraint.foreign_key_delete', 'Database')
->withData([
'table' => $table,
'referencing_table' => $referencingTable,
'sqlstate' => $sqlState->code,
'error_category' => 'Integrity Constraint Violation',
'constraint_type' => 'foreign_key',
'operation' => 'delete',
])
->withDebug([
'deleted_id' => self::sanitizeValue($deletedId),
]);
return self::fromContext($message, $context, DatabaseErrorCode::CONSTRAINT_VIOLATION, null, 500, $sqlState);
}
/**
* Create exception for NOT NULL violation
*
* @param string $table Table name
* @param string $column Column name
* @param SqlState $sqlState The SQLSTATE error code (typically 23502)
*/
public static function notNullViolation(
string $table,
string $column,
SqlState $sqlState
): self {
$message = "NOT NULL constraint violation on '{$table}.{$column}': value cannot be null";
$context = ExceptionContext::forOperation('constraint.not_null', 'Database')
->withData([
'table' => $table,
'column' => $column,
'sqlstate' => $sqlState->code,
'error_category' => 'Integrity Constraint Violation',
'constraint_type' => 'not_null',
]);
return self::fromContext($message, $context, DatabaseErrorCode::CONSTRAINT_VIOLATION, null, 500, $sqlState);
}
/**
* Create exception for CHECK constraint violation
*
* @param string $table Table name
* @param string $constraintName Check constraint name
* @param SqlState $sqlState The SQLSTATE error code (typically 23514)
* @param string|null $checkCondition Optional check condition description
*/
public static function checkViolation(
string $table,
string $constraintName,
SqlState $sqlState,
?string $checkCondition = null
): self {
$message = "CHECK constraint '{$constraintName}' violated on table '{$table}'";
if ($checkCondition !== null) {
$message .= ": {$checkCondition}";
}
$context = ExceptionContext::forOperation('constraint.check', 'Database')
->withData([
'table' => $table,
'constraint_name' => $constraintName,
'sqlstate' => $sqlState->code,
'error_category' => 'Integrity Constraint Violation',
'constraint_type' => 'check',
]);
if ($checkCondition !== null) {
$context = $context->withDebug(['check_condition' => $checkCondition]);
}
return self::fromContext($message, $context, DatabaseErrorCode::CONSTRAINT_VIOLATION, null, 500, $sqlState);
}
/**
* Create exception for primary key violation
*
* @param string $table Table name
* @param string $primaryKey Primary key column(s)
* @param mixed $value The duplicate value
* @param SqlState $sqlState The SQLSTATE error code (typically 23505)
*/
public static function primaryKeyViolation(
string $table,
string $primaryKey,
mixed $value,
SqlState $sqlState
): self {
$message = "Primary key constraint violation on '{$table}.{$primaryKey}': value already exists";
$context = ExceptionContext::forOperation('constraint.primary_key', 'Database')
->withData([
'table' => $table,
'primary_key' => $primaryKey,
'sqlstate' => $sqlState->code,
'error_category' => 'Integrity Constraint Violation',
'constraint_type' => 'primary_key',
])
->withDebug([
'duplicate_value' => self::sanitizeValue($value),
]);
return self::fromContext($message, $context, DatabaseErrorCode::CONSTRAINT_VIOLATION, null, 500, $sqlState);
}
/**
* Create exception for composite unique constraint violation
*
* @param string $table Table name
* @param array<string> $columns Array of column names
* @param array<string, mixed> $values Array of column => value pairs
* @param SqlState $sqlState The SQLSTATE error code (typically 23505)
*/
public static function compositeUniqueViolation(
string $table,
array $columns,
array $values,
SqlState $sqlState
): self {
$columnList = implode(', ', $columns);
$message = "Composite unique constraint violation on '{$table}' (" . $columnList . "): combination already exists";
$context = ExceptionContext::forOperation('constraint.composite_unique', 'Database')
->withData([
'table' => $table,
'columns' => $columns,
'sqlstate' => $sqlState->code,
'error_category' => 'Integrity Constraint Violation',
'constraint_type' => 'composite_unique',
])
->withDebug([
'duplicate_values' => self::sanitizeArray($values),
]);
return self::fromContext($message, $context, DatabaseErrorCode::CONSTRAINT_VIOLATION, null, 500, $sqlState);
}
/**
* Sanitize value for logging (prevent logging sensitive data)
*
* @param mixed $value The value to sanitize
* @return string Sanitized value
*/
private static function sanitizeValue(mixed $value): string
{
if (is_array($value)) {
return '[array:' . count($value) . ']';
}
if (is_object($value)) {
return '[object:' . get_class($value) . ']';
}
if (is_string($value) && strlen($value) > 100) {
return substr($value, 0, 100) . '... (truncated)';
}
return (string) $value;
}
/**
* Sanitize array for logging
*
* @param array<string, mixed> $values Array to sanitize
* @return array<string, string> Sanitized array
*/
private static function sanitizeArray(array $values): array
{
$sanitized = [];
foreach ($values as $key => $value) {
$sanitized[$key] = self::sanitizeValue($value);
}
return $sanitized;
}
}

View File

@@ -4,8 +4,79 @@ declare(strict_types=1);
namespace App\Framework\Database\Exception;
use App\Framework\Database\ValueObjects\SqlState;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\ExceptionMetadata;
use App\Framework\Exception\FrameworkException;
class DatabaseException extends FrameworkException
{
public function __construct(
string $message,
ExceptionContext $context,
ErrorCode $errorCode,
protected ?SqlState $sqlState = null,
?\Throwable $previous = null,
int $httpStatusCode = 500,
?ExceptionMetadata $metadata = null
) {
parent::__construct($message, $context, $httpStatusCode, $previous, $errorCode, $metadata);
}
/**
* Create exception from context with optional SQLSTATE
*
* NOTE: SqlState is added as last parameter to maintain parent signature compatibility
* Parent signature: fromContext(string, ExceptionContext, ?ErrorCode, ?\Throwable, int)
*/
public static function fromContext(
string $message,
ExceptionContext $context,
?ErrorCode $errorCode = null,
?\Throwable $previous = null,
int $httpStatusCode = 500,
?SqlState $sqlState = null
): static {
return new static(
message: $message,
context: $context,
errorCode: $errorCode ?? ErrorCode::INTERNAL,
sqlState: $sqlState,
previous: $previous,
httpStatusCode: $httpStatusCode
);
}
/**
* Get the SQLSTATE associated with this exception
*/
public function getSqlState(): ?SqlState
{
return $this->sqlState;
}
/**
* Check if this exception has an associated SQLSTATE
*/
public function hasSqlState(): bool
{
return $this->sqlState !== null;
}
/**
* Override withMetadata to maintain DatabaseException structure
*/
public function withMetadata(ExceptionMetadata $metadata): static
{
return new static(
message: $this->getMessage(),
context: $this->context,
errorCode: $this->errorCode ?? ErrorCode::INTERNAL,
sqlState: $this->sqlState,
previous: $this->getPrevious(),
httpStatusCode: $this->getCode(),
metadata: $metadata
);
}
}

View File

@@ -0,0 +1,267 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Exception;
use App\Framework\Database\ValueObjects\SqlState;
use App\Framework\Exception\Core\DatabaseErrorCode;
use App\Framework\Exception\ExceptionContext;
/**
* Deadlock Exception
*
* Thrown when a database deadlock is detected.
*
* This exception represents SQLSTATE 40001 (Serialization Failure) errors,
* typically caused by deadlocks or serialization conflicts in concurrent transactions.
*
* Deadlocks are typically recoverable by retrying the transaction.
*/
final class DeadlockException extends DatabaseException
{
/**
* Create exception for deadlock detection
*
* @param SqlState $sqlState The SQLSTATE error code (typically 40001)
* @param string|null $query Optional query that triggered the deadlock
* @param \Throwable|null $previous Previous exception
*/
public static function detected(
SqlState $sqlState,
?string $query = null,
?\Throwable $previous = null
): self {
$message = 'Deadlock detected - transaction conflict with concurrent operation';
$context = ExceptionContext::forOperation('transaction.deadlock', 'Database')
->withData([
'sqlstate' => $sqlState->code,
'error_category' => 'Transaction Rollback',
'recoverable' => true,
'retry_recommended' => true,
]);
if ($query !== null) {
$context = $context->withDebug([
'query' => self::truncateQuery($query),
]);
}
return self::fromContext($message, $context, DatabaseErrorCode::DEADLOCK_DETECTED, $previous, 500, $sqlState);
}
/**
* Create exception for deadlock with transaction details
*
* @param string $transactionId Transaction identifier
* @param SqlState $sqlState The SQLSTATE error code (typically 40001)
* @param int $attemptNumber Current retry attempt number
* @param string|null $conflictingQuery Optional conflicting query
*/
public static function inTransaction(
string $transactionId,
SqlState $sqlState,
int $attemptNumber = 1,
?string $conflictingQuery = null
): self {
$message = "Deadlock detected in transaction '{$transactionId}'";
if ($attemptNumber > 1) {
$message .= " (attempt {$attemptNumber})";
}
$context = ExceptionContext::forOperation('transaction.deadlock', 'Database')
->withData([
'transaction_id' => $transactionId,
'attempt_number' => $attemptNumber,
'sqlstate' => $sqlState->code,
'error_category' => 'Transaction Rollback',
'recoverable' => true,
'retry_recommended' => true,
]);
if ($conflictingQuery !== null) {
$context = $context->withDebug([
'conflicting_query' => self::truncateQuery($conflictingQuery),
]);
}
return self::fromContext($message, $context, DatabaseErrorCode::DEADLOCK_DETECTED, null, 500, $sqlState);
}
/**
* Create exception for serialization failure
*
* @param SqlState $sqlState The SQLSTATE error code (typically 40001)
* @param string|null $isolationLevel Optional transaction isolation level
*/
public static function serializationFailure(
SqlState $sqlState,
?string $isolationLevel = null
): self {
$message = 'Serialization failure - could not serialize access due to concurrent update';
$context = ExceptionContext::forOperation('transaction.serialization', 'Database')
->withData([
'sqlstate' => $sqlState->code,
'error_category' => 'Transaction Rollback',
'recoverable' => true,
'retry_recommended' => true,
]);
if ($isolationLevel !== null) {
$context = $context->withData(['isolation_level' => $isolationLevel]);
}
return self::fromContext($message, $context, DatabaseErrorCode::DEADLOCK_DETECTED, null, 500, $sqlState);
}
/**
* Create exception for deadlock with resource details
*
* @param array<string> $lockedResources Array of locked resource identifiers
* @param SqlState $sqlState The SQLSTATE error code (typically 40001)
*/
public static function withLockedResources(
array $lockedResources,
SqlState $sqlState
): self {
$resourceList = implode(', ', array_slice($lockedResources, 0, 5));
$message = 'Deadlock detected on resources: ' . $resourceList;
if (count($lockedResources) > 5) {
$message .= ' and ' . (count($lockedResources) - 5) . ' more';
}
$context = ExceptionContext::forOperation('transaction.deadlock', 'Database')
->withData([
'locked_resources_count' => count($lockedResources),
'sqlstate' => $sqlState->code,
'error_category' => 'Transaction Rollback',
'recoverable' => true,
'retry_recommended' => true,
])
->withDebug([
'locked_resources' => $lockedResources,
]);
return self::fromContext($message, $context, DatabaseErrorCode::DEADLOCK_DETECTED, null, 500, $sqlState);
}
/**
* Create exception after maximum retry attempts
*
* @param int $maxAttempts Maximum number of retry attempts
* @param SqlState $sqlState The SQLSTATE error code (typically 40001)
* @param string|null $transactionId Optional transaction identifier
*/
public static function maxRetriesExceeded(
int $maxAttempts,
SqlState $sqlState,
?string $transactionId = null
): self {
$message = "Deadlock persists after {$maxAttempts} retry attempts";
if ($transactionId !== null) {
$message .= " for transaction '{$transactionId}'";
}
$context = ExceptionContext::forOperation('transaction.deadlock_retries', 'Database')
->withData([
'max_attempts' => $maxAttempts,
'sqlstate' => $sqlState->code,
'error_category' => 'Transaction Rollback',
'recoverable' => false, // Not recoverable after max retries
'retry_recommended' => false,
]);
if ($transactionId !== null) {
$context = $context->withData(['transaction_id' => $transactionId]);
}
return self::fromContext($message, $context, DatabaseErrorCode::DEADLOCK_DETECTED, null, 500, $sqlState);
}
/**
* Create exception for deadlock with execution time
*
* @param float $executionTimeMs Execution time in milliseconds before deadlock
* @param SqlState $sqlState The SQLSTATE error code (typically 40001)
* @param string|null $query Optional query that was executing
*/
public static function withExecutionTime(
float $executionTimeMs,
SqlState $sqlState,
?string $query = null
): self {
$message = "Deadlock detected after {$executionTimeMs}ms of execution";
$context = ExceptionContext::forOperation('transaction.deadlock', 'Database')
->withData([
'execution_time_ms' => $executionTimeMs,
'sqlstate' => $sqlState->code,
'error_category' => 'Transaction Rollback',
'recoverable' => true,
'retry_recommended' => true,
]);
if ($query !== null) {
$context = $context->withDebug([
'query' => self::truncateQuery($query),
]);
}
return self::fromContext($message, $context, DatabaseErrorCode::DEADLOCK_DETECTED, null, 500, $sqlState);
}
/**
* Get recommended retry delay based on attempt number
*
* Uses exponential backoff with jitter for retry delays.
*
* @param int $attemptNumber Current attempt number (1-indexed)
* @return int Delay in milliseconds
*/
public static function getRetryDelay(int $attemptNumber): int
{
// Base delay: 100ms
// Exponential backoff: base * 2^(attempt - 1)
// Max delay: 5 seconds
$baseDelay = 100;
$delay = min($baseDelay * (2 ** ($attemptNumber - 1)), 5000);
// Add jitter: ±25% random variation
$jitter = (int) ($delay * 0.25 * (mt_rand(-100, 100) / 100));
return max(1, $delay + $jitter);
}
/**
* Check if retry is recommended based on attempt number
*
* @param int $attemptNumber Current attempt number (1-indexed)
* @param int $maxAttempts Maximum allowed attempts
* @return bool True if retry is recommended
*/
public static function shouldRetry(int $attemptNumber, int $maxAttempts = 3): bool
{
return $attemptNumber < $maxAttempts;
}
/**
* Truncate query for safe logging
*
* @param string $query The SQL query
* @param int $maxLength Maximum length
* @return string Truncated query
*/
private static function truncateQuery(string $query, int $maxLength = 200): string
{
if (strlen($query) <= $maxLength) {
return $query;
}
return substr($query, 0, $maxLength) . '... (truncated)';
}
}

View File

@@ -4,10 +4,63 @@ declare(strict_types=1);
namespace App\Framework\Database\Exception;
class EntityNotFoundException extends DatabaseException
use App\Framework\Exception\Core\DatabaseErrorCode;
use App\Framework\Exception\ExceptionContext;
/**
* Entity Not Found Exception
*
* Thrown when an entity cannot be found by its identifier.
* This is typically a 404-like error at the database layer.
*/
final class EntityNotFoundException extends DatabaseException
{
public function __construct(string $entityClass, mixed $id, ?\Throwable $previous = null)
/**
* Create exception for entity not found by ID
*
* @param string $entityClass Fully qualified entity class name
* @param mixed $id Entity identifier
* @param ?\Throwable $previous Previous exception
*/
public static function byId(string $entityClass, mixed $id, ?\Throwable $previous = null): self
{
parent::__construct("Entity {$entityClass} with ID {$id} not found", 0, $previous);
$message = "Entity '{$entityClass}' with ID '{$id}' not found";
$context = ExceptionContext::forOperation('entity.find', 'EntityManager')
->withData([
'entity_class' => $entityClass,
'entity_id' => (string) $id,
'lookup_type' => 'by_id',
]);
return self::fromContext($message, $context, DatabaseErrorCode::ENTITY_NOT_FOUND, $previous, 404);
}
/**
* Create exception for entity not found by criteria
*
* @param string $entityClass Fully qualified entity class name
* @param array<string, mixed> $criteria Search criteria
* @param ?\Throwable $previous Previous exception
*/
public static function byCriteria(string $entityClass, array $criteria, ?\Throwable $previous = null): self
{
$criteriaStr = json_encode($criteria);
$message = "Entity '{$entityClass}' not found with criteria: {$criteriaStr}";
$context = ExceptionContext::forOperation('entity.find', 'EntityManager')
->withData([
'entity_class' => $entityClass,
'lookup_type' => 'by_criteria',
])
->withDebug([
'criteria' => $criteria,
]);
return self::fromContext($message, $context, DatabaseErrorCode::ENTITY_NOT_FOUND, $previous, 404);
}
// No custom constructor - uses parent DatabaseException constructor directly
// For backward compatibility with old usage, use factory methods:
// EntityNotFoundException::byId($entityClass, $id) instead of new EntityNotFoundException($entityClass, $id)
}

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Exception;
use App\Framework\Database\ValueObjects\SqlState;
use App\Framework\Exception\Core\DatabaseErrorCode;
use App\Framework\Exception\ExceptionContext;
/**
* Pool Exhausted Exception
*
* Thrown when the connection pool has reached its maximum capacity
* and no connections are available.
*
* This is typically a recoverable error - the application should retry
* after a brief delay or implement connection pooling strategies.
*/
final class PoolExhaustedException extends DatabaseException
{
/**
* Create exception when pool is exhausted
*
* @param int $currentConnections Current number of connections
* @param int $maxConnections Maximum allowed connections
* @param int $connectionsInUse Number of connections currently in use
*/
public static function noConnectionsAvailable(
int $currentConnections,
int $maxConnections,
int $connectionsInUse
): self {
$message = "Connection pool exhausted: {$currentConnections}/{$maxConnections} connections in pool, " .
"{$connectionsInUse} in use, no healthy connections available";
$context = ExceptionContext::forOperation('connection_pool.acquire', 'ConnectionPool')
->withData([
'current_connections' => $currentConnections,
'max_connections' => $maxConnections,
'connections_in_use' => $connectionsInUse,
'free_connections' => $currentConnections - $connectionsInUse,
'utilization_percent' => round(($currentConnections / $maxConnections) * 100, 2),
]);
// Use SQLSTATE HY000 (general driver error) for pool exhaustion
return self::fromContext($message, $context, DatabaseErrorCode::POOL_EXHAUSTED, null, 500, new SqlState('HY000'));
}
/**
* Create exception when max connections reached but all are unhealthy
*
* @param int $maxConnections Maximum allowed connections
* @param int $unhealthyConnections Number of unhealthy connections
*/
public static function allConnectionsUnhealthy(
int $maxConnections,
int $unhealthyConnections
): self {
$message = "Connection pool exhausted: {$maxConnections} max connections reached, " .
"but all {$unhealthyConnections} connections are unhealthy";
$context = ExceptionContext::forOperation('connection_pool.acquire', 'ConnectionPool')
->withData([
'max_connections' => $maxConnections,
'unhealthy_connections' => $unhealthyConnections,
'health_status' => 'critical',
]);
return self::fromContext($message, $context, DatabaseErrorCode::POOL_EXHAUSTED, null, 500, new SqlState('HY000'));
}
/**
* Create exception when waiting for connection times out
*
* @param int $waitTimeSeconds Time spent waiting
* @param int $currentConnections Current pool size
* @param int $maxConnections Maximum pool size
*/
public static function waitTimeout(
int $waitTimeSeconds,
int $currentConnections,
int $maxConnections
): self {
$message = "Connection pool wait timeout after {$waitTimeSeconds} seconds - " .
"no connections became available";
$context = ExceptionContext::forOperation('connection_pool.wait', 'ConnectionPool')
->withData([
'wait_time_seconds' => $waitTimeSeconds,
'current_connections' => $currentConnections,
'max_connections' => $maxConnections,
]);
return self::fromContext($message, $context, DatabaseErrorCode::TIMEOUT, null, 500, new SqlState('HY000'));
}
/**
* Create exception when pool cannot create new connections
*
* @param int $currentConnections Current pool size
* @param int $maxConnections Maximum pool size
* @param string|null $reason Optional reason why connection creation failed
* @param \Throwable|null $previous Previous exception
*/
public static function cannotCreateConnection(
int $currentConnections,
int $maxConnections,
?string $reason = null,
?\Throwable $previous = null
): self {
$message = "Connection pool cannot create new connections";
if ($reason !== null) {
$message .= ": {$reason}";
}
$context = ExceptionContext::forOperation('connection_pool.create', 'ConnectionPool')
->withData([
'current_connections' => $currentConnections,
'max_connections' => $maxConnections,
]);
if ($reason !== null) {
$context = $context->withDebug(['failure_reason' => $reason]);
}
return self::fromContext($message, $context, DatabaseErrorCode::POOL_EXHAUSTED, $previous, 500, new SqlState('HY000'));
}
/**
* Create exception with retry recommendation
*
* @param int $currentConnections Current pool size
* @param int $maxConnections Maximum pool size
* @param int $recommendedRetryDelayMs Recommended retry delay in milliseconds
*/
public static function withRetryRecommendation(
int $currentConnections,
int $maxConnections,
int $recommendedRetryDelayMs
): self {
$message = "Connection pool temporarily exhausted - retry after {$recommendedRetryDelayMs}ms";
$context = ExceptionContext::forOperation('connection_pool.acquire', 'ConnectionPool')
->withData([
'current_connections' => $currentConnections,
'max_connections' => $maxConnections,
'recommended_retry_delay_ms' => $recommendedRetryDelayMs,
]);
$exception = self::fromContext($message, $context, DatabaseErrorCode::POOL_EXHAUSTED, null, 500, new SqlState('HY000'));
// Set retry after in seconds
return $exception->withRetryAfter((int) ceil($recommendedRetryDelayMs / 1000));
}
/**
* Get recommended action for pool exhaustion
*
* @return string Recommended action
*/
public function getRecommendedAction(): string
{
$data = $this->context->data;
if (isset($data['utilization_percent']) && $data['utilization_percent'] >= 95) {
return 'Increase max_connections pool size or optimize connection usage';
}
if (isset($data['unhealthy_connections']) && $data['unhealthy_connections'] > 0) {
return 'Check database server health - connections are failing health checks';
}
if (isset($data['recommended_retry_delay_ms'])) {
return "Retry after {$data['recommended_retry_delay_ms']}ms delay";
}
return 'Retry operation or implement connection pooling strategies';
}
}

View File

@@ -0,0 +1,282 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Exception;
use App\Framework\Database\ValueObjects\SqlState;
use App\Framework\Exception\Core\DatabaseErrorCode;
use App\Framework\Exception\ExceptionContext;
/**
* Query Execution Exception
*
* Thrown when a database query fails to execute due to data errors,
* runtime errors, or invalid data format.
*
* This exception represents errors that occur during query execution
* (not syntax errors), such as data type mismatches, constraint violations,
* or data format issues.
*/
final class QueryExecutionException extends DatabaseException
{
/**
* Create exception for general query execution failure
*
* @param string $query The SQL query that failed
* @param SqlState $sqlState The SQLSTATE error code
* @param string|null $errorMessage Optional database error message
* @param \Throwable|null $previous Previous exception
*/
public static function forQuery(
string $query,
SqlState $sqlState,
?string $errorMessage = null,
?\Throwable $previous = null
): self {
$message = "Query execution failed: {$errorMessage}";
$context = ExceptionContext::forOperation('query.execute', 'Database')
->withData([
'query' => self::truncateQuery($query),
'sqlstate' => $sqlState->code,
'sqlstate_class' => $sqlState->getClass(),
'error_category' => self::getSqlStateCategory($sqlState),
])
->withDebug([
'full_query' => $query,
'sqlstate_subclass' => $sqlState->getSubclass(),
]);
return self::fromContext($message, $context, DatabaseErrorCode::QUERY_EXECUTION_FAILED, $previous, 500, $sqlState);
}
/**
* Create exception for data type mismatch
*
* @param string $column The column name
* @param string $expectedType Expected data type
* @param mixed $actualValue Actual value provided
* @param SqlState $sqlState The SQLSTATE error code
*/
public static function forDataTypeMismatch(
string $column,
string $expectedType,
mixed $actualValue,
SqlState $sqlState
): self {
$actualType = get_debug_type($actualValue);
$message = "Data type mismatch for column '{$column}': expected {$expectedType}, got {$actualType}";
$context = ExceptionContext::forOperation('query.data_validation', 'Database')
->withData([
'column' => $column,
'expected_type' => $expectedType,
'actual_type' => $actualType,
'sqlstate' => $sqlState->code,
])
->withDebug([
'actual_value' => self::sanitizeValue($actualValue),
]);
return self::fromContext($message, $context, DatabaseErrorCode::QUERY_EXECUTION_FAILED, null, 500, $sqlState);
}
/**
* Create exception for data truncation
*
* @param string $column The column name
* @param int $maxLength Maximum allowed length
* @param int $actualLength Actual data length
* @param SqlState $sqlState The SQLSTATE error code (typically 22001)
*/
public static function forDataTruncation(
string $column,
int $maxLength,
int $actualLength,
SqlState $sqlState
): self {
$message = "Data too long for column '{$column}': maximum {$maxLength} characters, got {$actualLength}";
$context = ExceptionContext::forOperation('query.data_validation', 'Database')
->withData([
'column' => $column,
'max_length' => $maxLength,
'actual_length' => $actualLength,
'sqlstate' => $sqlState->code,
]);
return self::fromContext($message, $context, DatabaseErrorCode::QUERY_EXECUTION_FAILED, null, 500, $sqlState);
}
/**
* Create exception for numeric value out of range
*
* @param string $column The column name
* @param mixed $value The value that is out of range
* @param SqlState $sqlState The SQLSTATE error code (typically 22003)
*/
public static function forNumericOutOfRange(
string $column,
mixed $value,
SqlState $sqlState
): self {
$message = "Numeric value out of range for column '{$column}'";
$context = ExceptionContext::forOperation('query.data_validation', 'Database')
->withData([
'column' => $column,
'sqlstate' => $sqlState->code,
])
->withDebug([
'value' => self::sanitizeValue($value),
]);
return self::fromContext($message, $context, DatabaseErrorCode::QUERY_EXECUTION_FAILED, null, 500, $sqlState);
}
/**
* Create exception for invalid datetime format
*
* @param string $column The column name
* @param string $value The invalid datetime value
* @param SqlState $sqlState The SQLSTATE error code (typically 22007)
*/
public static function forInvalidDatetime(
string $column,
string $value,
SqlState $sqlState
): self {
$message = "Invalid datetime format for column '{$column}': {$value}";
$context = ExceptionContext::forOperation('query.data_validation', 'Database')
->withData([
'column' => $column,
'value' => $value,
'sqlstate' => $sqlState->code,
]);
return self::fromContext($message, $context, DatabaseErrorCode::QUERY_EXECUTION_FAILED, null, 500, $sqlState);
}
/**
* Create exception for division by zero
*
* @param string $expression The expression that caused division by zero
* @param SqlState $sqlState The SQLSTATE error code (typically 22012)
*/
public static function forDivisionByZero(
string $expression,
SqlState $sqlState
): self {
$message = "Division by zero error in expression: {$expression}";
$context = ExceptionContext::forOperation('query.execute', 'Database')
->withData([
'expression' => $expression,
'sqlstate' => $sqlState->code,
]);
return self::fromContext($message, $context, DatabaseErrorCode::QUERY_EXECUTION_FAILED, null, 500, $sqlState);
}
/**
* Create exception for invalid text representation
*
* @param string $column The column name
* @param string $value The invalid value
* @param SqlState $sqlState The SQLSTATE error code (typically 22P02)
*/
public static function forInvalidTextRepresentation(
string $column,
string $value,
SqlState $sqlState
): self {
$message = "Invalid text representation for column '{$column}': {$value}";
$context = ExceptionContext::forOperation('query.data_validation', 'Database')
->withData([
'column' => $column,
'value' => self::truncateValue($value),
'sqlstate' => $sqlState->code,
])
->withDebug([
'full_value' => $value,
]);
return self::fromContext($message, $context, DatabaseErrorCode::QUERY_EXECUTION_FAILED, null, 500, $sqlState);
}
/**
* Truncate query for safe logging
*
* @param string $query The SQL query
* @param int $maxLength Maximum length
* @return string Truncated query
*/
private static function truncateQuery(string $query, int $maxLength = 200): string
{
if (strlen($query) <= $maxLength) {
return $query;
}
return substr($query, 0, $maxLength) . '... (truncated)';
}
/**
* Truncate value for safe logging
*
* @param string $value The value
* @param int $maxLength Maximum length
* @return string Truncated value
*/
private static function truncateValue(string $value, int $maxLength = 100): string
{
if (strlen($value) <= $maxLength) {
return $value;
}
return substr($value, 0, $maxLength) . '... (truncated)';
}
/**
* Sanitize value for logging (prevent logging sensitive data)
*
* @param mixed $value The value to sanitize
* @return string Sanitized value
*/
private static function sanitizeValue(mixed $value): string
{
if (is_array($value)) {
return '[array:' . count($value) . ']';
}
if (is_object($value)) {
return '[object:' . get_class($value) . ']';
}
if (is_string($value) && strlen($value) > 100) {
return substr($value, 0, 100) . '... (truncated)';
}
return (string) $value;
}
/**
* Get human-readable category for SQLSTATE
*
* @param SqlState $sqlState The SQLSTATE code
* @return string Category name
*/
private static function getSqlStateCategory(SqlState $sqlState): string
{
return match ($sqlState->getClass()) {
'22' => 'Data Exception',
'23' => 'Integrity Constraint Violation',
'42' => 'Syntax Error or Access Violation',
'08' => 'Connection Exception',
'40' => 'Transaction Rollback',
default => 'Unknown',
};
}
}

View File

@@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Exception;
use App\Framework\Database\ValueObjects\SqlState;
use App\Framework\Exception\Core\DatabaseErrorCode;
use App\Framework\Exception\ExceptionContext;
/**
* Query Syntax Exception
*
* Thrown when a database query contains syntax errors or references
* invalid database objects (tables, columns, functions).
*
* This exception represents SQLSTATE class 42 (Syntax Error or Access Violation) errors:
* - 42000: Syntax error or access violation
* - 42501: Insufficient privilege
* - 42601: Syntax error
* - 42P01: Undefined table
* - 42703: Undefined column
* - 42883: Undefined function
* - 42S02: Table not found (MySQL)
* - 42S22: Column not found (MySQL)
*/
final class QuerySyntaxException extends DatabaseException
{
/**
* Create exception for general query syntax error
*
* @param string $query The SQL query that failed
* @param SqlState $sqlState The SQLSTATE error code
* @param string|null $errorMessage Optional database error message
* @param \Throwable|null $previous Previous exception
*/
public static function forQuery(
string $query,
SqlState $sqlState,
?string $errorMessage = null,
?\Throwable $previous = null
): self {
$message = "SQL syntax error: {$errorMessage}";
$context = ExceptionContext::forOperation('query.parse', 'Database')
->withData([
'query' => self::truncateQuery($query),
'sqlstate' => $sqlState->code,
'sqlstate_class' => $sqlState->getClass(),
'error_category' => 'Syntax Error or Access Violation',
])
->withDebug([
'full_query' => $query,
'sqlstate_subclass' => $sqlState->getSubclass(),
]);
return self::fromContext($message, $context, DatabaseErrorCode::QUERY_SYNTAX_ERROR, $previous, 500, $sqlState);
}
/**
* Create exception for undefined table
*
* @param string $tableName The table name that was not found
* @param SqlState $sqlState The SQLSTATE error code (typically 42P01 or 42S02)
* @param string|null $query Optional SQL query
*/
public static function tableNotFound(
string $tableName,
SqlState $sqlState,
?string $query = null
): self {
$message = "Table '{$tableName}' does not exist";
$context = ExceptionContext::forOperation('query.validate', 'Database')
->withData([
'table_name' => $tableName,
'sqlstate' => $sqlState->code,
'error_category' => 'Syntax Error or Access Violation',
]);
if ($query !== null) {
$context = $context->withDebug(['query' => self::truncateQuery($query)]);
}
return self::fromContext($message, $context, DatabaseErrorCode::QUERY_SYNTAX_ERROR, null, 500, $sqlState);
}
/**
* Create exception for undefined column
*
* @param string $columnName The column name that was not found
* @param string|null $tableName Optional table name
* @param SqlState $sqlState The SQLSTATE error code (typically 42703 or 42S22)
* @param string|null $query Optional SQL query
*/
public static function columnNotFound(
string $columnName,
?string $tableName,
SqlState $sqlState,
?string $query = null
): self {
$message = $tableName !== null
? "Column '{$tableName}.{$columnName}' does not exist"
: "Column '{$columnName}' does not exist";
$context = ExceptionContext::forOperation('query.validate', 'Database')
->withData([
'column_name' => $columnName,
'sqlstate' => $sqlState->code,
'error_category' => 'Syntax Error or Access Violation',
]);
if ($tableName !== null) {
$context = $context->withData(['table_name' => $tableName]);
}
if ($query !== null) {
$context = $context->withDebug(['query' => self::truncateQuery($query)]);
}
return self::fromContext($message, $context, DatabaseErrorCode::QUERY_SYNTAX_ERROR, null, 500, $sqlState);
}
/**
* Create exception for undefined function
*
* @param string $functionName The function name that was not found
* @param SqlState $sqlState The SQLSTATE error code (typically 42883)
* @param string|null $query Optional SQL query
*/
public static function functionNotFound(
string $functionName,
SqlState $sqlState,
?string $query = null
): self {
$message = "Function '{$functionName}' does not exist";
$context = ExceptionContext::forOperation('query.validate', 'Database')
->withData([
'function_name' => $functionName,
'sqlstate' => $sqlState->code,
'error_category' => 'Syntax Error or Access Violation',
]);
if ($query !== null) {
$context = $context->withDebug(['query' => self::truncateQuery($query)]);
}
return self::fromContext($message, $context, DatabaseErrorCode::QUERY_SYNTAX_ERROR, null, 500, $sqlState);
}
/**
* Create exception for insufficient privileges
*
* @param string $operation The operation that was attempted (e.g., SELECT, INSERT, DROP)
* @param string|null $objectName Optional database object name
* @param SqlState $sqlState The SQLSTATE error code (typically 42501)
*/
public static function insufficientPrivileges(
string $operation,
?string $objectName,
SqlState $sqlState
): self {
$message = $objectName !== null
? "Insufficient privileges to {$operation} on '{$objectName}'"
: "Insufficient privileges to perform {$operation}";
$context = ExceptionContext::forOperation('query.authorize', 'Database')
->withData([
'operation' => $operation,
'sqlstate' => $sqlState->code,
'error_category' => 'Syntax Error or Access Violation',
]);
if ($objectName !== null) {
$context = $context->withData(['object_name' => $objectName]);
}
return self::fromContext($message, $context, DatabaseErrorCode::QUERY_SYNTAX_ERROR, null, 500, $sqlState);
}
/**
* Create exception for ambiguous column reference
*
* @param string $columnName The ambiguous column name
* @param SqlState $sqlState The SQLSTATE error code
* @param string|null $query Optional SQL query
*/
public static function ambiguousColumn(
string $columnName,
SqlState $sqlState,
?string $query = null
): self {
$message = "Ambiguous column reference '{$columnName}' - specify table name";
$context = ExceptionContext::forOperation('query.validate', 'Database')
->withData([
'column_name' => $columnName,
'sqlstate' => $sqlState->code,
'error_category' => 'Syntax Error or Access Violation',
]);
if ($query !== null) {
$context = $context->withDebug(['query' => self::truncateQuery($query)]);
}
return self::fromContext($message, $context, DatabaseErrorCode::QUERY_SYNTAX_ERROR, null, 500, $sqlState);
}
/**
* Create exception for invalid SQL syntax at specific position
*
* @param string $query The SQL query
* @param int $position Error position in query
* @param string $nearText Text near the error
* @param SqlState $sqlState The SQLSTATE error code
*/
public static function syntaxErrorAt(
string $query,
int $position,
string $nearText,
SqlState $sqlState
): self {
$message = "Syntax error at position {$position} near '{$nearText}'";
$context = ExceptionContext::forOperation('query.parse', 'Database')
->withData([
'error_position' => $position,
'near_text' => $nearText,
'query' => self::truncateQuery($query),
'sqlstate' => $sqlState->code,
'error_category' => 'Syntax Error or Access Violation',
])
->withDebug([
'full_query' => $query,
]);
return self::fromContext($message, $context, DatabaseErrorCode::QUERY_SYNTAX_ERROR, null, 500, $sqlState);
}
/**
* Truncate query for safe logging
*
* @param string $query The SQL query
* @param int $maxLength Maximum length
* @return string Truncated query
*/
private static function truncateQuery(string $query, int $maxLength = 200): string
{
if (strlen($query) <= $maxLength) {
return $query;
}
return substr($query, 0, $maxLength) . '... (truncated)';
}
}