# 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 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 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 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 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 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.