diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Client/Exception/PostgreSqlError.php b/src/lib/postgresql/src/Flow/PostgreSql/Client/Exception/PostgreSqlError.php new file mode 100644 index 000000000..bc083a062 --- /dev/null +++ b/src/lib/postgresql/src/Flow/PostgreSql/Client/Exception/PostgreSqlError.php @@ -0,0 +1,133 @@ +message; + } + + public function isCheckViolation() : bool + { + return $this->sqlState === '23514'; + } + + public function isConnectionError() : bool + { + return $this->category === PostgreSqlErrorCategory::CONNECTION_EXCEPTION; + } + + public function isDataError() : bool + { + return $this->category === PostgreSqlErrorCategory::DATA_EXCEPTION; + } + + public function isDeadlockDetected() : bool + { + return $this->sqlState === '40P01'; + } + + public function isExclusionViolation() : bool + { + return $this->sqlState === '23P01'; + } + + public function isForeignKeyViolation() : bool + { + return $this->sqlState === '23503'; + } + + public function isIntegrityViolation() : bool + { + return $this->category === PostgreSqlErrorCategory::INTEGRITY_CONSTRAINT_VIOLATION; + } + + public function isNotNullViolation() : bool + { + return $this->sqlState === '23502'; + } + + public function isSerializationFailure() : bool + { + return $this->sqlState === '40001'; + } + + public function isSyntaxError() : bool + { + return $this->category === PostgreSqlErrorCategory::SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION; + } + + public function isTransactionRollback() : bool + { + return $this->category === PostgreSqlErrorCategory::TRANSACTION_ROLLBACK; + } + + public function isUniqueViolation() : bool + { + return $this->sqlState === '23505'; + } + + public function safeMessage() : string + { + return $this->category->safeMessage(); + } +} diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Client/Exception/PostgreSqlErrorCategory.php b/src/lib/postgresql/src/Flow/PostgreSql/Client/Exception/PostgreSqlErrorCategory.php new file mode 100644 index 000000000..a340fb650 --- /dev/null +++ b/src/lib/postgresql/src/Flow/PostgreSql/Client/Exception/PostgreSqlErrorCategory.php @@ -0,0 +1,122 @@ + true, + default => false, + }; + } + + public function safeMessage() : string + { + return match ($this) { + self::SUCCESSFUL_COMPLETION => 'Operation completed successfully', + self::WARNING => 'Operation completed with warnings', + self::NO_DATA => 'No data found', + self::SQL_STATEMENT_NOT_YET_COMPLETE => 'SQL statement not yet complete', + self::CONNECTION_EXCEPTION => 'Database connection error occurred', + self::TRIGGERED_ACTION_EXCEPTION => 'Triggered action error', + self::FEATURE_NOT_SUPPORTED => 'Feature not supported', + self::INVALID_TRANSACTION_INITIATION => 'Invalid transaction initiation', + self::LOCATOR_EXCEPTION => 'Locator error', + self::INVALID_GRANTOR => 'Invalid grantor', + self::INVALID_ROLE_SPECIFICATION => 'Invalid role specification', + self::DIAGNOSTICS_EXCEPTION => 'Diagnostics error', + self::CASE_NOT_FOUND => 'Case not found', + self::CARDINALITY_VIOLATION => 'Cardinality violation', + self::DATA_EXCEPTION => 'Invalid data format or value', + self::INTEGRITY_CONSTRAINT_VIOLATION => 'Data constraint violation', + self::INVALID_CURSOR_STATE => 'Invalid cursor state', + self::INVALID_TRANSACTION_STATE => 'Invalid transaction state', + self::INVALID_SQL_STATEMENT_NAME => 'Invalid SQL statement name', + self::TRIGGERED_DATA_CHANGE_VIOLATION => 'Triggered data change violation', + self::INVALID_AUTHORIZATION_SPECIFICATION => 'Authorization error', + self::DEPENDENT_PRIVILEGE_DESCRIPTORS => 'Dependent privilege descriptors still exist', + self::INVALID_TRANSACTION_TERMINATION => 'Invalid transaction termination', + self::SQL_ROUTINE_EXCEPTION => 'SQL routine error', + self::INVALID_CURSOR_NAME => 'Invalid cursor name', + self::EXTERNAL_ROUTINE_EXCEPTION => 'External routine error', + self::EXTERNAL_ROUTINE_INVOCATION_EXCEPTION => 'External routine invocation error', + self::SAVEPOINT_EXCEPTION => 'Savepoint error', + self::INVALID_CATALOG_NAME => 'Invalid catalog name', + self::INVALID_SCHEMA_NAME => 'Invalid schema name', + self::TRANSACTION_ROLLBACK => 'Transaction was rolled back', + self::SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION => 'Query syntax or permission error', + self::WITH_CHECK_OPTION_VIOLATION => 'Check option violation', + self::INSUFFICIENT_RESOURCES => 'Server resource limit reached', + self::PROGRAM_LIMIT_EXCEEDED => 'Program limit exceeded', + self::OBJECT_NOT_IN_PREREQUISITE_STATE => 'Object not in prerequisite state', + self::OPERATOR_INTERVENTION => 'Operator intervention', + self::SYSTEM_ERROR => 'Database system error', + self::SNAPSHOT_FAILURE => 'Snapshot failure', + self::CONFIGURATION_FILE_ERROR => 'Configuration file error', + self::FDW_ERROR => 'Foreign data wrapper error', + self::PL_PGSQL_ERROR => 'PL/pgSQL error', + self::INTERNAL_ERROR => 'Internal database error', + self::UNKNOWN => 'Database operation failed', + }; + } +} diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Client/Exception/QueryException.php b/src/lib/postgresql/src/Flow/PostgreSql/Client/Exception/QueryException.php index dc0d6457c..480effb11 100644 --- a/src/lib/postgresql/src/Flow/PostgreSql/Client/Exception/QueryException.php +++ b/src/lib/postgresql/src/Flow/PostgreSql/Client/Exception/QueryException.php @@ -8,52 +8,38 @@ final class QueryException extends ClientException { private const int SQL_PREVIEW_LENGTH = 100; - private ?string $sql = null; - - public static function columnNotFound(string $column) : self - { - return new self(\sprintf('Column "%s" not found in result set', $column)); + private function __construct( + string $message, + private readonly string $sql, + private readonly PostgreSqlError $error, + ) { + parent::__construct($message); } - public static function executionFailed(string $sql, string $error) : self + public static function executionFailed(string $sql, PostgreSqlError $error) : self { $sqlPreview = \strlen($sql) > self::SQL_PREVIEW_LENGTH ? \substr($sql, 0, self::SQL_PREVIEW_LENGTH) . '...' : $sql; - $exception = new self(\sprintf('Query execution failed: %s. SQL: %s', $error, $sqlPreview)); - $exception->sql = $sql; - - return $exception; - } - - public static function noRowsFound() : self - { - return new self('Expected exactly one row, but none were returned'); - } - - public static function sequenceNotUsed(string $sequenceName) : self - { - return new self(\sprintf('Sequence "%s" has not been used in this session', $sequenceName)); - } - - public static function tooManyRows(int $count) : self - { - return new self(\sprintf('Expected exactly one row, but %d were returned', $count)); + return new self( + \sprintf( + 'Query execution failed [%s]: %s. SQL: %s', + $error->sqlState, + $error->safeMessage(), + $sqlPreview + ), + $sql, + $error + ); } - public static function unexpectedScalarType(string $expected, string $actual) : self + public function error() : PostgreSqlError { - return new self(\sprintf('Expected scalar of type %s, got %s', $expected, $actual)); + return $this->error; } - /** - * Get the full SQL query that caused the exception. - * - * Note: This is only available for executionFailed exceptions. - * Use with caution - do not log or expose to users as it may contain sensitive data. - */ - public function sql() : ?string + public function sql() : string { return $this->sql; } diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Client/Exception/ResultException.php b/src/lib/postgresql/src/Flow/PostgreSql/Client/Exception/ResultException.php new file mode 100644 index 000000000..8fc52ba2c --- /dev/null +++ b/src/lib/postgresql/src/Flow/PostgreSql/Client/Exception/ResultException.php @@ -0,0 +1,28 @@ + 1) { \pg_free_result($result); - throw QueryException::tooManyRows($count); + throw ResultException::tooManyRows($count); } $row = \pg_fetch_assoc($result); @@ -223,7 +223,7 @@ public function fetchOne(SqlQuery|string $sql, array $parameters = []) : array if ($row === false) { \pg_free_result($result); - throw QueryException::noRowsFound(); + throw ResultException::noRowsFound(); } $converted = $this->convertRow($result, $row); @@ -273,7 +273,7 @@ public function fetchScalarBool(SqlQuery|string $sql, array $parameters = []) : $value = $this->fetchScalar($sql, $parameters); if (!\is_bool($value)) { - throw QueryException::unexpectedScalarType('bool', \get_debug_type($value)); + throw ResultException::unexpectedScalarType('bool', \get_debug_type($value)); } return $value; @@ -284,7 +284,7 @@ public function fetchScalarFloat(SqlQuery|string $sql, array $parameters = []) : $value = $this->fetchScalar($sql, $parameters); if (!\is_float($value)) { - throw QueryException::unexpectedScalarType('float', \get_debug_type($value)); + throw ResultException::unexpectedScalarType('float', \get_debug_type($value)); } return $value; @@ -295,7 +295,7 @@ public function fetchScalarInt(SqlQuery|string $sql, array $parameters = []) : i $value = $this->fetchScalar($sql, $parameters); if (!\is_int($value)) { - throw QueryException::unexpectedScalarType('int', \get_debug_type($value)); + throw ResultException::unexpectedScalarType('int', \get_debug_type($value)); } return $value; @@ -306,7 +306,7 @@ public function fetchScalarString(SqlQuery|string $sql, array $parameters = []) $value = $this->fetchScalar($sql, $parameters); if (!\is_string($value)) { - throw QueryException::unexpectedScalarType('string', \get_debug_type($value)); + throw ResultException::unexpectedScalarType('string', \get_debug_type($value)); } return $value; @@ -334,14 +334,14 @@ public function lastInsertId(string $sequenceName) : int|string $result = $this->fetchScalar('SELECT currval($1)', [$sequenceName]); } catch (QueryException $e) { if (\str_contains($e->getMessage(), 'is not yet defined in this session')) { - throw QueryException::sequenceNotUsed($sequenceName); + throw ResultException::sequenceNotUsed($sequenceName); } throw $e; } if ($result === null) { - throw QueryException::sequenceNotUsed($sequenceName); + throw ResultException::sequenceNotUsed($sequenceName); } /** @var int|string $result */ @@ -472,6 +472,39 @@ private function executeTransactionCommand(SqlQuery $query, callable $exceptionF \pg_free_result($result); } + private function extractError(Connection $connection, ?Result $result) : PostgreSqlError + { + if ($result !== null) { + $sqlState = \pg_result_error_field($result, \PGSQL_DIAG_SQLSTATE); + $message = \pg_result_error_field($result, \PGSQL_DIAG_MESSAGE_PRIMARY); + $detail = \pg_result_error_field($result, \PGSQL_DIAG_MESSAGE_DETAIL); + $hint = \pg_result_error_field($result, \PGSQL_DIAG_MESSAGE_HINT); + $schema = \pg_result_error_field($result, \PGSQL_DIAG_SCHEMA_NAME); + $table = \pg_result_error_field($result, \PGSQL_DIAG_TABLE_NAME); + $column = \pg_result_error_field($result, \PGSQL_DIAG_COLUMN_NAME); + $constraint = \pg_result_error_field($result, \PGSQL_DIAG_CONSTRAINT_NAME); + $position = \pg_result_error_field($result, \PGSQL_DIAG_STATEMENT_POSITION); + + if ($sqlState !== false && $sqlState !== null) { + return PostgreSqlError::fromDiagnostics( + $sqlState, + ($message !== false && $message !== null) ? $message : (\pg_result_error($result) ?: 'Unknown error'), + ($detail !== false && $detail !== null) ? $detail : null, + ($hint !== false && $hint !== null) ? $hint : null, + ($schema !== false && $schema !== null) ? $schema : null, + ($table !== false && $table !== null) ? $table : null, + ($column !== false && $column !== null) ? $column : null, + ($constraint !== false && $constraint !== null) ? $constraint : null, + ($position !== false && $position !== null) ? (int) $position : null, + ); + } + } + + $errorMessage = \pg_last_error($connection); + + return PostgreSqlError::unknown($errorMessage !== '' ? $errorMessage : 'Unknown error'); + } + /** * @param array $parameters */ @@ -483,10 +516,33 @@ private function query(SqlQuery|string $sql, array $parameters) : Result $connection = $this->connection; $query = $sql instanceof SqlQuery ? $sql->toSql() : $sql; - $result = @\pg_query_params($connection, $query, $this->convertParameters($parameters)); + $convertedParams = $this->convertParameters($parameters); + + $success = @\pg_send_query_params($connection, $query, $convertedParams); + + if ($success === false) { + throw QueryException::executionFailed( + $query, + $this->extractError($connection, null) + ); + } + + $result = \pg_get_result($connection); if ($result === false) { - throw QueryException::executionFailed($query, \pg_last_error($connection) ?: 'Unknown error'); + throw QueryException::executionFailed( + $query, + $this->extractError($connection, null) + ); + } + + $status = \pg_result_status($result); + + if ($status === \PGSQL_FATAL_ERROR || $status === \PGSQL_NONFATAL_ERROR) { + $error = $this->extractError($connection, $result); + \pg_free_result($result); + + throw QueryException::executionFailed($query, $error); } return $result; diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Integration/Client/PgSqlClientTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Integration/Client/PgSqlClientTest.php index 75f36daa0..64faec0ca 100644 --- a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Integration/Client/PgSqlClientTest.php +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Integration/Client/PgSqlClientTest.php @@ -26,7 +26,7 @@ table, values_table }; -use Flow\PostgreSql\Client\Exception\QueryException; +use Flow\PostgreSql\Client\Exception\{QueryException, ResultException}; use Flow\PostgreSql\Client\TypedValue; use Flow\PostgreSql\Client\Types\PostgreSqlType; use Flow\PostgreSql\Explain\Plan\{Plan, PlanNodeType}; @@ -235,7 +235,7 @@ public function test_fetch_one_returns_exactly_one_row() : void public function test_fetch_one_throws_when_multiple_rows() : void { - $this->expectException(QueryException::class); + $this->expectException(ResultException::class); $this->expectExceptionMessage('Expected exactly one row'); $this->client->fetchOne( @@ -245,7 +245,7 @@ public function test_fetch_one_throws_when_multiple_rows() : void public function test_fetch_one_throws_when_no_rows() : void { - $this->expectException(QueryException::class); + $this->expectException(ResultException::class); $this->expectExceptionMessage('Expected exactly one row'); $this->client->fetchOne( @@ -376,7 +376,7 @@ public function test_last_insert_id_throws_when_sequence_not_used() : void ); $this->expectException(QueryException::class); - $this->expectExceptionMessage('has not been used'); + $this->expectExceptionMessage('Query execution failed [55000]: Object not in prerequisite state. SQL: SELECT currval($1)'); $this->client->lastInsertId('test_unused_seq'); } diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Integration/Client/QueryExceptionTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Integration/Client/QueryExceptionTest.php new file mode 100644 index 000000000..ce9310c41 --- /dev/null +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Integration/Client/QueryExceptionTest.php @@ -0,0 +1,192 @@ +client->execute( + create()->temporaryTable('test_check_violation') + ->column(ColumnDefinition::create('id', DataType::integer())->primaryKey()) + ->column(ColumnDefinition::create('age', DataType::integer())) + ); + $this->client->execute('ALTER TABLE test_check_violation ADD CONSTRAINT age_positive CHECK (age > 0)'); + + try { + $this->client->execute( + insert()->into('test_check_violation') + ->columns('id', 'age') + ->values(literal(1), literal(-5)) + ); + self::fail('Expected QueryException to be thrown'); + } catch (QueryException $e) { + $error = $e->error(); + + self::assertSame('23514', $error->sqlState); + self::assertSame(PostgreSqlErrorCategory::INTEGRITY_CONSTRAINT_VIOLATION, $error->category); + self::assertNotNull($error->schema); + self::assertMatchesRegularExpression('/^pg_temp(_\d+)?$/', $error->schema); + self::assertSame('test_check_violation', $error->table); + self::assertSame('age_positive', $error->constraint); + } + } + + public function test_data_exception() : void + { + try { + $this->client->execute("SELECT 'not_a_number'::integer"); + self::fail('Expected QueryException to be thrown'); + } catch (QueryException $e) { + $error = $e->error(); + + self::assertSame('22P02', $error->sqlState); + self::assertSame(PostgreSqlErrorCategory::DATA_EXCEPTION, $error->category); + self::assertSame('invalid input syntax for type integer: "not_a_number"', $error->message); + } + } + + public function test_exception_message_format() : void + { + $this->client->execute( + create()->temporaryTable('test_safe_message') + ->column(ColumnDefinition::create('id', DataType::integer())->primaryKey()) + ); + $this->client->execute( + insert()->into('test_safe_message')->columns('id')->values(literal(1)) + ); + + try { + $this->client->execute( + insert()->into('test_safe_message')->columns('id')->values(literal(1)) + ); + self::fail('Expected QueryException to be thrown'); + } catch (QueryException $e) { + self::assertStringContainsString('[23505]', $e->getMessage()); + self::assertStringContainsString('Data constraint violation', $e->getMessage()); + } + } + + public function test_foreign_key_violation() : void + { + $this->client->execute( + create()->temporaryTable('test_fk_parent') + ->column(ColumnDefinition::create('id', DataType::integer())->primaryKey()) + ); + $this->client->execute( + create()->temporaryTable('test_fk_child') + ->column(ColumnDefinition::create('id', DataType::integer())->primaryKey()) + ->column(ColumnDefinition::create('parent_id', DataType::integer())->notNull()) + ); + $this->client->execute('ALTER TABLE test_fk_child ADD CONSTRAINT test_fk FOREIGN KEY (parent_id) REFERENCES test_fk_parent(id)'); + + try { + $this->client->execute( + insert()->into('test_fk_child') + ->columns('id', 'parent_id') + ->values(literal(1), literal(999)) + ); + self::fail('Expected QueryException to be thrown'); + } catch (QueryException $e) { + $error = $e->error(); + + self::assertSame('23503', $error->sqlState); + self::assertSame(PostgreSqlErrorCategory::INTEGRITY_CONSTRAINT_VIOLATION, $error->category); + self::assertNotNull($error->schema); + self::assertMatchesRegularExpression('/^pg_temp(_\d+)?$/', $error->schema); + self::assertSame('test_fk_child', $error->table); + self::assertSame('test_fk', $error->constraint); + } + } + + public function test_not_null_violation() : void + { + $this->client->execute( + create()->temporaryTable('test_not_null_violation') + ->column(ColumnDefinition::create('id', DataType::integer())->primaryKey()) + ->column(ColumnDefinition::create('name', DataType::text())->notNull()) + ); + + try { + $this->client->execute( + insert()->into('test_not_null_violation') + ->columns('id', 'name') + ->values(literal(1), literal(null)) + ); + self::fail('Expected QueryException to be thrown'); + } catch (QueryException $e) { + $error = $e->error(); + + self::assertSame('23502', $error->sqlState); + self::assertSame(PostgreSqlErrorCategory::INTEGRITY_CONSTRAINT_VIOLATION, $error->category); + self::assertNotNull($error->schema); + self::assertMatchesRegularExpression('/^pg_temp(_\d+)?$/', $error->schema); + self::assertSame('test_not_null_violation', $error->table); + self::assertSame('name', $error->column); + } + } + + public function test_sql_is_accessible() : void + { + try { + $this->client->execute('SELECT * FORM invalid_syntax'); + self::fail('Expected QueryException to be thrown'); + } catch (QueryException $e) { + self::assertSame('SELECT * FORM invalid_syntax', $e->sql()); + } + } + + public function test_syntax_error() : void + { + try { + $this->client->execute('SELECT * FORM users'); + self::fail('Expected QueryException to be thrown'); + } catch (QueryException $e) { + $error = $e->error(); + + self::assertSame('42601', $error->sqlState); + self::assertSame(PostgreSqlErrorCategory::SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION, $error->category); + self::assertSame(10, $error->position); + self::assertSame('syntax error at or near "FORM"', $error->message); + } + } + + public function test_unique_violation() : void + { + $this->client->execute( + create()->temporaryTable('test_unique_violation') + ->column(ColumnDefinition::create('id', DataType::integer())->primaryKey()) + ->column(ColumnDefinition::create('email', DataType::text())->unique()) + ); + $this->client->execute( + insert()->into('test_unique_violation') + ->columns('id', 'email') + ->values(literal(1), literal('test@example.com')) + ); + + try { + $this->client->execute( + insert()->into('test_unique_violation') + ->columns('id', 'email') + ->values(literal(2), literal('test@example.com')) + ); + self::fail('Expected QueryException to be thrown'); + } catch (QueryException $e) { + $error = $e->error(); + + self::assertSame('23505', $error->sqlState); + self::assertSame(PostgreSqlErrorCategory::INTEGRITY_CONSTRAINT_VIOLATION, $error->category); + self::assertNotNull($error->schema); + self::assertMatchesRegularExpression('/^pg_temp(_\d+)?$/', $error->schema); + self::assertSame('test_unique_violation', $error->table); + self::assertSame('test_unique_violation_email_key', $error->constraint); + self::assertSame('duplicate key value violates unique constraint "test_unique_violation_email_key"', $error->message); + } + } +} diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Exception/PostgreSqlErrorCategoryTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Exception/PostgreSqlErrorCategoryTest.php new file mode 100644 index 000000000..f40bf4492 --- /dev/null +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Exception/PostgreSqlErrorCategoryTest.php @@ -0,0 +1,112 @@ +isRecoverable()); + } + + public function test_safe_message_for_connection_exception() : void + { + $message = PostgreSqlErrorCategory::CONNECTION_EXCEPTION->safeMessage(); + + self::assertSame('Database connection error occurred', $message); + } + + public function test_safe_message_for_data_exception() : void + { + $message = PostgreSqlErrorCategory::DATA_EXCEPTION->safeMessage(); + + self::assertSame('Invalid data format or value', $message); + } + + public function test_safe_message_for_integrity_constraint() : void + { + $message = PostgreSqlErrorCategory::INTEGRITY_CONSTRAINT_VIOLATION->safeMessage(); + + self::assertSame('Data constraint violation', $message); + } + + public function test_safe_message_for_syntax_error() : void + { + $message = PostgreSqlErrorCategory::SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION->safeMessage(); + + self::assertSame('Query syntax or permission error', $message); + } + + public function test_safe_message_for_unknown() : void + { + $message = PostgreSqlErrorCategory::UNKNOWN->safeMessage(); + + self::assertSame('Database operation failed', $message); + } + + public function test_transaction_rollback_is_recoverable() : void + { + self::assertTrue(PostgreSqlErrorCategory::TRANSACTION_ROLLBACK->isRecoverable()); + } +} diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Exception/PostgreSqlErrorTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Exception/PostgreSqlErrorTest.php new file mode 100644 index 000000000..dd2176395 --- /dev/null +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/Exception/PostgreSqlErrorTest.php @@ -0,0 +1,201 @@ +sqlState); + self::assertSame(PostgreSqlErrorCategory::INTEGRITY_CONSTRAINT_VIOLATION, $error->category); + self::assertSame('duplicate key value violates unique constraint "users_email_key"', $error->message); + self::assertSame('Key (email)=(test@example.com) already exists.', $error->detail); + self::assertSame('Check if the email already exists before inserting.', $error->hint); + self::assertSame('public', $error->schema); + self::assertSame('users', $error->table); + self::assertSame('email', $error->column); + self::assertSame('users_email_key', $error->constraint); + self::assertSame(15, $error->position); + } + + public function test_from_diagnostics_with_minimal_fields() : void + { + $error = PostgreSqlError::fromDiagnostics( + '42601', + 'syntax error at or near "FORM"' + ); + + self::assertSame('42601', $error->sqlState); + self::assertSame(PostgreSqlErrorCategory::SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION, $error->category); + self::assertSame('syntax error at or near "FORM"', $error->message); + self::assertNull($error->detail); + self::assertNull($error->hint); + self::assertNull($error->schema); + self::assertNull($error->table); + self::assertNull($error->column); + self::assertNull($error->constraint); + self::assertNull($error->position); + } + + public function test_full_message_returns_original_message() : void + { + $originalMessage = 'duplicate key value violates unique constraint "users_email_key"'; + $error = PostgreSqlError::fromDiagnostics('23505', $originalMessage); + + self::assertSame($originalMessage, $error->fullMessage()); + } + + public function test_is_check_violation() : void + { + $error = PostgreSqlError::fromDiagnostics('23514', 'check violation'); + + self::assertTrue($error->isCheckViolation()); + self::assertTrue($error->isIntegrityViolation()); + } + + public function test_is_connection_error() : void + { + $error = PostgreSqlError::fromDiagnostics('08006', 'connection failure'); + + self::assertTrue($error->isConnectionError()); + } + + public function test_is_data_error() : void + { + $error = PostgreSqlError::fromDiagnostics('22001', 'string data right truncation'); + + self::assertTrue($error->isDataError()); + } + + public function test_is_deadlock_detected() : void + { + $error = PostgreSqlError::fromDiagnostics('40P01', 'deadlock detected'); + + self::assertTrue($error->isDeadlockDetected()); + self::assertTrue($error->isTransactionRollback()); + } + + public function test_is_exclusion_violation() : void + { + $error = PostgreSqlError::fromDiagnostics('23P01', 'exclusion violation'); + + self::assertTrue($error->isExclusionViolation()); + self::assertTrue($error->isIntegrityViolation()); + } + + public function test_is_foreign_key_violation() : void + { + $error = PostgreSqlError::fromDiagnostics('23503', 'foreign key violation'); + + self::assertTrue($error->isForeignKeyViolation()); + self::assertTrue($error->isIntegrityViolation()); + } + + public function test_is_not_null_violation() : void + { + $error = PostgreSqlError::fromDiagnostics('23502', 'not null violation'); + + self::assertTrue($error->isNotNullViolation()); + self::assertTrue($error->isIntegrityViolation()); + } + + public function test_is_serialization_failure() : void + { + $error = PostgreSqlError::fromDiagnostics('40001', 'serialization failure'); + + self::assertTrue($error->isSerializationFailure()); + self::assertTrue($error->isTransactionRollback()); + } + + public function test_is_syntax_error() : void + { + $error = PostgreSqlError::fromDiagnostics('42601', 'syntax error'); + + self::assertTrue($error->isSyntaxError()); + self::assertFalse($error->isIntegrityViolation()); + } + + public function test_is_transaction_rollback() : void + { + $error = PostgreSqlError::fromDiagnostics('40000', 'transaction rollback'); + + self::assertTrue($error->isTransactionRollback()); + } + + public function test_is_unique_violation() : void + { + $error = PostgreSqlError::fromDiagnostics('23505', 'unique violation'); + + self::assertTrue($error->isUniqueViolation()); + self::assertTrue($error->isIntegrityViolation()); + } + + public function test_safe_message_hides_sensitive_details() : void + { + $error = PostgreSqlError::fromDiagnostics( + '23505', + 'duplicate key value violates unique constraint "users_email_key"', + 'Key (email)=(secret@company.com) already exists.', + null, + 'internal_schema', + 'secret_users_table', + 'email', + 'users_email_key' + ); + + $safeMessage = $error->safeMessage(); + + self::assertStringNotContainsString('secret@company.com', $safeMessage); + self::assertStringNotContainsString('internal_schema', $safeMessage); + self::assertStringNotContainsString('secret_users_table', $safeMessage); + self::assertStringNotContainsString('users_email_key', $safeMessage); + self::assertSame('Data constraint violation', $safeMessage); + } + + public function test_safe_message_returns_category_safe_message() : void + { + $error = PostgreSqlError::fromDiagnostics( + '23505', + 'duplicate key value violates unique constraint "users_email_key"', + table: 'users', + constraint: 'users_email_key' + ); + + self::assertSame('Data constraint violation', $error->safeMessage()); + } + + public function test_unknown_creates_generic_error() : void + { + $error = PostgreSqlError::unknown(); + + self::assertSame('00000', $error->sqlState); + self::assertSame(PostgreSqlErrorCategory::UNKNOWN, $error->category); + self::assertSame('Unknown error', $error->message); + } + + public function test_unknown_with_custom_message() : void + { + $error = PostgreSqlError::unknown('Connection lost'); + + self::assertSame('00000', $error->sqlState); + self::assertSame(PostgreSqlErrorCategory::UNKNOWN, $error->category); + self::assertSame('Connection lost', $error->message); + } +}