- 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.
778 lines
23 KiB
Markdown
778 lines
23 KiB
Markdown
# SQLSTATE Integration Design
|
|
|
|
Comprehensive design for SQLSTATE-aware database exception handling.
|
|
|
|
## Overview
|
|
|
|
PDO exceptions provide SQLSTATE error codes that follow the SQL standard. These 5-character codes provide precise error categorization that should be leveraged for better error handling, recovery strategies, and user-facing error messages.
|
|
|
|
## SQLSTATE Structure
|
|
|
|
```
|
|
SQLSTATE: 5 characters (ANSI SQL-92)
|
|
Format: [Class][Subclass]
|
|
|
|
Class (2 chars): Error category
|
|
Subclass (3 chars): Specific error
|
|
|
|
Example: 23000
|
|
- 23: Integrity Constraint Violation
|
|
- 000: General (no subclass)
|
|
|
|
Example: 42S02
|
|
- 42: Syntax Error or Access Violation
|
|
- S02: Table or view not found
|
|
```
|
|
|
|
## Common SQLSTATE Classes
|
|
|
|
### Class 08: Connection Exceptions
|
|
```
|
|
08000 - Connection exception
|
|
08001 - Unable to establish connection
|
|
08003 - Connection does not exist
|
|
08004 - Connection rejected
|
|
08006 - Connection failure
|
|
08007 - Transaction resolution unknown
|
|
```
|
|
|
|
### Class 23: Integrity Constraint Violations
|
|
```
|
|
23000 - Integrity constraint violation (general)
|
|
23001 - Restrict violation
|
|
23502 - Not null violation
|
|
23503 - Foreign key violation
|
|
23505 - Unique violation
|
|
23514 - Check violation
|
|
```
|
|
|
|
### Class 40: Transaction Rollback
|
|
```
|
|
40000 - Transaction rollback
|
|
40001 - Serialization failure
|
|
40002 - Integrity constraint violation
|
|
40003 - Statement completion unknown
|
|
```
|
|
|
|
### Class 42: Syntax Error or Access Violation
|
|
```
|
|
42000 - Syntax error or access violation
|
|
42S01 - Table or view already exists
|
|
42S02 - Table or view not found
|
|
42S11 - Index already exists
|
|
42S12 - Index not found
|
|
42S21 - Column already exists
|
|
42S22 - Column not found
|
|
```
|
|
|
|
### Class HY: Driver-Specific Errors
|
|
```
|
|
HY000 - General error
|
|
HY001 - Memory allocation error
|
|
HY004 - Invalid SQL data type
|
|
HY008 - Operation canceled
|
|
```
|
|
|
|
## Proposed Architecture
|
|
|
|
### 1. SQLSTATE Value Object
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\Database\ValueObjects;
|
|
|
|
final readonly class SqlState
|
|
{
|
|
public function __construct(
|
|
public string $code // 5-character SQLSTATE code
|
|
) {
|
|
if (!preg_match('/^[0-9A-Z]{5}$/', $code)) {
|
|
throw new \InvalidArgumentException("Invalid SQLSTATE format: {$code}");
|
|
}
|
|
}
|
|
|
|
public function getClass(): string
|
|
{
|
|
return substr($this->code, 0, 2);
|
|
}
|
|
|
|
public function getSubclass(): string
|
|
{
|
|
return substr($this->code, 2, 3);
|
|
}
|
|
|
|
public function isConnectionError(): bool
|
|
{
|
|
return $this->getClass() === '08';
|
|
}
|
|
|
|
public function isConstraintViolation(): bool
|
|
{
|
|
return $this->getClass() === '23';
|
|
}
|
|
|
|
public function isTransactionRollback(): bool
|
|
{
|
|
return $this->getClass() === '40';
|
|
}
|
|
|
|
public function isSyntaxError(): bool
|
|
{
|
|
return $this->getClass() === '42';
|
|
}
|
|
|
|
public function isUniqueViolation(): bool
|
|
{
|
|
return $this->code === '23505';
|
|
}
|
|
|
|
public function isForeignKeyViolation(): bool
|
|
{
|
|
return $this->code === '23503';
|
|
}
|
|
|
|
public function isNotNullViolation(): bool
|
|
{
|
|
return $this->code === '23502';
|
|
}
|
|
|
|
public function isTableNotFound(): bool
|
|
{
|
|
return $this->code === '42S02';
|
|
}
|
|
|
|
public function equals(self $other): bool
|
|
{
|
|
return $this->code === $other->code;
|
|
}
|
|
|
|
public function toString(): string
|
|
{
|
|
return $this->code;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2. SQLSTATE-to-ErrorCode Mapper
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\Database\Services;
|
|
|
|
use App\Framework\Database\ValueObjects\SqlState;
|
|
use App\Framework\Exception\Core\DatabaseErrorCode;
|
|
|
|
final readonly class SqlStateErrorMapper
|
|
{
|
|
public function mapToErrorCode(SqlState $sqlState): DatabaseErrorCode
|
|
{
|
|
return match ($sqlState->code) {
|
|
// Connection errors
|
|
'08001', '08003', '08004', '08006' => DatabaseErrorCode::CONNECTION_FAILED,
|
|
'08000' => DatabaseErrorCode::CONNECTION_LOST,
|
|
|
|
// Constraint violations
|
|
'23505' => DatabaseErrorCode::UNIQUE_CONSTRAINT_VIOLATION,
|
|
'23503' => DatabaseErrorCode::FOREIGN_KEY_CONSTRAINT_VIOLATION,
|
|
'23502' => DatabaseErrorCode::NOT_NULL_CONSTRAINT_VIOLATION,
|
|
'23514' => DatabaseErrorCode::CHECK_CONSTRAINT_VIOLATION,
|
|
'23000' => DatabaseErrorCode::CONSTRAINT_VIOLATION,
|
|
|
|
// Transaction errors
|
|
'40001' => DatabaseErrorCode::DEADLOCK_DETECTED,
|
|
'40000', '40002', '40003' => DatabaseErrorCode::TRANSACTION_FAILED,
|
|
|
|
// Syntax and access errors
|
|
'42S02' => DatabaseErrorCode::TABLE_NOT_FOUND,
|
|
'42S22' => DatabaseErrorCode::COLUMN_NOT_FOUND,
|
|
'42000' => DatabaseErrorCode::QUERY_SYNTAX_ERROR,
|
|
|
|
// Default
|
|
default => $this->mapByClass($sqlState),
|
|
};
|
|
}
|
|
|
|
private function mapByClass(SqlState $sqlState): DatabaseErrorCode
|
|
{
|
|
return match ($sqlState->getClass()) {
|
|
'08' => DatabaseErrorCode::CONNECTION_FAILED,
|
|
'23' => DatabaseErrorCode::CONSTRAINT_VIOLATION,
|
|
'40' => DatabaseErrorCode::TRANSACTION_FAILED,
|
|
'42' => DatabaseErrorCode::QUERY_SYNTAX_ERROR,
|
|
default => DatabaseErrorCode::QUERY_EXECUTION_FAILED,
|
|
};
|
|
}
|
|
|
|
public function getHumanReadableMessage(SqlState $sqlState): string
|
|
{
|
|
return match ($sqlState->code) {
|
|
'08001' => 'Unable to establish database connection',
|
|
'08003' => 'Database connection does not exist',
|
|
'08004' => 'Database connection was rejected',
|
|
'08006' => 'Database connection failed',
|
|
|
|
'23505' => 'Duplicate entry - record already exists',
|
|
'23503' => 'Foreign key constraint violation - referenced record not found',
|
|
'23502' => 'Required field cannot be null',
|
|
'23514' => 'Check constraint violation - invalid value',
|
|
'23000' => 'Data integrity constraint violation',
|
|
|
|
'40001' => 'Database deadlock detected - transaction aborted',
|
|
'40000' => 'Transaction was rolled back',
|
|
|
|
'42S02' => 'Table or view not found',
|
|
'42S22' => 'Column not found',
|
|
'42000' => 'SQL syntax error or access violation',
|
|
|
|
default => $this->getHumanReadableMessageByClass($sqlState),
|
|
};
|
|
}
|
|
|
|
private function getHumanReadableMessageByClass(SqlState $sqlState): string
|
|
{
|
|
return match ($sqlState->getClass()) {
|
|
'08' => 'Database connection error',
|
|
'23' => 'Data constraint violation',
|
|
'40' => 'Transaction error',
|
|
'42' => 'Query syntax or access error',
|
|
default => 'Database operation failed',
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. Enhanced DatabaseErrorCode Enum
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\Exception\Core;
|
|
|
|
enum DatabaseErrorCode: string implements ErrorCode
|
|
{
|
|
// Connection errors (001-010)
|
|
case CONNECTION_FAILED = 'DB001';
|
|
case CONNECTION_LOST = 'DB002';
|
|
case CONNECTION_TIMEOUT = 'DB003';
|
|
|
|
// Query errors (011-020)
|
|
case QUERY_EXECUTION_FAILED = 'DB011';
|
|
case QUERY_SYNTAX_ERROR = 'DB012';
|
|
case TABLE_NOT_FOUND = 'DB013';
|
|
case COLUMN_NOT_FOUND = 'DB014';
|
|
|
|
// Constraint violations (021-030)
|
|
case CONSTRAINT_VIOLATION = 'DB021';
|
|
case UNIQUE_CONSTRAINT_VIOLATION = 'DB022';
|
|
case FOREIGN_KEY_CONSTRAINT_VIOLATION = 'DB023';
|
|
case NOT_NULL_CONSTRAINT_VIOLATION = 'DB024';
|
|
case CHECK_CONSTRAINT_VIOLATION = 'DB025';
|
|
|
|
// Transaction errors (031-040)
|
|
case TRANSACTION_FAILED = 'DB031';
|
|
case DEADLOCK_DETECTED = 'DB032';
|
|
case TRANSACTION_TIMEOUT = 'DB033';
|
|
|
|
public function getValue(): string
|
|
{
|
|
return $this->value;
|
|
}
|
|
|
|
public function getCategory(): string
|
|
{
|
|
return 'DB';
|
|
}
|
|
|
|
public function getNumericCode(): int
|
|
{
|
|
return (int) substr($this->value, -3);
|
|
}
|
|
|
|
public function getSeverity(): ErrorSeverity
|
|
{
|
|
return match($this) {
|
|
self::CONNECTION_FAILED,
|
|
self::CONNECTION_LOST,
|
|
self::CONNECTION_TIMEOUT => ErrorSeverity::CRITICAL,
|
|
|
|
self::DEADLOCK_DETECTED,
|
|
self::TRANSACTION_TIMEOUT => ErrorSeverity::ERROR,
|
|
|
|
self::QUERY_EXECUTION_FAILED,
|
|
self::QUERY_SYNTAX_ERROR,
|
|
self::TRANSACTION_FAILED => ErrorSeverity::ERROR,
|
|
|
|
self::TABLE_NOT_FOUND,
|
|
self::COLUMN_NOT_FOUND => ErrorSeverity::WARNING,
|
|
|
|
self::CONSTRAINT_VIOLATION,
|
|
self::UNIQUE_CONSTRAINT_VIOLATION,
|
|
self::FOREIGN_KEY_CONSTRAINT_VIOLATION,
|
|
self::NOT_NULL_CONSTRAINT_VIOLATION,
|
|
self::CHECK_CONSTRAINT_VIOLATION => ErrorSeverity::WARNING,
|
|
};
|
|
}
|
|
|
|
public function getDescription(): string
|
|
{
|
|
return match($this) {
|
|
self::CONNECTION_FAILED => 'Database connection failed',
|
|
self::CONNECTION_LOST => 'Database connection was lost',
|
|
self::CONNECTION_TIMEOUT => 'Database connection timed out',
|
|
|
|
self::QUERY_EXECUTION_FAILED => 'Query execution failed',
|
|
self::QUERY_SYNTAX_ERROR => 'SQL syntax error',
|
|
self::TABLE_NOT_FOUND => 'Table or view not found',
|
|
self::COLUMN_NOT_FOUND => 'Column not found',
|
|
|
|
self::CONSTRAINT_VIOLATION => 'Database constraint violation',
|
|
self::UNIQUE_CONSTRAINT_VIOLATION => 'Unique constraint violation',
|
|
self::FOREIGN_KEY_CONSTRAINT_VIOLATION => 'Foreign key constraint violation',
|
|
self::NOT_NULL_CONSTRAINT_VIOLATION => 'Not null constraint violation',
|
|
self::CHECK_CONSTRAINT_VIOLATION => 'Check constraint violation',
|
|
|
|
self::TRANSACTION_FAILED => 'Transaction failed',
|
|
self::DEADLOCK_DETECTED => 'Database deadlock detected',
|
|
self::TRANSACTION_TIMEOUT => 'Transaction timed out',
|
|
};
|
|
}
|
|
|
|
public function getRecoveryHint(): string
|
|
{
|
|
return match($this) {
|
|
self::CONNECTION_FAILED => 'Check database server status and credentials',
|
|
self::CONNECTION_LOST => 'Verify network connectivity and database server status',
|
|
self::CONNECTION_TIMEOUT => 'Check database server load and increase timeout if needed',
|
|
|
|
self::QUERY_EXECUTION_FAILED => 'Review query and parameters for errors',
|
|
self::QUERY_SYNTAX_ERROR => 'Check SQL syntax and table/column names',
|
|
self::TABLE_NOT_FOUND => 'Verify table exists and migrations are up-to-date',
|
|
self::COLUMN_NOT_FOUND => 'Check column name spelling and schema version',
|
|
|
|
self::UNIQUE_CONSTRAINT_VIOLATION => 'Record with this value already exists',
|
|
self::FOREIGN_KEY_CONSTRAINT_VIOLATION => 'Referenced record does not exist',
|
|
self::NOT_NULL_CONSTRAINT_VIOLATION => 'Required field must have a value',
|
|
self::CHECK_CONSTRAINT_VIOLATION => 'Value does not meet validation requirements',
|
|
self::CONSTRAINT_VIOLATION => 'Data does not meet database constraints',
|
|
|
|
self::DEADLOCK_DETECTED => 'Retry transaction - concurrent access conflict',
|
|
self::TRANSACTION_FAILED => 'Review transaction logic and retry',
|
|
self::TRANSACTION_TIMEOUT => 'Optimize transaction or increase timeout',
|
|
};
|
|
}
|
|
|
|
public function isRecoverable(): bool
|
|
{
|
|
return match($this) {
|
|
self::CONNECTION_FAILED,
|
|
self::CONNECTION_LOST,
|
|
self::CONNECTION_TIMEOUT,
|
|
self::DEADLOCK_DETECTED,
|
|
self::TRANSACTION_TIMEOUT => true,
|
|
|
|
default => false,
|
|
};
|
|
}
|
|
|
|
public function getRetryAfterSeconds(): ?int
|
|
{
|
|
return match($this) {
|
|
self::CONNECTION_FAILED,
|
|
self::CONNECTION_LOST => 30,
|
|
self::CONNECTION_TIMEOUT => 60,
|
|
self::DEADLOCK_DETECTED => 5,
|
|
self::TRANSACTION_TIMEOUT => 10,
|
|
default => null,
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4. Specific Database Exceptions with SQLSTATE
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\Database\Exception;
|
|
|
|
use App\Framework\Database\ValueObjects\SqlState;
|
|
use App\Framework\Database\Services\SqlStateErrorMapper;
|
|
use App\Framework\Exception\ExceptionContext;
|
|
|
|
final class QueryExecutionException extends DatabaseException
|
|
{
|
|
public static function fromPdoException(
|
|
\PDOException $previous,
|
|
string $query,
|
|
array $parameters = []
|
|
): self {
|
|
$sqlState = new SqlState($previous->getCode());
|
|
$mapper = new SqlStateErrorMapper();
|
|
|
|
$errorCode = $mapper->mapToErrorCode($sqlState);
|
|
$humanMessage = $mapper->getHumanReadableMessage($sqlState);
|
|
|
|
$context = ExceptionContext::forOperation('query.execute', 'PdoConnection')
|
|
->withData([
|
|
'sqlstate' => $sqlState->code,
|
|
'sqlstate_class' => $sqlState->getClass(),
|
|
'error_message' => $previous->getMessage(),
|
|
'human_message' => $humanMessage,
|
|
])
|
|
->withDebug([
|
|
'query' => $query,
|
|
'parameters' => $parameters,
|
|
'driver_error_code' => $previous->errorInfo[1] ?? null,
|
|
]);
|
|
|
|
return self::fromContext(
|
|
$humanMessage,
|
|
$context,
|
|
$errorCode,
|
|
$previous
|
|
);
|
|
}
|
|
}
|
|
|
|
final class ConnectionFailedException extends DatabaseException
|
|
{
|
|
public static function fromPdoException(\PDOException $previous, array $config): self
|
|
{
|
|
$sqlState = new SqlState($previous->getCode());
|
|
$mapper = new SqlStateErrorMapper();
|
|
|
|
$errorCode = $mapper->mapToErrorCode($sqlState);
|
|
$humanMessage = $mapper->getHumanReadableMessage($sqlState);
|
|
|
|
$context = ExceptionContext::forOperation('connection.establish', 'DatabaseFactory')
|
|
->withData([
|
|
'sqlstate' => $sqlState->code,
|
|
'host' => $config['host'] ?? 'unknown',
|
|
'database' => $config['database'] ?? 'unknown',
|
|
'driver' => $config['driver'] ?? 'unknown',
|
|
'human_message' => $humanMessage,
|
|
])
|
|
->withDebug([
|
|
'pdo_error' => $previous->getMessage(),
|
|
]);
|
|
|
|
return self::fromContext(
|
|
$humanMessage,
|
|
$context,
|
|
$errorCode,
|
|
$previous
|
|
);
|
|
}
|
|
}
|
|
|
|
final class ConstraintViolationException extends DatabaseException
|
|
{
|
|
public static function fromPdoException(
|
|
\PDOException $previous,
|
|
string $query,
|
|
array $parameters = []
|
|
): self {
|
|
$sqlState = new SqlState($previous->getCode());
|
|
$mapper = new SqlStateErrorMapper();
|
|
|
|
$errorCode = $mapper->mapToErrorCode($sqlState);
|
|
$humanMessage = $mapper->getHumanReadableMessage($sqlState);
|
|
|
|
// Extract constraint name from error message if possible
|
|
$constraintName = self::extractConstraintName($previous->getMessage());
|
|
|
|
$context = ExceptionContext::forOperation('constraint.validate', 'PdoConnection')
|
|
->withData([
|
|
'sqlstate' => $sqlState->code,
|
|
'constraint_type' => self::getConstraintType($sqlState),
|
|
'constraint_name' => $constraintName,
|
|
'human_message' => $humanMessage,
|
|
])
|
|
->withDebug([
|
|
'query' => $query,
|
|
'parameters' => $parameters,
|
|
'full_error' => $previous->getMessage(),
|
|
]);
|
|
|
|
return self::fromContext(
|
|
$humanMessage,
|
|
$context,
|
|
$errorCode,
|
|
$previous
|
|
);
|
|
}
|
|
|
|
private static function extractConstraintName(string $errorMessage): ?string
|
|
{
|
|
// PostgreSQL: constraint "constraint_name"
|
|
if (preg_match('/constraint "([^"]+)"/', $errorMessage, $matches)) {
|
|
return $matches[1];
|
|
}
|
|
|
|
// MySQL: Duplicate entry '...' for key 'constraint_name'
|
|
if (preg_match("/for key '([^']+)'/", $errorMessage, $matches)) {
|
|
return $matches[1];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static function getConstraintType(SqlState $sqlState): string
|
|
{
|
|
return match ($sqlState->code) {
|
|
'23505' => 'unique',
|
|
'23503' => 'foreign_key',
|
|
'23502' => 'not_null',
|
|
'23514' => 'check',
|
|
default => 'unknown',
|
|
};
|
|
}
|
|
}
|
|
|
|
final class DeadlockException extends DatabaseException
|
|
{
|
|
public static function fromPdoException(
|
|
\PDOException $previous,
|
|
string $query,
|
|
array $parameters = []
|
|
): self {
|
|
$context = ExceptionContext::forOperation('transaction.execute', 'PdoConnection')
|
|
->withData([
|
|
'sqlstate' => '40001',
|
|
'retry_recommended' => true,
|
|
])
|
|
->withDebug([
|
|
'query' => $query,
|
|
'parameters' => $parameters,
|
|
]);
|
|
|
|
return self::fromContext(
|
|
'Database deadlock detected - transaction was rolled back',
|
|
$context,
|
|
DatabaseErrorCode::DEADLOCK_DETECTED,
|
|
$previous
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5. Updated PdoConnection Implementation
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\Database;
|
|
|
|
use App\Framework\Database\Exception\QueryExecutionException;
|
|
use App\Framework\Database\Exception\ConnectionFailedException;
|
|
use App\Framework\Database\Exception\ConstraintViolationException;
|
|
use App\Framework\Database\Exception\DeadlockException;
|
|
use App\Framework\Database\ValueObjects\SqlQuery;
|
|
use App\Framework\Database\ValueObjects\SqlState;
|
|
|
|
final class PdoConnection implements ConnectionInterface
|
|
{
|
|
private \PDO $pdo;
|
|
|
|
public function __construct(\PDO $pdo)
|
|
{
|
|
$this->pdo = $pdo;
|
|
}
|
|
|
|
public function execute(SqlQuery $query): int
|
|
{
|
|
try {
|
|
$statement = $this->pdo->prepare($query->sql);
|
|
$statement->execute($query->parameters->toPdoArray());
|
|
|
|
return $statement->rowCount();
|
|
} catch (\PDOException $e) {
|
|
throw $this->handlePdoException($e, $query);
|
|
}
|
|
}
|
|
|
|
public function query(SqlQuery $query): ResultInterface
|
|
{
|
|
try {
|
|
$statement = $this->pdo->prepare($query->sql);
|
|
$statement->execute($query->parameters->toPdoArray());
|
|
|
|
return new PdoResult($statement);
|
|
} catch (\PDOException $e) {
|
|
throw $this->handlePdoException($e, $query);
|
|
}
|
|
}
|
|
|
|
private function handlePdoException(\PDOException $e, SqlQuery $query): \Throwable
|
|
{
|
|
$sqlState = new SqlState($e->getCode());
|
|
|
|
// Constraint violations
|
|
if ($sqlState->isConstraintViolation()) {
|
|
return ConstraintViolationException::fromPdoException(
|
|
$e,
|
|
$query->sql,
|
|
$query->parameters->toArray()
|
|
);
|
|
}
|
|
|
|
// Deadlock
|
|
if ($sqlState->code === '40001') {
|
|
return DeadlockException::fromPdoException(
|
|
$e,
|
|
$query->sql,
|
|
$query->parameters->toArray()
|
|
);
|
|
}
|
|
|
|
// Connection errors
|
|
if ($sqlState->isConnectionError()) {
|
|
return ConnectionFailedException::fromPdoException($e, []);
|
|
}
|
|
|
|
// Generic query execution error
|
|
return QueryExecutionException::fromPdoException(
|
|
$e,
|
|
$query->sql,
|
|
$query->parameters->toArray()
|
|
);
|
|
}
|
|
|
|
// ... rest of implementation
|
|
}
|
|
```
|
|
|
|
## Benefits
|
|
|
|
### 1. Precise Error Handling
|
|
```php
|
|
try {
|
|
$user = $this->userRepository->create($userData);
|
|
} catch (ConstraintViolationException $e) {
|
|
if ($e->isCategory('DB') && $e->getContext()->data['constraint_type'] === 'unique') {
|
|
return new JsonResponse([
|
|
'error' => 'Email address already registered',
|
|
'field' => 'email'
|
|
], 409);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2. Automatic Recovery Strategies
|
|
```php
|
|
// ErrorHandler automatically adds Retry-After header for deadlocks
|
|
// SQLSTATE 40001 → DatabaseErrorCode::DEADLOCK_DETECTED → Retry-After: 5
|
|
```
|
|
|
|
### 3. User-Friendly Error Messages
|
|
```php
|
|
// Instead of:
|
|
"SQLSTATE[23505]: Unique violation: 7 ERROR: duplicate key value..."
|
|
|
|
// User sees:
|
|
"Duplicate entry - record already exists"
|
|
```
|
|
|
|
### 4. Better Monitoring
|
|
```php
|
|
// Log with SQLSTATE context
|
|
$this->logger->error('Database constraint violation', [
|
|
'error_code' => 'DB022',
|
|
'sqlstate' => '23505',
|
|
'constraint_type' => 'unique',
|
|
'constraint_name' => 'users_email_unique',
|
|
'human_message' => 'Email address already registered'
|
|
]);
|
|
```
|
|
|
|
## Implementation Roadmap
|
|
|
|
### Phase 1: Foundation (1-2 hours)
|
|
- [ ] Create SqlState value object
|
|
- [ ] Create SqlStateErrorMapper service
|
|
- [ ] Extend DatabaseErrorCode enum with new codes
|
|
- [ ] Write tests for SQLSTATE parsing and mapping
|
|
|
|
### Phase 2: Exception Classes (2-3 hours)
|
|
- [ ] Create QueryExecutionException with SQLSTATE support
|
|
- [ ] Create ConnectionFailedException with SQLSTATE support
|
|
- [ ] Create ConstraintViolationException with SQLSTATE support
|
|
- [ ] Create DeadlockException with SQLSTATE support
|
|
- [ ] Write tests for each exception type
|
|
|
|
### Phase 3: Integration (1-2 hours)
|
|
- [ ] Update PdoConnection with handlePdoException()
|
|
- [ ] Update DatabaseFactory connection handling
|
|
- [ ] Update RetryMiddleware to handle SQLSTATE-aware exceptions
|
|
- [ ] Integration tests with real database
|
|
|
|
### Phase 4: Documentation (1 hour)
|
|
- [ ] Update database-patterns.md with SQLSTATE examples
|
|
- [ ] Add SQLSTATE reference table to documentation
|
|
- [ ] Update migration guide with SQLSTATE integration examples
|
|
|
|
## Testing Strategy
|
|
|
|
### Unit Tests
|
|
```php
|
|
it('parses SQLSTATE code correctly', function () {
|
|
$sqlState = new SqlState('23505');
|
|
|
|
expect($sqlState->getClass())->toBe('23');
|
|
expect($sqlState->getSubclass())->toBe('505');
|
|
expect($sqlState->isUniqueViolation())->toBeTrue();
|
|
});
|
|
|
|
it('maps SQLSTATE to appropriate ErrorCode', function () {
|
|
$mapper = new SqlStateErrorMapper();
|
|
$sqlState = new SqlState('23505');
|
|
|
|
$errorCode = $mapper->mapToErrorCode($sqlState);
|
|
|
|
expect($errorCode)->toBe(DatabaseErrorCode::UNIQUE_CONSTRAINT_VIOLATION);
|
|
});
|
|
```
|
|
|
|
### Integration Tests
|
|
```php
|
|
it('throws ConstraintViolationException for unique constraint', function () {
|
|
$connection = $this->getConnection();
|
|
|
|
// Insert first user
|
|
$connection->execute(SqlQuery::create(
|
|
'INSERT INTO users (email) VALUES (?)',
|
|
['test@example.com']
|
|
));
|
|
|
|
// Try to insert duplicate - should throw
|
|
expect(fn() => $connection->execute(SqlQuery::create(
|
|
'INSERT INTO users (email) VALUES (?)',
|
|
['test@example.com']
|
|
)))->toThrow(ConstraintViolationException::class);
|
|
});
|
|
```
|
|
|
|
## Production Benefits
|
|
|
|
1. **Better Debugging**: SQLSTATE in logs makes issues identifiable
|
|
2. **User Experience**: Human-readable messages instead of technical errors
|
|
3. **Retry Logic**: Automatic retry for recoverable errors (deadlocks, connection issues)
|
|
4. **Monitoring**: Track errors by SQLSTATE class for pattern detection
|
|
5. **Recovery**: Specific hints per SQLSTATE code
|
|
|
|
## Conclusion
|
|
|
|
SQLSTATE integration transforms generic database errors into actionable, user-friendly exceptions with automatic recovery strategies and precise error categorization.
|