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
47 changes: 33 additions & 14 deletions src/Usage/Adapter/ClickHouse.php
Original file line number Diff line number Diff line change
Expand Up @@ -3280,6 +3280,21 @@ 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('metric', ['100%'])` a literal
* substring match instead of a wildcard.
*
* @param string $value
* @return string
*/
private function escapeLikeWildcards(string $value): string
{
return \str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value);
}

/**
* Normalize a user-supplied cursor row into a column-keyed array.
*
Expand Down Expand Up @@ -3613,33 +3628,37 @@ private function parseQueries(string $tenant, array $queries, string $type = 'ev
break;

case Query::TYPE_CONTAINS:
// Substring match, mirroring utopia-php/database: each
// value becomes `LIKE '%value%'`, OR'd together.
$this->validateAttributeName($attribute, $type);
$escapedAttr = $this->escapeIdentifier($attribute);
$chType = $this->getParamType($attribute);
$inParams = [];
$conditions = [];
foreach ($values as $value) {
if (!is_string($value)) {
throw new Exception("contains value must be a string for attribute '{$attribute}'");
}
$paramName = 'param_' . $paramCounter++;
$inParams[] = "{{$paramName}:{$chType}}";
$params[$paramName] = $this->formatTypedValue($chType, $value);
}
if (!empty($inParams)) {
$filters[] = "{$escapedAttr} IN (" . implode(', ', $inParams) . ")";
$conditions[] = "{$escapedAttr} LIKE {{$paramName}:String}";
$params[$paramName] = '%' . $this->escapeLikeWildcards($value) . '%';
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
$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, $type);
$escapedAttr = $this->escapeIdentifier($attribute);
$chType = $this->getParamType($attribute);
$inParams = [];
$conditions = [];
foreach ($values as $value) {
if (!is_string($value)) {
throw new Exception("notContains value must be a string for attribute '{$attribute}'");
}
$paramName = 'param_' . $paramCounter++;
$inParams[] = "{{$paramName}:{$chType}}";
$params[$paramName] = $this->formatTypedValue($chType, $value);
}
if (!empty($inParams)) {
$filters[] = "{$escapedAttr} NOT IN (" . implode(', ', $inParams) . ")";
$conditions[] = "{$escapedAttr} NOT LIKE {{$paramName}:String}";
$params[$paramName] = '%' . $this->escapeLikeWildcards($value) . '%';
}
$filters[] = '(' . implode(' AND ', $conditions) . ')';
break;

case Query::TYPE_IS_NULL:
Expand Down
47 changes: 47 additions & 0 deletions tests/Usage/UsageBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,53 @@ public function testContainsQuery(): void

// Should find all metrics matching either 'requests' or 'bandwidth'
$this->assertGreaterThanOrEqual(2, count($results));

// Contains is a substring match (like utopia-php/database), not an
// exact IN match — 'band' should match 'bandwidth'
$results = $this->usage->find('1', [
Query::contains('metric', ['band']),
], Usage::TYPE_EVENT);

$this->assertGreaterThanOrEqual(1, count($results));
foreach ($results as $result) {
$this->assertStringContainsString('band', $result->getMetric());
}
}

public function testNotContainsQuery(): void
{
// notContains is a negated substring match — excludes any metric
// containing 'request', keeps 'bandwidth'
$results = $this->usage->find('1', [
Query::notContains('metric', ['request']),
], Usage::TYPE_EVENT);

$this->assertGreaterThanOrEqual(1, count($results));
foreach ($results as $result) {
$this->assertStringNotContainsString('request', $result->getMetric());
}
}

public function testContainsEscapesLikeWildcards(): void
{
$this->assertTrue($this->usage->addBatch([
['tenant' => '1', 'metric' => 'cache%hit', 'value' => 1, 'tags' => []],
], Usage::TYPE_EVENT));

// '%' in a needle is a literal, not a LIKE wildcard — an unescaped
// '%' would match every metric
$results = $this->usage->find('1', [
Query::contains('metric', ['%']),
], Usage::TYPE_EVENT);
$this->assertCount(1, $results);
$this->assertEquals('cache%hit', $results[0]->getMetric());

// '_' is a literal too — as a wildcard 'cache_hit' would match
// 'cache%hit'
$results = $this->usage->find('1', [
Query::contains('metric', ['cache_hit']),
], Usage::TYPE_EVENT);
$this->assertCount(0, $results);
}

public function testLessThanEqualQuery(): void
Expand Down
Loading