Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 156 additions & 7 deletions src/Audit/Adapter/ClickHouse.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'];
Expand All @@ -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'])) {
Expand All @@ -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<string>|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.
*
Expand Down Expand Up @@ -1012,7 +1083,7 @@ public function count(array $queries = [], ?int $max = null): int
* Parse Query objects into SQL components.
*
* @param array<Query> $queries
* @return array{filters: array<string>, params: array<string, mixed>, orderBy?: array<string>, orderAttributes?: array<int, array{attribute: string, direction: string}>, limit?: int, offset?: int, cursor?: array<string, mixed>, cursorDirection?: string}
* @return array{filters: array<string>, params: array<string, mixed>, orderBy?: array<string>, orderAttributes?: array<int, array{attribute: string, direction: string}>, randomOrder?: bool, limit?: int, offset?: int, cursor?: array<string, mixed>, cursorDirection?: string, select?: list<string>}
* @throws Exception
*/
private function parseQueries(array $queries): array
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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');
Expand Down Expand Up @@ -1258,6 +1399,10 @@ private function parseQueries(array $queries): array
$result['orderAttributes'] = $orderAttributes;
}

if ($randomOrder) {
$result['randomOrder'] = true;
}

if ($limit !== null) {
$result['limit'] = $limit;
}
Expand All @@ -1271,6 +1416,10 @@ private function parseQueries(array $queries): array
$result['cursorDirection'] = $cursorDirection;
}

if ($select !== null) {
$result['select'] = $select;
}

return $result;
}

Expand Down
138 changes: 138 additions & 0 deletions tests/Audit/Adapter/ClickHouseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
]);
}
}
Loading