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
71 changes: 67 additions & 4 deletions src/Query/Query.php
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,69 @@ public static function parseQueries(array $queries): array
return $parsed;
}

/**
* Compute a shape-only fingerprint of an array of queries.
*
* The fingerprint captures the structure of the queries — method and
* attribute — without values. Two query sets with the same shape but
* different parameter values produce the same fingerprint, which is
* useful for pattern-based counting and slow-query grouping.
*
* Logical queries (`and`, `or`, `elemMatch`) are recursively fingerprinted
* so their inner structure contributes to the hash — two `and(...)`
* queries with different child shapes produce different fingerprints.
*
* Accepts either raw query strings or parsed Query objects.
*
* @param array<mixed> $queries raw query strings or Query instances
* @return string md5 hash of the canonical shape
*
* @throws QueryException if an element is neither a string nor a Query
*/
public static function fingerprint(array $queries): string
{
$shapes = [];

foreach ($queries as $query) {
if (\is_string($query)) {
$query = static::parse($query);
}

if (! $query instanceof self) {
throw new QueryException('Invalid query element for fingerprint: expected string or Query instance');
}

$shapes[] = self::queryShape($query);
}

\sort($shapes);

return \md5(\implode('|', $shapes));
}

/**
* Canonical shape string for a single Query — recursive for logical types.
*/
private static function queryShape(self $query): string
{
$method = $query->getMethod();

if (\in_array($method, self::LOGICAL_TYPES, true)) {
$childShapes = [];
foreach ($query->getValues() as $child) {
if ($child instanceof self) {
$childShapes[] = self::queryShape($child);
}
}
\sort($childShapes);

// Attribute is empty for and/or; meaningful for elemMatch (the field being matched).
return $method.':'.$query->getAttribute().'('.\implode('|', $childShapes).')';
}

return $method.':'.$query->getAttribute();
}

/**
* @return array<string, mixed>
*/
Expand Down Expand Up @@ -824,12 +887,12 @@ public static function getCursorQueries(array $queries, bool $clone = true): arr
*
* @param array<mixed> $queries
* @return array{
* filters: array<static>,
* selections: array<static>,
* filters: list<self>,
* selections: list<self>,
* limit: int|null,
* offset: int|null,
* orderAttributes: array<string>,
* orderTypes: array<string>,
* orderAttributes: list<string>,
* orderTypes: list<string>,
* cursor: mixed,
* cursorDirection: string|null
* }
Expand Down
76 changes: 76 additions & 0 deletions tests/Query/QueryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,80 @@ public function testEmptyValues(): void
$query = Query::equal('name', []);
$this->assertEquals([], $query->getValues());
}

public function testFingerprint(): void
{
$equalAlice = '{"method":"equal","attribute":"name","values":["Alice"]}';
$equalBob = '{"method":"equal","attribute":"name","values":["Bob"]}';
$equalEmail = '{"method":"equal","attribute":"email","values":["a@b.c"]}';
$notEqualAlice = '{"method":"notEqual","attribute":"name","values":["Alice"]}';
$gtAge18 = '{"method":"greaterThan","attribute":"age","values":[18]}';
$gtAge42 = '{"method":"greaterThan","attribute":"age","values":[42]}';

// Same shape, different values produce the same fingerprint
$a = Query::fingerprint([$equalAlice, $gtAge18]);
$b = Query::fingerprint([$equalBob, $gtAge42]);
$this->assertSame($a, $b);

// Different attribute produces different fingerprint
$c = Query::fingerprint([$equalEmail, $gtAge18]);
$this->assertNotSame($a, $c);

// Different method produces different fingerprint
$d = Query::fingerprint([$notEqualAlice, $gtAge18]);
$this->assertNotSame($a, $d);

// Order-independent
$e = Query::fingerprint([$gtAge18, $equalAlice]);
$this->assertSame($a, $e);

// Accepts parsed Query objects
$parsed = [Query::equal('name', ['Alice']), Query::greaterThan('age', 18)];
$f = Query::fingerprint($parsed);
$this->assertSame($a, $f);

// Empty array returns deterministic hash
$this->assertSame(\md5(''), Query::fingerprint([]));
}

public function testFingerprintNestedLogicalQueries(): void
{
// AND queries with different inner shapes produce different fingerprints
$andEqName = new Query(Query::TYPE_AND, '', [Query::equal('name', ['Alice'])]);
$andEqEmail = new Query(Query::TYPE_AND, '', [Query::equal('email', ['a@b.c'])]);
$this->assertNotSame(Query::fingerprint([$andEqName]), Query::fingerprint([$andEqEmail]));

// AND queries with same inner shape produce the same fingerprint (values differ)
$andEqNameBob = new Query(Query::TYPE_AND, '', [Query::equal('name', ['Bob'])]);
$this->assertSame(Query::fingerprint([$andEqName]), Query::fingerprint([$andEqNameBob]));

// Order of children inside a logical query does not matter
$andA = new Query(Query::TYPE_AND, '', [Query::equal('name', ['Alice']), Query::greaterThan('age', 18)]);
$andB = new Query(Query::TYPE_AND, '', [Query::greaterThan('age', 42), Query::equal('name', ['Bob'])]);
$this->assertSame(Query::fingerprint([$andA]), Query::fingerprint([$andB]));

// AND of two filters differs from OR of the same two filters
$orA = new Query(Query::TYPE_OR, '', [Query::equal('name', ['Alice']), Query::greaterThan('age', 18)]);
$this->assertNotSame(Query::fingerprint([$andA]), Query::fingerprint([$orA]));

// AND with one child differs from AND with two children
$andOne = new Query(Query::TYPE_AND, '', [Query::equal('name', ['Alice'])]);
$andTwo = new Query(Query::TYPE_AND, '', [Query::equal('name', ['Alice']), Query::greaterThan('age', 18)]);
$this->assertNotSame(Query::fingerprint([$andOne]), Query::fingerprint([$andTwo]));

// elemMatch attribute matters: same inner shape on different fields must NOT collide
$elemTags = new Query(Query::TYPE_ELEM_MATCH, 'tags', [Query::equal('name', ['php'])]);
$elemCategories = new Query(Query::TYPE_ELEM_MATCH, 'categories', [Query::equal('name', ['php'])]);
$this->assertNotSame(Query::fingerprint([$elemTags]), Query::fingerprint([$elemCategories]));

// elemMatch values-only change (same field, same child shape) still collides — as expected
$elemTagsOther = new Query(Query::TYPE_ELEM_MATCH, 'tags', [Query::equal('name', ['js'])]);
$this->assertSame(Query::fingerprint([$elemTags]), Query::fingerprint([$elemTagsOther]));
}

public function testFingerprintRejectsInvalidElements(): void
{
$this->expectException(\Utopia\Query\Exception::class);
Query::fingerprint([42]);
}
}
Loading