diff --git a/Dockerfile b/Dockerfile index 7788050..95bf73a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ COPY composer.json /src/ RUN composer install --ignore-platform-reqs --optimize-autoloader \ --no-plugins --no-scripts --prefer-dist -FROM php:8.3.3-cli-alpine3.19 AS final +FROM php:8.4.21-cli-alpine3.23 AS final LABEL maintainer="team@appwrite.io" diff --git a/composer.json b/composer.json index 489c486..bd3fa22 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,8 @@ "audit" ], "license": "MIT", - "minimum-stability": "stable", + "minimum-stability": "dev", + "prefer-stable": true, "scripts": { "lint": "./vendor/bin/pint --test", "format": "./vendor/bin/pint", @@ -28,14 +29,15 @@ }, "require": { "php": ">=8.4", + "psr/http-client": "^1.0", + "utopia-php/client": "^0.1|^0.2", "utopia-php/database": "^6.0.0", - "utopia-php/fetch": "^1.1", - "utopia-php/query": "0.1.*", + "utopia-php/query": "0.3.*", "utopia-php/validators": "0.2.*" }, "require-dev": { "phpunit/phpunit": "9.*", - "phpstan/phpstan": "1.*", + "phpstan/phpstan": "^2.0", "laravel/pint": "1.*" }, "config": { diff --git a/composer.lock b/composer.lock index 2f3fd1e..f52a867 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5f62a9d29f55388576d7c758775b3593", + "content-hash": "6065b4b4d72c773b4b686885aedb9a1e", "packages": [ { "name": "brick/math", @@ -1315,16 +1315,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -1362,7 +1362,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -1382,20 +1382,20 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/http-client", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "e8a112b8415707265a7e614278136a9d92989a6a" + "reference": "f6bc6b5a54ff5afac4725cacec9bf2f52eb15920" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/e8a112b8415707265a7e614278136a9d92989a6a", - "reference": "e8a112b8415707265a7e614278136a9d92989a6a", + "url": "https://api.github.com/repos/symfony/http-client/zipball/f6bc6b5a54ff5afac4725cacec9bf2f52eb15920", + "reference": "f6bc6b5a54ff5afac4725cacec9bf2f52eb15920", "shasum": "" }, "require": { @@ -1463,7 +1463,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.13" + "source": "https://github.com/symfony/http-client/tree/v7.4.14" }, "funding": [ { @@ -1483,20 +1483,20 @@ "type": "tidelift" } ], - "time": "2026-05-24T09:57:54+00:00" + "time": "2026-06-16T11:50:14+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", - "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", "shasum": "" }, "require": { @@ -1545,7 +1545,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" }, "funding": [ { @@ -1565,7 +1565,7 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:17:50+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/polyfill-mbstring", @@ -1894,16 +1894,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { @@ -1957,7 +1957,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -1977,7 +1977,7 @@ "type": "tidelift" } ], - "time": "2026-03-28T09:44:51+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { "name": "tbachert/spi", @@ -2033,16 +2033,16 @@ }, { "name": "utopia-php/cache", - "version": "3.0.2", + "version": "3.4.0", "source": { "type": "git", "url": "https://github.com/utopia-php/cache.git", - "reference": "086687d7ae23dd1dae67b943161e8cef143539e1" + "reference": "5c292d4df156f8b008f29fa6b033834b906bf328" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cache/zipball/086687d7ae23dd1dae67b943161e8cef143539e1", - "reference": "086687d7ae23dd1dae67b943161e8cef143539e1", + "url": "https://api.github.com/repos/utopia-php/cache/zipball/5c292d4df156f8b008f29fa6b033834b906bf328", + "reference": "5c292d4df156f8b008f29fa6b033834b906bf328", "shasum": "" }, "require": { @@ -2081,9 +2081,9 @@ ], "support": { "issues": "https://github.com/utopia-php/cache/issues", - "source": "https://github.com/utopia-php/cache/tree/3.0.2" + "source": "https://github.com/utopia-php/cache/tree/3.4.0" }, - "time": "2026-05-19T22:38:16+00:00" + "time": "2026-07-01T15:44:35+00:00" }, { "name": "utopia-php/circuit-breaker", @@ -2147,6 +2147,62 @@ }, "time": "2026-05-29T12:12:23+00:00" }, + { + "name": "utopia-php/client", + "version": "0.2.1", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/client.git", + "reference": "18d2092637b1930e00a64383e55f91fdfa9fd436" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/client/zipball/18d2092637b1930e00a64383e55f91fdfa9fd436", + "reference": "18d2092637b1930e00a64383e55f91fdfa9fd436", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "utopia-php/pools": "^1.0", + "utopia-php/span": "^1.1 || ^3.0 || ^4.0" + }, + "require-dev": { + "swoole/ide-helper": "^6.0" + }, + "suggest": { + "ext-curl": "Required to use the cURL HTTP client adapter.", + "ext-simplexml": "Required to decode XML responses with Response::xml().", + "ext-swoole": "Required to use the Swoole coroutine HTTP client adapter." + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A lightweight PSR-18 HTTP client with cURL and Swoole coroutine backends", + "keywords": [ + "client", + "curl", + "http", + "php", + "psr-18", + "swoole", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/client/issues", + "source": "https://github.com/utopia-php/client/tree/0.2.1" + }, + "time": "2026-06-26T10:48:18+00:00" + }, { "name": "utopia-php/console", "version": "0.1.1", @@ -2197,16 +2253,16 @@ }, { "name": "utopia-php/database", - "version": "6.0.0", + "version": "6.4.1", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "fff9f0effbd40359ff925741ff9424856d8b4fde" + "reference": "efca1a633b8846ec26e376cc85491a62fdb2d033" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/fff9f0effbd40359ff925741ff9424856d8b4fde", - "reference": "fff9f0effbd40359ff925741ff9424856d8b4fde", + "url": "https://api.github.com/repos/utopia-php/database/zipball/efca1a633b8846ec26e376cc85491a62fdb2d033", + "reference": "efca1a633b8846ec26e376cc85491a62fdb2d033", "shasum": "" }, "require": { @@ -2215,7 +2271,7 @@ "ext-pdo": "*", "ext-redis": "*", "php": ">=8.4", - "utopia-php/cache": "^3.0", + "utopia-php/cache": "^3.1.0", "utopia-php/console": "0.1.*", "utopia-php/mongo": "1.*", "utopia-php/pools": "1.*", @@ -2251,49 +2307,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/6.0.0" + "source": "https://github.com/utopia-php/database/tree/6.4.1" }, - "time": "2026-06-18T10:37:40+00:00" - }, - { - "name": "utopia-php/fetch", - "version": "1.1.2", - "source": { - "type": "git", - "url": "https://github.com/utopia-php/fetch.git", - "reference": "64f2b3a789480f1deb102ce684dac4217d8e98d5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/utopia-php/fetch/zipball/64f2b3a789480f1deb102ce684dac4217d8e98d5", - "reference": "64f2b3a789480f1deb102ce684dac4217d8e98d5", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "require-dev": { - "laravel/pint": "^1.5.0", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^9.5", - "swoole/ide-helper": "^6.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Utopia\\Fetch\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A simple library that provides an interface for making HTTP Requests.", - "support": { - "issues": "https://github.com/utopia-php/fetch/issues", - "source": "https://github.com/utopia-php/fetch/tree/1.1.2" - }, - "time": "2026-04-29T11:19:19+00:00" + "time": "2026-07-02T05:55:38+00:00" }, { "name": "utopia-php/mongo", @@ -2358,16 +2374,16 @@ }, { "name": "utopia-php/pools", - "version": "1.0.5", + "version": "1.0.9", "source": { "type": "git", "url": "https://github.com/utopia-php/pools.git", - "reference": "e66e498d59cf3ac48c1f4c70be46f7eb6df7a63b" + "reference": "13346168ca7097d9b220872a017c9ad8c5dac62f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/pools/zipball/e66e498d59cf3ac48c1f4c70be46f7eb6df7a63b", - "reference": "e66e498d59cf3ac48c1f4c70be46f7eb6df7a63b", + "url": "https://api.github.com/repos/utopia-php/pools/zipball/13346168ca7097d9b220872a017c9ad8c5dac62f", + "reference": "13346168ca7097d9b220872a017c9ad8c5dac62f", "shasum": "" }, "require": { @@ -2405,30 +2421,33 @@ ], "support": { "issues": "https://github.com/utopia-php/pools/issues", - "source": "https://github.com/utopia-php/pools/tree/1.0.5" + "source": "https://github.com/utopia-php/pools/tree/1.0.9" }, - "time": "2026-06-10T15:14:31+00:00" + "time": "2026-06-26T10:48:18+00:00" }, { "name": "utopia-php/query", - "version": "0.1.1", + "version": "0.3.3", "source": { "type": "git", "url": "https://github.com/utopia-php/query.git", - "reference": "964a10ed3185490505f4c0062f2eb7b89287fb27" + "reference": "76cd71071cd59cb45ef6b30eb11449f3832bf18f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/query/zipball/964a10ed3185490505f4c0062f2eb7b89287fb27", - "reference": "964a10ed3185490505f4c0062f2eb7b89287fb27", + "url": "https://api.github.com/repos/utopia-php/query/zipball/76cd71071cd59cb45ef6b30eb11449f3832bf18f", + "reference": "76cd71071cd59cb45ef6b30eb11449f3832bf18f", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { + "brianium/paratest": "*", "laravel/pint": "*", + "mongodb/mongodb": "^2.0", "phpstan/phpstan": "*", + "phpunit/phpcov": "*", "phpunit/phpunit": "^12.0" }, "type": "library", @@ -2451,22 +2470,62 @@ ], "support": { "issues": "https://github.com/utopia-php/query/issues", - "source": "https://github.com/utopia-php/query/tree/0.1.1" + "source": "https://github.com/utopia-php/query/tree/0.3.3" + }, + "time": "2026-07-06T04:55:56+00:00" + }, + { + "name": "utopia-php/span", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/span.git", + "reference": "d11f2714324cb22b286b2afbf3ea9de32c68de83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/span/zipball/d11f2714324cb22b286b2afbf3ea9de32c68de83", + "reference": "d11f2714324cb22b286b2afbf3ea9de32c68de83", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "swoole/ide-helper": "^5.0" + }, + "suggest": { + "ext-swoole": "Required for coroutine-based storage" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Span\\": "src/Span/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Simple span tracing library for PHP with coroutine support", + "support": { + "issues": "https://github.com/utopia-php/span/issues", + "source": "https://github.com/utopia-php/span/tree/4.0.1" }, - "time": "2026-03-03T09:05:14+00:00" + "time": "2026-06-20T09:45:06+00:00" }, { "name": "utopia-php/telemetry", - "version": "0.4.1", + "version": "0.4.4", "source": { "type": "git", "url": "https://github.com/utopia-php/telemetry.git", - "reference": "028f3a85bb987b1e9357fb1632aa40f020d1b86e" + "reference": "9f77bd273df1ffde78c1ff7d1ac53f9f21dafdb4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/telemetry/zipball/028f3a85bb987b1e9357fb1632aa40f020d1b86e", - "reference": "028f3a85bb987b1e9357fb1632aa40f020d1b86e", + "url": "https://api.github.com/repos/utopia-php/telemetry/zipball/9f77bd273df1ffde78c1ff7d1ac53f9f21dafdb4", + "reference": "9f77bd273df1ffde78c1ff7d1ac53f9f21dafdb4", "shasum": "" }, "require": { @@ -2479,7 +2538,6 @@ "symfony/http-client": "7.*" }, "require-dev": { - "phpbench/phpbench": "1.*", "swoole/ide-helper": "6.*" }, "suggest": { @@ -2504,22 +2562,22 @@ ], "support": { "issues": "https://github.com/utopia-php/telemetry/issues", - "source": "https://github.com/utopia-php/telemetry/tree/0.4.1" + "source": "https://github.com/utopia-php/telemetry/tree/0.4.4" }, - "time": "2026-06-10T15:48:26+00:00" + "time": "2026-06-26T10:48:18+00:00" }, { "name": "utopia-php/validators", - "version": "0.2.6", + "version": "0.2.8", "source": { "type": "git", "url": "https://github.com/utopia-php/validators.git", - "reference": "f1ac4f08d6876daf117a1d9af53a4c7b752c2c0b" + "reference": "53c0cad30b4212627f5bce727af7b97d6ee05b5f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/validators/zipball/f1ac4f08d6876daf117a1d9af53a4c7b752c2c0b", - "reference": "f1ac4f08d6876daf117a1d9af53a4c7b752c2c0b", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/53c0cad30b4212627f5bce727af7b97d6ee05b5f", + "reference": "53c0cad30b4212627f5bce727af7b97d6ee05b5f", "shasum": "" }, "require": { @@ -2544,9 +2602,9 @@ ], "support": { "issues": "https://github.com/utopia-php/validators/issues", - "source": "https://github.com/utopia-php/validators/tree/0.2.6" + "source": "https://github.com/utopia-php/validators/tree/0.2.8" }, - "time": "2026-06-10T14:45:13+00:00" + "time": "2026-06-26T10:48:18+00:00" } ], "packages-dev": [ @@ -2924,15 +2982,15 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.32", + "version": "2.1.54", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/2770dcdf5078d0b0d53f94317e06affe88419aa8", - "reference": "2770dcdf5078d0b0d53f94317e06affe88419aa8", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", "shasum": "" }, "require": { - "php": "^7.2|^8.0" + "php": "^7.4|^8.0" }, "conflict": { "phpstan/phpstan-shim": "*" @@ -2973,7 +3031,7 @@ "type": "github" } ], - "time": "2025-09-30T10:16:31+00:00" + "time": "2026-04-29T13:31:09+00:00" }, { "name": "phpunit/php-code-coverage", @@ -4468,9 +4526,9 @@ } ], "aliases": [], - "minimum-stability": "stable", + "minimum-stability": "dev", "stability-flags": {}, - "prefer-stable": false, + "prefer-stable": true, "prefer-lowest": false, "platform": { "php": ">=8.4" diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index 0ff5880..95705be 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -3,10 +3,22 @@ namespace Utopia\Audit\Adapter; use Exception; +use Psr\Http\Client\ClientInterface; use Utopia\Audit\Log; use Utopia\Audit\Query; +use Utopia\Client; +use Utopia\Client\Adapter\Curl\Client as CurlAdapter; use Utopia\Database\Database; -use Utopia\Fetch\Client; +use Utopia\Psr7\Method as HttpMethod; +use Utopia\Psr7\Request\Factory as RequestFactory; +use Utopia\Query\Builder\ClickHouse as ClickHouseBuilder; +use Utopia\Query\Builder\ClickHouse\Format; +use Utopia\Query\Method; +use Utopia\Query\Query as BaseQuery; +use Utopia\Query\Schema\ClickHouse as ClickHouseSchema; +use Utopia\Query\Schema\ClickHouse\Engine as ClickHouseEngine; +use Utopia\Query\Schema\ClickHouse\IndexAlgorithm; +use Utopia\Query\Schema\ColumnType; use Utopia\Validator\Hostname; /** @@ -74,7 +86,9 @@ class ClickHouse extends SQL /** @var bool Whether to use HTTPS for ClickHouse HTTP interface */ private bool $secure = false; - private Client $client; + private readonly ClientInterface $client; + + private readonly RequestFactory $requestFactory; protected string $namespace = ''; @@ -90,6 +104,8 @@ class ClickHouse extends SQL * @param string $password ClickHouse password (default: '') * @param int $port ClickHouse HTTP port (default: 8123) * @param bool $secure Whether to use HTTPS (default: false) + * @param ClientInterface|null $client PSR-18 HTTP transport. Defaults to a + * cURL client with connection reuse enabled. * @throws Exception If validation fails */ public function __construct( @@ -97,7 +113,8 @@ public function __construct( string $username = 'default', string $password = '', int $port = self::DEFAULT_PORT, - bool $secure = false + bool $secure = false, + ?ClientInterface $client = null ) { $this->validateHost($host); $this->validatePort($port); @@ -108,11 +125,8 @@ public function __construct( $this->password = $password; $this->secure = $secure; - // Initialize the HTTP client for connection reuse - $this->client = new Client(); - $this->client->addHeader('X-ClickHouse-User', $this->username); - $this->client->addHeader('X-ClickHouse-Key', $this->password); - $this->client->setTimeout(30_000); // 30 seconds + $this->client = $client ?? new Client((new CurlAdapter())->withConnectionReuse()); + $this->requestFactory = new RequestFactory(); } /** @@ -138,7 +152,7 @@ public function ping(): bool $url = "{$scheme}://{$this->host}:{$this->port}/ping"; try { - $response = $this->client->fetch(url: $url, method: Client::METHOD_GET); + $response = $this->client->sendRequest($this->requestFactory->createRequest(HttpMethod::GET, $url)); } catch (\Throwable) { return false; } @@ -599,7 +613,55 @@ private function getTableName(): string } /** - * Execute a ClickHouse query via HTTP interface using Fetch Client. + * Build the column → ClickHouse type map registered on `Builder\ClickHouse` + * so positional `?` bindings are emitted as typed `{paramN:Type}` placeholders. + * + * Derived from `getAttributes()` so the map stays in sync with the schema — + * DateTime attributes get `DateTime64(3)`, everything else gets `String`. + * `id` is added explicitly because it lives outside `getAttributes()`, and + * `tenant` is added only when shared-tables mode is on. `limit`, `offset` + * and `max` are pseudo-columns used by the count/find SQL wrappers. + * + * @return array + */ + private function getColumnTypeMap(): array + { + $map = ['id' => 'String']; + + foreach ($this->getAttributes() as $attribute) { + /** @var string $id */ + $id = $attribute['$id']; + $map[$id] = ($attribute['type'] ?? null) === Database::VAR_DATETIME + ? 'DateTime64(3)' + : 'String'; + } + + if ($this->sharedTables) { + $map['tenant'] = 'UInt64'; + } + + $map['limit'] = 'UInt64'; + $map['offset'] = 'UInt64'; + $map['max'] = 'UInt64'; + + return $map; + } + + /** + * Build a `Builder\ClickHouse` instance with the adapter's column type map + * pre-registered. Every adapter call site that produces SQL goes through + * here so positional `?` bindings can be rewritten to typed `{paramN:Type}` + * placeholders at `Statement` time. + */ + private function newBuilder(): ClickHouseBuilder + { + return (new ClickHouseBuilder()) + ->useNamedBindings() + ->withParamTypes($this->getColumnTypeMap()); + } + + /** + * Execute a ClickHouse query via HTTP interface. * * This unified method supports two modes of operation: * @@ -608,68 +670,49 @@ private function getTableName(): string * Parameters are referenced in SQL using syntax: {paramName:Type} * Example: SELECT * WHERE id = {id:String} * - * 2. **JSON body queries** (when $jsonRows is provided): - * Uses JSONEachRow format for optimal INSERT performance. - * SQL is sent via URL query string, JSON data as POST body. - * Each row is a JSON object on a separate line. + * 2. **Pre-serialized body queries** (when $rawBody is provided): + * Used for FORMAT-style INSERT operations (e.g. JSONEachRow). + * SQL envelope is sent via URL query string and the body is sent + * verbatim as the POST body. The caller (typically the typed + * Builder\ClickHouse::bulkInsert() entry point) is responsible for + * serializing rows into the format ClickHouse expects. * * ClickHouse handles all parameter escaping and type conversion internally, * making both approaches fully injection-safe. * * @param string $sql The SQL query to execute * @param array $params Key-value pairs for query parameters (for SELECT/UPDATE/DELETE) - * @param array>|null $jsonRows Array of rows for JSONEachRow INSERT operations + * @param string|null $rawBody Pre-serialized request body for FORMAT INSERT operations * @return string Response body * @throws Exception */ - private function query(string $sql, array $params = [], ?array $jsonRows = null): string + private function query(string $sql, array $params = [], ?string $rawBody = null): string { $scheme = $this->secure ? 'https' : 'http'; - // Update the database header for each query (in case setDatabase was called) - $this->client->addHeader('X-ClickHouse-Database', $this->database); - try { - if ($jsonRows !== null) { - // JSON body mode for INSERT operations with JSONEachRow format + if ($rawBody !== null) { $url = "{$scheme}://{$this->host}:{$this->port}/?query=" . urlencode($sql); - - // Build JSONEachRow body - each row on a separate line - $jsonLines = []; - foreach ($jsonRows as $row) { - try { - $encoded = json_encode($row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - throw new Exception('Failed to encode row to JSON: ' . $e->getMessage()); - } - $jsonLines[] = $encoded; - } - $body = implode("\n", $jsonLines); + $request = $this->requestFactory->body(HttpMethod::POST, $url, $rawBody, 'application/x-ndjson', $this->buildHeaders()); } else { - // Parameterized query mode using multipart form data $url = "{$scheme}://{$this->host}:{$this->port}/"; - // Build multipart form data body with query and parameters - $body = ['query' => $sql]; + $parts = ['query' => $sql]; foreach ($params as $key => $value) { - $body['param_' . $key] = $this->formatParamValue($value); + $parts['param_' . $key] = $this->formatParamValue($value); } + + $request = $this->requestFactory->multipart(HttpMethod::POST, $url, $parts, $this->buildHeaders()); } - $response = $this->client->fetch( - url: $url, - method: Client::METHOD_POST, - body: $body - ); + $response = $this->client->sendRequest($request); + $responseBody = (string) $response->getBody(); if ($response->getStatusCode() !== 200) { - $responseBody = $response->getBody(); - $responseBody = is_string($responseBody) ? $responseBody : ''; throw new Exception("ClickHouse query failed with HTTP {$response->getStatusCode()}: {$responseBody}"); } - $responseBody = $response->getBody(); - return is_string($responseBody) ? $responseBody : ''; + return $responseBody; } catch (Exception $e) { throw new Exception( "ClickHouse query execution failed: {$e->getMessage()}", @@ -679,6 +722,20 @@ private function query(string $sql, array $params = [], ?array $jsonRows = null) } } + /** + * Build ClickHouse authentication and database headers. + * + * @return array + */ + private function buildHeaders(): array + { + return [ + 'X-ClickHouse-User' => $this->username, + 'X-ClickHouse-Key' => $this->password, + 'X-ClickHouse-Database' => $this->database, + ]; + } + /** * Format a parameter value for safe transmission to ClickHouse. * @@ -732,67 +789,78 @@ private function formatParamValue(mixed $value): string */ public function setup(): void { - // Create database if not exists $escapedDatabase = $this->escapeIdentifier($this->database); - $createDbSql = "CREATE DATABASE IF NOT EXISTS {$escapedDatabase}"; - $this->query($createDbSql); + $this->query("CREATE DATABASE IF NOT EXISTS {$escapedDatabase}"); - // Build column definitions from base adapter schema - // Override time column to be NOT NULL since it's used in partition key - $columns = [ - 'id String', - ]; + $schema = new ClickHouseSchema(); + $tableName = $this->getTableName(); + $qualifiedTable = $this->database . '.' . $tableName; + $table = $schema->table($qualifiedTable); + $table->string('id')->primary(); foreach ($this->getAttributes() as $attribute) { /** @var string $id */ $id = $attribute['$id']; - // Special handling for time column - must be NOT NULL for partition key if ($id === 'time') { - $columns[] = 'time DateTime64(3)'; - } else { - $columns[] = $this->getColumnDefinition($id); + $table->datetime('time', precision: 3); + + continue; + } + + $type = $this->mapAttributeType($attribute); + $column = $table->addColumn($id, $type); + if (\in_array($id, self::LOW_CARDINALITY_COLUMNS, true)) { + $column->lowCardinality(); + } + if (empty($attribute['required'])) { + $column->nullable(); } } - // Add tenant column only if tables are shared across tenants if ($this->sharedTables) { - $columns[] = 'tenant Nullable(UInt64)'; // Supports 11-digit MySQL auto-increment IDs + $table->bigInteger('tenant')->unsigned()->nullable(); } - // Build indexes from base adapter schema - $indexes = []; foreach ($this->getIndexes() as $index) { /** @var string $indexName */ $indexName = $index['$id']; /** @var array $attributes */ $attributes = $index['attributes']; - // Escape each attribute name to prevent SQL injection - $escapedAttributes = array_map(fn (string $attr) => $this->escapeIdentifier($attr), $attributes); - $attributeList = implode(', ', $escapedAttributes); - $indexes[] = "INDEX {$indexName} ({$attributeList}) TYPE bloom_filter GRANULARITY 1"; + $table->index( + columns: $attributes, + name: $indexName, + algorithm: IndexAlgorithm::BloomFilter, + granularity: 1, + ); } - $tableName = $this->getTableName(); - $escapedDatabaseAndTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); - - $orderByExpr = $this->sharedTables ? '(tenant, time, id)' : '(time, id)'; - - // Create table with MergeTree engine for optimal performance - $createTableSql = " - CREATE TABLE IF NOT EXISTS {$escapedDatabaseAndTable} ( - " . implode(",\n ", $columns) . ", - " . implode(",\n ", $indexes) . " - ) - ENGINE = MergeTree() - ORDER BY {$orderByExpr} - PARTITION BY toYYYYMM(time) - SETTINGS index_granularity = 8192" . ($this->sharedTables ? ', allow_nullable_key = 1' : '') . " - "; + $table->engine(ClickHouseEngine::MergeTree); + $table->orderBy($this->sharedTables ? ['tenant', 'time', 'id'] : ['time', 'id']); + $table->partitionBy('toYYYYMM(time)'); + + $settings = ['index_granularity' => '8192']; + if ($this->sharedTables) { + $settings['allow_nullable_key'] = '1'; + } + $table->settings($settings); + $createTableSql = $table->createIfNotExists()->query; $this->query($createTableSql); } + /** + * Map an audit attribute descriptor to its `Schema\ColumnType`. + * + * @param array $attribute + */ + private function mapAttributeType(array $attribute): ColumnType + { + return ($attribute['type'] ?? null) === Database::VAR_DATETIME + ? ColumnType::Datetime + : ColumnType::String; + } + /** * Get column names from attributes. * Returns an array of column names excluding 'id' and 'tenant' which are handled separately. @@ -931,19 +999,23 @@ public function create(array $log): Log public function getById(string $id): ?Log { $tableName = $this->getTableName(); + $qualifiedTable = $this->database . '.' . $tableName; + + $builder = $this->newBuilder() + ->from($qualifiedTable) + ->selectRaw($this->getSelectColumns()) + ->filter([Query::equal('id', $id)]) + ->limit(1); + $tenantFilter = $this->getTenantFilter(); - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); - $escapedId = $this->escapeIdentifier('id'); - - $sql = " - SELECT " . $this->getSelectColumns() . " - FROM {$escapedTable} - WHERE {$escapedId} = {id:String}{$tenantFilter} - LIMIT 1 - FORMAT JSON - "; - - $result = $this->query($sql, ['id' => $id]); + if ($tenantFilter !== '') { + $builder->whereRaw(ltrim($tenantFilter, ' AND')); + } + + $statement = $builder->build(); + $sql = $statement->query . ' FORMAT JSON'; + + $result = $this->query($sql, $statement->namedBindings ?? []); $logs = $this->parseJsonResults($result); return $logs[0] ?? null; @@ -959,9 +1031,8 @@ public function getById(string $id): ?Log public function find(array $queries = []): array { $tableName = $this->getTableName(); - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + $qualifiedTable = $this->database . '.' . $tableName; - // Parse queries $parsed = $this->parseQueries($queries); // Random ordering can't combine with anything that asks for a @@ -969,64 +1040,58 @@ public function find(array $queries = []): array // mixing column-based ORDER BY with rand() would silently drop the // column order. Reject loudly in both cases so the caller fixes the // query rather than getting unexpected results. - if (!empty($parsed['randomOrder']) && isset($parsed['cursor'])) { + if ($parsed['randomOrder'] && isset($parsed['cursor'])) { throw new Exception('Cursor pagination cannot be combined with orderRandom'); } - if (!empty($parsed['randomOrder']) && !empty($parsed['orderBy'])) { + if ($parsed['randomOrder'] && !empty($parsed['orderAttributes'])) { throw new Exception('orderRandom cannot be combined with orderAsc/orderDesc'); } - // Build SELECT clause — respect Query::select if provided, otherwise - // fall back to the full column list. $selectColumns = $this->buildProjection($parsed['select'] ?? null); - $filters = $parsed['filters']; - $params = $parsed['params']; - $orderAttributes = $parsed['orderAttributes'] ?? []; + $builder = $this->newBuilder() + ->from($qualifiedTable) + ->selectRaw($selectColumns) + ->filter($parsed['filters']); + + $tenantFilter = $this->getTenantFilter(); + if ($tenantFilter !== '') { + $builder->whereRaw(ltrim($tenantFilter, ' AND')); + } + $cursorDirection = $parsed['cursorDirection'] ?? null; + $orderAttributes = $parsed['orderAttributes']; + $cursorParams = []; if (isset($parsed['cursor'])) { $orderAttributes = $this->resolveCursorOrder($orderAttributes); - $cursorWhere = $this->buildCursorWhere($orderAttributes, $parsed['cursor'], $cursorDirection ?? 'after', $params); - $filters[] = $cursorWhere['clause']; - $params = $cursorWhere['params']; + $cursorWhere = $this->buildCursorWhere($orderAttributes, $parsed['cursor'], $cursorDirection ?? 'after', []); + $builder->whereRaw($cursorWhere['clause']); + $cursorParams = $cursorWhere['params']; } - // Build WHERE clause - $whereClause = ''; - $tenantFilter = $this->getTenantFilter(); - if (!empty($filters) || $tenantFilter) { - $conditions = $filters; - if ($tenantFilter) { - $conditions[] = ltrim($tenantFilter, ' AND'); - } - $whereClause = ' WHERE ' . implode(' AND ', $conditions); - } - - // Build ORDER BY clause. orderRandom is mutually exclusive with - // cursor and column ordering (rejected at the top of find()); when - // cursor is in play, rebuild from orderAttributes (always non-empty - // after resolveCursorOrder, which appends an id tiebreaker), - // flipping directions for `cursorBefore`. - $orderClause = ''; - if (!empty($parsed['randomOrder'])) { - $orderClause = ' ORDER BY rand()'; + // ORDER BY. orderRandom is mutually exclusive with cursor and column + // ordering (rejected above); when cursor is in play, rebuild from + // orderAttributes (always non-empty after resolveCursorOrder, which + // appends an id tiebreaker), flipping directions for `cursorBefore`. + if ($parsed['randomOrder']) { + $builder->sortRandom(); } elseif (isset($parsed['cursor'])) { - $orderSql = $this->buildOrderBySql($orderAttributes, flip: $cursorDirection === 'before'); - $orderClause = ' ORDER BY ' . implode(', ', $orderSql); - } elseif (!empty($parsed['orderBy'])) { - $orderClause = ' ORDER BY ' . implode(', ', $parsed['orderBy']); + $this->applyOrderBy($builder, $orderAttributes, flip: $cursorDirection === 'before'); + } else { + $this->applyOrderBy($builder, $orderAttributes); } - // Build LIMIT and OFFSET - $limitClause = isset($parsed['limit']) ? ' LIMIT {limit:UInt64}' : ''; - $offsetClause = isset($parsed['offset']) ? ' OFFSET {offset:UInt64}' : ''; + if (isset($parsed['limit'])) { + $builder->limit($parsed['limit']); + } + if (isset($parsed['offset'])) { + $builder->offset($parsed['offset']); + } - $sql = " - SELECT {$selectColumns} - FROM {$escapedTable}{$whereClause}{$orderClause}{$limitClause}{$offsetClause} - FORMAT JSON - "; + $statement = $builder->build(); + $sql = $statement->query . ' FORMAT JSON'; + $params = ($statement->namedBindings ?? []) + $cursorParams; $result = $this->query($sql, $params); $rows = $this->parseJsonResults($result); @@ -1103,43 +1168,31 @@ private function buildProjection(?array $select): string public function count(array $queries = [], ?int $max = null): int { $tableName = $this->getTableName(); - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + $qualifiedTable = $this->database . '.' . $tableName; - // Parse queries - we only need filters and params, not ordering/limit/offset/cursor $parsed = $this->parseQueries($queries); - // Build WHERE clause - $whereClause = ''; + $inner = $this->newBuilder() + ->from($qualifiedTable) + ->selectRaw($max !== null ? '1' : 'COUNT(*) AS count') + ->filter($parsed['filters']); + $tenantFilter = $this->getTenantFilter(); - if (!empty($parsed['filters']) || $tenantFilter) { - $conditions = $parsed['filters']; - if ($tenantFilter) { - $conditions[] = ltrim($tenantFilter, ' AND'); - } - $whereClause = ' WHERE ' . implode(' AND ', $conditions); + if ($tenantFilter !== '') { + $inner->whereRaw(ltrim($tenantFilter, ' AND')); } - // Remove limit and offset from params as they don't apply to count - $params = $parsed['params']; - unset($params['limit'], $params['offset']); - if ($max !== null) { - $params['max'] = $max; - $sql = " - SELECT COUNT(*) as count - FROM ( - SELECT 1 FROM {$escapedTable}{$whereClause} LIMIT {max:UInt64} - ) sub - FORMAT TabSeparated - "; - } else { - $sql = " - SELECT COUNT(*) as count - FROM {$escapedTable}{$whereClause} - FORMAT TabSeparated - "; + $inner->limit($max); } + $statement = $inner->build(); + $params = $statement->namedBindings ?? []; + + $sql = $max !== null + ? 'SELECT COUNT(*) AS count FROM (' . $statement->query . ') sub FORMAT TabSeparated' + : $statement->query . ' FORMAT TabSeparated'; + $result = $this->query($sql, $params); $trimmed = trim($result); @@ -1147,17 +1200,28 @@ public function count(array $queries = [], ?int $max = null): int } /** - * Parse Query objects into SQL components. + * Parse Query objects into builder-ready filters and auxiliary metadata. + * + * Returns the input filters as a list of `Utopia\Query\Query` instances — + * the caller hands them to `Builder\ClickHouse::filter()` which compiles + * them into typed `{paramN:Type}` placeholders via the column → type map + * registered on `newBuilder()`. Two audit-specific rewrites happen here: + * + * - `Contains` / `NotContains` are remapped to `Equal` / `NotEqual` so + * they keep the historical IN / NOT IN semantics (the base builder + * compiles `Contains` to substring-match `position(x, ?) > 0`). + * - `time`-column values arriving as `\DateTimeInterface` are pre-formatted + * to ClickHouse's `Y-m-d H:i:s.v` literal so the HTTP layer doesn't see + * raw DateTime objects in `namedBindings`. + * + * @param array $queries + * @return array{filters: array, orderAttributes: array, randomOrder: bool, limit?: int, offset?: int, cursor?: array, cursorDirection?: string, select?: list} * - * @param array $queries - * @return array{filters: array, params: array, orderBy?: array, orderAttributes?: array, randomOrder?: bool, limit?: int, offset?: int, cursor?: array, cursorDirection?: string, select?: list} * @throws Exception */ private function parseQueries(array $queries): array { $filters = []; - $params = []; - $orderBy = []; $orderAttributes = []; $limit = null; $offset = null; @@ -1165,233 +1229,56 @@ private function parseQueries(array $queries): array $cursorDirection = null; $select = null; $randomOrder = false; - $paramCounter = 0; foreach ($queries as $query) { + /** @phpstan-ignore-next-line instanceof.alwaysTrue - runtime validation despite type hint */ if (!$query instanceof Query) { /** @phpstan-ignore-next-line ternary.alwaysTrue - runtime validation despite type hint */ $type = is_object($query) ? get_class($query) : gettype($query); throw new \InvalidArgumentException("Invalid query item: expected instance of Query, got {$type}"); } - $method = $query->getMethod(); + $method = $query->getMethod()->value; $attribute = $query->getAttribute(); /** @var string $attribute */ $attribute = $this->translateAttribute($attribute); $values = $query->getValues(); - // Reject empty values for filter methods that take values — mirrors - // the validator in utopia-php/database (Validator/Query/Filter.php) - // and prevents silently dropping the WHERE fragment, which would - // otherwise turn `Query::contains('attr', [])` into a full-table - // match instead of an empty result. if (\in_array($method, self::VALUE_REQUIRED_METHODS, true) && empty($values)) { throw new \Exception(\ucfirst($method) . ' queries require at least one value.'); } switch ($method) { case Query::TYPE_EQUAL: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - - if (count($values) > 1) { - $inParams = []; - foreach ($values as $value) { - $paramName = 'param_' . $paramCounter++; - $inParams[] = "{{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $value); - } - $filters[] = "{$escapedAttr} IN (" . implode(', ', $inParams) . ")"; - } else { - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} = {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); - } - break; - case Query::TYPE_NOT_EQUAL: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} != {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); - break; - case Query::TYPE_LESSER: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} < {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); - break; - case Query::TYPE_LESSER_EQUAL: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} <= {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); - break; - case Query::TYPE_GREATER: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} > {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); - break; - case Query::TYPE_GREATER_EQUAL: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} >= {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); - break; - case Query::TYPE_BETWEEN: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName1 = 'param_' . $paramCounter++; - $paramName2 = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} BETWEEN {{$paramName1}:{$chType}} AND {{$paramName2}:{$chType}}"; - $params[$paramName1] = $this->formatTypedValue($chType, $values[0] ?? null); - $params[$paramName2] = $this->formatTypedValue($chType, $values[1] ?? null); - break; - case Query::TYPE_NOT_BETWEEN: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName1 = 'param_' . $paramCounter++; - $paramName2 = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} NOT BETWEEN {{$paramName1}:{$chType}} AND {{$paramName2}:{$chType}}"; - $params[$paramName1] = $this->formatTypedValue($chType, $values[0] ?? null); - $params[$paramName2] = $this->formatTypedValue($chType, $values[1] ?? null); - break; - - case Query::TYPE_CONTAINS: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $inParams = []; - foreach ($values as $value) { - $paramName = 'param_' . $paramCounter++; - $inParams[] = "{{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $value); - } - if (!empty($inParams)) { - $filters[] = "{$escapedAttr} IN (" . implode(', ', $inParams) . ")"; - } - break; - - case Query::TYPE_NOT_CONTAINS: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $inParams = []; - foreach ($values as $value) { - $paramName = 'param_' . $paramCounter++; - $inParams[] = "{{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $value); - } - if (!empty($inParams)) { - $filters[] = "{$escapedAttr} NOT IN (" . implode(', ', $inParams) . ")"; - } - break; - case Query::TYPE_IS_NULL: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $filters[] = "{$escapedAttr} IS NULL"; - break; - case Query::TYPE_IS_NOT_NULL: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $filters[] = "{$escapedAttr} IS NOT NULL"; - break; - case Query::TYPE_STARTS_WITH: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $needle = $values[0] ?? null; - if (!is_string($needle)) { - throw new Exception("startsWith needle must be a string for attribute '{$attribute}'"); - } - $paramName = 'param_' . $paramCounter++; - $filters[] = "startsWith({$escapedAttr}, {{$paramName}:String})"; - $params[$paramName] = $needle; - break; - case Query::TYPE_NOT_STARTS_WITH: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $needle = $values[0] ?? null; - if (!is_string($needle)) { - throw new Exception("notStartsWith needle must be a string for attribute '{$attribute}'"); - } - $paramName = 'param_' . $paramCounter++; - $filters[] = "NOT startsWith({$escapedAttr}, {{$paramName}:String})"; - $params[$paramName] = $needle; - break; - case Query::TYPE_ENDS_WITH: + case Query::TYPE_NOT_ENDS_WITH: + case Query::TYPE_REGEX: $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $needle = $values[0] ?? null; - if (!is_string($needle)) { - throw new Exception("endsWith needle must be a string for attribute '{$attribute}'"); - } - $paramName = 'param_' . $paramCounter++; - $filters[] = "endsWith({$escapedAttr}, {{$paramName}:String})"; - $params[$paramName] = $needle; + $filters[] = new BaseQuery($query->getMethod(), $attribute, $this->normalizeFilterValues($attribute, $values)); break; - case Query::TYPE_NOT_ENDS_WITH: + case Query::TYPE_CONTAINS: $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $needle = $values[0] ?? null; - if (!is_string($needle)) { - throw new Exception("notEndsWith needle must be a string for attribute '{$attribute}'"); - } - $paramName = 'param_' . $paramCounter++; - $filters[] = "NOT endsWith({$escapedAttr}, {{$paramName}:String})"; - $params[$paramName] = $needle; + $filters[] = new BaseQuery(Method::Equal, $attribute, $this->normalizeFilterValues($attribute, $values)); break; - case Query::TYPE_REGEX: + case Query::TYPE_NOT_CONTAINS: $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $pattern = $values[0] ?? null; - if (!is_string($pattern)) { - throw new Exception("regex pattern must be a string for attribute '{$attribute}'"); - } - $paramName = 'param_' . $paramCounter++; - // ClickHouse's `match(haystack, pattern)` is the re2-style - // regex predicate. Pattern is bound as a parameter, never - // interpolated, so it can't escape into the SQL. - $filters[] = "match({$escapedAttr}, {{$paramName}:String})"; - $params[$paramName] = $pattern; + $filters[] = new BaseQuery(Method::NotEqual, $attribute, $this->normalizeFilterValues($attribute, $values)); break; case Query::TYPE_SELECT: - if (empty($values)) { - // VALUE_REQUIRED_METHODS already rejects empty values - // earlier, but the explicit check keeps this branch safe - // if the guard is ever bypassed. - break; - } - // Multiple Query::select(...) calls combine into a single - // projection. Duplicates are removed; column names are - // validated and escaped at SQL build time in find(). $select ??= []; foreach ($values as $column) { if (!is_string($column) || $column === '') { @@ -1406,22 +1293,15 @@ private function parseQueries(array $queries): array case Query::TYPE_ORDER_DESC: $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $orderBy[] = "{$escapedAttr} DESC"; $orderAttributes[] = ['attribute' => $attribute, 'direction' => 'DESC']; break; case Query::TYPE_ORDER_ASC: $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $orderBy[] = "{$escapedAttr} ASC"; $orderAttributes[] = ['attribute' => $attribute, 'direction' => 'ASC']; break; case Query::TYPE_ORDER_RANDOM: - // ClickHouse's rand() is the per-row PRNG used for random - // sampling. Single emission across the result set — repeated - // Query::orderRandom() calls collapse into one ORDER BY rand(). $randomOrder = true; break; @@ -1430,7 +1310,6 @@ private function parseQueries(array $queries): array throw new \Exception('Invalid limit value. Expected int'); } $limit = $values[0]; - $params['limit'] = $limit; break; case Query::TYPE_OFFSET: @@ -1438,18 +1317,16 @@ private function parseQueries(array $queries): array throw new \Exception('Invalid offset value. Expected int'); } $offset = $values[0]; - $params['offset'] = $offset; break; case Query::TYPE_CURSOR_AFTER: case Query::TYPE_CURSOR_BEFORE: if ($cursor !== null) { - // Keep the first cursor encountered (matches base groupByType semantics) break; } $rawCursor = $values[0] ?? null; if ($rawCursor === null) { - break; // no-op cursor + break; } $cursor = $this->normalizeCursorRow($rawCursor); $cursorDirection = $method === Query::TYPE_CURSOR_AFTER ? 'after' : 'before'; @@ -1459,18 +1336,10 @@ private function parseQueries(array $queries): array $result = [ 'filters' => $filters, - 'params' => $params, + 'orderAttributes' => $orderAttributes, + 'randomOrder' => $randomOrder, ]; - if (!empty($orderBy)) { - $result['orderBy'] = $orderBy; - $result['orderAttributes'] = $orderAttributes; - } - - if ($randomOrder) { - $result['randomOrder'] = true; - } - if ($limit !== null) { $result['limit'] = $limit; } @@ -1491,6 +1360,59 @@ private function parseQueries(array $queries): array return $result; } + /** + * Normalize filter values so DateTime instances on the `time` column flow + * through `namedBindings` as ClickHouse-compatible strings rather than raw + * objects (the HTTP layer would otherwise serialise them as empty). + * + * @param array $values + * @return array + * + * @throws Exception + */ + private function normalizeFilterValues(string $attribute, array $values): array + { + if ($this->getParamType($attribute) !== 'DateTime64(3)') { + return $values; + } + + $normalized = []; + foreach ($values as $value) { + if ($value === null) { + $normalized[] = null; + + continue; + } + /** @var \DateTime|string $value */ + $normalized[] = $this->formatDateTime($value); + } + + return $normalized; + } + + /** + * Apply an ordered list of column directions to the builder via the + * canonical `sortAsc` / `sortDesc` API, optionally flipping each direction + * for `cursorBefore` pagination. + * + * @param array $orderAttributes + */ + private function applyOrderBy(ClickHouseBuilder $builder, array $orderAttributes, bool $flip = false): void + { + foreach ($orderAttributes as $entry) { + $direction = $entry['direction']; + if ($flip) { + $direction = $direction === 'DESC' ? 'ASC' : 'DESC'; + } + + if ($direction === 'DESC') { + $builder->sortDesc($entry['attribute']); + } else { + $builder->sortAsc($entry['attribute']); + } + } + } + /** * Normalize a user-supplied cursor row into a column-keyed array. * @@ -1674,29 +1596,6 @@ private function buildCursorWhere(array $orderAttributes, array $cursor, string ]; } - /** - * Build the ORDER BY SQL fragment list, optionally flipping all directions. - * - * Used when cursor direction is `before` — we run the query in reverse to - * grab the previous-page rows, then `array_reverse` the result. - * - * @param array $orderAttributes - * @param bool $flip Whether to flip ASC↔DESC - * @return array - */ - private function buildOrderBySql(array $orderAttributes, bool $flip = false): array - { - $sql = []; - foreach ($orderAttributes as $entry) { - $direction = $entry['direction']; - if ($flip) { - $direction = $direction === 'DESC' ? 'ASC' : 'DESC'; - } - $sql[] = $this->escapeIdentifier($entry['attribute']) . ' ' . $direction; - } - return $sql; - } - /** * Create multiple audit log entries in batch. * @@ -1710,7 +1609,7 @@ public function createBatch(array $logs): bool } $tableName = $this->getTableName(); - $escapedDatabaseAndTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + $qualifiedTable = $this->database . '.' . $tableName; // Get all attribute column names $schemaColumns = $this->getColumnNames(); @@ -1809,9 +1708,23 @@ public function createBatch(array $logs): bool $rows[] = $row; } - $insertSql = "INSERT INTO {$escapedDatabaseAndTable} FORMAT JSONEachRow"; + $columns = ['id', 'time']; + foreach ($schemaColumns as $columnName) { + if ($columnName === 'time') { + continue; + } + $columns[] = $columnName; + } + if ($this->sharedTables) { + $columns[] = 'tenant'; + } + + $statement = $this->newBuilder() + ->into($qualifiedTable) + ->bulkInsert(Format::JSONEachRow, $rows, $columns); + + $this->query($statement->query, [], $statement->body); - $this->query($insertSql, [], $rows); return true; } @@ -1839,7 +1752,7 @@ private function parseJsonResults(string $result): array return []; } - /** @var array> $data */ + /** @var array $data */ $data = $decoded['data']; $documents = []; @@ -1850,6 +1763,7 @@ private function parseJsonResults(string $result): array $document = []; + /** @var array $row */ foreach ($row as $columnName => $value) { if ($columnName === 'data') { // Decode JSON data column @@ -2237,17 +2151,24 @@ public function countByResourceAndEvents( public function cleanup(\DateTime $datetime): bool { $tableName = $this->getTableName(); - $tenantFilter = $this->getTenantFilter(); - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); - + $qualifiedTable = $this->database . '.' . $tableName; + $escapedTimeColumn = $this->escapeIdentifier('time'); $datetimeString = $datetime->format('Y-m-d H:i:s.v'); - $settings = $this->asyncCleanup ? ' SETTINGS lightweight_deletes_sync = 0' : ''; + $builder = $this->newBuilder() + ->into($qualifiedTable) + ->whereRaw($escapedTimeColumn . ' < {datetime:DateTime64(3)}'); + + $tenantFilter = $this->getTenantFilter(); + if ($tenantFilter !== '') { + $builder->whereRaw(ltrim($tenantFilter, ' AND')); + } + + if ($this->asyncCleanup) { + $builder->settings(['lightweight_deletes_sync' => '0']); + } - $sql = " - DELETE FROM {$escapedTable} - WHERE time < {datetime:String}{$tenantFilter}{$settings} - "; + $sql = $builder->delete()->query; $this->query($sql, ['datetime' => $datetimeString]); diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index 15d2e9d..0edb34f 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -494,6 +494,7 @@ public function find(array $queries = []): array $dbQueries = []; foreach ($queries as $query) { + /** @phpstan-ignore-next-line instanceof.alwaysTrue - runtime validation despite type hint */ if (!($query instanceof \Utopia\Audit\Query)) { throw new \Exception('Invalid query type. Expected Utopia\\Audit\\Query'); } @@ -529,12 +530,13 @@ public function count(array $queries = [], ?int $max = null): int $dbQueries = []; foreach ($queries as $query) { + /** @phpstan-ignore-next-line instanceof.alwaysTrue - runtime validation despite type hint */ if (!($query instanceof \Utopia\Audit\Query)) { throw new \Exception('Invalid query type. Expected Utopia\\Audit\\Query'); } // Skip limit, offset, and cursor queries — they don't apply to count - $method = $query->getMethod(); + $method = $query->getMethod()->value; if ( $method === \Utopia\Audit\Query::TYPE_LIMIT || $method === \Utopia\Audit\Query::TYPE_OFFSET diff --git a/src/Audit/Log.php b/src/Audit/Log.php index 3188ee7..9536397 100644 --- a/src/Audit/Log.php +++ b/src/Audit/Log.php @@ -142,6 +142,7 @@ public function getTime(): string public function getData(): array { $data = $this->getAttribute('data', []); + /** @var array */ return is_array($data) ? $data : []; } diff --git a/src/Audit/Query.php b/src/Audit/Query.php index cc2fecd..9718cdd 100644 --- a/src/Audit/Query.php +++ b/src/Audit/Query.php @@ -2,6 +2,7 @@ namespace Utopia\Audit; +use Utopia\Query\Method; use Utopia\Query\Query as BaseQuery; /** @@ -12,24 +13,89 @@ * `parse()` validation — while keeping audit's lenient single-value factory * signatures (the base requires arrays / scalars only; audit accepts mixed * including `DateTime` for the `time` column). + * + * Also re-exposes the legacy `TYPE_*` string constants the audit adapter and + * its tests have always used. The base library moved to a `Method` enum in + * 0.3.x; the constants here map to the same string values (`equal`, + * `lessThan`, etc.) so existing call sites keep working. */ class Query extends BaseQuery { + public const TYPE_EQUAL = 'equal'; + + public const TYPE_NOT_EQUAL = 'notEqual'; + + public const TYPE_LESSER = 'lessThan'; + + public const TYPE_LESSER_EQUAL = 'lessThanEqual'; + + public const TYPE_GREATER = 'greaterThan'; + + public const TYPE_GREATER_EQUAL = 'greaterThanEqual'; + + public const TYPE_BETWEEN = 'between'; + + public const TYPE_NOT_BETWEEN = 'notBetween'; + + public const TYPE_CONTAINS = 'contains'; + + public const TYPE_NOT_CONTAINS = 'notContains'; + + public const TYPE_IS_NULL = 'isNull'; + + public const TYPE_IS_NOT_NULL = 'isNotNull'; + + public const TYPE_STARTS_WITH = 'startsWith'; + + public const TYPE_NOT_STARTS_WITH = 'notStartsWith'; + + public const TYPE_ENDS_WITH = 'endsWith'; + + public const TYPE_NOT_ENDS_WITH = 'notEndsWith'; + + public const TYPE_REGEX = 'regex'; + + public const TYPE_SELECT = 'select'; + + public const TYPE_ORDER_DESC = 'orderDesc'; + + public const TYPE_ORDER_ASC = 'orderAsc'; + + public const TYPE_ORDER_RANDOM = 'orderRandom'; + + public const TYPE_LIMIT = 'limit'; + + public const TYPE_OFFSET = 'offset'; + + public const TYPE_CURSOR_AFTER = 'cursorAfter'; + + public const TYPE_CURSOR_BEFORE = 'cursorBefore'; + + /** + * Construct a query with a string method name (legacy `TYPE_*` constants) + * or a `Method` enum case (new 0.3.x API). + * + * @param array $values + */ + public function __construct(Method|string $method, string $attribute = '', array $values = []) + { + parent::__construct($method, $attribute, $values); + } + /** * Filter by equal condition. * * Accepts a single scalar/object/array value and stores it as the values * array. Matches the legacy audit signature. * - * @param string $attribute - * @param mixed $value Single value or array of values - * @return static + * @param mixed $value Single value or array of values */ public static function equal(string $attribute, mixed $value): static { /** @var array $values */ $values = is_array($value) ? $value : [$value]; - return new static(self::TYPE_EQUAL, $attribute, $values); + + return new static(Method::Equal, $attribute, $values); } /** @@ -37,38 +103,25 @@ public static function equal(string $attribute, mixed $value): static * * Accepts mixed (including `DateTime` for the `time` column); the * adapter handles type-specific formatting. - * - * @param string $attribute - * @param mixed $value - * @return static */ public static function lessThan(string $attribute, mixed $value): static { - return new static(self::TYPE_LESSER, $attribute, [$value]); + return new static(Method::LessThan, $attribute, [$value]); } /** * Filter by greater than condition. - * - * @param string $attribute - * @param mixed $value - * @return static */ public static function greaterThan(string $attribute, mixed $value): static { - return new static(self::TYPE_GREATER, $attribute, [$value]); + return new static(Method::GreaterThan, $attribute, [$value]); } /** * Filter by BETWEEN condition. - * - * @param string $attribute - * @param mixed $start - * @param mixed $end - * @return static */ public static function between(string $attribute, mixed $start, mixed $end): static { - return new static(self::TYPE_BETWEEN, $attribute, [$start, $end]); + return new static(Method::Between, $attribute, [$start, $end]); } } diff --git a/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php b/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php new file mode 100644 index 0000000..a98b76a --- /dev/null +++ b/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php @@ -0,0 +1,286 @@ + + */ + private function auditTypeMap(): array + { + return [ + 'id' => 'String', + 'actorId' => 'String', + 'actorType' => 'String', + 'actorInternalId' => 'String', + 'event' => 'String', + 'resource' => 'String', + 'userAgent' => 'String', + 'ip' => 'String', + 'time' => 'DateTime64(3)', + 'data' => 'String', + 'tenant' => 'UInt64', + ]; + } + + private function newAuditBuilder(): ClickHouseBuilder + { + return (new ClickHouseBuilder()) + ->useNamedBindings() + ->withParamTypes($this->auditTypeMap()); + } + + public function testSetupCreateTableSnapshot(): void + { + $schema = new ClickHouseSchema(); + $table = $schema->table('default.audits'); + $table->string('id')->primary(); + $table->string('actorId')->nullable(); + $table->string('actorType'); + $table->string('actorInternalId')->nullable(); + $table->string('event'); + $table->string('resource')->nullable(); + $table->string('userAgent'); + $table->string('ip'); + $table->datetime('time', precision: 3); + $table->addColumn('data', ColumnType::String)->nullable(); + + $table->index( + columns: ['event'], + name: 'idx_event', + algorithm: IndexAlgorithm::BloomFilter, + granularity: 1, + ); + $table->index( + columns: ['actorId', 'event'], + name: 'idx_actorId_event', + algorithm: IndexAlgorithm::BloomFilter, + granularity: 1, + ); + $table->index( + columns: ['actorType'], + name: '_key_actor_type', + algorithm: IndexAlgorithm::BloomFilter, + granularity: 1, + ); + + $table->engine(ClickHouseEngine::MergeTree); + $table->orderBy(['time', 'id']); + $table->partitionBy('toYYYYMM(time)'); + $table->settings(['index_granularity' => '8192']); + + $sql = $table->createIfNotExists()->query; + + $this->assertStringContainsString('CREATE TABLE IF NOT EXISTS `default`.`audits`', $sql); + $this->assertStringContainsString('`id` String', $sql); + $this->assertStringContainsString('`actorId` Nullable(String)', $sql); + $this->assertStringContainsString('`actorType` String', $sql); + $this->assertStringContainsString('`actorInternalId` Nullable(String)', $sql); + $this->assertStringContainsString('`event` String', $sql); + $this->assertStringContainsString('`time` DateTime64(3)', $sql); + $this->assertStringNotContainsString('`location`', $sql); + $this->assertStringNotContainsString('`userId`', $sql); + $this->assertStringContainsString('INDEX `idx_event` `event` TYPE bloom_filter GRANULARITY 1', $sql); + $this->assertStringContainsString('INDEX `idx_actorId_event` (`actorId`, `event`) TYPE bloom_filter GRANULARITY 1', $sql); + $this->assertStringContainsString('INDEX `_key_actor_type` `actorType` TYPE bloom_filter GRANULARITY 1', $sql); + $this->assertStringContainsString('ENGINE = MergeTree()', $sql); + $this->assertStringContainsString('PARTITION BY toYYYYMM(time)', $sql); + $this->assertStringContainsString('ORDER BY (`time`, `id`)', $sql); + $this->assertStringContainsString('SETTINGS index_granularity = 8192', $sql); + } + + public function testBulkInsertJsonEachRowSnapshot(): void + { + $columns = ['id', 'time', 'actorId', 'actorType', 'event', 'data']; + $rows = [ + [ + 'id' => 'log-1', + 'time' => '2025-01-02 03:04:05.678', + 'actorId' => 'u1', + 'actorType' => 'users', + 'event' => 'users.create', + 'data' => '{"foo":"bar"}', + ], + [ + 'id' => 'log-2', + 'time' => '2025-01-02 03:04:06.000', + 'actorId' => 'u2', + 'actorType' => 'users', + 'event' => 'users.delete', + 'data' => '{"foo":"baz"}', + ], + ]; + + $statement = (new ClickHouseBuilder()) + ->into('default.audits') + ->bulkInsert(Format::JSONEachRow, $rows, $columns); + + $this->assertEquals( + 'INSERT INTO `default`.`audits` (`id`, `time`, `actorId`, `actorType`, `event`, `data`) FORMAT JSONEachRow', + $statement->query, + ); + $this->assertSame('JSONEachRow', $statement->format); + $this->assertSame($columns, $statement->columns); + $this->assertSame( + '{"id":"log-1","time":"2025-01-02 03:04:05.678","actorId":"u1","actorType":"users","event":"users.create","data":"{\"foo\":\"bar\"}"}' . "\n" + . '{"id":"log-2","time":"2025-01-02 03:04:06.000","actorId":"u2","actorType":"users","event":"users.delete","data":"{\"foo\":\"baz\"}"}', + $statement->body, + ); + } + + public function testAsyncCleanupDeleteEmitsSettingsClause(): void + { + $sql = (new ClickHouseBuilder()) + ->into('default.audits') + ->whereRaw('`time` < {datetime:DateTime64(3)}') + ->settings(['lightweight_deletes_sync' => '0']) + ->delete() + ->query; + + $this->assertEquals( + 'DELETE FROM `default`.`audits` WHERE `time` < {datetime:DateTime64(3)} SETTINGS lightweight_deletes_sync=0', + $sql, + ); + } + + public function testSyncCleanupDeleteOmitsSettingsClause(): void + { + $sql = (new ClickHouseBuilder()) + ->into('default.audits') + ->whereRaw('`time` < {datetime:DateTime64(3)}') + ->delete() + ->query; + + $this->assertEquals( + 'DELETE FROM `default`.`audits` WHERE `time` < {datetime:DateTime64(3)}', + $sql, + ); + } + + public function testFindEmitsTypedNamedBindings(): void + { + $statement = $this->newAuditBuilder() + ->from('default.audits') + ->selectRaw('`id`, `event`, `time`') + ->filter([ + Query::equal('actorId', ['u1']), + Query::between('time', '2025-01-01 00:00:00.000', '2025-12-31 00:00:00.000'), + ]) + ->sortDesc('time') + ->limit(25) + ->build(); + + $expectedSql = 'SELECT `id`, `event`, `time` FROM `default`.`audits` ' + . 'WHERE `actorId` IN ({param0:String}) ' + . 'AND `time` BETWEEN {param1:DateTime64(3)} AND {param2:DateTime64(3)} ' + . 'ORDER BY `time` DESC ' + . 'LIMIT {param3:Int64}'; + + $this->assertEquals($expectedSql, $statement->query); + $this->assertSame( + [ + 'param0' => 'u1', + 'param1' => '2025-01-01 00:00:00.000', + 'param2' => '2025-12-31 00:00:00.000', + 'param3' => 25, + ], + $statement->namedBindings, + ); + } + + public function testNotContainsMultiValueEmitsTypedNotIn(): void + { + $statement = $this->newAuditBuilder() + ->from('default.audits') + ->selectRaw('`id`, `event`, `time`') + ->filter([ + Query::notEqual('event', ['users.delete', 'projects.delete']), + ]) + ->limit(25) + ->build(); + + $expectedSql = 'SELECT `id`, `event`, `time` FROM `default`.`audits` ' + . 'WHERE `event` NOT IN ({param0:String}, {param1:String}) ' + . 'LIMIT {param2:Int64}'; + + $this->assertEquals($expectedSql, $statement->query); + $this->assertSame( + [ + 'param0' => 'users.delete', + 'param1' => 'projects.delete', + 'param2' => 25, + ], + $statement->namedBindings, + ); + } + + public function testFindCursorRawFragmentMergesWithTypedBindings(): void + { + $cursorClause = '((`time` < {cursor_cmp_0:DateTime64(3)}) ' + . 'OR (`time` = {cursor_eq_1_0:DateTime64(3)} AND `id` < {cursor_cmp_1:String}))'; + + $statement = $this->newAuditBuilder() + ->from('default.audits') + ->selectRaw('`id`, `event`, `time`') + ->filter([Query::equal('actorId', ['u1'])]) + ->whereRaw($cursorClause) + ->sortDesc('time') + ->sortDesc('id') + ->limit(25) + ->build(); + + $expectedSql = 'SELECT `id`, `event`, `time` FROM `default`.`audits` ' + . 'WHERE `actorId` IN ({param0:String}) ' + . 'AND ' . $cursorClause . ' ' + . 'ORDER BY `time` DESC, `id` DESC ' + . 'LIMIT {param1:Int64}'; + + $this->assertEquals($expectedSql, $statement->query); + $this->assertSame( + [ + 'param0' => 'u1', + 'param1' => 25, + ], + $statement->namedBindings, + ); + } + + public function testCountWithMaxWrapsInnerSelect(): void + { + $inner = $this->newAuditBuilder() + ->from('default.audits') + ->selectRaw('1') + ->filter([Query::equal('actorId', ['u1'])]) + ->limit(5000) + ->build(); + + $sql = 'SELECT COUNT(*) AS count FROM (' . $inner->query . ') sub FORMAT TabSeparated'; + + $this->assertEquals( + 'SELECT COUNT(*) AS count FROM (' + . 'SELECT 1 FROM `default`.`audits` WHERE `actorId` IN ({param0:String}) LIMIT {param1:Int64}' + . ') sub FORMAT TabSeparated', + $sql, + ); + $this->assertSame( + ['param0' => 'u1', 'param1' => 5000], + $inner->namedBindings, + ); + } +} diff --git a/tests/Audit/Adapter/ClickHouseTest.php b/tests/Audit/Adapter/ClickHouseTest.php index 8074243..d81e3c6 100644 --- a/tests/Audit/Adapter/ClickHouseTest.php +++ b/tests/Audit/Adapter/ClickHouseTest.php @@ -965,8 +965,9 @@ public function testSharedTableSortKeyLeadsWithTenant(): void $url = "{$scheme}://{$host}:{$port}/?database=" . rawurlencode($database) . '&user=' . rawurlencode($username) . '&password=' . rawurlencode($password); + /** @var array $params */ foreach ($params as $key => $value) { - $url .= '¶m_' . rawurlencode((string) $key) . '=' . rawurlencode((string) $value); + $url .= '¶m_' . rawurlencode($key) . '=' . rawurlencode($value); } $ctx = stream_context_create(['http' => [ 'method' => 'POST', diff --git a/tests/Audit/AuditBase.php b/tests/Audit/AuditBase.php index 4ba7c74..179a50a 100644 --- a/tests/Audit/AuditBase.php +++ b/tests/Audit/AuditBase.php @@ -245,8 +245,6 @@ public function testLogByBatch(): void ] ]; - $batchEvents = $this->applyRequiredAttributesToBatch($batchEvents); - // Test batch insertion $batchEvents = $this->applyRequiredAttributesToBatch($batchEvents); diff --git a/tests/Audit/QueryTest.php b/tests/Audit/QueryTest.php index d151039..68f3fc2 100644 --- a/tests/Audit/QueryTest.php +++ b/tests/Audit/QueryTest.php @@ -14,55 +14,55 @@ public function testQueryStaticFactoryMethods(): void { // Test equal $query = Query::equal('userId', '123'); - $this->assertEquals(Query::TYPE_EQUAL, $query->getMethod()); + $this->assertEquals(Query::TYPE_EQUAL, $query->getMethod()->value); $this->assertEquals('userId', $query->getAttribute()); $this->assertEquals(['123'], $query->getValues()); // Test lessThan $query = Query::lessThan('time', '2024-01-01'); - $this->assertEquals(Query::TYPE_LESSER, $query->getMethod()); + $this->assertEquals(Query::TYPE_LESSER, $query->getMethod()->value); $this->assertEquals('time', $query->getAttribute()); $this->assertEquals(['2024-01-01'], $query->getValues()); // Test greaterThan $query = Query::greaterThan('time', '2023-01-01'); - $this->assertEquals(Query::TYPE_GREATER, $query->getMethod()); + $this->assertEquals(Query::TYPE_GREATER, $query->getMethod()->value); $this->assertEquals('time', $query->getAttribute()); $this->assertEquals(['2023-01-01'], $query->getValues()); // Test between $query = Query::between('time', '2023-01-01', '2024-01-01'); - $this->assertEquals(Query::TYPE_BETWEEN, $query->getMethod()); + $this->assertEquals(Query::TYPE_BETWEEN, $query->getMethod()->value); $this->assertEquals('time', $query->getAttribute()); $this->assertEquals(['2023-01-01', '2024-01-01'], $query->getValues()); // Test contains $query = Query::contains('event', ['create', 'update', 'delete']); - $this->assertEquals(Query::TYPE_CONTAINS, $query->getMethod()); + $this->assertEquals(Query::TYPE_CONTAINS, $query->getMethod()->value); $this->assertEquals('event', $query->getAttribute()); $this->assertEquals(['create', 'update', 'delete'], $query->getValues()); // Test orderDesc $query = Query::orderDesc('time'); - $this->assertEquals(Query::TYPE_ORDER_DESC, $query->getMethod()); + $this->assertEquals(Query::TYPE_ORDER_DESC, $query->getMethod()->value); $this->assertEquals('time', $query->getAttribute()); $this->assertEquals([], $query->getValues()); // Test orderAsc $query = Query::orderAsc('userId'); - $this->assertEquals(Query::TYPE_ORDER_ASC, $query->getMethod()); + $this->assertEquals(Query::TYPE_ORDER_ASC, $query->getMethod()->value); $this->assertEquals('userId', $query->getAttribute()); $this->assertEquals([], $query->getValues()); // Test limit $query = Query::limit(10); - $this->assertEquals(Query::TYPE_LIMIT, $query->getMethod()); + $this->assertEquals(Query::TYPE_LIMIT, $query->getMethod()->value); $this->assertEquals('', $query->getAttribute()); $this->assertEquals([10], $query->getValues()); // Test offset $query = Query::offset(5); - $this->assertEquals(Query::TYPE_OFFSET, $query->getMethod()); + $this->assertEquals(Query::TYPE_OFFSET, $query->getMethod()->value); $this->assertEquals('', $query->getAttribute()); $this->assertEquals([5], $query->getValues()); } @@ -75,7 +75,7 @@ public function testQueryParseAndToString(): void // Test parsing equal query $json = '{"method":"equal","attribute":"userId","values":["123"]}'; $query = Query::parse($json); - $this->assertEquals(Query::TYPE_EQUAL, $query->getMethod()); + $this->assertEquals(Query::TYPE_EQUAL, $query->getMethod()->value); $this->assertEquals('userId', $query->getAttribute()); $this->assertEquals(['123'], $query->getValues()); @@ -85,7 +85,7 @@ public function testQueryParseAndToString(): void $this->assertJson($json); $parsed = Query::parse($json); - $this->assertEquals(Query::TYPE_EQUAL, $parsed->getMethod()); + $this->assertEquals(Query::TYPE_EQUAL, $parsed->getMethod()->value); $this->assertEquals('event', $parsed->getAttribute()); $this->assertEquals(['create'], $parsed->getValues()); @@ -117,9 +117,9 @@ public function testQueryParseQueries(): void $this->assertInstanceOf(Query::class, $parsed[1]); $this->assertInstanceOf(Query::class, $parsed[2]); - $this->assertEquals(Query::TYPE_EQUAL, $parsed[0]->getMethod()); - $this->assertEquals(Query::TYPE_GREATER, $parsed[1]->getMethod()); - $this->assertEquals(Query::TYPE_LIMIT, $parsed[2]->getMethod()); + $this->assertEquals(Query::TYPE_EQUAL, $parsed[0]->getMethod()->value); + $this->assertEquals(Query::TYPE_GREATER, $parsed[1]->getMethod()->value); + $this->assertEquals(Query::TYPE_LIMIT, $parsed[2]->getMethod()->value); } /** @@ -218,7 +218,7 @@ public function testQueryToStringWithComplexValues(): void $this->assertJson($json); $parsed = Query::parse($json); - $this->assertEquals(Query::TYPE_BETWEEN, $parsed->getMethod()); + $this->assertEquals(Query::TYPE_BETWEEN, $parsed->getMethod()->value); $this->assertEquals('time', $parsed->getAttribute()); $this->assertEquals(['2023-01-01', '2024-12-31'], $parsed->getValues()); }