diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index fdd977e..068b5bc 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -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. * @@ -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) . '%'; } + $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: diff --git a/tests/Usage/UsageBase.php b/tests/Usage/UsageBase.php index ea126c4..da6a125 100644 --- a/tests/Usage/UsageBase.php +++ b/tests/Usage/UsageBase.php @@ -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