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
58 changes: 46 additions & 12 deletions src/Query/Query.php
Original file line number Diff line number Diff line change
Expand Up @@ -457,9 +457,9 @@ public static function parseQueries(array $queries): array
* 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.
* Logical queries (`and`, `or`, `elemMatch`) contribute their inner
* structure to the hash via `Query::shape()` — two `and(...)` queries
* with different child shapes produce different fingerprints.
*
* Accepts either raw query strings or parsed Query objects.
*
Expand All @@ -481,7 +481,7 @@ public static function fingerprint(array $queries): string
throw new QueryException('Invalid query element for fingerprint: expected string or Query instance');
}

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

\sort($shapes);
Expand All @@ -490,26 +490,60 @@ public static function fingerprint(array $queries): string
}

/**
* Canonical shape string for a single Query — recursive for logical types.
* Canonical shape string for this Query — values excluded.
*
* Non-logical queries produce `method:attribute`. Logical queries
* (`and`, `or`, `elemMatch`) produce `method:attribute(child1|child2|…)`
* with children sorted so child order does not affect the shape.
*
* Implemented iteratively: walks the tree into a preorder list via a
* stack, then processes the reversed list so each node's children are
* always resolved before the node itself.
*/
private static function queryShape(self $query): string
public function shape(): string
{
$method = $query->getMethod();
// 1. Preorder flatten the tree.
$nodes = [];
$stack = [$this];
while ($stack) {
/** @var self $node */
$node = \array_pop($stack);
$nodes[] = $node;

if (! \in_array($node->method, self::LOGICAL_TYPES, true)) {
continue;
}
foreach ($node->values as $child) {
if ($child instanceof self) {
$stack[] = $child;
}
}
}

// 2. Process reversed so children are always shaped before parents.
$shapes = [];
foreach (\array_reverse($nodes) as $node) {
$id = \spl_object_id($node);

if (! \in_array($node->method, self::LOGICAL_TYPES, true)) {
$shapes[$id] = $node->method.':'.$node->attribute;

continue;
}

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

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

return $method.':'.$query->getAttribute();
return $shapes[\spl_object_id($this)];
}

/**
Expand Down
31 changes: 31 additions & 0 deletions tests/Query/QueryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -214,4 +214,35 @@ public function testFingerprintRejectsInvalidElements(): void
$this->expectException(\Utopia\Query\Exception::class);
Query::fingerprint([42]);
}

public function testShape(): void
{
// Leaf queries
$this->assertSame('equal:name', Query::equal('name', ['Alice'])->shape());
$this->assertSame('greaterThan:age', Query::greaterThan('age', 18)->shape());

// Logical with empty attribute
$and = new Query(Query::TYPE_AND, '', [Query::equal('name', ['Alice']), Query::greaterThan('age', 18)]);
$this->assertSame('and:(equal:name|greaterThan:age)', $and->shape());

// elemMatch preserves the attribute (the field being matched)
$elem = new Query(Query::TYPE_ELEM_MATCH, 'tags', [Query::equal('name', ['php'])]);
$this->assertSame('elemMatch:tags(equal:name)', $elem->shape());

// Deeply nested — iterative traversal must match recursive result
$deep = new Query(Query::TYPE_AND, '', [
new Query(Query::TYPE_OR, '', [
Query::equal('a', ['x']),
new Query(Query::TYPE_AND, '', [
Query::equal('b', ['y']),
Query::lessThan('c', 5),
]),
]),
Query::greaterThan('d', 10),
]);
$this->assertSame(
'and:(greaterThan:d|or:(and:(equal:b|lessThan:c)|equal:a))',
$deep->shape(),
);
}
}
Loading