diff --git a/.coveralls.yml b/.coveralls.yml
new file mode 100644
index 0000000..da22584
--- /dev/null
+++ b/.coveralls.yml
@@ -0,0 +1,2 @@
+coverage_clover: /tmp/coverage/*.xml
+json_path: /tmp/coverage/coverage.json
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..f5083ee
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,11 @@
+# Set the default behavior, in case people don't have core.autocrlf set.
+* text eol=lf
+/.coveralls.yml export-ignore
+/.gitattributes export-ignore
+/.gitignore export-ignore
+/.github export-ignore
+/infection.json.dist export-ignore
+/phpcs.xml.dist export-ignore
+/phpstan.neon.dist export-ignore
+/phpunit.xml.dist export-ignore
+/tests export-ignore
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..e45ffe6
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,157 @@
+name: Continuous Integration
+
+on:
+ push:
+ branches:
+ tags:
+ pull_request:
+
+jobs:
+ coding-standard:
+ runs-on: ubuntu-18.04
+ name: Coding Standard
+
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Install PHP
+ uses: shivammathur/setup-php@1.7.0
+ with:
+ php-version: 7.3
+ coverage: none
+ extensions: json, mbstring
+
+ - name: Get Composer Cache Directory
+ id: composer-cache
+ run: echo "::set-output name=dir::$(composer config cache-files-dir)"
+
+ - name: Cache dependencies
+ uses: actions/cache@v1
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ hashFiles('composer.json') }}
+ restore-keys: ${{ runner.os }}-composer-
+
+ - name: Install Dependencies
+ run: composer install ${DEPENDENCIES}
+
+ - name: Coding Standard
+ run: vendor/bin/phpcs
+
+ phpstan:
+ runs-on: ubuntu-18.04
+ name: PHPStan
+
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Install PHP
+ uses: shivammathur/setup-php@1.7.0
+ with:
+ php-version: 7.3
+ coverage: none
+ extensions: json, mbstring
+
+ - name: Get Composer Cache Directory
+ id: composer-cache
+ run: echo "::set-output name=dir::$(composer config cache-files-dir)"
+
+ - name: Cache dependencies
+ uses: actions/cache@v1
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ hashFiles('composer.json') }}
+ restore-keys: ${{ runner.os }}-composer-
+
+ - name: Install Dependencies
+ run: composer install ${DEPENDENCIES}
+
+ - name: PHPStan
+ run: vendor/bin/phpstan analyse
+
+ coverage:
+ runs-on: ubuntu-18.04
+ name: Code Coverage
+
+ steps:
+ - uses: actions/checkout@v2
+ with:
+ ref: ${{ github.ref }}
+
+ - name: Build the docker-compose stack
+ run: docker-compose -f tests/docker-compose.yaml up -d
+
+ - name: Install PHP
+ uses: shivammathur/setup-php@1.7.0
+ with:
+ php-version: 7.3
+ coverage: pcov
+ extensions: json, mbstring
+
+ - name: Get Composer Cache Directory
+ id: composer-cache
+ run: echo "::set-output name=dir::$(composer config cache-files-dir)"
+
+ - name: Cache dependencies
+ uses: actions/cache@v1
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ hashFiles('composer.json') }}
+ restore-keys: ${{ runner.os }}-composer-
+
+ - name: Install Dependencies
+ run: composer install ${DEPENDENCIES}
+
+ - name: Code coverage
+ run: |
+ ./vendor/bin/phpunit --coverage-clover /tmp/coverage/clover.xml
+
+ - name: Report to Coveralls
+ env:
+ COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ COVERALLS_RUN_LOCALLY: 1
+ run: vendor/bin/php-coveralls --verbose
+
+ build:
+ runs-on: ubuntu-18.04
+ strategy:
+ matrix:
+ php: [7.3, 7.4]
+ env: [
+ 'DEPENDENCIES=--prefer-lowest',
+ '',
+ ]
+ name: PHP ${{ matrix.php }} Test ${{ matrix.env }}
+
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Build the docker-compose stack
+ run: docker-compose -f tests/docker-compose.yaml up -d
+
+ - name: Install PHP
+ uses: shivammathur/setup-php@1.7.0
+ with:
+ php-version: ${{ matrix.php }}
+ coverage: none
+ extensions: json, mbstring
+ - name: Get Composer Cache Directory
+ id: composer-cache
+ run: echo "::set-output name=dir::$(composer config cache-files-dir)"
+
+ - name: Cache dependencies
+ uses: actions/cache@v1
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ hashFiles('composer.json') }}
+ restore-keys: ${{ runner.os }}-composer-
+
+ - name: Install Dependencies
+ run: composer install ${DEPENDENCIES}
+
+ - name: Run unit tests
+ run: |
+ export $ENV
+ ./vendor/bin/phpunit --group default,ReactPromise
+ env:
+ ENV: ${{ matrix.env}}
diff --git a/.github/workflows/infection.yml b/.github/workflows/infection.yml
new file mode 100644
index 0000000..b6f5048
--- /dev/null
+++ b/.github/workflows/infection.yml
@@ -0,0 +1,26 @@
+name: Run Infection
+
+on: [push, pull_request]
+
+jobs:
+ build:
+ runs-on: ubuntu-18.04
+
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Build the docker-compose stack
+ run: docker-compose -f tests/docker-compose.yaml up -d
+
+ - name: Install PHP
+ uses: shivammathur/setup-php@1.7.0
+ with:
+ php-version: 7.3
+ coverage: xdebug
+ extensions: json, mbstring
+
+ - name: Install Dependencies
+ run: composer install --prefer-dist --no-progress --no-suggest
+
+ - name: Run Infection
+ run: vendor/bin/infection --min-msi=84 --min-covered-msi=90 --log-verbosity=none -s
diff --git a/.github/workflows/shepherd.yml b/.github/workflows/shepherd.yml
new file mode 100644
index 0000000..43da896
--- /dev/null
+++ b/.github/workflows/shepherd.yml
@@ -0,0 +1,16 @@
+name: Run Shepherd
+
+on: [push, pull_request]
+
+jobs:
+ build:
+ runs-on: ubuntu-18.04
+
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-progress --no-suggest
+
+ - name: Run Psalm
+ run: vendor/bin/psalm --threads=4 --output-format=github --shepherd
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..375f6d0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+/.phpunit.result.cache
+/.phpunit.xml
+/.phpcs-cache
+/infection.json
+/infection-log.txt
+/phpcs.xml
+/phpstan.neon
+/composer.lock
+/vendor/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..698768b
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2020 Šimon Podlipský
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1a895cf
--- /dev/null
+++ b/README.md
@@ -0,0 +1,251 @@
+# PHP ClickHouse Client
+
+[![Build Status](https://github.com/simPod/PhpClickHouseClient/workflows/Continuous%20Integration/badge.svg?branch=master)](https://github.com/simPod/PhpClickHouseClient/actions)
+[![Coverage Status](https://coveralls.io/repos/github/simPod/PhpClickHouseClient/badge.svg?branch=master)](https://coveralls.io/github/simPod/PhpClickHouseClient?branch=master)
+[![Downloads](https://poser.pugx.org/simpod/clickhouse-client/d/total.svg)](https://packagist.org/packages/simpod/clickhouse-client)
+[![Packagist](https://poser.pugx.org/simpod/clickhouse-client/v/stable.svg)](https://packagist.org/packages/simpod/clickhouse-client)
+[![Licence](https://poser.pugx.org/simpod/clickhouse-client/license.svg)](https://packagist.org/packages/simpod/clickhouse-client)
+[![GitHub Issues](https://img.shields.io/github/issues/simPod/PhpClickHouseClient.svg?style=flat-square)](https://github.com/simPod/PhpClickHouseClient/issues)
+[![Psalm Coverage](https://shepherd.dev/github/simPod/PhpClickHouseClient/coverage.svg)](https://shepherd.dev/github/simPod/PhpClickHouseClient)
+
+## Motivation
+
+The library is trying not to hide any ClickHouse HTTP interface specific details.
+That said everything is as much transparent as possible and so object-oriented API is provided without inventing own abstractions.
+Naming used here is the same as in ClickHouse docs.
+
+- Works with any HTTP Client implementation ([PSR-18 compliant](https://www.php-fig.org/psr/psr-18/))
+- All [ClickHouse Formats](https://clickhouse.yandex/docs/en/interfaces/formats/) support
+- Logging ([PSR-3 compliant](https://www.php-fig.org/psr/psr-3/))
+- SQL Factory for [parameters "binding"](#parameters-binding)
+- Dependency only on PSR interfaces, Guzzle A+ Promises for async requests and Safe
+
+## Contents
+
+- [Setup](#setup)
+ - [Time Zones](#time-zones)
+ - [PSR Factories who?](#psr-factories-who)
+- [Sync API](#sync-api)
+ - [Select](#select)
+ - [Select With Parameters](#select-with-parameters)
+ - [Insert](#insert)
+- [Async API](#async-api)
+ - [Select](#select-1)
+- [Parameters "binding"](#parameters-binding)
+- [Snippets](#snippets)
+
+## Setup
+
+```sh
+composer require simpod/clickhouse-client
+```
+
+Create a new instance of client and pass PSR factories:
+
+```php
+ 'dbname',
+ 'user' => 'username',
+ 'password' => 'secret',
+ ],
+ new DateTimeZone('UTC')
+);
+```
+
+### Time Zones
+
+ClickHouse does not have date times with timezones.
+Therefore you need to normalize DateTimes' timezones passed as parameters to ensure proper input format.
+
+Following would be inserted as `2020-01-31 01:00:00` into ClickHouse.
+
+```php
+new DateTimeImmutable('2020-01-31 01:00:00', new DateTimeZone('Europe/Prague'));
+```
+
+If your server uses `UTC`, the value is incorrect for you actually need to insert `2020-01-31 00:00:00`.
+
+Time zone normalization is enabled by passing `DateTimeZone` into `PsrClickHouseClient` constructor.
+
+```php
+new PsrClickHouseClient(..., new DateTimeZone('UTC'));
+```
+
+### PSR Factories who?
+
+_The library does not implement it's own HTTP.
+That has already been done via [PSR-7, PSR-17 and PSR-18](https://www.php-fig.org/psr/).
+This library respects it and allows you to plug your own implementation (eg. HTTPPlug or Guzzle)._
+
+_Recommended are `composer require nyholm/psr7` for PSR-17 and `composer require php-http/curl-client` for Curl PSR-18 implementation (used in example above)._
+
+## Sync API
+
+### Select
+
+`ClickHouseClient::select()`
+
+Intended for `SELECT` and `SHOW` queries.
+Appends `FORMAT` to the query and returns response in selected output format:
+
+```php
+select(
+ 'SELECT * FROM table',
+ new JsonEachRow(),
+ ['force_primary_key' => 1]
+);
+```
+
+### Select With Parameters
+
+`ClickHouseClient::selectWithParameters()`
+
+Same as `ClickHouseClient::select()` except it also allows [parameter binding](#parameters-binding).
+
+```php
+selectWithParameters(
+ 'SELECT * FROM :table',
+ ['table' => 'table_name'],
+ new JsonEachRow(),
+ ['force_primary_key' => 1]
+);
+```
+
+### Insert
+
+`ClickHouseClient::insert()`
+
+```php
+insert('table', $data, $columnNames);
+```
+
+If `$columnNames` is provided column names are generated based on it:
+
+`$client->insert( 'table', [[1,2]], ['a', 'b'] );` generates `INSERT INTO table (a,b) VALUES (1,2)`.
+
+If `$columnNames` is omitted column names are read from `$data`:
+
+`$client->insert( 'table', [['a' => 1,'b' => 2]]);` generates `INSERT INTO table (a,b) VALUES (1,2)`.
+
+Column names are read only from the first item:
+
+`$client->insert( 'table', [['a' => 1,'b' => 2], ['c' => 3,'d' => 4]]);` generates `INSERT INTO table (a,b) VALUES (1,2),(3,4)`.
+
+If not provided they're not passed either:
+
+`$client->insert( 'table', [[1,2]]);` generates `INSERT INTO table VALUES (1,2)`.
+
+## Async API
+
+### Select
+
+## Parameters "binding"
+
+```php
+createWithParameters(
+ 'SELECT :param',
+ ['param' => 'value']
+);
+```
+This produces `SELECT 'value'` and it can be passed to `ClickHouseClient::select()`.
+
+Supported types are:
+- scalars
+- DateTimeImmutable (`\DateTime` is not supported because `ValueFormatter` might modify its timezone so it's not considered safe)
+- [Expression](#expression)
+- objects implementing `__toString()`
+
+### Expression
+
+To represent complex expressions there's `SimPod\ClickHouseClient\Sql\Expression` class. When passed to `SqlFactory` its value gets evaluated.
+
+To pass eg. `UUIDStringToNum('6d38d288-5b13-4714-b6e4-faa59ffd49d8')` to SQL:
+
+```php
+templateAndValues(
+ 'UUIDStringToNum(%s)',
+ '6d38d288-5b13-4714-b6e4-faa59ffd49d8'
+);
+```
+
+## Snippets
+
+There are handy queries like getting database size, table list, current database etc.
+
+To prevent Client API pollution, those are extracted into Snippets.
+
+Example to obtain current database name:
+```php
+
+
+
+
+
+
+
+
+
+
+
+ src
+ tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
new file mode 100644
index 0000000..6318dda
--- /dev/null
+++ b/phpstan-baseline.neon
@@ -0,0 +1,17 @@
+parameters:
+ ignoreErrors:
+ -
+ message: "#^Function preg_replace is unsafe to use\\. It can return FALSE instead of throwing an exception\\. Please add 'use function Safe\\\\preg_replace;' at the beginning of the file to use the variant provided by the 'thecodingmachine/safe' library\\.$#"
+ count: 1
+ path: src/Sql/SqlFactory.php
+
+ -
+ message: "#^Parameter \\#3 \\$subject of function preg_replace expects array\\|string, string\\|null given\\.$#"
+ count: 1
+ path: src/Sql/SqlFactory.php
+
+ -
+ message: "#^Method SimPod\\\\ClickHouseClient\\\\Sql\\\\SqlFactory\\:\\:createWithParameters\\(\\) should return string but returns string\\|null\\.$#"
+ count: 1
+ path: src/Sql/SqlFactory.php
+
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
new file mode 100644
index 0000000..a9fb2b7
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,12 @@
+parameters:
+ level: max
+ paths:
+ - %currentWorkingDirectory%/src
+ - %currentWorkingDirectory%/tests
+
+ ignoreErrors:
+ # Adds unnecessary maintanence overhead. We rather rely on PHPStan telling us the method returns unhandled FALSE
+ - "~Class DateTime(Immutable)? is unsafe to use. Its methods can return FALSE instead of throwing an exception. Please add 'use Safe\\\\DateTime(Immutable)?;' at the beginning of the file to use the variant provided by the 'thecodingmachine/safe' library~"
+
+includes:
+ - phpstan-baseline.neon
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..3dd4785
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,29 @@
+
+
+
+
+ tests
+
+
+
+
+
+ ./src
+
+
+
+
+
+
+
+
+
+
diff --git a/psalm.xml b/psalm.xml
new file mode 100644
index 0000000..76933f8
--- /dev/null
+++ b/psalm.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/Client/ClickHouseAsyncClient.php b/src/Client/ClickHouseAsyncClient.php
new file mode 100644
index 0000000..bbe5732
--- /dev/null
+++ b/src/Client/ClickHouseAsyncClient.php
@@ -0,0 +1,36 @@
+ $requestParameters
+ *
+ * @psalm-template O of Output
+ * @psalm-param Format $outputFormat
+ */
+ public function select(string $sql, Format $outputFormat, array $requestParameters = []) : PromiseInterface;
+
+ /**
+ * @param array $requestParameters
+ * @param array $queryParameters
+ *
+ * @psalm-template O of Output
+ * @psalm-param Format $outputFormat
+ */
+ public function selectWithParameters(
+ string $query,
+ array $queryParameters,
+ Format $outputFormat,
+ array $requestParameters = []
+ ) : PromiseInterface;
+}
diff --git a/src/Client/ClickHouseClient.php b/src/Client/ClickHouseClient.php
new file mode 100644
index 0000000..02eaeec
--- /dev/null
+++ b/src/Client/ClickHouseClient.php
@@ -0,0 +1,52 @@
+ $queryParameters */
+ public function executeQueryWithParameters(string $query, array $queryParameters) : void;
+
+ /**
+ * @param array $requestParameters
+ *
+ * @psalm-template O of Output
+ * @psalm-param Format $outputFormat
+ * @psalm-return O
+ */
+ public function select(string $query, Format $outputFormat, array $requestParameters = []) : Output;
+
+ /**
+ * @param array $requestParameters
+ * @param array $queryParameters
+ *
+ * @psalm-template O of Output
+ * @psalm-param Format $outputFormat
+ * @psalm-return O
+ */
+ public function selectWithParameters(
+ string $query,
+ array $queryParameters,
+ Format $outputFormat,
+ array $requestParameters = []
+ ) : Output;
+
+ /**
+ * @param array> $values
+ * @param array|null $columns
+ */
+ public function insert(string $table, array $values, ?array $columns = null) : void;
+
+ /**
+ * @psalm-template O of Output
+ * @psalm-param Format $inputFormat
+ */
+ public function insertWithFormat(string $table, Format $inputFormat, string $data) : void;
+}
diff --git a/src/Client/Http/RequestFactory.php b/src/Client/Http/RequestFactory.php
new file mode 100644
index 0000000..b731098
--- /dev/null
+++ b/src/Client/Http/RequestFactory.php
@@ -0,0 +1,55 @@
+requestFactory = $requestFactory;
+ $this->uriFactory = $uriFactory;
+ $this->streamFactory = $streamFactory;
+ }
+
+ public function prepareRequest(string $endpoint, RequestOptions $requestOptions) : RequestInterface
+ {
+ $uri = $this->uriFactory->createUri($endpoint);
+ $uri = $uri->withQuery(
+ http_build_query(
+ $requestOptions->parameters,
+ '',
+ '&',
+ PHP_QUERY_RFC3986
+ )
+ );
+
+ $body = $this->streamFactory->createStream($requestOptions->sql);
+
+ $request = $this->requestFactory->createRequest('POST', $uri);
+
+ $request = $request->withBody($body);
+
+ return $request;
+ }
+}
diff --git a/src/Client/Http/RequestOptions.php b/src/Client/Http/RequestOptions.php
new file mode 100644
index 0000000..e01d9b7
--- /dev/null
+++ b/src/Client/Http/RequestOptions.php
@@ -0,0 +1,24 @@
+ */
+ public $parameters;
+
+ /**
+ * @param array $defaultParameters
+ * @param array $requestParameters
+ */
+ public function __construct(string $sql, array $defaultParameters, array $requestParameters)
+ {
+ $this->sql = $sql;
+ $this->parameters = $defaultParameters + $requestParameters;
+ }
+}
diff --git a/src/Client/PsrClickHouseAsyncClient.php b/src/Client/PsrClickHouseAsyncClient.php
new file mode 100644
index 0000000..80ec707
--- /dev/null
+++ b/src/Client/PsrClickHouseAsyncClient.php
@@ -0,0 +1,138 @@
+ */
+ private $defaultParameters;
+
+ /** @var SqlFactory */
+ private $sqlFactory;
+
+ /** @param array $defaultParameters */
+ public function __construct(
+ HttpAsyncClient $asyncClient,
+ RequestFactory $requestFactory,
+ LoggerInterface $logger,
+ string $endpoint,
+ array $defaultParameters = [],
+ ?DateTimeZone $clickHouseTimeZone = null
+ ) {
+ $this->asyncClient = $asyncClient;
+ $this->requestFactory = $requestFactory;
+ $this->logger = $logger;
+ $this->endpoint = $endpoint;
+ $this->defaultParameters = $defaultParameters;
+ $this->sqlFactory = new SqlFactory(new ValueFormatter($clickHouseTimeZone));
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function select(string $sql, Format $outputFormat, array $requestParameters = []) : PromiseInterface
+ {
+ $formatClause = $outputFormat::toSql();
+
+ return $this->executeRequest(
+ <<getBody());
+ }
+ );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function selectWithParameters(string $query, array $queryParameters, Format $outputFormat, array $requestParameters = []) : PromiseInterface
+ {
+ return $this->select(
+ $this->sqlFactory->createWithParameters($query, $queryParameters),
+ $outputFormat,
+ $requestParameters
+ );
+ }
+
+ /** @return array */
+ public function getDefaultParameters() : array
+ {
+ return $this->defaultParameters;
+ }
+
+ /** @param array $defaultParameters */
+ public function setDefaultParameters(array $defaultParameters) : void
+ {
+ $this->defaultParameters = $defaultParameters;
+ }
+
+ /**
+ * @param array $requestParameters
+ * @param callable(ResponseInterface):mixed|null $processResponse
+ */
+ private function executeRequest(
+ string $sql,
+ array $requestParameters = [],
+ ?callable $processResponse = null
+ ) : PromiseInterface {
+ $this->logger->debug($sql, $requestParameters);
+
+ $request = $this->requestFactory->prepareRequest(
+ $this->endpoint,
+ new RequestOptions(
+ $sql,
+ $this->defaultParameters,
+ $requestParameters
+ )
+ );
+
+ $promise = promise_for($this->asyncClient->sendAsyncRequest($request));
+
+ return $promise->then(
+ /** @return mixed */
+ static function (ResponseInterface $response) use ($processResponse) {
+ if ($response->getStatusCode() !== 200) {
+ throw ServerError::fromResponse($response);
+ }
+
+ if ($processResponse === null) {
+ return $response;
+ }
+
+ return $processResponse($response);
+ }
+ );
+ }
+}
diff --git a/src/Client/PsrClickHouseClient.php b/src/Client/PsrClickHouseClient.php
new file mode 100644
index 0000000..7b8b9bc
--- /dev/null
+++ b/src/Client/PsrClickHouseClient.php
@@ -0,0 +1,200 @@
+ */
+ private $defaultParameters;
+
+ /** @var ValueFormatter */
+ private $valueFormatter;
+
+ /** @var SqlFactory */
+ private $sqlFactory;
+
+ /** @param array $defaultParameters */
+ public function __construct(
+ ClientInterface $client,
+ RequestFactory $requestFactory,
+ LoggerInterface $logger,
+ string $endpoint,
+ array $defaultParameters = [],
+ ?DateTimeZone $clickHouseTimeZone = null
+ ) {
+ $this->client = $client;
+ $this->requestFactory = $requestFactory;
+ $this->logger = $logger;
+ $this->endpoint = $endpoint;
+ $this->defaultParameters = $defaultParameters;
+ $this->valueFormatter = new ValueFormatter($clickHouseTimeZone);
+ $this->sqlFactory = new SqlFactory($this->valueFormatter);
+ }
+
+ public function executeQuery(string $query) : void
+ {
+ $this->executeRequest($query);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function executeQueryWithParameters(string $query, array $queryParameters) : void
+ {
+ $this->executeQuery($this->sqlFactory->createWithParameters($query, $queryParameters));
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function select(string $query, Format $outputFormat, array $requestParameters = []) : Output
+ {
+ $formatClause = $outputFormat::toSql();
+
+ $response = $this->executeRequest(
+ <<getBody());
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function selectWithParameters(string $query, array $queryParameters, Format $outputFormat, array $requestParameters = []) : Output
+ {
+ return $this->select(
+ $this->sqlFactory->createWithParameters($query, $queryParameters),
+ $outputFormat,
+ $requestParameters
+ );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function insert(string $table, array $values, ?array $columns = null) : void
+ {
+ if ($values === []) {
+ throw CannotInsert::noValues();
+ }
+
+ if ($columns === null) {
+ $firstRow = $values[array_key_first($values)];
+ $columns = array_keys($firstRow);
+ if (is_int($columns[0])) {
+ $columns = null;
+ }
+ }
+
+ $columnsSql = $columns === null ? '' : sprintf('(%s)', implode(',', $columns));
+
+ $valuesSql = implode(
+ ',',
+ array_map(
+ function (array $map) : string {
+ return sprintf(
+ '(%s)',
+ implode(',', $this->valueFormatter->mapFormat($map))
+ );
+ },
+ $values
+ )
+ );
+
+ $table = Escaper::quoteIdentifier($table);
+
+ $response = $this->executeRequest(
+ <<executeRequest(
+ << */
+ public function getDefaultParameters() : array
+ {
+ return $this->defaultParameters;
+ }
+
+ /** @param array $defaultParameters */
+ public function setDefaultParameters(array $defaultParameters) : void
+ {
+ $this->defaultParameters = $defaultParameters;
+ }
+
+ /** @param array $requestParameters */
+ private function executeRequest(string $sql, array $requestParameters = []) : ResponseInterface
+ {
+ $this->logger->debug($sql, $requestParameters);
+
+ $request = $this->requestFactory->prepareRequest(
+ $this->endpoint,
+ new RequestOptions(
+ $sql,
+ $this->defaultParameters,
+ $requestParameters
+ )
+ );
+
+ $response = $this->client->sendRequest($request);
+ if ($response->getStatusCode() !== 200) {
+ throw ServerError::fromResponse($response);
+ }
+
+ return $response;
+ }
+}
diff --git a/src/Exception/CannotInsert.php b/src/Exception/CannotInsert.php
new file mode 100644
index 0000000..2b7ff6d
--- /dev/null
+++ b/src/Exception/CannotInsert.php
@@ -0,0 +1,15 @@
+getBody());
+ }
+}
diff --git a/src/Exception/UnsupportedValueType.php b/src/Exception/UnsupportedValueType.php
new file mode 100644
index 0000000..a727164
--- /dev/null
+++ b/src/Exception/UnsupportedValueType.php
@@ -0,0 +1,25 @@
+ */
+final class Json implements Format
+{
+ public static function output(string $contents) : Output
+ {
+ return new \SimPod\ClickHouseClient\Output\Json($contents);
+ }
+
+ public static function toSql() : string
+ {
+ return 'FORMAT JSON';
+ }
+}
diff --git a/src/Format/JsonCompact.php b/src/Format/JsonCompact.php
new file mode 100644
index 0000000..645a740
--- /dev/null
+++ b/src/Format/JsonCompact.php
@@ -0,0 +1,21 @@
+ */
+final class JsonCompact implements Format
+{
+ public static function output(string $contents) : Output
+ {
+ return new \SimPod\ClickHouseClient\Output\JsonCompact($contents);
+ }
+
+ public static function toSql() : string
+ {
+ return 'FORMAT JSONCompact';
+ }
+}
diff --git a/src/Format/JsonEachRow.php b/src/Format/JsonEachRow.php
new file mode 100644
index 0000000..d0c627e
--- /dev/null
+++ b/src/Format/JsonEachRow.php
@@ -0,0 +1,21 @@
+ */
+final class JsonEachRow implements Format
+{
+ public static function output(string $contents) : Output
+ {
+ return new \SimPod\ClickHouseClient\Output\JsonEachRow($contents);
+ }
+
+ public static function toSql() : string
+ {
+ return 'FORMAT JSONEachRow';
+ }
+}
diff --git a/src/Format/TabSeparated.php b/src/Format/TabSeparated.php
new file mode 100644
index 0000000..a24c90f
--- /dev/null
+++ b/src/Format/TabSeparated.php
@@ -0,0 +1,21 @@
+ */
+final class TabSeparated implements Format
+{
+ public static function output(string $contents) : Output
+ {
+ return new \SimPod\ClickHouseClient\Output\TabSeparated($contents);
+ }
+
+ public static function toSql() : string
+ {
+ return 'FORMAT TabSeparated';
+ }
+}
diff --git a/src/Output/Json.php b/src/Output/Json.php
new file mode 100644
index 0000000..45b0a0d
--- /dev/null
+++ b/src/Output/Json.php
@@ -0,0 +1,41 @@
+> */
+ public $data;
+
+ /** @var array */
+ public $meta;
+
+ /** @var int */
+ public $rows;
+
+ /** @var int|null */
+ public $rowsBeforeLimitAtLeast;
+
+ /** @var array{elapsed: float, rows_read: int, bytes_read: int} */
+ public $statistics;
+
+ public function __construct(string $contentsJson)
+ {
+ // phpcs:ignore SlevomatCodingStandard.Files.LineLength.LineTooLong
+ /**
+ * @var array{data: array>, meta: array, rows: int, rows_before_limit_at_least?: int, statistics: array{elapsed: float, rows_read: int, bytes_read: int}} $contents
+ * @psalm-suppress ImpureFunctionCall
+ */
+ $contents = json_decode($contentsJson, true);
+ $this->data = $contents['data'];
+ $this->meta = $contents['meta'];
+ $this->rows = $contents['rows'];
+ $this->rowsBeforeLimitAtLeast = $contents['rows_before_limit_at_least'] ?? null;
+ $this->statistics = $contents['statistics'];
+ }
+}
diff --git a/src/Output/JsonCompact.php b/src/Output/JsonCompact.php
new file mode 100644
index 0000000..2c517c5
--- /dev/null
+++ b/src/Output/JsonCompact.php
@@ -0,0 +1,41 @@
+> */
+ public $data;
+
+ /** @var array */
+ public $meta;
+
+ /** @var int */
+ public $rows;
+
+ /** @var int|null */
+ public $rowsBeforeLimitAtLeast;
+
+ /** @var array{elapsed: float, rows_read: int, bytes_read: int} */
+ public $statistics;
+
+ public function __construct(string $contentsJson)
+ {
+ // phpcs:ignore SlevomatCodingStandard.Files.LineLength.LineTooLong
+ /**
+ * @var array{data: array>, meta: array, rows: int, rows_before_limit_at_least: int, statistics: array{elapsed: float, rows_read: int, bytes_read: int}} $contents
+ * @psalm-suppress ImpureFunctionCall
+ */
+ $contents = json_decode($contentsJson, true);
+ $this->data = $contents['data'];
+ $this->meta = $contents['meta'];
+ $this->rows = $contents['rows'];
+ $this->rowsBeforeLimitAtLeast = $contents['rows_before_limit_at_least'] ?? null;
+ $this->statistics = $contents['statistics'];
+ }
+}
diff --git a/src/Output/JsonEachRow.php b/src/Output/JsonEachRow.php
new file mode 100644
index 0000000..8d66ef1
--- /dev/null
+++ b/src/Output/JsonEachRow.php
@@ -0,0 +1,26 @@
+> */
+ public $data;
+
+ public function __construct(string $contentsJson)
+ {
+ /**
+ * @var array> $contents
+ * @psalm-suppress ImpureFunctionCall
+ */
+ $contents = json_decode(sprintf('[%s]', str_replace("}\n{", '},{', $contentsJson)), true);
+ $this->data = $contents;
+ }
+}
diff --git a/src/Output/Output.php b/src/Output/Output.php
new file mode 100644
index 0000000..ec97e0a
--- /dev/null
+++ b/src/Output/Output.php
@@ -0,0 +1,10 @@
+contents = $contents;
+ }
+}
diff --git a/src/Snippet/CurrentDatabase.php b/src/Snippet/CurrentDatabase.php
new file mode 100644
index 0000000..698bb2e
--- /dev/null
+++ b/src/Snippet/CurrentDatabase.php
@@ -0,0 +1,28 @@
+select(
+ <<data[0]['database'];
+ assert(is_string($databaseName));
+
+ return $databaseName;
+ }
+}
diff --git a/src/Snippet/DatabaseSize.php b/src/Snippet/DatabaseSize.php
new file mode 100644
index 0000000..75d080b
--- /dev/null
+++ b/src/Snippet/DatabaseSize.php
@@ -0,0 +1,33 @@
+selectWithParameters(
+ << $databaseName ?? Expression::new('currentDatabase()')],
+ new JsonEachRow()
+ );
+
+ /** @psalm-suppress MixedAssignment */
+ $size = $currentDatabase->data[0]['size'];
+
+ assert($size !== null);
+
+ return (int) $size;
+ }
+}
diff --git a/src/Snippet/Parts.php b/src/Snippet/Parts.php
new file mode 100644
index 0000000..f9163f7
--- /dev/null
+++ b/src/Snippet/Parts.php
@@ -0,0 +1,31 @@
+> */
+ public static function run(ClickHouseClient $clickHouseClient, string $table, ?bool $active = null) : array
+ {
+ $whereActiveClause = $active === null ? '' : sprintf(' AND active = %s', (int) $active);
+
+ $currentDatabase = $clickHouseClient->selectWithParameters(
+ << $table],
+ new JsonEachRow()
+ );
+
+ return $currentDatabase->data;
+ }
+}
diff --git a/src/Snippet/ShowCreateTable.php b/src/Snippet/ShowCreateTable.php
new file mode 100644
index 0000000..6014dfe
--- /dev/null
+++ b/src/Snippet/ShowCreateTable.php
@@ -0,0 +1,24 @@
+select(
+ <<contents);
+ }
+}
diff --git a/src/Snippet/ShowDatabases.php b/src/Snippet/ShowDatabases.php
new file mode 100644
index 0000000..619eae3
--- /dev/null
+++ b/src/Snippet/ShowDatabases.php
@@ -0,0 +1,35 @@
+ */
+ public static function run(ClickHouseClient $clickHouseClient) : array
+ {
+ $output = $clickHouseClient->select(
+ <<data
+ );
+ }
+}
diff --git a/src/Snippet/TableSizes.php b/src/Snippet/TableSizes.php
new file mode 100644
index 0000000..743f56a
--- /dev/null
+++ b/src/Snippet/TableSizes.php
@@ -0,0 +1,45 @@
+> */
+ public static function run(ClickHouseClient $clickHouseClient, ?string $databaseName = null) : array
+ {
+ $currentDatabase = $clickHouseClient->selectWithParameters(
+ << ''
+GROUP BY table, database
+CLICKHOUSE,
+ ['database' => $databaseName ?? Expression::new('currentDatabase()')],
+ new JsonEachRow()
+ );
+
+ return $currentDatabase->data;
+ }
+}
diff --git a/src/Sql/Escaper.php b/src/Sql/Escaper.php
new file mode 100644
index 0000000..caa38e8
--- /dev/null
+++ b/src/Sql/Escaper.php
@@ -0,0 +1,27 @@
+innerExpression = $expression;
+ }
+
+ public static function new(string $expression) : self
+ {
+ return new self($expression);
+ }
+
+ public function __toString() : string
+ {
+ return $this->innerExpression;
+ }
+}
diff --git a/src/Sql/ExpressionFactory.php b/src/Sql/ExpressionFactory.php
new file mode 100644
index 0000000..7251640
--- /dev/null
+++ b/src/Sql/ExpressionFactory.php
@@ -0,0 +1,27 @@
+valueFormatter = $valueFormatter;
+ }
+
+ /** @param mixed ...$values */
+ public function templateAndValues(string $template, ...$values) : Expression
+ {
+ return Expression::new(
+ sprintf($template, ...array_map([$this->valueFormatter, 'format'], $values))
+ );
+ }
+}
diff --git a/src/Sql/SqlFactory.php b/src/Sql/SqlFactory.php
new file mode 100644
index 0000000..e4a8228
--- /dev/null
+++ b/src/Sql/SqlFactory.php
@@ -0,0 +1,31 @@
+valueFormatter = $valueFormatter;
+ }
+
+ /** @param array $parameters */
+ public function createWithParameters(string $query, array $parameters) : string
+ {
+ /** @var mixed $value */
+ foreach ($parameters as $name => $value) {
+ $query = preg_replace(sprintf('~:%s(?!\w)~', $name), $this->valueFormatter->format($value, $name, $query), $query);
+ }
+
+ return $query;
+ }
+}
diff --git a/src/Sql/ValueFormatter.php b/src/Sql/ValueFormatter.php
new file mode 100644
index 0000000..7e74299
--- /dev/null
+++ b/src/Sql/ValueFormatter.php
@@ -0,0 +1,140 @@
+dateTimeZone = $dateTimeZone;
+ }
+
+ /** @param mixed $value */
+ public function format($value, ?string $paramName = null, ?string $sql = null) : string
+ {
+ if (is_string($value)) {
+ return "'" . Escaper::escape($value) . "'";
+ }
+
+ if (is_int($value)) {
+ return (string) $value;
+ }
+
+ if (is_bool($value)) {
+ return (string) $value;
+ }
+
+ if (is_float($value)) {
+ return (string) $value;
+ }
+
+ if ($value === null) {
+ return 'IS NULL';
+ }
+
+ if ($value instanceof DateTimeImmutable) {
+ if ($this->dateTimeZone !== null) {
+ $value = $value->setTimezone($this->dateTimeZone);
+ }
+
+ return "'" . $value->format('Y-m-d H:i:s') . "'";
+ }
+
+ if ($value instanceof Expression) {
+ return (string) $value;
+ }
+
+ if (is_object($value) && method_exists($value, '__toString')) {
+ return "'" . $value . "'";
+ }
+
+ if (is_array($value)) {
+ if ($paramName !== null && $sql !== null
+ && preg_match(sprintf('~\s+IN\s+\\(:%s\\)~', $paramName), $sql) === 1
+ ) {
+ return implode(
+ ',',
+ array_map(
+ function ($value) : string {
+ if ($value === null) {
+ return 'NULL';
+ }
+
+ return $this->format($value);
+ },
+ $value
+ )
+ );
+ }
+
+ return $this->formatArray($value);
+ }
+
+ throw UnsupportedValueType::value($value);
+ }
+
+ /**
+ * @param array $values
+ *
+ * @return array
+ */
+ public function mapFormat(array $values) : array
+ {
+ return array_map(
+ function ($value) : string {
+ if ($value === null) {
+ return 'NULL';
+ }
+
+ return $this->format($value);
+ },
+ $values
+ );
+ }
+
+ /** @param array $value */
+ private function formatArray(array $value) : string
+ {
+ return sprintf(
+ '[%s]',
+ implode(
+ ',',
+ array_map(
+ function ($value) : string {
+ if ($value === null) {
+ return 'NULL';
+ }
+
+ if (is_array($value)) {
+ return $this->formatArray($value);
+ }
+
+ return $this->format($value);
+ },
+ $value
+ )
+ )
+ );
+ }
+}
diff --git a/tests/Client/InsertTest.php b/tests/Client/InsertTest.php
new file mode 100644
index 0000000..05ef45f
--- /dev/null
+++ b/tests/Client/InsertTest.php
@@ -0,0 +1,171 @@
+ 5, 'UserID' => 4324182021466249494, 'Duration' => 146, 'Sign' => -1],
+ ['PageViews' => 6, 'UserID' => 4324182021466249494, 'Duration' => 185, 'Sign' => 1],
+ ];
+
+ $this->client->executeQuery($tableSql);
+
+ $this->client->insert('UserActivity', $data);
+
+ $output = $this->client->select(
+ <<data);
+ }
+
+ /** @dataProvider providerInsert */
+ public function testInsertUseColumns(string $tableSql) : void
+ {
+ $expectedData = [
+ ['PageViews' => 5, 'UserID' => '4324182021466249494', 'Duration' => 146, 'Sign' => -1],
+ ['PageViews' => 6, 'UserID' => '4324182021466249494', 'Duration' => 185, 'Sign' => 1],
+ ];
+
+ $this->client->executeQuery($tableSql);
+
+ $this->client->insert(
+ 'UserActivity',
+ [
+ [5, 4324182021466249494, 146, -1],
+ [6, 4324182021466249494, 185, 1],
+ ],
+ ['PageViews', 'UserID', 'Duration', 'Sign']
+ );
+
+ $output = $this->client->select(
+ <<data);
+ }
+
+ public function testInsertEscaping() : void
+ {
+ $this->client->executeQuery(
+ <<client->insert('a', $expectedData);
+
+ $output = $this->client->select(
+ <<data);
+ }
+
+ /** @return iterable> */
+ public function providerInsert() : iterable
+ {
+ $sql = <<client->executeQuery(
+ <<client->insertWithFormat(
+ 'UserActivity',
+ new JsonEachRow(),
+ <<client->select(
+ << 5, 'UserID' => '4324182021466249494', 'Duration' => 146, 'Sign' => -1],
+ ['PageViews' => 6, 'UserID' => '4324182021466249494', 'Duration' => 185, 'Sign' => 1],
+ ],
+ $output->data
+ );
+ }
+
+ public function testInsertEmptyValuesThrowsException() : void
+ {
+ $this->expectException(CannotInsert::class);
+
+ $this->client->insert('table', []);
+ }
+
+ public function testInsertToNonExistentTableExpectServerError() : void
+ {
+ $this->expectException(ServerError::class);
+
+ $this->client->insert('table', [[1]]);
+ }
+}
diff --git a/tests/Client/SelectAsyncTest.php b/tests/Client/SelectAsyncTest.php
new file mode 100644
index 0000000..ce534e4
--- /dev/null
+++ b/tests/Client/SelectAsyncTest.php
@@ -0,0 +1,49 @@
+asyncClient;
+
+ $sql = <<select($sql, new JsonEachRow());
+ $promises[] = $client->select($sql, new JsonEachRow());
+
+ /** @var array<\SimPod\ClickHouseClient\Output\JsonEachRow> $jsonEachRowOutputs */
+ $jsonEachRowOutputs = all($promises)->wait();
+
+ $expectedData = [
+ ['number' => '0'],
+ ['number' => '1'],
+ ];
+
+ self::assertCount(2, $jsonEachRowOutputs);
+ self::assertSame($expectedData, $jsonEachRowOutputs[0]->data);
+ self::assertSame($expectedData, $jsonEachRowOutputs[1]->data);
+ }
+
+ public function testSelectFromNonExistentTableExpectServerError() : void
+ {
+ $this->expectException(ServerError::class);
+
+ $this->asyncClient->select('table', new TabSeparated())->wait();
+ }
+}
diff --git a/tests/Client/SelectTest.php b/tests/Client/SelectTest.php
new file mode 100644
index 0000000..4fa7465
--- /dev/null
+++ b/tests/Client/SelectTest.php
@@ -0,0 +1,145 @@
+client;
+ $output = $client->select($sql, new Json());
+
+ self::assertSame($expectedData, $output->data);
+ }
+
+ /** @return iterable */
+ public function providerJson() : iterable
+ {
+ yield [
+ [[1 => 1]],
+ << '0'],
+ ['number' => '1'],
+ ],
+ << 'ping'],
+ ],
+ <<client;
+ $output = $client->select($sql, new JsonCompact());
+
+ self::assertSame($expectedData, $output->data);
+ }
+
+ /** @return iterable */
+ public function providerJsonCompact() : iterable
+ {
+ yield [
+ [[1]],
+ <<client;
+ $output = $client->select($sql, new JsonEachRow());
+
+ self::assertSame($expectedData, $output->data);
+ }
+
+ /** @return iterable */
+ public function providerJsonEachRow() : iterable
+ {
+ yield [
+ [[1 => 1]],
+ << '0'],
+ ['number' => '1'],
+ ],
+ << 'ping'],
+ ],
+ <<getMessage());
+ }
+
+ /** @return iterable */
+ public function providerValue() : iterable
+ {
+ yield [
+ 'Value of type "resource" is not supported as a parameter',
+ opendir(__DIR__),
+ ];
+
+ yield [
+ 'Value of type "stdClass" is not supported as a parameter',
+ new stdClass(),
+ ];
+
+ yield [
+ 'Value of type "Safe\DateTime" is not supported as a parameter',
+ new DateTime(),
+ ];
+ }
+}
diff --git a/tests/Output/JsonCompactTest.php b/tests/Output/JsonCompactTest.php
new file mode 100644
index 0000000..54ab95e
--- /dev/null
+++ b/tests/Output/JsonCompactTest.php
@@ -0,0 +1,68 @@
+rows);
+ self::assertSame(2, $format->rowsBeforeLimitAtLeast);
+ self::assertSame(
+ [
+ [
+ 'name' => 'number',
+ 'type' => 'UInt64',
+ ],
+ ],
+ $format->meta
+ );
+ self::assertSame([['0'], ['1']], $format->data);
+ self::assertSame(
+ [
+ 'elapsed' => 5.04E-5,
+ 'rows_read' => 2,
+ 'bytes_read' => 16,
+ ],
+ $format->statistics
+ );
+ }
+}
diff --git a/tests/Output/JsonTest.php b/tests/Output/JsonTest.php
new file mode 100644
index 0000000..aca8c35
--- /dev/null
+++ b/tests/Output/JsonTest.php
@@ -0,0 +1,72 @@
+rows);
+ self::assertSame(2, $format->rowsBeforeLimitAtLeast);
+ self::assertSame(
+ [
+ [
+ 'name' => 'number',
+ 'type' => 'UInt64',
+ ],
+ ],
+ $format->meta
+ );
+ self::assertSame([['number' => '0'], ['number' => '1']], $format->data);
+ self::assertSame(
+ [
+ 'elapsed' => 3.42E-5,
+ 'rows_read' => 2,
+ 'bytes_read' => 16,
+ ],
+ $format->statistics
+ );
+ }
+}
diff --git a/tests/Output/TabSeparatedTest.php b/tests/Output/TabSeparatedTest.php
new file mode 100644
index 0000000..0da4138
--- /dev/null
+++ b/tests/Output/TabSeparatedTest.php
@@ -0,0 +1,21 @@
+contents);
+ }
+}
diff --git a/tests/Snippet/CurrentDatabaseTest.php b/tests/Snippet/CurrentDatabaseTest.php
new file mode 100644
index 0000000..4125c7e
--- /dev/null
+++ b/tests/Snippet/CurrentDatabaseTest.php
@@ -0,0 +1,23 @@
+currentDbName,
+ CurrentDatabase::run($this->client)
+ );
+ }
+}
diff --git a/tests/Snippet/DatabaseSizeTest.php b/tests/Snippet/DatabaseSizeTest.php
new file mode 100644
index 0000000..16af3e6
--- /dev/null
+++ b/tests/Snippet/DatabaseSizeTest.php
@@ -0,0 +1,45 @@
+client->executeQuery(
+ <<client));
+
+ $this->client->insert('test', [[new DateTimeImmutable(), 1]]);
+
+ self::assertSame(166, DatabaseSize::run($this->client));
+ }
+
+ public function tearDown() : void
+ {
+ $this->client->executeQuery('DROP TABLE test');
+ }
+}
diff --git a/tests/Snippet/PartsTest.php b/tests/Snippet/PartsTest.php
new file mode 100644
index 0000000..4f700ce
--- /dev/null
+++ b/tests/Snippet/PartsTest.php
@@ -0,0 +1,20 @@
+client, 'system.query_log'));
+ }
+}
diff --git a/tests/Snippet/ShowCreateTableTest.php b/tests/Snippet/ShowCreateTableTest.php
new file mode 100644
index 0000000..7411e07
--- /dev/null
+++ b/tests/Snippet/ShowCreateTableTest.php
@@ -0,0 +1,28 @@
+currentDbName;
+ $sql = <<client->executeQuery($sql);
+
+ $createTableSql = ShowCreateTable::run($this->client, 'test');
+ self::assertSame($sql, $createTableSql);
+ }
+}
diff --git a/tests/Snippet/ShowDatabasesTest.php b/tests/Snippet/ShowDatabasesTest.php
new file mode 100644
index 0000000..84bc605
--- /dev/null
+++ b/tests/Snippet/ShowDatabasesTest.php
@@ -0,0 +1,35 @@
+client);
+ self::assertGreaterThan(2, count($databases)); // Default, system, at least one test database
+
+ $databases = array_filter(
+ $databases,
+ function (string $database) : bool {
+ // Filter out zombie test databases
+ return strpos($database, 'clickhouse_client_test__') !== 0 || $database === $this->currentDbName;
+ }
+ );
+
+ self::assertSame([$this->currentDbName, 'default', 'system'], array_values($databases));
+ }
+}
diff --git a/tests/Snippet/TableSizesTest.php b/tests/Snippet/TableSizesTest.php
new file mode 100644
index 0000000..464145b
--- /dev/null
+++ b/tests/Snippet/TableSizesTest.php
@@ -0,0 +1,20 @@
+client));
+ }
+}
diff --git a/tests/Sql/EscaperTest.php b/tests/Sql/EscaperTest.php
new file mode 100644
index 0000000..67f5657
--- /dev/null
+++ b/tests/Sql/EscaperTest.php
@@ -0,0 +1,24 @@
+ $values
+ *
+ * @dataProvider providerTemplateAndValues
+ */
+ public function testTemplateAndValues(string $expectedExpressionString, string $template, array $values) : void
+ {
+ $expressionFactory = new ExpressionFactory(new ValueFormatter(new DateTimeZone('UTC')));
+
+ self::assertSame(
+ $expectedExpressionString,
+ (string) $expressionFactory->templateAndValues($template, ...$values)
+ );
+ }
+
+ /** @return iterable}> */
+ public function providerTemplateAndValues() : iterable
+ {
+ yield [
+ "UUIDStringToNum('6d38d288-5b13-4714-b6e4-faa59ffd49d8')",
+ 'UUIDStringToNum(%s)',
+ ['6d38d288-5b13-4714-b6e4-faa59ffd49d8'],
+ ];
+
+ yield [
+ 'power(2,3)',
+ 'power(%d,%d)',
+ [2, 3],
+ ];
+ }
+}
diff --git a/tests/Sql/ExpressionTest.php b/tests/Sql/ExpressionTest.php
new file mode 100644
index 0000000..7191cfd
--- /dev/null
+++ b/tests/Sql/ExpressionTest.php
@@ -0,0 +1,20 @@
+ $parameters
+ *
+ * @dataProvider providerCreateWithParameters
+ */
+ public function testCreateWithParameters(string $expectedSql, string $sqlWithPlaceholders, array $parameters) : void
+ {
+ $sql = (new SqlFactory(new ValueFormatter()))->createWithParameters($sqlWithPlaceholders, $parameters);
+
+ self::assertSame($expectedSql, $sql);
+ }
+
+ /** @return iterable}> */
+ public function providerCreateWithParameters() : iterable
+ {
+ yield 'empty parameters' => [
+ << [
+ << 'ping'],
+ ];
+
+ yield 'two parameters, 1. name substring of 2.' => [
+ << 1,
+ 'pingpong' => 2,
+ ],
+ ];
+ }
+}
diff --git a/tests/Sql/ValueFormatterTest.php b/tests/Sql/ValueFormatterTest.php
new file mode 100644
index 0000000..1ad9a13
--- /dev/null
+++ b/tests/Sql/ValueFormatterTest.php
@@ -0,0 +1,90 @@
+format($value, $paramName, $sql)
+ );
+ }
+
+ /** @return iterable> */
+ public function providerFormat() : iterable
+ {
+ yield 'boolean' => ['1', true];
+ yield 'integer' => ['1', 1];
+ yield 'float .0' => ['1', 1.0];
+ yield 'float .5' => ['1.5', 1.5];
+ yield 'string' => ["'ping'", 'ping'];
+ yield 'null' => ['IS NULL', null];
+ yield 'array' => ["['a','b','c']", ['a', 'b', 'c']];
+ yield 'array in array' => ["[['a']]", [['a']]];
+ yield 'array with null' => ['[NULL]', [null]];
+ yield 'array for IN' => ["'ping',1,NULL", ['ping', 1, null], 'list', 'SELECT * FROM table WHERE a IN (:list)'];
+ yield 'no array for IN without sql' => ["['ping',1,NULL]", ['ping', 1, null], 'list'];
+ yield 'DateTimeImmutable' => ["'2020-01-31 01:23:45'", new DateTimeImmutable('2020-01-31 01:23:45')];
+ yield 'DateTimeImmutable different PHP and ClickHouse timezones' => [
+ "'2020-01-31 01:23:45'",
+ new DateTimeImmutable('2020-01-31 02:23:45', new DateTimeZone('Europe/Prague')),
+ ];
+
+ yield 'Expression' => [
+ "UUIDStringToNum('6d38d288-5b13-4714-b6e4-faa59ffd49d8')",
+ Expression::new("UUIDStringToNum('6d38d288-5b13-4714-b6e4-faa59ffd49d8')"),
+ ];
+
+ yield 'Stringable' => [
+ "'stringable'",
+ new class() {
+ public function __toString() : string
+ {
+ return 'stringable';
+ }
+ },
+ ];
+ }
+
+ /**
+ * @param array $expectedValues
+ * @param array $values
+ *
+ * @dataProvider providerMapFormat
+ */
+ public function testMapFormat(array $expectedValues, array $values) : void
+ {
+ self::assertSame($expectedValues, (new ValueFormatter())->mapFormat($values));
+ }
+
+ /** @return iterable>> */
+ public function providerMapFormat() : iterable
+ {
+ yield 'string' => [["'ping'", "'pong'", 'NULL'], ['ping', 'pong', null]];
+ }
+
+ public function testUnsupportedTypeThrows() : void
+ {
+ $this->expectException(UnsupportedValueType::class);
+
+ (new ValueFormatter())->format(new stdClass());
+ }
+}
diff --git a/tests/TestCaseBase.php b/tests/TestCaseBase.php
new file mode 100644
index 0000000..39f910b
--- /dev/null
+++ b/tests/TestCaseBase.php
@@ -0,0 +1,11 @@
+restartClickHouseClient();
+ }
+
+ public function restartClickHouseClient() : void
+ {
+ $databaseName = getenv('CLICKHOUSE_DATABASE');
+ $username = getenv('CLICKHOUSE_USER');
+ $endpoint = getenv('CLICKHOUSE_HOST');
+ $password = getenv('CLICKHOUSE_PASSWORD');
+
+ assert(is_string($databaseName));
+ assert(is_string($username));
+ assert(is_string($endpoint));
+ assert(is_string($password));
+
+ $this->currentDbName = 'clickhouse_client_test__' . time();
+
+ $defaultParameters = [
+ 'database' => $databaseName,
+ 'user' => $username,
+ 'password' => $password,
+ ];
+
+ $this->controllerClient = new PsrClickHouseClient(
+ new Client(),
+ new RequestFactory(
+ new Psr17Factory(),
+ new Psr17Factory(),
+ new Psr17Factory()
+ ),
+ new NullLogger(),
+ $endpoint,
+ $defaultParameters
+ );
+
+ $defaultParameters['database'] = $this->currentDbName;
+
+ $this->client = new PsrClickHouseClient(
+ new Client(),
+ new RequestFactory(
+ new Psr17Factory(),
+ new Psr17Factory(),
+ new Psr17Factory()
+ ),
+ new TestLogger(),
+ $endpoint,
+ $defaultParameters
+ );
+
+ $this->asyncClient = new PsrClickHouseAsyncClient(
+ new Client(),
+ new RequestFactory(
+ new Psr17Factory(),
+ new Psr17Factory(),
+ new Psr17Factory()
+ ),
+ new TestLogger(),
+ $endpoint,
+ $defaultParameters
+ );
+
+ $this->controllerClient->executeQuery(sprintf('DROP DATABASE IF EXISTS "%s"', $this->currentDbName));
+ $this->controllerClient->executeQuery(sprintf('CREATE DATABASE "%s"', $this->currentDbName));
+ }
+
+ /** @after */
+ public function tearDownDataBase() : void
+ {
+ $this->controllerClient->executeQuery(sprintf('DROP DATABASE IF EXISTS "%s"', $this->currentDbName));
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..25157db
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,14 @@
+