diff --git a/doc/exceptions.md b/doc/exceptions.md index c3ba117..8c81350 100644 --- a/doc/exceptions.md +++ b/doc/exceptions.md @@ -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 } ``` @@ -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 | diff --git a/src/Exception/DatabaseException.php b/src/Exception/DatabaseException.php index 7845e65..c9afc8b 100644 --- a/src/Exception/DatabaseException.php +++ b/src/Exception/DatabaseException.php @@ -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; } @@ -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; + } } diff --git a/src/Statement.php b/src/Statement.php index aa7af07..84795c8 100644 --- a/src/Statement.php +++ b/src/Statement.php @@ -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; @@ -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; } @@ -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(); diff --git a/tests/StructuredExceptionTest.php b/tests/StructuredExceptionTest.php index 7b1aac7..ac398e7 100644 --- a/tests/StructuredExceptionTest.php +++ b/tests/StructuredExceptionTest.php @@ -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()); } } }