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

23 KiB

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

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

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

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

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

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

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

// ErrorHandler automatically adds Retry-After header for deadlocks
// SQLSTATE 40001 → DatabaseErrorCode::DEADLOCK_DETECTED → Retry-After: 5

3. User-Friendly Error Messages

// Instead of:
"SQLSTATE[23505]: Unique violation: 7 ERROR: duplicate key value..."

// User sees:
"Duplicate entry - record already exists"

4. Better Monitoring

// 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

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

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.