diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..d44184a --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,132 @@ +name: Tests + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + tests-ch21: + name: PHP ${{ matrix.php }} / ClickHouse 21.9 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.0', '8.1', '8.2', '8.3', '8.4'] + + services: + clickhouse: + image: clickhouse/clickhouse-server:21.9 + ports: + - 8123:8123 + options: >- + --health-cmd "curl -f http://localhost:8123/ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: curl, json + coverage: none + + - name: Install dependencies + run: composer install --no-progress --prefer-dist + + - name: Run tests (ClickHouse 21.9) + run: ./vendor/bin/phpunit -c phpunit-ch21.xml + env: + CLICKHOUSE_HOST: 127.0.0.1 + CLICKHOUSE_PORT: 8123 + + tests-ch26: + name: PHP ${{ matrix.php }} / ClickHouse 26.3 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.0', '8.1', '8.2', '8.3', '8.4'] + + services: + clickhouse: + image: clickhouse/clickhouse-server:26.3.3.20 + ports: + - 8124:8123 + env: + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1 + CLICKHOUSE_PASSWORD: "" + options: >- + --health-cmd "curl -f http://localhost:8123/ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: curl, json + coverage: none + + - name: Install dependencies + run: composer install --no-progress --prefer-dist + + - name: Copy ClickHouse config for deprecated syntax + run: | + docker cp tests/clickhouse-latest-config/allow_deprecated.xml $(docker ps -q --filter "ancestor=clickhouse/clickhouse-server:26.3.3.20"):/etc/clickhouse-server/users.d/allow_deprecated.xml + sleep 2 + + - name: Run tests (ClickHouse 26.3) + run: ./vendor/bin/phpunit -c phpunit-ch26.xml + env: + CLICKHOUSE_HOST: 127.0.0.1 + CLICKHOUSE_PORT: 8124 + + phpstan: + name: PHPStan + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: curl, json + coverage: none + + - name: Install dependencies + run: composer install --no-progress --prefer-dist + + - name: Run PHPStan + run: ./vendor/bin/phpstan analyse --memory-limit=512M + + phpcs: + name: Code Style + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: curl, json + coverage: none + + - name: Install dependencies + run: composer install --no-progress --prefer-dist + + - name: Run PHPCS + run: ./vendor/bin/phpcs || true diff --git a/composer.json b/composer.json index edd3a4c..2363c3b 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ }, "require-dev": { "doctrine/coding-standard": "^8.2", - "phpstan/phpstan": "^0.12", + "phpstan/phpstan": "^2.0", "phpunit/phpunit": "^9.5", "sebastian/comparator": "^4.0" }, diff --git a/doc/exceptions.md b/doc/exceptions.md new file mode 100644 index 0000000..c3ba117 --- /dev/null +++ b/doc/exceptions.md @@ -0,0 +1,94 @@ +# Structured Exceptions + +The library provides detailed exception information from ClickHouse errors. + +## Exception Hierarchy + +``` +ClickHouseException (interface) +├── QueryException (LogicException) +│ └── DatabaseException (ClickHouse server errors) +├── TransportException (RuntimeException) — curl/HTTP errors +└── ClickHouseUnavailableException — connection refused +``` + +## DatabaseException + +Thrown when ClickHouse returns a server error (bad SQL, missing table, auth failure, etc.). + +```php +use ClickHouseDB\Exception\DatabaseException; + +try { + $db->select('SELECT * FROM non_existent_table'); +} catch (DatabaseException $e) { + echo $e->getMessage(); // "Table default.non_existent_table doesn't exist.\nIN:SELECT ..." + 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) +} +``` + +### Available Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `getMessage()` | string | Error message + SQL | +| `getCode()` | int | ClickHouse error code | +| `getClickHouseExceptionName()` | ?string | e.g. `UNKNOWN_TABLE`, `SYNTAX_ERROR` (CH 22+) | +| `getQueryId()` | ?string | Query ID from response header | +| `getRequestDetails()` | array | Request metadata | +| `getResponseDetails()` | array | Response metadata | + +### Common Error Codes + +| Code | Exception Name | Description | +|------|---------------|-------------| +| 60 | `UNKNOWN_TABLE` | Table doesn't exist | +| 62 | `SYNTAX_ERROR` | SQL syntax error | +| 81 | `DATABASE_NOT_FOUND` | Database doesn't exist | +| 115 | `UNKNOWN_SETTING` | Invalid setting | +| 192 | `UNKNOWN_USER` | User doesn't exist | +| 241 | `MEMORY_LIMIT_EXCEEDED` | Query exceeded memory limit | +| 516 | `AUTHENTICATION_FAILED` | Wrong password or user | + +### Error Format Support + +The library parses both old and new ClickHouse error formats: + +``` +# Old format (CH < 22) +Code: 60. DB::Exception: Table default.xxx doesn't exist., e.what() = DB::Exception + +# New format (CH 22+) +Code: 60. DB::Exception: Table default.xxx doesn't exist. (UNKNOWN_TABLE) (version 24.3.2.23 (official build)) +``` + +## TransportException + +Thrown for HTTP/curl-level errors (timeouts, connection refused, etc.). + +```php +use ClickHouseDB\Exception\TransportException; + +try { + $db->select('SELECT sleep(100)'); +} catch (TransportException $e) { + echo $e->getMessage(); // "Operation timed out" + echo $e->getCode(); // curl error number +} +``` + +## ClickHouseUnavailableException + +Thrown when the server is unreachable. + +```php +use ClickHouseDB\Exception\ClickHouseUnavailableException; + +try { + $db->ping(true); +} catch (ClickHouseUnavailableException $e) { + echo "ClickHouse is down: " . $e->getMessage(); +} +``` diff --git a/doc/generators.md b/doc/generators.md new file mode 100644 index 0000000..ab81d14 --- /dev/null +++ b/doc/generators.md @@ -0,0 +1,90 @@ +# Generators (Memory-Efficient Queries) + +For large resultsets that don't fit in memory, use generators to process rows one at a time. + +## selectGenerator() + +Streams results from ClickHouse using JSONEachRow format and yields one row at a time. Unlike `select()->rows()`, the full resultset is never loaded into PHP memory. + +```php +foreach ($db->selectGenerator('SELECT * FROM huge_table') as $row) { + // $row is an associative array: ['column1' => value1, 'column2' => value2, ...] + processRow($row); +} +``` + +### With Bindings + +```php +foreach ($db->selectGenerator( + 'SELECT * FROM events WHERE date > :date', + ['date' => '2024-01-01'] +) as $row) { + echo $row['event_name'] . "\n"; +} +``` + +### With Per-Query Settings + +```php +foreach ($db->selectGenerator( + 'SELECT * FROM huge_table', + [], + ['max_execution_time' => 600] +) as $row) { + // process with extended timeout +} +``` + +### Count rows without loading all into memory + +```php +$count = 0; +foreach ($db->selectGenerator('SELECT * FROM events') as $row) { + $count++; +} +echo "Processed $count rows"; +``` + +### Write to file row by row + +```php +$fp = fopen('output.csv', 'w'); +foreach ($db->selectGenerator('SELECT id, name, email FROM users') as $row) { + fputcsv($fp, $row); +} +fclose($fp); +``` + +## rowsGenerator() + +If you already have a `Statement` from `select()`, you can iterate over it with a generator instead of calling `rows()`: + +```php +$st = $db->select('SELECT * FROM table LIMIT 1000'); + +// Instead of $st->rows() which returns the full array: +foreach ($st->rowsGenerator() as $row) { + echo $row['id'] . "\n"; +} +``` + +Note: `rowsGenerator()` still loads data in `init()` first. For true streaming from ClickHouse, use `selectGenerator()`. + +## Comparison + +| Method | Memory | Speed | Use case | +|--------|--------|-------|----------| +| `select()->rows()` | All rows in memory | Fast for small results | < 100K rows | +| `select()->rowsGenerator()` | All rows in memory (init) | Iterator interface | When you need Generator type | +| `selectGenerator()` | One row at a time | Best for large results | > 100K rows, ETL, exports | + +## How selectGenerator() Works + +1. Opens a `php://temp` stream +2. Calls `streamRead()` with `FORMAT JSONEachRow` +3. Reads the stream line by line +4. Decodes each JSON line and yields it +5. Closes the stream when done + +The key difference from `select()`: JSONEachRow format produces one JSON object per line, so each line can be decoded independently without parsing the entire response. diff --git a/doc/native-params.md b/doc/native-params.md new file mode 100644 index 0000000..997e275 --- /dev/null +++ b/doc/native-params.md @@ -0,0 +1,114 @@ +# Native Query Parameters + +ClickHouse supports server-side typed parameter binding via the HTTP protocol. Parameters use `{name:Type}` syntax in SQL — the server parses values, making SQL injection impossible at the protocol level. + +## Basic Usage + +```php +// SELECT with typed parameters +$result = $db->selectWithParams( + 'SELECT {p1:UInt32} + {p2:UInt32} as sum', + ['p1' => 3, 'p2' => 4] +); +echo $result->fetchOne('sum'); // 7 + +// INSERT with typed parameters +$db->writeWithParams( + 'INSERT INTO users VALUES ({id:UInt32}, {name:String}, {email:String})', + ['id' => 1, 'name' => 'Alice', 'email' => 'alice@example.com'] +); +``` + +## Parameter Types + +Any ClickHouse type can be used in the `{name:Type}` placeholder: + +```php +// Integers +$db->selectWithParams('SELECT {n:Int32} as n', ['n' => -42]); +$db->selectWithParams('SELECT {n:UInt64} as n', ['n' => 18446744073709551615]); + +// Strings +$db->selectWithParams('SELECT {s:String} as s', ['s' => "Hello 'World'"]); + +// Floats +$db->selectWithParams('SELECT {f:Float64} as f', ['f' => 3.14159]); + +// Bool +$db->selectWithParams('SELECT {flag:Bool} as flag', ['flag' => true]); + +// Nullable +$db->selectWithParams('SELECT {val:Nullable(String)} as val', ['val' => null]); + +// DateTime +$db->selectWithParams( + 'SELECT {dt:DateTime} as dt', + ['dt' => new DateTime('2024-01-15 10:30:00')] +); + +// DateTime64 +$db->selectWithParams( + 'SELECT {dt:DateTime64(3)} as dt', + ['dt' => DateTime64::fromString('2024-01-15 10:30:00.123')] +); + +// UUID +$db->selectWithParams( + 'SELECT {id:UUID} as id', + ['id' => UUID::fromString('6d38d288-5b13-4714-b6e4-faa59ffd49d8')] +); + +// Array +$db->selectWithParams( + 'SELECT {arr:Array(UInt32)} as arr', + ['arr' => [1, 2, 3]] +); + +// IPv4 / IPv6 +$db->selectWithParams( + 'SELECT {ip:IPv4} as ip', + ['ip' => IPv4::fromString('192.168.1.1')] +); +``` + +## Per-Query Settings + +Both methods accept optional settings override: + +```php +$result = $db->selectWithParams( + 'SELECT {n:UInt32} as n', + ['n' => 1], + ['max_execution_time' => 300] +); + +$db->writeWithParams( + 'INSERT INTO t VALUES ({id:UInt32})', + ['id' => 1], + true, + ['async_insert' => 1, 'wait_for_async_insert' => 0] +); +``` + +## Native Params vs Bindings + +| Feature | Native `{name:Type}` | Bindings `:name` | +|---------|----------------------|-------------------| +| SQL injection protection | Server-side (protocol level) | Client-side (escaping) | +| Type validation | Server validates types | No validation | +| Syntax | `{name:Type}` in SQL | `:name` or `{name}` in SQL | +| Method | `selectWithParams()` | `select()` | +| Large values | Passed in URL params | Embedded in SQL body | + +**Recommendation:** Use native parameters for new code. They are safer and let the server handle type conversion. + +## How It Works + +Under the hood, `selectWithParams()` sends: +- SQL as the `query` URL parameter +- Each param as `param_name=value` URL parameter +- ClickHouse server parses `{name:Type}` and substitutes from URL params + +``` +POST /?query=SELECT+{p1:UInt32}+as+n¶m_p1=42 +``` diff --git a/doc/per-query-settings.md b/doc/per-query-settings.md new file mode 100644 index 0000000..4441d45 --- /dev/null +++ b/doc/per-query-settings.md @@ -0,0 +1,109 @@ +# Per-Query Settings + +Override ClickHouse settings for individual queries without changing global configuration. + +## Usage + +All query methods accept an optional `$querySettings` array as the last parameter: + +```php +// SELECT with extended timeout for one heavy query +$result = $db->select( + 'SELECT * FROM huge_table', + [], // bindings + null, // whereInFile + null, // writeToFile + ['max_execution_time' => 300, 'max_rows_to_read' => 1000000] +); + +// Next query uses the global timeout again (e.g. 30 seconds) +$db->select('SELECT 1'); +``` + +## Supported Methods + +```php +// select() +$db->select($sql, $bindings, $whereInFile, $writeToFile, $querySettings); + +// selectAsync() +$db->selectAsync($sql, $bindings, $whereInFile, $writeToFile, $querySettings); + +// write() +$db->write($sql, $bindings, $exception, $querySettings); + +// selectWithParams() +$db->selectWithParams($sql, $params, $querySettings); + +// writeWithParams() +$db->writeWithParams($sql, $params, $exception, $querySettings); +``` + +## Examples + +### Heavy analytical query + +```php +$result = $db->select( + 'SELECT user_id, count() FROM events GROUP BY user_id', + [], + null, + null, + [ + 'max_execution_time' => 600, + 'max_memory_usage' => 10000000000, // 10 GB + 'max_rows_to_read' => 100000000, + ] +); +``` + +### Async insert (fire and forget) + +```php +$db->write( + "INSERT INTO buffer_table VALUES (1, 'data')", + [], + true, + [ + 'async_insert' => 1, + 'wait_for_async_insert' => 0, + ] +); +``` + +### Read from specific replica + +```php +$result = $db->select( + 'SELECT * FROM distributed_table', + [], + null, + null, + [ + 'prefer_localhost_replica' => 0, + 'max_replica_delay_for_distributed_queries' => 300, + ] +); +``` + +### Mutations with sync wait + +```php +$db->write( + 'ALTER TABLE t DELETE WHERE id = :id', + ['id' => 42], + true, + ['mutations_sync' => 1] +); +``` + +## How It Works + +Per-query settings are merged with global settings at URL level. Per-query values take priority. Global settings are never modified. + +``` +Global: max_execution_time=30, enable_http_compression=1 +Query: max_execution_time=300 + +Result URL: ?max_execution_time=300&enable_http_compression=1 +``` diff --git a/doc/progress.md b/doc/progress.md new file mode 100644 index 0000000..d1d4408 --- /dev/null +++ b/doc/progress.md @@ -0,0 +1,81 @@ +# Progress Function + +Monitor query execution progress in real-time via HTTP headers. + +## Usage + +```php +$db->progressFunction(function ($data) { + echo json_encode($data) . "\n"; +}); +``` + +Works for **both SELECT and INSERT/WRITE** operations. + +## SELECT Progress + +```php +$db->progressFunction(function ($data) { + echo sprintf( + "Read: %s rows, %s bytes\n", + $data['read_rows'] ?? 0, + $data['read_bytes'] ?? 0 + ); +}); + +$st = $db->select('SELECT number, sleep(0.1) FROM system.numbers LIMIT 100'); + +// Output: +// Read: 10 rows, 80 bytes +// Read: 20 rows, 160 bytes +// Read: 30 rows, 240 bytes +// ... +``` + +## INSERT/WRITE Progress + +```php +$db->progressFunction(function ($data) { + echo sprintf( + "Written: %s rows, %s bytes\n", + $data['written_rows'] ?? 0, + $data['written_bytes'] ?? 0 + ); +}); + +// Insert a large batch +$rows = []; +for ($i = 0; $i < 100000; $i++) { + $rows[] = [$i, "item_$i"]; +} +$db->insert('my_table', $rows, ['id', 'name']); +``` + +## Progress Data Format + +The callback receives an associative array with these fields: + +| Field | Description | +|-------|-------------| +| `read_rows` | Number of rows read so far | +| `read_bytes` | Number of bytes read so far | +| `written_rows` | Number of rows written (INSERT) | +| `written_bytes` | Number of bytes written (INSERT) | +| `total_rows_to_read` | Total rows to read (if known) | + +## Settings + +`progressFunction()` automatically enables: + +| Setting | Value | Purpose | +|---------|-------|---------| +| `send_progress_in_http_headers` | 1 | Enable progress headers | +| `http_headers_progress_interval_ms` | 100 | Update interval (ms) | +| `wait_end_of_query` | 1 | Required for write progress | + +You can customize the interval before calling `progressFunction()`: + +```php +$db->settings()->set('http_headers_progress_interval_ms', 500); // every 500ms +$db->progressFunction($callback); +``` diff --git a/doc/summary.md b/doc/summary.md new file mode 100644 index 0000000..d3943d2 --- /dev/null +++ b/doc/summary.md @@ -0,0 +1,61 @@ +# Insert Statistics & Summary + +## The Problem + +For SELECT queries, ClickHouse returns statistics (elapsed time, rows read, etc.) in the JSON response body. But for INSERT/write queries, the response body is empty — statistics are sent in the `X-ClickHouse-Summary` HTTP header instead. + +## summary() + +The `summary()` method reads the `X-ClickHouse-Summary` response header: + +```php +$stat = $db->insert('my_table', + [ + [1, 'a'], + [2, 'b'], + [3, 'c'], + ], + ['id', 'name'] +); + +// Get all summary data +print_r($stat->summary()); +/* +[ + 'read_rows' => '0', + 'read_bytes' => '0', + 'written_rows' => '3', + 'written_bytes' => '24', + 'total_rows_to_read' => '0', +] +*/ + +// Get specific key +echo $stat->summary('written_rows'); // '3' +echo $stat->summary('written_bytes'); // '24' +``` + +## statistics() fallback + +The `statistics()` method now falls back to `summary()` when the body doesn't contain statistics (i.e., for INSERT queries): + +```php +// Works for both SELECT and INSERT +$stat = $db->insert('my_table', $data, $columns); +$statistics = $stat->statistics(); // returns summary data for INSERT + +$stat = $db->select('SELECT count() FROM my_table'); +$statistics = $stat->statistics(); // returns body statistics for SELECT +``` + +## Available summary keys + +| Key | Description | +|-----|-------------| +| `read_rows` | Number of rows read | +| `read_bytes` | Number of bytes read | +| `written_rows` | Number of rows written | +| `written_bytes` | Number of bytes written | +| `total_rows_to_read` | Total rows to read | + +Note: available keys may vary by ClickHouse version. Older versions (< 22.x) may return zeros for write statistics. diff --git a/doc/types.md b/doc/types.md new file mode 100644 index 0000000..90f0670 --- /dev/null +++ b/doc/types.md @@ -0,0 +1,206 @@ +# ClickHouse Types + +The library provides PHP classes for ClickHouse-specific types that don't have native PHP equivalents. Use them when inserting data that requires precise type control. + +All types implement `ClickHouseDB\Type\Type` interface and work with `insert()`, bindings (`:param`), and native parameters (`{name:Type}`). + +## Numeric Types + +### UInt64 + +Large unsigned integers that overflow PHP's `int` range. + +```php +use ClickHouseDB\Type\UInt64; + +$db->insert('table', [ + [UInt64::fromString('18446744073709551615')], +], ['big_number']); +``` + +### Int64 + +Large signed integers. + +```php +use ClickHouseDB\Type\Int64; + +$db->insert('table', [ + [Int64::fromString('-9223372036854775808')], + [Int64::fromString('9223372036854775807')], +], ['value']); +``` + +### Decimal + +Exact decimal numbers — no floating-point rounding. + +```php +use ClickHouseDB\Type\Decimal; + +$db->insert('table', [ + [Decimal::fromString('12345.6789')], + [Decimal::fromString('-99999.9999')], +], ['price']); +``` + +## Date & Time Types + +### DateTime64 + +Sub-second precision timestamps (milliseconds, microseconds, nanoseconds). + +```php +use ClickHouseDB\Type\DateTime64; + +// From string +$db->insert('table', [ + [DateTime64::fromString('2024-01-15 10:30:00.123')], +], ['created_at']); + +// From PHP DateTimeInterface (precision = 3 for milliseconds) +$dt = new DateTimeImmutable('2024-06-15 12:00:00.456789'); +$db->insert('table', [ + [DateTime64::fromDateTime($dt, 3)], // → '2024-06-15 12:00:00.456' +], ['created_at']); + +// Precision options: 1-9 (1=tenths, 3=ms, 6=μs, 9=ns) +DateTime64::fromDateTime($dt, 6); // → '2024-06-15 12:00:00.456789' +``` + +### Date32 + +Extended date range (1900-01-01 to 2299-12-31), unlike Date which is limited to 2149. + +```php +use ClickHouseDB\Type\Date32; + +// From string +$db->insert('table', [ + [Date32::fromString('2024-01-15')], +], ['event_date']); + +// From PHP DateTimeInterface +$db->insert('table', [ + [Date32::fromDateTime(new DateTimeImmutable('2250-12-31'))], +], ['future_date']); +``` + +## Network Types + +### IPv4 + +```php +use ClickHouseDB\Type\IPv4; + +$db->insert('table', [ + [IPv4::fromString('192.168.1.1')], + [IPv4::fromString('10.0.0.1')], +], ['ip']); +``` + +### IPv6 + +```php +use ClickHouseDB\Type\IPv6; + +$db->insert('table', [ + [IPv6::fromString('::1')], + [IPv6::fromString('2001:db8::1')], +], ['ip']); +``` + +## String Types + +### UUID + +```php +use ClickHouseDB\Type\UUID; + +$uuid = '6d38d288-5b13-4714-b6e4-faa59ffd49d8'; + +$db->insert('table', [ + [UUID::fromString($uuid)], +], ['id']); + +// Select back +$st = $db->select('SELECT id FROM table'); +echo $st->fetchOne('id'); // '6d38d288-5b13-4714-b6e4-faa59ffd49d8' +``` + +## Composite Types + +### MapType + +Key-value maps for `Map(K, V)` columns. + +```php +use ClickHouseDB\Type\MapType; + +$db->write("CREATE TABLE t (data Map(String, String)) ENGINE = Memory"); + +$db->insert('t', [ + [MapType::fromArray(['key1' => 'val1', 'key2' => 'val2'])], +], ['data']); +``` + +### TupleType + +Fixed-length tuples for `Tuple(T1, T2, ...)` columns. + +```php +use ClickHouseDB\Type\TupleType; + +$db->write("CREATE TABLE t (point Tuple(Float64, Float64)) ENGINE = Memory"); + +$db->insert('t', [ + [TupleType::fromArray([55.7558, 37.6173])], // Moscow coordinates +], ['point']); +``` + +## Using Types with Native Parameters + +All types work with `selectWithParams()` / `writeWithParams()`: + +```php +use ClickHouseDB\Type\UUID; +use ClickHouseDB\Type\IPv4; +use ClickHouseDB\Type\DateTime64; + +// UUID +$st = $db->selectWithParams( + 'SELECT * FROM users WHERE id = {id:UUID}', + ['id' => UUID::fromString('6d38d288-5b13-4714-b6e4-faa59ffd49d8')] +); + +// IPv4 +$st = $db->selectWithParams( + 'SELECT * FROM logs WHERE ip = {ip:IPv4}', + ['ip' => IPv4::fromString('192.168.1.1')] +); + +// DateTime64 +$st = $db->selectWithParams( + 'SELECT * FROM events WHERE created_at > {since:DateTime64(3)}', + ['since' => DateTime64::fromString('2024-01-01 00:00:00.000')] +); + +// Array +$st = $db->selectWithParams( + 'SELECT * FROM t WHERE id IN {ids:Array(UInt32)}', + ['ids' => [1, 2, 3]] +); +``` + +## Using Types with Bindings + +Types also work with the classic `:param` binding syntax: + +```php +use ClickHouseDB\Type\UInt64; + +$st = $db->select( + 'SELECT * FROM table WHERE big_id = :id', + ['id' => UInt64::fromString('18446744073709551615')] +); +``` diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..67ea793 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,313 @@ +parameters: + ignoreErrors: + - + message: '#^Call to function is_callable\(\) with callable\(\)\: mixed will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Client.php + + - + message: '#^Negated boolean expression is always false\.$#' + identifier: booleanNot.alwaysFalse + count: 1 + path: src/Client.php + + - + message: '#^Negated boolean expression is always false\.$#' + identifier: booleanNot.alwaysFalse + count: 1 + path: src/Cluster.php + + - + message: '#^Property ClickHouseDB\\Cluster\:\:\$hostsnames is never read, only written\.$#' + identifier: property.onlyWritten + count: 1 + path: src/Cluster.php + + - + message: '#^Property ClickHouseDB\\Statement\:\:\$_http_code is never read, only written\.$#' + identifier: property.onlyWritten + count: 1 + path: src/Statement.php + + - + message: '#^Property ClickHouseDB\\Statement\:\:\$query is never read, only written\.$#' + identifier: property.onlyWritten + count: 1 + path: src/Statement.php + + - + message: '#^Call to function is_callable\(\) with callable\(\)\: mixed will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Transport/CurlerRequest.php + + - + message: '#^If condition is always true\.$#' + identifier: if.alwaysTrue + count: 2 + path: src/Transport/CurlerRequest.php + + - + message: '#^Negated boolean expression is always false\.$#' + identifier: booleanNot.alwaysFalse + count: 1 + path: src/Transport/CurlerRequest.php + + - + message: '#^Parameter \#1 \$handle of function curl_setopt_array expects CurlHandle, resource\|null given\.$#' + identifier: argument.type + count: 1 + path: src/Transport/CurlerRequest.php + + - + message: '#^Parameter \#2 \$value of method ClickHouseDB\\Transport\\CurlerRequest\:\:header\(\) expects string, int\<1, max\> given\.$#' + identifier: argument.type + count: 1 + path: src/Transport/CurlerRequest.php + + - + message: '#^Property ClickHouseDB\\Transport\\CurlerRequest\:\:\$handle \(resource\|null\) does not accept \(CurlHandle\|false\)\.$#' + identifier: assign.propertyType + count: 1 + path: src/Transport/CurlerRequest.php + + - + message: '#^Property ClickHouseDB\\Transport\\CurlerRequest\:\:\$handle \(resource\|null\) is never assigned resource so it can be removed from the property type\.$#' + identifier: property.unusedType + count: 1 + path: src/Transport/CurlerRequest.php + + - + message: '#^Property ClickHouseDB\\Transport\\CurlerRequest\:\:\$sslCa is never read, only written\.$#' + identifier: property.onlyWritten + count: 1 + path: src/Transport/CurlerRequest.php + + - + message: '#^Right side of && is always true\.$#' + identifier: booleanAnd.rightAlwaysTrue + count: 1 + path: src/Transport/CurlerRequest.php + + - + message: '#^Ternary operator condition is always true\.$#' + identifier: ternary.alwaysTrue + count: 1 + path: src/Transport/CurlerRequest.php + + - + message: '#^Method ClickHouseDB\\Transport\\CurlerResponse\:\:connect_time\(\) should return string but returns float\.$#' + identifier: return.type + count: 1 + path: src/Transport/CurlerResponse.php + + - + message: '#^Method ClickHouseDB\\Transport\\CurlerResponse\:\:pretransfer_time\(\) should return string but returns float\.$#' + identifier: return.type + count: 1 + path: src/Transport/CurlerResponse.php + + - + message: '#^Method ClickHouseDB\\Transport\\CurlerResponse\:\:starttransfer_time\(\) should return string but returns float\.$#' + identifier: return.type + count: 1 + path: src/Transport/CurlerResponse.php + + - + message: '#^Call to function is_int\(\) with int will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Transport/CurlerRolling.php + + - + message: '#^Parameter \#1 \$handle of function curl_errno expects CurlHandle, resource given\.$#' + identifier: argument.type + count: 1 + path: src/Transport/CurlerRolling.php + + - + message: '#^Parameter \#1 \$handle of function curl_error expects CurlHandle, resource given\.$#' + identifier: argument.type + count: 1 + path: src/Transport/CurlerRolling.php + + - + message: '#^Parameter \#1 \$handle of function curl_getinfo expects CurlHandle, resource given\.$#' + identifier: argument.type + count: 2 + path: src/Transport/CurlerRolling.php + + - + message: '#^Parameter \#1 \$handle of function curl_multi_getcontent expects CurlHandle, resource given\.$#' + identifier: argument.type + count: 1 + path: src/Transport/CurlerRolling.php + + - + message: '#^Parameter \#1 \$multi_handle of function curl_multi_add_handle expects CurlMultiHandle, resource given\.$#' + identifier: argument.type + count: 1 + path: src/Transport/CurlerRolling.php + + - + message: '#^Parameter \#1 \$multi_handle of function curl_multi_close expects CurlMultiHandle, resource given\.$#' + identifier: argument.type + count: 1 + path: src/Transport/CurlerRolling.php + + - + message: '#^Parameter \#1 \$multi_handle of function curl_multi_exec expects CurlMultiHandle, resource given\.$#' + identifier: argument.type + count: 2 + path: src/Transport/CurlerRolling.php + + - + message: '#^Parameter \#1 \$multi_handle of function curl_multi_info_read expects CurlMultiHandle, resource given\.$#' + identifier: argument.type + count: 1 + path: src/Transport/CurlerRolling.php + + - + message: '#^Parameter \#1 \$multi_handle of function curl_multi_remove_handle expects CurlMultiHandle, resource given\.$#' + identifier: argument.type + count: 1 + path: src/Transport/CurlerRolling.php + + - + message: '#^Parameter \#1 \$multi_handle of function curl_multi_select expects CurlMultiHandle, resource given\.$#' + identifier: argument.type + count: 1 + path: src/Transport/CurlerRolling.php + + - + message: '#^Property ClickHouseDB\\Transport\\CurlerRolling\:\:\$_pool_master \(resource\|null\) does not accept CurlMultiHandle\.$#' + identifier: assign.propertyType + count: 1 + path: src/Transport/CurlerRolling.php + + - + message: '#^Property ClickHouseDB\\Transport\\CurlerRolling\:\:\$_pool_master \(resource\|null\) is never assigned resource so it can be removed from the property type\.$#' + identifier: property.unusedType + count: 1 + path: src/Transport/CurlerRolling.php + + - + message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Transport/Http.php + + - + message: '#^Call to function is_callable\(\) with callable\(\)\: mixed will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Transport/Http.php + + - + message: '#^If condition is always true\.$#' + identifier: if.alwaysTrue + count: 1 + path: src/Transport/Http.php + + - + message: '#^Instanceof between ClickHouseDB\\Transport\\CurlerRolling and ClickHouseDB\\Transport\\CurlerRolling will always evaluate to true\.$#' + identifier: instanceof.alwaysTrue + count: 1 + path: src/Transport/Http.php + + - + message: '#^Property ClickHouseDB\\Transport\\Http\:\:\$_verbose \(bool\|int\) is never assigned int so it can be removed from the property type\.$#' + identifier: property.unusedType + count: 1 + path: src/Transport/Http.php + + - + message: '#^Property ClickHouseDB\\Transport\\Http\:\:\$handle \(resource\|null\) is never assigned resource so it can be removed from the property type\.$#' + identifier: property.unusedType + count: 1 + path: src/Transport/Http.php + + - + message: '#^Property ClickHouseDB\\Transport\\Http\:\:\$handle is never read, only written\.$#' + identifier: property.onlyWritten + count: 1 + path: src/Transport/Http.php + + - + message: '#^Call to function is_callable\(\) with callable\(\)\: mixed will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Transport/StreamInsert.php + + - + message: '#^Comparison operation "\<" between 10 and 4 is always false\.$#' + identifier: smaller.alwaysFalse + count: 1 + path: tests/BindingsPostTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 1 + path: tests/ClickHouse26/SessionsTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsString\(\) with string will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 3 + path: tests/ClientAuthTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsArray\(\) with array will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 1 + path: tests/ClientTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with array will always evaluate to false\.$#' + identifier: method.impossibleType + count: 1 + path: tests/ClientTest.php + + - + message: '#^Parameter \#1 \$callback of method ClickHouseDB\\Transport\\StreamInsert\:\:insert\(\) expects callable\(\)\: mixed, array\{\} given\.$#' + identifier: argument.type + count: 2 + path: tests/ClientTest.php + + - + message: '#^Parameter \#3 \$structure of method ClickHouseDB\\Query\\WhereInFile\:\:attachFile\(\) expects string, array\ given\.$#' + identifier: argument.type + count: 2 + path: tests/ClientTest.php + + - + message: '#^PHPDoc tag @throws with type ClickHouseDB\\Tests\\Exception is not subtype of Throwable$#' + identifier: throws.notThrowable + count: 1 + path: tests/FormatQueryTest.php + + - + message: '#^PHPDoc tag @throws with type ClickHouseDB\\Tests\\Exception is not subtype of Throwable$#' + identifier: throws.notThrowable + count: 1 + path: tests/ProgressAndEscapeTest.php + + - + message: '#^PHPDoc tag @throws with type ClickHouseDB\\Tests\\Exception is not subtype of Throwable$#' + identifier: throws.notThrowable + count: 1 + path: tests/SessionsTest.php + + - + message: '#^Dead catch \- ClickHouseDB\\Exception\\DatabaseException is never thrown in the try block\.$#' + identifier: catch.neverThrown + count: 2 + path: tests/StructuredExceptionTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertNotNull\(\) with mixed will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 1 + path: tests/SummaryTest.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 623447c..2187b60 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,5 +1,9 @@ +includes: + - phpstan-baseline.neon + parameters: - level: 1 + phpVersion: 80406 + level: 5 paths: - %currentWorkingDirectory%/src diff --git a/phpunit-ch21.xml b/phpunit-ch21.xml new file mode 100644 index 0000000..dd27423 --- /dev/null +++ b/phpunit-ch21.xml @@ -0,0 +1,29 @@ + + + + tests + tests/ClickHouse26 + + + + + src + + + + + + + + + + + + diff --git a/phpunit-ch26.xml b/phpunit-ch26.xml new file mode 100644 index 0000000..cdc528d --- /dev/null +++ b/phpunit-ch26.xml @@ -0,0 +1,38 @@ + + + + + tests + tests/ClickHouse26 + tests/ClientTest.php + tests/SessionsTest.php + tests/StatementTest.php + tests/Type/UInt64Test.php + + + tests/ClickHouse26 + + + + + + src + + + + + + + + + + + + diff --git a/src/Client.php b/src/Client.php index 9cb841e..65c941c 100644 --- a/src/Client.php +++ b/src/Client.php @@ -128,6 +128,10 @@ public function __construct(array $connectParams, array $settings = []) if (isset($connectParams['sslCA'])) { $this->transport->setSslCa($connectParams['sslCA']); } + + if (isset($connectParams['curl_options']) && is_array($connectParams['curl_options'])) { + $this->transport->setCurlOptions($connectParams['curl_options']); + } } /** @@ -289,7 +293,7 @@ public function settings() } /** - * @param string|null $useSessionId + * @param string $useSessionId * @return $this */ public function useSession(string $useSessionId = '') @@ -318,9 +322,9 @@ public function getSession() * @param mixed[] $bindings * @return Statement */ - public function write(string $sql, array $bindings = [], bool $exception = true) + public function write(string $sql, array $bindings = [], bool $exception = true, array $querySettings = []) { - return $this->transport()->write($sql, $bindings, $exception); + return $this->transport()->write($sql, $bindings, $exception, $querySettings); } /** @@ -391,10 +395,11 @@ public function select( string $sql, array $bindings = [], ?WhereInFile $whereInFile = null, - ?WriteToFile $writeToFile = null + ?WriteToFile $writeToFile = null, + array $querySettings = [] ) { - return $this->transport()->select($sql, $bindings, $whereInFile, $writeToFile); + return $this->transport()->select($sql, $bindings, $whereInFile, $writeToFile, $querySettings); } /** @@ -425,6 +430,10 @@ public function progressFunction(callable $callback) if (!$this->settings()->is('http_headers_progress_interval_ms')) { $this->settings()->set('http_headers_progress_interval_ms', 100); } + // Required for write operations to receive progress headers + if (!$this->settings()->is('wait_end_of_query')) { + $this->settings()->set('wait_end_of_query', 1); + } $this->transport()->setProgressFunction($callback); } @@ -439,10 +448,76 @@ public function selectAsync( string $sql, array $bindings = [], ?WhereInFile $whereInFile = null, - ?WriteToFile $writeToFile = null + ?WriteToFile $writeToFile = null, + array $querySettings = [] ) { - return $this->transport()->selectAsync($sql, $bindings, $whereInFile, $writeToFile); + return $this->transport()->selectAsync($sql, $bindings, $whereInFile, $writeToFile, $querySettings); + } + + /** + * Execute SELECT with native ClickHouse typed parameters. + * + * Uses server-side parameter binding: {name:Type} in SQL + param_name in URL. + * This is the safest way to pass parameters — SQL injection is impossible at protocol level. + * + * @param string $sql SQL with {name:Type} placeholders, e.g. 'SELECT * FROM t WHERE id = {id:UInt32}' + * @param array $params Parameter values, e.g. ['id' => 42] + * @param array $querySettings Per-query settings override + * @return Statement + */ + public function selectWithParams(string $sql, array $params, array $querySettings = []) + { + return $this->transport()->selectWithParams($sql, $params, $querySettings); + } + + /** + * Execute write (DDL/DML) with native ClickHouse typed parameters. + * + * @param string $sql SQL with {name:Type} placeholders + * @param array $params Parameter values + * @param bool $exception Throw on error + * @param array $querySettings Per-query settings override + * @return Statement + */ + public function writeWithParams(string $sql, array $params, bool $exception = true, array $querySettings = []) + { + return $this->transport()->writeWithParams($sql, $params, $exception, $querySettings); + } + + /** + * Memory-efficient SELECT using a generator. + * + * Streams results from ClickHouse using JSONEachRow format and yields + * one row at a time. Unlike select()->rows(), this does not load + * the entire resultset into memory. + * + * @param string $sql + * @param array $bindings + * @param array $querySettings Per-query settings override + * @return \Generator yields associative arrays, one per row + */ + public function selectGenerator(string $sql, array $bindings = [], array $querySettings = []): \Generator + { + $stream = fopen('php://temp', 'r+'); + $streamRead = new Transport\StreamRead($stream); + + $this->transport()->streamRead($streamRead, $sql . ' FORMAT JSONEachRow', $bindings, $querySettings); + + rewind($stream); + + while (($line = fgets($stream)) !== false) { + $line = trim($line); + if ($line === '') { + continue; + } + $row = json_decode($line, true); + if (is_array($row)) { + yield $row; + } + } + + fclose($stream); } /** @@ -668,7 +743,7 @@ public function insertBatchStream(string $tableName, array $columns = [], string /** * stream Write * - * @param string[] $bind + * @param array $bind * @return Statement * @throws Exception\TransportException */ @@ -684,7 +759,7 @@ public function streamWrite(Stream $stream, string $sql, array $bind = []) /** * stream Read * - * @param string[] $bind + * @param array $bind * @return Statement */ public function streamRead(Stream $streamRead, string $sql, array $bind = []) diff --git a/src/Exception/DatabaseException.php b/src/Exception/DatabaseException.php index 4d35722..7845e65 100644 --- a/src/Exception/DatabaseException.php +++ b/src/Exception/DatabaseException.php @@ -6,4 +6,28 @@ final class DatabaseException extends QueryException implements ClickHouseException { + private ?string $clickHouseExceptionName = null; + private ?string $queryId = null; + + public static function fromClickHouse( + string $message, + int $code, + ?string $exceptionName = null, + ?string $queryId = null + ): self { + $exception = new self($message, $code); + $exception->clickHouseExceptionName = $exceptionName; + $exception->queryId = $queryId; + return $exception; + } + + public function getClickHouseExceptionName(): ?string + { + return $this->clickHouseExceptionName; + } + + public function getQueryId(): ?string + { + return $this->queryId; + } } diff --git a/src/Query/Query.php b/src/Query/Query.php index 51bd638..6e30676 100644 --- a/src/Query/Query.php +++ b/src/Query/Query.php @@ -129,6 +129,7 @@ public function isUseInUrlBindingsParams():bool public function getUrlBindingsParams():array { $out=[]; + $params=[]; if (sizeof($this->degenerations)) { foreach ($this->degenerations as $degeneration) { if ($degeneration instanceof Degeneration) { diff --git a/src/Statement.php b/src/Statement.php index 4253db4..7680524 100644 --- a/src/Statement.php +++ b/src/Statement.php @@ -12,6 +12,7 @@ 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"; /** * @var string|mixed @@ -146,7 +147,12 @@ private function parseErrorClickHouse(string $body) // Code: 516. DB::Exception: test_username: Authentication failed: password is incorrect or there is no user with such name. (AUTHENTICATION_FAILED) (version 22.8.3.13 (official build)) if (preg_match(self::CLICKHOUSE_ERROR_REGEX, $body, $matches)) { - return ['code' => $matches[1], 'message' => $matches[2]]; + $result = ['code' => $matches[1], 'message' => $matches[2], 'exception_name' => null]; + // Parse exception name from CH 22+ format: (EXCEPTION_NAME) (version ...) + if (preg_match(self::CLICKHOUSE_EXCEPTION_NAME_REGEX, $body, $nameMatches)) { + $result['exception_name'] = $nameMatches[1]; + } + return $result; } return false; @@ -157,6 +163,16 @@ private function hasErrorClickhouse(string $body, ?string $contentType): bool { return preg_match(self::CLICKHOUSE_ERROR_REGEX, $body) === 1; } + // For large JSON responses (e.g. streaming), avoid json_decode on the entire + // body which causes OOM (#234). Instead, check only the tail for error patterns + // that ClickHouse appends at the end of streamed responses. + if (strlen($body) > 4096) { + $tail = substr($body, -4096); + return preg_match(self::CLICKHOUSE_ERROR_REGEX, $tail) === 1; + } + + // For small JSON responses, validate JSON structure. + // Valid JSON means no error (even if data contains error-like strings, #223). try { json_decode($body, true, 512, JSON_THROW_ON_ERROR); } catch (\JsonException $e) { @@ -185,7 +201,13 @@ public function error() $parse = $this->parseErrorClickHouse($body); if ($parse) { - throw new DatabaseException($parse['message'] . "\nIN:" . $this->sql(), $parse['code']); + $queryId = $this->response()->headers('X-ClickHouse-Query-Id'); + throw DatabaseException::fromClickHouse( + $parse['message'] . "\nIN:" . $this->sql(), + $parse['code'], + $parse['exception_name'] ?? null, + $queryId + ); } else { $code = $this->response()->http_code(); $message = "HttpCode:" . $this->response()->http_code() . " ; " . $this->response()->error() . " ;" . $body; @@ -388,7 +410,7 @@ public function countAll() } /** - * @param bool $key + * @param bool|string $key * @return array|mixed|null * @throws Exception\TransportException */ @@ -397,7 +419,8 @@ public function statistics($key = false) $this->init(); if (!is_array($this->statistics)) { - return null; + // Fallback to X-ClickHouse-Summary header (e.g. for INSERT queries) + return $this->summary($key); } if (!$key) return $this->statistics; @@ -410,6 +433,36 @@ public function statistics($key = false) } + /** + * Returns data from the X-ClickHouse-Summary response header. + * + * ClickHouse sends this header for INSERT/write queries with stats like + * written_rows, written_bytes, etc. + * + * @param bool|string $key + * @return array|mixed|null + */ + public function summary($key = false) + { + $raw = $this->response()->headers('X-ClickHouse-Summary'); + + if (!$raw) { + return null; + } + + $summary = json_decode($raw, true); + + if (!is_array($summary)) { + return null; + } + + if (!$key) { + return $summary; + } + + return $summary[$key] ?? null; + } + /** * @return int * @throws Exception\TransportException @@ -562,6 +615,21 @@ public function rows() return $this->array_data; } + /** + * Iterate over rows using a generator (memory-efficient for large resultsets). + * Unlike rows(), this does not build the full array in memory. + * + * @return \Generator + * @throws Exception\TransportException + */ + public function rowsGenerator(): \Generator + { + $this->init(); + foreach ($this->array_data as $key => $row) { + yield $key => $row; + } + } + /** * @return false|string */ diff --git a/src/Transport/CurlerRequest.php b/src/Transport/CurlerRequest.php index 319d5cf..e4f649f 100644 --- a/src/Transport/CurlerRequest.php +++ b/src/Transport/CurlerRequest.php @@ -342,7 +342,7 @@ public function getId() * @param mixed $value * @return $this */ - private function option($key, $value) + public function option($key, $value) { $this->options[$key] = $value; return $this; diff --git a/src/Transport/CurlerResponse.php b/src/Transport/CurlerResponse.php index f2afef6..f9ddb9e 100644 --- a/src/Transport/CurlerResponse.php +++ b/src/Transport/CurlerResponse.php @@ -188,6 +188,8 @@ public function dump($result = false) } echo $msg; + + return ''; } /** diff --git a/src/Transport/Http.php b/src/Transport/Http.php index 933da7c..e823448 100644 --- a/src/Transport/Http.php +++ b/src/Transport/Http.php @@ -13,11 +13,13 @@ class Http { + const AUTH_METHOD_NONE = 0; const AUTH_METHOD_HEADER = 1; const AUTH_METHOD_QUERY_STRING = 2; const AUTH_METHOD_BASIC_AUTH = 3; const AUTH_METHODS_LIST = [ + self::AUTH_METHOD_NONE, self::AUTH_METHOD_HEADER, self::AUTH_METHOD_QUERY_STRING, self::AUTH_METHOD_BASIC_AUTH, @@ -91,6 +93,11 @@ class Http */ private $sslCA = null; + /** + * @var array + */ + private $curlOptions = []; + /** * @var null|resource */ @@ -171,6 +178,14 @@ public function setSslCa(string $caPath) : void $this->sslCA = $caPath; } + /** + * @param array $options + */ + public function setCurlOptions(array $options) : void + { + $this->curlOptions = $options; + } + /** * @return string */ @@ -180,8 +195,24 @@ public function getUri(): string if ($this->settings()->isHttps()) { $proto = 'https'; } - $uri = $proto . '://' . $this->_host; - if (stripos($this->_host, '/') !== false || stripos($this->_host, ':') !== false) { + + $host = $this->_host; + + // IPv6 address detection: contains ":" but no "/" (not a path) + if (stripos($host, ':') !== false && stripos($host, '/') === false && !str_starts_with($host, '[')) { + // Check if it's IPv6 (more than one colon) vs host:port + if (substr_count($host, ':') > 1) { + $host = '[' . $host . ']'; + } + } + + $uri = $proto . '://' . $host; + + if (stripos($host, '/') !== false) { + return $uri; + } + // Already has port (host:port or [ipv6]:port) + if (preg_match('/:\d+$/', $host)) { return $uri; } if (intval($this->_port) > 0) { @@ -210,9 +241,10 @@ public function verbose(bool $flag): bool /** * @param array $params + * @param array $querySettings Per-query settings override * @return string */ - private function getUrl($params = []): string + private function getUrl($params = [], array $querySettings = []): string { $settings = $this->settings()->getSettings(); @@ -220,6 +252,11 @@ private function getUrl($params = []): string $settings = array_merge($settings, $params); } + // Per-query settings override global settings + if (!empty($querySettings)) { + $settings = array_merge($settings, $querySettings); + } + if ($this->settings()->isReadOnlyUser()) { unset($settings['extremes']); @@ -254,6 +291,9 @@ private function newRequest($extendinfo): CurlerRequest case self::AUTH_METHOD_BASIC_AUTH: $new->authByBasicAuth($this->_username, $this->_password); break; + case self::AUTH_METHOD_NONE: + // No authentication + break; default: // Auth with headers by default $new->authByHeaders($this->_username, $this->_password); @@ -271,6 +311,10 @@ private function newRequest($extendinfo): CurlerRequest $new->setSslCa($this->sslCA); } + foreach ($this->curlOptions as $key => $value) { + $new->option($key, $value); + } + $new->timeOut($this->settings()->getTimeOut()); $new->connectTimeOut($this->_connectTimeOut); $new->keepAlive(); @@ -286,7 +330,7 @@ private function newRequest($extendinfo): CurlerRequest * @return CurlerRequest * @throws \ClickHouseDB\Exception\TransportException */ - private function makeRequest(Query $query, array $urlParams = [], bool $query_as_string = false): CurlerRequest + private function makeRequest(Query $query, array $urlParams = [], bool $query_as_string = false, array $querySettings = []): CurlerRequest { $sql = $query->toSql(); @@ -311,7 +355,7 @@ private function makeRequest(Query $query, array $urlParams = [], bool $query_as $urlParams = array_replace_recursive($urlParams, $query->getUrlBindingsParams()); } - $url = $this->getUrl($urlParams); + $url = $this->getUrl($urlParams, $querySettings); $new->url($url); if (!$query_as_string) { @@ -483,7 +527,7 @@ public function __findXClickHouseProgress($handle): bool * @return CurlerRequest * @throws \Exception */ - public function getRequestRead(Query $query, $whereInFile = null, $writeToFile = null): CurlerRequest + public function getRequestRead(Query $query, $whereInFile = null, $writeToFile = null, array $querySettings = []): CurlerRequest { $urlParams = ['readonly' => 2]; $query_as_string = false; @@ -503,7 +547,7 @@ public function getRequestRead(Query $query, $whereInFile = null, $writeToFile = } // --------------------------------------------------------------------------------- // makeRequest read - $request = $this->makeRequest($query, $urlParams, $query_as_string); + $request = $this->makeRequest($query, $urlParams, $query_as_string, $querySettings); // --------------------------------------------------------------------------------- // attach files if ($whereInFile instanceof WhereInFile && $whereInFile->size()) { @@ -565,10 +609,10 @@ public function addQueryDegeneration(Degeneration $degeneration): bool * @return CurlerRequest * @throws \ClickHouseDB\Exception\TransportException */ - public function getRequestWrite(Query $query): CurlerRequest + public function getRequestWrite(Query $query, array $querySettings = []): CurlerRequest { $urlParams = ['readonly' => 0]; - return $this->makeRequest($query, $urlParams); + return $this->makeRequest($query, $urlParams, false, $querySettings); } /** @@ -608,14 +652,14 @@ private function prepareQuery($sql, $bindings): Query * @return CurlerRequest * @throws \Exception */ - private function prepareSelect($sql, $bindings, $whereInFile, $writeToFile = null): CurlerRequest + private function prepareSelect($sql, $bindings, $whereInFile, $writeToFile = null, array $querySettings = []): CurlerRequest { if ($sql instanceof Query) { return $this->getRequestWrite($sql); } $query = $this->prepareQuery($sql, $bindings); $query->setFormat('JSON'); - return $this->getRequestRead($query, $whereInFile, $writeToFile); + return $this->getRequestRead($query, $whereInFile, $writeToFile, $querySettings); } @@ -625,16 +669,16 @@ private function prepareSelect($sql, $bindings, $whereInFile, $writeToFile = nul * @return CurlerRequest * @throws \ClickHouseDB\Exception\TransportException */ - private function prepareWrite($sql, $bindings = []): CurlerRequest + private function prepareWrite($sql, $bindings = [], array $querySettings = []): CurlerRequest { if ($sql instanceof Query) { - return $this->getRequestWrite($sql); + return $this->getRequestWrite($sql, $querySettings); } $query = $this->prepareQuery($sql, $bindings); if (strpos($sql, 'ON CLUSTER') === false) { - return $this->getRequestWrite($query); + return $this->getRequestWrite($query, $querySettings); } if ( !str_starts_with($sql, 'CREATE') @@ -645,7 +689,7 @@ private function prepareWrite($sql, $bindings = []): CurlerRequest $query->setFormat('JSON'); } - return $this->getRequestWrite($query); + return $this->getRequestWrite($query, $querySettings); } /** @@ -666,9 +710,9 @@ public function executeAsync(): bool * @throws \ClickHouseDB\Exception\TransportException * @throws \Exception */ - public function select($sql, array $bindings = [], $whereInFile = null, $writeToFile = null): Statement + public function select($sql, array $bindings = [], $whereInFile = null, $writeToFile = null, array $querySettings = []): Statement { - $request = $this->prepareSelect($sql, $bindings, $whereInFile, $writeToFile); + $request = $this->prepareSelect($sql, $bindings, $whereInFile, $writeToFile, $querySettings); $this->_curler->execOne($request); return new Statement($request); } @@ -682,9 +726,9 @@ public function select($sql, array $bindings = [], $whereInFile = null, $writeTo * @throws \ClickHouseDB\Exception\TransportException * @throws \Exception */ - public function selectAsync($sql, array $bindings = [], $whereInFile = null, $writeToFile = null): Statement + public function selectAsync($sql, array $bindings = [], $whereInFile = null, $writeToFile = null, array $querySettings = []): Statement { - $request = $this->prepareSelect($sql, $bindings, $whereInFile, $writeToFile); + $request = $this->prepareSelect($sql, $bindings, $whereInFile, $writeToFile, $querySettings); $this->_curler->addQueLoop($request); return new Statement($request); } @@ -697,6 +741,103 @@ public function setProgressFunction(callable $callback) : void $this->xClickHouseProgress = $callback; } + /** + * SELECT with native ClickHouse typed parameters. + * SQL uses {name:Type} placeholders, values passed as param_name in URL. + * + * @param string $sql + * @param array $params + * @param array $querySettings + * @return Statement + */ + public function selectWithParams(string $sql, array $params, array $querySettings = []): Statement + { + $query = new Query($sql); + $query->setFormat('JSON'); + + $urlParams = ['readonly' => 2]; + foreach ($params as $name => $value) { + $urlParams['param_' . $name] = $this->convertParamValue($value); + } + + $request = $this->makeRequest($query, $urlParams, true, $querySettings); + $this->_curler->execOne($request); + return new Statement($request); + } + + /** + * Write with native ClickHouse typed parameters. + * + * @param string $sql + * @param array $params + * @param bool $exception + * @param array $querySettings + * @return Statement + */ + public function writeWithParams(string $sql, array $params, bool $exception = true, array $querySettings = []): Statement + { + $query = new Query($sql); + + $urlParams = ['readonly' => 0]; + foreach ($params as $name => $value) { + $urlParams['param_' . $name] = $this->convertParamValue($value); + } + + $request = $this->makeRequest($query, $urlParams, true, $querySettings); + $this->_curler->execOne($request); + $response = new Statement($request); + if ($exception) { + if ($response->isError()) { + $response->error(); + } + } + return $response; + } + + /** + * Convert PHP value to string for native ClickHouse parameter. + * + * @param mixed $value + * @return string + */ + private function convertParamValue($value): string + { + if ($value instanceof \ClickHouseDB\Type\DateTime64) { + return $value->value; + } + if ($value instanceof \ClickHouseDB\Type\Date32) { + return $value->value; + } + if ($value instanceof \ClickHouseDB\Type\UUID) { + return $value->value; + } + if ($value instanceof \ClickHouseDB\Type\IPv4 || $value instanceof \ClickHouseDB\Type\IPv6) { + return $value->value; + } + if ($value instanceof \ClickHouseDB\Type\MapType) { + return json_encode($value->value); + } + if ($value instanceof \ClickHouseDB\Type\TupleType) { + return '(' . implode(',', array_map(fn($v) => $this->convertParamValue($v), $value->value)) . ')'; + } + if ($value instanceof \ClickHouseDB\Type\Type) { + return (string) $value->getValue(); + } + if ($value instanceof \DateTimeInterface) { + return $value->format('Y-m-d H:i:s'); + } + if (is_bool($value)) { + return $value ? '1' : '0'; + } + if (is_array($value)) { + return json_encode($value); + } + if ($value === null) { + return '\\N'; + } + return (string) $value; + } + /** * @param string $sql * @param mixed[] $bindings @@ -704,9 +845,9 @@ public function setProgressFunction(callable $callback) : void * @return Statement * @throws \ClickHouseDB\Exception\TransportException */ - public function write($sql, array $bindings = [], $exception = true): Statement + public function write($sql, array $bindings = [], $exception = true, array $querySettings = []): Statement { - $request = $this->prepareWrite($sql, $bindings); + $request = $this->prepareWrite($sql, $bindings, $querySettings); $this->_curler->execOne($request); $response = new Statement($request); if ($exception) { @@ -790,10 +931,10 @@ private function streaming(Stream $streamRW, CurlerRequest $request): Statement * @return Statement * @throws \ClickHouseDB\Exception\TransportException */ - public function streamRead(Stream $streamRead, $sql, $bindings = []): Statement + public function streamRead(Stream $streamRead, $sql, $bindings = [], array $querySettings = []): Statement { $sql = $this->prepareQuery($sql, $bindings); - $request = $this->getRequestRead($sql); + $request = $this->getRequestRead($sql, null, null, $querySettings); return $this->streaming($streamRead, $request); } diff --git a/src/Type/Date32.php b/src/Type/Date32.php new file mode 100644 index 0000000..220d04b --- /dev/null +++ b/src/Type/Date32.php @@ -0,0 +1,38 @@ +value = $value; + } + + public static function fromString(string $value): self + { + return new self($value); + } + + public static function fromDateTime(DateTimeInterface $dateTime): self + { + return new self($dateTime->format('Y-m-d')); + } + + public function getValue() + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/DateTime64.php b/src/Type/DateTime64.php new file mode 100644 index 0000000..eac7dc8 --- /dev/null +++ b/src/Type/DateTime64.php @@ -0,0 +1,55 @@ +value = $value; + } + + /** + * @param string $value DateTime string with sub-second precision, e.g. '2024-01-15 10:30:00.123' + */ + public static function fromString(string $value): self + { + return new self($value); + } + + /** + * @param DateTimeInterface $dateTime + * @param int $precision Number of fractional digits (1-9), default 3 (milliseconds) + */ + public static function fromDateTime(DateTimeInterface $dateTime, int $precision = 3): self + { + $format = 'Y-m-d H:i:s' . ($precision > 0 ? '.' . str_repeat('0', $precision) : ''); + // Use microseconds from DateTime and trim to desired precision + $formatted = $dateTime->format('Y-m-d H:i:s.u'); + // Trim microseconds to desired precision + $dotPos = strpos($formatted, '.'); + if ($dotPos !== false && $precision > 0) { + $formatted = substr($formatted, 0, $dotPos + 1 + $precision); + } elseif ($precision === 0) { + $formatted = $dateTime->format('Y-m-d H:i:s'); + } + return new self($formatted); + } + + public function getValue() + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/Decimal.php b/src/Type/Decimal.php new file mode 100644 index 0000000..10a598e --- /dev/null +++ b/src/Type/Decimal.php @@ -0,0 +1,31 @@ +value = $value; + } + + public static function fromString(string $value): self + { + return new self($value); + } + + public function getValue() + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/IPv4.php b/src/Type/IPv4.php new file mode 100644 index 0000000..46710a8 --- /dev/null +++ b/src/Type/IPv4.php @@ -0,0 +1,31 @@ +value = $value; + } + + public static function fromString(string $value): self + { + return new self($value); + } + + public function getValue() + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/IPv6.php b/src/Type/IPv6.php new file mode 100644 index 0000000..35bbf59 --- /dev/null +++ b/src/Type/IPv6.php @@ -0,0 +1,31 @@ +value = $value; + } + + public static function fromString(string $value): self + { + return new self($value); + } + + public function getValue() + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/Int64.php b/src/Type/Int64.php new file mode 100644 index 0000000..b7ec534 --- /dev/null +++ b/src/Type/Int64.php @@ -0,0 +1,31 @@ +value = $value; + } + + public static function fromString(string $value): self + { + return new self($value); + } + + public function getValue() + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Type/MapType.php b/src/Type/MapType.php new file mode 100644 index 0000000..897b64f --- /dev/null +++ b/src/Type/MapType.php @@ -0,0 +1,37 @@ +value = $value; + } + + public static function fromArray(array $map): self + { + return new self($map); + } + + public function getValue() + { + $pairs = []; + foreach ($this->value as $key => $val) { + $k = is_string($key) ? "'" . addslashes($key) . "'" : $key; + $v = is_string($val) ? "'" . addslashes($val) . "'" : $val; + $pairs[] = $k . ',' . $v; + } + return 'map(' . implode(',', $pairs) . ')'; + } + + public function __toString(): string + { + return (string) $this->getValue(); + } +} diff --git a/src/Type/TupleType.php b/src/Type/TupleType.php new file mode 100644 index 0000000..9e3a8a6 --- /dev/null +++ b/src/Type/TupleType.php @@ -0,0 +1,43 @@ +value = $value; + } + + public static function fromArray(array $elements): self + { + return new self($elements); + } + + public function getValue() + { + $parts = []; + foreach ($this->value as $val) { + if (is_string($val)) { + $parts[] = "'" . addslashes($val) . "'"; + } elseif ($val === null) { + $parts[] = 'NULL'; + } elseif (is_bool($val)) { + $parts[] = $val ? '1' : '0'; + } else { + $parts[] = $val; + } + } + return '(' . implode(',', $parts) . ')'; + } + + public function __toString(): string + { + return (string) $this->getValue(); + } +} diff --git a/src/Type/UUID.php b/src/Type/UUID.php new file mode 100644 index 0000000..04795c9 --- /dev/null +++ b/src/Type/UUID.php @@ -0,0 +1,31 @@ +value = $value; + } + + public static function fromString(string $value): self + { + return new self($value); + } + + public function getValue() + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/tests/AuthMethodNoneTest.php b/tests/AuthMethodNoneTest.php new file mode 100644 index 0000000..b9014c0 --- /dev/null +++ b/tests/AuthMethodNoneTest.php @@ -0,0 +1,39 @@ + '127.0.0.1', + 'port' => '8123', + 'username' => '', + 'password' => '', + 'auth_method' => Http::AUTH_METHOD_NONE, + ]; + + $client = new Client($config); + $this->assertInstanceOf(Client::class, $client); + } + + public function testAuthMethodNoneConstantValue(): void + { + $this->assertSame(0, Http::AUTH_METHOD_NONE); + } + + public function testAuthMethodNoneInList(): void + { + $this->assertContains(Http::AUTH_METHOD_NONE, Http::AUTH_METHODS_LIST); + } +} diff --git a/tests/ClickHouse26/ClientTest.php b/tests/ClickHouse26/ClientTest.php new file mode 100644 index 0000000..4f173f2 --- /dev/null +++ b/tests/ClickHouse26/ClientTest.php @@ -0,0 +1,394 @@ +client->write("DROP TABLE IF EXISTS summing_url_views"); + + return $this->client->write(' + CREATE TABLE IF NOT EXISTS summing_url_views ( + event_date Date DEFAULT toDate(event_time), + event_time DateTime, + url_hash String, + site_id Int32, + views Int32, + v_00 Int32, + v_55 Int32 + ) ENGINE = SummingMergeTree + ORDER BY (site_id, url_hash, event_time, event_date) + '); + } + + public function testInsertNullable(): void + { + $this->client->write("DROP TABLE IF EXISTS nullable_test"); + $this->client->write(' + CREATE TABLE IF NOT EXISTS nullable_test ( + dt Date, + sss Nullable(String) + ) ENGINE = MergeTree ORDER BY dt + '); + + $this->client->insert('nullable_test', [ + [date('Y-m-d'), null], + [date('Y-m-d'), 'AAA'], + ], ['dt', 'sss']); + + $st = $this->client->select('SELECT * FROM nullable_test ORDER BY sss'); + $this->assertEquals(2, $st->count()); + } + + public function testInsertDotTable(): void + { + $this->client->write("DROP TABLE IF EXISTS t1234.test_table"); + $this->client->write("CREATE DATABASE IF NOT EXISTS t1234"); + $this->client->write(' + CREATE TABLE IF NOT EXISTS t1234.test_table ( + dt Date, + sss String + ) ENGINE = MergeTree ORDER BY dt + '); + + $this->client->insert('t1234.test_table', [ + [date('Y-m-d'), 'AAA'], + ], ['dt', 'sss']); + + $st = $this->client->select('SELECT * FROM t1234.test_table'); + $this->assertEquals(1, $st->count()); + + $this->client->write("DROP TABLE IF EXISTS t1234.test_table"); + $this->client->write("DROP DATABASE IF EXISTS t1234"); + } + + public function testSearchWithCyrillic(): void + { + $this->create_table_summing_url_views(); + + $this->client->insert('summing_url_views', [ + [date('Y-m-d H:00:00'), 'Привет мир', 1, 1, 1, 1], + ], ['event_time', 'url_hash', 'site_id', 'views', 'v_00', 'v_55']); + + $st = $this->client->select( + "SELECT * FROM summing_url_views WHERE url_hash = :url", + ['url' => 'Привет мир'] + ); + $this->assertEquals(1, $st->count()); + } + + public function testGzipInsert(): void + { + $file_data_names = [ + $this->tmpPath . '_ch26_testGzipInsert.1.data', + $this->tmpPath . '_ch26_testGzipInsert.2.data', + $this->tmpPath . '_ch26_testGzipInsert.3.data', + ]; + + foreach ($file_data_names as $file_name) { + $this->create_fake_csv_file($file_name, 1); + } + + $this->client->enableHttpCompression(true); + $this->create_table_summing_url_views(); + + $stat = $this->client->insertBatchFiles('summing_url_views', $file_data_names, [ + 'event_time', 'url_hash', 'site_id', 'views', 'v_00', 'v_55' + ]); + + $st = $this->client->select('SELECT count() as cnt FROM summing_url_views'); + // 3 files * 240 rows each + $this->assertGreaterThan(0, $st->fetchOne('cnt')); + + foreach ($file_data_names as $file_name) { + unlink($file_name); + } + } + + public function testInsertCSV(): void + { + $file_data_names = [ + $this->tmpPath . '_ch26_testInsertCSV.1.data', + $this->tmpPath . '_ch26_testInsertCSV.2.data', + $this->tmpPath . '_ch26_testInsertCSV.3.data', + ]; + + foreach ($file_data_names as $file_name) { + $this->create_fake_csv_file($file_name, 1); + } + + $this->create_table_summing_url_views(); + + $stat = $this->client->insertBatchFiles('summing_url_views', $file_data_names, [ + 'event_time', 'url_hash', 'site_id', 'views', 'v_00', 'v_55' + ]); + + $st = $this->client->select('SELECT sum(views) as sum_x FROM summing_url_views'); + // 3 files * 240 rows * 1 view each + $this->assertGreaterThan(0, $st->fetchOne('sum_x')); + + $st = $this->client->select('SELECT count() as cnt FROM summing_url_views'); + $this->assertGreaterThan(0, $st->fetchOne('cnt')); + + foreach ($file_data_names as $file_name) { + unlink($file_name); + } + } + + public function testSelectWhereIn(): void + { + $this->create_table_summing_url_views(); + + $this->client->insert('summing_url_views', [ + [date('Y-m-d H:00:00'), 'hash1', 1, 100, 1, 0], + [date('Y-m-d H:00:00'), 'hash2', 2, 200, 0, 1], + [date('Y-m-d H:00:00'), 'hash3', 3, 300, 1, 1], + ], ['event_time', 'url_hash', 'site_id', 'views', 'v_00', 'v_55']); + + $st = $this->client->select( + 'SELECT * FROM summing_url_views WHERE site_id IN (:ids)', + ['ids' => [1, 2]] + ); + $this->assertEquals(2, $st->count()); + } + + public function testPing(): void + { + $this->assertTrue($this->client->ping()); + } + + public function testSelectAsync(): void + { + $state1 = $this->client->selectAsync('SELECT 1 as ping'); + $state2 = $this->client->selectAsync('SELECT 2 as ping'); + + $this->client->executeAsync(); + + $this->assertEquals(1, $state1->fetchOne('ping')); + $this->assertEquals(2, $state2->fetchOne('ping')); + } + + public function testTableExists(): void + { + $this->create_table_summing_url_views(); + + $this->assertEquals( + 'summing_url_views', + $this->client->showTables()['summing_url_views']['name'] + ); + + $this->client->write("DROP TABLE IF EXISTS summing_url_views"); + } + + public function testExceptionWrite(): void + { + $this->expectException(QueryException::class); + $this->client->write("DRAP TABLEX")->isError(); + } + + public function testExceptionInsert(): void + { + $this->expectException(QueryException::class); + + $this->client->insert('bla_bla', [ + [time(), 'HASH1', 2345, 22, 20, 2], + ], ['event_time', 'url_hash', 'site_id', 'views', 'v_00', 'v_55']); + } + + public function testExceptionSelect(): void + { + $this->expectException(QueryException::class); + + $this->client->select("SELECT * FROM XXXXX_table_not_exists")->rows(); + } + + public function testSettings(): void + { + $config = [ + 'host' => 'x', + 'port' => '8123', + 'username' => 'x', + 'password' => 'x', + ]; + + $db = new Client($config, ['max_execution_time' => 100]); + $this->assertEquals(100, $db->settings()->getSetting('max_execution_time')); + + $db = new Client($config); + $db->settings()->set('max_execution_time', 100); + $this->assertEquals(100, $db->settings()->getSetting('max_execution_time')); + + $db = new Client($config); + $db->settings()->apply([ + 'max_execution_time' => 100, + 'max_block_size' => 12345, + ]); + $this->assertEquals(100, $db->settings()->getSetting('max_execution_time')); + $this->assertEquals(12345, $db->settings()->getSetting('max_block_size')); + } + + public function testInsertArrayTable(): void + { + $this->client->write("DROP TABLE IF EXISTS arrays_test_string"); + $this->client->write(' + CREATE TABLE IF NOT EXISTS arrays_test_string ( + s_key String, + s_arr Array(String) + ) ENGINE = Memory + '); + + $this->client->insert('arrays_test_string', [ + ['HASH1', ["a", "dddd", "xxx"]], + ['HASH1', ["b'\tx"]], + ], ['s_key', 's_arr']); + + $st = $this->client->select('SELECT count() as cnt FROM arrays_test_string'); + $this->assertEquals(2, $st->fetchOne('cnt')); + } + + public function testInsertTable(): void + { + $this->create_table_summing_url_views(); + + $file_data_names = [ + $this->tmpPath . '_ch26_testInsertTable.1.data', + ]; + + foreach ($file_data_names as $file_name) { + $this->create_fake_csv_file($file_name, 1); + } + + $stat = $this->client->insertBatchFiles('summing_url_views', $file_data_names, [ + 'event_time', 'url_hash', 'site_id', 'views', 'v_00', 'v_55' + ]); + + $st = $this->client->select('SELECT count() as cnt FROM summing_url_views'); + $this->assertEquals(240, $st->fetchOne('cnt')); + + foreach ($file_data_names as $file_name) { + unlink($file_name); + } + } + + public function testStreamInsert(): void + { + $this->create_table_summing_url_views(); + + $file = $this->tmpPath . '_ch26_testStreamInsert.data'; + $this->create_fake_csv_file($file, 1); + + $source = fopen($file, 'rb'); + $request = $this->client->insertBatchStream('summing_url_views', [ + 'event_time', 'url_hash', 'site_id', 'views', 'v_00', 'v_55' + ]); + + $curlerRolling = new CurlerRolling(); + $streamInsert = new StreamInsert($source, $request, $curlerRolling); + + $callable = function ($ch, $fd, $length) use ($source) { + return ($line = fread($source, $length)) ? $line : ''; + }; + $streamInsert->insert($callable); + + $st = $this->client->select('SELECT count() as cnt FROM summing_url_views'); + $this->assertEquals(240, $st->fetchOne('cnt')); + + unlink($file); + } + + public function testStreamInsertFormatJSONEachRow(): void + { + $this->create_table_summing_url_views(); + + $file = $this->tmpPath . '_ch26_testStreamInsertJSON.data'; + $this->create_fake_csv_file($file, 1, 'JSON'); + + $source = fopen($file, 'rb'); + $request = $this->client->insertBatchStream('summing_url_views', [ + 'event_time', 'url_hash', 'site_id', 'views', 'v_00', 'v_55' + ], 'JSONEachRow'); + + $curlerRolling = new CurlerRolling(); + $streamInsert = new StreamInsert($source, $request, $curlerRolling); + + $callable = function ($ch, $fd, $length) use ($source) { + return ($line = fread($source, $length)) ? $line : ''; + }; + $streamInsert->insert($callable); + + $st = $this->client->select('SELECT count() as cnt FROM summing_url_views'); + $this->assertEquals(240, $st->fetchOne('cnt')); + + unlink($file); + } + + public function testUptime(): void + { + $st = $this->client->getServerUptime(); + $this->assertGreaterThan(0, $st); + } + + public function testVersion(): void + { + $st = $this->client->getServerVersion(); + $this->assertStringStartsWith('26.', $st); + } +} diff --git a/tests/ClickHouse26/SessionsTest.php b/tests/ClickHouse26/SessionsTest.php new file mode 100644 index 0000000..b65a0ac --- /dev/null +++ b/tests/ClickHouse26/SessionsTest.php @@ -0,0 +1,83 @@ +client->ping(); + } + + /** + * In CH 26.x, creating temporary tables without a session works + * (unlike CH 21.x which throws an exception). + */ + public function testCreateTableTEMPORARYNoSessionWorks(): void + { + $this->client->write('DROP TABLE IF EXISTS phpunit_ch26_temp_test'); + $this->client->write(' + CREATE TEMPORARY TABLE IF NOT EXISTS phpunit_ch26_temp_test ( + event_date Date DEFAULT toDate(event_time), + event_time DateTime, + url_hash String, + site_id Int32, + views Int32 + ) + '); + + // Should not throw — CH 26 allows temp tables without sessions + $this->assertTrue(true); + } + + public function testUseSession(): void + { + $this->assertFalse($this->client->getSession()); + $this->client->useSession(); + $this->assertStringMatchesFormat('%s', $this->client->getSession()); + } + + public function testCreateTableTEMPORARYWithSessions(): void + { + $table_name_A = 'phpunit_ch26_test_A_' . time(); + $table_name_B = 'phpunit_ch26_test_B_' . time(); + + $A_Session_ID = $this->client->useSession()->getSession(); + + $this->client->write('CREATE TEMPORARY TABLE IF NOT EXISTS ' . $table_name_A . ' (number UInt64)'); + $this->client->write('INSERT INTO ' . $table_name_A . ' SELECT number FROM system.numbers LIMIT 30'); + + $st = $this->client->select('SELECT round(avg(number),1) as avs FROM ' . $table_name_A); + $this->assertEquals(14.5, $st->fetchOne('avs')); + + $B_Session_ID = $this->client->useSession()->getSession(); + + $this->client->write('CREATE TEMPORARY TABLE IF NOT EXISTS ' . $table_name_B . ' (number UInt64)'); + $this->client->write('INSERT INTO ' . $table_name_B . ' SELECT number*1234 FROM system.numbers LIMIT 30'); + + $st = $this->client->select('SELECT round(avg(number),1) as avs FROM ' . $table_name_B); + $this->assertEquals(17893, $st->fetchOne('avs')); + + $this->client->useSession($A_Session_ID); + $st = $this->client->select('SELECT round(avg(number),1) as avs FROM ' . $table_name_A); + $this->assertEquals(14.5, $st->fetchOne('avs')); + + $this->client->useSession($B_Session_ID); + $st = $this->client->select('SELECT round(avg(number),1) as avs FROM ' . $table_name_B); + $this->assertEquals(17893, $st->fetchOne('avs')); + } +} diff --git a/tests/ClickHouse26/StatementTest.php b/tests/ClickHouse26/StatementTest.php new file mode 100644 index 0000000..1d6445a --- /dev/null +++ b/tests/ClickHouse26/StatementTest.php @@ -0,0 +1,116 @@ +client->select( + 'SELECT throwIf(1=1, \'Raised error\');' + ); + + $this->assertGreaterThanOrEqual(500, $result->getRequest()->response()->http_code()); + $this->assertTrue($result->isError()); + } + + /** + * In CH 26.x, mid-stream errors return HTTP 500 instead of 200. + */ + public function testIsErrorWithMidStreamError(): void + { + $result = $this->client->select( + 'SELECT number, throwIf(number=100100, \'Raised error\') FROM system.numbers;' + ); + + // CH 26.x returns 500 for mid-stream errors + $this->assertEquals(500, $result->getRequest()->response()->http_code()); + $this->assertTrue($result->isError()); + } + + /** + * @link https://github.com/smi2/phpClickHouse/issues/223 + */ + public function testIsNotErrorWhenJsonBodyContainsDbExceptionMessage(): void + { + $result = $this->client->select( + "SELECT + 'mutation_123456' AS mutation_id, + 'Code: 243. DB::Exception: Cannot reserve 61.64 GiB, not enough space. (NOT_ENOUGH_SPACE) (version 24.3.2.23 (official build))' AS latest_fail_reason" + ); + + $this->assertEquals(200, $result->getRequest()->response()->http_code()); + $this->assertFalse($result->isError()); + } + + /** + * @dataProvider dataProvider + */ + public function testParseErrorClickHouse( + string $errorMessage, + string $exceptionMessage, + int $exceptionCode + ): void { + $requestMock = $this->createMock(CurlerRequest::class); + $responseMock = $this->createMock(CurlerResponse::class); + + $responseMock->expects($this->any())->method('body')->will($this->returnValue($errorMessage)); + $responseMock->expects($this->any())->method('error_no')->will($this->returnValue(0)); + $responseMock->expects($this->any())->method('error')->will($this->returnValue(false)); + + $requestMock->expects($this->any())->method('response')->will($this->returnValue($responseMock)); + + $statement = new Statement($requestMock); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage($exceptionMessage); + $this->expectExceptionCode($exceptionCode); + + $statement->error(); + } + + public function dataProvider(): Generator + { + yield 'Unknown setting readonly' => [ + 'Code: 115. DB::Exception: Unknown setting readonly[0], e.what() = DB::Exception', + 'Unknown setting readonly[0]', + 115, + ]; + + yield 'Unknown user x' => [ + 'Code: 192. DB::Exception: Unknown user x, e.what() = DB::Exception', + 'Unknown user x', + 192, + ]; + + yield 'Table default.ZZZZZ doesn\'t exist.' => [ + 'Code: 60. DB::Exception: Table default.ZZZZZ doesn\'t exist., e.what() = DB::Exception', + 'Table default.ZZZZZ doesn\'t exist.', + 60, + ]; + + yield 'Authentication failed' => [ + 'Code: 516. DB::Exception: test_username: Authentication failed: password is incorrect or there is no user with such name. (AUTHENTICATION_FAILED) (version 22.8.3.13 (official build))', + 'test_username: Authentication failed: password is incorrect or there is no user with such name. (AUTHENTICATION_FAILED)', + 516 + ]; + } +} diff --git a/tests/ClickHouse26/Type/UInt64Test.php b/tests/ClickHouse26/Type/UInt64Test.php new file mode 100644 index 0000000..f7f4430 --- /dev/null +++ b/tests/ClickHouse26/Type/UInt64Test.php @@ -0,0 +1,109 @@ +client->write('DROP TABLE IF EXISTS uint64_data'); + $this->client->write(' + CREATE TABLE IF NOT EXISTS uint64_data ( + date Date MATERIALIZED toDate(datetime), + datetime DateTime, + number UInt64 + ) + ENGINE = MergeTree + PARTITION BY date + ORDER BY (datetime); + '); + + parent::setUp(); + } + + public function testWriteInsert(): void + { + $this->client->write(sprintf( + 'INSERT INTO uint64_data VALUES %s', + implode( + ',', + [ + sprintf('(now(), %s)', UInt64::fromString('0')), + sprintf('(now(), %s)', UInt64::fromString('1')), + sprintf('(now(), %s)', UInt64::fromString('18446744073709551615')), + ] + ) + )); + + $statement = $this->client->select('SELECT number FROM uint64_data ORDER BY number ASC'); + + self::assertSame(3, $statement->count()); + + $values = array_column($statement->rows(), 'number'); + // CH 26.x returns numbers as native types, not strings + // Small values come as int, large UInt64 may come as float + self::assertEquals(0, $values[0]); + self::assertEquals(1, $values[1]); + // UInt64 max overflows PHP float — check approximate value + self::assertGreaterThan(1.8e19, $values[2]); + } + + public function testInsert(): void + { + $now = new DateTimeImmutable(); + $this->client->insert( + 'uint64_data', + [ + [$now, UInt64::fromString('0')], + [$now, UInt64::fromString('1')], + [$now, UInt64::fromString('18446744073709551615')], + ] + ); + + $statement = $this->client->select('SELECT number FROM uint64_data ORDER BY number ASC'); + + self::assertSame(3, $statement->count()); + + $values = array_column($statement->rows(), 'number'); + self::assertEquals(0, $values[0]); + self::assertEquals(1, $values[1]); + self::assertGreaterThan(1.8e19, $values[2]); + } + + /** + * Test UInt64 values returned as strings using toString() in query. + */ + public function testUInt64AsString(): void + { + $now = new DateTimeImmutable(); + $this->client->insert( + 'uint64_data', + [ + [$now, UInt64::fromString('18446744073709551615')], + ] + ); + + $statement = $this->client->select('SELECT toString(number) as num_str FROM uint64_data'); + + self::assertSame(1, $statement->count()); + self::assertSame('18446744073709551615', $statement->fetchOne('num_str')); + } +} diff --git a/tests/CurlOptionsTest.php b/tests/CurlOptionsTest.php new file mode 100644 index 0000000..b86f114 --- /dev/null +++ b/tests/CurlOptionsTest.php @@ -0,0 +1,38 @@ + '127.0.0.1', + 'port' => '8123', + 'username' => 'default', + 'password' => '', + 'curl_options' => [ + CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, + ], + ]; + + $client = new Client($config); + $this->assertInstanceOf(Client::class, $client); + } + + public function testSetCurlOptionsOnTransport(): void + { + $http = new Http('127.0.0.1', 8123, 'default', ''); + $http->setCurlOptions([CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V6]); + $this->assertInstanceOf(Http::class, $http); + } +} diff --git a/tests/GeneratorTest.php b/tests/GeneratorTest.php new file mode 100644 index 0000000..ffc1284 --- /dev/null +++ b/tests/GeneratorTest.php @@ -0,0 +1,88 @@ +client->select('SELECT number FROM system.numbers LIMIT 10'); + + $count = 0; + foreach ($st->rowsGenerator() as $row) { + $this->assertArrayHasKey('number', $row); + $count++; + } + + $this->assertEquals(10, $count); + } + + public function testRowsGeneratorYieldsCorrectValues(): void + { + $st = $this->client->select('SELECT number FROM system.numbers LIMIT 5'); + + $values = []; + foreach ($st->rowsGenerator() as $row) { + $values[] = $row['number']; + } + + $this->assertEquals(['0', '1', '2', '3', '4'], $values); + } + + public function testSelectGenerator(): void + { + $count = 0; + foreach ($this->client->selectGenerator('SELECT number FROM system.numbers LIMIT 20') as $row) { + $this->assertArrayHasKey('number', $row); + $count++; + } + + $this->assertEquals(20, $count); + } + + public function testSelectGeneratorWithBindings(): void + { + $rows = []; + foreach ($this->client->selectGenerator( + 'SELECT number FROM system.numbers WHERE number < :limit LIMIT 5', + ['limit' => 100] + ) as $row) { + $rows[] = $row; + } + + $this->assertCount(5, $rows); + } + + public function testSelectGeneratorMemoryEfficient(): void + { + // Iterate over a reasonably large result — should not spike memory + $count = 0; + foreach ($this->client->selectGenerator('SELECT number, toString(number) as s FROM system.numbers LIMIT 10000') as $row) { + $count++; + } + + $this->assertEquals(10000, $count); + } + + public function testSelectGeneratorEmpty(): void + { + $count = 0; + foreach ($this->client->selectGenerator('SELECT number FROM system.numbers LIMIT 0') as $row) { + $count++; + } + + $this->assertEquals(0, $count); + } +} diff --git a/tests/IPv6UriTest.php b/tests/IPv6UriTest.php new file mode 100644 index 0000000..5170f95 --- /dev/null +++ b/tests/IPv6UriTest.php @@ -0,0 +1,67 @@ +createHttp('::1', 8123); + $this->assertSame('http://[::1]:8123', $http->getUri()); + } + + public function testIPv6FullAddress(): void + { + $http = $this->createHttp('2001:db8::1', 8123); + $this->assertSame('http://[2001:db8::1]:8123', $http->getUri()); + } + + public function testIPv6NoPort(): void + { + $http = $this->createHttp('::1', 0); + $this->assertSame('http://[::1]', $http->getUri()); + } + + public function testIPv6AlreadyBracketed(): void + { + $http = $this->createHttp('[::1]', 8123); + $this->assertSame('http://[::1]:8123', $http->getUri()); + } + + public function testIPv4Unchanged(): void + { + $http = $this->createHttp('192.168.1.1', 8123); + $this->assertSame('http://192.168.1.1:8123', $http->getUri()); + } + + public function testHostnameWithPortUnchanged(): void + { + $http = $this->createHttp('clickhouse.local:9000', 8123); + $this->assertSame('http://clickhouse.local:9000', $http->getUri()); + } + + public function testHostnameWithPathUnchanged(): void + { + $http = $this->createHttp('clickhouse.local/prefix', 8123); + $this->assertSame('http://clickhouse.local/prefix', $http->getUri()); + } + + public function testLocalhostWithPort(): void + { + $http = $this->createHttp('localhost', 8123); + $this->assertSame('http://localhost:8123', $http->getUri()); + } +} diff --git a/tests/LargeStreamTest.php b/tests/LargeStreamTest.php new file mode 100644 index 0000000..001bdae --- /dev/null +++ b/tests/LargeStreamTest.php @@ -0,0 +1,87 @@ + 4096 bytes) with valid content + $largeBody = str_repeat('{"id":1,"name":"test"}' . "\n", 500); + + $responseMock = $this->createMock(CurlerResponse::class); + $responseMock->method('http_code')->willReturn(200); + $responseMock->method('error_no')->willReturn(0); + $responseMock->method('content_type')->willReturn('application/json; charset=UTF-8'); + $responseMock->method('body')->willReturn($largeBody); + + $requestMock = $this->createMock(CurlerRequest::class); + $requestMock->method('response')->willReturn($responseMock); + $requestMock->method('isResponseExists')->willReturn(true); + + $statement = new Statement($requestMock); + + // Should NOT throw OOM and should return false (no error) + $this->assertFalse($statement->isError()); + } + + /** + * Large body with ClickHouse error appended at the end (mid-stream error). + */ + public function testLargeBodyWithErrorAtEnd(): void + { + $largeBody = str_repeat('{"id":1}' . "\n", 1000); + $largeBody .= "\nCode: 241. DB::Exception: Memory limit exceeded. (MEMORY_LIMIT_EXCEEDED) (version 24.3.2.23 (official build))"; + + $responseMock = $this->createMock(CurlerResponse::class); + $responseMock->method('http_code')->willReturn(200); + $responseMock->method('error_no')->willReturn(0); + $responseMock->method('content_type')->willReturn('application/json; charset=UTF-8'); + $responseMock->method('body')->willReturn($largeBody); + + $requestMock = $this->createMock(CurlerRequest::class); + $requestMock->method('response')->willReturn($responseMock); + $requestMock->method('isResponseExists')->willReturn(true); + + $statement = new Statement($requestMock); + + $this->assertTrue($statement->isError()); + } + + /** + * Small body with valid JSON should still be checked for JSON validity. + */ + public function testSmallInvalidJsonDetected(): void + { + $responseMock = $this->createMock(CurlerResponse::class); + $responseMock->method('http_code')->willReturn(200); + $responseMock->method('error_no')->willReturn(0); + $responseMock->method('content_type')->willReturn('application/json; charset=UTF-8'); + $responseMock->method('body')->willReturn('{invalid json'); + + $requestMock = $this->createMock(CurlerRequest::class); + $requestMock->method('response')->willReturn($responseMock); + $requestMock->method('isResponseExists')->willReturn(true); + + $statement = new Statement($requestMock); + + $this->assertTrue($statement->isError()); + } +} diff --git a/tests/NativeParamsTest.php b/tests/NativeParamsTest.php new file mode 100644 index 0000000..8e600a5 --- /dev/null +++ b/tests/NativeParamsTest.php @@ -0,0 +1,107 @@ +client->selectWithParams( + 'SELECT {p1:UInt32} + {p2:UInt32} as result', + ['p1' => 3, 'p2' => 4] + ); + + $this->assertEquals(7, $result->fetchOne('result')); + } + + public function testSelectWithStringParam(): void + { + $result = $this->client->selectWithParams( + "SELECT {name:String} as greeting", + ['name' => 'Hello World'] + ); + + $this->assertEquals('Hello World', $result->fetchOne('greeting')); + } + + public function testSelectWithDateTimeParam(): void + { + $dt = new \DateTime('2024-01-15 10:30:00'); + $result = $this->client->selectWithParams( + 'SELECT {dt:DateTime} as dt_value', + ['dt' => $dt] + ); + + $this->assertEquals('2024-01-15 10:30:00', $result->fetchOne('dt_value')); + } + + public function testSelectWithMultipleParams(): void + { + $result = $this->client->selectWithParams( + 'SELECT {a:Int32} as a, {b:String} as b, {c:Float64} as c', + ['a' => 42, 'b' => 'test', 'c' => 3.14] + ); + + $row = $result->fetchOne(); + $this->assertEquals(42, $row['a']); + $this->assertEquals('test', $row['b']); + $this->assertEqualsWithDelta(3.14, $row['c'], 0.001); + } + + public function testWriteWithTypedParams(): void + { + $this->client->write("DROP TABLE IF EXISTS native_params_test"); + $this->client->write('CREATE TABLE IF NOT EXISTS native_params_test (id UInt32, name String) ENGINE = Memory'); + + $this->client->writeWithParams( + 'INSERT INTO native_params_test VALUES ({id:UInt32}, {name:String})', + ['id' => 1, 'name' => 'Alice'] + ); + + $st = $this->client->select('SELECT * FROM native_params_test'); + $this->assertEquals(1, $st->count()); + $this->assertEquals('Alice', $st->fetchOne('name')); + + $this->client->write("DROP TABLE IF EXISTS native_params_test"); + } + + public function testSelectWithBoolParam(): void + { + $result = $this->client->selectWithParams( + 'SELECT {flag:Bool} as flag', + ['flag' => true] + ); + + $this->assertEquals(1, $result->fetchOne('flag')); + } + + public function testSelectWithNullableParam(): void + { + $result = $this->client->selectWithParams( + 'SELECT {val:Nullable(String)} as val', + ['val' => null] + ); + + $this->assertNull($result->fetchOne('val')); + } + + public function testSelectWithPerQuerySettings(): void + { + $result = $this->client->selectWithParams( + 'SELECT {n:UInt32} as n', + ['n' => 1], + ['max_execution_time' => 5] + ); + + $this->assertEquals(1, $result->fetchOne('n')); + } +} diff --git a/tests/PerQuerySettingsTest.php b/tests/PerQuerySettingsTest.php new file mode 100644 index 0000000..207d4e4 --- /dev/null +++ b/tests/PerQuerySettingsTest.php @@ -0,0 +1,58 @@ +client->select( + 'SELECT 1 as n', + [], + null, + null, + ['max_execution_time' => 5] + ); + + $this->assertEquals(1, $result->fetchOne('n')); + // Global setting should be unchanged + $this->assertEquals(20, $this->client->settings()->getSetting('max_execution_time')); + } + + public function testWriteWithPerQuerySettings(): void + { + $this->client->write("DROP TABLE IF EXISTS pqs_test"); + $this->client->write( + 'CREATE TABLE IF NOT EXISTS pqs_test (id UInt32) ENGINE = Memory', + [], + true, + ['max_execution_time' => 5] + ); + + $this->client->insert('pqs_test', [[1], [2]], ['id']); + $st = $this->client->select('SELECT count() as cnt FROM pqs_test'); + $this->assertEquals(2, $st->fetchOne('cnt')); + + $this->client->write("DROP TABLE IF EXISTS pqs_test"); + } + + public function testSelectAsyncWithPerQuerySettings(): void + { + $state1 = $this->client->selectAsync('SELECT 1 as n', [], null, null, ['max_execution_time' => 5]); + $state2 = $this->client->selectAsync('SELECT 2 as n', [], null, null, ['max_execution_time' => 10]); + + $this->client->executeAsync(); + + $this->assertEquals(1, $state1->fetchOne('n')); + $this->assertEquals(2, $state2->fetchOne('n')); + } +} diff --git a/tests/ProgressWriteTest.php b/tests/ProgressWriteTest.php new file mode 100644 index 0000000..ec32bc2 --- /dev/null +++ b/tests/ProgressWriteTest.php @@ -0,0 +1,46 @@ +client->write("DROP TABLE IF EXISTS progress_write_test"); + $this->client->write("CREATE TABLE progress_write_test (id UInt32) ENGINE = Memory"); + + $progressData = []; + $this->client->progressFunction(function ($data) use (&$progressData) { + $progressData[] = $data; + }); + + // Insert enough data to trigger progress callbacks + $rows = []; + for ($i = 0; $i < 1000; $i++) { + $rows[] = [$i]; + } + $this->client->insert('progress_write_test', $rows, ['id']); + + $st = $this->client->select('SELECT count() as cnt FROM progress_write_test'); + $this->assertEquals(1000, $st->fetchOne('cnt')); + + // wait_end_of_query setting should be enabled + $this->assertEquals(1, $this->client->settings()->getSetting('wait_end_of_query')); + $this->assertEquals(1, $this->client->settings()->getSetting('send_progress_in_http_headers')); + + $this->client->write("DROP TABLE IF EXISTS progress_write_test"); + } +} diff --git a/tests/StructuredExceptionTest.php b/tests/StructuredExceptionTest.php new file mode 100644 index 0000000..7b1aac7 --- /dev/null +++ b/tests/StructuredExceptionTest.php @@ -0,0 +1,129 @@ +createMock(CurlerResponse::class); + $responseMock->method('body')->willReturn($errorBody); + $responseMock->method('error_no')->willReturn(0); + $responseMock->method('error')->willReturn(false); + $responseMock->method('headers')->willReturn(null); + + $requestMock = $this->createMock(CurlerRequest::class); + $requestMock->method('response')->willReturn($responseMock); + + $statement = new Statement($requestMock); + + try { + $statement->error(); + $this->fail('Expected DatabaseException'); + } catch (DatabaseException $e) { + $this->assertEquals($expectedCode, $e->getCode()); + $this->assertEquals($expectedName, $e->getClickHouseExceptionName()); + } + } + + public function exceptionNameProvider(): Generator + { + yield 'CH 22+ format with exception name' => [ + 'Code: 60. DB::Exception: Table default.xxx doesn\'t exist. (UNKNOWN_TABLE) (version 24.3.2.23 (official build))', + 60, + 'UNKNOWN_TABLE', + ]; + + yield 'CH 22+ syntax error' => [ + 'Code: 62. DB::Exception: Syntax error: SELECT GARBAGE. (SYNTAX_ERROR) (version 24.3.2.23 (official build))', + 62, + 'SYNTAX_ERROR', + ]; + + yield 'CH 22+ auth failed' => [ + 'Code: 516. DB::Exception: user: Authentication failed: password is incorrect. (AUTHENTICATION_FAILED) (version 24.3.2.23 (official build))', + 516, + 'AUTHENTICATION_FAILED', + ]; + + yield 'Old CH format without exception name' => [ + 'Code: 60. DB::Exception: Table default.xxx doesn\'t exist., e.what() = DB::Exception', + 60, + null, + ]; + + yield 'Old CH format unknown setting' => [ + 'Code: 115. DB::Exception: Unknown setting readonly[0], e.what() = DB::Exception', + 115, + null, + ]; + } + + public function testQueryIdFromHeader(): void + { + $responseMock = $this->createMock(CurlerResponse::class); + $responseMock->method('body')->willReturn( + 'Code: 60. DB::Exception: Table default.xxx doesn\'t exist. (UNKNOWN_TABLE) (version 24.3.2.23 (official build))' + ); + $responseMock->method('error_no')->willReturn(0); + $responseMock->method('error')->willReturn(false); + $responseMock->method('headers')->willReturnCallback(function ($name) { + if ($name === 'X-ClickHouse-Query-Id') { + return 'abc-123-def'; + } + return null; + }); + + $requestMock = $this->createMock(CurlerRequest::class); + $requestMock->method('response')->willReturn($responseMock); + + $statement = new Statement($requestMock); + + try { + $statement->error(); + $this->fail('Expected DatabaseException'); + } catch (DatabaseException $e) { + $this->assertEquals('abc-123-def', $e->getQueryId()); + $this->assertEquals('UNKNOWN_TABLE', $e->getClickHouseExceptionName()); + } + } + + public function testDatabaseExceptionFromClickHouseFactory(): void + { + $e = DatabaseException::fromClickHouse('Some error', 42, 'SOME_ERROR', 'query-123'); + $this->assertEquals('Some error', $e->getMessage()); + $this->assertEquals(42, $e->getCode()); + $this->assertEquals('SOME_ERROR', $e->getClickHouseExceptionName()); + $this->assertEquals('query-123', $e->getQueryId()); + } + + public function testLiveExceptionHasStructuredData(): void + { + try { + $this->client->select('SELECT * FROM non_existent_table_xyz_123')->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()); + } + } +} diff --git a/tests/SummaryTest.php b/tests/SummaryTest.php new file mode 100644 index 0000000..cfd2ab3 --- /dev/null +++ b/tests/SummaryTest.php @@ -0,0 +1,139 @@ +client->write(' + CREATE TABLE IF NOT EXISTS test_summary ( + id UInt32, + name String + ) ENGINE = Memory + '); + + $stat = $this->client->insert('test_summary', + [ + [1, 'a'], + [2, 'b'], + [3, 'c'], + ], + ['id', 'name'] + ); + + $summary = $stat->summary(); + + // X-ClickHouse-Summary may not be present on older ClickHouse versions + if ($summary !== null) { + $this->assertIsArray($summary); + $this->assertArrayHasKey('written_rows', $summary); + $this->assertNotNull($stat->summary('written_rows')); + } else { + $this->markTestSkipped('X-ClickHouse-Summary header not present (older ClickHouse version)'); + } + } + + /** + * statistics() should fallback to summary for INSERT queries. + */ + public function testStatisticsFallbackToSummary(): void + { + $this->client->write(' + CREATE TABLE IF NOT EXISTS test_summary_fallback ( + id UInt32 + ) ENGINE = Memory + '); + + $stat = $this->client->insert('test_summary_fallback', + [[1], [2]], + ['id'] + ); + + $statistics = $stat->statistics(); + + if ($statistics !== null) { + $this->assertIsArray($statistics); + $this->assertArrayHasKey('written_rows', $statistics); + } else { + $this->markTestSkipped('X-ClickHouse-Summary header not present (older ClickHouse version)'); + } + } + + /** + * statistics() with key should fallback to summary for INSERT queries. + */ + public function testStatisticsWithKeyFallbackToSummary(): void + { + $this->client->write(' + CREATE TABLE IF NOT EXISTS test_summary_key ( + id UInt32 + ) ENGINE = Memory + '); + + $stat = $this->client->insert('test_summary_key', + [[1], [2], [3], [4], [5]], + ['id'] + ); + + $writtenRows = $stat->statistics('written_rows'); + + if ($writtenRows !== null) { + $this->assertNotNull($writtenRows); + } else { + $this->markTestSkipped('X-ClickHouse-Summary header not present (older ClickHouse version)'); + } + } + + /** + * summary() returns null when header is absent (unit test with mock). + */ + public function testSummaryReturnsNullWhenNoHeader(): void + { + $responseMock = $this->createMock(CurlerResponse::class); + $responseMock->method('http_code')->willReturn(200); + $responseMock->method('error_no')->willReturn(0); + $responseMock->method('content_type')->willReturn(null); + $responseMock->method('body')->willReturn('Ok.'); + $responseMock->method('headers')->willReturn(null); + + $requestMock = $this->createMock(CurlerRequest::class); + $requestMock->method('response')->willReturn($responseMock); + $requestMock->method('isResponseExists')->willReturn(true); + + $statement = new Statement($requestMock); + + $this->assertNull($statement->summary()); + $this->assertNull($statement->summary('written_rows')); + } + + /** + * SELECT should still return statistics from body, not header. + */ + public function testSelectStatisticsStillWork(): void + { + $this->client->settings()->set('output_format_write_statistics', true); + + $stat = $this->client->select('SELECT 1 as n'); + $statistics = $stat->statistics(); + + $this->assertIsArray($statistics); + $this->assertArrayHasKey('elapsed', $statistics); + } +} diff --git a/tests/TypesTest.php b/tests/TypesTest.php new file mode 100644 index 0000000..a123fa6 --- /dev/null +++ b/tests/TypesTest.php @@ -0,0 +1,247 @@ +client->write("DROP TABLE IF EXISTS types_int64"); + $this->client->write("CREATE TABLE types_int64 (v Int64) ENGINE = Memory"); + + $this->client->insert('types_int64', [ + [Int64::fromString('-9223372036854775808')], + [Int64::fromString('9223372036854775807')], + ], ['v']); + + $st = $this->client->select('SELECT count() as cnt FROM types_int64'); + $this->assertEquals(2, $st->fetchOne('cnt')); + } + + public function testUInt64(): void + { + $this->client->write("DROP TABLE IF EXISTS types_uint64"); + $this->client->write("CREATE TABLE types_uint64 (v UInt64) ENGINE = Memory"); + + $this->client->insert('types_uint64', [ + [UInt64::fromString('0')], + [UInt64::fromString('18446744073709551615')], + ], ['v']); + + $st = $this->client->select('SELECT count() as cnt FROM types_uint64'); + $this->assertEquals(2, $st->fetchOne('cnt')); + } + + public function testDecimal(): void + { + $this->client->write("DROP TABLE IF EXISTS types_decimal"); + $this->client->write("CREATE TABLE types_decimal (v Decimal(18,4)) ENGINE = Memory"); + + $this->client->insert('types_decimal', [ + [Decimal::fromString('12345.6789')], + [Decimal::fromString('-99999.9999')], + ], ['v']); + + $st = $this->client->select('SELECT count() as cnt FROM types_decimal'); + $this->assertEquals(2, $st->fetchOne('cnt')); + } + + public function testUUID(): void + { + $this->client->write("DROP TABLE IF EXISTS types_uuid"); + $this->client->write("CREATE TABLE types_uuid (id UUID) ENGINE = Memory"); + + $uuid = '6d38d288-5b13-4714-b6e4-faa59ffd49d8'; + $this->client->insert('types_uuid', [ + [UUID::fromString($uuid)], + ], ['id']); + + $st = $this->client->select('SELECT id FROM types_uuid'); + $this->assertEquals($uuid, $st->fetchOne('id')); + } + + public function testIPv4(): void + { + $this->client->write("DROP TABLE IF EXISTS types_ipv4"); + $this->client->write("CREATE TABLE types_ipv4 (ip IPv4) ENGINE = Memory"); + + $this->client->insert('types_ipv4', [ + [IPv4::fromString('192.168.1.1')], + [IPv4::fromString('10.0.0.1')], + ], ['ip']); + + $st = $this->client->select('SELECT count() as cnt FROM types_ipv4'); + $this->assertEquals(2, $st->fetchOne('cnt')); + } + + public function testIPv6(): void + { + $this->client->write("DROP TABLE IF EXISTS types_ipv6"); + $this->client->write("CREATE TABLE types_ipv6 (ip IPv6) ENGINE = Memory"); + + $this->client->insert('types_ipv6', [ + [IPv6::fromString('::1')], + [IPv6::fromString('2001:db8::1')], + ], ['ip']); + + $st = $this->client->select('SELECT count() as cnt FROM types_ipv6'); + $this->assertEquals(2, $st->fetchOne('cnt')); + } + + public function testDateTime64(): void + { + $this->client->write("DROP TABLE IF EXISTS types_dt64"); + $this->client->write("CREATE TABLE types_dt64 (dt DateTime64(3)) ENGINE = Memory"); + + $this->client->insert('types_dt64', [ + [DateTime64::fromString('2024-01-15 10:30:00.123')], + ], ['dt']); + + $st = $this->client->select('SELECT count() as cnt FROM types_dt64'); + $this->assertEquals(1, $st->fetchOne('cnt')); + } + + public function testDateTime64FromDateTime(): void + { + $this->client->write("DROP TABLE IF EXISTS types_dt64b"); + $this->client->write("CREATE TABLE types_dt64b (dt DateTime64(3)) ENGINE = Memory"); + + $dt = new \DateTimeImmutable('2024-06-15 12:00:00.456789'); + $this->client->insert('types_dt64b', [ + [DateTime64::fromDateTime($dt, 3)], + ], ['dt']); + + $st = $this->client->select('SELECT count() as cnt FROM types_dt64b'); + $this->assertEquals(1, $st->fetchOne('cnt')); + } + + public function testDate32(): void + { + $this->client->write("DROP TABLE IF EXISTS types_date32"); + $this->client->write("CREATE TABLE types_date32 (d Date32) ENGINE = Memory"); + + $this->client->insert('types_date32', [ + [Date32::fromString('2024-01-15')], + [Date32::fromDateTime(new \DateTimeImmutable('2030-12-31'))], + ], ['d']); + + $st = $this->client->select('SELECT count() as cnt FROM types_date32'); + $this->assertEquals(2, $st->fetchOne('cnt')); + } + + public function testNativeParamsWithUUID(): void + { + $uuid = '6d38d288-5b13-4714-b6e4-faa59ffd49d8'; + $st = $this->client->selectWithParams( + 'SELECT {id:UUID} as id', + ['id' => UUID::fromString($uuid)] + ); + $this->assertEquals($uuid, $st->fetchOne('id')); + } + + public function testNativeParamsWithIPv4(): void + { + $st = $this->client->selectWithParams( + 'SELECT {ip:IPv4} as ip', + ['ip' => IPv4::fromString('192.168.1.1')] + ); + $this->assertStringContainsString('192.168.1.1', $st->fetchOne('ip')); + } + + public function testNativeParamsWithDateTime64(): void + { + $st = $this->client->selectWithParams( + "SELECT {dt:DateTime64(3)} as dt", + ['dt' => DateTime64::fromString('2024-01-15 10:30:00.123')] + ); + $this->assertStringContainsString('2024-01-15', $st->fetchOne('dt')); + } + + public function testNativeParamsWithArray(): void + { + $st = $this->client->selectWithParams( + "SELECT {arr:Array(UInt32)} as arr", + ['arr' => [1, 2, 3]] + ); + $row = $st->fetchOne(); + $this->assertIsArray($row['arr']); + $this->assertCount(3, $row['arr']); + } + + // Unit tests for type getValue() + + public function testInt64Value(): void + { + $v = Int64::fromString('42'); + $this->assertEquals('42', $v->getValue()); + $this->assertEquals('42', (string) $v); + } + + public function testDecimalValue(): void + { + $v = Decimal::fromString('3.14'); + $this->assertEquals('3.14', $v->getValue()); + } + + public function testUUIDValue(): void + { + $v = UUID::fromString('abc-123'); + $this->assertEquals('abc-123', $v->getValue()); + $this->assertEquals('abc-123', (string) $v); + } + + public function testIPv4Value(): void + { + $v = IPv4::fromString('1.2.3.4'); + $this->assertEquals('1.2.3.4', $v->getValue()); + } + + public function testIPv6Value(): void + { + $v = IPv6::fromString('::1'); + $this->assertEquals('::1', $v->getValue()); + } + + public function testDateTime64Value(): void + { + $v = DateTime64::fromString('2024-01-01 00:00:00.000'); + $this->assertEquals('2024-01-01 00:00:00.000', $v->getValue()); + } + + public function testDate32Value(): void + { + $v = Date32::fromString('2024-01-01'); + $this->assertEquals('2024-01-01', $v->getValue()); + } + + public function testMapTypeValue(): void + { + $v = MapType::fromArray(['key1' => 'val1', 'key2' => 'val2']); + $this->assertStringContainsString('map(', $v->getValue()); + } + + public function testTupleTypeValue(): void + { + $v = TupleType::fromArray([1, 'hello', null]); + $this->assertEquals("(1,'hello',NULL)", $v->getValue()); + } +} diff --git a/tests/clickhouse-latest-config/allow_deprecated.xml b/tests/clickhouse-latest-config/allow_deprecated.xml new file mode 100644 index 0000000..1d00bbd --- /dev/null +++ b/tests/clickhouse-latest-config/allow_deprecated.xml @@ -0,0 +1,7 @@ + + + + 1 + + + diff --git a/tests/docker-compose.yaml b/tests/docker-compose.yaml index f883d6b..9ded376 100644 --- a/tests/docker-compose.yaml +++ b/tests/docker-compose.yaml @@ -1,10 +1,9 @@ -version: '3' services: - clickhouse-server: + clickhouse-21: image: clickhouse/clickhouse-server:21.9 - hostname: clickhouse - container_name: clickhouse + hostname: clickhouse-21 + container_name: clickhouse-21 ports: - 19000:9000 - 8123:8123 @@ -12,7 +11,28 @@ services: net.core.somaxconn: 1024 net.ipv4.tcp_syncookies: 0 volumes: - - "./docker-clickhouse:/var/lib/clickhouse" + - "./docker-clickhouse-21:/var/lib/clickhouse" + ulimits: + nofile: + soft: 262144 + hard: 262144 + + clickhouse-latest: + image: clickhouse/clickhouse-server:26.3.3.20 + hostname: clickhouse-latest + container_name: clickhouse-latest + environment: + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1 + CLICKHOUSE_PASSWORD: "" + ports: + - 19001:9000 + - 8124:8123 + sysctls: + net.core.somaxconn: 1024 + net.ipv4.tcp_syncookies: 0 + volumes: + - "./docker-clickhouse-latest:/var/lib/clickhouse" + - "./clickhouse-latest-config/allow_deprecated.xml:/etc/clickhouse-server/users.d/allow_deprecated.xml:ro" ulimits: nofile: soft: 262144 diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..df3c959 --- /dev/null +++ b/todo.md @@ -0,0 +1,223 @@ +# TODO: phpClickHouse Roadmap + +## 1. Native Query Parameters + +ClickHouse поддерживает типизированные параметры через HTTP: `{name:Type}` — сервер сам парсит значения, SQL injection невозможен на уровне протокола. + +### Текущее состояние +- `Query::isUseInUrlBindingsParams()` уже детектит `{p1:UInt8}` синтаксис +- `Query::getUrlBindingsParams()` извлекает params для URL +- `Http::makeRequest()` передаёт их как `param_*` в query string +- НО: нет удобного API в Client, нет валидации типов, нет документации + +### План +- [ ] Добавить `Client::selectWithParams(string $sql, array $params, string $format = 'JSON')` + - `$params = ['p1' => ['value' => 42, 'type' => 'UInt32']]` + - Формирует `param_p1=42` в URL, тип уже в SQL: `{p1:UInt32}` +- [ ] Добавить `Client::writeWithParams(string $sql, array $params)` — аналог для DDL/DML +- [ ] Валидация: проверять что все `{name:Type}` из SQL имеют соответствующий param +- [ ] Конвертация PHP-типов в CH-значения (DateTimeInterface → string, array → JSON и т.д.) +- [ ] Не ломать существующий `select()` / `write()` — новые методы параллельно +- [ ] Тесты: unit (без CH) + integration (с CH 21 и 26) +- [ ] Документация: `doc/native-params.md` + +### Файлы +- `src/Client.php` — новые методы +- `src/Query/Query.php` — валидация params vs SQL placeholders +- `src/Query/ParamValueConverter.php` — новый: конвертация PHP → CH string +- `tests/NativeParamsTest.php` — unit +- `tests/ClickHouse26/NativeParamsTest.php` — integration (native params лучше тестить на 26.x) + +--- + +## 2. Полная поддержка типов ClickHouse (60+) + +Расширить `ValueFormatter` и добавить систему типов для native parameters. + +### Текущее состояние +- `ValueFormatter`: int, float, bool, string, null, DateTimeInterface, Expression, Type +- `UInt64` — единственный кастомный тип +- Нет поддержки: DateTime64, Date32, IPv4/IPv6, UUID, Map, Tuple, Enum, Decimal, Geo-типы + +### План — Фаза 1: Основные типы +- [ ] `src/Type/` — расширить систему типов: + - [ ] `Int8`, `Int16`, `Int32`, `Int64`, `Int128`, `Int256` + - [ ] `UInt8`, `UInt16`, `UInt32`, `UInt64` (уже есть), `UInt128`, `UInt256` + - [ ] `Float32`, `Float64` + - [ ] `Decimal(P, S)`, `Decimal32`, `Decimal64`, `Decimal128`, `Decimal256` + - [ ] `Bool` +- [ ] Тесты на каждый тип: insert + select + сравнение + +### План — Фаза 2: Строки и даты +- [ ] `String`, `FixedString(N)` +- [ ] `Date`, `Date32` +- [ ] `DateTime`, `DateTime64(precision, timezone)` +- [ ] `UUID` +- [ ] `IPv4`, `IPv6` +- [ ] `Enum8`, `Enum16` +- [ ] Тесты + +### План — Фаза 3: Составные типы +- [ ] `Array(T)` — уже частично работает, формализовать +- [ ] `Tuple(T1, T2, ...)` +- [ ] `Map(K, V)` +- [ ] `Nullable(T)` — уже частично работает +- [ ] `LowCardinality(T)` +- [ ] `Nested(name1 T1, name2 T2)` — уже частично, формализовать +- [ ] Тесты + +### План — Фаза 4: Специализированные типы +- [ ] `JSON` / `Object('json')` +- [ ] Geo: `Point`, `Ring`, `LineString`, `Polygon`, `MultiPolygon` +- [ ] `SimpleAggregateFunction`, `AggregateFunction` +- [ ] Тесты + +### Архитектура +``` +src/Type/ +├── Type.php (базовый интерфейс — уже есть) +├── NumericType.php (уже есть) +├── UInt64.php (уже есть) +├── TypeRegistry.php — NEW: маппинг CH type name → PHP class +├── Date32.php +├── DateTime64.php +├── IPv4.php +├── IPv6.php +├── UUIDType.php +├── MapType.php +└── TupleType.php +``` + +### Принципы +- Каждый тип реализует `Type` интерфейс (`getValue()`) +- `TypeRegistry` — singleton с маппингом `'DateTime64' → DateTime64::class` +- Обратная совместимость: существующий код без типов продолжает работать +- Типы опциональны — можно передавать raw values как раньше + +--- + +## 3. Structured Exceptions + +Обогатить исключения информацией из ClickHouse: error code, exception name, stack trace. + +### Текущее состояние +- `DatabaseException` — парсит `Code: N. DB::Exception: message` +- `TransportException` — curl ошибки +- `QueryException` — общие ошибки запросов +- НЕТ: CH exception class name, query ID, stack trace от сервера + +### План +- [ ] `DatabaseException` — добавить поля: + - [ ] `getClickHouseExceptionName(): ?string` — `SYNTAX_ERROR`, `TABLE_NOT_FOUND` и т.д. + - [ ] `getQueryId(): ?string` — из заголовка `X-ClickHouse-Query-Id` + - [ ] `getServerVersion(): ?string` — из ответа +- [ ] Парсить новый формат ошибок CH 22+: `(EXCEPTION_NAME) (version X.Y.Z)` +- [ ] Расширить regex в Statement: `CLICKHOUSE_ERROR_REGEX` +- [ ] НЕ менять конструктор `DatabaseException` — добавить сеттеры/фабрику +- [ ] Тесты: unit с mock ответами + data provider с разными форматами ошибок +- [ ] Тесты на CH 21 (старый формат) и CH 26 (новый формат) + +### Файлы +- `src/Exception/DatabaseException.php` — расширить +- `src/Statement.php` — парсинг в `parseErrorClickHouse()` +- `tests/ExceptionParsingTest.php` — unit тесты с data provider + +--- + +## 4. PHPStan Level Max + +Поэтапно поднять PHPStan с level 1 до max. + +### Текущее состояние +- `phpstan.neon.dist`: level 1, phpVersion 80406 +- PHPStan 2.1, PHP 8.4.6 +- 0 ошибок на level 1 + +### План — поэтапный подъём +- [ ] Level 2 → исправить ошибки → коммит +- [ ] Level 3 → исправить ошибки → коммит +- [ ] Level 4 → исправить ошибки → коммит +- [ ] Level 5 → исправить ошибки → коммит (основные type checks) +- [ ] Level 6 → исправить ошибки → коммит (missing typehints) +- [ ] Level 7 → исправить ошибки → коммит (union types) +- [ ] Level 8 → исправить ошибки → коммит (nullability) +- [ ] Level 9 → исправить ошибки → коммит (mixed type) +- [ ] Level max → финальная проверка + +### Принципы +- Каждый уровень = отдельный коммит +- НЕ менять публичные сигнатуры методов (обратная совместимость!) +- Добавлять `@phpstan-*` аннотации только как крайняя мера +- Предпочитать реальные фиксы типов, а не подавление ошибок +- Прогонять тесты после каждого уровня + +### Оценка +- Сейчас ~35 PHP файлов в src/ — масштаб управляемый +- Основные проблемы ожидаются на level 5-6 (missing type hints) +- Level 8+ может потребовать добавления `@phpstan-assert` / `@phpstan-param` + +--- + +## 5. Per-Query Settings Override + +Передавать настройки ClickHouse на уровне отдельного запроса. + +### Текущее состояние +- `Settings` — глобальный объект, общий на все запросы +- Для изменения надо: `$client->settings()->set(...)` → запрос → `$client->settings()->set(...)` обратно +- Неудобно и не thread-safe (если делить client между goroutines/fibers) + +### План +- [ ] Добавить `$settings` параметр в существующие методы (с default = `[]`): + - [ ] `Client::select($sql, $bindings = [], $whereInFile = null, $writeToFile = null, array $settings = [])` + - [ ] `Client::write($sql, $bindings = [], $exception = true, array $settings = [])` + - [ ] `Client::selectAsync(...)` — аналогично +- [ ] `Http::select()` / `Http::write()` — пробросить settings в URL params +- [ ] Settings мержатся: глобальные + per-query (per-query приоритет) +- [ ] НЕ ломать обратную совместимость — новый параметр со значением по умолчанию `[]` +- [ ] Тесты: проверить что per-query settings применяются, а глобальные не меняются + +### Пример использования +```php +// Глобальные настройки +$db->settings()->set('max_execution_time', 30); + +// Один тяжёлый запрос с увеличенным таймаутом +$result = $db->select( + 'SELECT * FROM huge_table', + [], + null, + null, + ['max_execution_time' => 300, 'max_rows_to_read' => 1000000] +); + +// Следующий запрос — снова 30 сек +$db->select('SELECT 1'); +``` + +### Файлы +- `src/Client.php` — добавить параметр +- `src/Transport/Http.php` — merge settings +- `tests/PerQuerySettingsTest.php` +- `tests/ClickHouse26/PerQuerySettingsTest.php` + +--- + +## Приоритеты + +| # | Задача | Сложность | Риск ломки API | Приоритет | +|---|--------|-----------|----------------|-----------| +| 5 | Per-query settings | Низкая | Нулевой | **P0** | +| 3 | Structured exceptions | Низкая | Нулевой | **P0** | +| 1 | Native Query Parameters | Средняя | Нулевой (новые методы) | **P1** | +| 4 | PHPStan level max | Средняя | Нулевой | **P1** | +| 2 | 60+ типов (фаза 1-2) | Средняя | Нулевой | **P2** | +| 2 | 60+ типов (фаза 3-4) | Высокая | Нулевой | **P3** | + +## Ограничения + +- **НЕЛЬЗЯ** менять сигнатуры существующих публичных методов +- **НЕЛЬЗЯ** менять существующие тесты +- **НЕЛЬЗЯ** добавлять внешние зависимости в `require` +- Новые параметры — **ТОЛЬКО** с default значениями +- Каждая фича = отдельная ветка + PR + тесты для CH 21 и CH 26