diff --git a/Dockerfile b/Dockerfile index 7788050..95bf73a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ COPY composer.json /src/ RUN composer install --ignore-platform-reqs --optimize-autoloader \ --no-plugins --no-scripts --prefer-dist -FROM php:8.3.3-cli-alpine3.19 AS final +FROM php:8.4.21-cli-alpine3.23 AS final LABEL maintainer="team@appwrite.io" diff --git a/composer.json b/composer.json index 32ce7fc..1ca5300 100644 --- a/composer.json +++ b/composer.json @@ -30,12 +30,12 @@ "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": { "phpunit/phpunit": "9.*", - "phpstan/phpstan": "1.*", + "phpstan/phpstan": "^2.0", "laravel/pint": "1.*" }, "config": { diff --git a/composer.lock b/composer.lock index 3e69f83..8544c15 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": "ce81cb6886b4c53426c0c0f609d8c180", "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": "c77f2280f4e899236d737dd0ea8f54398fd20710" }, "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/c77f2280f4e899236d737dd0ea8f54398fd20710", + "reference": "c77f2280f4e899236d737dd0ea8f54398fd20710", "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-17T09:28:32+00:00" }, { "name": "utopia-php/telemetry", @@ -2899,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": "*" @@ -2948,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", @@ -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": { diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index 894f721..9683f7b 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -7,6 +7,13 @@ use Utopia\Audit\Query; 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; +use Utopia\Query\Schema\ColumnType; use Utopia\Validator\Hostname; /** @@ -565,6 +572,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. * @@ -699,65 +754,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. @@ -896,19 +956,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; @@ -924,9 +988,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 @@ -934,64 +997,58 @@ public function find(array $queries = []): array // mixing column-based ORDER BY with rand() would silently drop the // column order. Reject loudly in both cases so the caller fixes the // query rather than getting unexpected results. - if (!empty($parsed['randomOrder']) && isset($parsed['cursor'])) { + if ($parsed['randomOrder'] && isset($parsed['cursor'])) { throw new Exception('Cursor pagination cannot be combined with orderRandom'); } - if (!empty($parsed['randomOrder']) && !empty($parsed['orderBy'])) { + if ($parsed['randomOrder'] && !empty($parsed['orderAttributes'])) { throw new Exception('orderRandom cannot be combined with orderAsc/orderDesc'); } - // Build SELECT clause — respect Query::select if provided, otherwise - // fall back to the full column list. $selectColumns = $this->buildProjection($parsed['select'] ?? null); - $filters = $parsed['filters']; - $params = $parsed['params']; - $orderAttributes = $parsed['orderAttributes'] ?? []; + $builder = $this->newBuilder() + ->from($qualifiedTable) + ->selectRaw($selectColumns) + ->filter($parsed['filters']); + + $tenantFilter = $this->getTenantFilter(); + if ($tenantFilter !== '') { + $builder->whereRaw(ltrim($tenantFilter, ' AND')); + } + $cursorDirection = $parsed['cursorDirection'] ?? null; + $orderAttributes = $parsed['orderAttributes']; + $cursorParams = []; if (isset($parsed['cursor'])) { $orderAttributes = $this->resolveCursorOrder($orderAttributes); - $cursorWhere = $this->buildCursorWhere($orderAttributes, $parsed['cursor'], $cursorDirection ?? 'after', $params); - $filters[] = $cursorWhere['clause']; - $params = $cursorWhere['params']; + $cursorWhere = $this->buildCursorWhere($orderAttributes, $parsed['cursor'], $cursorDirection ?? 'after', []); + $builder->whereRaw($cursorWhere['clause']); + $cursorParams = $cursorWhere['params']; } - // Build WHERE clause - $whereClause = ''; - $tenantFilter = $this->getTenantFilter(); - if (!empty($filters) || $tenantFilter) { - $conditions = $filters; - if ($tenantFilter) { - $conditions[] = ltrim($tenantFilter, ' AND'); - } - $whereClause = ' WHERE ' . implode(' AND ', $conditions); - } - - // Build ORDER BY clause. orderRandom is mutually exclusive with - // cursor and column ordering (rejected at the top of find()); when - // cursor is in play, rebuild from orderAttributes (always non-empty - // after resolveCursorOrder, which appends an id tiebreaker), - // flipping directions for `cursorBefore`. - $orderClause = ''; - if (!empty($parsed['randomOrder'])) { - $orderClause = ' ORDER BY rand()'; + // ORDER BY. orderRandom is mutually exclusive with cursor and column + // ordering (rejected above); when cursor is in play, rebuild from + // orderAttributes (always non-empty after resolveCursorOrder, which + // appends an id tiebreaker), flipping directions for `cursorBefore`. + if ($parsed['randomOrder']) { + $builder->sortRandom(); } elseif (isset($parsed['cursor'])) { - $orderSql = $this->buildOrderBySql($orderAttributes, flip: $cursorDirection === 'before'); - $orderClause = ' ORDER BY ' . implode(', ', $orderSql); - } elseif (!empty($parsed['orderBy'])) { - $orderClause = ' ORDER BY ' . implode(', ', $parsed['orderBy']); + $this->applyOrderBy($builder, $orderAttributes, flip: $cursorDirection === 'before'); + } else { + $this->applyOrderBy($builder, $orderAttributes); } - // Build LIMIT and OFFSET - $limitClause = isset($parsed['limit']) ? ' LIMIT {limit:UInt64}' : ''; - $offsetClause = isset($parsed['offset']) ? ' OFFSET {offset:UInt64}' : ''; + if (isset($parsed['limit'])) { + $builder->limit($parsed['limit']); + } + if (isset($parsed['offset'])) { + $builder->offset($parsed['offset']); + } - $sql = " - SELECT {$selectColumns} - FROM {$escapedTable}{$whereClause}{$orderClause}{$limitClause}{$offsetClause} - FORMAT JSON - "; + $statement = $builder->build(); + $sql = $statement->query . ' FORMAT JSON'; + $params = ($statement->namedBindings ?? []) + $cursorParams; $result = $this->query($sql, $params); $rows = $this->parseJsonResults($result); @@ -1068,43 +1125,31 @@ private function buildProjection(?array $select): string public function count(array $queries = [], ?int $max = null): int { $tableName = $this->getTableName(); - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); + $qualifiedTable = $this->database . '.' . $tableName; - // Parse queries - we only need filters and params, not ordering/limit/offset/cursor $parsed = $this->parseQueries($queries); - // Build WHERE clause - $whereClause = ''; + $inner = $this->newBuilder() + ->from($qualifiedTable) + ->selectRaw($max !== null ? '1' : 'COUNT(*) AS count') + ->filter($parsed['filters']); + $tenantFilter = $this->getTenantFilter(); - if (!empty($parsed['filters']) || $tenantFilter) { - $conditions = $parsed['filters']; - if ($tenantFilter) { - $conditions[] = ltrim($tenantFilter, ' AND'); - } - $whereClause = ' WHERE ' . implode(' AND ', $conditions); + if ($tenantFilter !== '') { + $inner->whereRaw(ltrim($tenantFilter, ' AND')); } - // Remove limit and offset from params as they don't apply to count - $params = $parsed['params']; - unset($params['limit'], $params['offset']); - if ($max !== null) { - $params['max'] = $max; - $sql = " - SELECT COUNT(*) as count - FROM ( - SELECT 1 FROM {$escapedTable}{$whereClause} LIMIT {max:UInt64} - ) sub - FORMAT TabSeparated - "; - } else { - $sql = " - SELECT COUNT(*) as count - FROM {$escapedTable}{$whereClause} - FORMAT TabSeparated - "; + $inner->limit($max); } + $statement = $inner->build(); + $params = $statement->namedBindings ?? []; + + $sql = $max !== null + ? 'SELECT COUNT(*) AS count FROM (' . $statement->query . ') sub FORMAT TabSeparated' + : $statement->query . ' FORMAT TabSeparated'; + $result = $this->query($sql, $params); $trimmed = trim($result); @@ -1112,17 +1157,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; @@ -1130,233 +1186,56 @@ private function parseQueries(array $queries): array $cursorDirection = null; $select = null; $randomOrder = false; - $paramCounter = 0; foreach ($queries as $query) { + /** @phpstan-ignore-next-line instanceof.alwaysTrue - runtime validation despite type hint */ if (!$query instanceof Query) { /** @phpstan-ignore-next-line ternary.alwaysTrue - runtime validation despite type hint */ $type = is_object($query) ? get_class($query) : gettype($query); throw new \InvalidArgumentException("Invalid query item: expected instance of Query, got {$type}"); } - $method = $query->getMethod(); + $method = $query->getMethod()->value; $attribute = $query->getAttribute(); /** @var string $attribute */ $attribute = $this->translateAttribute($attribute); $values = $query->getValues(); - // Reject empty values for filter methods that take values — mirrors - // the validator in utopia-php/database (Validator/Query/Filter.php) - // and prevents silently dropping the WHERE fragment, which would - // otherwise turn `Query::contains('attr', [])` into a full-table - // match instead of an empty result. if (\in_array($method, self::VALUE_REQUIRED_METHODS, true) && empty($values)) { throw new \Exception(\ucfirst($method) . ' queries require at least one value.'); } switch ($method) { case Query::TYPE_EQUAL: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - - if (count($values) > 1) { - $inParams = []; - foreach ($values as $value) { - $paramName = 'param_' . $paramCounter++; - $inParams[] = "{{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $value); - } - $filters[] = "{$escapedAttr} IN (" . implode(', ', $inParams) . ")"; - } else { - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} = {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); - } - break; - case Query::TYPE_NOT_EQUAL: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} != {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); - break; - case Query::TYPE_LESSER: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} < {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); - break; - case Query::TYPE_LESSER_EQUAL: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} <= {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); - break; - case Query::TYPE_GREATER: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} > {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); - break; - case Query::TYPE_GREATER_EQUAL: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} >= {{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $values[0] ?? null); - break; - case Query::TYPE_BETWEEN: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName1 = 'param_' . $paramCounter++; - $paramName2 = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} BETWEEN {{$paramName1}:{$chType}} AND {{$paramName2}:{$chType}}"; - $params[$paramName1] = $this->formatTypedValue($chType, $values[0] ?? null); - $params[$paramName2] = $this->formatTypedValue($chType, $values[1] ?? null); - break; - case Query::TYPE_NOT_BETWEEN: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $paramName1 = 'param_' . $paramCounter++; - $paramName2 = 'param_' . $paramCounter++; - $filters[] = "{$escapedAttr} NOT BETWEEN {{$paramName1}:{$chType}} AND {{$paramName2}:{$chType}}"; - $params[$paramName1] = $this->formatTypedValue($chType, $values[0] ?? null); - $params[$paramName2] = $this->formatTypedValue($chType, $values[1] ?? null); - break; - - case Query::TYPE_CONTAINS: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $inParams = []; - foreach ($values as $value) { - $paramName = 'param_' . $paramCounter++; - $inParams[] = "{{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $value); - } - if (!empty($inParams)) { - $filters[] = "{$escapedAttr} IN (" . implode(', ', $inParams) . ")"; - } - break; - - case Query::TYPE_NOT_CONTAINS: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $chType = $this->getParamType($attribute); - $inParams = []; - foreach ($values as $value) { - $paramName = 'param_' . $paramCounter++; - $inParams[] = "{{$paramName}:{$chType}}"; - $params[$paramName] = $this->formatTypedValue($chType, $value); - } - if (!empty($inParams)) { - $filters[] = "{$escapedAttr} NOT IN (" . implode(', ', $inParams) . ")"; - } - break; - case Query::TYPE_IS_NULL: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $filters[] = "{$escapedAttr} IS NULL"; - break; - case Query::TYPE_IS_NOT_NULL: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $filters[] = "{$escapedAttr} IS NOT NULL"; - break; - case Query::TYPE_STARTS_WITH: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $needle = $values[0] ?? null; - if (!is_string($needle)) { - throw new Exception("startsWith needle must be a string for attribute '{$attribute}'"); - } - $paramName = 'param_' . $paramCounter++; - $filters[] = "startsWith({$escapedAttr}, {{$paramName}:String})"; - $params[$paramName] = $needle; - break; - case Query::TYPE_NOT_STARTS_WITH: - $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $needle = $values[0] ?? null; - if (!is_string($needle)) { - throw new Exception("notStartsWith needle must be a string for attribute '{$attribute}'"); - } - $paramName = 'param_' . $paramCounter++; - $filters[] = "NOT startsWith({$escapedAttr}, {{$paramName}:String})"; - $params[$paramName] = $needle; - break; - case Query::TYPE_ENDS_WITH: + case Query::TYPE_NOT_ENDS_WITH: + case Query::TYPE_REGEX: $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $needle = $values[0] ?? null; - if (!is_string($needle)) { - throw new Exception("endsWith needle must be a string for attribute '{$attribute}'"); - } - $paramName = 'param_' . $paramCounter++; - $filters[] = "endsWith({$escapedAttr}, {{$paramName}:String})"; - $params[$paramName] = $needle; + $filters[] = new BaseQuery($query->getMethod(), $attribute, $this->normalizeFilterValues($attribute, $values)); break; - case Query::TYPE_NOT_ENDS_WITH: + case Query::TYPE_CONTAINS: $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $needle = $values[0] ?? null; - if (!is_string($needle)) { - throw new Exception("notEndsWith needle must be a string for attribute '{$attribute}'"); - } - $paramName = 'param_' . $paramCounter++; - $filters[] = "NOT endsWith({$escapedAttr}, {{$paramName}:String})"; - $params[$paramName] = $needle; + $filters[] = new BaseQuery(Method::Equal, $attribute, $this->normalizeFilterValues($attribute, $values)); break; - case Query::TYPE_REGEX: + case Query::TYPE_NOT_CONTAINS: $this->validateAttributeName($attribute); - $escapedAttr = $this->escapeIdentifier($attribute); - $pattern = $values[0] ?? null; - if (!is_string($pattern)) { - throw new Exception("regex pattern must be a string for attribute '{$attribute}'"); - } - $paramName = 'param_' . $paramCounter++; - // ClickHouse's `match(haystack, pattern)` is the re2-style - // regex predicate. Pattern is bound as a parameter, never - // interpolated, so it can't escape into the SQL. - $filters[] = "match({$escapedAttr}, {{$paramName}:String})"; - $params[$paramName] = $pattern; + $filters[] = new BaseQuery(Method::NotEqual, $attribute, $this->normalizeFilterValues($attribute, $values)); break; case Query::TYPE_SELECT: - if (empty($values)) { - // VALUE_REQUIRED_METHODS already rejects empty values - // earlier, but the explicit check keeps this branch safe - // if the guard is ever bypassed. - break; - } - // Multiple Query::select(...) calls combine into a single - // projection. Duplicates are removed; column names are - // validated and escaped at SQL build time in find(). $select ??= []; foreach ($values as $column) { if (!is_string($column) || $column === '') { @@ -1371,22 +1250,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; @@ -1395,7 +1267,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: @@ -1403,18 +1274,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'; @@ -1424,18 +1293,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; } @@ -1456,6 +1317,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. * @@ -1639,29 +1553,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. * @@ -1675,7 +1566,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(); @@ -1774,9 +1665,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 = $this->newBuilder() + ->into($qualifiedTable) + ->insertFormat('JSONEachRow', $columns) + ->insert() + ->query; $this->query($insertSql, [], $rows); + return true; } @@ -1804,7 +1711,7 @@ private function parseJsonResults(string $result): array return []; } - /** @var array> $data */ + /** @var array $data */ $data = $decoded['data']; $documents = []; @@ -1815,6 +1722,7 @@ private function parseJsonResults(string $result): array $document = []; + /** @var array $row */ foreach ($row as $columnName => $value) { if ($columnName === 'data') { // Decode JSON data column @@ -2192,17 +2100,24 @@ public function countByResourceAndEvents( public function cleanup(\DateTime $datetime): bool { $tableName = $this->getTableName(); - $tenantFilter = $this->getTenantFilter(); - $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($tableName); - + $qualifiedTable = $this->database . '.' . $tableName; + $escapedTimeColumn = $this->escapeIdentifier('time'); $datetimeString = $datetime->format('Y-m-d H:i:s.v'); - $settings = $this->asyncCleanup ? ' SETTINGS lightweight_deletes_sync = 0' : ''; + $builder = $this->newBuilder() + ->into($qualifiedTable) + ->whereRaw($escapedTimeColumn . ' < {datetime:DateTime64(3)}'); + + $tenantFilter = $this->getTenantFilter(); + if ($tenantFilter !== '') { + $builder->whereRaw(ltrim($tenantFilter, ' AND')); + } + + if ($this->asyncCleanup) { + $builder->settings(['lightweight_deletes_sync' => '0']); + } - $sql = " - DELETE FROM {$escapedTable} - WHERE time < {datetime:String}{$tenantFilter}{$settings} - "; + $sql = $builder->delete()->query; $this->query($sql, ['datetime' => $datetimeString]); diff --git a/src/Audit/Adapter/Database.php b/src/Audit/Adapter/Database.php index cda3383..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,12 +514,13 @@ public function count(array $queries = [], ?int $max = null): int $dbQueries = []; foreach ($queries as $query) { + /** @phpstan-ignore-next-line instanceof.alwaysTrue - runtime validation despite type hint */ if (!($query instanceof \Utopia\Audit\Query)) { throw new \Exception('Invalid query type. Expected Utopia\\Audit\\Query'); } // Skip limit, offset, and cursor queries — they don't apply to count - $method = $query->getMethod(); + $method = $query->getMethod()->value; if ( $method === \Utopia\Audit\Query::TYPE_LIMIT || $method === \Utopia\Audit\Query::TYPE_OFFSET diff --git a/src/Audit/Log.php b/src/Audit/Log.php index 3188ee7..9536397 100644 --- a/src/Audit/Log.php +++ b/src/Audit/Log.php @@ -142,6 +142,7 @@ public function getTime(): string public function getData(): array { $data = $this->getAttribute('data', []); + /** @var array */ return is_array($data) ? $data : []; } diff --git a/src/Audit/Query.php b/src/Audit/Query.php index cc2fecd..9718cdd 100644 --- a/src/Audit/Query.php +++ b/src/Audit/Query.php @@ -2,6 +2,7 @@ namespace Utopia\Audit; +use Utopia\Query\Method; use Utopia\Query\Query as BaseQuery; /** @@ -12,24 +13,89 @@ * `parse()` validation — while keeping audit's lenient single-value factory * signatures (the base requires arrays / scalars only; audit accepts mixed * including `DateTime` for the `time` column). + * + * Also re-exposes the legacy `TYPE_*` string constants the audit adapter and + * its tests have always used. The base library moved to a `Method` enum in + * 0.3.x; the constants here map to the same string values (`equal`, + * `lessThan`, etc.) so existing call sites keep working. */ class Query extends BaseQuery { + public const TYPE_EQUAL = 'equal'; + + public const TYPE_NOT_EQUAL = 'notEqual'; + + public const TYPE_LESSER = 'lessThan'; + + public const TYPE_LESSER_EQUAL = 'lessThanEqual'; + + public const TYPE_GREATER = 'greaterThan'; + + public const TYPE_GREATER_EQUAL = 'greaterThanEqual'; + + public const TYPE_BETWEEN = 'between'; + + public const TYPE_NOT_BETWEEN = 'notBetween'; + + public const TYPE_CONTAINS = 'contains'; + + public const TYPE_NOT_CONTAINS = 'notContains'; + + public const TYPE_IS_NULL = 'isNull'; + + public const TYPE_IS_NOT_NULL = 'isNotNull'; + + public const TYPE_STARTS_WITH = 'startsWith'; + + public const TYPE_NOT_STARTS_WITH = 'notStartsWith'; + + public const TYPE_ENDS_WITH = 'endsWith'; + + public const TYPE_NOT_ENDS_WITH = 'notEndsWith'; + + public const TYPE_REGEX = 'regex'; + + public const TYPE_SELECT = 'select'; + + public const TYPE_ORDER_DESC = 'orderDesc'; + + public const TYPE_ORDER_ASC = 'orderAsc'; + + public const TYPE_ORDER_RANDOM = 'orderRandom'; + + public const TYPE_LIMIT = 'limit'; + + public const TYPE_OFFSET = 'offset'; + + public const TYPE_CURSOR_AFTER = 'cursorAfter'; + + public const TYPE_CURSOR_BEFORE = 'cursorBefore'; + + /** + * Construct a query with a string method name (legacy `TYPE_*` constants) + * or a `Method` enum case (new 0.3.x API). + * + * @param array $values + */ + public function __construct(Method|string $method, string $attribute = '', array $values = []) + { + parent::__construct($method, $attribute, $values); + } + /** * Filter by equal condition. * * Accepts a single scalar/object/array value and stores it as the values * array. Matches the legacy audit signature. * - * @param string $attribute - * @param mixed $value Single value or array of values - * @return static + * @param mixed $value Single value or array of values */ public static function equal(string $attribute, mixed $value): static { /** @var array $values */ $values = is_array($value) ? $value : [$value]; - return new static(self::TYPE_EQUAL, $attribute, $values); + + return new static(Method::Equal, $attribute, $values); } /** @@ -37,38 +103,25 @@ public static function equal(string $attribute, mixed $value): static * * Accepts mixed (including `DateTime` for the `time` column); the * adapter handles type-specific formatting. - * - * @param string $attribute - * @param mixed $value - * @return static */ public static function lessThan(string $attribute, mixed $value): static { - return new static(self::TYPE_LESSER, $attribute, [$value]); + return new static(Method::LessThan, $attribute, [$value]); } /** * Filter by greater than condition. - * - * @param string $attribute - * @param mixed $value - * @return static */ public static function greaterThan(string $attribute, mixed $value): static { - return new static(self::TYPE_GREATER, $attribute, [$value]); + return new static(Method::GreaterThan, $attribute, [$value]); } /** * Filter by BETWEEN condition. - * - * @param string $attribute - * @param mixed $start - * @param mixed $end - * @return static */ public static function between(string $attribute, mixed $start, mixed $end): static { - return new static(self::TYPE_BETWEEN, $attribute, [$start, $end]); + return new static(Method::Between, $attribute, [$start, $end]); } } diff --git a/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php b/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php new file mode 100644 index 0000000..6c7dd8c --- /dev/null +++ b/tests/Audit/Adapter/ClickHouseSqlSnapshotTest.php @@ -0,0 +1,261 @@ + + */ + private function auditTypeMap(): array + { + return [ + 'id' => 'String', + 'actorId' => 'String', + 'actorType' => 'String', + 'actorInternalId' => 'String', + 'event' => 'String', + 'resource' => 'String', + 'userAgent' => 'String', + 'ip' => 'String', + 'time' => 'DateTime64(3)', + 'data' => 'String', + 'tenant' => 'UInt64', + ]; + } + + private function newAuditBuilder(): ClickHouseBuilder + { + return (new ClickHouseBuilder()) + ->useNamedBindings() + ->withParamTypes($this->auditTypeMap()); + } + + public function testSetupCreateTableSnapshot(): void + { + $schema = new ClickHouseSchema(); + $table = $schema->table('default.audits'); + $table->string('id')->primary(); + $table->string('actorId')->nullable(); + $table->string('actorType'); + $table->string('actorInternalId')->nullable(); + $table->string('event'); + $table->string('resource')->nullable(); + $table->string('userAgent'); + $table->string('ip'); + $table->datetime('time', precision: 3); + $table->addColumn('data', ColumnType::String)->nullable(); + + $table->index( + columns: ['event'], + name: 'idx_event', + algorithm: IndexAlgorithm::BloomFilter, + granularity: 1, + ); + $table->index( + columns: ['actorId', 'event'], + name: 'idx_actorId_event', + algorithm: IndexAlgorithm::BloomFilter, + granularity: 1, + ); + $table->index( + columns: ['actorType'], + name: '_key_actor_type', + algorithm: IndexAlgorithm::BloomFilter, + granularity: 1, + ); + + $table->engine(ClickHouseEngine::MergeTree); + $table->orderBy(['time', 'id']); + $table->partitionBy('toYYYYMM(time)'); + $table->settings(['index_granularity' => '8192']); + + $sql = $table->createIfNotExists()->query; + + $this->assertStringContainsString('CREATE TABLE IF NOT EXISTS `default`.`audits`', $sql); + $this->assertStringContainsString('`id` String', $sql); + $this->assertStringContainsString('`actorId` Nullable(String)', $sql); + $this->assertStringContainsString('`actorType` String', $sql); + $this->assertStringContainsString('`actorInternalId` Nullable(String)', $sql); + $this->assertStringContainsString('`event` String', $sql); + $this->assertStringContainsString('`time` DateTime64(3)', $sql); + $this->assertStringNotContainsString('`location`', $sql); + $this->assertStringNotContainsString('`userId`', $sql); + $this->assertStringContainsString('INDEX `idx_event` `event` TYPE bloom_filter GRANULARITY 1', $sql); + $this->assertStringContainsString('INDEX `idx_actorId_event` (`actorId`, `event`) TYPE bloom_filter GRANULARITY 1', $sql); + $this->assertStringContainsString('INDEX `_key_actor_type` `actorType` TYPE bloom_filter GRANULARITY 1', $sql); + $this->assertStringContainsString('ENGINE = MergeTree()', $sql); + $this->assertStringContainsString('PARTITION BY toYYYYMM(time)', $sql); + $this->assertStringContainsString('ORDER BY (`time`, `id`)', $sql); + $this->assertStringContainsString('SETTINGS index_granularity = 8192', $sql); + } + + public function testInsertFormatJsonEachRowSnapshot(): void + { + $columns = ['id', 'time', 'actorId', 'actorType', 'event', 'data']; + $sql = (new ClickHouseBuilder()) + ->into('default.audits') + ->insertFormat('JSONEachRow', $columns) + ->insert() + ->query; + + $this->assertEquals( + 'INSERT INTO `default`.`audits` (`id`, `time`, `actorId`, `actorType`, `event`, `data`) FORMAT JSONEachRow', + $sql, + ); + } + + public function testAsyncCleanupDeleteEmitsSettingsClause(): void + { + $sql = (new ClickHouseBuilder()) + ->into('default.audits') + ->whereRaw('`time` < {datetime:DateTime64(3)}') + ->settings(['lightweight_deletes_sync' => '0']) + ->delete() + ->query; + + $this->assertEquals( + 'DELETE FROM `default`.`audits` WHERE `time` < {datetime:DateTime64(3)} SETTINGS lightweight_deletes_sync=0', + $sql, + ); + } + + public function testSyncCleanupDeleteOmitsSettingsClause(): void + { + $sql = (new ClickHouseBuilder()) + ->into('default.audits') + ->whereRaw('`time` < {datetime:DateTime64(3)}') + ->delete() + ->query; + + $this->assertEquals( + 'DELETE FROM `default`.`audits` WHERE `time` < {datetime:DateTime64(3)}', + $sql, + ); + } + + public function testFindEmitsTypedNamedBindings(): void + { + $statement = $this->newAuditBuilder() + ->from('default.audits') + ->selectRaw('`id`, `event`, `time`') + ->filter([ + Query::equal('actorId', ['u1']), + Query::between('time', '2025-01-01 00:00:00.000', '2025-12-31 00:00:00.000'), + ]) + ->sortDesc('time') + ->limit(25) + ->build(); + + $expectedSql = 'SELECT `id`, `event`, `time` FROM `default`.`audits` ' + . 'WHERE `actorId` IN ({param0:String}) ' + . 'AND `time` BETWEEN {param1:DateTime64(3)} AND {param2:DateTime64(3)} ' + . 'ORDER BY `time` DESC ' + . 'LIMIT {param3:Int64}'; + + $this->assertEquals($expectedSql, $statement->query); + $this->assertSame( + [ + 'param0' => 'u1', + 'param1' => '2025-01-01 00:00:00.000', + 'param2' => '2025-12-31 00:00:00.000', + 'param3' => 25, + ], + $statement->namedBindings, + ); + } + + public function testNotContainsMultiValueEmitsTypedNotIn(): void + { + $statement = $this->newAuditBuilder() + ->from('default.audits') + ->selectRaw('`id`, `event`, `time`') + ->filter([ + Query::notEqual('event', ['users.delete', 'projects.delete']), + ]) + ->limit(25) + ->build(); + + $expectedSql = 'SELECT `id`, `event`, `time` FROM `default`.`audits` ' + . 'WHERE `event` NOT IN ({param0:String}, {param1:String}) ' + . 'LIMIT {param2:Int64}'; + + $this->assertEquals($expectedSql, $statement->query); + $this->assertSame( + [ + 'param0' => 'users.delete', + 'param1' => 'projects.delete', + 'param2' => 25, + ], + $statement->namedBindings, + ); + } + + public function testFindCursorRawFragmentMergesWithTypedBindings(): void + { + $cursorClause = '((`time` < {cursor_cmp_0:DateTime64(3)}) ' + . 'OR (`time` = {cursor_eq_1_0:DateTime64(3)} AND `id` < {cursor_cmp_1:String}))'; + + $statement = $this->newAuditBuilder() + ->from('default.audits') + ->selectRaw('`id`, `event`, `time`') + ->filter([Query::equal('actorId', ['u1'])]) + ->whereRaw($cursorClause) + ->sortDesc('time') + ->sortDesc('id') + ->limit(25) + ->build(); + + $expectedSql = 'SELECT `id`, `event`, `time` FROM `default`.`audits` ' + . 'WHERE `actorId` IN ({param0:String}) ' + . 'AND ' . $cursorClause . ' ' + . 'ORDER BY `time` DESC, `id` DESC ' + . 'LIMIT {param1:Int64}'; + + $this->assertEquals($expectedSql, $statement->query); + $this->assertSame( + [ + 'param0' => 'u1', + 'param1' => 25, + ], + $statement->namedBindings, + ); + } + + public function testCountWithMaxWrapsInnerSelect(): void + { + $inner = $this->newAuditBuilder() + ->from('default.audits') + ->selectRaw('1') + ->filter([Query::equal('actorId', ['u1'])]) + ->limit(5000) + ->build(); + + $sql = 'SELECT COUNT(*) AS count FROM (' . $inner->query . ') sub FORMAT TabSeparated'; + + $this->assertEquals( + 'SELECT COUNT(*) AS count FROM (' + . 'SELECT 1 FROM `default`.`audits` WHERE `actorId` IN ({param0:String}) LIMIT {param1:Int64}' + . ') sub FORMAT TabSeparated', + $sql, + ); + $this->assertSame( + ['param0' => 'u1', 'param1' => 5000], + $inner->namedBindings, + ); + } +} diff --git a/tests/Audit/AuditBase.php b/tests/Audit/AuditBase.php index 30531b1..4415fd7 100644 --- a/tests/Audit/AuditBase.php +++ b/tests/Audit/AuditBase.php @@ -240,8 +240,6 @@ public function testLogByBatch(): void ] ]; - $batchEvents = $this->applyRequiredAttributesToBatch($batchEvents); - // Test batch insertion $batchEvents = $this->applyRequiredAttributesToBatch($batchEvents); diff --git a/tests/Audit/QueryTest.php b/tests/Audit/QueryTest.php index d151039..68f3fc2 100644 --- a/tests/Audit/QueryTest.php +++ b/tests/Audit/QueryTest.php @@ -14,55 +14,55 @@ public function testQueryStaticFactoryMethods(): void { // Test equal $query = Query::equal('userId', '123'); - $this->assertEquals(Query::TYPE_EQUAL, $query->getMethod()); + $this->assertEquals(Query::TYPE_EQUAL, $query->getMethod()->value); $this->assertEquals('userId', $query->getAttribute()); $this->assertEquals(['123'], $query->getValues()); // Test lessThan $query = Query::lessThan('time', '2024-01-01'); - $this->assertEquals(Query::TYPE_LESSER, $query->getMethod()); + $this->assertEquals(Query::TYPE_LESSER, $query->getMethod()->value); $this->assertEquals('time', $query->getAttribute()); $this->assertEquals(['2024-01-01'], $query->getValues()); // Test greaterThan $query = Query::greaterThan('time', '2023-01-01'); - $this->assertEquals(Query::TYPE_GREATER, $query->getMethod()); + $this->assertEquals(Query::TYPE_GREATER, $query->getMethod()->value); $this->assertEquals('time', $query->getAttribute()); $this->assertEquals(['2023-01-01'], $query->getValues()); // Test between $query = Query::between('time', '2023-01-01', '2024-01-01'); - $this->assertEquals(Query::TYPE_BETWEEN, $query->getMethod()); + $this->assertEquals(Query::TYPE_BETWEEN, $query->getMethod()->value); $this->assertEquals('time', $query->getAttribute()); $this->assertEquals(['2023-01-01', '2024-01-01'], $query->getValues()); // Test contains $query = Query::contains('event', ['create', 'update', 'delete']); - $this->assertEquals(Query::TYPE_CONTAINS, $query->getMethod()); + $this->assertEquals(Query::TYPE_CONTAINS, $query->getMethod()->value); $this->assertEquals('event', $query->getAttribute()); $this->assertEquals(['create', 'update', 'delete'], $query->getValues()); // Test orderDesc $query = Query::orderDesc('time'); - $this->assertEquals(Query::TYPE_ORDER_DESC, $query->getMethod()); + $this->assertEquals(Query::TYPE_ORDER_DESC, $query->getMethod()->value); $this->assertEquals('time', $query->getAttribute()); $this->assertEquals([], $query->getValues()); // Test orderAsc $query = Query::orderAsc('userId'); - $this->assertEquals(Query::TYPE_ORDER_ASC, $query->getMethod()); + $this->assertEquals(Query::TYPE_ORDER_ASC, $query->getMethod()->value); $this->assertEquals('userId', $query->getAttribute()); $this->assertEquals([], $query->getValues()); // Test limit $query = Query::limit(10); - $this->assertEquals(Query::TYPE_LIMIT, $query->getMethod()); + $this->assertEquals(Query::TYPE_LIMIT, $query->getMethod()->value); $this->assertEquals('', $query->getAttribute()); $this->assertEquals([10], $query->getValues()); // Test offset $query = Query::offset(5); - $this->assertEquals(Query::TYPE_OFFSET, $query->getMethod()); + $this->assertEquals(Query::TYPE_OFFSET, $query->getMethod()->value); $this->assertEquals('', $query->getAttribute()); $this->assertEquals([5], $query->getValues()); } @@ -75,7 +75,7 @@ public function testQueryParseAndToString(): void // Test parsing equal query $json = '{"method":"equal","attribute":"userId","values":["123"]}'; $query = Query::parse($json); - $this->assertEquals(Query::TYPE_EQUAL, $query->getMethod()); + $this->assertEquals(Query::TYPE_EQUAL, $query->getMethod()->value); $this->assertEquals('userId', $query->getAttribute()); $this->assertEquals(['123'], $query->getValues()); @@ -85,7 +85,7 @@ public function testQueryParseAndToString(): void $this->assertJson($json); $parsed = Query::parse($json); - $this->assertEquals(Query::TYPE_EQUAL, $parsed->getMethod()); + $this->assertEquals(Query::TYPE_EQUAL, $parsed->getMethod()->value); $this->assertEquals('event', $parsed->getAttribute()); $this->assertEquals(['create'], $parsed->getValues()); @@ -117,9 +117,9 @@ public function testQueryParseQueries(): void $this->assertInstanceOf(Query::class, $parsed[1]); $this->assertInstanceOf(Query::class, $parsed[2]); - $this->assertEquals(Query::TYPE_EQUAL, $parsed[0]->getMethod()); - $this->assertEquals(Query::TYPE_GREATER, $parsed[1]->getMethod()); - $this->assertEquals(Query::TYPE_LIMIT, $parsed[2]->getMethod()); + $this->assertEquals(Query::TYPE_EQUAL, $parsed[0]->getMethod()->value); + $this->assertEquals(Query::TYPE_GREATER, $parsed[1]->getMethod()->value); + $this->assertEquals(Query::TYPE_LIMIT, $parsed[2]->getMethod()->value); } /** @@ -218,7 +218,7 @@ public function testQueryToStringWithComplexValues(): void $this->assertJson($json); $parsed = Query::parse($json); - $this->assertEquals(Query::TYPE_BETWEEN, $parsed->getMethod()); + $this->assertEquals(Query::TYPE_BETWEEN, $parsed->getMethod()->value); $this->assertEquals('time', $parsed->getAttribute()); $this->assertEquals(['2023-01-01', '2024-12-31'], $parsed->getValues()); }