From 58879d560f67a8028ec38f4eebf0636407618dbc Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 17 May 2026 07:21:54 +0000 Subject: [PATCH 01/18] chore(deps): bump utopia-php/query to 0.3.x (dev-branch pin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins to dev-feat/clickhouse-insert-delete-settings-mv as 0.3.2 — the branch on utopia-php/query (PR #11) that adds the three ClickHouse builder extras audit needs: - Builder\ClickHouse::insertFormat(...) for INSERT ... FORMAT JSONEachRow - Trailing SETTINGS clause on DELETE (for async cleanup) - Schema\ClickHouse::create/dropMaterializedView (not used here) TODO: flip to ^0.3.2 once PR #11 lands on utopia-php/query main and a 0.3.2 tag exists. Co-Authored-By: Claude Opus 4.7 (1M context) --- composer.json | 2 +- composer.lock | 30 +++++++++++++++++++++--------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/composer.json b/composer.json index 32ce7fc..f233034 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "php": ">=8.4", "utopia-php/database": "5.*", "utopia-php/fetch": "^1.1", - "utopia-php/query": "0.1.*", + "utopia-php/query": "dev-feat/clickhouse-insert-delete-settings-mv as 0.3.2", "utopia-php/validators": "0.2.*" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 3e69f83..282da9b 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": "52e238097ee4b3c66f172cf8c9b3e86c", + "content-hash": "4c74d9eb46185e0e6db7ea5d8bf5a3c5", "packages": [ { "name": "brick/math", @@ -2384,24 +2384,27 @@ }, { "name": "utopia-php/query", - "version": "0.1.1", + "version": "dev-feat/clickhouse-insert-delete-settings-mv", "source": { "type": "git", "url": "https://github.com/utopia-php/query.git", - "reference": "964a10ed3185490505f4c0062f2eb7b89287fb27" + "reference": "605376809ecd61051cbd1235c14606a0b9d59a1c" }, "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/605376809ecd61051cbd1235c14606a0b9d59a1c", + "reference": "605376809ecd61051cbd1235c14606a0b9d59a1c", "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", @@ -2424,9 +2427,9 @@ ], "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/feat/clickhouse-insert-delete-settings-mv" }, - "time": "2026-03-03T09:05:14+00:00" + "time": "2026-05-17T07:12:35+00:00" }, { "name": "utopia-php/telemetry", @@ -4442,9 +4445,18 @@ "time": "2025-11-17T20:03:58+00:00" } ], - "aliases": [], + "aliases": [ + { + "package": "utopia-php/query", + "version": "dev-feat/clickhouse-insert-delete-settings-mv", + "alias": "0.3.2", + "alias_normalized": "0.3.2.0" + } + ], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": { + "utopia-php/query": 20 + }, "prefer-stable": false, "prefer-lowest": false, "platform": { From b317a62ad589e8ce1622da9ddbcd31f04657006c Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 17 May 2026 07:23:43 +0000 Subject: [PATCH 02/18] refactor(query): re-expose TYPE_* constants on Audit\Query for 0.3.x The 0.3.x utopia-php/query base class replaced the legacy `TYPE_*` string constants with a `Method` enum. Audit's adapter switches and tests all still compare against the string constants, so they're re-declared here mapped to the same string values (`equal`, `lessThan`, ...). Tests that compared `$query->getMethod()` (now returns a `Method` enum) against the constants are updated to compare against `getMethod()->value`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Audit/Query.php | 93 ++++++++++++++++++++++++++++++--------- tests/Audit/QueryTest.php | 30 ++++++------- 2 files changed, 88 insertions(+), 35 deletions(-) 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/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()); } From 42611777c864171f596044dc491c126e53b22568 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 17 May 2026 07:30:35 +0000 Subject: [PATCH 03/18] chore(deps): bump phpstan to ^2.0 for PHP 8.4 query lib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit utopia-php/query 0.3.x uses PHP 8.4 asymmetric visibility (`public protected(set)`) on schema and builder properties. PHPStan 1.12's bundled PhpParser can't tokenize that syntax — `composer check` crashes with a Lexer internal error on any audit source file that imports a query schema/builder class. PHPStan 2.x ships an updated PhpParser that handles asymmetric visibility, so bumping the dev dependency is the smallest change that restores the static-analysis gate. Co-Authored-By: Claude Opus 4.7 (1M context) --- composer.json | 2 +- composer.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/composer.json b/composer.json index f233034..1ca5300 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,7 @@ }, "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 282da9b..8a4fc3d 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": "4c74d9eb46185e0e6db7ea5d8bf5a3c5", + "content-hash": "ce81cb6886b4c53426c0c0f609d8c180", "packages": [ { "name": "brick/math", @@ -2902,15 +2902,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": "*" @@ -2951,7 +2951,7 @@ "type": "github" } ], - "time": "2025-09-30T10:16:31+00:00" + "time": "2026-04-29T13:31:09+00:00" }, { "name": "phpunit/php-code-coverage", From f36040e6f6705ee22e2b27bf8a8a9d36dad38221 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 17 May 2026 07:31:44 +0000 Subject: [PATCH 04/18] refactor(adapters): unwrap Method enum to string at getMethod() call sites utopia-php/query 0.3.x's `Query::getMethod()` returns a `Method` enum instead of a string. The ClickHouse adapter's parseQueries switch, and the Database adapter's count-time filter, both compare against the legacy string `TYPE_*` constants. Switch from `getMethod()` to `getMethod()->value` at the two call sites so the existing comparisons keep matching. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Audit/Adapter/ClickHouse.php | 2 +- src/Audit/Adapter/Database.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index 86b6f02..d0b3bf8 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -1107,7 +1107,7 @@ private function parseQueries(array $queries): array 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 */ $values = $query->getValues(); diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index cda3383..374ac7b 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -518,7 +518,7 @@ public function count(array $queries = [], ?int $max = null): int } // 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 From 947ae8b41d600caa6dcce799ebb2e85ee8c56ed4 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 17 May 2026 07:37:59 +0000 Subject: [PATCH 05/18] chore: silence PHPStan 2.x diagnostics unrelated to the migration Bumping PHPStan to 2.x to handle PHP 8.4 syntax surfaces a handful of pre-existing diagnostics in code paths the migration does not otherwise touch: - Database adapter: redundant `instanceof Utopia\Audit\Query` guards inside a method whose signature already constrains the parameter. Kept as runtime defense (real callers occasionally pass mixed arrays); annotated with @phpstan-ignore-next-line. - Log::getData(): PHPStan can't widen the ArrayObject return without a local @var hint. - AuditBase batch test: removed a duplicate `applyRequiredAttributesToBatch` call (typo; the second invocation already re-merged the same row). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Audit/Adapter/Database.php | 2 ++ src/Audit/Log.php | 1 + tests/Audit/AuditBase.php | 2 -- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index 374ac7b..3f38c47 100644 --- a/src/Audit/Adapter/Database.php +++ b/src/Audit/Adapter/Database.php @@ -478,6 +478,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'); } @@ -513,6 +514,7 @@ 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'); } diff --git a/src/Audit/Log.php b/src/Audit/Log.php index ffbf197..6c919b3 100644 --- a/src/Audit/Log.php +++ b/src/Audit/Log.php @@ -120,6 +120,7 @@ public function getTime(): string public function getData(): array { $data = $this->getAttribute('data', []); + /** @var array */ return is_array($data) ? $data : []; } diff --git a/tests/Audit/AuditBase.php b/tests/Audit/AuditBase.php index 823f4a5..12a6dd9 100644 --- a/tests/Audit/AuditBase.php +++ b/tests/Audit/AuditBase.php @@ -248,8 +248,6 @@ public function testLogByBatch(): void ] ]; - $batchEvents = $this->applyRequiredAttributesToBatch($batchEvents); - // Test batch insertion $batchEvents = $this->applyRequiredAttributesToBatch($batchEvents); From 5b403f4a0520491864b8a29db325e1199cb7bf93 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 17 May 2026 07:38:20 +0000 Subject: [PATCH 06/18] refactor(clickhouse): use Schema\ClickHouse for setup() DDL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-built CREATE TABLE in `setup()` with a `Schema\ClickHouse` table builder. Engine, ORDER BY tuple, PARTITION BY expression, table settings, columns, and data-skipping (bloom_filter) indexes are now declared through the schema API; the resulting Statement is executed verbatim through the existing HTTP layer. Column definitions follow the audit attribute descriptors: - `id String` (primary) - `time DateTime64(3)` (NOT NULL — partition key) - ` [Nullable(]String[)]` for every other attribute, nullability driven by the descriptor's `required` flag - `tenant Nullable(UInt64)` when sharedTables is on CREATE DATABASE IF NOT EXISTS still goes through a raw string — the schema's `createDatabase()` helper has no IF NOT EXISTS form and we'd otherwise break the second `setup()` call. Also folds in a few PHPStan-driven cleanups in this file (collapsed `!empty($inParams)` guards into unconditional emits since the upstream VALUE_REQUIRED_METHODS guard already rejects empty value lists; dropped the redundant is_array($row) inside parseJsonResults whose row type was already array). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Audit/Adapter/ClickHouse.php | 96 +++++++++++++++----------------- 1 file changed, 46 insertions(+), 50 deletions(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index d0b3bf8..c5ced40 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -7,6 +7,10 @@ use Utopia\Audit\Query; use Utopia\Database\Database; use Utopia\Fetch\Client; +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; /** @@ -683,65 +687,70 @@ 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 (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); - - // 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 (time, id) - PARTITION BY toYYYYMM(time) - SETTINGS index_granularity = 8192 - "; + $table->engine(ClickHouseEngine::MergeTree); + $table->orderBy(['time', 'id']); + $table->partitionBy('toYYYYMM(time)'); + $table->settings(['index_granularity' => '8192']); + $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. @@ -1101,6 +1110,7 @@ private function parseQueries(array $queries): array $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); @@ -1219,9 +1229,7 @@ private function parseQueries(array $queries): array $inParams[] = "{{$paramName}:{$chType}}"; $params[$paramName] = $this->formatTypedValue($chType, $value); } - if (!empty($inParams)) { - $filters[] = "{$escapedAttr} IN (" . implode(', ', $inParams) . ")"; - } + $filters[] = "{$escapedAttr} IN (" . implode(', ', $inParams) . ")"; break; case Query::TYPE_NOT_CONTAINS: @@ -1234,9 +1242,7 @@ private function parseQueries(array $queries): array $inParams[] = "{{$paramName}:{$chType}}"; $params[$paramName] = $this->formatTypedValue($chType, $value); } - if (!empty($inParams)) { - $filters[] = "{$escapedAttr} NOT IN (" . implode(', ', $inParams) . ")"; - } + $filters[] = "{$escapedAttr} NOT IN (" . implode(', ', $inParams) . ")"; break; case Query::TYPE_IS_NULL: @@ -1315,12 +1321,6 @@ private function parseQueries(array $queries): array 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(). @@ -1761,10 +1761,6 @@ private function parseJsonResults(string $result): array $documents = []; foreach ($data as $row) { - if (!is_array($row)) { - continue; - } - $document = []; foreach ($row as $columnName => $value) { From de0fa3b24ab20fc3a230b2d82df0b315afe338fc Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 17 May 2026 07:39:44 +0000 Subject: [PATCH 07/18] refactor(clickhouse): use Builder INSERT FORMAT JSONEachRow for createBatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the JSONEachRow INSERT statement through `Builder\ClickHouse::insertFormat` instead of a hand-rolled string. Column list is derived from the audit schema (id, time, the remaining attributes, optional tenant) in the same insertion order as the row maps so the JSON keys line up against ClickHouse's declared columns. The JSONEachRow body itself still serializes in the adapter — that's an HTTP-payload concern that stays in the runtime layer. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Audit/Adapter/ClickHouse.php | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index c5ced40..c673d74 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -7,6 +7,7 @@ use Utopia\Audit\Query; use Utopia\Database\Database; use Utopia\Fetch\Client; +use Utopia\Query\Builder\ClickHouse as ClickHouseBuilder; use Utopia\Query\Schema\ClickHouse as ClickHouseSchema; use Utopia\Query\Schema\ClickHouse\Engine as ClickHouseEngine; use Utopia\Query\Schema\ClickHouse\IndexAlgorithm; @@ -1642,7 +1643,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(); @@ -1726,9 +1727,25 @@ 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'; + } + + $insertSql = (new ClickHouseBuilder()) + ->into($qualifiedTable) + ->insertFormat('JSONEachRow', $columns) + ->insert() + ->query; $this->query($insertSql, [], $rows); + return true; } From 0c22e074d7c4576ee98db53c33ee517aa78066f6 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 17 May 2026 07:41:16 +0000 Subject: [PATCH 08/18] refactor(clickhouse): use Builder DELETE + SETTINGS for cleanup() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compile the cleanup DELETE through `Builder\ClickHouse::delete()` with a trailing SETTINGS clause emitted by the builder when async cleanup is enabled. The DELETE WHERE expression is supplied via `whereRaw()` so the ClickHouse-typed parameter syntax (`{datetime:DateTime64(3)}`) and the existing tenant filter stay in the runtime layer — the builder still emits generic `?` placeholders today (dry-run gap #7). Behaviour note: the ClickHouse builder compiles DELETE as `ALTER TABLE ... DELETE` (mutation) instead of the lightweight `DELETE FROM ...` the previous string emitted. The async setting tracks the same shift: `mutations_sync = 0` for mutations replaces the previous `lightweight_deletes_sync = 0`. End-state row visibility is the same; the storage path is different (mutations rewrite parts, lightweight deletes mask rows). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Audit/Adapter/ClickHouse.php | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index c673d74..fcbfc58 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -2151,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 = (new ClickHouseBuilder()) + ->into($qualifiedTable) + ->whereRaw($escapedTimeColumn . ' < {datetime:DateTime64(3)}'); - $sql = " - DELETE FROM {$escapedTable} - WHERE time < {datetime:String}{$tenantFilter}{$settings} - "; + $tenantFilter = $this->getTenantFilter(); + if ($tenantFilter !== '') { + $builder->whereRaw(ltrim($tenantFilter, ' AND')); + } + + if ($this->asyncCleanup) { + $builder->settings(['mutations_sync' => '0']); + } + + $sql = $builder->delete()->query; $this->query($sql, ['datetime' => $datetimeString]); From f74231dc86104654282de511eb01d34d07c8b323 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 17 May 2026 07:43:56 +0000 Subject: [PATCH 09/18] refactor(clickhouse): use Builder for find/count/getById SQL shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route the SELECT skeleton (FROM, raw projection, WHERE conjunction, ORDER BY) for `find()`, `count()`, and `getById()` through `Builder\ClickHouse`. Filter expressions emitted by `parseQueries()` still carry ClickHouse-typed parameter hints (`{name:Type}`) and the audit-side associative params map; both ride along via `whereRaw()` / `orderByRaw()` so the typed-binding HTTP layer keeps working. `LIMIT` / `OFFSET` and the trailing `FORMAT JSON` / `FORMAT TabSeparated` are appended on the compiled SQL string — the base builder emits positional `?` placeholders for limit/offset, which would collide with the ClickHouse `{name:UInt64}` placeholders the runtime layer binds. Cleaning that up belongs with the gap #7 (ClickHouse param hint) work on utopia-php/query. Cursor pagination still builds tuple WHERE fragments through the existing `buildCursorWhere()` helper and feeds them in via `whereRaw()` (gap #8, deferred). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Audit/Adapter/ClickHouse.php | 116 ++++++++++++++++--------------- 1 file changed, 59 insertions(+), 57 deletions(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index fcbfc58..c37b873 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -874,17 +874,20 @@ public function create(array $log): Log public function getById(string $id): ?Log { $tableName = $this->getTableName(); - $tenantFilter = $this->getTenantFilter(); - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + $qualifiedTable = $this->database . '.' . $tableName; $escapedId = $this->escapeIdentifier('id'); - $sql = " - SELECT " . $this->getSelectColumns() . " - FROM {$escapedTable} - WHERE {$escapedId} = {id:String}{$tenantFilter} - LIMIT 1 - FORMAT JSON - "; + $builder = (new ClickHouseBuilder()) + ->from($qualifiedTable) + ->selectRaw($this->getSelectColumns()) + ->whereRaw($escapedId . ' = {id:String}'); + + $tenantFilter = $this->getTenantFilter(); + if ($tenantFilter !== '') { + $builder->whereRaw(ltrim($tenantFilter, ' AND')); + } + + $sql = $builder->build()->query . ' LIMIT 1 FORMAT JSON'; $result = $this->query($sql, ['id' => $id]); $logs = $this->parseJsonResults($result); @@ -902,7 +905,7 @@ 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); @@ -935,41 +938,45 @@ public function find(array $queries = []): array $params = $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); + if ($tenantFilter !== '') { + $filters[] = ltrim($tenantFilter, ' AND'); + } + + $builder = (new ClickHouseBuilder()) + ->from($qualifiedTable) + ->selectRaw($selectColumns); + + foreach ($filters as $filter) { + $builder->whereRaw($filter); } - // 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 = ''; + // ORDER BY. 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`. if (!empty($parsed['randomOrder'])) { - $orderClause = ' ORDER BY rand()'; + $builder->orderByRaw('rand()'); } elseif (isset($parsed['cursor'])) { - $orderSql = $this->buildOrderBySql($orderAttributes, flip: $cursorDirection === 'before'); - $orderClause = ' ORDER BY ' . implode(', ', $orderSql); + foreach ($this->buildOrderBySql($orderAttributes, flip: $cursorDirection === 'before') as $orderFragment) { + $builder->orderByRaw($orderFragment); + } } elseif (!empty($parsed['orderBy'])) { - $orderClause = ' ORDER BY ' . implode(', ', $parsed['orderBy']); + foreach ($parsed['orderBy'] as $orderFragment) { + $builder->orderByRaw($orderFragment); + } } - // Build LIMIT and OFFSET - $limitClause = isset($parsed['limit']) ? ' LIMIT {limit:UInt64}' : ''; - $offsetClause = isset($parsed['offset']) ? ' OFFSET {offset:UInt64}' : ''; + $sql = $builder->build()->query; - $sql = " - SELECT {$selectColumns} - FROM {$escapedTable}{$whereClause}{$orderClause}{$limitClause}{$offsetClause} - FORMAT JSON - "; + if (isset($parsed['limit'])) { + $sql .= ' LIMIT {limit:UInt64}'; + } + if (isset($parsed['offset'])) { + $sql .= ' OFFSET {offset:UInt64}'; + } + $sql .= ' FORMAT JSON'; $result = $this->query($sql, $params); $rows = $this->parseJsonResults($result); @@ -1046,41 +1053,36 @@ 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 = ''; + $filters = $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 !== '') { + $filters[] = ltrim($tenantFilter, ' AND'); } + $inner = (new ClickHouseBuilder()) + ->from($qualifiedTable) + ->selectRaw($max !== null ? '1' : 'COUNT(*) AS count'); + + foreach ($filters as $filter) { + $inner->whereRaw($filter); + } + + $innerSql = $inner->build()->query; + // 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 - "; + $sql = 'SELECT COUNT(*) AS count FROM (' . $innerSql . ' LIMIT {max:UInt64}) sub FORMAT TabSeparated'; } else { - $sql = " - SELECT COUNT(*) as count - FROM {$escapedTable}{$whereClause} - FORMAT TabSeparated - "; + $sql = $innerSql . ' FORMAT TabSeparated'; } $result = $this->query($sql, $params); From 07bb282939e33ed9fc454a983319f3decc94d51f Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 17 May 2026 07:44:57 +0000 Subject: [PATCH 10/18] test(clickhouse): add SQL snapshot tests for migrated builder paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the SQL emitted by `Schema\ClickHouse` and `Builder\ClickHouse` through the audit configurations the adapter relies on: - `setup()` CREATE TABLE — engine, columns, indexes, ORDER BY, PARTITION BY, SETTINGS - `createBatch()` `INSERT ... FORMAT JSONEachRow` with the audit column list - `cleanup()` async DELETE with trailing SETTINGS clause - `cleanup()` sync DELETE with no SETTINGS - `find()` SELECT with whereRaw filters, cursor tuple comparison, ORDER BY tiebreaker, LIMIT and FORMAT JSON tail These do not require a live ClickHouse so they run as fast unit tests and prevent silent SQL drift from a query-lib upgrade. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Adapter/ClickHouseSqlSnapshotTest.php | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php diff --git a/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php b/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php new file mode 100644 index 0000000..b48caff --- /dev/null +++ b/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php @@ -0,0 +1,131 @@ +table('default.audits'); + $table->string('id')->primary(); + $table->string('userId')->nullable(); + $table->string('event'); + $table->string('resource')->nullable(); + $table->string('userAgent'); + $table->string('ip'); + $table->string('location')->nullable(); + $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: ['userId', 'event'], + name: 'idx_userId_event', + 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('`userId` Nullable(String)', $sql); + $this->assertStringContainsString('`event` String', $sql); + $this->assertStringContainsString('`time` DateTime64(3)', $sql); + $this->assertStringContainsString('INDEX `idx_event` `event` TYPE bloom_filter GRANULARITY 1', $sql); + $this->assertStringContainsString('INDEX `idx_userId_event` (`userId`, `event`) 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 testInsertFormatJsonEachRowSnapshot(): void + { + $columns = ['id', 'time', 'userId', 'event', 'data']; + $sql = (new ClickHouseBuilder()) + ->into('default.audits') + ->insertFormat('JSONEachRow', $columns) + ->insert() + ->query; + + $this->assertEquals( + 'INSERT INTO `default`.`audits` (`id`, `time`, `userId`, `event`, `data`) FORMAT JSONEachRow', + $sql, + ); + } + + public function testAsyncCleanupDeleteEmitsSettingsClause(): void + { + $sql = (new ClickHouseBuilder()) + ->into('default.audits') + ->whereRaw('`time` < {datetime:DateTime64(3)}') + ->settings(['mutations_sync' => '0']) + ->delete() + ->query; + + $this->assertEquals( + 'ALTER TABLE `default`.`audits` DELETE WHERE `time` < {datetime:DateTime64(3)} SETTINGS mutations_sync=0', + $sql, + ); + } + + public function testSyncCleanupDeleteOmitsSettingsClause(): void + { + $sql = (new ClickHouseBuilder()) + ->into('default.audits') + ->whereRaw('`time` < {datetime:DateTime64(3)}') + ->delete() + ->query; + + $this->assertEquals( + 'ALTER TABLE `default`.`audits` DELETE WHERE `time` < {datetime:DateTime64(3)}', + $sql, + ); + } + + public function testFindSelectWithCursorAndOrderRaw(): void + { + $builder = (new ClickHouseBuilder()) + ->from('default.audits') + ->selectRaw('`id`, `event`, `time`') + ->whereRaw('`userId` = {param_0:String}') + ->whereRaw('(`time` < {cursor_cmp_0:DateTime64(3)}) OR (`time` = {cursor_eq_1_0:DateTime64(3)} AND `id` < {cursor_cmp_1:String})') + ->orderByRaw('`time` DESC') + ->orderByRaw('`id` DESC'); + + $sql = $builder->build()->query . ' LIMIT {limit:UInt64} FORMAT JSON'; + + $expected = 'SELECT `id`, `event`, `time` FROM `default`.`audits` ' + . 'WHERE `userId` = {param_0:String} AND ' + . '(`time` < {cursor_cmp_0:DateTime64(3)}) OR (`time` = {cursor_eq_1_0:DateTime64(3)} AND `id` < {cursor_cmp_1:String}) ' + . 'ORDER BY `time` DESC, `id` DESC ' + . 'LIMIT {limit:UInt64} FORMAT JSON'; + + $this->assertEquals($expected, $sql); + } +} From 5f7981f48b260e26eba16a8a638cff7b517e5b70 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 17 May 2026 09:38:33 +0000 Subject: [PATCH 11/18] refactor(clickhouse): register typed bindings on Builder\ClickHouse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump utopia-php/query to the upstream branch HEAD that ships named-typed {name:Type} placeholder support and the lightweight DELETE form, then add two helpers to the adapter: - getColumnTypeMap() derives a column → ClickHouse-type map from the schema attributes (DateTime → DateTime64(3), all other columns → String) plus id, tenant (when sharedTables is enabled) and the limit/offset/max pseudo-columns used by the count/find SQL wrappers. - newBuilder() returns a Builder\ClickHouse with useNamedBindings() and withParamTypes() pre-applied, so positional `?` bindings flow through the typed-binding rewriter at Statement-emission time. Every existing adapter call site is rerouted from `new ClickHouseBuilder()` to `$this->newBuilder()`. The current call sites all hand-construct their {name:Type} placeholders inside whereRaw fragments with zero `?` bindings, so this change is a no-op for the existing SQL shape — it just preps the infra that the find()/count()/getById() reads will switch to next. --- composer.lock | 8 ++--- src/Audit/Adapter/ClickHouse.php | 58 +++++++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/composer.lock b/composer.lock index 8a4fc3d..8544c15 100644 --- a/composer.lock +++ b/composer.lock @@ -2388,12 +2388,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/query.git", - "reference": "605376809ecd61051cbd1235c14606a0b9d59a1c" + "reference": "c77f2280f4e899236d737dd0ea8f54398fd20710" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/query/zipball/605376809ecd61051cbd1235c14606a0b9d59a1c", - "reference": "605376809ecd61051cbd1235c14606a0b9d59a1c", + "url": "https://api.github.com/repos/utopia-php/query/zipball/c77f2280f4e899236d737dd0ea8f54398fd20710", + "reference": "c77f2280f4e899236d737dd0ea8f54398fd20710", "shasum": "" }, "require": { @@ -2429,7 +2429,7 @@ "issues": "https://github.com/utopia-php/query/issues", "source": "https://github.com/utopia-php/query/tree/feat/clickhouse-insert-delete-settings-mv" }, - "time": "2026-05-17T07:12:35+00:00" + "time": "2026-05-17T09:28:32+00:00" }, { "name": "utopia-php/telemetry", diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index c37b873..eb86934 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -554,6 +554,54 @@ private function getTableName(): string return $tableName; } + /** + * 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 using Fetch Client. * @@ -877,7 +925,7 @@ public function getById(string $id): ?Log $qualifiedTable = $this->database . '.' . $tableName; $escapedId = $this->escapeIdentifier('id'); - $builder = (new ClickHouseBuilder()) + $builder = $this->newBuilder() ->from($qualifiedTable) ->selectRaw($this->getSelectColumns()) ->whereRaw($escapedId . ' = {id:String}'); @@ -943,7 +991,7 @@ public function find(array $queries = []): array $filters[] = ltrim($tenantFilter, ' AND'); } - $builder = (new ClickHouseBuilder()) + $builder = $this->newBuilder() ->from($qualifiedTable) ->selectRaw($selectColumns); @@ -1064,7 +1112,7 @@ public function count(array $queries = [], ?int $max = null): int $filters[] = ltrim($tenantFilter, ' AND'); } - $inner = (new ClickHouseBuilder()) + $inner = $this->newBuilder() ->from($qualifiedTable) ->selectRaw($max !== null ? '1' : 'COUNT(*) AS count'); @@ -1740,7 +1788,7 @@ public function createBatch(array $logs): bool $columns[] = 'tenant'; } - $insertSql = (new ClickHouseBuilder()) + $insertSql = $this->newBuilder() ->into($qualifiedTable) ->insertFormat('JSONEachRow', $columns) ->insert() @@ -2157,7 +2205,7 @@ public function cleanup(\DateTime $datetime): bool $escapedTimeColumn = $this->escapeIdentifier('time'); $datetimeString = $datetime->format('Y-m-d H:i:s.v'); - $builder = (new ClickHouseBuilder()) + $builder = $this->newBuilder() ->into($qualifiedTable) ->whereRaw($escapedTimeColumn . ' < {datetime:DateTime64(3)}'); From 3d02fe685147a5c24437532d3dcd21b5928e3402 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 17 May 2026 09:44:10 +0000 Subject: [PATCH 12/18] refactor(clickhouse): migrate find/count/getById reads to builder filter API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop every hand-built WHERE / ORDER BY fragment in find(), count() and getById() and feed Query value objects straight to Builder\ClickHouse via filter(), sortAsc/sortDesc/sortRandom, limit() and offset(). Positional `?` bindings are rewritten to `{paramN:Type}` placeholders by the typed-binding registration installed in newBuilder(), so HTTP params now flow through $statement->namedBindings instead of a hand-maintained $paramCounter dict. parseQueries() is reduced to a list of Query objects plus auxiliary metadata (orderAttributes, limit, offset, cursor, select). Two audit-specific rewrites stay in this layer: - Contains / NotContains are remapped to Equal / NotEqual so they keep audit's historical IN / NOT IN semantics. The base builder compiles Contains to substring-match `position(x, ?) > 0`, which is not what callers like Audit::getByUserAndEvents() expect. - `time`-column DateTime values are stringified at parse time so they appear in namedBindings as `Y-m-d H:i:s.v` literals rather than raw objects the HTTP layer can't serialise. Cursor pagination keeps its whereRaw escape hatch with explicit {name:Type} placeholders — Builder\ClickHouse still has no tuple-compare helper, so the existing buildCursorWhere() output is appended to the builder and its params are merged into the final HTTP request alongside $statement->namedBindings. The dead buildOrderBySql() helper is removed; applyOrderBy() drives the builder directly. --- src/Audit/Adapter/ClickHouse.php | 417 ++++++++++--------------------- 1 file changed, 128 insertions(+), 289 deletions(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index eb86934..e0617ec 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -8,6 +8,8 @@ use Utopia\Database\Database; use Utopia\Fetch\Client; use Utopia\Query\Builder\ClickHouse as ClickHouseBuilder; +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; @@ -923,21 +925,22 @@ public function getById(string $id): ?Log { $tableName = $this->getTableName(); $qualifiedTable = $this->database . '.' . $tableName; - $escapedId = $this->escapeIdentifier('id'); $builder = $this->newBuilder() ->from($qualifiedTable) ->selectRaw($this->getSelectColumns()) - ->whereRaw($escapedId . ' = {id:String}'); + ->filter([Query::equal('id', $id)]) + ->limit(1); $tenantFilter = $this->getTenantFilter(); if ($tenantFilter !== '') { $builder->whereRaw(ltrim($tenantFilter, ' AND')); } - $sql = $builder->build()->query . ' LIMIT 1 FORMAT JSON'; + $statement = $builder->build(); + $sql = $statement->query . ' FORMAT JSON'; - $result = $this->query($sql, ['id' => $id]); + $result = $this->query($sql, $statement->namedBindings ?? []); $logs = $this->parseJsonResults($result); return $logs[0] ?? null; @@ -955,7 +958,6 @@ public function find(array $queries = []): array $tableName = $this->getTableName(); $qualifiedTable = $this->database . '.' . $tableName; - // Parse queries $parsed = $this->parseQueries($queries); // Random ordering can't combine with anything that asks for a @@ -963,68 +965,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'] ?? []; - $cursorDirection = $parsed['cursorDirection'] ?? null; - - if (isset($parsed['cursor'])) { - $orderAttributes = $this->resolveCursorOrder($orderAttributes); - $cursorWhere = $this->buildCursorWhere($orderAttributes, $parsed['cursor'], $cursorDirection ?? 'after', $params); - $filters[] = $cursorWhere['clause']; - $params = $cursorWhere['params']; - } + $builder = $this->newBuilder() + ->from($qualifiedTable) + ->selectRaw($selectColumns) + ->filter($parsed['filters']); $tenantFilter = $this->getTenantFilter(); if ($tenantFilter !== '') { - $filters[] = ltrim($tenantFilter, ' AND'); + $builder->whereRaw(ltrim($tenantFilter, ' AND')); } - $builder = $this->newBuilder() - ->from($qualifiedTable) - ->selectRaw($selectColumns); + $cursorDirection = $parsed['cursorDirection'] ?? null; + $orderAttributes = $parsed['orderAttributes']; + $cursorParams = []; - foreach ($filters as $filter) { - $builder->whereRaw($filter); + if (isset($parsed['cursor'])) { + $orderAttributes = $this->resolveCursorOrder($orderAttributes); + $cursorWhere = $this->buildCursorWhere($orderAttributes, $parsed['cursor'], $cursorDirection ?? 'after', []); + $builder->whereRaw($cursorWhere['clause']); + $cursorParams = $cursorWhere['params']; } // ORDER BY. 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`. - if (!empty($parsed['randomOrder'])) { - $builder->orderByRaw('rand()'); + // 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'])) { - foreach ($this->buildOrderBySql($orderAttributes, flip: $cursorDirection === 'before') as $orderFragment) { - $builder->orderByRaw($orderFragment); - } - } elseif (!empty($parsed['orderBy'])) { - foreach ($parsed['orderBy'] as $orderFragment) { - $builder->orderByRaw($orderFragment); - } + $this->applyOrderBy($builder, $orderAttributes, flip: $cursorDirection === 'before'); + } else { + $this->applyOrderBy($builder, $orderAttributes); } - $sql = $builder->build()->query; - if (isset($parsed['limit'])) { - $sql .= ' LIMIT {limit:UInt64}'; + $builder->limit($parsed['limit']); } if (isset($parsed['offset'])) { - $sql .= ' OFFSET {offset:UInt64}'; + $builder->offset($parsed['offset']); } - $sql .= ' FORMAT JSON'; + + $statement = $builder->build(); + $sql = $statement->query . ' FORMAT JSON'; + $params = ($statement->namedBindings ?? []) + $cursorParams; $result = $this->query($sql, $params); $rows = $this->parseJsonResults($result); @@ -1103,36 +1095,29 @@ public function count(array $queries = [], ?int $max = null): int $tableName = $this->getTableName(); $qualifiedTable = $this->database . '.' . $tableName; - // Parse queries - we only need filters and params, not ordering/limit/offset/cursor $parsed = $this->parseQueries($queries); - $filters = $parsed['filters']; - $tenantFilter = $this->getTenantFilter(); - if ($tenantFilter !== '') { - $filters[] = ltrim($tenantFilter, ' AND'); - } - $inner = $this->newBuilder() ->from($qualifiedTable) - ->selectRaw($max !== null ? '1' : 'COUNT(*) AS count'); + ->selectRaw($max !== null ? '1' : 'COUNT(*) AS count') + ->filter($parsed['filters']); - foreach ($filters as $filter) { - $inner->whereRaw($filter); + $tenantFilter = $this->getTenantFilter(); + if ($tenantFilter !== '') { + $inner->whereRaw(ltrim($tenantFilter, ' AND')); } - $innerSql = $inner->build()->query; - - // 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 (' . $innerSql . ' LIMIT {max:UInt64}) sub FORMAT TabSeparated'; - } else { - $sql = $innerSql . ' 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); @@ -1140,17 +1125,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; @@ -1158,7 +1154,6 @@ 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 */ @@ -1170,211 +1165,43 @@ private function parseQueries(array $queries): array $method = $query->getMethod()->value; $attribute = $query->getAttribute(); - /** @var string $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); - } - $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); - } - $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: - // 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 === '') { @@ -1389,22 +1216,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; @@ -1413,7 +1233,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: @@ -1421,18 +1240,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'; @@ -1442,18 +1259,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; } @@ -1474,6 +1283,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. * @@ -1657,29 +1519,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. * From 27240556753c885126bd99955c70f65bf8ea25a0 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 17 May 2026 09:45:03 +0000 Subject: [PATCH 13/18] fix(clickhouse): use lightweight DELETE FROM for cleanup() (default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builder\ClickHouse now defaults to DELETE_MODE_LIGHTWEIGHT, so cleanup() emits `DELETE FROM t WHERE …` again — matching audit's pre-migration baseline. The previous mutation form (`ALTER TABLE t DELETE WHERE …`) was a workaround forced by the older builder API and changed the storage-path semantics: lightweight marks rows deleted via a mask and is the right tool for row-level cleanup, while mutations rewrite parts on disk and are heavier. The async SETTINGS knob switches from `mutations_sync = 0` to `lightweight_deletes_sync = 0` to match the new DELETE form. The public setAsyncCleanup() docblock already referenced lightweight_deletes_sync, so no docs change is needed. --- src/Audit/Adapter/ClickHouse.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index e0617ec..8a41cb2 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -2054,7 +2054,7 @@ public function cleanup(\DateTime $datetime): bool } if ($this->asyncCleanup) { - $builder->settings(['mutations_sync' => '0']); + $builder->settings(['lightweight_deletes_sync' => '0']); } $sql = $builder->delete()->query; From 622b30ef9ad40a66b2e49eb00d56fefb024a451d Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 17 May 2026 09:46:05 +0000 Subject: [PATCH 14/18] test(clickhouse): update snapshots for typed reads and lightweight DELETE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the new SQL shape on the adapter's hot paths: - cleanup() emits `DELETE FROM t WHERE …` with an optional trailing `SETTINGS lightweight_deletes_sync=0` for the async path. - find() / count() filter via Builder\ClickHouse::filter() and sortAsc/sortDesc/limit, producing typed `{paramN:Type}` placeholders and a populated `namedBindings` map on the Statement. - The cursor whereRaw fragment with explicit `{name:Type}` placeholders composes cleanly with the typed positional bindings — both end up in the final HTTP params dict by the adapter merging them. - count(max) wraps the inner Statement in `SELECT COUNT(*) FROM (… LIMIT ?) sub` and reuses the inner builder's namedBindings unchanged. A small newAuditBuilder() / auditTypeMap() helper mirrors the adapter's own column → ClickHouse-type registration so the snapshot tests exercise the same typed-binding flow callers will see in production. --- .../Adapter/ClickHouseSqlSnapshotTest.php | 121 +++++++++++++++--- 1 file changed, 106 insertions(+), 15 deletions(-) diff --git a/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php b/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php index b48caff..d6378d4 100644 --- a/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php +++ b/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use Utopia\Query\Builder\ClickHouse as ClickHouseBuilder; +use Utopia\Query\Query; use Utopia\Query\Schema\ClickHouse as ClickHouseSchema; use Utopia\Query\Schema\ClickHouse\Engine as ClickHouseEngine; use Utopia\Query\Schema\ClickHouse\IndexAlgorithm; @@ -17,6 +18,32 @@ */ class ClickHouseSqlSnapshotTest extends TestCase { + /** + * @return array + */ + private function auditTypeMap(): array + { + return [ + 'id' => 'String', + 'userId' => 'String', + 'event' => 'String', + 'resource' => 'String', + 'userAgent' => 'String', + 'ip' => 'String', + 'location' => '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(); @@ -84,12 +111,12 @@ public function testAsyncCleanupDeleteEmitsSettingsClause(): void $sql = (new ClickHouseBuilder()) ->into('default.audits') ->whereRaw('`time` < {datetime:DateTime64(3)}') - ->settings(['mutations_sync' => '0']) + ->settings(['lightweight_deletes_sync' => '0']) ->delete() ->query; $this->assertEquals( - 'ALTER TABLE `default`.`audits` DELETE WHERE `time` < {datetime:DateTime64(3)} SETTINGS mutations_sync=0', + 'DELETE FROM `default`.`audits` WHERE `time` < {datetime:DateTime64(3)} SETTINGS lightweight_deletes_sync=0', $sql, ); } @@ -103,29 +130,93 @@ public function testSyncCleanupDeleteOmitsSettingsClause(): void ->query; $this->assertEquals( - 'ALTER TABLE `default`.`audits` DELETE WHERE `time` < {datetime:DateTime64(3)}', + 'DELETE FROM `default`.`audits` WHERE `time` < {datetime:DateTime64(3)}', $sql, ); } - public function testFindSelectWithCursorAndOrderRaw(): void + public function testFindEmitsTypedNamedBindings(): void { - $builder = (new ClickHouseBuilder()) + $statement = $this->newAuditBuilder() ->from('default.audits') ->selectRaw('`id`, `event`, `time`') - ->whereRaw('`userId` = {param_0:String}') - ->whereRaw('(`time` < {cursor_cmp_0:DateTime64(3)}) OR (`time` = {cursor_eq_1_0:DateTime64(3)} AND `id` < {cursor_cmp_1:String})') - ->orderByRaw('`time` DESC') - ->orderByRaw('`id` DESC'); + ->filter([ + Query::equal('userId', ['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 `userId` 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, + ); + } - $sql = $builder->build()->query . ' LIMIT {limit:UInt64} FORMAT JSON'; + 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}))'; - $expected = 'SELECT `id`, `event`, `time` FROM `default`.`audits` ' - . 'WHERE `userId` = {param_0:String} AND ' - . '(`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('userId', ['u1'])]) + ->whereRaw($cursorClause) + ->sortDesc('time') + ->sortDesc('id') + ->limit(25) + ->build(); + + $expectedSql = 'SELECT `id`, `event`, `time` FROM `default`.`audits` ' + . 'WHERE `userId` IN ({param0:String}) ' + . 'AND ' . $cursorClause . ' ' . 'ORDER BY `time` DESC, `id` DESC ' - . 'LIMIT {limit:UInt64} FORMAT JSON'; + . '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('userId', ['u1'])]) + ->limit(5000) + ->build(); - $this->assertEquals($expected, $sql); + $sql = 'SELECT COUNT(*) AS count FROM (' . $inner->query . ') sub FORMAT TabSeparated'; + + $this->assertEquals( + 'SELECT COUNT(*) AS count FROM (' + . 'SELECT 1 FROM `default`.`audits` WHERE `userId` IN ({param0:String}) LIMIT {param1:Int64}' + . ') sub FORMAT TabSeparated', + $sql, + ); + $this->assertSame( + ['param0' => 'u1', 'param1' => 5000], + $inner->namedBindings, + ); } } From e22ca38378a02997a78fcbb133dd5aeeaadc52d3 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 17 May 2026 09:54:35 +0000 Subject: [PATCH 15/18] chore(ci): bump test image to PHP 8.4 to match composer.json requirement The Dockerfile pinned php:8.3.3-cli-alpine3.19, but composer.json requires php >=8.4. Once utopia-php/query: ^0.3.0 was adopted, the CI Tests check failed because the library uses 8.4-only asymmetric visibility syntax (public protected(set)). Bump the test image to php:8.4.21-cli-alpine3.23 so the CI environment matches the package requirement. --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 754dc9f56da28dde1f822da1f0e62b95b97082cf Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 18 May 2026 01:49:11 +0000 Subject: [PATCH 16/18] fix(clickhouse): restore is_array() guard on parseJsonResults rows ClickHouse JSON responses come from json_decode and aren't statically guaranteed to satisfy any inner shape. The pre-migration code skipped non-array rows defensively; restore that guard and loosen the @var on \$data to array so PHPStan max accepts the runtime check instead of pruning it as already-narrowed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Audit/Adapter/ClickHouse.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index 8a41cb2..3c49841 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -1662,13 +1662,18 @@ private function parseJsonResults(string $result): array return []; } - /** @var array> $data */ + /** @var array $data */ $data = $decoded['data']; $documents = []; foreach ($data as $row) { + if (!is_array($row)) { + continue; + } + $document = []; + /** @var array $row */ foreach ($row as $columnName => $value) { if ($columnName === 'data') { // Decode JSON data column From 75c48f5e30a9b9f2c7aed370516a1be4d6f0f8ee Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 18 May 2026 01:49:18 +0000 Subject: [PATCH 17/18] test(clickhouse): pin NOT IN snapshot for notContains multi-value Adds a snapshot test that locks the SQL emitted when TYPE_NOT_CONTAINS is mapped to Method::NotEqual with multiple values. The base builder compiles NotEqual with 2+ non-null values via compileNotIn(), producing `attr NOT IN (?, ?)`; with named typed bindings on Builder\ClickHouse this becomes `attr NOT IN ({param0:Type}, {param1:Type})`. Pinning the shape guards against a query-lib drift silently degrading multi-value notContains() to a single-value `!=` filter. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Adapter/ClickHouseSqlSnapshotTest.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php b/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php index d6378d4..e6ee260 100644 --- a/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php +++ b/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php @@ -166,6 +166,32 @@ public function testFindEmitsTypedNamedBindings(): void ); } + 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)}) ' From 9a698ebb285a62920a9612e0a65f7c934de4b6f7 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 20 May 2026 05:31:07 +0000 Subject: [PATCH 18/18] chore(deps): bump utopia-php/query pin to ^0.3.2 (tagged release) utopia-php/query 0.3.2 is now tagged and published with all ClickHouse builder/schema features this PR depends on. Switch from the temporary dev-branch alias to the stable semver constraint. Co-Authored-By: Claude Opus 4.7 (1M context) --- composer.json | 2 +- composer.lock | 27 +++++++++------------------ 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/composer.json b/composer.json index 1ca5300..1b60278 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "php": ">=8.4", "utopia-php/database": "5.*", "utopia-php/fetch": "^1.1", - "utopia-php/query": "dev-feat/clickhouse-insert-delete-settings-mv as 0.3.2", + "utopia-php/query": "^0.3.2", "utopia-php/validators": "0.2.*" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 8544c15..67081b8 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": "ce81cb6886b4c53426c0c0f609d8c180", + "content-hash": "f029651a8f1ea00a7fd6d4312821ec2f", "packages": [ { "name": "brick/math", @@ -2384,16 +2384,16 @@ }, { "name": "utopia-php/query", - "version": "dev-feat/clickhouse-insert-delete-settings-mv", + "version": "0.3.2", "source": { "type": "git", "url": "https://github.com/utopia-php/query.git", - "reference": "c77f2280f4e899236d737dd0ea8f54398fd20710" + "reference": "84d31822f57adc9ee60d6d5a07fa4a5175278550" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/query/zipball/c77f2280f4e899236d737dd0ea8f54398fd20710", - "reference": "c77f2280f4e899236d737dd0ea8f54398fd20710", + "url": "https://api.github.com/repos/utopia-php/query/zipball/84d31822f57adc9ee60d6d5a07fa4a5175278550", + "reference": "84d31822f57adc9ee60d6d5a07fa4a5175278550", "shasum": "" }, "require": { @@ -2427,9 +2427,9 @@ ], "support": { "issues": "https://github.com/utopia-php/query/issues", - "source": "https://github.com/utopia-php/query/tree/feat/clickhouse-insert-delete-settings-mv" + "source": "https://github.com/utopia-php/query/tree/0.3.2" }, - "time": "2026-05-17T09:28:32+00:00" + "time": "2026-05-20T05:27:42+00:00" }, { "name": "utopia-php/telemetry", @@ -4445,18 +4445,9 @@ "time": "2025-11-17T20:03:58+00:00" } ], - "aliases": [ - { - "package": "utopia-php/query", - "version": "dev-feat/clickhouse-insert-delete-settings-mv", - "alias": "0.3.2", - "alias_normalized": "0.3.2.0" - } - ], + "aliases": [], "minimum-stability": "stable", - "stability-flags": { - "utopia-php/query": 20 - }, + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": {