diff --git a/packages/audit/CHANGELOG.md b/packages/audit/CHANGELOG.md index afb8bd84..8b693391 100644 --- a/packages/audit/CHANGELOG.md +++ b/packages/audit/CHANGELOG.md @@ -2,6 +2,32 @@ All notable changes to `utopia-php/audit` are documented in this file. +## Unreleased + +### ClickHouse adapter — migrated to the utopia-php/query 0.3 builder + +#### Changed + +- `utopia-php/query` bumped from `0.1.*` to `0.3.*` (locked at 0.3.3). +- `setup()` builds its DDL through `Utopia\Query\Schema\ClickHouse` instead of + hand-assembled SQL. Column types, `LowCardinality(...)` / `Nullable(...)` + wrapping, bloom-filter indexes, engine, `ORDER BY`, `PARTITION BY` and + `SETTINGS` are all emitted by the schema builder. The retention `MODIFY TTL` + / `REMOVE TTL` statements are unchanged. +- `find()`, `count()`, `getById()`, `createBatch()` and `cleanup()` build their + SQL through `Utopia\Query\Builder\ClickHouse`. Positional bindings are + rewritten to typed `{paramN:Type}` ClickHouse placeholders from a column → + type map derived from `getAttributes()`. +- `createBatch()` uses `Builder\ClickHouse::bulkInsert(Format::JSONEachRow, …)` + to emit the `INSERT … FORMAT JSONEachRow` envelope and serialize the body. +- `Query::getMethod()` now returns the `Utopia\Query\Method` enum (upstream + 0.3 change). `Utopia\Audit\Query` continues to expose the legacy `TYPE_*` + string constants, which map to the same string values. + +Filter semantics are unchanged: `contains` / `notContains` remain substring +matches (now compiled to ClickHouse `position(col, ?) > 0` / `= 0` rather than +`LIKE '%needle%'`, which also removes the need for wildcard escaping). + ## 2.9.0 ### ClickHouse adapter — user-agent columns diff --git a/packages/audit/composer.json b/packages/audit/composer.json index 9e657277..15510e36 100644 --- a/packages/audit/composer.json +++ b/packages/audit/composer.json @@ -29,7 +29,7 @@ "php": ">=8.5", "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.3.*" }, "config": { diff --git a/packages/audit/composer.lock b/packages/audit/composer.lock index e54f2ade..c3100efe 100644 --- a/packages/audit/composer.lock +++ b/packages/audit/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": "4a91c87b103bb97847d08d3c77925bea", + "content-hash": "3e5c0d05eb866f51d6e31c4edbb46546", "packages": [ { "name": "brick/math", @@ -144,23 +144,23 @@ }, { "name": "google/protobuf", - "version": "v4.33.6", + "version": "v5.35.1", "source": { "type": "git", "url": "https://github.com/protocolbuffers/protobuf-php.git", - "reference": "84b008c23915ed94536737eae46f41ba3bccfe67" + "reference": "55bb4a7d6739b5af0927b96213c1371a3afb7cfb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/84b008c23915ed94536737eae46f41ba3bccfe67", - "reference": "84b008c23915ed94536737eae46f41ba3bccfe67", + "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/55bb4a7d6739b5af0927b96213c1371a3afb7cfb", + "reference": "55bb4a7d6739b5af0927b96213c1371a3afb7cfb", "shasum": "" }, "require": { - "php": ">=8.1.0" + "php": ">=8.2.0" }, "require-dev": { - "phpunit/phpunit": ">=10.5.62 <11.0.0" + "phpunit/phpunit": ">=11.5.0 <12.0.0" }, "suggest": { "ext-bcmath": "Need to support JSON deserialization" @@ -182,9 +182,9 @@ "proto" ], "support": { - "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.6" + "source": "https://github.com/protocolbuffers/protobuf-php/tree/v5.35.1" }, - "time": "2026-03-18T17:32:05+00:00" + "time": "2026-06-11T21:19:23+00:00" }, { "name": "mongodb/mongodb", @@ -409,16 +409,16 @@ }, { "name": "open-telemetry/api", - "version": "1.9.0", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be" + "reference": "7c029c4a6fd457094a20569bf98f93d95e9a7559" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/6f8d237ce2c304ca85f31970f788e7f074d147be", - "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/7c029c4a6fd457094a20569bf98f93d95e9a7559", + "reference": "7c029c4a6fd457094a20569bf98f93d95e9a7559", "shasum": "" }, "require": { @@ -475,7 +475,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2026-02-25T13:24:05+00:00" + "time": "2026-07-06T12:28:04+00:00" }, { "name": "open-telemetry/context", @@ -602,20 +602,20 @@ }, { "name": "open-telemetry/gen-otlp-protobuf", - "version": "1.9.0", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/gen-otlp-protobuf.git", - "reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9" + "reference": "66f04d0e448ad333033bfc7baae1aa56330be088" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/a229cf161d42001d64c8f21e8f678581fe1c66b9", - "reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9", + "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/66f04d0e448ad333033bfc7baae1aa56330be088", + "reference": "66f04d0e448ad333033bfc7baae1aa56330be088", "shasum": "" }, "require": { - "google/protobuf": "^3.22 || ^4.0", + "google/protobuf": "^3.22 || ^4.0 || ^5.0", "php": "^8.0" }, "suggest": { @@ -661,20 +661,20 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-10-19T06:44:33+00:00" + "time": "2026-06-17T12:06:32+00:00" }, { "name": "open-telemetry/sdk", - "version": "1.14.0", + "version": "1.15.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sdk.git", - "reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410" + "reference": "77e1aa73850154abb86937d52a70883edc3b4547" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/6e3d0ce93e76555dd5e2f1d19443ff45b990e410", - "reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/77e1aa73850154abb86937d52a70883edc3b4547", + "reference": "77e1aa73850154abb86937d52a70883edc3b4547", "shasum": "" }, "require": { @@ -682,7 +682,7 @@ "nyholm/psr7-server": "^1.1", "open-telemetry/api": "^1.8", "open-telemetry/context": "^1.4", - "open-telemetry/sem-conv": "^1.0", + "open-telemetry/sem-conv": "^1.38.0", "php": "^8.1", "php-http/discovery": "^1.14", "psr/http-client": "^1.0", @@ -705,7 +705,10 @@ "spi": { "OpenTelemetry\\API\\Configuration\\ConfigEnv\\EnvComponentLoader": [ "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderHttpConfig", - "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderPeerConfig" + "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderPeerConfig", + "OpenTelemetry\\SDK\\ConfigEnv\\Trace\\SpanSuppressionStrategySemConv", + "OpenTelemetry\\SDK\\ConfigEnv\\Trace\\SpanSuppressionStrategySpanKind", + "OpenTelemetry\\SDK\\ConfigEnv\\Distribution\\DistributionConfigurationSdk" ], "OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\ResolverInterface": [ "OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\SdkConfigurationResolver" @@ -715,7 +718,7 @@ ] }, "branch-alias": { - "dev-main": "1.12.x-dev" + "dev-main": "1.14.x-dev" } }, "autoload": { @@ -758,7 +761,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2026-03-21T11:50:01+00:00" + "time": "2026-07-14T13:09:54+00:00" }, { "name": "open-telemetry/sem-conv", @@ -2197,16 +2200,16 @@ }, { "name": "utopia-php/database", - "version": "6.4.3", + "version": "6.4.5", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "d5ebecc2f4518329e3e1c5ded3769638908c78ba" + "reference": "aa3fe328c1704f2dab4606b0b3a6316abeabbc56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/d5ebecc2f4518329e3e1c5ded3769638908c78ba", - "reference": "d5ebecc2f4518329e3e1c5ded3769638908c78ba", + "url": "https://api.github.com/repos/utopia-php/database/zipball/aa3fe328c1704f2dab4606b0b3a6316abeabbc56", + "reference": "aa3fe328c1704f2dab4606b0b3a6316abeabbc56", "shasum": "" }, "require": { @@ -2251,9 +2254,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/6.4.3" + "source": "https://github.com/utopia-php/database/tree/6.4.5" }, - "time": "2026-07-13T13:24:23+00:00" + "time": "2026-07-23T09:53:11+00:00" }, { "name": "utopia-php/fetch", @@ -2297,16 +2300,16 @@ }, { "name": "utopia-php/mongo", - "version": "1.3.0", + "version": "1.3.2", "source": { "type": "git", "url": "https://github.com/utopia-php/mongo.git", - "reference": "44eeb4db9bfcfddccd6e757668a519d9541e65c9" + "reference": "34d9ab406a87d2006b0e108796fdb6b5397853d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/44eeb4db9bfcfddccd6e757668a519d9541e65c9", - "reference": "44eeb4db9bfcfddccd6e757668a519d9541e65c9", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/34d9ab406a87d2006b0e108796fdb6b5397853d3", + "reference": "34d9ab406a87d2006b0e108796fdb6b5397853d3", "shasum": "" }, "require": { @@ -2352,22 +2355,22 @@ ], "support": { "issues": "https://github.com/utopia-php/mongo/issues", - "source": "https://github.com/utopia-php/mongo/tree/1.3.0" + "source": "https://github.com/utopia-php/mongo/tree/1.3.2" }, - "time": "2026-07-08T04:09:01+00:00" + "time": "2026-07-20T09:02:13+00:00" }, { "name": "utopia-php/pools", - "version": "1.0.9", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/utopia-php/pools.git", - "reference": "13346168ca7097d9b220872a017c9ad8c5dac62f" + "reference": "fd5aa7e89685ffc74f9dff3d23c270ef30c7bbf3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/pools/zipball/13346168ca7097d9b220872a017c9ad8c5dac62f", - "reference": "13346168ca7097d9b220872a017c9ad8c5dac62f", + "url": "https://api.github.com/repos/utopia-php/pools/zipball/fd5aa7e89685ffc74f9dff3d23c270ef30c7bbf3", + "reference": "fd5aa7e89685ffc74f9dff3d23c270ef30c7bbf3", "shasum": "" }, "require": { @@ -2378,7 +2381,10 @@ "swoole/ide-helper": "6.*" }, "suggest": { - "ext-swoole": "Required to use the Swoole pool adapter" + "ext-mongodb": "Needed to support MongoDB database pools", + "ext-pdo": "Needed to support MariaDB, MySQL or SQLite database pools", + "ext-redis": "Needed to support Redis cache pools", + "ext-swoole": "Needed to support Swoole based pool adapter" }, "type": "library", "autoload": { @@ -2405,30 +2411,33 @@ ], "support": { "issues": "https://github.com/utopia-php/pools/issues", - "source": "https://github.com/utopia-php/pools/tree/1.0.9" + "source": "https://github.com/utopia-php/pools/tree/1.1.0" }, - "time": "2026-06-26T10:48:18+00:00" + "time": "2026-07-17T10:19:09+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,9 +2460,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/0.3.3" }, - "time": "2026-03-03T09:05:14+00:00" + "time": "2026-07-06T04:55:56+00:00" }, { "name": "utopia-php/telemetry", @@ -2508,16 +2517,16 @@ }, { "name": "utopia-php/validators", - "version": "0.3.0", + "version": "0.3.1", "source": { "type": "git", "url": "https://github.com/utopia-php/validators.git", - "reference": "5a3ef67ce17c2e148da7895e8de699614f0afdfa" + "reference": "d9c2269ebd2596a09681ccd2fd133eeedfcdf9ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/validators/zipball/5a3ef67ce17c2e148da7895e8de699614f0afdfa", - "reference": "5a3ef67ce17c2e148da7895e8de699614f0afdfa", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/d9c2269ebd2596a09681ccd2fd133eeedfcdf9ba", + "reference": "d9c2269ebd2596a09681ccd2fd133eeedfcdf9ba", "shasum": "" }, "require": { @@ -2542,9 +2551,9 @@ ], "support": { "issues": "https://github.com/utopia-php/validators/issues", - "source": "https://github.com/utopia-php/validators/tree/0.3.0" + "source": "https://github.com/utopia-php/validators/tree/0.3.1" }, - "time": "2026-07-13T09:46:33+00:00" + "time": "2026-07-14T11:55:48+00:00" } ], "packages-dev": [], diff --git a/packages/audit/phpunit.xml b/packages/audit/phpunit.xml index 2a61dfa8..aee3fdc3 100755 --- a/packages/audit/phpunit.xml +++ b/packages/audit/phpunit.xml @@ -7,9 +7,12 @@ tests/Audit/QueryTest.php + + tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php tests/Audit/Adapter + tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php diff --git a/packages/audit/src/Audit/Adapter/ClickHouse.php b/packages/audit/src/Audit/Adapter/ClickHouse.php index d7c7e813..4476d9e2 100644 --- a/packages/audit/src/Audit/Adapter/ClickHouse.php +++ b/packages/audit/src/Audit/Adapter/ClickHouse.php @@ -7,6 +7,14 @@ use Utopia\Audit\Query; use Utopia\Database\Database; use Utopia\Fetch\Client; +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; /** @@ -55,25 +63,25 @@ class ClickHouse extends SQL * arrays for these methods are rejected up front so they can't silently * compile into a "no filter applied" WHERE clause. * - * @var list + * @var list */ private const array VALUE_REQUIRED_METHODS = [ - Query::TYPE_EQUAL, - Query::TYPE_NOT_EQUAL, - Query::TYPE_LESSER, - Query::TYPE_LESSER_EQUAL, - Query::TYPE_GREATER, - Query::TYPE_GREATER_EQUAL, - Query::TYPE_BETWEEN, - Query::TYPE_NOT_BETWEEN, - Query::TYPE_CONTAINS, - Query::TYPE_NOT_CONTAINS, - Query::TYPE_STARTS_WITH, - Query::TYPE_NOT_STARTS_WITH, - Query::TYPE_ENDS_WITH, - Query::TYPE_NOT_ENDS_WITH, - Query::TYPE_REGEX, - Query::TYPE_SELECT, + Method::Equal, + Method::NotEqual, + Method::LessThan, + Method::LessThanEqual, + Method::GreaterThan, + Method::GreaterThanEqual, + Method::Between, + Method::NotBetween, + Method::Contains, + Method::NotContains, + Method::StartsWith, + Method::NotStartsWith, + Method::EndsWith, + Method::NotEndsWith, + Method::Regex, + Method::Select, ]; private readonly string $host; @@ -854,6 +862,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. * @@ -864,21 +920,23 @@ 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'; @@ -886,21 +944,10 @@ private function query(string $sql, array $params = [], ?array $jsonRows = null) $this->client->addHeader('X-ClickHouse-Database', $this->database); try { - if ($jsonRows !== null) { - // JSON body mode for INSERT operations with JSONEachRow format + if ($rawBody !== null) { + // Pre-serialized body mode for FORMAT INSERT operations $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(), $e->getCode(), $e); - } - $jsonLines[] = $encoded; - } - $body = implode("\n", $jsonLines); + $body = $rawBody; } else { // Parameterized query mode using multipart form data $url = "{$scheme}://{$this->host}:{$this->port}/"; @@ -987,59 +1034,69 @@ 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', - ]; + $tableName = $this->getTableName(); + $qualifiedTable = $this->database . '.' . $tableName; + $escapedDatabaseAndTable = $escapedDatabase . '.' . $this->escapeIdentifier($tableName); + + // Build the DDL through Schema\ClickHouse so column types, LowCardinality + // wrappers, nullability, indexes, engine and settings all come from the + // typed builder rather than hand-assembled SQL fragments. + $schema = new ClickHouseSchema(); + $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 - $columns[] = $id === 'time' ? 'time DateTime64(3)' : $this->getColumnDefinition($id); + if ($id === 'time') { + $table->datetime('time', precision: 3); + + continue; + } + + $column = $table->addColumn($id, $this->mapAttributeType($attribute)); + 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 + // 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($this->escapeIdentifier(...), $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); + $table->engine(ClickHouseEngine::MergeTree); + $table->orderBy($this->sharedTables ? ['tenant', 'time', 'id'] : ['time', 'id']); + $table->partitionBy('toYYYYMM(time)'); - $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' : '') . ' - '; + $settings = ['index_granularity' => '8192']; + if ($this->sharedTables) { + $settings['allow_nullable_key'] = '1'; + } + $table->settings($settings); - $this->query($createTableSql); + $this->query($table->createIfNotExists()->query); // Apply retention as a separate, idempotent ALTER. CREATE TABLE IF NOT // EXISTS won't add a TTL to a table that already exists, and MODIFY TTL @@ -1070,6 +1127,18 @@ public function setup(): void } } + /** + * 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. @@ -1198,19 +1267,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; @@ -1226,9 +1299,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 @@ -1236,10 +1308,10 @@ 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'] && $parsed['orderAttributes'] !== []) { throw new Exception('orderRandom cannot be combined with orderAsc/orderDesc'); } @@ -1247,53 +1319,49 @@ public function find(array $queries = []): array // 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 !== '' && $tenantFilter !== '0') { - $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); @@ -1369,43 +1437,32 @@ 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 + // Parse queries - we only need filters, 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 !== '' && $tenantFilter !== '0') { - $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); @@ -1413,17 +1470,31 @@ 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. * - * @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} + * 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()`. Filter semantics are unchanged from the + * hand-written SQL this replaces: + * + * - `Equal` with multiple values compiles to `IN (...)`. + * - `Contains` / `NotContains` stay substring matches — the ClickHouse + * builder emits `position(col, ?) > 0` / `= 0`, which is equivalent to + * the previous `LIKE '%v%'` / `NOT LIKE '%v%'` form but needs no + * wildcard escaping. + * - `Regex` compiles to ClickHouse's re2 `match(col, ?)` predicate. + * - `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} * @throws Exception */ private function parseQueries(array $queries): array { $filters = []; - $params = []; - $orderBy = []; $orderAttributes = []; $limit = null; $offset = null; @@ -1431,7 +1502,6 @@ private function parseQueries(array $queries): array $cursorDirection = null; $select = null; $randomOrder = false; - $paramCounter = 0; foreach ($queries as $query) { if (!$query instanceof Query) { @@ -1450,208 +1520,33 @@ private function parseQueries(array $queries): array // otherwise turn `Query::contains('attr', [])` into a full-table // match instead of an empty result. if (\in_array($method, self::VALUE_REQUIRED_METHODS, true) && $values === []) { - throw new \Exception(ucfirst($method) . ' queries require at least one value.'); + throw new \Exception(ucfirst($method->value) . ' 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: - // Substring match, mirroring utopia-php/database: each - // value becomes `LIKE '%value%'`, OR'd together. - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $conditions = []; - foreach ($values as $value) { - if (!\is_string($value)) { - throw new Exception("contains value must be a string for attribute '{$attribute}'"); - } - $paramName = 'param_' . $paramCounter++; - $conditions[] = "{$escapedAttr} LIKE {{$paramName}:String}"; - $params[$paramName] = '%' . $this->escapeLikeWildcards($value) . '%'; - } - $filters[] = '(' . implode(' OR ', $conditions) . ')'; - break; - - case Query::TYPE_NOT_CONTAINS: - // Negated substring match, mirroring utopia-php/database: - // each value becomes `NOT LIKE '%value%'`, AND'd together. - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $conditions = []; - foreach ($values as $value) { - if (!\is_string($value)) { - throw new Exception("notContains value must be a string for attribute '{$attribute}'"); - } - $paramName = 'param_' . $paramCounter++; - $conditions[] = "{$escapedAttr} NOT LIKE {{$paramName}:String}"; - $params[$paramName] = '%' . $this->escapeLikeWildcards($value) . '%'; - } - $filters[] = '(' . implode(' AND ', $conditions) . ')'; - 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: + case Method::Equal: + case Method::NotEqual: + case Method::LessThan: + case Method::LessThanEqual: + case Method::GreaterThan: + case Method::GreaterThanEqual: + case Method::Between: + case Method::NotBetween: + case Method::Contains: + case Method::NotContains: + case Method::IsNull: + case Method::IsNotNull: + case Method::StartsWith: + case Method::NotStartsWith: + case Method::EndsWith: + case Method::NotEndsWith: + case Method::Regex: $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; + $filters[] = new BaseQuery($method, $attribute, $this->normalizeFilterValues($attribute, $values)); 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: - $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; - break; - - case Query::TYPE_NOT_ENDS_WITH: - $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; - break; - - case Query::TYPE_REGEX: - $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; - break; - - case Query::TYPE_SELECT: - if (empty($values)) { + case Method::Select: + if ($values === []) { // VALUE_REQUIRED_METHODS already rejects empty values // earlier, but the explicit check keeps this branch safe // if the guard is ever bypassed. @@ -1672,45 +1567,39 @@ private function parseQueries(array $queries): array } break; - case Query::TYPE_ORDER_DESC: + case Method::OrderDesc: $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $orderBy[] = "{$escapedAttr} DESC"; $orderAttributes[] = ['attribute' => $attribute, 'direction' => 'DESC']; break; - case Query::TYPE_ORDER_ASC: + case Method::OrderAsc: $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $orderBy[] = "{$escapedAttr} ASC"; $orderAttributes[] = ['attribute' => $attribute, 'direction' => 'ASC']; break; - case Query::TYPE_ORDER_RANDOM: + case Method::OrderRandom: // 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; - case Query::TYPE_LIMIT: + case Method::Limit: if (!\is_int($values[0])) { throw new \Exception('Invalid limit value. Expected int'); } $limit = $values[0]; - $params['limit'] = $limit; break; - case Query::TYPE_OFFSET: + case Method::Offset: if (!\is_int($values[0])) { 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: + case Method::CursorAfter: + case Method::CursorBefore: if ($cursor !== null) { // Keep the first cursor encountered (matches base groupByType semantics) break; @@ -1720,25 +1609,17 @@ private function parseQueries(array $queries): array break; // no-op cursor } $cursor = $this->normalizeCursorRow($rawCursor); - $cursorDirection = $method === Query::TYPE_CURSOR_AFTER ? 'after' : 'before'; + $cursorDirection = $method === Method::CursorAfter ? 'after' : 'before'; break; } } $result = [ 'filters' => $filters, - 'params' => $params, + 'orderAttributes' => $orderAttributes, + 'randomOrder' => $randomOrder, ]; - if ($orderBy !== []) { - $result['orderBy'] = $orderBy; - $result['orderAttributes'] = $orderAttributes; - } - - if ($randomOrder) { - $result['randomOrder'] = true; - } - if ($limit !== null) { $result['limit'] = $limit; } @@ -1759,6 +1640,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. * @@ -1836,18 +1770,6 @@ private function formatTypedValue(string $chType, mixed $value): string return $this->formatParamValue($value); } - /** - * Escape ClickHouse LIKE-pattern wildcards in a user-supplied needle. - * - * Backslash is escaped first so already-escaped characters aren't - * double-escaped. Keeps `contains('event', ['100%'])` a literal - * substring match instead of a wildcard. - */ - private function escapeLikeWildcards(string $value): string - { - return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value); - } - /** * Resolve the effective order attributes for cursor pagination. * @@ -1950,29 +1872,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. * @@ -1986,7 +1885,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(); @@ -2087,9 +1986,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'; + } + + // Builder\ClickHouse::bulkInsert() emits the `INSERT INTO ... FORMAT + // JSONEachRow` envelope and serialises the rows into the request body. + $statement = $this->newBuilder() + ->into($qualifiedTable) + ->bulkInsert(Format::JSONEachRow, $rows, $columns); + + $this->query($statement->query, [], $statement->body); - $this->query($insertSql, [], $rows); return true; } @@ -2507,17 +2422,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:DateTime64(3)}{$tenantFilter}{$settings} - "; + $sql = $builder->delete()->query; $this->query($sql, ['datetime' => $datetimeString]); diff --git a/packages/audit/src/Audit/Adapter/Database.php b/packages/audit/src/Audit/Adapter/Database.php index 2f22765d..796f5981 100644 --- a/packages/audit/src/Audit/Adapter/Database.php +++ b/packages/audit/src/Audit/Adapter/Database.php @@ -10,6 +10,7 @@ use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Timeout; use Utopia\Database\Query; +use Utopia\Query\Method; /** * Database Adapter for Audit @@ -499,16 +500,16 @@ public function count(array $queries = [], ?int $max = null): int // Skip limit, offset, and cursor queries — they don't apply to count $method = $query->getMethod(); - if ($method === \Utopia\Audit\Query::TYPE_LIMIT) { + if ($method === Method::Limit) { continue; } - if ($method === \Utopia\Audit\Query::TYPE_OFFSET) { + if ($method === Method::Offset) { continue; } - if ($method === \Utopia\Audit\Query::TYPE_CURSOR_AFTER) { + if ($method === Method::CursorAfter) { continue; } - if ($method === \Utopia\Audit\Query::TYPE_CURSOR_BEFORE) { + if ($method === Method::CursorBefore) { continue; } diff --git a/packages/audit/src/Audit/Query.php b/packages/audit/src/Audit/Query.php index 4ae063b3..dfba1825 100644 --- a/packages/audit/src/Audit/Query.php +++ b/packages/audit/src/Audit/Query.php @@ -2,6 +2,7 @@ namespace Utopia\Audit; +use Utopia\Query\Method; use Utopia\Query\Query as BaseQuery; /** @@ -12,23 +13,90 @@ * `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 mixed $value Single value or array of values + * @param mixed $value Single value or array of values */ #[\Override] 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); } /** @@ -40,7 +108,7 @@ public static function equal(string $attribute, mixed $value): static #[\Override] public static function lessThan(string $attribute, mixed $value): static { - return new static(self::TYPE_LESSER, $attribute, [$value]); + return new static(Method::LessThan, $attribute, [$value]); } /** @@ -49,7 +117,7 @@ public static function lessThan(string $attribute, mixed $value): static #[\Override] public static function greaterThan(string $attribute, mixed $value): static { - return new static(self::TYPE_GREATER, $attribute, [$value]); + return new static(Method::GreaterThan, $attribute, [$value]); } /** @@ -58,6 +126,6 @@ public static function greaterThan(string $attribute, mixed $value): static #[\Override] 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/packages/audit/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php b/packages/audit/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php new file mode 100644 index 00000000..a701792a --- /dev/null +++ b/packages/audit/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php @@ -0,0 +1,345 @@ + + */ + 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')->lowCardinality(); + $table->string('actorInternalId')->nullable(); + $table->string('event')->lowCardinality(); + $table->string('resource')->nullable(); + $table->string('userAgent'); + $table->string('ip'); + // Optional low-cardinality dimension, as emitted for sdk / country / + // the premium-geo and user-agent columns added in audit 2.7–2.9. + $table->addColumn('sdk', ColumnType::String)->lowCardinality()->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: ['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` LowCardinality(String)', $sql); + $this->assertStringContainsString('`actorInternalId` Nullable(String)', $sql); + $this->assertStringContainsString('`event` LowCardinality(String)', $sql); + $this->assertStringContainsString('`sdk` LowCardinality(Nullable(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->assertSame( + '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->assertSame( + '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->assertSame( + '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->assertSame($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 testEqualMultiValueEmitsTypedIn(): 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->assertSame($expectedSql, $statement->query); + $this->assertSame( + [ + 'param0' => 'users.delete', + 'param1' => 'projects.delete', + 'param2' => 25, + ], + $statement->namedBindings, + ); + } + + /** + * `contains` is a substring match on ClickHouse — the builder emits + * `position(col, ?) > 0`, which replaces the adapter's previous + * hand-written `LIKE '%needle%'` (and needs no wildcard escaping). + */ + public function testContainsEmitsPositionPredicate(): void + { + $statement = $this->newAuditBuilder() + ->from('default.audits') + ->selectRaw('`id`, `event`, `time`') + ->filter([Query::contains('event', ['dat'])]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `event`, `time` FROM `default`.`audits` ' + . 'WHERE position(`event`, {param0:String}) > 0', + $statement->query, + ); + $this->assertSame(['param0' => 'dat'], $statement->namedBindings); + } + + public function testContainsMultiValueOrsPositionPredicates(): void + { + $statement = $this->newAuditBuilder() + ->from('default.audits') + ->selectRaw('`id`') + ->filter([Query::contains('event', ['dat', 'ins'])]) + ->build(); + + $this->assertSame( + 'SELECT `id` FROM `default`.`audits` ' + . 'WHERE (position(`event`, {param0:String}) > 0 OR position(`event`, {param1:String}) > 0)', + $statement->query, + ); + $this->assertSame(['param0' => 'dat', 'param1' => 'ins'], $statement->namedBindings); + } + + public function testNotContainsMultiValueAndsNegatedPositionPredicates(): void + { + $statement = $this->newAuditBuilder() + ->from('default.audits') + ->selectRaw('`id`') + ->filter([Query::notContains('event', ['update', 'delete'])]) + ->build(); + + $this->assertSame( + 'SELECT `id` FROM `default`.`audits` ' + . 'WHERE (position(`event`, {param0:String}) = 0 AND position(`event`, {param1:String}) = 0)', + $statement->query, + ); + $this->assertSame(['param0' => 'update', 'param1' => 'delete'], $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->assertSame($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->assertSame( + '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/packages/audit/tests/Audit/Adapter/ClickHouseTest.php b/packages/audit/tests/Audit/Adapter/ClickHouseTest.php index b95a8cdd..16e2f210 100644 --- a/packages/audit/tests/Audit/Adapter/ClickHouseTest.php +++ b/packages/audit/tests/Audit/Adapter/ClickHouseTest.php @@ -1327,8 +1327,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/packages/audit/tests/Audit/AuditBase.php b/packages/audit/tests/Audit/AuditBase.php index 6463b3b4..e0dcb10f 100644 --- a/packages/audit/tests/Audit/AuditBase.php +++ b/packages/audit/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/packages/audit/tests/Audit/QueryTest.php b/packages/audit/tests/Audit/QueryTest.php index 7c297122..f1d59fd3 100644 --- a/packages/audit/tests/Audit/QueryTest.php +++ b/packages/audit/tests/Audit/QueryTest.php @@ -16,55 +16,55 @@ public function testQueryStaticFactoryMethods(): void { // Test equal $query = Query::equal('userId', '123'); - $this->assertSame(Query::TYPE_EQUAL, $query->getMethod()); + $this->assertSame(Query::TYPE_EQUAL, $query->getMethod()->value); $this->assertSame('userId', $query->getAttribute()); $this->assertSame(['123'], $query->getValues()); // Test lessThan $query = Query::lessThan('time', '2024-01-01'); - $this->assertSame(Query::TYPE_LESSER, $query->getMethod()); + $this->assertSame(Query::TYPE_LESSER, $query->getMethod()->value); $this->assertSame('time', $query->getAttribute()); $this->assertSame(['2024-01-01'], $query->getValues()); // Test greaterThan $query = Query::greaterThan('time', '2023-01-01'); - $this->assertSame(Query::TYPE_GREATER, $query->getMethod()); + $this->assertSame(Query::TYPE_GREATER, $query->getMethod()->value); $this->assertSame('time', $query->getAttribute()); $this->assertSame(['2023-01-01'], $query->getValues()); // Test between $query = Query::between('time', '2023-01-01', '2024-01-01'); - $this->assertSame(Query::TYPE_BETWEEN, $query->getMethod()); + $this->assertSame(Query::TYPE_BETWEEN, $query->getMethod()->value); $this->assertSame('time', $query->getAttribute()); $this->assertSame(['2023-01-01', '2024-01-01'], $query->getValues()); // Test contains $query = Query::contains('event', ['create', 'update', 'delete']); - $this->assertSame(Query::TYPE_CONTAINS, $query->getMethod()); + $this->assertSame(Query::TYPE_CONTAINS, $query->getMethod()->value); $this->assertSame('event', $query->getAttribute()); $this->assertSame(['create', 'update', 'delete'], $query->getValues()); // Test orderDesc $query = Query::orderDesc('time'); - $this->assertSame(Query::TYPE_ORDER_DESC, $query->getMethod()); + $this->assertSame(Query::TYPE_ORDER_DESC, $query->getMethod()->value); $this->assertSame('time', $query->getAttribute()); $this->assertSame([], $query->getValues()); // Test orderAsc $query = Query::orderAsc('userId'); - $this->assertSame(Query::TYPE_ORDER_ASC, $query->getMethod()); + $this->assertSame(Query::TYPE_ORDER_ASC, $query->getMethod()->value); $this->assertSame('userId', $query->getAttribute()); $this->assertSame([], $query->getValues()); // Test limit $query = Query::limit(10); - $this->assertSame(Query::TYPE_LIMIT, $query->getMethod()); + $this->assertSame(Query::TYPE_LIMIT, $query->getMethod()->value); $this->assertSame('', $query->getAttribute()); $this->assertSame([10], $query->getValues()); // Test offset $query = Query::offset(5); - $this->assertSame(Query::TYPE_OFFSET, $query->getMethod()); + $this->assertSame(Query::TYPE_OFFSET, $query->getMethod()->value); $this->assertSame('', $query->getAttribute()); $this->assertSame([5], $query->getValues()); } @@ -77,7 +77,7 @@ public function testQueryParseAndToString(): void // Test parsing equal query $json = '{"method":"equal","attribute":"userId","values":["123"]}'; $query = Query::parse($json); - $this->assertSame(Query::TYPE_EQUAL, $query->getMethod()); + $this->assertSame(Query::TYPE_EQUAL, $query->getMethod()->value); $this->assertSame('userId', $query->getAttribute()); $this->assertSame(['123'], $query->getValues()); @@ -87,7 +87,7 @@ public function testQueryParseAndToString(): void $this->assertJson($json); $parsed = Query::parse($json); - $this->assertSame(Query::TYPE_EQUAL, $parsed->getMethod()); + $this->assertSame(Query::TYPE_EQUAL, $parsed->getMethod()->value); $this->assertSame('event', $parsed->getAttribute()); $this->assertSame(['create'], $parsed->getValues()); @@ -119,9 +119,9 @@ public function testQueryParseQueries(): void $this->assertInstanceOf(Query::class, $parsed[1]); $this->assertInstanceOf(Query::class, $parsed[2]); - $this->assertSame(Query::TYPE_EQUAL, $parsed[0]->getMethod()); - $this->assertSame(Query::TYPE_GREATER, $parsed[1]->getMethod()); - $this->assertSame(Query::TYPE_LIMIT, $parsed[2]->getMethod()); + $this->assertSame(Query::TYPE_EQUAL, $parsed[0]->getMethod()->value); + $this->assertSame(Query::TYPE_GREATER, $parsed[1]->getMethod()->value); + $this->assertSame(Query::TYPE_LIMIT, $parsed[2]->getMethod()->value); } /** @@ -220,7 +220,7 @@ public function testQueryToStringWithComplexValues(): void $this->assertJson($json); $parsed = Query::parse($json); - $this->assertSame(Query::TYPE_BETWEEN, $parsed->getMethod()); + $this->assertSame(Query::TYPE_BETWEEN, $parsed->getMethod()->value); $this->assertSame('time', $parsed->getAttribute()); $this->assertSame(['2023-01-01', '2024-12-31'], $parsed->getValues()); }