diff --git a/src/Query/Query.php b/src/Query/Query.php index 3af237f..624717c 100644 --- a/src/Query/Query.php +++ b/src/Query/Query.php @@ -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 $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 */ @@ -824,12 +887,12 @@ public static function getCursorQueries(array $queries, bool $clone = true): arr * * @param array $queries * @return array{ - * filters: array, - * selections: array, + * filters: list, + * selections: list, * limit: int|null, * offset: int|null, - * orderAttributes: array, - * orderTypes: array, + * orderAttributes: list, + * orderTypes: list, * cursor: mixed, * cursorDirection: string|null * } diff --git a/tests/Query/QueryTest.php b/tests/Query/QueryTest.php index 1fb05bd..a3820df 100644 --- a/tests/Query/QueryTest.php +++ b/tests/Query/QueryTest.php @@ -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]); + } }