diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index 3bc1b81..86b6f02 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -42,7 +42,11 @@ class ClickHouse extends SQL 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, ]; private string $host; @@ -893,8 +897,21 @@ public function find(array $queries = []): array // Parse queries $parsed = $this->parseQueries($queries); - // Build SELECT clause - $selectColumns = $this->getSelectColumns(); + // Random ordering can't combine with anything that asks for a + // specific row order: cursor pagination needs a stable anchor, and + // 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'])) { + throw new Exception('Cursor pagination cannot be combined with orderRandom'); + } + if (!empty($parsed['randomOrder']) && !empty($parsed['orderBy'])) { + 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']; @@ -919,11 +936,15 @@ public function find(array $queries = []): array $whereClause = ' WHERE ' . implode(' AND ', $conditions); } - // Build ORDER BY clause — when cursor is in play, rebuild from - // orderAttributes (always non-empty after resolveCursorOrder, which - // appends an id tiebreaker), flipping directions for `cursorBefore`. + // 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 (isset($parsed['cursor'])) { + if (!empty($parsed['randomOrder'])) { + $orderClause = ' ORDER BY rand()'; + } elseif (isset($parsed['cursor'])) { $orderSql = $this->buildOrderBySql($orderAttributes, flip: $cursorDirection === 'before'); $orderClause = ' ORDER BY ' . implode(', ', $orderSql); } elseif (!empty($parsed['orderBy'])) { @@ -950,6 +971,56 @@ public function find(array $queries = []): array return $rows; } + /** + * Build the SELECT projection list. When `$select` is null, returns the + * full column list from `getSelectColumns()`; otherwise validates and + * escapes each requested column. + * + * `id` is always projected so `parseJsonResults` can map it back to the + * `$id` field on the `Log` model. When `sharedTables` is enabled, the + * `tenant` column is also always projected — it's metadata callers expect + * on every row and the full-projection path already includes it. Callers + * requesting a slim projection don't have to remember either. + * + * @param list|null $select + * @throws Exception + */ + private function buildProjection(?array $select): string + { + if ($select === null) { + return $this->getSelectColumns(); + } + + // Forced columns are injected here, so they're validated defensively. + // User-supplied columns in $select are already validated inside the + // TYPE_SELECT branch of parseQueries() — no need to walk + // getAttributes() a second time per column. + $forced = ['id']; + if ($this->sharedTables) { + $forced[] = 'tenant'; + } + + $columns = []; + $seen = []; + foreach ($forced as $column) { + if (isset($seen[$column])) { + continue; + } + $this->validateAttributeName($column); + $columns[] = $this->escapeIdentifier($column); + $seen[$column] = true; + } + foreach ($select as $column) { + if (isset($seen[$column])) { + continue; + } + $columns[] = $this->escapeIdentifier($column); + $seen[$column] = true; + } + + return implode(', ', $columns); + } + /** * Count logs using Query objects. * @@ -1012,7 +1083,7 @@ public function count(array $queries = [], ?int $max = null): int * Parse Query objects into SQL components. * * @param array $queries - * @return array{filters: array, params: array, orderBy?: array, orderAttributes?: array, limit?: int, offset?: int, cursor?: array, cursorDirection?: string} + * @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 @@ -1025,6 +1096,8 @@ private function parseQueries(array $queries): array $offset = null; $cursor = null; $cursorDirection = null; + $select = null; + $randomOrder = false; $paramCounter = 0; foreach ($queries as $query) { @@ -1190,6 +1263,18 @@ private function parseQueries(array $queries): array $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: $this->validateAttributeName($attribute); $escapedAttr = $this->escapeIdentifier($attribute); @@ -1202,6 +1287,55 @@ private function parseQueries(array $queries): array $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)) { + // 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 === '') { + throw new Exception('select columns must be non-empty strings'); + } + $this->validateAttributeName($column); + if (!in_array($column, $select, true)) { + $select[] = $column; + } + } + break; + case Query::TYPE_ORDER_DESC: $this->validateAttributeName($attribute); $escapedAttr = $this->escapeIdentifier($attribute); @@ -1216,6 +1350,13 @@ private function parseQueries(array $queries): array $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; + case Query::TYPE_LIMIT: if (!\is_int($values[0])) { throw new \Exception('Invalid limit value. Expected int'); @@ -1258,6 +1399,10 @@ private function parseQueries(array $queries): array $result['orderAttributes'] = $orderAttributes; } + if ($randomOrder) { + $result['randomOrder'] = true; + } + if ($limit !== null) { $result['limit'] = $limit; } @@ -1271,6 +1416,10 @@ private function parseQueries(array $queries): array $result['cursorDirection'] = $cursorDirection; } + if ($select !== null) { + $result['select'] = $select; + } + return $result; } diff --git a/tests/Audit/Adapter/ClickHouseTest.php b/tests/Audit/Adapter/ClickHouseTest.php index 521d143..b50da71 100644 --- a/tests/Audit/Adapter/ClickHouseTest.php +++ b/tests/Audit/Adapter/ClickHouseTest.php @@ -773,4 +773,142 @@ public function testEqualRejectsEmptyValues(): void new Query(Query::TYPE_EQUAL, 'event', []), ]); } + + public function testSelectProjectsRequestedColumns(): void + { + $logs = $this->audit->find([ + Query::select(['event', 'resource']), + Query::equal('userId', 'userId'), + Query::limit(1), + ]); + + $this->assertGreaterThanOrEqual(1, count($logs)); + + $row = $logs[0]->getArrayCopy(); + // `id` is always projected so the Log model still has its identifier + $this->assertArrayHasKey('$id', $row); + // Requested columns present + $this->assertArrayHasKey('event', $row); + $this->assertArrayHasKey('resource', $row); + // Unrequested columns are absent + $this->assertArrayNotHasKey('userAgent', $row); + $this->assertArrayNotHasKey('ip', $row); + $this->assertArrayNotHasKey('data', $row); + } + + public function testSelectAutoIncludesTenantWhenShared(): void + { + $host = getenv('CLICKHOUSE_HOST') ?: 'clickhouse'; + $port = (int) (getenv('CLICKHOUSE_PORT') ?: 8123); + + $adapter = new ClickHouse( + host: $host, + username: 'default', + password: 'clickhouse', + port: $port, + ); + $adapter->setNamespace('select_tenant_test'); + $adapter->setSharedTables(true); + $adapter->setTenant(7); + $adapter->setup(); + + $audit = new Audit($adapter); + $audit->log('u1', 'create', 'doc/1', 'agent', '127.0.0.1', 'US', $this->getRequiredAttributes()); + + $logs = $audit->find([ + Query::select(['event']), + Query::limit(1), + ]); + + $this->assertCount(1, $logs); + $row = $logs[0]->getArrayCopy(); + $this->assertArrayHasKey('$id', $row); + $this->assertArrayHasKey('event', $row); + // tenant is always projected when sharedTables is on, even if the + // caller didn't list it + $this->assertArrayHasKey('tenant', $row); + } + + public function testSelectRejectsUnknownColumn(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid attribute name: bogus_column'); + + $this->audit->find([ + Query::select(['bogus_column']), + ]); + } + + public function testSelectRejectsEmptyValues(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Select queries require at least one value.'); + + $this->audit->find([ + Query::select([]), + ]); + } + + public function testNotStartsWithFilter(): void + { + $logs = $this->audit->find([ + Query::notStartsWith('resource', 'database/'), + ]); + // From fixture: only 'user/null' doesn't start with 'database/' + $this->assertCount(1, $logs); + $this->assertEquals('user/null', $logs[0]->getResource()); + } + + public function testNotEndsWithFilter(): void + { + $logs = $this->audit->find([ + Query::notEndsWith('resource', '/null'), + ]); + // From fixture: 3 logs are on database/document/{1,2,2} + $this->assertCount(3, $logs); + foreach ($logs as $log) { + $this->assertStringStartsNotWith('user/', $log->getResource()); + } + } + + public function testRegexFilter(): void + { + $logs = $this->audit->find([ + Query::regex('resource', '^database/document/\\d+$'), + ]); + // From fixture: 3 database/document/{1,2,2} rows match + $this->assertCount(3, $logs); + } + + public function testOrderRandomReturnsRows(): void + { + $logs = $this->audit->find([ + Query::orderRandom(), + Query::limit(2), + ]); + // Hard to assert randomness; just confirm the query executes and limits. + $this->assertCount(2, $logs); + } + + public function testOrderRandomRejectedWithCursor(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Cursor pagination cannot be combined with orderRandom'); + + $this->audit->find([ + Query::orderRandom(), + Query::cursorAfter(['id' => 'whatever']), + ]); + } + + public function testOrderRandomRejectedWithColumnOrder(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('orderRandom cannot be combined with orderAsc/orderDesc'); + + $this->audit->find([ + Query::orderRandom(), + Query::orderDesc('time'), + ]); + } }