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 @@ +