Skip to content

Commit 71a3e69

Browse files
authored
chore: split query and result exception in postgresql client (#2111)
- QueryException received more details about error - errors related to result itself now are thrown as ResultException
1 parent aeeee09 commit 71a3e69

File tree

9 files changed

+880
-50
lines changed

9 files changed

+880
-50
lines changed
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flow\PostgreSql\Client\Exception;
6+
7+
final readonly class PostgreSqlError
8+
{
9+
private function __construct(
10+
public string $sqlState,
11+
public PostgreSqlErrorCategory $category,
12+
public string $message,
13+
public ?string $detail,
14+
public ?string $hint,
15+
public ?string $schema,
16+
public ?string $table,
17+
public ?string $column,
18+
public ?string $constraint,
19+
public ?int $position,
20+
) {
21+
}
22+
23+
public static function fromDiagnostics(
24+
string $sqlState,
25+
string $message,
26+
?string $detail = null,
27+
?string $hint = null,
28+
?string $schema = null,
29+
?string $table = null,
30+
?string $column = null,
31+
?string $constraint = null,
32+
?int $position = null,
33+
) : self {
34+
return new self(
35+
$sqlState,
36+
PostgreSqlErrorCategory::fromSqlState($sqlState),
37+
$message,
38+
$detail,
39+
$hint,
40+
$schema,
41+
$table,
42+
$column,
43+
$constraint,
44+
$position,
45+
);
46+
}
47+
48+
public static function unknown(string $message = 'Unknown error') : self
49+
{
50+
return new self(
51+
'00000',
52+
PostgreSqlErrorCategory::UNKNOWN,
53+
$message,
54+
null,
55+
null,
56+
null,
57+
null,
58+
null,
59+
null,
60+
null,
61+
);
62+
}
63+
64+
public function fullMessage() : string
65+
{
66+
return $this->message;
67+
}
68+
69+
public function isCheckViolation() : bool
70+
{
71+
return $this->sqlState === '23514';
72+
}
73+
74+
public function isConnectionError() : bool
75+
{
76+
return $this->category === PostgreSqlErrorCategory::CONNECTION_EXCEPTION;
77+
}
78+
79+
public function isDataError() : bool
80+
{
81+
return $this->category === PostgreSqlErrorCategory::DATA_EXCEPTION;
82+
}
83+
84+
public function isDeadlockDetected() : bool
85+
{
86+
return $this->sqlState === '40P01';
87+
}
88+
89+
public function isExclusionViolation() : bool
90+
{
91+
return $this->sqlState === '23P01';
92+
}
93+
94+
public function isForeignKeyViolation() : bool
95+
{
96+
return $this->sqlState === '23503';
97+
}
98+
99+
public function isIntegrityViolation() : bool
100+
{
101+
return $this->category === PostgreSqlErrorCategory::INTEGRITY_CONSTRAINT_VIOLATION;
102+
}
103+
104+
public function isNotNullViolation() : bool
105+
{
106+
return $this->sqlState === '23502';
107+
}
108+
109+
public function isSerializationFailure() : bool
110+
{
111+
return $this->sqlState === '40001';
112+
}
113+
114+
public function isSyntaxError() : bool
115+
{
116+
return $this->category === PostgreSqlErrorCategory::SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION;
117+
}
118+
119+
public function isTransactionRollback() : bool
120+
{
121+
return $this->category === PostgreSqlErrorCategory::TRANSACTION_ROLLBACK;
122+
}
123+
124+
public function isUniqueViolation() : bool
125+
{
126+
return $this->sqlState === '23505';
127+
}
128+
129+
public function safeMessage() : string
130+
{
131+
return $this->category->safeMessage();
132+
}
133+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flow\PostgreSql\Client\Exception;
6+
7+
enum PostgreSqlErrorCategory : string
8+
{
9+
case CARDINALITY_VIOLATION = '21';
10+
case CASE_NOT_FOUND = '20';
11+
case CONFIGURATION_FILE_ERROR = 'F0';
12+
case CONNECTION_EXCEPTION = '08';
13+
case DATA_EXCEPTION = '22';
14+
case DEPENDENT_PRIVILEGE_DESCRIPTORS = '2B';
15+
case DIAGNOSTICS_EXCEPTION = '0Z';
16+
case EXTERNAL_ROUTINE_EXCEPTION = '38';
17+
case EXTERNAL_ROUTINE_INVOCATION_EXCEPTION = '39';
18+
case FDW_ERROR = 'HV';
19+
case FEATURE_NOT_SUPPORTED = '0A';
20+
case INSUFFICIENT_RESOURCES = '53';
21+
case INTEGRITY_CONSTRAINT_VIOLATION = '23';
22+
case INTERNAL_ERROR = 'XX';
23+
case INVALID_AUTHORIZATION_SPECIFICATION = '28';
24+
case INVALID_CATALOG_NAME = '3D';
25+
case INVALID_CURSOR_NAME = '34';
26+
case INVALID_CURSOR_STATE = '24';
27+
case INVALID_GRANTOR = '0L';
28+
case INVALID_ROLE_SPECIFICATION = '0P';
29+
case INVALID_SCHEMA_NAME = '3F';
30+
case INVALID_SQL_STATEMENT_NAME = '26';
31+
case INVALID_TRANSACTION_INITIATION = '0B';
32+
case INVALID_TRANSACTION_STATE = '25';
33+
case INVALID_TRANSACTION_TERMINATION = '2D';
34+
case LOCATOR_EXCEPTION = '0F';
35+
case NO_DATA = '02';
36+
case OBJECT_NOT_IN_PREREQUISITE_STATE = '55';
37+
case OPERATOR_INTERVENTION = '57';
38+
case PL_PGSQL_ERROR = 'P0';
39+
case PROGRAM_LIMIT_EXCEEDED = '54';
40+
case SAVEPOINT_EXCEPTION = '3B';
41+
case SNAPSHOT_FAILURE = '72';
42+
case SQL_ROUTINE_EXCEPTION = '2F';
43+
case SQL_STATEMENT_NOT_YET_COMPLETE = '03';
44+
case SUCCESSFUL_COMPLETION = '00';
45+
case SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION = '42';
46+
case SYSTEM_ERROR = '58';
47+
case TRANSACTION_ROLLBACK = '40';
48+
case TRIGGERED_ACTION_EXCEPTION = '09';
49+
case TRIGGERED_DATA_CHANGE_VIOLATION = '27';
50+
case UNKNOWN = '??';
51+
case WARNING = '01';
52+
case WITH_CHECK_OPTION_VIOLATION = '44';
53+
54+
public static function fromSqlState(string $sqlState) : self
55+
{
56+
if (\strlen($sqlState) < 2) {
57+
return self::UNKNOWN;
58+
}
59+
60+
$class = \substr($sqlState, 0, 2);
61+
62+
return self::tryFrom($class) ?? self::UNKNOWN;
63+
}
64+
65+
public function isRecoverable() : bool
66+
{
67+
return match ($this) {
68+
self::TRANSACTION_ROLLBACK => true,
69+
default => false,
70+
};
71+
}
72+
73+
public function safeMessage() : string
74+
{
75+
return match ($this) {
76+
self::SUCCESSFUL_COMPLETION => 'Operation completed successfully',
77+
self::WARNING => 'Operation completed with warnings',
78+
self::NO_DATA => 'No data found',
79+
self::SQL_STATEMENT_NOT_YET_COMPLETE => 'SQL statement not yet complete',
80+
self::CONNECTION_EXCEPTION => 'Database connection error occurred',
81+
self::TRIGGERED_ACTION_EXCEPTION => 'Triggered action error',
82+
self::FEATURE_NOT_SUPPORTED => 'Feature not supported',
83+
self::INVALID_TRANSACTION_INITIATION => 'Invalid transaction initiation',
84+
self::LOCATOR_EXCEPTION => 'Locator error',
85+
self::INVALID_GRANTOR => 'Invalid grantor',
86+
self::INVALID_ROLE_SPECIFICATION => 'Invalid role specification',
87+
self::DIAGNOSTICS_EXCEPTION => 'Diagnostics error',
88+
self::CASE_NOT_FOUND => 'Case not found',
89+
self::CARDINALITY_VIOLATION => 'Cardinality violation',
90+
self::DATA_EXCEPTION => 'Invalid data format or value',
91+
self::INTEGRITY_CONSTRAINT_VIOLATION => 'Data constraint violation',
92+
self::INVALID_CURSOR_STATE => 'Invalid cursor state',
93+
self::INVALID_TRANSACTION_STATE => 'Invalid transaction state',
94+
self::INVALID_SQL_STATEMENT_NAME => 'Invalid SQL statement name',
95+
self::TRIGGERED_DATA_CHANGE_VIOLATION => 'Triggered data change violation',
96+
self::INVALID_AUTHORIZATION_SPECIFICATION => 'Authorization error',
97+
self::DEPENDENT_PRIVILEGE_DESCRIPTORS => 'Dependent privilege descriptors still exist',
98+
self::INVALID_TRANSACTION_TERMINATION => 'Invalid transaction termination',
99+
self::SQL_ROUTINE_EXCEPTION => 'SQL routine error',
100+
self::INVALID_CURSOR_NAME => 'Invalid cursor name',
101+
self::EXTERNAL_ROUTINE_EXCEPTION => 'External routine error',
102+
self::EXTERNAL_ROUTINE_INVOCATION_EXCEPTION => 'External routine invocation error',
103+
self::SAVEPOINT_EXCEPTION => 'Savepoint error',
104+
self::INVALID_CATALOG_NAME => 'Invalid catalog name',
105+
self::INVALID_SCHEMA_NAME => 'Invalid schema name',
106+
self::TRANSACTION_ROLLBACK => 'Transaction was rolled back',
107+
self::SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION => 'Query syntax or permission error',
108+
self::WITH_CHECK_OPTION_VIOLATION => 'Check option violation',
109+
self::INSUFFICIENT_RESOURCES => 'Server resource limit reached',
110+
self::PROGRAM_LIMIT_EXCEEDED => 'Program limit exceeded',
111+
self::OBJECT_NOT_IN_PREREQUISITE_STATE => 'Object not in prerequisite state',
112+
self::OPERATOR_INTERVENTION => 'Operator intervention',
113+
self::SYSTEM_ERROR => 'Database system error',
114+
self::SNAPSHOT_FAILURE => 'Snapshot failure',
115+
self::CONFIGURATION_FILE_ERROR => 'Configuration file error',
116+
self::FDW_ERROR => 'Foreign data wrapper error',
117+
self::PL_PGSQL_ERROR => 'PL/pgSQL error',
118+
self::INTERNAL_ERROR => 'Internal database error',
119+
self::UNKNOWN => 'Database operation failed',
120+
};
121+
}
122+
}

src/lib/postgresql/src/Flow/PostgreSql/Client/Exception/QueryException.php

Lines changed: 20 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,52 +8,38 @@ final class QueryException extends ClientException
88
{
99
private const int SQL_PREVIEW_LENGTH = 100;
1010

11-
private ?string $sql = null;
12-
13-
public static function columnNotFound(string $column) : self
14-
{
15-
return new self(\sprintf('Column "%s" not found in result set', $column));
11+
private function __construct(
12+
string $message,
13+
private readonly string $sql,
14+
private readonly PostgreSqlError $error,
15+
) {
16+
parent::__construct($message);
1617
}
1718

18-
public static function executionFailed(string $sql, string $error) : self
19+
public static function executionFailed(string $sql, PostgreSqlError $error) : self
1920
{
2021
$sqlPreview = \strlen($sql) > self::SQL_PREVIEW_LENGTH
2122
? \substr($sql, 0, self::SQL_PREVIEW_LENGTH) . '...'
2223
: $sql;
2324

24-
$exception = new self(\sprintf('Query execution failed: %s. SQL: %s', $error, $sqlPreview));
25-
$exception->sql = $sql;
26-
27-
return $exception;
28-
}
29-
30-
public static function noRowsFound() : self
31-
{
32-
return new self('Expected exactly one row, but none were returned');
33-
}
34-
35-
public static function sequenceNotUsed(string $sequenceName) : self
36-
{
37-
return new self(\sprintf('Sequence "%s" has not been used in this session', $sequenceName));
38-
}
39-
40-
public static function tooManyRows(int $count) : self
41-
{
42-
return new self(\sprintf('Expected exactly one row, but %d were returned', $count));
25+
return new self(
26+
\sprintf(
27+
'Query execution failed [%s]: %s. SQL: %s',
28+
$error->sqlState,
29+
$error->safeMessage(),
30+
$sqlPreview
31+
),
32+
$sql,
33+
$error
34+
);
4335
}
4436

45-
public static function unexpectedScalarType(string $expected, string $actual) : self
37+
public function error() : PostgreSqlError
4638
{
47-
return new self(\sprintf('Expected scalar of type %s, got %s', $expected, $actual));
39+
return $this->error;
4840
}
4941

50-
/**
51-
* Get the full SQL query that caused the exception.
52-
*
53-
* Note: This is only available for executionFailed exceptions.
54-
* Use with caution - do not log or expose to users as it may contain sensitive data.
55-
*/
56-
public function sql() : ?string
42+
public function sql() : string
5743
{
5844
return $this->sql;
5945
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flow\PostgreSql\Client\Exception;
6+
7+
final class ResultException extends ClientException
8+
{
9+
public static function noRowsFound() : self
10+
{
11+
return new self('Expected exactly one row, but none were returned');
12+
}
13+
14+
public static function sequenceNotUsed(string $sequenceName) : self
15+
{
16+
return new self(\sprintf('Sequence "%s" has not been used in this session', $sequenceName));
17+
}
18+
19+
public static function tooManyRows(int $count) : self
20+
{
21+
return new self(\sprintf('Expected exactly one row, but %d were returned', $count));
22+
}
23+
24+
public static function unexpectedScalarType(string $expected, string $actual) : self
25+
{
26+
return new self(\sprintf('Expected scalar of type %s, got %s', $expected, $actual));
27+
}
28+
}

0 commit comments

Comments
 (0)