Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions doc/exceptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ try {
echo $e->getCode(); // 60
echo $e->getClickHouseExceptionName(); // 'UNKNOWN_TABLE' (CH 22+) or null (older versions)
echo $e->getQueryId(); // 'abc-123-def' (from X-ClickHouse-Query-Id header)
echo $e->getServerVersion(); // '26.3.3.20' or null
echo $e->getServerStackTrace(); // Server stack frames or null
}
```

Expand All @@ -37,9 +39,16 @@ try {
| `getCode()` | int | ClickHouse error code |
| `getClickHouseExceptionName()` | ?string | e.g. `UNKNOWN_TABLE`, `SYNTAX_ERROR` (CH 22+) |
| `getQueryId()` | ?string | Query ID from response header |
| `getServerVersion()` | ?string | Server version from the error response |
| `getServerStackTrace()` | ?string | Server stack frames, when returned by ClickHouse |
| `getRequestDetails()` | array | Request metadata |
| `getResponseDetails()` | array | Response metadata |

Metadata that is absent from the response is `null`. To request a server stack trace,
pass `['stacktrace' => 1]` as per-query settings. `getServerStackTrace()` returns the
frames without the trace heading or version suffix; PHP's `getTrace()` remains unchanged.
The standard exception constructor and existing `fromClickHouse()` calls remain compatible.

### Common Error Codes

| Code | Exception Name | Description |
Expand Down
26 changes: 22 additions & 4 deletions src/Exception/DatabaseException.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,24 @@
final class DatabaseException extends QueryException implements ClickHouseException
{
private ?string $clickHouseExceptionName = null;
private ?string $queryId = null;
private ?string $queryId = null;
private ?string $serverVersion = null;
private ?string $serverStackTrace = null;

public static function fromClickHouse(
string $message,
int $code,
?string $exceptionName = null,
?string $queryId = null
?string $queryId = null,
?string $serverVersion = null,
?string $serverStackTrace = null
): self {
$exception = new self($message, $code);
$exception = new self($message, $code);
$exception->clickHouseExceptionName = $exceptionName;
$exception->queryId = $queryId;
$exception->queryId = $queryId;
$exception->serverVersion = $serverVersion;
$exception->serverStackTrace = $serverStackTrace;

return $exception;
}

Expand All @@ -30,4 +37,15 @@ public function getQueryId(): ?string
{
return $this->queryId;
}

public function getServerVersion(): ?string
{
return $this->serverVersion;
}

/** Returns the server's stack frames, separate from PHP's local exception trace. */
public function getServerStackTrace(): ?string
{
return $this->serverStackTrace;
}
}
49 changes: 44 additions & 5 deletions src/Statement.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,20 @@
use ClickHouseDB\Transport\CurlerRequest;
use ClickHouseDB\Transport\CurlerResponse;

use function preg_match;
use function strpos;
use function substr;
use function trim;

use const PREG_OFFSET_CAPTURE;

class Statement implements \Iterator
{
private const CLICKHOUSE_ERROR_REGEX = "%Code:\s(\d+)\.\s*DB::Exception\s*:\s*(.*)(?:,\s*e\.what|\(version).*%ius";
private const CLICKHOUSE_EXCEPTION_NAME_REGEX = "%\(([A-Z_]+)\)\s*(?:\(version|$)%i";
private const CLICKHOUSE_ERROR_REGEX = '%Code:\s*(\d+)\.\s*DB::Exception\s*:\s*(.*)%is';
private const CLICKHOUSE_EXCEPTION_NAME_REGEX = '%\(([A-Z][A-Z0-9_]*)\)\s*$%';
private const CLICKHOUSE_VERSION_REGEX = '%\s*\(version\s+([0-9]+(?:\.[0-9]+)+(?:[-\w.]*)?)'
. '(?:\s+\([^\r\n]*\))?\)\s*$%';
private const CLICKHOUSE_STACK_TRACE_REGEX = '%,?\s*Stack trace(?:\s*\([^\r\n]*\))?:\s*\R(.*)$%s';

private mixed $_rawData = null;

Expand Down Expand Up @@ -94,10 +104,37 @@ private function parseErrorClickHouse(string $body): array|false
$matches = [];

if (preg_match(self::CLICKHOUSE_ERROR_REGEX, $body, $matches)) {
$result = ['code' => $matches[1], 'message' => $matches[2], 'exception_name' => null];
if (preg_match(self::CLICKHOUSE_EXCEPTION_NAME_REGEX, $body, $nameMatches)) {
$message = $matches[2];
$result = [
'code' => $matches[1],
'message' => $message,
'exception_name' => null,
'server_version' => null,
'server_stack_trace' => null,
];

if (preg_match(self::CLICKHOUSE_VERSION_REGEX, $message, $versionMatches, PREG_OFFSET_CAPTURE)) {
$result['server_version'] = $versionMatches[1][0];
$message = substr($message, 0, $versionMatches[0][1]);
}

$result['message'] = $message;

if (preg_match(self::CLICKHOUSE_STACK_TRACE_REGEX, $message, $traceMatches, PREG_OFFSET_CAPTURE)) {
$result['server_stack_trace'] = trim($traceMatches[1][0]) ?: null;
$message = substr($message, 0, $traceMatches[0][1]);
}

if (preg_match(self::CLICKHOUSE_EXCEPTION_NAME_REGEX, $message, $nameMatches)) {
$result['exception_name'] = $nameMatches[1];
}

$legacySuffix = strpos($result['message'], ', e.what');

if ($legacySuffix !== false) {
$result['message'] = substr($result['message'], 0, $legacySuffix);
}

return $result;
}

Expand Down Expand Up @@ -155,7 +192,9 @@ public function error()
$parse['message'] . "\nIN:" . $this->sql(),
(int) $parse['code'],
$parse['exception_name'] ?? null,
$queryId
$queryId,
$parse['server_version'],
$parse['server_stack_trace']
);
} else {
$code = $this->response()->http_code();
Expand Down
19 changes: 14 additions & 5 deletions tests/StructuredExceptionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,14 +116,23 @@ public function testDatabaseExceptionFromClickHouseFactory(): void

public function testLiveExceptionHasStructuredData(): void
{
$version = $this->client->select('SELECT version() AS version')->fetchOne('version');
try {
$this->client->select('SELECT * FROM non_existent_table_xyz_123')->rows();
$this->client->select('SELECT missing_function_xyz()', [], null, null, [
'stacktrace' => 1,
'query_id' => 'structured-exception-test',
])->rows();
$this->fail('Expected exception');
} catch (DatabaseException $e) {
$this->assertGreaterThan(0, $e->getCode());
} catch (\ClickHouseDB\Exception\QueryException $e) {
// QueryException is also acceptable (wraps DatabaseException)
$this->assertGreaterThan(0, $e->getCode());
$this->assertSame(46, $e->getCode());
$this->assertSame('UNKNOWN_FUNCTION', $e->getClickHouseExceptionName());
// CH 21 omits this header for errors raised before query execution.
$expectedQueryId = version_compare($version, '22.0', '<') ? null : 'structured-exception-test';
$this->assertSame($expectedQueryId, $e->getQueryId());
$this->assertSame($version, $e->getServerVersion());
$this->assertNotNull($e->getServerStackTrace());
$this->assertStringContainsString('DB::', $e->getServerStackTrace());
$this->assertStringNotContainsString('(version', $e->getServerStackTrace());
}
}
}
Loading