Files
michaelschiemer/docs/sqlstate-integration-design.md
Michael Schiemer fc3d7e6357 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.
2025-10-25 19:18:37 +02:00

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.