From 1d615f5657f2a79f5d19c2cea3b607b47c29721b Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Wed, 17 Jun 2026 17:50:43 +0100 Subject: [PATCH 01/51] Add cached find support --- src/Database/Database.php | 196 ++++++++++++++++--- tests/e2e/Adapter/Scopes/DocumentTests.php | 36 ++++ tests/unit/CacheKeyTest.php | 96 +++++++++- tests/unit/FindCacheTest.php | 208 +++++++++++++++++++++ 4 files changed, 510 insertions(+), 26 deletions(-) create mode 100644 tests/unit/FindCacheTest.php diff --git a/src/Database/Database.php b/src/Database/Database.php index 5a6cd97d7..c9c340145 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8353,10 +8353,29 @@ public function purgeCachedCollection(string $collectionId): bool } $this->cache->purge($collectionKey); + $this->purgeCachedFindCollection($collectionId); return true; } + public function purgeCachedFindCollection(string $collectionId): bool + { + [$findKey] = $this->getFindCacheKeys($collectionId); + + return $this->cache->purge($findKey); + } + + /** + * @param array $queries + */ + public function purgeCachedFind(string $collectionId, ?string $key = null, array $queries = [], string $forPermission = Database::PERMISSION_READ): bool + { + $collection = $this->silent(fn () => $this->getCollection($collectionId)); + [$findKey, $findField] = $this->getFindCacheKeys($collectionId, $queries, $key, $forPermission, $collection); + + return $this->cache->purge($findKey, $findField); + } + /** * Cleans a specific document from cache * And related document reference in the collection cache. @@ -8564,6 +8583,70 @@ public function find(string $collection, array $queries = [], string $forPermiss return $results; } + /** + * Find documents using an explicit TTL-backed cache. + * + * Results may be stale until the TTL expires or the caller purges the cached + * find entry. Use this only for read paths that can tolerate bounded + * staleness. + * + * @param string $collection + * @param array $queries + * @param int $ttl + * @param string|null $key Optional caller-owned cache variation key + * @param string $forPermission + * @return array + * @throws DatabaseException + * @throws QueryException + * @throws TimeoutException + * @throws Exception + */ + public function findCached(string $collection, array $queries = [], int $ttl = self::TTL, ?string $key = null, string $forPermission = Database::PERMISSION_READ): array + { + if ($ttl <= 0) { + return $this->find($collection, $queries, $forPermission); + } + + $collectionDocument = $this->silent(fn () => $this->getCollection($collection)); + + if ($collectionDocument->isEmpty()) { + throw new NotFoundException('Collection not found'); + } + + [$findKey, $findField] = $this->getFindCacheKeys($collectionDocument->getId(), $queries, $key, $forPermission, $collectionDocument); + + try { + $cached = $this->cache->load($findKey, $ttl, $findField); + } catch (Exception $e) { + Console::warning('Warning: Failed to get find result from cache: ' . $e->getMessage()); + $cached = null; + } + + if ($cached !== null && $cached !== false && \is_array($cached)) { + $documents = \array_map( + fn (array $document): Document => $this->createDocumentInstance($collectionDocument->getId(), $document), + $cached + ); + + $this->trigger(self::EVENT_DOCUMENT_FIND, $documents); + + return $documents; + } + + $documents = $this->find($collectionDocument->getId(), $queries, $forPermission); + + try { + $this->cache->save($findKey, \array_map( + static fn (Document $document): array => $document->getArrayCopy(), + $documents + ), $findField); + } catch (Exception $e) { + Console::warning('Failed to save find result to cache: ' . $e->getMessage()); + } + + return $documents; + } + /** * Helper method to iterate documents in collection using callback pattern * Alterative is @@ -9445,34 +9528,10 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a $sortedSelects = $selects; \sort($sortedSelects); - $filterSignatures = []; - if ($this->filter) { - $disabled = $this->disabledFilters ?? []; - - foreach (self::$filters as $name => $callbacks) { - if (isset($disabled[$name])) { - continue; - } - if (\array_key_exists($name, $this->instanceFilters)) { - continue; - } - $filterSignatures[$name] = $callbacks['signature']; - } - - foreach ($this->instanceFilters as $name => $callbacks) { - if (isset($disabled[$name])) { - continue; - } - $filterSignatures[$name] = $callbacks['signature']; - } - - \ksort($filterSignatures); - } - $payload = \json_encode([ 'selects' => $sortedSelects, 'relationships' => $this->resolveRelationships, - 'filters' => $filterSignatures, + 'filters' => $this->getFilterSignatures(), ]) ?: ''; $documentHashKey = $documentKey . ':' . \md5($payload); } @@ -9484,6 +9543,93 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a ]; } + /** + * @param string $collectionId + * @param array $queries + * @param string|null $key + * @param string $forPermission + * @param Document|null $collection + * @return array{0: string, 1: string} + */ + public function getFindCacheKeys(string $collectionId, array $queries = [], ?string $key = null, string $forPermission = self::PERMISSION_READ, ?Document $collection = null): array + { + [$collectionKey] = $this->getCacheKeys($collectionId); + $findKey = "{$collectionKey}:find"; + + $payload = [ + 'version' => 1, + 'database' => $this->getDatabase(), + 'schema' => $this->getFindCacheSchemaHash($collection), + 'key' => $key, + 'queries' => $key === null ? $this->serializeFindCacheQueries($queries) : null, + 'authorization' => $this->authorization->getStatus(), + 'roles' => $this->authorization->getRoles(), + 'permission' => $forPermission, + 'relationships' => $this->resolveRelationships, + 'filters' => $this->getFilterSignatures(), + ]; + + return [$findKey, \md5(\json_encode($payload) ?: '')]; + } + + private function getFindCacheSchemaHash(?Document $collection): string + { + if ($collection === null || $collection->isEmpty()) { + return ''; + } + + return \md5( + \json_encode($collection->getAttribute('attributes', [])) + . \json_encode($collection->getAttribute('indexes', [])) + ); + } + + /** + * @param array $queries + * @return array + */ + private function serializeFindCacheQueries(array $queries): array + { + return \array_map( + static fn (Query $query): array => $query->toArray(), + $queries + ); + } + + /** + * @return array + */ + private function getFilterSignatures(): array + { + $filterSignatures = []; + if (!$this->filter) { + return $filterSignatures; + } + + $disabled = $this->disabledFilters ?? []; + + foreach (self::$filters as $name => $callbacks) { + if (isset($disabled[$name])) { + continue; + } + if (\array_key_exists($name, $this->instanceFilters)) { + continue; + } + $filterSignatures[$name] = $callbacks['signature']; + } + + foreach ($this->instanceFilters as $name => $callbacks) { + if (isset($disabled[$name])) { + continue; + } + $filterSignatures[$name] = $callbacks['signature']; + } + + \ksort($filterSignatures); + + return $filterSignatures; + } + private static function computeCallableSignature(callable $callable): string { if (\is_string($callable)) { diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 4f998372d..fcf58a3f0 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -103,6 +103,42 @@ public function testBigintSequence(): void } } + public function testFindCachedReturnsStaleResultUntilPurged(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $database->createCollection(__FUNCTION__); + $database->createAttribute(__FUNCTION__, 'name', Database::VAR_STRING, 255, true); + + $database->createDocument(__FUNCTION__, new Document([ + '$id' => 'first', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'First', + ])); + + $documents = $database->findCached(__FUNCTION__, [Query::orderAsc('name')], ttl: 3600); + $this->assertCount(1, $documents); + $this->assertSame('first', $documents[0]->getId()); + + $database->createDocument(__FUNCTION__, new Document([ + '$id' => 'second', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'Second', + ])); + + $documents = $database->findCached(__FUNCTION__, [Query::orderAsc('name')], ttl: 3600); + $this->assertCount(1, $documents); + $this->assertSame('first', $documents[0]->getId()); + + $database->purgeCachedFindCollection(__FUNCTION__); + + $documents = $database->findCached(__FUNCTION__, [Query::orderAsc('name')], ttl: 3600); + $this->assertCount(2, $documents); + $this->assertSame('first', $documents[0]->getId()); + $this->assertSame('second', $documents[1]->getId()); + } + public function testCreateDocumentWithBigIntType(): void { /** @var Database $database */ diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index 118bf4897..87b694b84 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -7,18 +7,21 @@ use Utopia\Cache\Cache; use Utopia\Database\Adapter; use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\Database\Query; class CacheKeyTest extends TestCase { /** * @param array $instanceFilters */ - private function createDatabase(array $instanceFilters = []): Database + private function createDatabase(array $instanceFilters = [], string $database = 'test'): Database { $adapter = $this->createMock(Adapter::class); $adapter->method('getSupportForHostname')->willReturn(false); $adapter->method('getTenant')->willReturn(null); $adapter->method('getNamespace')->willReturn('test'); + $adapter->method('getDatabase')->willReturn($database); return new Database($adapter, new Cache(new None()), $instanceFilters); } @@ -131,6 +134,97 @@ public function testFiltersDisabledEntirelyProducesDifferentCacheKey(): void $this->assertNotEquals($hashEnabled, $hashDisabled); } + public function testDifferentFindQueriesProduceDifferentCacheKeys(): void + { + $db = $this->createDatabase(); + + [, $fieldA] = $db->getFindCacheKeys('col', [Query::equal('status', ['active'])]); + [, $fieldB] = $db->getFindCacheKeys('col', [Query::equal('status', ['paused'])]); + + $this->assertNotEquals($fieldA, $fieldB); + } + + public function testCallerFindCacheKeyIgnoresQueryVariation(): void + { + $db = $this->createDatabase(); + + [, $fieldA] = $db->getFindCacheKeys('col', [Query::equal('status', ['active'])], 'domain-key'); + [, $fieldB] = $db->getFindCacheKeys('col', [Query::equal('status', ['paused'])], 'domain-key'); + + $this->assertEquals($fieldA, $fieldB); + } + + public function testDifferentFindRolesProduceDifferentCacheKeys(): void + { + $db = $this->createDatabase(); + + [, $fieldA] = $db->getFindCacheKeys('col', [Query::limit(10)]); + + $db->getAuthorization()->addRole('user:1'); + [, $fieldB] = $db->getFindCacheKeys('col', [Query::limit(10)]); + + $this->assertNotEquals($fieldA, $fieldB); + } + + public function testDifferentFindAuthorizationStatusProducesDifferentCacheKeys(): void + { + $db = $this->createDatabase(); + + [, $fieldA] = $db->getFindCacheKeys('col', [Query::limit(10)]); + + $db->getAuthorization()->disable(); + [, $fieldB] = $db->getFindCacheKeys('col', [Query::limit(10)]); + + $this->assertNotEquals($fieldA, $fieldB); + } + + public function testDifferentFindDatabasesProduceDifferentCacheKeys(): void + { + $dbPlatform = $this->createDatabase(database: 'platform'); + $dbProject = $this->createDatabase(database: 'project'); + + [, $fieldA] = $dbPlatform->getFindCacheKeys('col', [Query::limit(10)]); + [, $fieldB] = $dbProject->getFindCacheKeys('col', [Query::limit(10)]); + + $this->assertNotEquals($fieldA, $fieldB); + } + + public function testDifferentFindSchemasProduceDifferentCacheKeys(): void + { + $db = $this->createDatabase(); + + $collectionA = new Document([ + '$id' => 'col', + 'attributes' => [ + new Document(['$id' => 'name', 'type' => Database::VAR_STRING]), + ], + 'indexes' => [], + ]); + $collectionB = new Document([ + '$id' => 'col', + 'attributes' => [ + new Document(['$id' => 'name', 'type' => Database::VAR_STRING]), + new Document(['$id' => 'status', 'type' => Database::VAR_STRING]), + ], + 'indexes' => [], + ]); + + [, $fieldA] = $db->getFindCacheKeys('col', [Query::limit(10)], collection: $collectionA); + [, $fieldB] = $db->getFindCacheKeys('col', [Query::limit(10)], collection: $collectionB); + + $this->assertNotEquals($fieldA, $fieldB); + } + + public function testFindRelationshipModeProducesDifferentCacheKeys(): void + { + $db = $this->createDatabase(); + + [, $fieldA] = $db->getFindCacheKeys('col', [Query::limit(10)]); + [, $fieldB] = $db->skipRelationships(fn () => $db->getFindCacheKeys('col', [Query::limit(10)])); + + $this->assertNotEquals($fieldA, $fieldB); + } + public function testParseHostname(): void { $hostname = 'database_db_nyc3_self_hosted_0_0'; diff --git a/tests/unit/FindCacheTest.php b/tests/unit/FindCacheTest.php new file mode 100644 index 000000000..d48a9b600 --- /dev/null +++ b/tests/unit/FindCacheTest.php @@ -0,0 +1,208 @@ +database = $this->createDatabase(new CacheMemory()); + } + + private function createDatabase(Adapter $cache): Database + { + $database = new Database(new DatabaseMemory(), new Cache($cache)); + $database + ->setDatabase('utopiaTests') + ->setNamespace('find_cache_' . \uniqid()); + + $database->create(); + $database->createCollection('projects'); + $database->createAttribute('projects', 'name', Database::VAR_STRING, 255, true); + + return $database; + } + + private function seedProject(Database $database, string $id, string $name): void + { + $database->createDocument('projects', new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + 'name' => $name, + ])); + } + + public function testFindCachedReturnsStaleResultUntilPurged(): void + { + $this->seedProject($this->database, 'first', 'First'); + + $documents = $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); + $this->assertCount(1, $documents); + $this->assertSame('first', $documents[0]->getId()); + + $this->seedProject($this->database, 'second', 'Second'); + + $documents = $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); + $this->assertCount(1, $documents); + $this->assertSame('first', $documents[0]->getId()); + + $this->database->purgeCachedFindCollection('projects'); + + $documents = $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); + $this->assertCount(2, $documents); + $this->assertSame('first', $documents[0]->getId()); + $this->assertSame('second', $documents[1]->getId()); + } + + public function testFindCachedBypassesCacheWhenTtlIsZero(): void + { + $this->seedProject($this->database, 'first', 'First'); + + $documents = $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); + $this->assertCount(1, $documents); + + $this->seedProject($this->database, 'second', 'Second'); + + $documents = $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 0); + $this->assertCount(2, $documents); + } + + public function testFindCachedTriggersFindEventOnCacheHit(): void + { + $events = []; + $this->database->on(Database::EVENT_DOCUMENT_FIND, 'test', function (string $event) use (&$events): void { + $events[] = $event; + }); + + $this->seedProject($this->database, 'first', 'First'); + + $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); + $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); + + $this->assertSame([ + Database::EVENT_DOCUMENT_FIND, + Database::EVENT_DOCUMENT_FIND, + ], $events); + } + + public function testPurgeCachedFindRemovesOnlyOneCallerKey(): void + { + $database = $this->createDatabase(new HashMemoryCache()); + + $this->seedProject($database, 'first', 'First'); + + $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'a'); + $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'b'); + + $this->seedProject($database, 'second', 'Second'); + + $database->purgeCachedFind('projects', key: 'a'); + + $documents = $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'a'); + $this->assertCount(2, $documents); + + $documents = $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'b'); + $this->assertCount(1, $documents); + } +} + +class HashMemoryCache implements Adapter +{ + /** + * @var array|string}>> + */ + private array $store = []; + + public function load(string $key, int $ttl, string $hash = ''): mixed + { + $hash = $hash === '' ? $key : $hash; + $saved = $this->store[$key][$hash] ?? null; + if ($saved === null) { + return false; + } + + return ($saved['time'] + $ttl > \time()) ? $saved['data'] : false; + } + + public function save(string $key, array|string $data, string $hash = ''): bool|string|array + { + if ($key === '' || empty($data)) { + return false; + } + + $hash = $hash === '' ? $key : $hash; + $this->store[$key][$hash] = [ + 'time' => \time(), + 'data' => $data, + ]; + + return $data; + } + + public function touch(string $key, string $hash = ''): bool + { + $hash = $hash === '' ? $key : $hash; + if (!isset($this->store[$key][$hash])) { + return false; + } + + $this->store[$key][$hash]['time'] = \time(); + + return true; + } + + /** + * @return array + */ + public function list(string $key): array + { + return \array_keys($this->store[$key] ?? []); + } + + public function purge(string $key, string $hash = ''): bool + { + if ($hash !== '') { + unset($this->store[$key][$hash]); + return true; + } + + unset($this->store[$key]); + + return true; + } + + public function flush(): bool + { + $this->store = []; + + return true; + } + + public function ping(): bool + { + return true; + } + + public function getSize(): int + { + return \count($this->store); + } + + public function getName(?string $key = null): string + { + return 'hash-memory'; + } +} From 6fad393887f89424a10d2d1f5328ffbcb30cb943 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Wed, 17 Jun 2026 19:51:24 +0100 Subject: [PATCH 02/51] Fix cached find adapter tests --- tests/e2e/Adapter/Base.php | 5 +++++ tests/e2e/Adapter/RedisTest.php | 5 +++++ tests/e2e/Adapter/Scopes/DocumentTests.php | 21 +++++++++++++-------- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index 4baeba35b..e5828b404 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -65,6 +65,11 @@ abstract protected function deleteColumn(string $collection, string $column): bo */ abstract protected function deleteIndex(string $collection, string $index): bool; + protected function supportsFindCache(): bool + { + return true; + } + public function setUp(): void { if (is_null(self::$authorization)) { diff --git a/tests/e2e/Adapter/RedisTest.php b/tests/e2e/Adapter/RedisTest.php index 23d779db0..fdee620f3 100644 --- a/tests/e2e/Adapter/RedisTest.php +++ b/tests/e2e/Adapter/RedisTest.php @@ -114,6 +114,11 @@ protected function deleteIndex(string $collection, string $index): bool return true; } + protected function supportsFindCache(): bool + { + return false; + } + /** * Inherited test exercises the case where an INTEGER column is altered * to VARCHAR. Redis stores documents as JSON; type changes do not diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index fcf58a3f0..e2798815f 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -105,35 +105,40 @@ public function testBigintSequence(): void public function testFindCachedReturnsStaleResultUntilPurged(): void { + if (!$this->supportsFindCache()) { + $this->markTestSkipped('Adapter test disables the cache layer.'); + } + /** @var Database $database */ $database = $this->getDatabase(); + $collection = 'findCached'; - $database->createCollection(__FUNCTION__); - $database->createAttribute(__FUNCTION__, 'name', Database::VAR_STRING, 255, true); + $database->createCollection($collection); + $database->createAttribute($collection, 'name', Database::VAR_STRING, 255, true); - $database->createDocument(__FUNCTION__, new Document([ + $database->createDocument($collection, new Document([ '$id' => 'first', '$permissions' => [Permission::read(Role::any())], 'name' => 'First', ])); - $documents = $database->findCached(__FUNCTION__, [Query::orderAsc('name')], ttl: 3600); + $documents = $database->findCached($collection, [Query::orderAsc('name')], ttl: 3600); $this->assertCount(1, $documents); $this->assertSame('first', $documents[0]->getId()); - $database->createDocument(__FUNCTION__, new Document([ + $database->createDocument($collection, new Document([ '$id' => 'second', '$permissions' => [Permission::read(Role::any())], 'name' => 'Second', ])); - $documents = $database->findCached(__FUNCTION__, [Query::orderAsc('name')], ttl: 3600); + $documents = $database->findCached($collection, [Query::orderAsc('name')], ttl: 3600); $this->assertCount(1, $documents); $this->assertSame('first', $documents[0]->getId()); - $database->purgeCachedFindCollection(__FUNCTION__); + $database->purgeCachedFindCollection($collection); - $documents = $database->findCached(__FUNCTION__, [Query::orderAsc('name')], ttl: 3600); + $documents = $database->findCached($collection, [Query::orderAsc('name')], ttl: 3600); $this->assertCount(2, $documents); $this->assertSame('first', $documents[0]->getId()); $this->assertSame('second', $documents[1]->getId()); From 5275b3374a7b5a165dada01ec6af146c0fa839ba Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Wed, 17 Jun 2026 23:34:37 +0100 Subject: [PATCH 03/51] Refine cached find behavior --- src/Database/Database.php | 57 +++++++++--- tests/e2e/Adapter/Base.php | 2 +- tests/e2e/Adapter/RedisTest.php | 2 +- tests/e2e/Adapter/Scopes/DocumentTests.php | 4 +- tests/unit/FindCacheTest.php | 100 ++++++++++++++++++++- 5 files changed, 146 insertions(+), 19 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index c9c340145..e4d4313c4 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8353,12 +8353,12 @@ public function purgeCachedCollection(string $collectionId): bool } $this->cache->purge($collectionKey); - $this->purgeCachedFindCollection($collectionId); + $this->purgeCachedFinds($collectionId); return true; } - public function purgeCachedFindCollection(string $collectionId): bool + public function purgeCachedFinds(string $collectionId): bool { [$findKey] = $this->getFindCacheKeys($collectionId); @@ -8595,13 +8595,14 @@ public function find(string $collection, array $queries = [], string $forPermiss * @param int $ttl * @param string|null $key Optional caller-owned cache variation key * @param string $forPermission + * @param bool $touchOnHit Refresh the cached entry timestamp on cache hit * @return array * @throws DatabaseException * @throws QueryException * @throws TimeoutException * @throws Exception */ - public function findCached(string $collection, array $queries = [], int $ttl = self::TTL, ?string $key = null, string $forPermission = Database::PERMISSION_READ): array + public function findCached(string $collection, array $queries = [], int $ttl = self::TTL, ?string $key = null, string $forPermission = Database::PERMISSION_READ, bool $touchOnHit = false): array { if ($ttl <= 0) { return $this->find($collection, $queries, $forPermission); @@ -8623,10 +8624,15 @@ public function findCached(string $collection, array $queries = [], int $ttl = s } if ($cached !== null && $cached !== false && \is_array($cached)) { - $documents = \array_map( - fn (array $document): Document => $this->createDocumentInstance($collectionDocument->getId(), $document), - $cached - ); + [$documents, $hasExpiredDocuments] = $this->decodeCachedFindPayload($collectionDocument, $cached); + + if ($touchOnHit && !$hasExpiredDocuments) { + try { + $this->cache->touch($findKey, $findField); + } catch (Exception $e) { + Console::warning('Warning: Failed to touch find result cache: ' . $e->getMessage()); + } + } $this->trigger(self::EVENT_DOCUMENT_FIND, $documents); @@ -8647,6 +8653,33 @@ public function findCached(string $collection, array $queries = [], int $ttl = s return $documents; } + /** + * @param array $payload + * @return array{0: array, 1: bool} + */ + private function decodeCachedFindPayload(Document $collection, array $payload): array + { + $results = []; + $hasExpiredDocuments = false; + + foreach ($payload as $document) { + if (!\is_array($document)) { + continue; + } + + $document = $this->createDocumentInstance($collection->getId(), $document); + + if ($this->isTtlExpired($collection, $document)) { + $hasExpiredDocuments = true; + continue; + } + + $results[] = $document; + } + + return [$results, $hasExpiredDocuments]; + } + /** * Helper method to iterate documents in collection using callback pattern * Alterative is @@ -9531,7 +9564,7 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a $payload = \json_encode([ 'selects' => $sortedSelects, 'relationships' => $this->resolveRelationships, - 'filters' => $this->getFilterSignatures(), + 'filters' => $this->getActiveFilterSignatures(), ]) ?: ''; $documentHashKey = $documentKey . ':' . \md5($payload); } @@ -9561,12 +9594,12 @@ public function getFindCacheKeys(string $collectionId, array $queries = [], ?str 'database' => $this->getDatabase(), 'schema' => $this->getFindCacheSchemaHash($collection), 'key' => $key, - 'queries' => $key === null ? $this->serializeFindCacheQueries($queries) : null, + 'queries' => $key === null ? $this->serializeQueriesForFindCache($queries) : null, 'authorization' => $this->authorization->getStatus(), 'roles' => $this->authorization->getRoles(), 'permission' => $forPermission, 'relationships' => $this->resolveRelationships, - 'filters' => $this->getFilterSignatures(), + 'filters' => $this->getActiveFilterSignatures(), ]; return [$findKey, \md5(\json_encode($payload) ?: '')]; @@ -9588,7 +9621,7 @@ private function getFindCacheSchemaHash(?Document $collection): string * @param array $queries * @return array */ - private function serializeFindCacheQueries(array $queries): array + private function serializeQueriesForFindCache(array $queries): array { return \array_map( static fn (Query $query): array => $query->toArray(), @@ -9599,7 +9632,7 @@ private function serializeFindCacheQueries(array $queries): array /** * @return array */ - private function getFilterSignatures(): array + private function getActiveFilterSignatures(): array { $filterSignatures = []; if (!$this->filter) { diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index e5828b404..105c1af62 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -65,7 +65,7 @@ abstract protected function deleteColumn(string $collection, string $column): bo */ abstract protected function deleteIndex(string $collection, string $index): bool; - protected function supportsFindCache(): bool + protected function supportsCachedFind(): bool { return true; } diff --git a/tests/e2e/Adapter/RedisTest.php b/tests/e2e/Adapter/RedisTest.php index fdee620f3..86ad85c63 100644 --- a/tests/e2e/Adapter/RedisTest.php +++ b/tests/e2e/Adapter/RedisTest.php @@ -114,7 +114,7 @@ protected function deleteIndex(string $collection, string $index): bool return true; } - protected function supportsFindCache(): bool + protected function supportsCachedFind(): bool { return false; } diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index e2798815f..3281124b2 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -105,7 +105,7 @@ public function testBigintSequence(): void public function testFindCachedReturnsStaleResultUntilPurged(): void { - if (!$this->supportsFindCache()) { + if (!$this->supportsCachedFind()) { $this->markTestSkipped('Adapter test disables the cache layer.'); } @@ -136,7 +136,7 @@ public function testFindCachedReturnsStaleResultUntilPurged(): void $this->assertCount(1, $documents); $this->assertSame('first', $documents[0]->getId()); - $database->purgeCachedFindCollection($collection); + $database->purgeCachedFinds($collection); $documents = $database->findCached($collection, [Query::orderAsc('name')], ttl: 3600); $this->assertCount(2, $documents); diff --git a/tests/unit/FindCacheTest.php b/tests/unit/FindCacheTest.php index d48a9b600..637d916d9 100644 --- a/tests/unit/FindCacheTest.php +++ b/tests/unit/FindCacheTest.php @@ -22,9 +22,9 @@ protected function setUp(): void $this->database = $this->createDatabase(new CacheMemory()); } - private function createDatabase(Adapter $cache): Database + private function createDatabase(Adapter $cache, ?DatabaseMemory $adapter = null): Database { - $database = new Database(new DatabaseMemory(), new Cache($cache)); + $database = new Database($adapter ?? new DatabaseMemory(), new Cache($cache)); $database ->setDatabase('utopiaTests') ->setNamespace('find_cache_' . \uniqid()); @@ -59,7 +59,7 @@ public function testFindCachedReturnsStaleResultUntilPurged(): void $this->assertCount(1, $documents); $this->assertSame('first', $documents[0]->getId()); - $this->database->purgeCachedFindCollection('projects'); + $this->database->purgeCachedFinds('projects'); $documents = $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); $this->assertCount(2, $documents); @@ -117,6 +117,76 @@ public function testPurgeCachedFindRemovesOnlyOneCallerKey(): void $documents = $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'b'); $this->assertCount(1, $documents); } + + public function testFindCachedTouchesCacheEntryOnHitWhenEnabled(): void + { + $cache = new TouchSpyCache(); + $database = $this->createDatabase($cache); + + $this->seedProject($database, 'first', 'First'); + + $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, touchOnHit: true); + $this->assertSame(0, $cache->touches); + + $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, touchOnHit: true); + $this->assertSame(1, $cache->touches); + } + + public function testFindCachedDoesNotTouchCacheEntryByDefault(): void + { + $cache = new TouchSpyCache(); + $database = $this->createDatabase($cache); + + $this->seedProject($database, 'first', 'First'); + + $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); + $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); + + $this->assertSame(0, $cache->touches); + } + + public function testFindCachedFiltersExpiredCachedDocuments(): void + { + $database = $this->createDatabase(new HashMemoryCache(), new TtlMemoryAdapter()); + $database->createAttribute('projects', 'expiresAt', Database::VAR_DATETIME, 0, false); + $database->createIndex('projects', 'expiresAtTtl', Database::INDEX_TTL, ['expiresAt'], ttl: 1); + + $database->createDocument('projects', new Document([ + '$id' => 'first', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'First', + 'expiresAt' => '2000-01-01T00:00:00.000+00:00', + ])); + + $documents = $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); + $this->assertCount(1, $documents); + + $documents = $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); + $this->assertCount(0, $documents); + } + + public function testFindCachedDoesNotTouchCacheEntryWithExpiredDocuments(): void + { + $cache = new TouchSpyCache(); + $database = $this->createDatabase($cache, new TtlMemoryAdapter()); + $database->createAttribute('projects', 'expiresAt', Database::VAR_DATETIME, 0, false); + $database->createIndex('projects', 'expiresAtTtl', Database::INDEX_TTL, ['expiresAt'], ttl: 1); + + $database->createDocument('projects', new Document([ + '$id' => 'first', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'First', + 'expiresAt' => '2000-01-01T00:00:00.000+00:00', + ])); + + $documents = $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, touchOnHit: true); + $this->assertCount(1, $documents); + $this->assertSame(0, $cache->touches); + + $documents = $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, touchOnHit: true); + $this->assertCount(0, $documents); + $this->assertSame(0, $cache->touches); + } } class HashMemoryCache implements Adapter @@ -206,3 +276,27 @@ public function getName(?string $key = null): string return 'hash-memory'; } } + +class TouchSpyCache extends HashMemoryCache +{ + public int $touches = 0; + + public function touch(string $key, string $hash = ''): bool + { + $touched = parent::touch($key, $hash); + + if ($touched) { + $this->touches++; + } + + return $touched; + } +} + +class TtlMemoryAdapter extends DatabaseMemory +{ + public function getSupportForTTLIndexes(): bool + { + return true; + } +} From 7cd9d19edcd92911452b83d91b0e5aef15fe93b2 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 18 Jun 2026 09:29:43 +0100 Subject: [PATCH 04/51] Harden cached find behavior --- src/Database/Database.php | 58 ++++++++--- tests/e2e/Adapter/Scopes/DocumentTests.php | 6 +- tests/unit/CacheKeyTest.php | 24 ----- tests/unit/FindCacheTest.php | 111 ++++++++++++++++----- 4 files changed, 136 insertions(+), 63 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index e4d4313c4..a1cd73036 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -173,6 +173,7 @@ class Database // Cache public const TTL = 60 * 60 * 24; // 24 hours + public const CACHE_TTL_MAX = 60 * 60 * 24; // 24 hours // Events public const EVENT_ALL = '*'; @@ -8368,10 +8369,10 @@ public function purgeCachedFinds(string $collectionId): bool /** * @param array $queries */ - public function purgeCachedFind(string $collectionId, ?string $key = null, array $queries = [], string $forPermission = Database::PERMISSION_READ): bool + public function purgeCachedFind(string $collectionId, ?string $key = null, array $queries = []): bool { $collection = $this->silent(fn () => $this->getCollection($collectionId)); - [$findKey, $findField] = $this->getFindCacheKeys($collectionId, $queries, $key, $forPermission, $collection); + [$findKey, $findField] = $this->authorization->skip(fn () => $this->getFindCacheKeys($collectionId, $queries, $key, $collection)); return $this->cache->purge($findKey, $findField); } @@ -8590,9 +8591,19 @@ public function find(string $collection, array $queries = [], string $forPermiss * find entry. Use this only for read paths that can tolerate bounded * staleness. * + * Cache reads and writes are only used when authorization is disabled, for + * example inside Authorization::skip(). With active authorization this + * delegates to find() so cached results cannot outlive permission changes. + * Cached hits rebuild top-level Document instances from stored arrays and + * are intended for internal flat-document reads. + * + * When $key is provided, it replaces query serialization in the cache key. + * The caller-owned key must include every query dimension that can change + * the result. + * * @param string $collection * @param array $queries - * @param int $ttl + * @param int $ttl Cache TTL in seconds. Defaults to 0, which disables caching. Values above CACHE_TTL_MAX are clamped. * @param string|null $key Optional caller-owned cache variation key * @param string $forPermission * @param bool $touchOnHit Refresh the cached entry timestamp on cache hit @@ -8602,19 +8613,25 @@ public function find(string $collection, array $queries = [], string $forPermiss * @throws TimeoutException * @throws Exception */ - public function findCached(string $collection, array $queries = [], int $ttl = self::TTL, ?string $key = null, string $forPermission = Database::PERMISSION_READ, bool $touchOnHit = false): array + public function findCached(string $collection, array $queries = [], int $ttl = 0, ?string $key = null, string $forPermission = Database::PERMISSION_READ, bool $touchOnHit = false): array { if ($ttl <= 0) { return $this->find($collection, $queries, $forPermission); } + if ($this->authorization->getStatus()) { + return $this->find($collection, $queries, $forPermission); + } + + $ttl = \min($ttl, self::CACHE_TTL_MAX); + $collectionDocument = $this->silent(fn () => $this->getCollection($collection)); if ($collectionDocument->isEmpty()) { throw new NotFoundException('Collection not found'); } - [$findKey, $findField] = $this->getFindCacheKeys($collectionDocument->getId(), $queries, $key, $forPermission, $collectionDocument); + [$findKey, $findField] = $this->getFindCacheKeys($collectionDocument->getId(), $queries, $key, $collectionDocument); try { $cached = $this->cache->load($findKey, $ttl, $findField); @@ -8626,6 +8643,19 @@ public function findCached(string $collection, array $queries = [], int $ttl = s if ($cached !== null && $cached !== false && \is_array($cached)) { [$documents, $hasExpiredDocuments] = $this->decodeCachedFindPayload($collectionDocument, $cached); + if ($hasExpiredDocuments) { + try { + $this->cache->purge($findKey, $findField); + } catch (Exception $e) { + Console::warning('Warning: Failed to purge expired find result cache: ' . $e->getMessage()); + } + + $documents = $this->find($collectionDocument->getId(), $queries, $forPermission); + $this->saveCachedFindPayload($findKey, $findField, $documents); + + return $documents; + } + if ($touchOnHit && !$hasExpiredDocuments) { try { $this->cache->touch($findKey, $findField); @@ -8641,6 +8671,16 @@ public function findCached(string $collection, array $queries = [], int $ttl = s $documents = $this->find($collectionDocument->getId(), $queries, $forPermission); + $this->saveCachedFindPayload($findKey, $findField, $documents); + + return $documents; + } + + /** + * @param array $documents + */ + private function saveCachedFindPayload(string $findKey, string $findField, array $documents): void + { try { $this->cache->save($findKey, \array_map( static fn (Document $document): array => $document->getArrayCopy(), @@ -8649,8 +8689,6 @@ public function findCached(string $collection, array $queries = [], int $ttl = s } catch (Exception $e) { Console::warning('Failed to save find result to cache: ' . $e->getMessage()); } - - return $documents; } /** @@ -9580,11 +9618,10 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a * @param string $collectionId * @param array $queries * @param string|null $key - * @param string $forPermission * @param Document|null $collection * @return array{0: string, 1: string} */ - public function getFindCacheKeys(string $collectionId, array $queries = [], ?string $key = null, string $forPermission = self::PERMISSION_READ, ?Document $collection = null): array + public function getFindCacheKeys(string $collectionId, array $queries = [], ?string $key = null, ?Document $collection = null): array { [$collectionKey] = $this->getCacheKeys($collectionId); $findKey = "{$collectionKey}:find"; @@ -9595,9 +9632,6 @@ public function getFindCacheKeys(string $collectionId, array $queries = [], ?str 'schema' => $this->getFindCacheSchemaHash($collection), 'key' => $key, 'queries' => $key === null ? $this->serializeQueriesForFindCache($queries) : null, - 'authorization' => $this->authorization->getStatus(), - 'roles' => $this->authorization->getRoles(), - 'permission' => $forPermission, 'relationships' => $this->resolveRelationships, 'filters' => $this->getActiveFilterSignatures(), ]; diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 3281124b2..6758190da 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -122,7 +122,7 @@ public function testFindCachedReturnsStaleResultUntilPurged(): void 'name' => 'First', ])); - $documents = $database->findCached($collection, [Query::orderAsc('name')], ttl: 3600); + $documents = $database->getAuthorization()->skip(fn () => $database->findCached($collection, [Query::orderAsc('name')], ttl: 3600)); $this->assertCount(1, $documents); $this->assertSame('first', $documents[0]->getId()); @@ -132,13 +132,13 @@ public function testFindCachedReturnsStaleResultUntilPurged(): void 'name' => 'Second', ])); - $documents = $database->findCached($collection, [Query::orderAsc('name')], ttl: 3600); + $documents = $database->getAuthorization()->skip(fn () => $database->findCached($collection, [Query::orderAsc('name')], ttl: 3600)); $this->assertCount(1, $documents); $this->assertSame('first', $documents[0]->getId()); $database->purgeCachedFinds($collection); - $documents = $database->findCached($collection, [Query::orderAsc('name')], ttl: 3600); + $documents = $database->getAuthorization()->skip(fn () => $database->findCached($collection, [Query::orderAsc('name')], ttl: 3600)); $this->assertCount(2, $documents); $this->assertSame('first', $documents[0]->getId()); $this->assertSame('second', $documents[1]->getId()); diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index 87b694b84..4b9821981 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -154,30 +154,6 @@ public function testCallerFindCacheKeyIgnoresQueryVariation(): void $this->assertEquals($fieldA, $fieldB); } - public function testDifferentFindRolesProduceDifferentCacheKeys(): void - { - $db = $this->createDatabase(); - - [, $fieldA] = $db->getFindCacheKeys('col', [Query::limit(10)]); - - $db->getAuthorization()->addRole('user:1'); - [, $fieldB] = $db->getFindCacheKeys('col', [Query::limit(10)]); - - $this->assertNotEquals($fieldA, $fieldB); - } - - public function testDifferentFindAuthorizationStatusProducesDifferentCacheKeys(): void - { - $db = $this->createDatabase(); - - [, $fieldA] = $db->getFindCacheKeys('col', [Query::limit(10)]); - - $db->getAuthorization()->disable(); - [, $fieldB] = $db->getFindCacheKeys('col', [Query::limit(10)]); - - $this->assertNotEquals($fieldA, $fieldB); - } - public function testDifferentFindDatabasesProduceDifferentCacheKeys(): void { $dbPlatform = $this->createDatabase(database: 'platform'); diff --git a/tests/unit/FindCacheTest.php b/tests/unit/FindCacheTest.php index 637d916d9..5a82dd313 100644 --- a/tests/unit/FindCacheTest.php +++ b/tests/unit/FindCacheTest.php @@ -49,19 +49,19 @@ public function testFindCachedReturnsStaleResultUntilPurged(): void { $this->seedProject($this->database, 'first', 'First'); - $documents = $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); $this->assertCount(1, $documents); $this->assertSame('first', $documents[0]->getId()); $this->seedProject($this->database, 'second', 'Second'); - $documents = $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); $this->assertCount(1, $documents); $this->assertSame('first', $documents[0]->getId()); $this->database->purgeCachedFinds('projects'); - $documents = $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); $this->assertCount(2, $documents); $this->assertSame('first', $documents[0]->getId()); $this->assertSame('second', $documents[1]->getId()); @@ -71,12 +71,38 @@ public function testFindCachedBypassesCacheWhenTtlIsZero(): void { $this->seedProject($this->database, 'first', 'First'); + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); + $this->assertCount(1, $documents); + + $this->seedProject($this->database, 'second', 'Second'); + + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 0)); + $this->assertCount(2, $documents); + } + + public function testFindCachedBypassesCacheByDefault(): void + { + $this->seedProject($this->database, 'first', 'First'); + + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')])); + $this->assertCount(1, $documents); + + $this->seedProject($this->database, 'second', 'Second'); + + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')])); + $this->assertCount(2, $documents); + } + + public function testFindCachedDelegatesToFindWhenAuthorizationIsEnabled(): void + { + $this->seedProject($this->database, 'first', 'First'); + $documents = $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); $this->assertCount(1, $documents); $this->seedProject($this->database, 'second', 'Second'); - $documents = $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 0); + $documents = $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); $this->assertCount(2, $documents); } @@ -89,8 +115,8 @@ public function testFindCachedTriggersFindEventOnCacheHit(): void $this->seedProject($this->database, 'first', 'First'); - $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); - $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); + $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); + $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); $this->assertSame([ Database::EVENT_DOCUMENT_FIND, @@ -104,17 +130,17 @@ public function testPurgeCachedFindRemovesOnlyOneCallerKey(): void $this->seedProject($database, 'first', 'First'); - $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'a'); - $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'b'); + $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'a')); + $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'b')); $this->seedProject($database, 'second', 'Second'); $database->purgeCachedFind('projects', key: 'a'); - $documents = $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'a'); + $documents = $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'a')); $this->assertCount(2, $documents); - $documents = $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'b'); + $documents = $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'b')); $this->assertCount(1, $documents); } @@ -125,10 +151,10 @@ public function testFindCachedTouchesCacheEntryOnHitWhenEnabled(): void $this->seedProject($database, 'first', 'First'); - $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, touchOnHit: true); + $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, touchOnHit: true)); $this->assertSame(0, $cache->touches); - $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, touchOnHit: true); + $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, touchOnHit: true)); $this->assertSame(1, $cache->touches); } @@ -139,15 +165,16 @@ public function testFindCachedDoesNotTouchCacheEntryByDefault(): void $this->seedProject($database, 'first', 'First'); - $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); - $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); + $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); + $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); $this->assertSame(0, $cache->touches); } - public function testFindCachedFiltersExpiredCachedDocuments(): void + public function testFindCachedRefetchesExpiredCachedDocuments(): void { - $database = $this->createDatabase(new HashMemoryCache(), new TtlMemoryAdapter()); + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache, new TtlMemoryAdapter()); $database->createAttribute('projects', 'expiresAt', Database::VAR_DATETIME, 0, false); $database->createIndex('projects', 'expiresAtTtl', Database::INDEX_TTL, ['expiresAt'], ttl: 1); @@ -155,14 +182,25 @@ public function testFindCachedFiltersExpiredCachedDocuments(): void '$id' => 'first', '$permissions' => [Permission::read(Role::any())], 'name' => 'First', - 'expiresAt' => '2000-01-01T00:00:00.000+00:00', + 'expiresAt' => '2999-01-01T00:00:00.000+00:00', ])); + $this->seedProject($database, 'second', 'Second'); - $documents = $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); + $documents = $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name'), Query::limit(1)], ttl: 3600)); $this->assertCount(1, $documents); + $this->assertSame('first', $documents[0]->getId()); - $documents = $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); - $this->assertCount(0, $documents); + [$findKey, $findField] = $database->getAuthorization()->skip(fn () => $database->getFindCacheKeys( + 'projects', + [Query::orderAsc('name'), Query::limit(1)], + collection: $database->getCollection('projects') + )); + $cache->setCachedDocumentAttribute($findKey, $findField, 'first', 'expiresAt', '2000-01-01T00:00:00.000+00:00'); + $database->getAuthorization()->skip(fn () => $database->updateDocument('projects', 'first', new Document(['name' => 'Zulu']))); + + $documents = $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name'), Query::limit(1)], ttl: 3600)); + $this->assertCount(1, $documents); + $this->assertSame('second', $documents[0]->getId()); } public function testFindCachedDoesNotTouchCacheEntryWithExpiredDocuments(): void @@ -176,15 +214,22 @@ public function testFindCachedDoesNotTouchCacheEntryWithExpiredDocuments(): void '$id' => 'first', '$permissions' => [Permission::read(Role::any())], 'name' => 'First', - 'expiresAt' => '2000-01-01T00:00:00.000+00:00', + 'expiresAt' => '2999-01-01T00:00:00.000+00:00', ])); - $documents = $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, touchOnHit: true); + $documents = $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, touchOnHit: true)); $this->assertCount(1, $documents); $this->assertSame(0, $cache->touches); - $documents = $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, touchOnHit: true); - $this->assertCount(0, $documents); + [$findKey, $findField] = $database->getAuthorization()->skip(fn () => $database->getFindCacheKeys( + 'projects', + [Query::orderAsc('name')], + collection: $database->getCollection('projects') + )); + $cache->setCachedDocumentAttribute($findKey, $findField, 'first', 'expiresAt', '2000-01-01T00:00:00.000+00:00'); + + $documents = $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, touchOnHit: true)); + $this->assertCount(1, $documents); $this->assertSame(0, $cache->touches); } } @@ -234,6 +279,24 @@ public function touch(string $key, string $hash = ''): bool return true; } + public function setCachedDocumentAttribute(string $key, string $hash, string $documentId, string $attribute, mixed $value): void + { + $documents = $this->store[$key][$hash]['data'] ?? []; + if (!\is_array($documents)) { + return; + } + + foreach ($documents as $index => $document) { + if (!\is_array($document) || ($document['$id'] ?? '') !== $documentId) { + continue; + } + + $documents[$index][$attribute] = $value; + $this->store[$key][$hash]['data'] = $documents; + return; + } + } + /** * @return array */ From 65d732069cc5ac6a4dc69babe80002df4cda2b83 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 18 Jun 2026 09:42:57 +0100 Subject: [PATCH 05/51] Simplify cached find query key serialization --- src/Database/Database.php | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index a1cd73036..efc4b8f6f 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -9631,7 +9631,10 @@ public function getFindCacheKeys(string $collectionId, array $queries = [], ?str 'database' => $this->getDatabase(), 'schema' => $this->getFindCacheSchemaHash($collection), 'key' => $key, - 'queries' => $key === null ? $this->serializeQueriesForFindCache($queries) : null, + 'queries' => $key === null ? \array_map( + static fn (Query $query): array => $query->toArray(), + $queries + ) : null, 'relationships' => $this->resolveRelationships, 'filters' => $this->getActiveFilterSignatures(), ]; @@ -9651,18 +9654,6 @@ private function getFindCacheSchemaHash(?Document $collection): string ); } - /** - * @param array $queries - * @return array - */ - private function serializeQueriesForFindCache(array $queries): array - { - return \array_map( - static fn (Query $query): array => $query->toArray(), - $queries - ); - } - /** * @return array */ From f6e0855bc30fb54dc58f7bcd922dadff44bf5598 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 18 Jun 2026 11:15:25 +0100 Subject: [PATCH 06/51] Fix cached find keys --- src/Database/Database.php | 65 +++++++++++++++++++++++++++++++----- tests/unit/CacheKeyTest.php | 42 +++++++++++++++++------ tests/unit/FindCacheTest.php | 4 +-- 3 files changed, 91 insertions(+), 20 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index efc4b8f6f..e4574f1d0 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8361,7 +8361,7 @@ public function purgeCachedCollection(string $collectionId): bool public function purgeCachedFinds(string $collectionId): bool { - [$findKey] = $this->getFindCacheKeys($collectionId); + [$findKey] = $this->getCachedFindKeys($collectionId); return $this->cache->purge($findKey); } @@ -8372,7 +8372,7 @@ public function purgeCachedFinds(string $collectionId): bool public function purgeCachedFind(string $collectionId, ?string $key = null, array $queries = []): bool { $collection = $this->silent(fn () => $this->getCollection($collectionId)); - [$findKey, $findField] = $this->authorization->skip(fn () => $this->getFindCacheKeys($collectionId, $queries, $key, $collection)); + [$findKey, $findField] = $this->authorization->skip(fn () => $this->getCachedFindKeys($collectionId, $queries, $key, $collection)); return $this->cache->purge($findKey, $findField); } @@ -8631,7 +8631,7 @@ public function findCached(string $collection, array $queries = [], int $ttl = 0 throw new NotFoundException('Collection not found'); } - [$findKey, $findField] = $this->getFindCacheKeys($collectionDocument->getId(), $queries, $key, $collectionDocument); + [$findKey, $findField] = $this->getCachedFindKeys($collectionDocument->getId(), $queries, $key, $collectionDocument); try { $cached = $this->cache->load($findKey, $ttl, $findField); @@ -8656,7 +8656,7 @@ public function findCached(string $collection, array $queries = [], int $ttl = 0 return $documents; } - if ($touchOnHit && !$hasExpiredDocuments) { + if ($touchOnHit) { try { $this->cache->touch($findKey, $findField); } catch (Exception $e) { @@ -9621,7 +9621,7 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a * @param Document|null $collection * @return array{0: string, 1: string} */ - public function getFindCacheKeys(string $collectionId, array $queries = [], ?string $key = null, ?Document $collection = null): array + public function getCachedFindKeys(string $collectionId, array $queries = [], ?string $key = null, ?Document $collection = null): array { [$collectionKey] = $this->getCacheKeys($collectionId); $findKey = "{$collectionKey}:find"; @@ -9629,10 +9629,10 @@ public function getFindCacheKeys(string $collectionId, array $queries = [], ?str $payload = [ 'version' => 1, 'database' => $this->getDatabase(), - 'schema' => $this->getFindCacheSchemaHash($collection), + 'schema' => $this->getCachedFindSchemaHash($collection), 'key' => $key, 'queries' => $key === null ? \array_map( - static fn (Query $query): array => $query->toArray(), + fn (Query $query): array => $this->serializeCachedFindQuery($query), $queries ) : null, 'relationships' => $this->resolveRelationships, @@ -9642,7 +9642,56 @@ public function getFindCacheKeys(string $collectionId, array $queries = [], ?str return [$findKey, \md5(\json_encode($payload) ?: '')]; } - private function getFindCacheSchemaHash(?Document $collection): string + /** + * @return array + */ + private function serializeCachedFindQuery(Query $query): array + { + $serialized = [ + 'method' => $query->getMethod(), + ]; + + if ($query->getAttribute() !== '') { + $serialized['attribute'] = $query->getAttribute(); + } + + $values = []; + foreach ($query->getValues() as $value) { + if ($value instanceof Query) { + $values[] = $this->serializeCachedFindQuery($value); + continue; + } + + if ($value instanceof Document && \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE], true)) { + $value = $value->getArrayCopy(); + } + + $values[] = $this->normalizeCachedFindQueryValue($value); + } + + $serialized['values'] = $values; + + return $serialized; + } + + private function normalizeCachedFindQueryValue(mixed $value): mixed + { + if ($value instanceof Document) { + $value = $value->getArrayCopy(); + } + + if (!\is_array($value)) { + return $value; + } + + foreach ($value as $key => $item) { + $value[$key] = $this->normalizeCachedFindQueryValue($item); + } + + return $value; + } + + private function getCachedFindSchemaHash(?Document $collection): string { if ($collection === null || $collection->isEmpty()) { return ''; diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index 4b9821981..67bb82daf 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -138,8 +138,8 @@ public function testDifferentFindQueriesProduceDifferentCacheKeys(): void { $db = $this->createDatabase(); - [, $fieldA] = $db->getFindCacheKeys('col', [Query::equal('status', ['active'])]); - [, $fieldB] = $db->getFindCacheKeys('col', [Query::equal('status', ['paused'])]); + [, $fieldA] = $db->getCachedFindKeys('col', [Query::equal('status', ['active'])]); + [, $fieldB] = $db->getCachedFindKeys('col', [Query::equal('status', ['paused'])]); $this->assertNotEquals($fieldA, $fieldB); } @@ -148,8 +148,8 @@ public function testCallerFindCacheKeyIgnoresQueryVariation(): void { $db = $this->createDatabase(); - [, $fieldA] = $db->getFindCacheKeys('col', [Query::equal('status', ['active'])], 'domain-key'); - [, $fieldB] = $db->getFindCacheKeys('col', [Query::equal('status', ['paused'])], 'domain-key'); + [, $fieldA] = $db->getCachedFindKeys('col', [Query::equal('status', ['active'])], 'domain-key'); + [, $fieldB] = $db->getCachedFindKeys('col', [Query::equal('status', ['paused'])], 'domain-key'); $this->assertEquals($fieldA, $fieldB); } @@ -159,8 +159,8 @@ public function testDifferentFindDatabasesProduceDifferentCacheKeys(): void $dbPlatform = $this->createDatabase(database: 'platform'); $dbProject = $this->createDatabase(database: 'project'); - [, $fieldA] = $dbPlatform->getFindCacheKeys('col', [Query::limit(10)]); - [, $fieldB] = $dbProject->getFindCacheKeys('col', [Query::limit(10)]); + [, $fieldA] = $dbPlatform->getCachedFindKeys('col', [Query::limit(10)]); + [, $fieldB] = $dbProject->getCachedFindKeys('col', [Query::limit(10)]); $this->assertNotEquals($fieldA, $fieldB); } @@ -185,8 +185,8 @@ public function testDifferentFindSchemasProduceDifferentCacheKeys(): void 'indexes' => [], ]); - [, $fieldA] = $db->getFindCacheKeys('col', [Query::limit(10)], collection: $collectionA); - [, $fieldB] = $db->getFindCacheKeys('col', [Query::limit(10)], collection: $collectionB); + [, $fieldA] = $db->getCachedFindKeys('col', [Query::limit(10)], collection: $collectionA); + [, $fieldB] = $db->getCachedFindKeys('col', [Query::limit(10)], collection: $collectionB); $this->assertNotEquals($fieldA, $fieldB); } @@ -195,8 +195,30 @@ public function testFindRelationshipModeProducesDifferentCacheKeys(): void { $db = $this->createDatabase(); - [, $fieldA] = $db->getFindCacheKeys('col', [Query::limit(10)]); - [, $fieldB] = $db->skipRelationships(fn () => $db->getFindCacheKeys('col', [Query::limit(10)])); + [, $fieldA] = $db->getCachedFindKeys('col', [Query::limit(10)]); + [, $fieldB] = $db->skipRelationships(fn () => $db->getCachedFindKeys('col', [Query::limit(10)])); + + $this->assertNotEquals($fieldA, $fieldB); + } + + public function testFindCursorDocumentValuesProduceDifferentCacheKeys(): void + { + $db = $this->createDatabase(); + + [, $fieldA] = $db->getCachedFindKeys('col', [ + Query::orderAsc('name'), + Query::cursorAfter(new Document([ + '$id' => 'cursor', + 'name' => 'alpha', + ])), + ]); + [, $fieldB] = $db->getCachedFindKeys('col', [ + Query::orderAsc('name'), + Query::cursorAfter(new Document([ + '$id' => 'cursor', + 'name' => 'beta', + ])), + ]); $this->assertNotEquals($fieldA, $fieldB); } diff --git a/tests/unit/FindCacheTest.php b/tests/unit/FindCacheTest.php index 5a82dd313..e91f967f8 100644 --- a/tests/unit/FindCacheTest.php +++ b/tests/unit/FindCacheTest.php @@ -190,7 +190,7 @@ public function testFindCachedRefetchesExpiredCachedDocuments(): void $this->assertCount(1, $documents); $this->assertSame('first', $documents[0]->getId()); - [$findKey, $findField] = $database->getAuthorization()->skip(fn () => $database->getFindCacheKeys( + [$findKey, $findField] = $database->getAuthorization()->skip(fn () => $database->getCachedFindKeys( 'projects', [Query::orderAsc('name'), Query::limit(1)], collection: $database->getCollection('projects') @@ -221,7 +221,7 @@ public function testFindCachedDoesNotTouchCacheEntryWithExpiredDocuments(): void $this->assertCount(1, $documents); $this->assertSame(0, $cache->touches); - [$findKey, $findField] = $database->getAuthorization()->skip(fn () => $database->getFindCacheKeys( + [$findKey, $findField] = $database->getAuthorization()->skip(fn () => $database->getCachedFindKeys( 'projects', [Query::orderAsc('name')], collection: $database->getCollection('projects') From 4e514a38764c84c5372f1fc54fd1336fdf99fd6b Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 18 Jun 2026 11:27:47 +0100 Subject: [PATCH 07/51] Use default TTL for cached find --- src/Database/Database.php | 7 +++---- tests/unit/FindCacheTest.php | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index e4574f1d0..db7840a32 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -173,7 +173,6 @@ class Database // Cache public const TTL = 60 * 60 * 24; // 24 hours - public const CACHE_TTL_MAX = 60 * 60 * 24; // 24 hours // Events public const EVENT_ALL = '*'; @@ -8603,7 +8602,7 @@ public function find(string $collection, array $queries = [], string $forPermiss * * @param string $collection * @param array $queries - * @param int $ttl Cache TTL in seconds. Defaults to 0, which disables caching. Values above CACHE_TTL_MAX are clamped. + * @param int $ttl Cache TTL in seconds. Values above TTL are clamped. Set to 0 to disable caching. * @param string|null $key Optional caller-owned cache variation key * @param string $forPermission * @param bool $touchOnHit Refresh the cached entry timestamp on cache hit @@ -8613,7 +8612,7 @@ public function find(string $collection, array $queries = [], string $forPermiss * @throws TimeoutException * @throws Exception */ - public function findCached(string $collection, array $queries = [], int $ttl = 0, ?string $key = null, string $forPermission = Database::PERMISSION_READ, bool $touchOnHit = false): array + public function findCached(string $collection, array $queries = [], int $ttl = self::TTL, ?string $key = null, string $forPermission = Database::PERMISSION_READ, bool $touchOnHit = false): array { if ($ttl <= 0) { return $this->find($collection, $queries, $forPermission); @@ -8623,7 +8622,7 @@ public function findCached(string $collection, array $queries = [], int $ttl = 0 return $this->find($collection, $queries, $forPermission); } - $ttl = \min($ttl, self::CACHE_TTL_MAX); + $ttl = \min($ttl, self::TTL); $collectionDocument = $this->silent(fn () => $this->getCollection($collection)); diff --git a/tests/unit/FindCacheTest.php b/tests/unit/FindCacheTest.php index e91f967f8..104d39fa9 100644 --- a/tests/unit/FindCacheTest.php +++ b/tests/unit/FindCacheTest.php @@ -80,7 +80,7 @@ public function testFindCachedBypassesCacheWhenTtlIsZero(): void $this->assertCount(2, $documents); } - public function testFindCachedBypassesCacheByDefault(): void + public function testFindCachedUsesDefaultTtl(): void { $this->seedProject($this->database, 'first', 'First'); @@ -90,7 +90,7 @@ public function testFindCachedBypassesCacheByDefault(): void $this->seedProject($this->database, 'second', 'Second'); $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')])); - $this->assertCount(2, $documents); + $this->assertCount(1, $documents); } public function testFindCachedDelegatesToFindWhenAuthorizationIsEnabled(): void From 8b63a7595dd68f7627278f0eabd2db18ddd93244 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 18 Jun 2026 11:53:20 +0100 Subject: [PATCH 08/51] Bypass cached find for random order --- src/Database/Database.php | 6 ++++++ tests/unit/FindCacheTest.php | 19 ++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index db7840a32..8ee30d696 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8622,6 +8622,12 @@ public function findCached(string $collection, array $queries = [], int $ttl = s return $this->find($collection, $queries, $forPermission); } + foreach ($queries as $query) { + if ($query->getMethod() === Query::TYPE_ORDER_RANDOM) { + return $this->find($collection, $queries, $forPermission); + } + } + $ttl = \min($ttl, self::TTL); $collectionDocument = $this->silent(fn () => $this->getCollection($collection)); diff --git a/tests/unit/FindCacheTest.php b/tests/unit/FindCacheTest.php index 104d39fa9..cfd5e6216 100644 --- a/tests/unit/FindCacheTest.php +++ b/tests/unit/FindCacheTest.php @@ -106,6 +106,19 @@ public function testFindCachedDelegatesToFindWhenAuthorizationIsEnabled(): void $this->assertCount(2, $documents); } + public function testFindCachedBypassesCacheForRandomOrder(): void + { + $this->seedProject($this->database, 'first', 'First'); + + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderRandom()], ttl: 3600)); + $this->assertCount(1, $documents); + + $this->seedProject($this->database, 'second', 'Second'); + + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderRandom()], ttl: 3600)); + $this->assertCount(2, $documents); + } + public function testFindCachedTriggersFindEventOnCacheHit(): void { $events = []; @@ -116,7 +129,11 @@ public function testFindCachedTriggersFindEventOnCacheHit(): void $this->seedProject($this->database, 'first', 'First'); $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); - $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); + $this->seedProject($this->database, 'second', 'Second'); + + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); + $this->assertCount(1, $documents); + $this->assertSame('first', $documents[0]->getId()); $this->assertSame([ Database::EVENT_DOCUMENT_FIND, From d543ce5be0ef7d3bdbb5e5564d3f34f6ebdaf702 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 18 Jun 2026 12:18:59 +0100 Subject: [PATCH 09/51] Guard cached find random query check --- src/Database/Database.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 8ee30d696..d66a0be7f 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8623,7 +8623,7 @@ public function findCached(string $collection, array $queries = [], int $ttl = s } foreach ($queries as $query) { - if ($query->getMethod() === Query::TYPE_ORDER_RANDOM) { + if ($query instanceof Query && $query->getMethod() === Query::TYPE_ORDER_RANDOM) { return $this->find($collection, $queries, $forPermission); } } From a9ff6a62827566d265c63a9527ee17730f73fa22 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 18 Jun 2026 12:46:06 +0100 Subject: [PATCH 10/51] Inline cached find save --- src/Database/Database.php | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index d66a0be7f..98ab856ce 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8656,7 +8656,14 @@ public function findCached(string $collection, array $queries = [], int $ttl = s } $documents = $this->find($collectionDocument->getId(), $queries, $forPermission); - $this->saveCachedFindPayload($findKey, $findField, $documents); + try { + $this->cache->save($findKey, \array_map( + static fn (Document $document): array => $document->getArrayCopy(), + $documents + ), $findField); + } catch (Exception $e) { + Console::warning('Failed to save find result to cache: ' . $e->getMessage()); + } return $documents; } @@ -8676,16 +8683,6 @@ public function findCached(string $collection, array $queries = [], int $ttl = s $documents = $this->find($collectionDocument->getId(), $queries, $forPermission); - $this->saveCachedFindPayload($findKey, $findField, $documents); - - return $documents; - } - - /** - * @param array $documents - */ - private function saveCachedFindPayload(string $findKey, string $findField, array $documents): void - { try { $this->cache->save($findKey, \array_map( static fn (Document $document): array => $document->getArrayCopy(), @@ -8694,6 +8691,8 @@ private function saveCachedFindPayload(string $findKey, string $findField, array } catch (Exception $e) { Console::warning('Failed to save find result to cache: ' . $e->getMessage()); } + + return $documents; } /** From 73d22367d65250d2cfc76bc2d2ead607c9e17276 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 18 Jun 2026 13:18:56 +0100 Subject: [PATCH 11/51] Simplify cached find checks --- src/Database/Database.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 98ab856ce..23309c278 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8645,7 +8645,7 @@ public function findCached(string $collection, array $queries = [], int $ttl = s $cached = null; } - if ($cached !== null && $cached !== false && \is_array($cached)) { + if (\is_array($cached)) { [$documents, $hasExpiredDocuments] = $this->decodeCachedFindPayload($collectionDocument, $cached); if ($hasExpiredDocuments) { @@ -9666,10 +9666,6 @@ private function serializeCachedFindQuery(Query $query): array continue; } - if ($value instanceof Document && \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE], true)) { - $value = $value->getArrayCopy(); - } - $values[] = $this->normalizeCachedFindQueryValue($value); } From 3ba5988d8186391f368b86cc9f7ff0bbe6c89961 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 18 Jun 2026 20:23:35 +0100 Subject: [PATCH 12/51] Add list cache key helper --- src/Database/Database.php | 24 +++++++++++++++++++ tests/unit/CacheKeyTest.php | 48 +++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/Database/Database.php b/src/Database/Database.php index 23309c278..6481b1be1 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -9618,6 +9618,30 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a ]; } + /** + * Stable cache key for cached list entries on a collection. + * + * @param string $collectionId + * @param string|null $namespace + * @param int|string|null $tenant + * @return string + */ + public function getListCacheKey(string $collectionId, ?string $namespace = null, int|string|null $tenant = null): string + { + $hostname = $this->adapter->getSupportForHostname() + ? $this->adapter->getHostname() + : ''; + + return \sprintf( + '%s-cache:%s:%s:%s:collection:%s', + $this->cacheName, + $hostname, + $namespace ?? $this->getNamespace(), + $tenant ?? $this->adapter->getTenant(), + $collectionId, + ); + } + /** * @param string $collectionId * @param array $queries diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index 67bb82daf..573a76044 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -154,6 +154,54 @@ public function testCallerFindCacheKeyIgnoresQueryVariation(): void $this->assertEquals($fieldA, $fieldB); } + public function testCollectionCacheKeyUsesListCacheShape(): void + { + $adapter = $this->createMock(Adapter::class); + $adapter->method('getSupportForHostname')->willReturn(true); + $adapter->method('getHostname')->willReturn('mysql-console'); + $adapter->method('getNamespace')->willReturn('_39'); + $adapter->method('getTenant')->willReturn(null); + + $db = new Database($adapter, new Cache(new None()), []); + + $this->assertSame( + 'default-cache:mysql-console:_39::collection:ttl_cache_table', + $db->getListCacheKey('ttl_cache_table'), + ); + } + + public function testCollectionCacheKeyCanOverrideNamespaceSegment(): void + { + $adapter = $this->createMock(Adapter::class); + $adapter->method('getSupportForHostname')->willReturn(true); + $adapter->method('getHostname')->willReturn('mysql-console'); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('getTenant')->willReturn(null); + + $db = new Database($adapter, new Cache(new None()), []); + + $this->assertSame( + 'default-cache:mysql-console:_39::collection:wafrules', + $db->getListCacheKey('wafrules', '_39'), + ); + } + + public function testCollectionCacheKeyCanOverrideTenantSegment(): void + { + $adapter = $this->createMock(Adapter::class); + $adapter->method('getSupportForHostname')->willReturn(true); + $adapter->method('getHostname')->willReturn('mysql-console'); + $adapter->method('getNamespace')->willReturn('_39'); + $adapter->method('getTenant')->willReturn(null); + + $db = new Database($adapter, new Cache(new None()), []); + + $this->assertSame( + 'default-cache:mysql-console:_39:tenant-a:collection:wafrules', + $db->getListCacheKey('wafrules', tenant: 'tenant-a'), + ); + } + public function testDifferentFindDatabasesProduceDifferentCacheKeys(): void { $dbPlatform = $this->createDatabase(database: 'platform'); From d4531b5521ea19ce3e72fde4bdf706706451e8ba Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 18 Jun 2026 20:43:31 +0100 Subject: [PATCH 13/51] Add list cache field helper --- src/Database/Database.php | 25 +++++++++++++++ tests/unit/CacheKeyTest.php | 63 +++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/src/Database/Database.php b/src/Database/Database.php index 6481b1be1..f569557e2 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -9642,6 +9642,31 @@ public function getListCacheKey(string $collectionId, ?string $namespace = null, ); } + /** + * Stable cache field for cached list entries on a collection. + * + * @param Document|null $collection + * @param array $queries + * @param array $roles + * @param string $field + * @return string + */ + public function getListCacheField(?Document $collection = null, array $queries = [], array $roles = [], string $field = 'documents'): string + { + $serialized = \array_map( + static fn (Query $query): array => $query->toArray(), + $queries, + ); + + return \sprintf( + '%s:%s:%s:%s', + $this->getCachedFindSchemaHash($collection), + \md5(\json_encode($roles) ?: ''), + \md5(\json_encode($serialized) ?: ''), + $field, + ); + } + /** * @param string $collectionId * @param array $queries diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index 573a76044..f9375b037 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -202,6 +202,69 @@ public function testCollectionCacheKeyCanOverrideTenantSegment(): void ); } + public function testCollectionCacheFieldUsesListCacheShape(): void + { + $db = $this->createDatabase(); + $collection = new Document([ + '$id' => 'wafRules', + 'attributes' => [ + new Document(['$id' => 'projectId', 'type' => Database::VAR_STRING]), + new Document(['$id' => 'enabled', 'type' => Database::VAR_BOOLEAN]), + ], + 'indexes' => [ + new Document(['$id' => 'project_enabled', 'attributes' => ['projectId', 'enabled']]), + ], + ]); + $queries = [ + Query::equal('projectId', ['project-a']), + Query::equal('enabled', [true]), + Query::orderAsc('priority'), + ]; + + $schemaHash = \md5( + \json_encode($collection->getAttribute('attributes', [])) + . \json_encode($collection->getAttribute('indexes', [])) + ); + $queryHash = \md5(\json_encode(\array_map( + static fn (Query $query): array => $query->toArray(), + $queries, + ))); + + $this->assertSame( + "{$schemaHash}:".\md5(\json_encode(['waf'])).":{$queryHash}:documents", + $db->getListCacheField($collection, $queries, ['waf']), + ); + } + + public function testCollectionCacheFieldChangesWithInputs(): void + { + $db = $this->createDatabase(); + + $field = $db->getListCacheField( + new Document([ + 'attributes' => [new Document(['$id' => 'name', 'type' => Database::VAR_STRING])], + 'indexes' => [], + ]), + [Query::limit(10)], + ['role-a'], + ); + + $this->assertNotSame( + $field, + $db->getListCacheField( + new Document([ + 'attributes' => [new Document(['$id' => 'status', 'type' => Database::VAR_STRING])], + 'indexes' => [], + ]), + [Query::limit(10)], + ['role-a'], + ), + ); + $this->assertNotSame($field, $db->getListCacheField(null, [Query::limit(20)], ['role-a'])); + $this->assertNotSame($field, $db->getListCacheField(null, [Query::limit(10)], ['role-b'])); + $this->assertStringEndsWith(':total', $db->getListCacheField(null, [Query::limit(10)], ['role-a'], 'total')); + } + public function testDifferentFindDatabasesProduceDifferentCacheKeys(): void { $dbPlatform = $this->createDatabase(database: 'platform'); From 4f7bbf6a36a66ec7af452ab3d312633dd1a9c6cd Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 18 Jun 2026 21:04:16 +0100 Subject: [PATCH 14/51] Add list cached find support --- src/Database/Database.php | 109 ++++++++++++++++++++++++++++ tests/unit/FindCacheTest.php | 137 +++++++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+) diff --git a/src/Database/Database.php b/src/Database/Database.php index f569557e2..e8261fa8d 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8695,6 +8695,115 @@ public function findCached(string $collection, array $queries = [], int $ttl = s return $documents; } + /** + * Find documents using an Appwrite list-cache compatible key and field. + * + * @param string $collection + * @param array $queries + * @param int $ttl Cache TTL in seconds. Values above TTL are clamped. Set to 0 to disable caching. + * @param string|null $cacheCollection + * @param string|null $namespace + * @param int|string|null $tenant + * @param array $roles + * @param string $field + * @param string $payload + * @param string $forPermission + * @return array + * @throws DatabaseException + * @throws QueryException + * @throws TimeoutException + * @throws Exception + */ + public function findListCached( + string $collection, + array $queries = [], + int $ttl = self::TTL, + ?string $cacheCollection = null, + ?string $namespace = null, + int|string|null $tenant = null, + array $roles = [], + string $field = 'documents', + string $payload = 'documents', + string $forPermission = Database::PERMISSION_READ, + ): array { + if ($ttl <= 0) { + return $this->find($collection, $queries, $forPermission); + } + + if ($this->authorization->getStatus()) { + return $this->find($collection, $queries, $forPermission); + } + + foreach ($queries as $query) { + if ($query instanceof Query && $query->getMethod() === Query::TYPE_ORDER_RANDOM) { + return $this->find($collection, $queries, $forPermission); + } + } + + $ttl = \min($ttl, self::TTL); + + $collectionDocument = $this->silent(fn () => $this->getCollection($collection)); + + if ($collectionDocument->isEmpty()) { + throw new NotFoundException('Collection not found'); + } + + $cacheKey = $this->getListCacheKey($cacheCollection ?? $collectionDocument->getId(), $namespace, $tenant); + $cacheField = $this->getListCacheField($collectionDocument, $queries, $roles, $field); + + try { + $cached = $this->cache->load($cacheKey, $ttl, $cacheField); + } catch (Exception $e) { + Console::warning('Warning: Failed to get list result from cache: ' . $e->getMessage()); + $cached = null; + } + + if (\is_array($cached) && isset($cached[$payload]) && \is_array($cached[$payload])) { + [$documents, $hasExpiredDocuments] = $this->decodeCachedFindPayload($collectionDocument, $cached[$payload]); + + if ($hasExpiredDocuments) { + try { + $this->cache->purge($cacheKey, $cacheField); + } catch (Exception $e) { + Console::warning('Warning: Failed to purge expired list result cache: ' . $e->getMessage()); + } + + $documents = $this->find($collectionDocument->getId(), $queries, $forPermission); + try { + $this->cache->save($cacheKey, [ + $payload => \array_map( + static fn (Document $document): array => $document->getArrayCopy(), + $documents, + ), + ], $cacheField); + } catch (Exception $e) { + Console::warning('Failed to save list result to cache: ' . $e->getMessage()); + } + + return $documents; + } + + $this->trigger(self::EVENT_DOCUMENT_FIND, $documents); + + return $documents; + } + + $documents = $this->find($collectionDocument->getId(), $queries, $forPermission); + + try { + $this->cache->save($cacheKey, [ + $payload => \array_map( + static fn (Document $document): array => $document->getArrayCopy(), + $documents, + ), + ], $cacheField); + } catch (Exception $e) { + Console::warning('Failed to save list result to cache: ' . $e->getMessage()); + } + + return $documents; + } + /** * @param array $payload * @return array{0: array, 1: bool} diff --git a/tests/unit/FindCacheTest.php b/tests/unit/FindCacheTest.php index cfd5e6216..d8561dfe2 100644 --- a/tests/unit/FindCacheTest.php +++ b/tests/unit/FindCacheTest.php @@ -249,6 +249,125 @@ public function testFindCachedDoesNotTouchCacheEntryWithExpiredDocuments(): void $this->assertCount(1, $documents); $this->assertSame(0, $cache->touches); } + + public function testFindListCachedReturnsStaleResultUntilListKeyIsPurged(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + $this->seedProject($database, 'first', 'First'); + + $documents = $database->getAuthorization()->skip(fn () => $database->findListCached( + 'projects', + [Query::orderAsc('name')], + ttl: 3600, + cacheCollection: 'wafrules', + namespace: '_39', + roles: ['waf'], + payload: 'rules', + )); + $this->assertCount(1, $documents); + $this->assertSame('first', $documents[0]->getId()); + + $this->seedProject($database, 'second', 'Second'); + + $documents = $database->getAuthorization()->skip(fn () => $database->findListCached( + 'projects', + [Query::orderAsc('name')], + ttl: 3600, + cacheCollection: 'wafrules', + namespace: '_39', + roles: ['waf'], + payload: 'rules', + )); + $this->assertCount(1, $documents); + $this->assertSame('first', $documents[0]->getId()); + + $cache->purge($database->getListCacheKey('wafrules', '_39')); + + $documents = $database->getAuthorization()->skip(fn () => $database->findListCached( + 'projects', + [Query::orderAsc('name')], + ttl: 3600, + cacheCollection: 'wafrules', + namespace: '_39', + roles: ['waf'], + payload: 'rules', + )); + $this->assertCount(2, $documents); + } + + public function testFindListCachedUsesListCacheKeyAndField(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + $this->seedProject($database, 'first', 'First'); + + $database->getAuthorization()->skip(fn () => $database->findListCached( + 'projects', + [Query::orderAsc('name')], + ttl: 3600, + cacheCollection: 'wafrules', + namespace: '_39', + roles: ['waf'], + payload: 'rules', + )); + + $fields = $cache->list($database->getListCacheKey('wafrules', '_39')); + + $this->assertCount(1, $fields); + $this->assertStringEndsWith(':documents', $fields[0]); + $this->assertSame(3, \substr_count($fields[0], ':')); + } + + public function testFindListCachedRefetchesExpiredCachedDocuments(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache, new TtlMemoryAdapter()); + $database->createAttribute('projects', 'expiresAt', Database::VAR_DATETIME, 0, false); + $database->createIndex('projects', 'expiresAtTtl', Database::INDEX_TTL, ['expiresAt'], ttl: 1); + + $database->createDocument('projects', new Document([ + '$id' => 'first', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'First', + 'expiresAt' => '2999-01-01T00:00:00.000+00:00', + ])); + $this->seedProject($database, 'second', 'Second'); + + $queries = [Query::orderAsc('name'), Query::limit(1)]; + $database->getAuthorization()->skip(fn () => $database->findListCached( + 'projects', + $queries, + ttl: 3600, + cacheCollection: 'wafrules', + namespace: '_39', + roles: ['waf'], + payload: 'rules', + )); + + $collection = $database->getCollection('projects'); + $cache->setCachedPayloadDocumentAttribute( + $database->getListCacheKey('wafrules', '_39'), + $database->getListCacheField($collection, $queries, ['waf']), + 'rules', + 'first', + 'expiresAt', + '2000-01-01T00:00:00.000+00:00', + ); + $database->getAuthorization()->skip(fn () => $database->updateDocument('projects', 'first', new Document(['name' => 'Zulu']))); + + $documents = $database->getAuthorization()->skip(fn () => $database->findListCached( + 'projects', + $queries, + ttl: 3600, + cacheCollection: 'wafrules', + namespace: '_39', + roles: ['waf'], + payload: 'rules', + )); + $this->assertCount(1, $documents); + $this->assertSame('second', $documents[0]->getId()); + } } class HashMemoryCache implements Adapter @@ -314,6 +433,24 @@ public function setCachedDocumentAttribute(string $key, string $hash, string $do } } + public function setCachedPayloadDocumentAttribute(string $key, string $hash, string $payload, string $documentId, string $attribute, mixed $value): void + { + $documents = $this->store[$key][$hash]['data'][$payload] ?? []; + if (!\is_array($documents)) { + return; + } + + foreach ($documents as $index => $document) { + if (!\is_array($document) || ($document['$id'] ?? '') !== $documentId) { + continue; + } + + $documents[$index][$attribute] = $value; + $this->store[$key][$hash]['data'][$payload] = $documents; + return; + } + } + /** * @return array */ From 3dcddd7343a28801f19b27ac5eb943ada8342dc0 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 18 Jun 2026 21:23:08 +0100 Subject: [PATCH 15/51] Fix list cache field hashing --- src/Database/Database.php | 20 ++++++++--- tests/unit/CacheKeyTest.php | 66 ++++++++++++++++++++++++++++++------ tests/unit/FindCacheTest.php | 27 +++++++++++++-- 3 files changed, 96 insertions(+), 17 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index e8261fa8d..feaa927ad 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8726,6 +8726,8 @@ public function findListCached( string $payload = 'documents', string $forPermission = Database::PERMISSION_READ, ): array { + $this->checkQueryTypes($queries); + if ($ttl <= 0) { return $this->find($collection, $queries, $forPermission); } @@ -9762,16 +9764,24 @@ public function getListCacheKey(string $collectionId, ?string $namespace = null, */ public function getListCacheField(?Document $collection = null, array $queries = [], array $roles = [], string $field = 'documents'): string { - $serialized = \array_map( - static fn (Query $query): array => $query->toArray(), - $queries, - ); + $this->checkQueryTypes($queries); + + $queryPayload = [ + 'version' => 1, + 'database' => $this->getDatabase(), + 'queries' => \array_map( + fn (Query $query): array => $this->serializeCachedFindQuery($query), + $queries, + ), + 'relationships' => $this->resolveRelationships, + 'filters' => $this->getActiveFilterSignatures(), + ]; return \sprintf( '%s:%s:%s:%s', $this->getCachedFindSchemaHash($collection), \md5(\json_encode($roles) ?: ''), - \md5(\json_encode($serialized) ?: ''), + \md5(\json_encode($queryPayload) ?: ''), $field, ); } diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index f9375b037..2ec1d1720 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -8,6 +8,7 @@ use Utopia\Database\Adapter; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; class CacheKeyTest extends TestCase @@ -222,18 +223,14 @@ public function testCollectionCacheFieldUsesListCacheShape(): void ]; $schemaHash = \md5( - \json_encode($collection->getAttribute('attributes', [])) - . \json_encode($collection->getAttribute('indexes', [])) + (\json_encode($collection->getAttribute('attributes', [])) ?: '') + . (\json_encode($collection->getAttribute('indexes', [])) ?: '') ); - $queryHash = \md5(\json_encode(\array_map( - static fn (Query $query): array => $query->toArray(), - $queries, - ))); + $field = $db->getListCacheField($collection, $queries, ['waf']); - $this->assertSame( - "{$schemaHash}:".\md5(\json_encode(['waf'])).":{$queryHash}:documents", - $db->getListCacheField($collection, $queries, ['waf']), - ); + $this->assertStringStartsWith("{$schemaHash}:".\md5(\json_encode(['waf']) ?: '').':', $field); + $this->assertStringEndsWith(':documents', $field); + $this->assertSame(3, \substr_count($field, ':')); } public function testCollectionCacheFieldChangesWithInputs(): void @@ -265,6 +262,55 @@ public function testCollectionCacheFieldChangesWithInputs(): void $this->assertStringEndsWith(':total', $db->getListCacheField(null, [Query::limit(10)], ['role-a'], 'total')); } + public function testCollectionCacheFieldIncludesCursorDocumentPayload(): void + { + $db = $this->createDatabase(); + + $fieldA = $db->getListCacheField(null, [ + Query::orderAsc('name'), + Query::cursorAfter(new Document([ + '$id' => 'cursor', + 'name' => 'alpha', + ])), + ]); + $fieldB = $db->getListCacheField(null, [ + Query::orderAsc('name'), + Query::cursorAfter(new Document([ + '$id' => 'cursor', + 'name' => 'beta', + ])), + ]); + + $this->assertNotSame($fieldA, $fieldB); + } + + public function testCollectionCacheFieldIncludesAmbientState(): void + { + $db = $this->createDatabase(); + + $field = $db->getListCacheField(null, [Query::limit(10)]); + + $this->assertNotSame( + $field, + $db->skipFilters(fn () => $db->getListCacheField(null, [Query::limit(10)]), ['json']), + ); + $this->assertNotSame( + $field, + $db->skipRelationships(fn () => $db->getListCacheField(null, [Query::limit(10)])), + ); + } + + public function testCollectionCacheFieldValidatesQueryTypes(): void + { + $this->expectException(QueryException::class); + + $db = $this->createDatabase(); + /** @var array $queries */ + $queries = ['invalid']; + + $db->getListCacheField(null, $queries); + } + public function testDifferentFindDatabasesProduceDifferentCacheKeys(): void { $dbPlatform = $this->createDatabase(database: 'platform'); diff --git a/tests/unit/FindCacheTest.php b/tests/unit/FindCacheTest.php index d8561dfe2..4bf2f944c 100644 --- a/tests/unit/FindCacheTest.php +++ b/tests/unit/FindCacheTest.php @@ -9,6 +9,7 @@ use Utopia\Database\Adapter\Memory as DatabaseMemory; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; @@ -368,6 +369,22 @@ public function testFindListCachedRefetchesExpiredCachedDocuments(): void $this->assertCount(1, $documents); $this->assertSame('second', $documents[0]->getId()); } + + public function testFindListCachedValidatesQueryTypesBeforeCaching(): void + { + $this->expectException(QueryException::class); + + /** @var array $queries */ + $queries = ['invalid']; + + $this->database->getAuthorization()->skip(fn () => $this->database->findListCached( + 'projects', + $queries, + ttl: 3600, + cacheCollection: 'wafrules', + namespace: '_39', + )); + } } class HashMemoryCache implements Adapter @@ -435,7 +452,12 @@ public function setCachedDocumentAttribute(string $key, string $hash, string $do public function setCachedPayloadDocumentAttribute(string $key, string $hash, string $payload, string $documentId, string $attribute, mixed $value): void { - $documents = $this->store[$key][$hash]['data'][$payload] ?? []; + $data = $this->store[$key][$hash]['data'] ?? []; + if (!\is_array($data)) { + return; + } + + $documents = $data[$payload] ?? []; if (!\is_array($documents)) { return; } @@ -446,7 +468,8 @@ public function setCachedPayloadDocumentAttribute(string $key, string $hash, str } $documents[$index][$attribute] = $value; - $this->store[$key][$hash]['data'][$payload] = $documents; + $data[$payload] = $documents; + $this->store[$key][$hash]['data'] = $data; return; } } From e4d8f12e6ec7b430ec2ca26a485a64451ee22c30 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 18 Jun 2026 22:22:19 +0100 Subject: [PATCH 16/51] Harden cached find payloads --- src/Database/Database.php | 26 +++++++++++++++--------- tests/unit/FindCacheTest.php | 39 ++++++++++++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index feaa927ad..cc2a22905 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8614,6 +8614,8 @@ public function find(string $collection, array $queries = [], string $forPermiss */ public function findCached(string $collection, array $queries = [], int $ttl = self::TTL, ?string $key = null, string $forPermission = Database::PERMISSION_READ, bool $touchOnHit = false): array { + $this->checkQueryTypes($queries); + if ($ttl <= 0) { return $this->find($collection, $queries, $forPermission); } @@ -8646,7 +8648,9 @@ public function findCached(string $collection, array $queries = [], int $ttl = s } if (\is_array($cached)) { - [$documents, $hasExpiredDocuments] = $this->decodeCachedFindPayload($collectionDocument, $cached); + $payload = $cached['documents'] ?? $cached; + $payload = \is_array($payload) ? $payload : []; + [$documents, $hasExpiredDocuments] = $this->decodeCachedFindPayload($collectionDocument, $payload); if ($hasExpiredDocuments) { try { @@ -8657,10 +8661,12 @@ public function findCached(string $collection, array $queries = [], int $ttl = s $documents = $this->find($collectionDocument->getId(), $queries, $forPermission); try { - $this->cache->save($findKey, \array_map( - static fn (Document $document): array => $document->getArrayCopy(), - $documents - ), $findField); + $this->cache->save($findKey, [ + 'documents' => \array_map( + static fn (Document $document): array => $document->getArrayCopy(), + $documents + ), + ], $findField); } catch (Exception $e) { Console::warning('Failed to save find result to cache: ' . $e->getMessage()); } @@ -8684,10 +8690,12 @@ public function findCached(string $collection, array $queries = [], int $ttl = s $documents = $this->find($collectionDocument->getId(), $queries, $forPermission); try { - $this->cache->save($findKey, \array_map( - static fn (Document $document): array => $document->getArrayCopy(), - $documents - ), $findField); + $this->cache->save($findKey, [ + 'documents' => \array_map( + static fn (Document $document): array => $document->getArrayCopy(), + $documents + ), + ], $findField); } catch (Exception $e) { Console::warning('Failed to save find result to cache: ' . $e->getMessage()); } diff --git a/tests/unit/FindCacheTest.php b/tests/unit/FindCacheTest.php index 4bf2f944c..1ecab5e7a 100644 --- a/tests/unit/FindCacheTest.php +++ b/tests/unit/FindCacheTest.php @@ -94,6 +94,17 @@ public function testFindCachedUsesDefaultTtl(): void $this->assertCount(1, $documents); } + public function testFindCachedCachesEmptyResults(): void + { + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); + $this->assertSame([], $documents); + + $this->seedProject($this->database, 'first', 'First'); + + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); + $this->assertSame([], $documents); + } + public function testFindCachedDelegatesToFindWhenAuthorizationIsEnabled(): void { $this->seedProject($this->database, 'first', 'First'); @@ -251,6 +262,20 @@ public function testFindCachedDoesNotTouchCacheEntryWithExpiredDocuments(): void $this->assertSame(0, $cache->touches); } + public function testFindCachedValidatesQueryTypesBeforeCaching(): void + { + $this->expectException(QueryException::class); + + /** @var array $queries */ + $queries = ['invalid']; + + $this->database->getAuthorization()->skip(fn () => $this->database->findCached( + 'projects', + $queries, + ttl: 3600, + )); + } + public function testFindListCachedReturnsStaleResultUntilListKeyIsPurged(): void { $cache = new HashMemoryCache(); @@ -434,7 +459,12 @@ public function touch(string $key, string $hash = ''): bool public function setCachedDocumentAttribute(string $key, string $hash, string $documentId, string $attribute, mixed $value): void { - $documents = $this->store[$key][$hash]['data'] ?? []; + $data = $this->store[$key][$hash]['data'] ?? []; + if (!\is_array($data)) { + return; + } + + $documents = $data['documents'] ?? $data; if (!\is_array($documents)) { return; } @@ -445,7 +475,12 @@ public function setCachedDocumentAttribute(string $key, string $hash, string $do } $documents[$index][$attribute] = $value; - $this->store[$key][$hash]['data'] = $documents; + if (isset($data['documents']) && \is_array($data['documents'])) { + $data['documents'] = $documents; + $this->store[$key][$hash]['data'] = $data; + } else { + $this->store[$key][$hash]['data'] = $documents; + } return; } } From a2d982b134b4aef698ee8be2e98776bfd2117ee4 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 18 Jun 2026 23:35:49 +0100 Subject: [PATCH 17/51] Simplify cached find API --- src/Database/Database.php | 213 +------- tests/e2e/Adapter/Base.php | 5 - tests/e2e/Adapter/RedisTest.php | 5 - tests/e2e/Adapter/Scopes/DocumentTests.php | 41 -- tests/unit/CacheKeyTest.php | 140 +---- tests/unit/FindCacheTest.php | 577 --------------------- tests/unit/ListCacheTest.php | 303 +++++++++++ 7 files changed, 352 insertions(+), 932 deletions(-) delete mode 100644 tests/unit/FindCacheTest.php create mode 100644 tests/unit/ListCacheTest.php diff --git a/src/Database/Database.php b/src/Database/Database.php index cc2a22905..5f9b12049 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8353,29 +8353,10 @@ public function purgeCachedCollection(string $collectionId): bool } $this->cache->purge($collectionKey); - $this->purgeCachedFinds($collectionId); return true; } - public function purgeCachedFinds(string $collectionId): bool - { - [$findKey] = $this->getCachedFindKeys($collectionId); - - return $this->cache->purge($findKey); - } - - /** - * @param array $queries - */ - public function purgeCachedFind(string $collectionId, ?string $key = null, array $queries = []): bool - { - $collection = $this->silent(fn () => $this->getCollection($collectionId)); - [$findKey, $findField] = $this->authorization->skip(fn () => $this->getCachedFindKeys($collectionId, $queries, $key, $collection)); - - return $this->cache->purge($findKey, $findField); - } - /** * Cleans a specific document from cache * And related document reference in the collection cache. @@ -8583,126 +8564,6 @@ public function find(string $collection, array $queries = [], string $forPermiss return $results; } - /** - * Find documents using an explicit TTL-backed cache. - * - * Results may be stale until the TTL expires or the caller purges the cached - * find entry. Use this only for read paths that can tolerate bounded - * staleness. - * - * Cache reads and writes are only used when authorization is disabled, for - * example inside Authorization::skip(). With active authorization this - * delegates to find() so cached results cannot outlive permission changes. - * Cached hits rebuild top-level Document instances from stored arrays and - * are intended for internal flat-document reads. - * - * When $key is provided, it replaces query serialization in the cache key. - * The caller-owned key must include every query dimension that can change - * the result. - * - * @param string $collection - * @param array $queries - * @param int $ttl Cache TTL in seconds. Values above TTL are clamped. Set to 0 to disable caching. - * @param string|null $key Optional caller-owned cache variation key - * @param string $forPermission - * @param bool $touchOnHit Refresh the cached entry timestamp on cache hit - * @return array - * @throws DatabaseException - * @throws QueryException - * @throws TimeoutException - * @throws Exception - */ - public function findCached(string $collection, array $queries = [], int $ttl = self::TTL, ?string $key = null, string $forPermission = Database::PERMISSION_READ, bool $touchOnHit = false): array - { - $this->checkQueryTypes($queries); - - if ($ttl <= 0) { - return $this->find($collection, $queries, $forPermission); - } - - if ($this->authorization->getStatus()) { - return $this->find($collection, $queries, $forPermission); - } - - foreach ($queries as $query) { - if ($query instanceof Query && $query->getMethod() === Query::TYPE_ORDER_RANDOM) { - return $this->find($collection, $queries, $forPermission); - } - } - - $ttl = \min($ttl, self::TTL); - - $collectionDocument = $this->silent(fn () => $this->getCollection($collection)); - - if ($collectionDocument->isEmpty()) { - throw new NotFoundException('Collection not found'); - } - - [$findKey, $findField] = $this->getCachedFindKeys($collectionDocument->getId(), $queries, $key, $collectionDocument); - - try { - $cached = $this->cache->load($findKey, $ttl, $findField); - } catch (Exception $e) { - Console::warning('Warning: Failed to get find result from cache: ' . $e->getMessage()); - $cached = null; - } - - if (\is_array($cached)) { - $payload = $cached['documents'] ?? $cached; - $payload = \is_array($payload) ? $payload : []; - [$documents, $hasExpiredDocuments] = $this->decodeCachedFindPayload($collectionDocument, $payload); - - if ($hasExpiredDocuments) { - try { - $this->cache->purge($findKey, $findField); - } catch (Exception $e) { - Console::warning('Warning: Failed to purge expired find result cache: ' . $e->getMessage()); - } - - $documents = $this->find($collectionDocument->getId(), $queries, $forPermission); - try { - $this->cache->save($findKey, [ - 'documents' => \array_map( - static fn (Document $document): array => $document->getArrayCopy(), - $documents - ), - ], $findField); - } catch (Exception $e) { - Console::warning('Failed to save find result to cache: ' . $e->getMessage()); - } - - return $documents; - } - - if ($touchOnHit) { - try { - $this->cache->touch($findKey, $findField); - } catch (Exception $e) { - Console::warning('Warning: Failed to touch find result cache: ' . $e->getMessage()); - } - } - - $this->trigger(self::EVENT_DOCUMENT_FIND, $documents); - - return $documents; - } - - $documents = $this->find($collectionDocument->getId(), $queries, $forPermission); - - try { - $this->cache->save($findKey, [ - 'documents' => \array_map( - static fn (Document $document): array => $document->getArrayCopy(), - $documents - ), - ], $findField); - } catch (Exception $e) { - Console::warning('Failed to save find result to cache: ' . $e->getMessage()); - } - - return $documents; - } - /** * Find documents using an Appwrite list-cache compatible key and field. * @@ -8714,7 +8575,7 @@ public function findCached(string $collection, array $queries = [], int $ttl = s * @param int|string|null $tenant * @param array $roles * @param string $field - * @param string $payload + * @param string $payloadKey * @param string $forPermission * @return array * @throws DatabaseException @@ -8722,7 +8583,7 @@ public function findCached(string $collection, array $queries = [], int $ttl = s * @throws TimeoutException * @throws Exception */ - public function findListCached( + public function findCached( string $collection, array $queries = [], int $ttl = self::TTL, @@ -8731,7 +8592,7 @@ public function findListCached( int|string|null $tenant = null, array $roles = [], string $field = 'documents', - string $payload = 'documents', + string $payloadKey = 'documents', string $forPermission = Database::PERMISSION_READ, ): array { $this->checkQueryTypes($queries); @@ -8758,8 +8619,8 @@ public function findListCached( throw new NotFoundException('Collection not found'); } - $cacheKey = $this->getListCacheKey($cacheCollection ?? $collectionDocument->getId(), $namespace, $tenant); - $cacheField = $this->getListCacheField($collectionDocument, $queries, $roles, $field); + $cacheKey = $this->getFindCacheKey($cacheCollection ?? $collectionDocument->getId(), $namespace, $tenant); + $cacheField = $this->getFindCacheField($collectionDocument, $queries, $roles, $field); try { $cached = $this->cache->load($cacheKey, $ttl, $cacheField); @@ -8768,8 +8629,8 @@ public function findListCached( $cached = null; } - if (\is_array($cached) && isset($cached[$payload]) && \is_array($cached[$payload])) { - [$documents, $hasExpiredDocuments] = $this->decodeCachedFindPayload($collectionDocument, $cached[$payload]); + if (\is_array($cached) && isset($cached[$payloadKey]) && \is_array($cached[$payloadKey])) { + [$documents, $hasExpiredDocuments] = $this->decodeFindCachePayload($collectionDocument, $cached[$payloadKey]); if ($hasExpiredDocuments) { try { @@ -8781,7 +8642,7 @@ public function findListCached( $documents = $this->find($collectionDocument->getId(), $queries, $forPermission); try { $this->cache->save($cacheKey, [ - $payload => \array_map( + $payloadKey => \array_map( static fn (Document $document): array => $document->getArrayCopy(), $documents, ), @@ -8802,7 +8663,7 @@ public function findListCached( try { $this->cache->save($cacheKey, [ - $payload => \array_map( + $payloadKey => \array_map( static fn (Document $document): array => $document->getArrayCopy(), $documents, ), @@ -8818,7 +8679,7 @@ public function findListCached( * @param array $payload * @return array{0: array, 1: bool} */ - private function decodeCachedFindPayload(Document $collection, array $payload): array + private function decodeFindCachePayload(Document $collection, array $payload): array { $results = []; $hasExpiredDocuments = false; @@ -9738,21 +9599,21 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a } /** - * Stable cache key for cached list entries on a collection. + * Stable cache key for cached find entries on a collection. * * @param string $collectionId * @param string|null $namespace * @param int|string|null $tenant * @return string */ - public function getListCacheKey(string $collectionId, ?string $namespace = null, int|string|null $tenant = null): string + public function getFindCacheKey(string $collectionId, ?string $namespace = null, int|string|null $tenant = null): string { $hostname = $this->adapter->getSupportForHostname() ? $this->adapter->getHostname() : ''; return \sprintf( - '%s-cache:%s:%s:%s:collection:%s', + '%s-cache:%s:%s:%s:collection:%s:find', $this->cacheName, $hostname, $namespace ?? $this->getNamespace(), @@ -9762,7 +9623,7 @@ public function getListCacheKey(string $collectionId, ?string $namespace = null, } /** - * Stable cache field for cached list entries on a collection. + * Stable cache field for cached find entries on a collection. * * @param Document|null $collection * @param array $queries @@ -9770,7 +9631,7 @@ public function getListCacheKey(string $collectionId, ?string $namespace = null, * @param string $field * @return string */ - public function getListCacheField(?Document $collection = null, array $queries = [], array $roles = [], string $field = 'documents'): string + public function getFindCacheField(?Document $collection = null, array $queries = [], array $roles = [], string $field = 'documents'): string { $this->checkQueryTypes($queries); @@ -9778,7 +9639,7 @@ public function getListCacheField(?Document $collection = null, array $queries = 'version' => 1, 'database' => $this->getDatabase(), 'queries' => \array_map( - fn (Query $query): array => $this->serializeCachedFindQuery($query), + fn (Query $query): array => $this->serializeFindCacheQuery($query), $queries, ), 'relationships' => $this->resolveRelationships, @@ -9787,45 +9648,17 @@ public function getListCacheField(?Document $collection = null, array $queries = return \sprintf( '%s:%s:%s:%s', - $this->getCachedFindSchemaHash($collection), + $this->getFindCacheSchemaHash($collection), \md5(\json_encode($roles) ?: ''), \md5(\json_encode($queryPayload) ?: ''), $field, ); } - /** - * @param string $collectionId - * @param array $queries - * @param string|null $key - * @param Document|null $collection - * @return array{0: string, 1: string} - */ - public function getCachedFindKeys(string $collectionId, array $queries = [], ?string $key = null, ?Document $collection = null): array - { - [$collectionKey] = $this->getCacheKeys($collectionId); - $findKey = "{$collectionKey}:find"; - - $payload = [ - 'version' => 1, - 'database' => $this->getDatabase(), - 'schema' => $this->getCachedFindSchemaHash($collection), - 'key' => $key, - 'queries' => $key === null ? \array_map( - fn (Query $query): array => $this->serializeCachedFindQuery($query), - $queries - ) : null, - 'relationships' => $this->resolveRelationships, - 'filters' => $this->getActiveFilterSignatures(), - ]; - - return [$findKey, \md5(\json_encode($payload) ?: '')]; - } - /** * @return array */ - private function serializeCachedFindQuery(Query $query): array + private function serializeFindCacheQuery(Query $query): array { $serialized = [ 'method' => $query->getMethod(), @@ -9838,11 +9671,11 @@ private function serializeCachedFindQuery(Query $query): array $values = []; foreach ($query->getValues() as $value) { if ($value instanceof Query) { - $values[] = $this->serializeCachedFindQuery($value); + $values[] = $this->serializeFindCacheQuery($value); continue; } - $values[] = $this->normalizeCachedFindQueryValue($value); + $values[] = $this->normalizeFindCacheQueryValue($value); } $serialized['values'] = $values; @@ -9850,7 +9683,7 @@ private function serializeCachedFindQuery(Query $query): array return $serialized; } - private function normalizeCachedFindQueryValue(mixed $value): mixed + private function normalizeFindCacheQueryValue(mixed $value): mixed { if ($value instanceof Document) { $value = $value->getArrayCopy(); @@ -9861,13 +9694,13 @@ private function normalizeCachedFindQueryValue(mixed $value): mixed } foreach ($value as $key => $item) { - $value[$key] = $this->normalizeCachedFindQueryValue($item); + $value[$key] = $this->normalizeFindCacheQueryValue($item); } return $value; } - private function getCachedFindSchemaHash(?Document $collection): string + private function getFindCacheSchemaHash(?Document $collection): string { if ($collection === null || $collection->isEmpty()) { return ''; diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index 105c1af62..4baeba35b 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -65,11 +65,6 @@ abstract protected function deleteColumn(string $collection, string $column): bo */ abstract protected function deleteIndex(string $collection, string $index): bool; - protected function supportsCachedFind(): bool - { - return true; - } - public function setUp(): void { if (is_null(self::$authorization)) { diff --git a/tests/e2e/Adapter/RedisTest.php b/tests/e2e/Adapter/RedisTest.php index 86ad85c63..23d779db0 100644 --- a/tests/e2e/Adapter/RedisTest.php +++ b/tests/e2e/Adapter/RedisTest.php @@ -114,11 +114,6 @@ protected function deleteIndex(string $collection, string $index): bool return true; } - protected function supportsCachedFind(): bool - { - return false; - } - /** * Inherited test exercises the case where an INTEGER column is altered * to VARCHAR. Redis stores documents as JSON; type changes do not diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 6758190da..4f998372d 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -103,47 +103,6 @@ public function testBigintSequence(): void } } - public function testFindCachedReturnsStaleResultUntilPurged(): void - { - if (!$this->supportsCachedFind()) { - $this->markTestSkipped('Adapter test disables the cache layer.'); - } - - /** @var Database $database */ - $database = $this->getDatabase(); - $collection = 'findCached'; - - $database->createCollection($collection); - $database->createAttribute($collection, 'name', Database::VAR_STRING, 255, true); - - $database->createDocument($collection, new Document([ - '$id' => 'first', - '$permissions' => [Permission::read(Role::any())], - 'name' => 'First', - ])); - - $documents = $database->getAuthorization()->skip(fn () => $database->findCached($collection, [Query::orderAsc('name')], ttl: 3600)); - $this->assertCount(1, $documents); - $this->assertSame('first', $documents[0]->getId()); - - $database->createDocument($collection, new Document([ - '$id' => 'second', - '$permissions' => [Permission::read(Role::any())], - 'name' => 'Second', - ])); - - $documents = $database->getAuthorization()->skip(fn () => $database->findCached($collection, [Query::orderAsc('name')], ttl: 3600)); - $this->assertCount(1, $documents); - $this->assertSame('first', $documents[0]->getId()); - - $database->purgeCachedFinds($collection); - - $documents = $database->getAuthorization()->skip(fn () => $database->findCached($collection, [Query::orderAsc('name')], ttl: 3600)); - $this->assertCount(2, $documents); - $this->assertSame('first', $documents[0]->getId()); - $this->assertSame('second', $documents[1]->getId()); - } - public function testCreateDocumentWithBigIntType(): void { /** @var Database $database */ diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index 2ec1d1720..832e30c7f 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -135,27 +135,7 @@ public function testFiltersDisabledEntirelyProducesDifferentCacheKey(): void $this->assertNotEquals($hashEnabled, $hashDisabled); } - public function testDifferentFindQueriesProduceDifferentCacheKeys(): void - { - $db = $this->createDatabase(); - - [, $fieldA] = $db->getCachedFindKeys('col', [Query::equal('status', ['active'])]); - [, $fieldB] = $db->getCachedFindKeys('col', [Query::equal('status', ['paused'])]); - - $this->assertNotEquals($fieldA, $fieldB); - } - - public function testCallerFindCacheKeyIgnoresQueryVariation(): void - { - $db = $this->createDatabase(); - - [, $fieldA] = $db->getCachedFindKeys('col', [Query::equal('status', ['active'])], 'domain-key'); - [, $fieldB] = $db->getCachedFindKeys('col', [Query::equal('status', ['paused'])], 'domain-key'); - - $this->assertEquals($fieldA, $fieldB); - } - - public function testCollectionCacheKeyUsesListCacheShape(): void + public function testFindCacheKeyUsesListCacheShapeWithFindSuffix(): void { $adapter = $this->createMock(Adapter::class); $adapter->method('getSupportForHostname')->willReturn(true); @@ -166,12 +146,12 @@ public function testCollectionCacheKeyUsesListCacheShape(): void $db = new Database($adapter, new Cache(new None()), []); $this->assertSame( - 'default-cache:mysql-console:_39::collection:ttl_cache_table', - $db->getListCacheKey('ttl_cache_table'), + 'default-cache:mysql-console:_39::collection:ttl_cache_table:find', + $db->getFindCacheKey('ttl_cache_table'), ); } - public function testCollectionCacheKeyCanOverrideNamespaceSegment(): void + public function testFindCacheKeyCanOverrideNamespaceSegment(): void { $adapter = $this->createMock(Adapter::class); $adapter->method('getSupportForHostname')->willReturn(true); @@ -182,12 +162,12 @@ public function testCollectionCacheKeyCanOverrideNamespaceSegment(): void $db = new Database($adapter, new Cache(new None()), []); $this->assertSame( - 'default-cache:mysql-console:_39::collection:wafrules', - $db->getListCacheKey('wafrules', '_39'), + 'default-cache:mysql-console:_39::collection:wafrules:find', + $db->getFindCacheKey('wafrules', '_39'), ); } - public function testCollectionCacheKeyCanOverrideTenantSegment(): void + public function testFindCacheKeyCanOverrideTenantSegment(): void { $adapter = $this->createMock(Adapter::class); $adapter->method('getSupportForHostname')->willReturn(true); @@ -198,12 +178,12 @@ public function testCollectionCacheKeyCanOverrideTenantSegment(): void $db = new Database($adapter, new Cache(new None()), []); $this->assertSame( - 'default-cache:mysql-console:_39:tenant-a:collection:wafrules', - $db->getListCacheKey('wafrules', tenant: 'tenant-a'), + 'default-cache:mysql-console:_39:tenant-a:collection:wafrules:find', + $db->getFindCacheKey('wafrules', tenant: 'tenant-a'), ); } - public function testCollectionCacheFieldUsesListCacheShape(): void + public function testFindCacheFieldUsesListCacheShape(): void { $db = $this->createDatabase(); $collection = new Document([ @@ -226,18 +206,18 @@ public function testCollectionCacheFieldUsesListCacheShape(): void (\json_encode($collection->getAttribute('attributes', [])) ?: '') . (\json_encode($collection->getAttribute('indexes', [])) ?: '') ); - $field = $db->getListCacheField($collection, $queries, ['waf']); + $field = $db->getFindCacheField($collection, $queries, ['waf']); $this->assertStringStartsWith("{$schemaHash}:".\md5(\json_encode(['waf']) ?: '').':', $field); $this->assertStringEndsWith(':documents', $field); $this->assertSame(3, \substr_count($field, ':')); } - public function testCollectionCacheFieldChangesWithInputs(): void + public function testFindCacheFieldChangesWithInputs(): void { $db = $this->createDatabase(); - $field = $db->getListCacheField( + $field = $db->getFindCacheField( new Document([ 'attributes' => [new Document(['$id' => 'name', 'type' => Database::VAR_STRING])], 'indexes' => [], @@ -248,7 +228,7 @@ public function testCollectionCacheFieldChangesWithInputs(): void $this->assertNotSame( $field, - $db->getListCacheField( + $db->getFindCacheField( new Document([ 'attributes' => [new Document(['$id' => 'status', 'type' => Database::VAR_STRING])], 'indexes' => [], @@ -257,23 +237,23 @@ public function testCollectionCacheFieldChangesWithInputs(): void ['role-a'], ), ); - $this->assertNotSame($field, $db->getListCacheField(null, [Query::limit(20)], ['role-a'])); - $this->assertNotSame($field, $db->getListCacheField(null, [Query::limit(10)], ['role-b'])); - $this->assertStringEndsWith(':total', $db->getListCacheField(null, [Query::limit(10)], ['role-a'], 'total')); + $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(20)], ['role-a'])); + $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(10)], ['role-b'])); + $this->assertStringEndsWith(':total', $db->getFindCacheField(null, [Query::limit(10)], ['role-a'], 'total')); } - public function testCollectionCacheFieldIncludesCursorDocumentPayload(): void + public function testFindCacheFieldIncludesCursorDocumentPayload(): void { $db = $this->createDatabase(); - $fieldA = $db->getListCacheField(null, [ + $fieldA = $db->getFindCacheField(null, [ Query::orderAsc('name'), Query::cursorAfter(new Document([ '$id' => 'cursor', 'name' => 'alpha', ])), ]); - $fieldB = $db->getListCacheField(null, [ + $fieldB = $db->getFindCacheField(null, [ Query::orderAsc('name'), Query::cursorAfter(new Document([ '$id' => 'cursor', @@ -284,23 +264,23 @@ public function testCollectionCacheFieldIncludesCursorDocumentPayload(): void $this->assertNotSame($fieldA, $fieldB); } - public function testCollectionCacheFieldIncludesAmbientState(): void + public function testFindCacheFieldIncludesAmbientState(): void { $db = $this->createDatabase(); - $field = $db->getListCacheField(null, [Query::limit(10)]); + $field = $db->getFindCacheField(null, [Query::limit(10)]); $this->assertNotSame( $field, - $db->skipFilters(fn () => $db->getListCacheField(null, [Query::limit(10)]), ['json']), + $db->skipFilters(fn () => $db->getFindCacheField(null, [Query::limit(10)]), ['json']), ); $this->assertNotSame( $field, - $db->skipRelationships(fn () => $db->getListCacheField(null, [Query::limit(10)])), + $db->skipRelationships(fn () => $db->getFindCacheField(null, [Query::limit(10)])), ); } - public function testCollectionCacheFieldValidatesQueryTypes(): void + public function testFindCacheFieldValidatesQueryTypes(): void { $this->expectException(QueryException::class); @@ -308,77 +288,9 @@ public function testCollectionCacheFieldValidatesQueryTypes(): void /** @var array $queries */ $queries = ['invalid']; - $db->getListCacheField(null, $queries); + $db->getFindCacheField(null, $queries); } - public function testDifferentFindDatabasesProduceDifferentCacheKeys(): void - { - $dbPlatform = $this->createDatabase(database: 'platform'); - $dbProject = $this->createDatabase(database: 'project'); - - [, $fieldA] = $dbPlatform->getCachedFindKeys('col', [Query::limit(10)]); - [, $fieldB] = $dbProject->getCachedFindKeys('col', [Query::limit(10)]); - - $this->assertNotEquals($fieldA, $fieldB); - } - - public function testDifferentFindSchemasProduceDifferentCacheKeys(): void - { - $db = $this->createDatabase(); - - $collectionA = new Document([ - '$id' => 'col', - 'attributes' => [ - new Document(['$id' => 'name', 'type' => Database::VAR_STRING]), - ], - 'indexes' => [], - ]); - $collectionB = new Document([ - '$id' => 'col', - 'attributes' => [ - new Document(['$id' => 'name', 'type' => Database::VAR_STRING]), - new Document(['$id' => 'status', 'type' => Database::VAR_STRING]), - ], - 'indexes' => [], - ]); - - [, $fieldA] = $db->getCachedFindKeys('col', [Query::limit(10)], collection: $collectionA); - [, $fieldB] = $db->getCachedFindKeys('col', [Query::limit(10)], collection: $collectionB); - - $this->assertNotEquals($fieldA, $fieldB); - } - - public function testFindRelationshipModeProducesDifferentCacheKeys(): void - { - $db = $this->createDatabase(); - - [, $fieldA] = $db->getCachedFindKeys('col', [Query::limit(10)]); - [, $fieldB] = $db->skipRelationships(fn () => $db->getCachedFindKeys('col', [Query::limit(10)])); - - $this->assertNotEquals($fieldA, $fieldB); - } - - public function testFindCursorDocumentValuesProduceDifferentCacheKeys(): void - { - $db = $this->createDatabase(); - - [, $fieldA] = $db->getCachedFindKeys('col', [ - Query::orderAsc('name'), - Query::cursorAfter(new Document([ - '$id' => 'cursor', - 'name' => 'alpha', - ])), - ]); - [, $fieldB] = $db->getCachedFindKeys('col', [ - Query::orderAsc('name'), - Query::cursorAfter(new Document([ - '$id' => 'cursor', - 'name' => 'beta', - ])), - ]); - - $this->assertNotEquals($fieldA, $fieldB); - } public function testParseHostname(): void { diff --git a/tests/unit/FindCacheTest.php b/tests/unit/FindCacheTest.php deleted file mode 100644 index 1ecab5e7a..000000000 --- a/tests/unit/FindCacheTest.php +++ /dev/null @@ -1,577 +0,0 @@ -database = $this->createDatabase(new CacheMemory()); - } - - private function createDatabase(Adapter $cache, ?DatabaseMemory $adapter = null): Database - { - $database = new Database($adapter ?? new DatabaseMemory(), new Cache($cache)); - $database - ->setDatabase('utopiaTests') - ->setNamespace('find_cache_' . \uniqid()); - - $database->create(); - $database->createCollection('projects'); - $database->createAttribute('projects', 'name', Database::VAR_STRING, 255, true); - - return $database; - } - - private function seedProject(Database $database, string $id, string $name): void - { - $database->createDocument('projects', new Document([ - '$id' => $id, - '$permissions' => [Permission::read(Role::any())], - 'name' => $name, - ])); - } - - public function testFindCachedReturnsStaleResultUntilPurged(): void - { - $this->seedProject($this->database, 'first', 'First'); - - $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); - $this->assertCount(1, $documents); - $this->assertSame('first', $documents[0]->getId()); - - $this->seedProject($this->database, 'second', 'Second'); - - $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); - $this->assertCount(1, $documents); - $this->assertSame('first', $documents[0]->getId()); - - $this->database->purgeCachedFinds('projects'); - - $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); - $this->assertCount(2, $documents); - $this->assertSame('first', $documents[0]->getId()); - $this->assertSame('second', $documents[1]->getId()); - } - - public function testFindCachedBypassesCacheWhenTtlIsZero(): void - { - $this->seedProject($this->database, 'first', 'First'); - - $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); - $this->assertCount(1, $documents); - - $this->seedProject($this->database, 'second', 'Second'); - - $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 0)); - $this->assertCount(2, $documents); - } - - public function testFindCachedUsesDefaultTtl(): void - { - $this->seedProject($this->database, 'first', 'First'); - - $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')])); - $this->assertCount(1, $documents); - - $this->seedProject($this->database, 'second', 'Second'); - - $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')])); - $this->assertCount(1, $documents); - } - - public function testFindCachedCachesEmptyResults(): void - { - $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); - $this->assertSame([], $documents); - - $this->seedProject($this->database, 'first', 'First'); - - $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); - $this->assertSame([], $documents); - } - - public function testFindCachedDelegatesToFindWhenAuthorizationIsEnabled(): void - { - $this->seedProject($this->database, 'first', 'First'); - - $documents = $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); - $this->assertCount(1, $documents); - - $this->seedProject($this->database, 'second', 'Second'); - - $documents = $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600); - $this->assertCount(2, $documents); - } - - public function testFindCachedBypassesCacheForRandomOrder(): void - { - $this->seedProject($this->database, 'first', 'First'); - - $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderRandom()], ttl: 3600)); - $this->assertCount(1, $documents); - - $this->seedProject($this->database, 'second', 'Second'); - - $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderRandom()], ttl: 3600)); - $this->assertCount(2, $documents); - } - - public function testFindCachedTriggersFindEventOnCacheHit(): void - { - $events = []; - $this->database->on(Database::EVENT_DOCUMENT_FIND, 'test', function (string $event) use (&$events): void { - $events[] = $event; - }); - - $this->seedProject($this->database, 'first', 'First'); - - $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); - $this->seedProject($this->database, 'second', 'Second'); - - $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); - $this->assertCount(1, $documents); - $this->assertSame('first', $documents[0]->getId()); - - $this->assertSame([ - Database::EVENT_DOCUMENT_FIND, - Database::EVENT_DOCUMENT_FIND, - ], $events); - } - - public function testPurgeCachedFindRemovesOnlyOneCallerKey(): void - { - $database = $this->createDatabase(new HashMemoryCache()); - - $this->seedProject($database, 'first', 'First'); - - $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'a')); - $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'b')); - - $this->seedProject($database, 'second', 'Second'); - - $database->purgeCachedFind('projects', key: 'a'); - - $documents = $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'a')); - $this->assertCount(2, $documents); - - $documents = $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, key: 'b')); - $this->assertCount(1, $documents); - } - - public function testFindCachedTouchesCacheEntryOnHitWhenEnabled(): void - { - $cache = new TouchSpyCache(); - $database = $this->createDatabase($cache); - - $this->seedProject($database, 'first', 'First'); - - $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, touchOnHit: true)); - $this->assertSame(0, $cache->touches); - - $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, touchOnHit: true)); - $this->assertSame(1, $cache->touches); - } - - public function testFindCachedDoesNotTouchCacheEntryByDefault(): void - { - $cache = new TouchSpyCache(); - $database = $this->createDatabase($cache); - - $this->seedProject($database, 'first', 'First'); - - $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); - $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); - - $this->assertSame(0, $cache->touches); - } - - public function testFindCachedRefetchesExpiredCachedDocuments(): void - { - $cache = new HashMemoryCache(); - $database = $this->createDatabase($cache, new TtlMemoryAdapter()); - $database->createAttribute('projects', 'expiresAt', Database::VAR_DATETIME, 0, false); - $database->createIndex('projects', 'expiresAtTtl', Database::INDEX_TTL, ['expiresAt'], ttl: 1); - - $database->createDocument('projects', new Document([ - '$id' => 'first', - '$permissions' => [Permission::read(Role::any())], - 'name' => 'First', - 'expiresAt' => '2999-01-01T00:00:00.000+00:00', - ])); - $this->seedProject($database, 'second', 'Second'); - - $documents = $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name'), Query::limit(1)], ttl: 3600)); - $this->assertCount(1, $documents); - $this->assertSame('first', $documents[0]->getId()); - - [$findKey, $findField] = $database->getAuthorization()->skip(fn () => $database->getCachedFindKeys( - 'projects', - [Query::orderAsc('name'), Query::limit(1)], - collection: $database->getCollection('projects') - )); - $cache->setCachedDocumentAttribute($findKey, $findField, 'first', 'expiresAt', '2000-01-01T00:00:00.000+00:00'); - $database->getAuthorization()->skip(fn () => $database->updateDocument('projects', 'first', new Document(['name' => 'Zulu']))); - - $documents = $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name'), Query::limit(1)], ttl: 3600)); - $this->assertCount(1, $documents); - $this->assertSame('second', $documents[0]->getId()); - } - - public function testFindCachedDoesNotTouchCacheEntryWithExpiredDocuments(): void - { - $cache = new TouchSpyCache(); - $database = $this->createDatabase($cache, new TtlMemoryAdapter()); - $database->createAttribute('projects', 'expiresAt', Database::VAR_DATETIME, 0, false); - $database->createIndex('projects', 'expiresAtTtl', Database::INDEX_TTL, ['expiresAt'], ttl: 1); - - $database->createDocument('projects', new Document([ - '$id' => 'first', - '$permissions' => [Permission::read(Role::any())], - 'name' => 'First', - 'expiresAt' => '2999-01-01T00:00:00.000+00:00', - ])); - - $documents = $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, touchOnHit: true)); - $this->assertCount(1, $documents); - $this->assertSame(0, $cache->touches); - - [$findKey, $findField] = $database->getAuthorization()->skip(fn () => $database->getCachedFindKeys( - 'projects', - [Query::orderAsc('name')], - collection: $database->getCollection('projects') - )); - $cache->setCachedDocumentAttribute($findKey, $findField, 'first', 'expiresAt', '2000-01-01T00:00:00.000+00:00'); - - $documents = $database->getAuthorization()->skip(fn () => $database->findCached('projects', [Query::orderAsc('name')], ttl: 3600, touchOnHit: true)); - $this->assertCount(1, $documents); - $this->assertSame(0, $cache->touches); - } - - public function testFindCachedValidatesQueryTypesBeforeCaching(): void - { - $this->expectException(QueryException::class); - - /** @var array $queries */ - $queries = ['invalid']; - - $this->database->getAuthorization()->skip(fn () => $this->database->findCached( - 'projects', - $queries, - ttl: 3600, - )); - } - - public function testFindListCachedReturnsStaleResultUntilListKeyIsPurged(): void - { - $cache = new HashMemoryCache(); - $database = $this->createDatabase($cache); - $this->seedProject($database, 'first', 'First'); - - $documents = $database->getAuthorization()->skip(fn () => $database->findListCached( - 'projects', - [Query::orderAsc('name')], - ttl: 3600, - cacheCollection: 'wafrules', - namespace: '_39', - roles: ['waf'], - payload: 'rules', - )); - $this->assertCount(1, $documents); - $this->assertSame('first', $documents[0]->getId()); - - $this->seedProject($database, 'second', 'Second'); - - $documents = $database->getAuthorization()->skip(fn () => $database->findListCached( - 'projects', - [Query::orderAsc('name')], - ttl: 3600, - cacheCollection: 'wafrules', - namespace: '_39', - roles: ['waf'], - payload: 'rules', - )); - $this->assertCount(1, $documents); - $this->assertSame('first', $documents[0]->getId()); - - $cache->purge($database->getListCacheKey('wafrules', '_39')); - - $documents = $database->getAuthorization()->skip(fn () => $database->findListCached( - 'projects', - [Query::orderAsc('name')], - ttl: 3600, - cacheCollection: 'wafrules', - namespace: '_39', - roles: ['waf'], - payload: 'rules', - )); - $this->assertCount(2, $documents); - } - - public function testFindListCachedUsesListCacheKeyAndField(): void - { - $cache = new HashMemoryCache(); - $database = $this->createDatabase($cache); - $this->seedProject($database, 'first', 'First'); - - $database->getAuthorization()->skip(fn () => $database->findListCached( - 'projects', - [Query::orderAsc('name')], - ttl: 3600, - cacheCollection: 'wafrules', - namespace: '_39', - roles: ['waf'], - payload: 'rules', - )); - - $fields = $cache->list($database->getListCacheKey('wafrules', '_39')); - - $this->assertCount(1, $fields); - $this->assertStringEndsWith(':documents', $fields[0]); - $this->assertSame(3, \substr_count($fields[0], ':')); - } - - public function testFindListCachedRefetchesExpiredCachedDocuments(): void - { - $cache = new HashMemoryCache(); - $database = $this->createDatabase($cache, new TtlMemoryAdapter()); - $database->createAttribute('projects', 'expiresAt', Database::VAR_DATETIME, 0, false); - $database->createIndex('projects', 'expiresAtTtl', Database::INDEX_TTL, ['expiresAt'], ttl: 1); - - $database->createDocument('projects', new Document([ - '$id' => 'first', - '$permissions' => [Permission::read(Role::any())], - 'name' => 'First', - 'expiresAt' => '2999-01-01T00:00:00.000+00:00', - ])); - $this->seedProject($database, 'second', 'Second'); - - $queries = [Query::orderAsc('name'), Query::limit(1)]; - $database->getAuthorization()->skip(fn () => $database->findListCached( - 'projects', - $queries, - ttl: 3600, - cacheCollection: 'wafrules', - namespace: '_39', - roles: ['waf'], - payload: 'rules', - )); - - $collection = $database->getCollection('projects'); - $cache->setCachedPayloadDocumentAttribute( - $database->getListCacheKey('wafrules', '_39'), - $database->getListCacheField($collection, $queries, ['waf']), - 'rules', - 'first', - 'expiresAt', - '2000-01-01T00:00:00.000+00:00', - ); - $database->getAuthorization()->skip(fn () => $database->updateDocument('projects', 'first', new Document(['name' => 'Zulu']))); - - $documents = $database->getAuthorization()->skip(fn () => $database->findListCached( - 'projects', - $queries, - ttl: 3600, - cacheCollection: 'wafrules', - namespace: '_39', - roles: ['waf'], - payload: 'rules', - )); - $this->assertCount(1, $documents); - $this->assertSame('second', $documents[0]->getId()); - } - - public function testFindListCachedValidatesQueryTypesBeforeCaching(): void - { - $this->expectException(QueryException::class); - - /** @var array $queries */ - $queries = ['invalid']; - - $this->database->getAuthorization()->skip(fn () => $this->database->findListCached( - 'projects', - $queries, - ttl: 3600, - cacheCollection: 'wafrules', - namespace: '_39', - )); - } -} - -class HashMemoryCache implements Adapter -{ - /** - * @var array|string}>> - */ - private array $store = []; - - public function load(string $key, int $ttl, string $hash = ''): mixed - { - $hash = $hash === '' ? $key : $hash; - $saved = $this->store[$key][$hash] ?? null; - if ($saved === null) { - return false; - } - - return ($saved['time'] + $ttl > \time()) ? $saved['data'] : false; - } - - public function save(string $key, array|string $data, string $hash = ''): bool|string|array - { - if ($key === '' || empty($data)) { - return false; - } - - $hash = $hash === '' ? $key : $hash; - $this->store[$key][$hash] = [ - 'time' => \time(), - 'data' => $data, - ]; - - return $data; - } - - public function touch(string $key, string $hash = ''): bool - { - $hash = $hash === '' ? $key : $hash; - if (!isset($this->store[$key][$hash])) { - return false; - } - - $this->store[$key][$hash]['time'] = \time(); - - return true; - } - - public function setCachedDocumentAttribute(string $key, string $hash, string $documentId, string $attribute, mixed $value): void - { - $data = $this->store[$key][$hash]['data'] ?? []; - if (!\is_array($data)) { - return; - } - - $documents = $data['documents'] ?? $data; - if (!\is_array($documents)) { - return; - } - - foreach ($documents as $index => $document) { - if (!\is_array($document) || ($document['$id'] ?? '') !== $documentId) { - continue; - } - - $documents[$index][$attribute] = $value; - if (isset($data['documents']) && \is_array($data['documents'])) { - $data['documents'] = $documents; - $this->store[$key][$hash]['data'] = $data; - } else { - $this->store[$key][$hash]['data'] = $documents; - } - return; - } - } - - public function setCachedPayloadDocumentAttribute(string $key, string $hash, string $payload, string $documentId, string $attribute, mixed $value): void - { - $data = $this->store[$key][$hash]['data'] ?? []; - if (!\is_array($data)) { - return; - } - - $documents = $data[$payload] ?? []; - if (!\is_array($documents)) { - return; - } - - foreach ($documents as $index => $document) { - if (!\is_array($document) || ($document['$id'] ?? '') !== $documentId) { - continue; - } - - $documents[$index][$attribute] = $value; - $data[$payload] = $documents; - $this->store[$key][$hash]['data'] = $data; - return; - } - } - - /** - * @return array - */ - public function list(string $key): array - { - return \array_keys($this->store[$key] ?? []); - } - - public function purge(string $key, string $hash = ''): bool - { - if ($hash !== '') { - unset($this->store[$key][$hash]); - return true; - } - - unset($this->store[$key]); - - return true; - } - - public function flush(): bool - { - $this->store = []; - - return true; - } - - public function ping(): bool - { - return true; - } - - public function getSize(): int - { - return \count($this->store); - } - - public function getName(?string $key = null): string - { - return 'hash-memory'; - } -} - -class TouchSpyCache extends HashMemoryCache -{ - public int $touches = 0; - - public function touch(string $key, string $hash = ''): bool - { - $touched = parent::touch($key, $hash); - - if ($touched) { - $this->touches++; - } - - return $touched; - } -} - -class TtlMemoryAdapter extends DatabaseMemory -{ - public function getSupportForTTLIndexes(): bool - { - return true; - } -} diff --git a/tests/unit/ListCacheTest.php b/tests/unit/ListCacheTest.php new file mode 100644 index 000000000..a6ff11404 --- /dev/null +++ b/tests/unit/ListCacheTest.php @@ -0,0 +1,303 @@ +database = $this->createDatabase(new CacheMemory()); + } + + private function createDatabase(Adapter $cache, ?DatabaseMemory $adapter = null): Database + { + $database = new Database($adapter ?? new DatabaseMemory(), new Cache($cache)); + $database + ->setDatabase('utopiaTests') + ->setNamespace('list_cache_' . \uniqid()); + + $database->create(); + $database->createCollection('projects'); + $database->createAttribute('projects', 'name', Database::VAR_STRING, 255, true); + + return $database; + } + + private function seedProject(Database $database, string $id, string $name): void + { + $database->createDocument('projects', new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + 'name' => $name, + ])); + } + + public function testFindCachedReturnsStaleResultUntilListKeyIsPurged(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + $this->seedProject($database, 'first', 'First'); + + $documents = $database->getAuthorization()->skip(fn () => $database->findCached( + 'projects', + [Query::orderAsc('name')], + ttl: 3600, + cacheCollection: 'wafrules', + namespace: '_39', + roles: ['waf'], + payloadKey: 'rules', + )); + $this->assertCount(1, $documents); + $this->assertSame('first', $documents[0]->getId()); + + $this->seedProject($database, 'second', 'Second'); + + $documents = $database->getAuthorization()->skip(fn () => $database->findCached( + 'projects', + [Query::orderAsc('name')], + ttl: 3600, + cacheCollection: 'wafrules', + namespace: '_39', + roles: ['waf'], + payloadKey: 'rules', + )); + $this->assertCount(1, $documents); + $this->assertSame('first', $documents[0]->getId()); + + $cache->purge($database->getFindCacheKey('wafrules', '_39')); + + $documents = $database->getAuthorization()->skip(fn () => $database->findCached( + 'projects', + [Query::orderAsc('name')], + ttl: 3600, + cacheCollection: 'wafrules', + namespace: '_39', + roles: ['waf'], + payloadKey: 'rules', + )); + $this->assertCount(2, $documents); + } + + public function testFindCachedUsesListCacheKeyAndField(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + $this->seedProject($database, 'first', 'First'); + + $database->getAuthorization()->skip(fn () => $database->findCached( + 'projects', + [Query::orderAsc('name')], + ttl: 3600, + cacheCollection: 'wafrules', + namespace: '_39', + roles: ['waf'], + payloadKey: 'rules', + )); + + $fields = $cache->list($database->getFindCacheKey('wafrules', '_39')); + + $this->assertCount(1, $fields); + $this->assertStringEndsWith(':documents', $fields[0]); + $this->assertSame(3, \substr_count($fields[0], ':')); + } + + public function testFindCachedRefetchesExpiredCachedDocuments(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache, new TtlMemoryAdapter()); + $database->createAttribute('projects', 'expiresAt', Database::VAR_DATETIME, 0, false); + $database->createIndex('projects', 'expiresAtTtl', Database::INDEX_TTL, ['expiresAt'], ttl: 1); + + $database->createDocument('projects', new Document([ + '$id' => 'first', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'First', + 'expiresAt' => '2999-01-01T00:00:00.000+00:00', + ])); + $this->seedProject($database, 'second', 'Second'); + + $queries = [Query::orderAsc('name'), Query::limit(1)]; + $database->getAuthorization()->skip(fn () => $database->findCached( + 'projects', + $queries, + ttl: 3600, + cacheCollection: 'wafrules', + namespace: '_39', + roles: ['waf'], + payloadKey: 'rules', + )); + + $collection = $database->getCollection('projects'); + $cache->setCachedPayloadDocumentAttribute( + $database->getFindCacheKey('wafrules', '_39'), + $database->getFindCacheField($collection, $queries, ['waf']), + 'rules', + 'first', + 'expiresAt', + '2000-01-01T00:00:00.000+00:00', + ); + $database->getAuthorization()->skip(fn () => $database->updateDocument('projects', 'first', new Document(['name' => 'Zulu']))); + + $documents = $database->getAuthorization()->skip(fn () => $database->findCached( + 'projects', + $queries, + ttl: 3600, + cacheCollection: 'wafrules', + namespace: '_39', + roles: ['waf'], + payloadKey: 'rules', + )); + $this->assertCount(1, $documents); + $this->assertSame('second', $documents[0]->getId()); + } + + public function testFindCachedValidatesQueryTypesBeforeCaching(): void + { + $this->expectException(QueryException::class); + + /** @var array $queries */ + $queries = ['invalid']; + + $this->database->getAuthorization()->skip(fn () => $this->database->findCached( + 'projects', + $queries, + ttl: 3600, + cacheCollection: 'wafrules', + namespace: '_39', + )); + } +} + +class HashMemoryCache implements Adapter +{ + /** + * @var array|string}>> + */ + private array $store = []; + + public function load(string $key, int $ttl, string $hash = ''): mixed + { + $hash = $hash === '' ? $key : $hash; + $saved = $this->store[$key][$hash] ?? null; + if ($saved === null) { + return false; + } + + return ($saved['time'] + $ttl > \time()) ? $saved['data'] : false; + } + + public function save(string $key, array|string $data, string $hash = ''): bool|string|array + { + if ($key === '' || empty($data)) { + return false; + } + + $hash = $hash === '' ? $key : $hash; + $this->store[$key][$hash] = [ + 'time' => \time(), + 'data' => $data, + ]; + + return $data; + } + + public function touch(string $key, string $hash = ''): bool + { + $hash = $hash === '' ? $key : $hash; + if (!isset($this->store[$key][$hash])) { + return false; + } + + $this->store[$key][$hash]['time'] = \time(); + + return true; + } + + public function setCachedPayloadDocumentAttribute(string $key, string $hash, string $payload, string $documentId, string $attribute, mixed $value): void + { + $data = $this->store[$key][$hash]['data'] ?? []; + if (!\is_array($data)) { + return; + } + + $documents = $data[$payload] ?? []; + if (!\is_array($documents)) { + return; + } + + foreach ($documents as $index => $document) { + if (!\is_array($document) || ($document['$id'] ?? '') !== $documentId) { + continue; + } + + $documents[$index][$attribute] = $value; + $data[$payload] = $documents; + $this->store[$key][$hash]['data'] = $data; + return; + } + } + + /** + * @return array + */ + public function list(string $key): array + { + return \array_keys($this->store[$key] ?? []); + } + + public function purge(string $key, string $hash = ''): bool + { + if ($hash !== '') { + unset($this->store[$key][$hash]); + return true; + } + + unset($this->store[$key]); + + return true; + } + + public function flush(): bool + { + $this->store = []; + + return true; + } + + public function ping(): bool + { + return true; + } + + public function getSize(): int + { + return \count($this->store); + } + + public function getName(?string $key = null): string + { + return 'hash-memory'; + } +} + +class TtlMemoryAdapter extends DatabaseMemory +{ + public function getSupportForTTLIndexes(): bool + { + return true; + } +} From 2eb827169aff1ec4417a29e1157fa6e5eafa7c88 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 18 Jun 2026 23:51:53 +0100 Subject: [PATCH 18/51] Include payload key in find cache field --- src/Database/Database.php | 8 +++++--- tests/unit/CacheKeyTest.php | 7 ++++--- tests/unit/ListCacheTest.php | 6 +++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 5f9b12049..64b3cd42e 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8620,7 +8620,7 @@ public function findCached( } $cacheKey = $this->getFindCacheKey($cacheCollection ?? $collectionDocument->getId(), $namespace, $tenant); - $cacheField = $this->getFindCacheField($collectionDocument, $queries, $roles, $field); + $cacheField = $this->getFindCacheField($collectionDocument, $queries, $roles, $field, $payloadKey); try { $cached = $this->cache->load($cacheKey, $ttl, $cacheField); @@ -9629,9 +9629,10 @@ public function getFindCacheKey(string $collectionId, ?string $namespace = null, * @param array $queries * @param array $roles * @param string $field + * @param string $payloadKey * @return string */ - public function getFindCacheField(?Document $collection = null, array $queries = [], array $roles = [], string $field = 'documents'): string + public function getFindCacheField(?Document $collection = null, array $queries = [], array $roles = [], string $field = 'documents', string $payloadKey = 'documents'): string { $this->checkQueryTypes($queries); @@ -9647,11 +9648,12 @@ public function getFindCacheField(?Document $collection = null, array $queries = ]; return \sprintf( - '%s:%s:%s:%s', + '%s:%s:%s:%s:%s', $this->getFindCacheSchemaHash($collection), \md5(\json_encode($roles) ?: ''), \md5(\json_encode($queryPayload) ?: ''), $field, + $payloadKey, ); } diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index 832e30c7f..118f72f5f 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -209,8 +209,8 @@ public function testFindCacheFieldUsesListCacheShape(): void $field = $db->getFindCacheField($collection, $queries, ['waf']); $this->assertStringStartsWith("{$schemaHash}:".\md5(\json_encode(['waf']) ?: '').':', $field); - $this->assertStringEndsWith(':documents', $field); - $this->assertSame(3, \substr_count($field, ':')); + $this->assertStringEndsWith(':documents:documents', $field); + $this->assertSame(4, \substr_count($field, ':')); } public function testFindCacheFieldChangesWithInputs(): void @@ -239,7 +239,8 @@ public function testFindCacheFieldChangesWithInputs(): void ); $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(20)], ['role-a'])); $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(10)], ['role-b'])); - $this->assertStringEndsWith(':total', $db->getFindCacheField(null, [Query::limit(10)], ['role-a'], 'total')); + $this->assertStringEndsWith(':total:documents', $db->getFindCacheField(null, [Query::limit(10)], ['role-a'], 'total')); + $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(10)], ['role-a'], 'documents', 'items')); } public function testFindCacheFieldIncludesCursorDocumentPayload(): void diff --git a/tests/unit/ListCacheTest.php b/tests/unit/ListCacheTest.php index a6ff11404..e8fd940be 100644 --- a/tests/unit/ListCacheTest.php +++ b/tests/unit/ListCacheTest.php @@ -111,8 +111,8 @@ public function testFindCachedUsesListCacheKeyAndField(): void $fields = $cache->list($database->getFindCacheKey('wafrules', '_39')); $this->assertCount(1, $fields); - $this->assertStringEndsWith(':documents', $fields[0]); - $this->assertSame(3, \substr_count($fields[0], ':')); + $this->assertStringEndsWith(':documents:rules', $fields[0]); + $this->assertSame(4, \substr_count($fields[0], ':')); } public function testFindCachedRefetchesExpiredCachedDocuments(): void @@ -144,7 +144,7 @@ public function testFindCachedRefetchesExpiredCachedDocuments(): void $collection = $database->getCollection('projects'); $cache->setCachedPayloadDocumentAttribute( $database->getFindCacheKey('wafrules', '_39'), - $database->getFindCacheField($collection, $queries, ['waf']), + $database->getFindCacheField($collection, $queries, ['waf'], 'documents', 'rules'), 'rules', 'first', 'expiresAt', From 20f0a56764e1267d255c45c317f01f98aba8bf9a Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Fri, 19 Jun 2026 00:09:58 +0100 Subject: [PATCH 19/51] Harden find cache validation --- src/Database/Database.php | 28 +++++++++++--- tests/unit/CacheKeyTest.php | 2 +- tests/unit/ListCacheTest.php | 71 +++++++++++++++++++++++++++++++++++- 3 files changed, 94 insertions(+), 7 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 64b3cd42e..1fe59e723 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8619,6 +8619,23 @@ public function findCached( throw new NotFoundException('Collection not found'); } + if ($this->validate) { + $validator = new DocumentsValidator( + $collectionDocument->getAttribute('attributes', []), + $collectionDocument->getAttribute('indexes', []), + $this->adapter->getIdAttributeType(), + $this->maxQueryValues, + $this->adapter->getMaxUIDLength(), + $this->adapter->getMinDateTime(), + $this->adapter->getMaxDateTime(), + $this->adapter->getSupportForAttributes(), + $this->adapter->getSupportForUnsignedBigInt() + ); + if (!$validator->isValid($queries)) { + throw new QueryException($validator->getDescription()); + } + } + $cacheKey = $this->getFindCacheKey($cacheCollection ?? $collectionDocument->getId(), $namespace, $tenant); $cacheField = $this->getFindCacheField($collectionDocument, $queries, $roles, $field, $payloadKey); @@ -8630,9 +8647,9 @@ public function findCached( } if (\is_array($cached) && isset($cached[$payloadKey]) && \is_array($cached[$payloadKey])) { - [$documents, $hasExpiredDocuments] = $this->decodeFindCachePayload($collectionDocument, $cached[$payloadKey]); + [$documents, $shouldRefreshCache] = $this->decodeFindCachePayload($collectionDocument, $cached[$payloadKey]); - if ($hasExpiredDocuments) { + if ($shouldRefreshCache) { try { $this->cache->purge($cacheKey, $cacheField); } catch (Exception $e) { @@ -8682,24 +8699,25 @@ public function findCached( private function decodeFindCachePayload(Document $collection, array $payload): array { $results = []; - $hasExpiredDocuments = false; + $shouldRefreshCache = false; foreach ($payload as $document) { if (!\is_array($document)) { + $shouldRefreshCache = true; continue; } $document = $this->createDocumentInstance($collection->getId(), $document); if ($this->isTtlExpired($collection, $document)) { - $hasExpiredDocuments = true; + $shouldRefreshCache = true; continue; } $results[] = $document; } - return [$results, $hasExpiredDocuments]; + return [$results, $shouldRefreshCache]; } /** diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index 118f72f5f..8188ea988 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -286,9 +286,9 @@ public function testFindCacheFieldValidatesQueryTypes(): void $this->expectException(QueryException::class); $db = $this->createDatabase(); - /** @var array $queries */ $queries = ['invalid']; + /** @phpstan-ignore-next-line intentionally passing invalid query type */ $db->getFindCacheField(null, $queries); } diff --git a/tests/unit/ListCacheTest.php b/tests/unit/ListCacheTest.php index e8fd940be..5f51f4137 100644 --- a/tests/unit/ListCacheTest.php +++ b/tests/unit/ListCacheTest.php @@ -165,15 +165,70 @@ public function testFindCachedRefetchesExpiredCachedDocuments(): void $this->assertSame('second', $documents[0]->getId()); } + public function testFindCachedRefetchesInvalidCachedPayload(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + $this->seedProject($database, 'first', 'First'); + + $queries = [Query::orderAsc('name')]; + $database->getAuthorization()->skip(fn () => $database->findCached( + 'projects', + $queries, + ttl: 3600, + cacheCollection: 'wafrules', + namespace: '_39', + roles: ['waf'], + payloadKey: 'rules', + )); + + $this->seedProject($database, 'second', 'Second'); + $cache->setCachedPayload( + $database->getFindCacheKey('wafrules', '_39'), + $database->getFindCacheField($database->getCollection('projects'), $queries, ['waf'], 'documents', 'rules'), + 'rules', + ['invalid'], + ); + + $documents = $database->getAuthorization()->skip(fn () => $database->findCached( + 'projects', + $queries, + ttl: 3600, + cacheCollection: 'wafrules', + namespace: '_39', + roles: ['waf'], + payloadKey: 'rules', + )); + + $this->assertCount(2, $documents); + } + + public function testFindCachedValidatesQuerySemanticsBeforeReadingCache(): void + { + $this->expectException(QueryException::class); + + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + $this->seedProject($database, 'first', 'First'); + + $database->getAuthorization()->skip(fn () => $database->findCached( + 'projects', + [Query::equal('missing', ['value'])], + ttl: 3600, + cacheCollection: 'wafrules', + namespace: '_39', + )); + } + public function testFindCachedValidatesQueryTypesBeforeCaching(): void { $this->expectException(QueryException::class); - /** @var array $queries */ $queries = ['invalid']; $this->database->getAuthorization()->skip(fn () => $this->database->findCached( 'projects', + /** @phpstan-ignore-next-line intentionally passing invalid query type */ $queries, ttl: 3600, cacheCollection: 'wafrules', @@ -251,6 +306,20 @@ public function setCachedPayloadDocumentAttribute(string $key, string $hash, str } } + /** + * @param array $documents + */ + public function setCachedPayload(string $key, string $hash, string $payload, array $documents): void + { + $data = $this->store[$key][$hash]['data'] ?? []; + if (!\is_array($data)) { + return; + } + + $data[$payload] = $documents; + $this->store[$key][$hash]['data'] = $data; + } + /** * @return array */ From 4af25ad34545e6fb02149afbcfafc025ef7c70fc Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Fri, 19 Jun 2026 12:26:27 +0100 Subject: [PATCH 20/51] Add cache-aside helper --- src/Database/Database.php | 206 ++++++++----------- tests/unit/ListCacheTest.php | 369 +++++++++++++---------------------- 2 files changed, 214 insertions(+), 361 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 1fe59e723..4b20de9c2 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8565,159 +8565,111 @@ public function find(string $collection, array $queries = [], string $forPermiss } /** - * Find documents using an Appwrite list-cache compatible key and field. + * Execute a callback behind a cache-aside lookup. * - * @param string $collection - * @param array $queries - * @param int $ttl Cache TTL in seconds. Values above TTL are clamped. Set to 0 to disable caching. - * @param string|null $cacheCollection - * @param string|null $namespace - * @param int|string|null $tenant - * @param array $roles - * @param string $field - * @param string $payloadKey - * @param string $forPermission - * @return array - * @throws DatabaseException - * @throws QueryException - * @throws TimeoutException - * @throws Exception + * The callback runs on cache miss and its value is returned to the caller. + * A literal false value is treated as a cache miss and is not cacheable. + * + * @template T + * @param string $key + * @param callable(): T $callback + * @param string $hash + * @return T */ - public function findCached( - string $collection, - array $queries = [], - int $ttl = self::TTL, - ?string $cacheCollection = null, - ?string $namespace = null, - int|string|null $tenant = null, - array $roles = [], - string $field = 'documents', - string $payloadKey = 'documents', - string $forPermission = Database::PERMISSION_READ, - ): array { - $this->checkQueryTypes($queries); + public function withCache( + string $key, + callable $callback, + string $hash = '', + ): mixed { + return $this->withCachedPayload( + key: $key, + callback: $callback, + hash: $hash, + fromCache: fn (mixed $cached): mixed => \is_array($cached) && \array_key_exists('value', $cached) ? $cached['value'] : false, + toCache: fn (mixed $value): array => ['value' => $value], + ); + } + /** + * Execute a callback behind a cache-aside lookup with cache payload hooks. + * + * @template T + * @param string $key + * @param callable(): T $callback + * @param int $ttl + * @param string $hash + * @param (callable(mixed): (T|false))|null $fromCache + * @param (callable(T): (array|string))|null $toCache + * @param (callable(T, mixed): void)|null $onCacheHit + * @param bool $touchOnHit + * @return T + */ + private function withCachedPayload( + string $key, + callable $callback, + int $ttl = self::TTL, + string $hash = '', + ?callable $fromCache = null, + ?callable $toCache = null, + ?callable $onCacheHit = null, + bool $touchOnHit = false, + ): mixed { if ($ttl <= 0) { - return $this->find($collection, $queries, $forPermission); - } - - if ($this->authorization->getStatus()) { - return $this->find($collection, $queries, $forPermission); - } - - foreach ($queries as $query) { - if ($query instanceof Query && $query->getMethod() === Query::TYPE_ORDER_RANDOM) { - return $this->find($collection, $queries, $forPermission); - } + return $callback(); } $ttl = \min($ttl, self::TTL); - - $collectionDocument = $this->silent(fn () => $this->getCollection($collection)); - - if ($collectionDocument->isEmpty()) { - throw new NotFoundException('Collection not found'); - } - - if ($this->validate) { - $validator = new DocumentsValidator( - $collectionDocument->getAttribute('attributes', []), - $collectionDocument->getAttribute('indexes', []), - $this->adapter->getIdAttributeType(), - $this->maxQueryValues, - $this->adapter->getMaxUIDLength(), - $this->adapter->getMinDateTime(), - $this->adapter->getMaxDateTime(), - $this->adapter->getSupportForAttributes(), - $this->adapter->getSupportForUnsignedBigInt() - ); - if (!$validator->isValid($queries)) { - throw new QueryException($validator->getDescription()); - } - } - - $cacheKey = $this->getFindCacheKey($cacheCollection ?? $collectionDocument->getId(), $namespace, $tenant); - $cacheField = $this->getFindCacheField($collectionDocument, $queries, $roles, $field, $payloadKey); + $shouldRefreshCache = false; try { - $cached = $this->cache->load($cacheKey, $ttl, $cacheField); - } catch (Exception $e) { - Console::warning('Warning: Failed to get list result from cache: ' . $e->getMessage()); - $cached = null; + $cached = $this->cache->load($key, $ttl, $hash); + } catch (Throwable $e) { + Console::warning('Warning: Failed to load cache value: ' . $e->getMessage()); + $cached = false; } - if (\is_array($cached) && isset($cached[$payloadKey]) && \is_array($cached[$payloadKey])) { - [$documents, $shouldRefreshCache] = $this->decodeFindCachePayload($collectionDocument, $cached[$payloadKey]); + if ($cached !== false && $cached !== null) { + $value = $fromCache === null ? $cached : $fromCache($cached); - if ($shouldRefreshCache) { - try { - $this->cache->purge($cacheKey, $cacheField); - } catch (Exception $e) { - Console::warning('Warning: Failed to purge expired list result cache: ' . $e->getMessage()); + if ($value !== false) { + if ($touchOnHit) { + try { + $this->cache->touch($key, $hash); + } catch (Throwable $e) { + Console::warning('Warning: Failed to touch cache value: ' . $e->getMessage()); + } } - $documents = $this->find($collectionDocument->getId(), $queries, $forPermission); - try { - $this->cache->save($cacheKey, [ - $payloadKey => \array_map( - static fn (Document $document): array => $document->getArrayCopy(), - $documents, - ), - ], $cacheField); - } catch (Exception $e) { - Console::warning('Failed to save list result to cache: ' . $e->getMessage()); + if ($onCacheHit !== null) { + $onCacheHit($value, $cached); } - return $documents; + return $value; } - $this->trigger(self::EVENT_DOCUMENT_FIND, $documents); - - return $documents; - } - - $documents = $this->find($collectionDocument->getId(), $queries, $forPermission); - - try { - $this->cache->save($cacheKey, [ - $payloadKey => \array_map( - static fn (Document $document): array => $document->getArrayCopy(), - $documents, - ), - ], $cacheField); - } catch (Exception $e) { - Console::warning('Failed to save list result to cache: ' . $e->getMessage()); + $shouldRefreshCache = true; } - return $documents; - } - - /** - * @param array $payload - * @return array{0: array, 1: bool} - */ - private function decodeFindCachePayload(Document $collection, array $payload): array - { - $results = []; - $shouldRefreshCache = false; - - foreach ($payload as $document) { - if (!\is_array($document)) { - $shouldRefreshCache = true; - continue; + if ($shouldRefreshCache) { + try { + $this->cache->purge($key, $hash); + } catch (Throwable $e) { + Console::warning('Warning: Failed to purge rejected cache value: ' . $e->getMessage()); } + } - $document = $this->createDocumentInstance($collection->getId(), $document); + $value = $callback(); + $payload = $toCache === null ? $value : $toCache($value); - if ($this->isTtlExpired($collection, $document)) { - $shouldRefreshCache = true; - continue; + if ($value !== false && (\is_array($payload) || \is_string($payload))) { + try { + $this->cache->save($key, $payload, $hash); + } catch (Throwable $e) { + Console::warning('Warning: Failed to save cache value: ' . $e->getMessage()); } - - $results[] = $document; } - return [$results, $shouldRefreshCache]; + return $value; } /** diff --git a/tests/unit/ListCacheTest.php b/tests/unit/ListCacheTest.php index 5f51f4137..f5bb2b439 100644 --- a/tests/unit/ListCacheTest.php +++ b/tests/unit/ListCacheTest.php @@ -4,236 +4,183 @@ use PHPUnit\Framework\TestCase; use Utopia\Cache\Adapter; -use Utopia\Cache\Adapter\Memory as CacheMemory; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Memory as DatabaseMemory; use Utopia\Database\Database; -use Utopia\Database\Document; -use Utopia\Database\Exception\Query as QueryException; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; -use Utopia\Database\Query; class ListCacheTest extends TestCase { - private Database $database; - - protected function setUp(): void + private function createDatabase(Adapter $cache): Database { - $this->database = $this->createDatabase(new CacheMemory()); - } - - private function createDatabase(Adapter $cache, ?DatabaseMemory $adapter = null): Database - { - $database = new Database($adapter ?? new DatabaseMemory(), new Cache($cache)); + $database = new Database(new DatabaseMemory(), new Cache($cache)); $database ->setDatabase('utopiaTests') ->setNamespace('list_cache_' . \uniqid()); $database->create(); - $database->createCollection('projects'); - $database->createAttribute('projects', 'name', Database::VAR_STRING, 255, true); return $database; } - private function seedProject(Database $database, string $id, string $name): void - { - $database->createDocument('projects', new Document([ - '$id' => $id, - '$permissions' => [Permission::read(Role::any())], - 'name' => $name, - ])); - } - - public function testFindCachedReturnsStaleResultUntilListKeyIsPurged(): void + public function testWithCacheUsesCallbackOnMissAndCachesResult(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); - $this->seedProject($database, 'first', 'First'); - - $documents = $database->getAuthorization()->skip(fn () => $database->findCached( - 'projects', - [Query::orderAsc('name')], - ttl: 3600, - cacheCollection: 'wafrules', - namespace: '_39', - roles: ['waf'], - payloadKey: 'rules', - )); - $this->assertCount(1, $documents); - $this->assertSame('first', $documents[0]->getId()); - - $this->seedProject($database, 'second', 'Second'); - - $documents = $database->getAuthorization()->skip(fn () => $database->findCached( - 'projects', - [Query::orderAsc('name')], - ttl: 3600, - cacheCollection: 'wafrules', - namespace: '_39', - roles: ['waf'], - payloadKey: 'rules', - )); - $this->assertCount(1, $documents); - $this->assertSame('first', $documents[0]->getId()); - - $cache->purge($database->getFindCacheKey('wafrules', '_39')); - - $documents = $database->getAuthorization()->skip(fn () => $database->findCached( - 'projects', - [Query::orderAsc('name')], - ttl: 3600, - cacheCollection: 'wafrules', - namespace: '_39', - roles: ['waf'], - payloadKey: 'rules', - )); - $this->assertCount(2, $documents); + + $callbackCalls = 0; + + $value = $database->withCache( + 'key', + function () use (&$callbackCalls): array { + $callbackCalls++; + return ['value' => 'fresh']; + }, + ); + + $this->assertSame(['value' => 'fresh'], $value); + $this->assertSame(1, $callbackCalls); + + $value = $database->withCache( + 'key', + function () use (&$callbackCalls): array { + $callbackCalls++; + return ['value' => 'new']; + }, + ); + + $this->assertSame(['value' => 'fresh'], $value); + $this->assertSame(1, $callbackCalls); } - public function testFindCachedUsesListCacheKeyAndField(): void + public function testWithCacheCachesEmptyValues(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); - $this->seedProject($database, 'first', 'First'); - - $database->getAuthorization()->skip(fn () => $database->findCached( - 'projects', - [Query::orderAsc('name')], - ttl: 3600, - cacheCollection: 'wafrules', - namespace: '_39', - roles: ['waf'], - payloadKey: 'rules', - )); - - $fields = $cache->list($database->getFindCacheKey('wafrules', '_39')); - - $this->assertCount(1, $fields); - $this->assertStringEndsWith(':documents:rules', $fields[0]); - $this->assertSame(4, \substr_count($fields[0], ':')); + + $callbackCalls = 0; + + $value = $database->withCache( + 'key', + function () use (&$callbackCalls): array { + $callbackCalls++; + return []; + }, + ); + + $this->assertSame([], $value); + + $value = $database->withCache( + 'key', + function () use (&$callbackCalls): array { + $callbackCalls++; + return ['value' => 'miss']; + }, + ); + + $this->assertSame([], $value); + $this->assertSame(1, $callbackCalls); } - public function testFindCachedRefetchesExpiredCachedDocuments(): void + public function testWithCacheCachesNullValues(): void { $cache = new HashMemoryCache(); - $database = $this->createDatabase($cache, new TtlMemoryAdapter()); - $database->createAttribute('projects', 'expiresAt', Database::VAR_DATETIME, 0, false); - $database->createIndex('projects', 'expiresAtTtl', Database::INDEX_TTL, ['expiresAt'], ttl: 1); - - $database->createDocument('projects', new Document([ - '$id' => 'first', - '$permissions' => [Permission::read(Role::any())], - 'name' => 'First', - 'expiresAt' => '2999-01-01T00:00:00.000+00:00', - ])); - $this->seedProject($database, 'second', 'Second'); - - $queries = [Query::orderAsc('name'), Query::limit(1)]; - $database->getAuthorization()->skip(fn () => $database->findCached( - 'projects', - $queries, - ttl: 3600, - cacheCollection: 'wafrules', - namespace: '_39', - roles: ['waf'], - payloadKey: 'rules', - )); - - $collection = $database->getCollection('projects'); - $cache->setCachedPayloadDocumentAttribute( - $database->getFindCacheKey('wafrules', '_39'), - $database->getFindCacheField($collection, $queries, ['waf'], 'documents', 'rules'), - 'rules', - 'first', - 'expiresAt', - '2000-01-01T00:00:00.000+00:00', + $database = $this->createDatabase($cache); + + $callbackCalls = 0; + + $value = $database->withCache( + 'key', + function () use (&$callbackCalls): mixed { + $callbackCalls++; + return null; + }, + ); + + $this->assertNull($value); + + $value = $database->withCache( + 'key', + function () use (&$callbackCalls): string { + $callbackCalls++; + return 'miss'; + }, ); - $database->getAuthorization()->skip(fn () => $database->updateDocument('projects', 'first', new Document(['name' => 'Zulu']))); - - $documents = $database->getAuthorization()->skip(fn () => $database->findCached( - 'projects', - $queries, - ttl: 3600, - cacheCollection: 'wafrules', - namespace: '_39', - roles: ['waf'], - payloadKey: 'rules', - )); - $this->assertCount(1, $documents); - $this->assertSame('second', $documents[0]->getId()); + + $this->assertNull($value); + $this->assertSame(1, $callbackCalls); } - public function testFindCachedRefetchesInvalidCachedPayload(): void + public function testWithCacheSeparatesPayloadsByHashField(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); - $this->seedProject($database, 'first', 'First'); - - $queries = [Query::orderAsc('name')]; - $database->getAuthorization()->skip(fn () => $database->findCached( - 'projects', - $queries, - ttl: 3600, - cacheCollection: 'wafrules', - namespace: '_39', - roles: ['waf'], - payloadKey: 'rules', - )); - - $this->seedProject($database, 'second', 'Second'); - $cache->setCachedPayload( - $database->getFindCacheKey('wafrules', '_39'), - $database->getFindCacheField($database->getCollection('projects'), $queries, ['waf'], 'documents', 'rules'), - 'rules', - ['invalid'], + + $firstCalls = 0; + $secondCalls = 0; + + $first = $database->withCache( + 'key', + function () use (&$firstCalls): array { + $firstCalls++; + return ['value' => 'first']; + }, + 'first-field', + ); + + $second = $database->withCache( + 'key', + function () use (&$secondCalls): array { + $secondCalls++; + return ['value' => 'second']; + }, + 'second-field', ); - $documents = $database->getAuthorization()->skip(fn () => $database->findCached( - 'projects', - $queries, - ttl: 3600, - cacheCollection: 'wafrules', - namespace: '_39', - roles: ['waf'], - payloadKey: 'rules', - )); - - $this->assertCount(2, $documents); + $cachedFirst = $database->withCache( + 'key', + function () use (&$firstCalls): array { + $firstCalls++; + return ['value' => 'miss']; + }, + 'first-field', + ); + + $this->assertSame(['value' => 'first'], $first); + $this->assertSame(['value' => 'second'], $second); + $this->assertSame(['value' => 'first'], $cachedFirst); + $this->assertSame(1, $firstCalls); + $this->assertSame(1, $secondCalls); + $this->assertSame(['first-field', 'second-field'], $cache->list('key')); } - public function testFindCachedValidatesQuerySemanticsBeforeReadingCache(): void + public function testWithCacheDoesNotCacheFalseValues(): void { - $this->expectException(QueryException::class); - $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); - $this->seedProject($database, 'first', 'First'); - - $database->getAuthorization()->skip(fn () => $database->findCached( - 'projects', - [Query::equal('missing', ['value'])], - ttl: 3600, - cacheCollection: 'wafrules', - namespace: '_39', - )); - } - public function testFindCachedValidatesQueryTypesBeforeCaching(): void - { - $this->expectException(QueryException::class); - - $queries = ['invalid']; - - $this->database->getAuthorization()->skip(fn () => $this->database->findCached( - 'projects', - /** @phpstan-ignore-next-line intentionally passing invalid query type */ - $queries, - ttl: 3600, - cacheCollection: 'wafrules', - namespace: '_39', - )); + $callbackCalls = 0; + + $value = $database->withCache( + 'key', + function () use (&$callbackCalls): bool { + $callbackCalls++; + return false; + }, + ); + + $this->assertFalse($value); + $this->assertSame([], $cache->list('key')); + + $value = $database->withCache( + 'key', + function () use (&$callbackCalls): string { + $callbackCalls++; + return 'fresh'; + }, + ); + + $this->assertSame('fresh', $value); + $this->assertSame(2, $callbackCalls); } } @@ -282,44 +229,6 @@ public function touch(string $key, string $hash = ''): bool return true; } - public function setCachedPayloadDocumentAttribute(string $key, string $hash, string $payload, string $documentId, string $attribute, mixed $value): void - { - $data = $this->store[$key][$hash]['data'] ?? []; - if (!\is_array($data)) { - return; - } - - $documents = $data[$payload] ?? []; - if (!\is_array($documents)) { - return; - } - - foreach ($documents as $index => $document) { - if (!\is_array($document) || ($document['$id'] ?? '') !== $documentId) { - continue; - } - - $documents[$index][$attribute] = $value; - $data[$payload] = $documents; - $this->store[$key][$hash]['data'] = $data; - return; - } - } - - /** - * @param array $documents - */ - public function setCachedPayload(string $key, string $hash, string $payload, array $documents): void - { - $data = $this->store[$key][$hash]['data'] ?? []; - if (!\is_array($data)) { - return; - } - - $data[$payload] = $documents; - $this->store[$key][$hash]['data'] = $data; - } - /** * @return array */ @@ -362,11 +271,3 @@ public function getName(?string $key = null): string return 'hash-memory'; } } - -class TtlMemoryAdapter extends DatabaseMemory -{ - public function getSupportForTTLIndexes(): bool - { - return true; - } -} From 7223453dbec47ea7958fc7949425236ac778ce2f Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Fri, 19 Jun 2026 12:58:28 +0100 Subject: [PATCH 21/51] Trigger review From 176247de2119d9dceeafeb23ce22fed9c5728280 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Fri, 19 Jun 2026 13:34:53 +0100 Subject: [PATCH 22/51] Simplify cache helper internals --- src/Database/Database.php | 64 ++++--------------------------------- tests/unit/CacheKeyTest.php | 16 ---------- 2 files changed, 6 insertions(+), 74 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 4b20de9c2..72dce4939 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8581,69 +8581,19 @@ public function withCache( callable $callback, string $hash = '', ): mixed { - return $this->withCachedPayload( - key: $key, - callback: $callback, - hash: $hash, - fromCache: fn (mixed $cached): mixed => \is_array($cached) && \array_key_exists('value', $cached) ? $cached['value'] : false, - toCache: fn (mixed $value): array => ['value' => $value], - ); - } - - /** - * Execute a callback behind a cache-aside lookup with cache payload hooks. - * - * @template T - * @param string $key - * @param callable(): T $callback - * @param int $ttl - * @param string $hash - * @param (callable(mixed): (T|false))|null $fromCache - * @param (callable(T): (array|string))|null $toCache - * @param (callable(T, mixed): void)|null $onCacheHit - * @param bool $touchOnHit - * @return T - */ - private function withCachedPayload( - string $key, - callable $callback, - int $ttl = self::TTL, - string $hash = '', - ?callable $fromCache = null, - ?callable $toCache = null, - ?callable $onCacheHit = null, - bool $touchOnHit = false, - ): mixed { - if ($ttl <= 0) { - return $callback(); - } - - $ttl = \min($ttl, self::TTL); $shouldRefreshCache = false; try { - $cached = $this->cache->load($key, $ttl, $hash); + $cached = $this->cache->load($key, self::TTL, $hash); } catch (Throwable $e) { Console::warning('Warning: Failed to load cache value: ' . $e->getMessage()); $cached = false; } if ($cached !== false && $cached !== null) { - $value = $fromCache === null ? $cached : $fromCache($cached); + $value = \is_array($cached) && \array_key_exists('value', $cached) ? $cached['value'] : false; if ($value !== false) { - if ($touchOnHit) { - try { - $this->cache->touch($key, $hash); - } catch (Throwable $e) { - Console::warning('Warning: Failed to touch cache value: ' . $e->getMessage()); - } - } - - if ($onCacheHit !== null) { - $onCacheHit($value, $cached); - } - return $value; } @@ -8659,11 +8609,10 @@ private function withCachedPayload( } $value = $callback(); - $payload = $toCache === null ? $value : $toCache($value); - if ($value !== false && (\is_array($payload) || \is_string($payload))) { + if ($value !== false) { try { - $this->cache->save($key, $payload, $hash); + $this->cache->save($key, ['value' => $value], $hash); } catch (Throwable $e) { Console::warning('Warning: Failed to save cache value: ' . $e->getMessage()); } @@ -9573,10 +9522,9 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a * * @param string $collectionId * @param string|null $namespace - * @param int|string|null $tenant * @return string */ - public function getFindCacheKey(string $collectionId, ?string $namespace = null, int|string|null $tenant = null): string + public function getFindCacheKey(string $collectionId, ?string $namespace = null): string { $hostname = $this->adapter->getSupportForHostname() ? $this->adapter->getHostname() @@ -9587,7 +9535,7 @@ public function getFindCacheKey(string $collectionId, ?string $namespace = null, $this->cacheName, $hostname, $namespace ?? $this->getNamespace(), - $tenant ?? $this->adapter->getTenant(), + $this->adapter->getTenant(), $collectionId, ); } diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index 8188ea988..8a76a8750 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -167,22 +167,6 @@ public function testFindCacheKeyCanOverrideNamespaceSegment(): void ); } - public function testFindCacheKeyCanOverrideTenantSegment(): void - { - $adapter = $this->createMock(Adapter::class); - $adapter->method('getSupportForHostname')->willReturn(true); - $adapter->method('getHostname')->willReturn('mysql-console'); - $adapter->method('getNamespace')->willReturn('_39'); - $adapter->method('getTenant')->willReturn(null); - - $db = new Database($adapter, new Cache(new None()), []); - - $this->assertSame( - 'default-cache:mysql-console:_39:tenant-a:collection:wafrules:find', - $db->getFindCacheKey('wafrules', tenant: 'tenant-a'), - ); - } - public function testFindCacheFieldUsesListCacheShape(): void { $db = $this->createDatabase(); From 7478a8b0146fdf98c424a99c53eaa5c0ea9210b1 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Fri, 19 Jun 2026 13:56:57 +0100 Subject: [PATCH 23/51] Simplify find cache fields --- src/Database/Database.php | 6 ++---- tests/unit/CacheKeyTest.php | 7 +++---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 72dce4939..a5811c13f 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -9547,10 +9547,9 @@ public function getFindCacheKey(string $collectionId, ?string $namespace = null) * @param array $queries * @param array $roles * @param string $field - * @param string $payloadKey * @return string */ - public function getFindCacheField(?Document $collection = null, array $queries = [], array $roles = [], string $field = 'documents', string $payloadKey = 'documents'): string + public function getFindCacheField(?Document $collection = null, array $queries = [], array $roles = [], string $field = 'documents'): string { $this->checkQueryTypes($queries); @@ -9566,12 +9565,11 @@ public function getFindCacheField(?Document $collection = null, array $queries = ]; return \sprintf( - '%s:%s:%s:%s:%s', + '%s:%s:%s:%s', $this->getFindCacheSchemaHash($collection), \md5(\json_encode($roles) ?: ''), \md5(\json_encode($queryPayload) ?: ''), $field, - $payloadKey, ); } diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index 8a76a8750..6e5555d2e 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -193,8 +193,8 @@ public function testFindCacheFieldUsesListCacheShape(): void $field = $db->getFindCacheField($collection, $queries, ['waf']); $this->assertStringStartsWith("{$schemaHash}:".\md5(\json_encode(['waf']) ?: '').':', $field); - $this->assertStringEndsWith(':documents:documents', $field); - $this->assertSame(4, \substr_count($field, ':')); + $this->assertStringEndsWith(':documents', $field); + $this->assertSame(3, \substr_count($field, ':')); } public function testFindCacheFieldChangesWithInputs(): void @@ -223,8 +223,7 @@ public function testFindCacheFieldChangesWithInputs(): void ); $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(20)], ['role-a'])); $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(10)], ['role-b'])); - $this->assertStringEndsWith(':total:documents', $db->getFindCacheField(null, [Query::limit(10)], ['role-a'], 'total')); - $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(10)], ['role-a'], 'documents', 'items')); + $this->assertStringEndsWith(':total', $db->getFindCacheField(null, [Query::limit(10)], ['role-a'], 'total')); } public function testFindCacheFieldIncludesCursorDocumentPayload(): void From a761a16f4c5cf9fc2c5775d91958e0024eb39761 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Sat, 20 Jun 2026 11:59:15 +0100 Subject: [PATCH 24/51] Add cached find helpers --- src/Database/Database.php | 69 +++++++++++++++++++++++++ tests/unit/ListCacheTest.php | 99 ++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) diff --git a/src/Database/Database.php b/src/Database/Database.php index a5811c13f..42d48beab 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8564,6 +8564,75 @@ public function find(string $collection, array $queries = [], string $forPermiss return $results; } + /** + * Find documents through a cache-aside lookup. + * + * The caller owns authorization context. Use Authorization::skip() around + * this method for internal reads that should bypass request-user roles. + * + * @param string $collection + * @param array $queries + * @param string|null $namespace + * @param array $roles + * @param string $forPermission + * @return array + * @throws DatabaseException + * @throws QueryException + * @throws TimeoutException + * @throws Exception + */ + public function cachedFind( + string $collection, + array $queries = [], + ?string $namespace = null, + array $roles = [], + string $forPermission = Database::PERMISSION_READ, + ): array { + $collectionDocument = $this->silent(fn () => $this->getCollection($collection)); + + if ($collectionDocument->isEmpty()) { + throw new NotFoundException('Collection not found'); + } + + $payload = $this->withCache( + key: $this->getFindCacheKey($collection, $namespace), + callback: fn (): array => \array_map( + static fn (Document $document): array => $document->getArrayCopy(), + $this->find($collection, $queries, $forPermission), + ), + hash: $this->getFindCacheField($collectionDocument, $queries, $roles, 'documents'), + ); + + if (!\is_array($payload)) { + return []; + } + + $documents = []; + foreach ($payload as $document) { + if (!\is_array($document)) { + continue; + } + + $documents[] = $this->createDocumentInstance($collection, $document); + } + + return $documents; + } + + /** + * Purge all cached find entries for a collection namespace. + * + * @param string $collection + * @param string|null $namespace + * @return bool + */ + public function purgeCachedFind(string $collection, ?string $namespace = null): bool + { + return $this->cache->purge( + $this->getFindCacheKey($collection, $namespace) + ); + } + /** * Execute a callback behind a cache-aside lookup. * diff --git a/tests/unit/ListCacheTest.php b/tests/unit/ListCacheTest.php index f5bb2b439..4b3e2e1a8 100644 --- a/tests/unit/ListCacheTest.php +++ b/tests/unit/ListCacheTest.php @@ -7,6 +7,10 @@ use Utopia\Cache\Cache; use Utopia\Database\Adapter\Memory as DatabaseMemory; use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\Database\Helpers\Permission; +use Utopia\Database\Helpers\Role; +use Utopia\Database\Query; class ListCacheTest extends TestCase { @@ -182,6 +186,101 @@ function () use (&$callbackCalls): string { $this->assertSame('fresh', $value); $this->assertSame(2, $callbackCalls); } + + public function testCachedFindUsesCacheUntilPurged(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + $database->createCollection('wafRules', [ + new Document([ + '$id' => 'projectId', + 'type' => Database::VAR_STRING, + 'size' => 255, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + + $database->createDocument('wafRules', new Document([ + '$id' => 'rule-a', + 'projectId' => 'project-a', + ])); + + $queries = [ + Query::equal('projectId', ['project-a']), + Query::limit(25), + ]; + + $first = $database->cachedFind('wafRules', $queries, '_39', ['waf']); + $this->assertCount(1, $first); + $this->assertSame('rule-a', $first[0]->getId()); + + $database->createDocument('wafRules', new Document([ + '$id' => 'rule-b', + 'projectId' => 'project-a', + ])); + + $cached = $database->cachedFind('wafRules', $queries, '_39', ['waf']); + $this->assertCount(1, $cached); + $this->assertSame('rule-a', $cached[0]->getId()); + + $this->assertTrue($database->purgeCachedFind('wafRules', '_39')); + + $fresh = $database->cachedFind('wafRules', $queries, '_39', ['waf']); + $this->assertCount(2, $fresh); + $this->assertSame(['rule-a', 'rule-b'], \array_map( + static fn (Document $document): string => $document->getId(), + $fresh, + )); + } + + public function testCachedFindSeparatesEntriesByRoles(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + $database->createCollection('wafRules', [ + new Document([ + '$id' => 'projectId', + 'type' => Database::VAR_STRING, + 'size' => 255, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + + $database->createDocument('wafRules', new Document([ + '$id' => 'rule-a', + 'projectId' => 'project-a', + ])); + + $queries = [ + Query::equal('projectId', ['project-a']), + Query::limit(25), + ]; + + $database->cachedFind('wafRules', $queries, '_39', ['waf']); + + $database->createDocument('wafRules', new Document([ + '$id' => 'rule-b', + 'projectId' => 'project-a', + ])); + + $cached = $database->cachedFind('wafRules', $queries, '_39', ['waf']); + $this->assertCount(1, $cached); + + $roleSeparated = $database->cachedFind('wafRules', $queries, '_39', ['manager']); + $this->assertCount(2, $roleSeparated); + } } class HashMemoryCache implements Adapter From f8eb6326557007256837e087f45f644d886b5648 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Sat, 20 Jun 2026 12:07:47 +0100 Subject: [PATCH 25/51] Include permission in cached find hash --- src/Database/Database.php | 13 ++++++++--- tests/unit/CacheKeyTest.php | 1 + tests/unit/ListCacheTest.php | 44 ++++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 42d48beab..ded3397a7 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8600,7 +8600,7 @@ public function cachedFind( static fn (Document $document): array => $document->getArrayCopy(), $this->find($collection, $queries, $forPermission), ), - hash: $this->getFindCacheField($collectionDocument, $queries, $roles, 'documents'), + hash: $this->getFindCacheField($collectionDocument, $queries, $roles, 'documents', $forPermission), ); if (!\is_array($payload)) { @@ -9616,15 +9616,22 @@ public function getFindCacheKey(string $collectionId, ?string $namespace = null) * @param array $queries * @param array $roles * @param string $field + * @param string $forPermission * @return string */ - public function getFindCacheField(?Document $collection = null, array $queries = [], array $roles = [], string $field = 'documents'): string - { + public function getFindCacheField( + ?Document $collection = null, + array $queries = [], + array $roles = [], + string $field = 'documents', + string $forPermission = self::PERMISSION_READ, + ): string { $this->checkQueryTypes($queries); $queryPayload = [ 'version' => 1, 'database' => $this->getDatabase(), + 'permission' => $forPermission, 'queries' => \array_map( fn (Query $query): array => $this->serializeFindCacheQuery($query), $queries, diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index 6e5555d2e..00f1db1bf 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -223,6 +223,7 @@ public function testFindCacheFieldChangesWithInputs(): void ); $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(20)], ['role-a'])); $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(10)], ['role-b'])); + $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(10)], ['role-a'], 'documents', Database::PERMISSION_UPDATE)); $this->assertStringEndsWith(':total', $db->getFindCacheField(null, [Query::limit(10)], ['role-a'], 'total')); } diff --git a/tests/unit/ListCacheTest.php b/tests/unit/ListCacheTest.php index 4b3e2e1a8..209272fa6 100644 --- a/tests/unit/ListCacheTest.php +++ b/tests/unit/ListCacheTest.php @@ -281,6 +281,50 @@ public function testCachedFindSeparatesEntriesByRoles(): void $roleSeparated = $database->cachedFind('wafRules', $queries, '_39', ['manager']); $this->assertCount(2, $roleSeparated); } + + public function testCachedFindSeparatesEntriesByPermissionMode(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + $database->createCollection('wafRules', [ + new Document([ + '$id' => 'projectId', + 'type' => Database::VAR_STRING, + 'size' => 255, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ]); + + $database->createDocument('wafRules', new Document([ + '$id' => 'rule-a', + 'projectId' => 'project-a', + ])); + + $queries = [ + Query::equal('projectId', ['project-a']), + Query::limit(25), + ]; + + $database->cachedFind('wafRules', $queries, '_39', ['waf'], Database::PERMISSION_READ); + + $database->createDocument('wafRules', new Document([ + '$id' => 'rule-b', + 'projectId' => 'project-a', + ])); + + $cached = $database->cachedFind('wafRules', $queries, '_39', ['waf'], Database::PERMISSION_READ); + $this->assertCount(1, $cached); + + $permissionSeparated = $database->cachedFind('wafRules', $queries, '_39', ['waf'], Database::PERMISSION_UPDATE); + $this->assertCount(2, $permissionSeparated); + } } class HashMemoryCache implements Adapter From f3b6c300539dfeda3f23482b1ca8f86531298689 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Sat, 20 Jun 2026 12:14:21 +0100 Subject: [PATCH 26/51] Order cached find test results --- tests/unit/ListCacheTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/ListCacheTest.php b/tests/unit/ListCacheTest.php index 209272fa6..ae0175efd 100644 --- a/tests/unit/ListCacheTest.php +++ b/tests/unit/ListCacheTest.php @@ -213,6 +213,7 @@ public function testCachedFindUsesCacheUntilPurged(): void $queries = [ Query::equal('projectId', ['project-a']), + Query::orderAsc('$id'), Query::limit(25), ]; @@ -265,6 +266,7 @@ public function testCachedFindSeparatesEntriesByRoles(): void $queries = [ Query::equal('projectId', ['project-a']), + Query::orderAsc('$id'), Query::limit(25), ]; @@ -309,6 +311,7 @@ public function testCachedFindSeparatesEntriesByPermissionMode(): void $queries = [ Query::equal('projectId', ['project-a']), + Query::orderAsc('$id'), Query::limit(25), ]; From f126784c3cb0869a4d6cec5648820f51c53e5015 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Sat, 20 Jun 2026 12:19:18 +0100 Subject: [PATCH 27/51] Handle cached find hit edge cases --- src/Database/Database.php | 16 +++- tests/unit/ListCacheTest.php | 166 +++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index ded3397a7..82b7cd248 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8588,12 +8588,21 @@ public function cachedFind( array $roles = [], string $forPermission = Database::PERMISSION_READ, ): array { + foreach ($queries as $query) { + if ($query instanceof Query && $query->getMethod() === Query::TYPE_ORDER_RANDOM) { + return $this->find($collection, $queries, $forPermission); + } + } + $collectionDocument = $this->silent(fn () => $this->getCollection($collection)); if ($collectionDocument->isEmpty()) { throw new NotFoundException('Collection not found'); } + $selects = Query::groupByType($queries)['selections']; + $selections = $this->validateSelections($collectionDocument, $selects); + $payload = $this->withCache( key: $this->getFindCacheKey($collection, $namespace), callback: fn (): array => \array_map( @@ -8613,7 +8622,12 @@ public function cachedFind( continue; } - $documents[] = $this->createDocumentInstance($collection, $document); + $document = $this->createDocumentInstance($collection, $document); + $document = $this->adapter->castingAfter($collectionDocument, $document); + $document = $this->casting($collectionDocument, $document); + $document = $this->decode($collectionDocument, $document, $selections); + + $documents[] = $document; } return $documents; diff --git a/tests/unit/ListCacheTest.php b/tests/unit/ListCacheTest.php index ae0175efd..a3f824f9e 100644 --- a/tests/unit/ListCacheTest.php +++ b/tests/unit/ListCacheTest.php @@ -328,6 +328,84 @@ public function testCachedFindSeparatesEntriesByPermissionMode(): void $permissionSeparated = $database->cachedFind('wafRules', $queries, '_39', ['waf'], Database::PERMISSION_UPDATE); $this->assertCount(2, $permissionSeparated); } + + public function testCachedFindRecastsCacheHits(): void + { + $cache = new JsonHashMemoryCache(); + $database = $this->createDatabase($cache); + $database->createCollection('metrics', [ + new Document([ + '$id' => 'value', + 'type' => Database::VAR_FLOAT, + 'size' => 0, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + + $database->createDocument('metrics', new Document([ + '$id' => 'metric-a', + 'value' => 1.0, + ])); + + $queries = [ + Query::orderAsc('$id'), + Query::limit(25), + ]; + + $fresh = $database->cachedFind('metrics', $queries, '_39', ['metrics']); + $cached = $database->cachedFind('metrics', $queries, '_39', ['metrics']); + + $this->assertSame(1.0, $fresh[0]->getAttribute('value')); + $this->assertSame(1.0, $cached[0]->getAttribute('value')); + } + + public function testCachedFindBypassesCacheForRandomOrder(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + $database->createCollection('wafRules', [ + new Document([ + '$id' => 'projectId', + 'type' => Database::VAR_STRING, + 'size' => 255, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + + $database->createDocument('wafRules', new Document([ + '$id' => 'rule-a', + 'projectId' => 'project-a', + ])); + + $queries = [ + Query::equal('projectId', ['project-a']), + Query::orderRandom(), + Query::limit(25), + ]; + + $first = $database->cachedFind('wafRules', $queries, '_39', ['waf']); + $this->assertCount(1, $first); + + $database->createDocument('wafRules', new Document([ + '$id' => 'rule-b', + 'projectId' => 'project-a', + ])); + + $second = $database->cachedFind('wafRules', $queries, '_39', ['waf']); + $this->assertCount(2, $second); + } } class HashMemoryCache implements Adapter @@ -417,3 +495,91 @@ public function getName(?string $key = null): string return 'hash-memory'; } } + +class JsonHashMemoryCache implements Adapter +{ + /** + * @var array> + */ + private array $store = []; + + public function load(string $key, int $ttl, string $hash = ''): mixed + { + $hash = $hash === '' ? $key : $hash; + $saved = $this->store[$key][$hash] ?? null; + if ($saved === null || $saved['time'] + $ttl <= \time()) { + return false; + } + + return \json_decode($saved['data'], true); + } + + public function save(string $key, array|string $data, string $hash = ''): bool|string|array + { + if ($key === '' || empty($data)) { + return false; + } + + $hash = $hash === '' ? $key : $hash; + $this->store[$key][$hash] = [ + 'time' => \time(), + 'data' => \json_encode($data) ?: '', + ]; + + return $data; + } + + public function touch(string $key, string $hash = ''): bool + { + $hash = $hash === '' ? $key : $hash; + if (!isset($this->store[$key][$hash])) { + return false; + } + + $this->store[$key][$hash]['time'] = \time(); + + return true; + } + + /** + * @return array + */ + public function list(string $key): array + { + return \array_keys($this->store[$key] ?? []); + } + + public function purge(string $key, string $hash = ''): bool + { + if ($hash !== '') { + unset($this->store[$key][$hash]); + return true; + } + + unset($this->store[$key]); + + return true; + } + + public function flush(): bool + { + $this->store = []; + + return true; + } + + public function ping(): bool + { + return true; + } + + public function getSize(): int + { + return \count($this->store); + } + + public function getName(?string $key = null): string + { + return 'json-hash-memory'; + } +} From b89bf04fe26cf29ad91b11e467029983a83e2ded Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Sat, 20 Jun 2026 12:41:24 +0100 Subject: [PATCH 28/51] Trigger CI From 383eb0f0e4cf53e77982abead5c7747c6a2d6ebe Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Sat, 20 Jun 2026 12:55:53 +0100 Subject: [PATCH 29/51] Fingerprint cached find auth context --- src/Database/Database.php | 10 ++++++++++ tests/unit/CacheKeyTest.php | 19 ++++++++++++++++++ tests/unit/ListCacheTest.php | 37 ++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/src/Database/Database.php b/src/Database/Database.php index 82b7cd248..82ade7a96 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -9642,8 +9642,18 @@ public function getFindCacheField( ): string { $this->checkQueryTypes($queries); + $roles = \array_values(\array_unique($roles)); + \sort($roles); + + $authorizationRoles = \array_values(\array_unique($this->authorization->getRoles())); + \sort($authorizationRoles); + $queryPayload = [ 'version' => 1, + 'authorization' => [ + 'enabled' => $this->authorization->getStatus(), + 'roles' => $authorizationRoles, + ], 'database' => $this->getDatabase(), 'permission' => $forPermission, 'queries' => \array_map( diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index 00f1db1bf..ba2f6d750 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -227,6 +227,25 @@ public function testFindCacheFieldChangesWithInputs(): void $this->assertStringEndsWith(':total', $db->getFindCacheField(null, [Query::limit(10)], ['role-a'], 'total')); } + public function testFindCacheFieldChangesWithActiveAuthorizationContext(): void + { + $db = $this->createDatabase(); + + $field = $db->getFindCacheField(null, [Query::limit(10)], ['waf']); + + $this->assertNotSame( + $field, + $db->getAuthorization()->skip(fn () => $db->getFindCacheField(null, [Query::limit(10)], ['waf'])), + ); + + $db->getAuthorization()->addRole('user:1'); + + $this->assertNotSame( + $field, + $db->getFindCacheField(null, [Query::limit(10)], ['waf']), + ); + } + public function testFindCacheFieldIncludesCursorDocumentPayload(): void { $db = $this->createDatabase(); diff --git a/tests/unit/ListCacheTest.php b/tests/unit/ListCacheTest.php index a3f824f9e..5cc2b8068 100644 --- a/tests/unit/ListCacheTest.php +++ b/tests/unit/ListCacheTest.php @@ -406,6 +406,43 @@ public function testCachedFindBypassesCacheForRandomOrder(): void $second = $database->cachedFind('wafRules', $queries, '_39', ['waf']); $this->assertCount(2, $second); } + + public function testCachedFindRehydratesNestedDocumentPayloads(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + $database->createCollection('parents', permissions: [ + Permission::read(Role::any()), + ]); + + $queries = [ + Query::limit(25), + ]; + + $collection = $database->getCollection('parents'); + $cache->save( + $database->getFindCacheKey('parents', '_39'), + [ + 'value' => [ + [ + '$id' => 'parent-a', + 'child' => [ + '$id' => 'child-a', + '$collection' => 'children', + 'name' => 'Child A', + ], + ], + ], + ], + $database->getFindCacheField($collection, $queries, ['waf']), + ); + + $parents = $database->cachedFind('parents', $queries, '_39', ['waf']); + + $this->assertCount(1, $parents); + $this->assertInstanceOf(Document::class, $parents[0]->getAttribute('child')); + $this->assertSame('child-a', $parents[0]->getAttribute('child')->getId()); + } } class HashMemoryCache implements Adapter From 52233d5f14dc10fb44201a0ab0eddde33fb41493 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Sat, 20 Jun 2026 13:05:47 +0100 Subject: [PATCH 30/51] Avoid double decoding cached find results --- src/Database/Database.php | 23 ++++++++++++------ tests/unit/ListCacheTest.php | 47 ++++++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 82ade7a96..857cbf541 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8600,18 +8600,27 @@ public function cachedFind( throw new NotFoundException('Collection not found'); } - $selects = Query::groupByType($queries)['selections']; - $selections = $this->validateSelections($collectionDocument, $selects); + $cacheMiss = false; + $documents = []; $payload = $this->withCache( key: $this->getFindCacheKey($collection, $namespace), - callback: fn (): array => \array_map( - static fn (Document $document): array => $document->getArrayCopy(), - $this->find($collection, $queries, $forPermission), - ), + callback: function () use ($collection, $queries, $forPermission, &$cacheMiss, &$documents): array { + $cacheMiss = true; + $documents = $this->find($collection, $queries, $forPermission); + + return \array_map( + static fn (Document $document): array => $document->getArrayCopy(), + $documents, + ); + }, hash: $this->getFindCacheField($collectionDocument, $queries, $roles, 'documents', $forPermission), ); + if ($cacheMiss) { + return $documents; + } + if (!\is_array($payload)) { return []; } @@ -8623,9 +8632,7 @@ public function cachedFind( } $document = $this->createDocumentInstance($collection, $document); - $document = $this->adapter->castingAfter($collectionDocument, $document); $document = $this->casting($collectionDocument, $document); - $document = $this->decode($collectionDocument, $document, $selections); $documents[] = $document; } diff --git a/tests/unit/ListCacheTest.php b/tests/unit/ListCacheTest.php index 5cc2b8068..1a17ccaee 100644 --- a/tests/unit/ListCacheTest.php +++ b/tests/unit/ListCacheTest.php @@ -14,9 +14,9 @@ class ListCacheTest extends TestCase { - private function createDatabase(Adapter $cache): Database + private function createDatabase(Adapter $cache, array $filters = []): Database { - $database = new Database(new DatabaseMemory(), new Cache($cache)); + $database = new Database(new DatabaseMemory(), new Cache($cache), $filters); $database ->setDatabase('utopiaTests') ->setNamespace('list_cache_' . \uniqid()); @@ -365,6 +365,49 @@ public function testCachedFindRecastsCacheHits(): void $this->assertSame(1.0, $cached[0]->getAttribute('value')); } + public function testCachedFindDoesNotDoubleDecodeCustomFilters(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache, [ + 'wrapped' => [ + 'encode' => static fn (mixed $value): string => 'encoded:' . $value, + 'decode' => static fn (mixed $value): string => \str_starts_with((string) $value, 'encoded:') + ? \substr((string) $value, 8) + : 'double:' . $value, + ], + ]); + $database->createCollection('secrets', [ + new Document([ + '$id' => 'secret', + 'type' => Database::VAR_STRING, + 'size' => 255, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => ['wrapped'], + ]), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + + $database->createDocument('secrets', new Document([ + '$id' => 'secret-a', + 'secret' => 'value', + ])); + + $queries = [ + Query::orderAsc('$id'), + Query::limit(25), + ]; + + $fresh = $database->cachedFind('secrets', $queries, '_39', ['secrets']); + $cached = $database->cachedFind('secrets', $queries, '_39', ['secrets']); + + $this->assertSame('value', $fresh[0]->getAttribute('secret')); + $this->assertSame('value', $cached[0]->getAttribute('secret')); + } + public function testCachedFindBypassesCacheForRandomOrder(): void { $cache = new HashMemoryCache(); From bcfa5cf25d2f9aaeda0e66c4a14f5c0c7360eb15 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Sat, 20 Jun 2026 13:09:38 +0100 Subject: [PATCH 31/51] Type cached find test filters --- tests/unit/ListCacheTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/ListCacheTest.php b/tests/unit/ListCacheTest.php index 1a17ccaee..59c55c28d 100644 --- a/tests/unit/ListCacheTest.php +++ b/tests/unit/ListCacheTest.php @@ -14,6 +14,9 @@ class ListCacheTest extends TestCase { + /** + * @param array $filters + */ private function createDatabase(Adapter $cache, array $filters = []): Database { $database = new Database(new DatabaseMemory(), new Cache($cache), $filters); From 6f70767b1a33143e5106f31aa6a2d034167a24c6 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Sat, 20 Jun 2026 14:40:25 +0100 Subject: [PATCH 32/51] Revalidate cached find hits --- src/Database/Database.php | 43 ++++++++++-- tests/unit/ListCacheTest.php | 125 ++++++++++++++++++++++++++++------- 2 files changed, 138 insertions(+), 30 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 857cbf541..9891cb108 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8604,7 +8604,7 @@ public function cachedFind( $documents = []; $payload = $this->withCache( - key: $this->getFindCacheKey($collection, $namespace), + key: $this->getFindCacheKey($collectionDocument->getId(), $namespace), callback: function () use ($collection, $queries, $forPermission, &$cacheMiss, &$documents): array { $cacheMiss = true; $documents = $this->find($collection, $queries, $forPermission); @@ -8625,14 +8625,44 @@ public function cachedFind( return []; } + $documentSecurity = $collectionDocument->getAttribute('documentSecurity', false); + $skipAuth = $this->authorization->isValid(new Input($forPermission, $collectionDocument->getPermissionsByType($forPermission))); + + if (!$skipAuth && !$documentSecurity && $collectionDocument->getId() !== self::METADATA) { + throw new AuthorizationException($this->authorization->getDescription()); + } + + $selects = Query::groupByType($queries)['selections']; $documents = []; - foreach ($payload as $document) { - if (!\is_array($document)) { + + // A cached list stores candidate IDs. Refresh each candidate so TTL, + // deletion, and permission changes are respected; callers still own + // purging when writes change which documents match the original query. + foreach ($payload as $payloadDocument) { + if (!\is_array($payloadDocument)) { + continue; + } + + $cachedDocument = $this->createDocumentInstance($collection, $payloadDocument); + if ($cachedDocument->isEmpty()) { + continue; + } + + $document = $this->silent(fn () => $this->getDocument($collection, $cachedDocument->getId(), $selects)); + if ($document->isEmpty()) { continue; } - $document = $this->createDocumentInstance($collection, $document); - $document = $this->casting($collectionDocument, $document); + if (!$skipAuth && $collectionDocument->getId() !== self::METADATA) { + $permissions = [ + ...$collectionDocument->getPermissionsByType($forPermission), + ...($documentSecurity ? $document->getPermissionsByType($forPermission) : []), + ]; + + if (!$this->authorization->isValid(new Input($forPermission, $permissions))) { + continue; + } + } $documents[] = $document; } @@ -8649,6 +8679,9 @@ public function cachedFind( */ public function purgeCachedFind(string $collection, ?string $namespace = null): bool { + $collectionDocument = $this->silent(fn () => $this->getCollection($collection)); + $collection = $collectionDocument->isEmpty() ? $collection : $collectionDocument->getId(); + return $this->cache->purge( $this->getFindCacheKey($collection, $namespace) ); diff --git a/tests/unit/ListCacheTest.php b/tests/unit/ListCacheTest.php index 59c55c28d..275eaaa08 100644 --- a/tests/unit/ListCacheTest.php +++ b/tests/unit/ListCacheTest.php @@ -17,9 +17,9 @@ class ListCacheTest extends TestCase /** * @param array $filters */ - private function createDatabase(Adapter $cache, array $filters = []): Database + private function createDatabase(Adapter $cache, array $filters = [], ?DatabaseMemory $adapter = null): Database { - $database = new Database(new DatabaseMemory(), new Cache($cache), $filters); + $database = new Database($adapter ?? new DatabaseMemory(), new Cache($cache), $filters); $database ->setDatabase('utopiaTests') ->setNamespace('list_cache_' . \uniqid()); @@ -453,41 +453,108 @@ public function testCachedFindBypassesCacheForRandomOrder(): void $this->assertCount(2, $second); } - public function testCachedFindRehydratesNestedDocumentPayloads(): void + public function testCachedFindRevalidatesDocumentPermissionsOnCacheHit(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); - $database->createCollection('parents', permissions: [ + $database->getAuthorization()->skip(function () use ($database): void { + $database->createCollection('secureRules', [ + new Document([ + '$id' => 'projectId', + 'type' => Database::VAR_STRING, + 'size' => 255, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + ], permissions: [ + Permission::create(Role::any()), + ], documentSecurity: true); + + $database->createDocument('secureRules', new Document([ + '$id' => 'rule-a', + '$permissions' => [ + Permission::read(Role::user('user-1')), + Permission::update(Role::any()), + ], + 'projectId' => 'project-a', + ])); + }); + + $database->getAuthorization()->addRole(Role::user('user-1')->toString()); + + $queries = [ + Query::equal('projectId', ['project-a']), + Query::orderAsc('$id'), + Query::limit(25), + ]; + + $first = $database->cachedFind('secureRules', $queries, '_39', ['waf']); + $this->assertCount(1, $first); + + $database->getAuthorization()->skip(function () use ($database): void { + $database->updateDocument('secureRules', 'rule-a', new Document([ + '$permissions' => [ + Permission::read(Role::user('user-2')), + Permission::update(Role::any()), + ], + ])); + }); + + $cached = $database->cachedFind('secureRules', $queries, '_39', ['waf']); + + $this->assertSame([], $cached); + } + + public function testCachedFindFiltersTtlExpiredDocumentsOnCacheHit(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache, adapter: new TtlDatabaseMemory()); + $database->createCollection('ttlRules', [ + new Document([ + '$id' => 'expiresAt', + 'type' => Database::VAR_DATETIME, + 'size' => 0, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + ], [ + new Document([ + '$id' => 'expiresAtTtl', + 'type' => Database::INDEX_TTL, + 'attributes' => ['expiresAt'], + 'lengths' => [], + 'orders' => [], + 'ttl' => 1, + ]), + ], permissions: [ Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), ]); $queries = [ Query::limit(25), ]; - $collection = $database->getCollection('parents'); - $cache->save( - $database->getFindCacheKey('parents', '_39'), - [ - 'value' => [ - [ - '$id' => 'parent-a', - 'child' => [ - '$id' => 'child-a', - '$collection' => 'children', - 'name' => 'Child A', - ], - ], - ], - ], - $database->getFindCacheField($collection, $queries, ['waf']), - ); + $database->createDocument('ttlRules', new Document([ + '$id' => 'rule-a', + 'expiresAt' => \date('c', \time() + 3600), + ])); + + $first = $database->cachedFind('ttlRules', $queries, '_39', ['waf']); + $this->assertCount(1, $first); - $parents = $database->cachedFind('parents', $queries, '_39', ['waf']); + $database->updateDocument('ttlRules', 'rule-a', new Document([ + 'expiresAt' => \date('c', \time() - 10), + ])); + + $cached = $database->cachedFind('ttlRules', $queries, '_39', ['waf']); - $this->assertCount(1, $parents); - $this->assertInstanceOf(Document::class, $parents[0]->getAttribute('child')); - $this->assertSame('child-a', $parents[0]->getAttribute('child')->getId()); + $this->assertSame([], $cached); } } @@ -666,3 +733,11 @@ public function getName(?string $key = null): string return 'json-hash-memory'; } } + +class TtlDatabaseMemory extends DatabaseMemory +{ + public function getSupportForTTLIndexes(): bool + { + return true; + } +} From 9ac837b26841d72afef08f5b9fd2d4e17d6dd545 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Sat, 20 Jun 2026 22:52:09 +0100 Subject: [PATCH 33/51] Clean up cached find semantics --- src/Database/Database.php | 73 +++++++++++++++++------------------- tests/unit/CacheKeyTest.php | 14 ++++--- tests/unit/ListCacheTest.php | 60 +++++++++++++++++++++++------ 3 files changed, 92 insertions(+), 55 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 9891cb108..8e9ef3dea 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8569,11 +8569,13 @@ public function find(string $collection, array $queries = [], string $forPermiss * * The caller owns authorization context. Use Authorization::skip() around * this method for internal reads that should bypass request-user roles. + * The caller owns invalidation and must purge cached find entries after + * writes that can change matching documents or returned document payloads. * * @param string $collection * @param array $queries * @param string|null $namespace - * @param array $roles + * @param array $cacheLabels * @param string $forPermission * @return array * @throws DatabaseException @@ -8585,7 +8587,7 @@ public function cachedFind( string $collection, array $queries = [], ?string $namespace = null, - array $roles = [], + array $cacheLabels = [], string $forPermission = Database::PERMISSION_READ, ): array { foreach ($queries as $query) { @@ -8605,16 +8607,19 @@ public function cachedFind( $payload = $this->withCache( key: $this->getFindCacheKey($collectionDocument->getId(), $namespace), - callback: function () use ($collection, $queries, $forPermission, &$cacheMiss, &$documents): array { + callback: function () use ($collection, $collectionDocument, $queries, $forPermission, &$cacheMiss, &$documents): array { $cacheMiss = true; - $documents = $this->find($collection, $queries, $forPermission); + $documents = $this->filterCachedFindDocuments( + $collectionDocument, + $this->find($collection, $queries, $forPermission), + ); return \array_map( static fn (Document $document): array => $document->getArrayCopy(), $documents, ); }, - hash: $this->getFindCacheField($collectionDocument, $queries, $roles, 'documents', $forPermission), + hash: $this->getFindCacheField($collectionDocument, $queries, $cacheLabels, 'documents', $forPermission), ); if ($cacheMiss) { @@ -8632,42 +8637,32 @@ public function cachedFind( throw new AuthorizationException($this->authorization->getDescription()); } - $selects = Query::groupByType($queries)['selections']; $documents = []; - - // A cached list stores candidate IDs. Refresh each candidate so TTL, - // deletion, and permission changes are respected; callers still own - // purging when writes change which documents match the original query. - foreach ($payload as $payloadDocument) { - if (!\is_array($payloadDocument)) { + foreach ($payload as $document) { + if (!\is_array($document)) { continue; } - $cachedDocument = $this->createDocumentInstance($collection, $payloadDocument); - if ($cachedDocument->isEmpty()) { - continue; - } - - $document = $this->silent(fn () => $this->getDocument($collection, $cachedDocument->getId(), $selects)); - if ($document->isEmpty()) { - continue; - } - - if (!$skipAuth && $collectionDocument->getId() !== self::METADATA) { - $permissions = [ - ...$collectionDocument->getPermissionsByType($forPermission), - ...($documentSecurity ? $document->getPermissionsByType($forPermission) : []), - ]; - - if (!$this->authorization->isValid(new Input($forPermission, $permissions))) { - continue; - } - } + $document = $this->createDocumentInstance($collection, $document); + $document = $this->casting($collectionDocument, $document); $documents[] = $document; } - return $documents; + return $this->filterCachedFindDocuments($collectionDocument, $documents); + } + + /** + * @param Document $collection + * @param array $documents + * @return array + */ + private function filterCachedFindDocuments(Document $collection, array $documents): array + { + return \array_values(\array_filter( + $documents, + fn (Document $document): bool => !$this->isTtlExpired($collection, $document), + )); } /** @@ -9668,7 +9663,7 @@ public function getFindCacheKey(string $collectionId, ?string $namespace = null) * * @param Document|null $collection * @param array $queries - * @param array $roles + * @param array $cacheLabels * @param string $field * @param string $forPermission * @return string @@ -9676,14 +9671,14 @@ public function getFindCacheKey(string $collectionId, ?string $namespace = null) public function getFindCacheField( ?Document $collection = null, array $queries = [], - array $roles = [], + array $cacheLabels = [], string $field = 'documents', string $forPermission = self::PERMISSION_READ, ): string { $this->checkQueryTypes($queries); - $roles = \array_values(\array_unique($roles)); - \sort($roles); + $cacheLabels = \array_values(\array_unique($cacheLabels)); + \sort($cacheLabels); $authorizationRoles = \array_values(\array_unique($this->authorization->getRoles())); \sort($authorizationRoles); @@ -9707,7 +9702,7 @@ public function getFindCacheField( return \sprintf( '%s:%s:%s:%s', $this->getFindCacheSchemaHash($collection), - \md5(\json_encode($roles) ?: ''), + \md5(\json_encode($cacheLabels) ?: ''), \md5(\json_encode($queryPayload) ?: ''), $field, ); @@ -9767,6 +9762,8 @@ private function getFindCacheSchemaHash(?Document $collection): string return \md5( \json_encode($collection->getAttribute('attributes', [])) . \json_encode($collection->getAttribute('indexes', [])) + . \json_encode($collection->getAttribute('$permissions', [])) + . \json_encode($collection->getAttribute('documentSecurity', false)) ); } diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index ba2f6d750..af8c2eb9c 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -189,6 +189,8 @@ public function testFindCacheFieldUsesListCacheShape(): void $schemaHash = \md5( (\json_encode($collection->getAttribute('attributes', [])) ?: '') . (\json_encode($collection->getAttribute('indexes', [])) ?: '') + . (\json_encode($collection->getAttribute('$permissions', [])) ?: '') + . (\json_encode($collection->getAttribute('documentSecurity', false)) ?: '') ); $field = $db->getFindCacheField($collection, $queries, ['waf']); @@ -207,7 +209,7 @@ public function testFindCacheFieldChangesWithInputs(): void 'indexes' => [], ]), [Query::limit(10)], - ['role-a'], + ['cache-label-a'], ); $this->assertNotSame( @@ -218,13 +220,13 @@ public function testFindCacheFieldChangesWithInputs(): void 'indexes' => [], ]), [Query::limit(10)], - ['role-a'], + ['cache-label-a'], ), ); - $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(20)], ['role-a'])); - $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(10)], ['role-b'])); - $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(10)], ['role-a'], 'documents', Database::PERMISSION_UPDATE)); - $this->assertStringEndsWith(':total', $db->getFindCacheField(null, [Query::limit(10)], ['role-a'], 'total')); + $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(20)], ['cache-label-a'])); + $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(10)], ['cache-label-b'])); + $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(10)], ['cache-label-a'], 'documents', Database::PERMISSION_UPDATE)); + $this->assertStringEndsWith(':total', $db->getFindCacheField(null, [Query::limit(10)], ['cache-label-a'], 'total')); } public function testFindCacheFieldChangesWithActiveAuthorizationContext(): void diff --git a/tests/unit/ListCacheTest.php b/tests/unit/ListCacheTest.php index 275eaaa08..f0e6302ca 100644 --- a/tests/unit/ListCacheTest.php +++ b/tests/unit/ListCacheTest.php @@ -243,7 +243,7 @@ public function testCachedFindUsesCacheUntilPurged(): void )); } - public function testCachedFindSeparatesEntriesByRoles(): void + public function testCachedFindSeparatesEntriesByCacheLabels(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -283,8 +283,8 @@ public function testCachedFindSeparatesEntriesByRoles(): void $cached = $database->cachedFind('wafRules', $queries, '_39', ['waf']); $this->assertCount(1, $cached); - $roleSeparated = $database->cachedFind('wafRules', $queries, '_39', ['manager']); - $this->assertCount(2, $roleSeparated); + $labelSeparated = $database->cachedFind('wafRules', $queries, '_39', ['manager']); + $this->assertCount(2, $labelSeparated); } public function testCachedFindSeparatesEntriesByPermissionMode(): void @@ -453,7 +453,7 @@ public function testCachedFindBypassesCacheForRandomOrder(): void $this->assertCount(2, $second); } - public function testCachedFindRevalidatesDocumentPermissionsOnCacheHit(): void + public function testCachedFindReliesOnPurgeForDocumentSecurityCollections(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -504,7 +504,12 @@ public function testCachedFindRevalidatesDocumentPermissionsOnCacheHit(): void $cached = $database->cachedFind('secureRules', $queries, '_39', ['waf']); - $this->assertSame([], $cached); + $this->assertCount(1, $cached); + + $this->assertTrue($database->purgeCachedFind('secureRules', '_39')); + + $fresh = $database->cachedFind('secureRules', $queries, '_39', ['waf']); + $this->assertSame([], $fresh); } public function testCachedFindFiltersTtlExpiredDocumentsOnCacheHit(): void @@ -542,20 +547,53 @@ public function testCachedFindFiltersTtlExpiredDocumentsOnCacheHit(): void $database->createDocument('ttlRules', new Document([ '$id' => 'rule-a', - 'expiresAt' => \date('c', \time() + 3600), + 'expiresAt' => \date('c', \time() - 10), ])); $first = $database->cachedFind('ttlRules', $queries, '_39', ['waf']); - $this->assertCount(1, $first); - - $database->updateDocument('ttlRules', 'rule-a', new Document([ - 'expiresAt' => \date('c', \time() - 10), - ])); + $this->assertSame([], $first); $cached = $database->cachedFind('ttlRules', $queries, '_39', ['waf']); $this->assertSame([], $cached); } + + public function testCachedFindRehydratesNestedDocumentPayloads(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + $database->createCollection('parents', permissions: [ + Permission::read(Role::any()), + ]); + + $queries = [ + Query::limit(25), + ]; + + $collection = $database->getCollection('parents'); + $cache->save( + $database->getFindCacheKey($collection->getId(), '_39'), + [ + 'value' => [ + [ + '$id' => 'parent-a', + 'child' => [ + '$id' => 'child-a', + '$collection' => 'children', + 'name' => 'Child A', + ], + ], + ], + ], + $database->getFindCacheField($collection, $queries, ['waf']), + ); + + $parents = $database->cachedFind('parents', $queries, '_39', ['waf']); + + $this->assertCount(1, $parents); + $this->assertInstanceOf(Document::class, $parents[0]->getAttribute('child')); + $this->assertSame('child-a', $parents[0]->getAttribute('child')->getId()); + } } class HashMemoryCache implements Adapter From 7d45e6c0dbeb9c8645465ce715efc7d34759fad5 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Sat, 20 Jun 2026 23:25:12 +0100 Subject: [PATCH 34/51] Simplify cached find keying --- src/Database/Database.php | 34 ++------- tests/unit/CacheKeyTest.php | 21 +++--- tests/unit/ListCacheTest.php | 132 +++++------------------------------ 3 files changed, 31 insertions(+), 156 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 8e9ef3dea..bcaccdf4f 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8575,7 +8575,6 @@ public function find(string $collection, array $queries = [], string $forPermiss * @param string $collection * @param array $queries * @param string|null $namespace - * @param array $cacheLabels * @param string $forPermission * @return array * @throws DatabaseException @@ -8587,7 +8586,6 @@ public function cachedFind( string $collection, array $queries = [], ?string $namespace = null, - array $cacheLabels = [], string $forPermission = Database::PERMISSION_READ, ): array { foreach ($queries as $query) { @@ -8607,19 +8605,16 @@ public function cachedFind( $payload = $this->withCache( key: $this->getFindCacheKey($collectionDocument->getId(), $namespace), - callback: function () use ($collection, $collectionDocument, $queries, $forPermission, &$cacheMiss, &$documents): array { + callback: function () use ($collection, $queries, $forPermission, &$cacheMiss, &$documents): array { $cacheMiss = true; - $documents = $this->filterCachedFindDocuments( - $collectionDocument, - $this->find($collection, $queries, $forPermission), - ); + $documents = $this->find($collection, $queries, $forPermission); return \array_map( static fn (Document $document): array => $document->getArrayCopy(), $documents, ); }, - hash: $this->getFindCacheField($collectionDocument, $queries, $cacheLabels, 'documents', $forPermission), + hash: $this->getFindCacheField($collectionDocument, $queries, 'documents', $forPermission), ); if ($cacheMiss) { @@ -8649,20 +8644,7 @@ public function cachedFind( $documents[] = $document; } - return $this->filterCachedFindDocuments($collectionDocument, $documents); - } - - /** - * @param Document $collection - * @param array $documents - * @return array - */ - private function filterCachedFindDocuments(Document $collection, array $documents): array - { - return \array_values(\array_filter( - $documents, - fn (Document $document): bool => !$this->isTtlExpired($collection, $document), - )); + return $documents; } /** @@ -9663,7 +9645,6 @@ public function getFindCacheKey(string $collectionId, ?string $namespace = null) * * @param Document|null $collection * @param array $queries - * @param array $cacheLabels * @param string $field * @param string $forPermission * @return string @@ -9671,15 +9652,11 @@ public function getFindCacheKey(string $collectionId, ?string $namespace = null) public function getFindCacheField( ?Document $collection = null, array $queries = [], - array $cacheLabels = [], string $field = 'documents', string $forPermission = self::PERMISSION_READ, ): string { $this->checkQueryTypes($queries); - $cacheLabels = \array_values(\array_unique($cacheLabels)); - \sort($cacheLabels); - $authorizationRoles = \array_values(\array_unique($this->authorization->getRoles())); \sort($authorizationRoles); @@ -9700,9 +9677,8 @@ public function getFindCacheField( ]; return \sprintf( - '%s:%s:%s:%s', + '%s:%s:%s', $this->getFindCacheSchemaHash($collection), - \md5(\json_encode($cacheLabels) ?: ''), \md5(\json_encode($queryPayload) ?: ''), $field, ); diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index af8c2eb9c..74c329cbf 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -192,11 +192,11 @@ public function testFindCacheFieldUsesListCacheShape(): void . (\json_encode($collection->getAttribute('$permissions', [])) ?: '') . (\json_encode($collection->getAttribute('documentSecurity', false)) ?: '') ); - $field = $db->getFindCacheField($collection, $queries, ['waf']); + $field = $db->getFindCacheField($collection, $queries); - $this->assertStringStartsWith("{$schemaHash}:".\md5(\json_encode(['waf']) ?: '').':', $field); + $this->assertStringStartsWith("{$schemaHash}:", $field); $this->assertStringEndsWith(':documents', $field); - $this->assertSame(3, \substr_count($field, ':')); + $this->assertSame(2, \substr_count($field, ':')); } public function testFindCacheFieldChangesWithInputs(): void @@ -209,7 +209,6 @@ public function testFindCacheFieldChangesWithInputs(): void 'indexes' => [], ]), [Query::limit(10)], - ['cache-label-a'], ); $this->assertNotSame( @@ -220,31 +219,29 @@ public function testFindCacheFieldChangesWithInputs(): void 'indexes' => [], ]), [Query::limit(10)], - ['cache-label-a'], ), ); - $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(20)], ['cache-label-a'])); - $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(10)], ['cache-label-b'])); - $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(10)], ['cache-label-a'], 'documents', Database::PERMISSION_UPDATE)); - $this->assertStringEndsWith(':total', $db->getFindCacheField(null, [Query::limit(10)], ['cache-label-a'], 'total')); + $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(20)])); + $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(10)], forPermission: Database::PERMISSION_UPDATE)); + $this->assertStringEndsWith(':total', $db->getFindCacheField(null, [Query::limit(10)], 'total')); } public function testFindCacheFieldChangesWithActiveAuthorizationContext(): void { $db = $this->createDatabase(); - $field = $db->getFindCacheField(null, [Query::limit(10)], ['waf']); + $field = $db->getFindCacheField(null, [Query::limit(10)]); $this->assertNotSame( $field, - $db->getAuthorization()->skip(fn () => $db->getFindCacheField(null, [Query::limit(10)], ['waf'])), + $db->getAuthorization()->skip(fn () => $db->getFindCacheField(null, [Query::limit(10)])), ); $db->getAuthorization()->addRole('user:1'); $this->assertNotSame( $field, - $db->getFindCacheField(null, [Query::limit(10)], ['waf']), + $db->getFindCacheField(null, [Query::limit(10)]), ); } diff --git a/tests/unit/ListCacheTest.php b/tests/unit/ListCacheTest.php index f0e6302ca..a7e8e0d9c 100644 --- a/tests/unit/ListCacheTest.php +++ b/tests/unit/ListCacheTest.php @@ -220,7 +220,7 @@ public function testCachedFindUsesCacheUntilPurged(): void Query::limit(25), ]; - $first = $database->cachedFind('wafRules', $queries, '_39', ['waf']); + $first = $database->cachedFind('wafRules', $queries, '_39'); $this->assertCount(1, $first); $this->assertSame('rule-a', $first[0]->getId()); @@ -229,13 +229,13 @@ public function testCachedFindUsesCacheUntilPurged(): void 'projectId' => 'project-a', ])); - $cached = $database->cachedFind('wafRules', $queries, '_39', ['waf']); + $cached = $database->cachedFind('wafRules', $queries, '_39'); $this->assertCount(1, $cached); $this->assertSame('rule-a', $cached[0]->getId()); $this->assertTrue($database->purgeCachedFind('wafRules', '_39')); - $fresh = $database->cachedFind('wafRules', $queries, '_39', ['waf']); + $fresh = $database->cachedFind('wafRules', $queries, '_39'); $this->assertCount(2, $fresh); $this->assertSame(['rule-a', 'rule-b'], \array_map( static fn (Document $document): string => $document->getId(), @@ -243,50 +243,6 @@ public function testCachedFindUsesCacheUntilPurged(): void )); } - public function testCachedFindSeparatesEntriesByCacheLabels(): void - { - $cache = new HashMemoryCache(); - $database = $this->createDatabase($cache); - $database->createCollection('wafRules', [ - new Document([ - '$id' => 'projectId', - 'type' => Database::VAR_STRING, - 'size' => 255, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - ], permissions: [ - Permission::read(Role::any()), - Permission::create(Role::any()), - ]); - - $database->createDocument('wafRules', new Document([ - '$id' => 'rule-a', - 'projectId' => 'project-a', - ])); - - $queries = [ - Query::equal('projectId', ['project-a']), - Query::orderAsc('$id'), - Query::limit(25), - ]; - - $database->cachedFind('wafRules', $queries, '_39', ['waf']); - - $database->createDocument('wafRules', new Document([ - '$id' => 'rule-b', - 'projectId' => 'project-a', - ])); - - $cached = $database->cachedFind('wafRules', $queries, '_39', ['waf']); - $this->assertCount(1, $cached); - - $labelSeparated = $database->cachedFind('wafRules', $queries, '_39', ['manager']); - $this->assertCount(2, $labelSeparated); - } - public function testCachedFindSeparatesEntriesByPermissionMode(): void { $cache = new HashMemoryCache(); @@ -318,17 +274,17 @@ public function testCachedFindSeparatesEntriesByPermissionMode(): void Query::limit(25), ]; - $database->cachedFind('wafRules', $queries, '_39', ['waf'], Database::PERMISSION_READ); + $database->cachedFind('wafRules', $queries, '_39', forPermission: Database::PERMISSION_READ); $database->createDocument('wafRules', new Document([ '$id' => 'rule-b', 'projectId' => 'project-a', ])); - $cached = $database->cachedFind('wafRules', $queries, '_39', ['waf'], Database::PERMISSION_READ); + $cached = $database->cachedFind('wafRules', $queries, '_39', forPermission: Database::PERMISSION_READ); $this->assertCount(1, $cached); - $permissionSeparated = $database->cachedFind('wafRules', $queries, '_39', ['waf'], Database::PERMISSION_UPDATE); + $permissionSeparated = $database->cachedFind('wafRules', $queries, '_39', forPermission: Database::PERMISSION_UPDATE); $this->assertCount(2, $permissionSeparated); } @@ -361,8 +317,8 @@ public function testCachedFindRecastsCacheHits(): void Query::limit(25), ]; - $fresh = $database->cachedFind('metrics', $queries, '_39', ['metrics']); - $cached = $database->cachedFind('metrics', $queries, '_39', ['metrics']); + $fresh = $database->cachedFind('metrics', $queries, '_39'); + $cached = $database->cachedFind('metrics', $queries, '_39'); $this->assertSame(1.0, $fresh[0]->getAttribute('value')); $this->assertSame(1.0, $cached[0]->getAttribute('value')); @@ -404,8 +360,8 @@ public function testCachedFindDoesNotDoubleDecodeCustomFilters(): void Query::limit(25), ]; - $fresh = $database->cachedFind('secrets', $queries, '_39', ['secrets']); - $cached = $database->cachedFind('secrets', $queries, '_39', ['secrets']); + $fresh = $database->cachedFind('secrets', $queries, '_39'); + $cached = $database->cachedFind('secrets', $queries, '_39'); $this->assertSame('value', $fresh[0]->getAttribute('secret')); $this->assertSame('value', $cached[0]->getAttribute('secret')); @@ -441,7 +397,7 @@ public function testCachedFindBypassesCacheForRandomOrder(): void Query::limit(25), ]; - $first = $database->cachedFind('wafRules', $queries, '_39', ['waf']); + $first = $database->cachedFind('wafRules', $queries, '_39'); $this->assertCount(1, $first); $database->createDocument('wafRules', new Document([ @@ -449,7 +405,7 @@ public function testCachedFindBypassesCacheForRandomOrder(): void 'projectId' => 'project-a', ])); - $second = $database->cachedFind('wafRules', $queries, '_39', ['waf']); + $second = $database->cachedFind('wafRules', $queries, '_39'); $this->assertCount(2, $second); } @@ -490,7 +446,7 @@ public function testCachedFindReliesOnPurgeForDocumentSecurityCollections(): voi Query::limit(25), ]; - $first = $database->cachedFind('secureRules', $queries, '_39', ['waf']); + $first = $database->cachedFind('secureRules', $queries, '_39'); $this->assertCount(1, $first); $database->getAuthorization()->skip(function () use ($database): void { @@ -502,62 +458,16 @@ public function testCachedFindReliesOnPurgeForDocumentSecurityCollections(): voi ])); }); - $cached = $database->cachedFind('secureRules', $queries, '_39', ['waf']); + $cached = $database->cachedFind('secureRules', $queries, '_39'); $this->assertCount(1, $cached); $this->assertTrue($database->purgeCachedFind('secureRules', '_39')); - $fresh = $database->cachedFind('secureRules', $queries, '_39', ['waf']); + $fresh = $database->cachedFind('secureRules', $queries, '_39'); $this->assertSame([], $fresh); } - public function testCachedFindFiltersTtlExpiredDocumentsOnCacheHit(): void - { - $cache = new HashMemoryCache(); - $database = $this->createDatabase($cache, adapter: new TtlDatabaseMemory()); - $database->createCollection('ttlRules', [ - new Document([ - '$id' => 'expiresAt', - 'type' => Database::VAR_DATETIME, - 'size' => 0, - 'required' => false, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]), - ], [ - new Document([ - '$id' => 'expiresAtTtl', - 'type' => Database::INDEX_TTL, - 'attributes' => ['expiresAt'], - 'lengths' => [], - 'orders' => [], - 'ttl' => 1, - ]), - ], permissions: [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - ]); - - $queries = [ - Query::limit(25), - ]; - - $database->createDocument('ttlRules', new Document([ - '$id' => 'rule-a', - 'expiresAt' => \date('c', \time() - 10), - ])); - - $first = $database->cachedFind('ttlRules', $queries, '_39', ['waf']); - $this->assertSame([], $first); - - $cached = $database->cachedFind('ttlRules', $queries, '_39', ['waf']); - - $this->assertSame([], $cached); - } - public function testCachedFindRehydratesNestedDocumentPayloads(): void { $cache = new HashMemoryCache(); @@ -585,10 +495,10 @@ public function testCachedFindRehydratesNestedDocumentPayloads(): void ], ], ], - $database->getFindCacheField($collection, $queries, ['waf']), + $database->getFindCacheField($collection, $queries), ); - $parents = $database->cachedFind('parents', $queries, '_39', ['waf']); + $parents = $database->cachedFind('parents', $queries, '_39'); $this->assertCount(1, $parents); $this->assertInstanceOf(Document::class, $parents[0]->getAttribute('child')); @@ -771,11 +681,3 @@ public function getName(?string $key = null): string return 'json-hash-memory'; } } - -class TtlDatabaseMemory extends DatabaseMemory -{ - public function getSupportForTTLIndexes(): bool - { - return true; - } -} From ad49dba72a22dea06aa598d6610b31392999fb61 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Sun, 21 Jun 2026 02:49:10 +0100 Subject: [PATCH 35/51] Align cached find key format --- src/Database/Database.php | 2 +- tests/unit/CacheKeyTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index bcaccdf4f..218aadab9 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -9631,7 +9631,7 @@ public function getFindCacheKey(string $collectionId, ?string $namespace = null) : ''; return \sprintf( - '%s-cache:%s:%s:%s:collection:%s:find', + '%s-cache-%s:%s:%s:collection:%s:find', $this->cacheName, $hostname, $namespace ?? $this->getNamespace(), diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index 74c329cbf..db09c7dfa 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -146,7 +146,7 @@ public function testFindCacheKeyUsesListCacheShapeWithFindSuffix(): void $db = new Database($adapter, new Cache(new None()), []); $this->assertSame( - 'default-cache:mysql-console:_39::collection:ttl_cache_table:find', + 'default-cache-mysql-console:_39::collection:ttl_cache_table:find', $db->getFindCacheKey('ttl_cache_table'), ); } @@ -162,7 +162,7 @@ public function testFindCacheKeyCanOverrideNamespaceSegment(): void $db = new Database($adapter, new Cache(new None()), []); $this->assertSame( - 'default-cache:mysql-console:_39::collection:wafrules:find', + 'default-cache-mysql-console:_39::collection:wafrules:find', $db->getFindCacheKey('wafrules', '_39'), ); } From bc4572ac0abe2d9affb49b4bbf94f2afffe5e759 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Sun, 21 Jun 2026 03:26:02 +0100 Subject: [PATCH 36/51] Refresh invalid cached find payloads --- src/Database/Database.php | 57 ++++++++++++++++++-- tests/unit/ListCacheTest.php | 100 +++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 4 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 218aadab9..783f3d99d 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8602,9 +8602,11 @@ public function cachedFind( $cacheMiss = false; $documents = []; + $cacheKey = $this->getFindCacheKey($collectionDocument->getId(), $namespace); + $cacheHash = $this->getFindCacheField($collectionDocument, $queries, 'documents', $forPermission); $payload = $this->withCache( - key: $this->getFindCacheKey($collectionDocument->getId(), $namespace), + key: $cacheKey, callback: function () use ($collection, $queries, $forPermission, &$cacheMiss, &$documents): array { $cacheMiss = true; $documents = $this->find($collection, $queries, $forPermission); @@ -8614,7 +8616,7 @@ public function cachedFind( $documents, ); }, - hash: $this->getFindCacheField($collectionDocument, $queries, 'documents', $forPermission), + hash: $cacheHash, ); if ($cacheMiss) { @@ -8622,7 +8624,7 @@ public function cachedFind( } if (!\is_array($payload)) { - return []; + return $this->refreshCachedFind($cacheKey, $cacheHash, $collection, $queries, $forPermission); } $documentSecurity = $collectionDocument->getAttribute('documentSecurity', false); @@ -8635,7 +8637,7 @@ public function cachedFind( $documents = []; foreach ($payload as $document) { if (!\is_array($document)) { - continue; + return $this->refreshCachedFind($cacheKey, $cacheHash, $collection, $queries, $forPermission); } $document = $this->createDocumentInstance($collection, $document); @@ -8647,6 +8649,53 @@ public function cachedFind( return $documents; } + /** + * Refresh a cached find field after detecting an invalid cached payload. + * + * @param string $cacheKey + * @param string $cacheHash + * @param string $collection + * @param array $queries + * @param string $forPermission + * @return array + * @throws DatabaseException + * @throws QueryException + * @throws TimeoutException + * @throws Exception + */ + private function refreshCachedFind( + string $cacheKey, + string $cacheHash, + string $collection, + array $queries, + string $forPermission, + ): array { + try { + $this->cache->purge($cacheKey, $cacheHash); + } catch (Throwable $e) { + Console::warning('Warning: Failed to purge invalid cached find payload: ' . $e->getMessage()); + } + + $documents = $this->find($collection, $queries, $forPermission); + + try { + $this->cache->save( + $cacheKey, + [ + 'value' => \array_map( + static fn (Document $document): array => $document->getArrayCopy(), + $documents, + ), + ], + $cacheHash, + ); + } catch (Throwable $e) { + Console::warning('Warning: Failed to save refreshed cached find payload: ' . $e->getMessage()); + } + + return $documents; + } + /** * Purge all cached find entries for a collection namespace. * diff --git a/tests/unit/ListCacheTest.php b/tests/unit/ListCacheTest.php index a7e8e0d9c..4d892ba3d 100644 --- a/tests/unit/ListCacheTest.php +++ b/tests/unit/ListCacheTest.php @@ -504,6 +504,106 @@ public function testCachedFindRehydratesNestedDocumentPayloads(): void $this->assertInstanceOf(Document::class, $parents[0]->getAttribute('child')); $this->assertSame('child-a', $parents[0]->getAttribute('child')->getId()); } + + public function testCachedFindRefreshesInvalidPayload(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + $database->createCollection('wafRules', [ + new Document([ + '$id' => 'projectId', + 'type' => Database::VAR_STRING, + 'size' => 255, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + + $database->createDocument('wafRules', new Document([ + '$id' => 'rule-a', + 'projectId' => 'project-a', + ])); + + $queries = [ + Query::equal('projectId', ['project-a']), + Query::orderAsc('$id'), + Query::limit(25), + ]; + + $collection = $database->getCollection('wafRules'); + $cache->save( + $database->getFindCacheKey($collection->getId(), '_39'), + ['value' => 'invalid'], + $database->getFindCacheField($collection, $queries), + ); + + $rules = $database->cachedFind('wafRules', $queries, '_39'); + + $this->assertCount(1, $rules); + $this->assertSame('rule-a', $rules[0]->getId()); + } + + public function testCachedFindRefreshesInvalidPayloadEntry(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + $database->createCollection('wafRules', [ + new Document([ + '$id' => 'projectId', + 'type' => Database::VAR_STRING, + 'size' => 255, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + + $database->createDocument('wafRules', new Document([ + '$id' => 'rule-a', + 'projectId' => 'project-a', + ])); + $database->createDocument('wafRules', new Document([ + '$id' => 'rule-b', + 'projectId' => 'project-a', + ])); + + $queries = [ + Query::equal('projectId', ['project-a']), + Query::orderAsc('$id'), + Query::limit(25), + ]; + + $collection = $database->getCollection('wafRules'); + $cache->save( + $database->getFindCacheKey($collection->getId(), '_39'), + [ + 'value' => [ + [ + '$id' => 'rule-a', + 'projectId' => 'project-a', + ], + 'invalid', + ], + ], + $database->getFindCacheField($collection, $queries), + ); + + $rules = $database->cachedFind('wafRules', $queries, '_39'); + + $this->assertSame(['rule-a', 'rule-b'], \array_map( + static fn (Document $document): string => $document->getId(), + $rules, + )); + } } class HashMemoryCache implements Adapter From 0677d4aa1c16b57d3c1812be178cc8fb84377f6f Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 22 Jun 2026 06:08:20 +0100 Subject: [PATCH 37/51] Make PDOStatement compatible with Swoole proxy --- src/Database/Connection.php | 5 +- src/Database/PDOStatement.php | 102 ++++++++++++++++++++++++++++++-- tests/unit/PDOStatementTest.php | 8 ++- 3 files changed, 107 insertions(+), 8 deletions(-) diff --git a/src/Database/Connection.php b/src/Database/Connection.php index 474d10a7f..daf7d1410 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -10,7 +10,8 @@ class Connection * @var array */ protected static array $errors = [ - 'Max connect timeout reached' + 'Max connect timeout reached', + 'server has gone away', ]; /** @@ -21,7 +22,7 @@ class Connection */ public static function hasError(\Throwable $e): bool { - if (DetectsLostConnections::causedByLostConnection($e)) { + if (\class_exists(DetectsLostConnections::class) && DetectsLostConnections::causedByLostConnection($e)) { return true; } diff --git a/src/Database/PDOStatement.php b/src/Database/PDOStatement.php index 5dbfc4d68..f97a236e0 100644 --- a/src/Database/PDOStatement.php +++ b/src/Database/PDOStatement.php @@ -19,7 +19,7 @@ * @mixin \PDOStatement * @implements \IteratorAggregate */ -class PDOStatement implements \IteratorAggregate +class PDOStatement extends \PDOStatement implements \IteratorAggregate { /** * @var array @@ -94,9 +94,99 @@ public function __clone(): void * Preserve \PDOStatement's native iterability (foreach over rows), which * does not route through __call(). */ - public function getIterator(): \Traversable + public function getIterator(): \Iterator { - return $this->statement; + $iterator = $this->statement->getIterator(); + + if (!$iterator instanceof \Iterator) { + throw new \RuntimeException('PDOStatement iterator is invalid.'); + } + + return $iterator; + } + + /** + * @param array|null $params + */ + public function execute(?array $params = null): bool + { + try { + return $this->statement->execute($params); + } catch (\Throwable $e) { + if ($this->pdo->inTransaction() || !Connection::hasError($e)) { + throw $e; + } + + Console::warning('[Database] ' . $e->getMessage()); + Console::warning('[Database] Lost connection detected. Re-preparing statement...'); + + $this->reprepare(); + + return $this->statement->execute($params); + } + } + + public function fetch(int $mode = \PDO::FETCH_DEFAULT, int $cursorOrientation = \PDO::FETCH_ORI_NEXT, int $cursorOffset = 0): mixed + { + return $this->statement->fetch($mode, $cursorOrientation, $cursorOffset); + } + + /** + * @return array + */ + public function fetchAll(int $mode = \PDO::FETCH_DEFAULT, mixed ...$args): array + { + return $this->statement->fetchAll($mode, ...$args); + } + + public function fetchColumn(int $column = 0): mixed + { + return $this->statement->fetchColumn($column); + } + + public function fetchObject(?string $class = 'stdClass', array $constructorArgs = []): object|false + { + return $this->statement->fetchObject($class, $constructorArgs); + } + + public function rowCount(): int + { + return $this->statement->rowCount(); + } + + public function closeCursor(): bool + { + return $this->statement->closeCursor(); + } + + public function columnCount(): int + { + return $this->statement->columnCount(); + } + + public function errorCode(): ?string + { + return $this->statement->errorCode(); + } + + /** + * @return array + */ + public function errorInfo(): array + { + return $this->statement->errorInfo(); + } + + public function debugDumpParams(): ?bool + { + $this->statement->debugDumpParams(); + + return null; + } + + public function nextRowset(): bool + { + return $this->statement->nextRowset(); } /** @@ -137,11 +227,13 @@ public function setAttribute(int $attribute, mixed $value): bool return $this->statement->setAttribute($attribute, $value); } - public function setFetchMode(int $mode, mixed ...$args): bool + public function setFetchMode(int $mode, mixed ...$args): true { $this->fetchMode = [$mode, ...$args]; - return $this->statement->setFetchMode($mode, ...$args); + $this->statement->setFetchMode($mode, ...$args); + + return true; } public function bindValue(int|string $param, mixed $value, int $type = \PDO::PARAM_STR): bool diff --git a/tests/unit/PDOStatementTest.php b/tests/unit/PDOStatementTest.php index 8bd8f280b..d3f3af8e3 100644 --- a/tests/unit/PDOStatementTest.php +++ b/tests/unit/PDOStatementTest.php @@ -126,11 +126,17 @@ public function testIsIterableAndDelegatesIterationToTheStatement(): void { $pdo = $this->pdoMock(inTransaction: false); $statement = $this->statementMock(); + $iterator = new \ArrayIterator([]); + + $statement->expects($this->once()) + ->method('getIterator') + ->willReturn($iterator); $wrapper = new PDOStatement($pdo, $statement, 'SELECT 1'); $this->assertInstanceOf(\IteratorAggregate::class, $wrapper); - $this->assertSame($statement, $wrapper->getIterator()); + $this->assertInstanceOf(\PDOStatement::class, $wrapper); + $this->assertSame($iterator, $wrapper->getIterator()); } public function testDoesNotReconnectForNonExecuteMethods(): void From dbaa3b3fd87e10bc3deb0b448e6751fd1eddaf86 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 22 Jun 2026 06:29:53 +0100 Subject: [PATCH 38/51] Revert "Make PDOStatement compatible with Swoole proxy" This reverts commit 0677d4aa1c16b57d3c1812be178cc8fb84377f6f. --- src/Database/Connection.php | 5 +- src/Database/PDOStatement.php | 102 ++------------------------------ tests/unit/PDOStatementTest.php | 8 +-- 3 files changed, 8 insertions(+), 107 deletions(-) diff --git a/src/Database/Connection.php b/src/Database/Connection.php index daf7d1410..474d10a7f 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -10,8 +10,7 @@ class Connection * @var array */ protected static array $errors = [ - 'Max connect timeout reached', - 'server has gone away', + 'Max connect timeout reached' ]; /** @@ -22,7 +21,7 @@ class Connection */ public static function hasError(\Throwable $e): bool { - if (\class_exists(DetectsLostConnections::class) && DetectsLostConnections::causedByLostConnection($e)) { + if (DetectsLostConnections::causedByLostConnection($e)) { return true; } diff --git a/src/Database/PDOStatement.php b/src/Database/PDOStatement.php index f97a236e0..5dbfc4d68 100644 --- a/src/Database/PDOStatement.php +++ b/src/Database/PDOStatement.php @@ -19,7 +19,7 @@ * @mixin \PDOStatement * @implements \IteratorAggregate */ -class PDOStatement extends \PDOStatement implements \IteratorAggregate +class PDOStatement implements \IteratorAggregate { /** * @var array @@ -94,99 +94,9 @@ public function __clone(): void * Preserve \PDOStatement's native iterability (foreach over rows), which * does not route through __call(). */ - public function getIterator(): \Iterator + public function getIterator(): \Traversable { - $iterator = $this->statement->getIterator(); - - if (!$iterator instanceof \Iterator) { - throw new \RuntimeException('PDOStatement iterator is invalid.'); - } - - return $iterator; - } - - /** - * @param array|null $params - */ - public function execute(?array $params = null): bool - { - try { - return $this->statement->execute($params); - } catch (\Throwable $e) { - if ($this->pdo->inTransaction() || !Connection::hasError($e)) { - throw $e; - } - - Console::warning('[Database] ' . $e->getMessage()); - Console::warning('[Database] Lost connection detected. Re-preparing statement...'); - - $this->reprepare(); - - return $this->statement->execute($params); - } - } - - public function fetch(int $mode = \PDO::FETCH_DEFAULT, int $cursorOrientation = \PDO::FETCH_ORI_NEXT, int $cursorOffset = 0): mixed - { - return $this->statement->fetch($mode, $cursorOrientation, $cursorOffset); - } - - /** - * @return array - */ - public function fetchAll(int $mode = \PDO::FETCH_DEFAULT, mixed ...$args): array - { - return $this->statement->fetchAll($mode, ...$args); - } - - public function fetchColumn(int $column = 0): mixed - { - return $this->statement->fetchColumn($column); - } - - public function fetchObject(?string $class = 'stdClass', array $constructorArgs = []): object|false - { - return $this->statement->fetchObject($class, $constructorArgs); - } - - public function rowCount(): int - { - return $this->statement->rowCount(); - } - - public function closeCursor(): bool - { - return $this->statement->closeCursor(); - } - - public function columnCount(): int - { - return $this->statement->columnCount(); - } - - public function errorCode(): ?string - { - return $this->statement->errorCode(); - } - - /** - * @return array - */ - public function errorInfo(): array - { - return $this->statement->errorInfo(); - } - - public function debugDumpParams(): ?bool - { - $this->statement->debugDumpParams(); - - return null; - } - - public function nextRowset(): bool - { - return $this->statement->nextRowset(); + return $this->statement; } /** @@ -227,13 +137,11 @@ public function setAttribute(int $attribute, mixed $value): bool return $this->statement->setAttribute($attribute, $value); } - public function setFetchMode(int $mode, mixed ...$args): true + public function setFetchMode(int $mode, mixed ...$args): bool { $this->fetchMode = [$mode, ...$args]; - $this->statement->setFetchMode($mode, ...$args); - - return true; + return $this->statement->setFetchMode($mode, ...$args); } public function bindValue(int|string $param, mixed $value, int $type = \PDO::PARAM_STR): bool diff --git a/tests/unit/PDOStatementTest.php b/tests/unit/PDOStatementTest.php index d3f3af8e3..8bd8f280b 100644 --- a/tests/unit/PDOStatementTest.php +++ b/tests/unit/PDOStatementTest.php @@ -126,17 +126,11 @@ public function testIsIterableAndDelegatesIterationToTheStatement(): void { $pdo = $this->pdoMock(inTransaction: false); $statement = $this->statementMock(); - $iterator = new \ArrayIterator([]); - - $statement->expects($this->once()) - ->method('getIterator') - ->willReturn($iterator); $wrapper = new PDOStatement($pdo, $statement, 'SELECT 1'); $this->assertInstanceOf(\IteratorAggregate::class, $wrapper); - $this->assertInstanceOf(\PDOStatement::class, $wrapper); - $this->assertSame($iterator, $wrapper->getIterator()); + $this->assertSame($statement, $wrapper->getIterator()); } public function testDoesNotReconnectForNonExecuteMethods(): void From a33931da6cd61517569e7fe3d2c655a7d6641dda Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 22 Jun 2026 09:32:25 +0100 Subject: [PATCH 39/51] Generalize cache value transforms --- src/Database/Database.php | 168 +++++++++++----------------------- tests/unit/ListCacheTest.php | 169 +++++++++++++++++++++++++++++------ 2 files changed, 190 insertions(+), 147 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 783f3d99d..74574f5e6 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8565,66 +8565,44 @@ public function find(string $collection, array $queries = [], string $forPermiss } /** - * Find documents through a cache-aside lookup. - * - * The caller owns authorization context. Use Authorization::skip() around - * this method for internal reads that should bypass request-user roles. - * The caller owns invalidation and must purge cached find entries after - * writes that can change matching documents or returned document payloads. + * Purge all cached find entries for a collection namespace. * * @param string $collection - * @param array $queries * @param string|null $namespace - * @param string $forPermission - * @return array - * @throws DatabaseException - * @throws QueryException - * @throws TimeoutException - * @throws Exception + * @return bool */ - public function cachedFind( - string $collection, - array $queries = [], - ?string $namespace = null, - string $forPermission = Database::PERMISSION_READ, - ): array { - foreach ($queries as $query) { - if ($query instanceof Query && $query->getMethod() === Query::TYPE_ORDER_RANDOM) { - return $this->find($collection, $queries, $forPermission); - } - } - + public function purgeFindCache(string $collection, ?string $namespace = null): bool + { $collectionDocument = $this->silent(fn () => $this->getCollection($collection)); + $collection = $collectionDocument->isEmpty() ? $collection : $collectionDocument->getId(); - if ($collectionDocument->isEmpty()) { - throw new NotFoundException('Collection not found'); - } - - $cacheMiss = false; - $documents = []; - $cacheKey = $this->getFindCacheKey($collectionDocument->getId(), $namespace); - $cacheHash = $this->getFindCacheField($collectionDocument, $queries, 'documents', $forPermission); - - $payload = $this->withCache( - key: $cacheKey, - callback: function () use ($collection, $queries, $forPermission, &$cacheMiss, &$documents): array { - $cacheMiss = true; - $documents = $this->find($collection, $queries, $forPermission); - - return \array_map( - static fn (Document $document): array => $document->getArrayCopy(), - $documents, - ); - }, - hash: $cacheHash, + return $this->cache->purge( + $this->getFindCacheKey($collection, $namespace) ); + } - if ($cacheMiss) { - return $documents; - } - + /** + * Restore a cached find document payload into database documents. + * + * Returns false when the cached payload is invalid or stale and should be + * refreshed by the caller. + * + * @param string $collection + * @param Document $collectionDocument + * @param mixed $payload + * @param string $forPermission + * @return array|false + * @throws AuthorizationException + * @throws Exception + */ + public function restoreFindCacheDocuments( + string $collection, + Document $collectionDocument, + mixed $payload, + string $forPermission = Database::PERMISSION_READ, + ): array|false { if (!\is_array($payload)) { - return $this->refreshCachedFind($cacheKey, $cacheHash, $collection, $queries, $forPermission); + return false; } $documentSecurity = $collectionDocument->getAttribute('documentSecurity', false); @@ -8637,100 +8615,50 @@ public function cachedFind( $documents = []; foreach ($payload as $document) { if (!\is_array($document)) { - return $this->refreshCachedFind($cacheKey, $cacheHash, $collection, $queries, $forPermission); + return false; } $document = $this->createDocumentInstance($collection, $document); $document = $this->casting($collectionDocument, $document); - $documents[] = $document; - } - - return $documents; - } - - /** - * Refresh a cached find field after detecting an invalid cached payload. - * - * @param string $cacheKey - * @param string $cacheHash - * @param string $collection - * @param array $queries - * @param string $forPermission - * @return array - * @throws DatabaseException - * @throws QueryException - * @throws TimeoutException - * @throws Exception - */ - private function refreshCachedFind( - string $cacheKey, - string $cacheHash, - string $collection, - array $queries, - string $forPermission, - ): array { - try { - $this->cache->purge($cacheKey, $cacheHash); - } catch (Throwable $e) { - Console::warning('Warning: Failed to purge invalid cached find payload: ' . $e->getMessage()); - } - - $documents = $this->find($collection, $queries, $forPermission); + if ($this->isTtlExpired($collectionDocument, $document)) { + return false; + } - try { - $this->cache->save( - $cacheKey, - [ - 'value' => \array_map( - static fn (Document $document): array => $document->getArrayCopy(), - $documents, - ), - ], - $cacheHash, - ); - } catch (Throwable $e) { - Console::warning('Warning: Failed to save refreshed cached find payload: ' . $e->getMessage()); + $documents[] = $document; } return $documents; } - /** - * Purge all cached find entries for a collection namespace. - * - * @param string $collection - * @param string|null $namespace - * @return bool - */ - public function purgeCachedFind(string $collection, ?string $namespace = null): bool - { - $collectionDocument = $this->silent(fn () => $this->getCollection($collection)); - $collection = $collectionDocument->isEmpty() ? $collection : $collectionDocument->getId(); - - return $this->cache->purge( - $this->getFindCacheKey($collection, $namespace) - ); - } - /** * Execute a callback behind a cache-aside lookup. * * The callback runs on cache miss and its value is returned to the caller. + * The encode callback converts fresh values into cache payloads before save. + * The decode callback converts cached payloads back into return values; a + * literal false decode result rejects the cached payload and refreshes it. * A literal false value is treated as a cache miss and is not cacheable. * * @template T + * @template C * @param string $key * @param callable(): T $callback * @param string $hash + * @param (callable(T): C)|null $encode + * @param (callable(C): (T|false))|null $decode * @return T */ public function withCache( string $key, callable $callback, string $hash = '', + ?callable $encode = null, + ?callable $decode = null, ): mixed { $shouldRefreshCache = false; + $encode ??= static fn (mixed $value): mixed => $value; + $decode ??= static fn (mixed $value): mixed => $value; try { $cached = $this->cache->load($key, self::TTL, $hash); @@ -8743,7 +8671,11 @@ public function withCache( $value = \is_array($cached) && \array_key_exists('value', $cached) ? $cached['value'] : false; if ($value !== false) { - return $value; + $decoded = $decode($value); + + if ($decoded !== false) { + return $decoded; + } } $shouldRefreshCache = true; @@ -8761,7 +8693,7 @@ public function withCache( if ($value !== false) { try { - $this->cache->save($key, ['value' => $value], $hash); + $this->cache->save($key, ['value' => $encode($value)], $hash); } catch (Throwable $e) { Console::warning('Warning: Failed to save cache value: ' . $e->getMessage()); } diff --git a/tests/unit/ListCacheTest.php b/tests/unit/ListCacheTest.php index 4d892ba3d..99524fff7 100644 --- a/tests/unit/ListCacheTest.php +++ b/tests/unit/ListCacheTest.php @@ -29,6 +29,44 @@ private function createDatabase(Adapter $cache, array $filters = [], ?DatabaseMe return $database; } + /** + * @param array $queries + * @return array + */ + private function findWithCache( + Database $database, + string $collection, + array $queries = [], + ?string $namespace = null, + string $forPermission = Database::PERMISSION_READ, + ): array { + foreach ($queries as $query) { + if ($query instanceof Query && $query->getMethod() === Query::TYPE_ORDER_RANDOM) { + return $database->find($collection, $queries, $forPermission); + } + } + + $collectionDocument = $database->getCollection($collection); + $cacheKey = $database->getFindCacheKey($collectionDocument->getId(), $namespace); + $cacheHash = $database->getFindCacheField($collectionDocument, $queries, 'documents', $forPermission); + + return $database->withCache( + key: $cacheKey, + callback: fn (): array => $database->find($collection, $queries, $forPermission), + hash: $cacheHash, + encode: static fn (array $documents): array => \array_map( + static fn (Document $document): array => $document->getArrayCopy(), + $documents, + ), + decode: fn (mixed $payload): array|false => $database->restoreFindCacheDocuments( + collection: $collection, + collectionDocument: $collectionDocument, + payload: $payload, + forPermission: $forPermission, + ), + ); + } + public function testWithCacheUsesCallbackOnMissAndCachesResult(): void { $cache = new HashMemoryCache(); @@ -190,7 +228,80 @@ function () use (&$callbackCalls): string { $this->assertSame(2, $callbackCalls); } - public function testCachedFindUsesCacheUntilPurged(): void + public function testWithCacheEncodesSavedValueAndDecodesCachedValue(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + + $callbackCalls = 0; + + $first = $database->withCache( + key: 'key', + callback: function () use (&$callbackCalls): array { + $callbackCalls++; + return ['fresh']; + }, + encode: static fn (array $value): array => ['encoded' => $value], + decode: static fn (array $value): array => $value['encoded'], + ); + + $second = $database->withCache( + key: 'key', + callback: function () use (&$callbackCalls): array { + $callbackCalls++; + return ['miss']; + }, + encode: static fn (array $value): array => ['encoded' => $value], + decode: static fn (array $value): array => $value['encoded'], + ); + + $this->assertSame(['fresh'], $first); + $this->assertSame(['fresh'], $second); + $this->assertSame(1, $callbackCalls); + } + + public function testWithCacheRefreshesWhenDecodeRejectsCachedValue(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + + $callbackCalls = 0; + + $database->withCache( + key: 'key', + callback: function () use (&$callbackCalls): array { + $callbackCalls++; + return ['stale']; + }, + encode: static fn (array $value): array => $value, + ); + + $fresh = $database->withCache( + key: 'key', + callback: function () use (&$callbackCalls): array { + $callbackCalls++; + return ['fresh']; + }, + encode: static fn (array $value): array => $value, + decode: static fn (array $value): array|false => $value === ['stale'] ? false : $value, + ); + + $cachedFresh = $database->withCache( + key: 'key', + callback: function () use (&$callbackCalls): array { + $callbackCalls++; + return ['miss']; + }, + encode: static fn (array $value): array => $value, + decode: static fn (array $value): array|false => $value === ['stale'] ? false : $value, + ); + + $this->assertSame(['fresh'], $fresh); + $this->assertSame(['fresh'], $cachedFresh); + $this->assertSame(2, $callbackCalls); + } + + public function testFindCacheUsesCacheUntilPurged(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -220,7 +331,7 @@ public function testCachedFindUsesCacheUntilPurged(): void Query::limit(25), ]; - $first = $database->cachedFind('wafRules', $queries, '_39'); + $first = $this->findWithCache($database, 'wafRules', $queries, '_39'); $this->assertCount(1, $first); $this->assertSame('rule-a', $first[0]->getId()); @@ -229,13 +340,13 @@ public function testCachedFindUsesCacheUntilPurged(): void 'projectId' => 'project-a', ])); - $cached = $database->cachedFind('wafRules', $queries, '_39'); + $cached = $this->findWithCache($database, 'wafRules', $queries, '_39'); $this->assertCount(1, $cached); $this->assertSame('rule-a', $cached[0]->getId()); - $this->assertTrue($database->purgeCachedFind('wafRules', '_39')); + $this->assertTrue($database->purgeFindCache('wafRules', '_39')); - $fresh = $database->cachedFind('wafRules', $queries, '_39'); + $fresh = $this->findWithCache($database, 'wafRules', $queries, '_39'); $this->assertCount(2, $fresh); $this->assertSame(['rule-a', 'rule-b'], \array_map( static fn (Document $document): string => $document->getId(), @@ -243,7 +354,7 @@ public function testCachedFindUsesCacheUntilPurged(): void )); } - public function testCachedFindSeparatesEntriesByPermissionMode(): void + public function testFindCacheSeparatesEntriesByPermissionMode(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -274,21 +385,21 @@ public function testCachedFindSeparatesEntriesByPermissionMode(): void Query::limit(25), ]; - $database->cachedFind('wafRules', $queries, '_39', forPermission: Database::PERMISSION_READ); + $this->findWithCache($database, 'wafRules', $queries, '_39', forPermission: Database::PERMISSION_READ); $database->createDocument('wafRules', new Document([ '$id' => 'rule-b', 'projectId' => 'project-a', ])); - $cached = $database->cachedFind('wafRules', $queries, '_39', forPermission: Database::PERMISSION_READ); + $cached = $this->findWithCache($database, 'wafRules', $queries, '_39', forPermission: Database::PERMISSION_READ); $this->assertCount(1, $cached); - $permissionSeparated = $database->cachedFind('wafRules', $queries, '_39', forPermission: Database::PERMISSION_UPDATE); + $permissionSeparated = $this->findWithCache($database, 'wafRules', $queries, '_39', forPermission: Database::PERMISSION_UPDATE); $this->assertCount(2, $permissionSeparated); } - public function testCachedFindRecastsCacheHits(): void + public function testFindCacheRecastsCacheHits(): void { $cache = new JsonHashMemoryCache(); $database = $this->createDatabase($cache); @@ -317,14 +428,14 @@ public function testCachedFindRecastsCacheHits(): void Query::limit(25), ]; - $fresh = $database->cachedFind('metrics', $queries, '_39'); - $cached = $database->cachedFind('metrics', $queries, '_39'); + $fresh = $this->findWithCache($database, 'metrics', $queries, '_39'); + $cached = $this->findWithCache($database, 'metrics', $queries, '_39'); $this->assertSame(1.0, $fresh[0]->getAttribute('value')); $this->assertSame(1.0, $cached[0]->getAttribute('value')); } - public function testCachedFindDoesNotDoubleDecodeCustomFilters(): void + public function testFindCacheDoesNotDoubleDecodeCustomFilters(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache, [ @@ -360,14 +471,14 @@ public function testCachedFindDoesNotDoubleDecodeCustomFilters(): void Query::limit(25), ]; - $fresh = $database->cachedFind('secrets', $queries, '_39'); - $cached = $database->cachedFind('secrets', $queries, '_39'); + $fresh = $this->findWithCache($database, 'secrets', $queries, '_39'); + $cached = $this->findWithCache($database, 'secrets', $queries, '_39'); $this->assertSame('value', $fresh[0]->getAttribute('secret')); $this->assertSame('value', $cached[0]->getAttribute('secret')); } - public function testCachedFindBypassesCacheForRandomOrder(): void + public function testFindCacheBypassesCacheForRandomOrder(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -397,7 +508,7 @@ public function testCachedFindBypassesCacheForRandomOrder(): void Query::limit(25), ]; - $first = $database->cachedFind('wafRules', $queries, '_39'); + $first = $this->findWithCache($database, 'wafRules', $queries, '_39'); $this->assertCount(1, $first); $database->createDocument('wafRules', new Document([ @@ -405,11 +516,11 @@ public function testCachedFindBypassesCacheForRandomOrder(): void 'projectId' => 'project-a', ])); - $second = $database->cachedFind('wafRules', $queries, '_39'); + $second = $this->findWithCache($database, 'wafRules', $queries, '_39'); $this->assertCount(2, $second); } - public function testCachedFindReliesOnPurgeForDocumentSecurityCollections(): void + public function testFindCacheReliesOnPurgeForDocumentSecurityCollections(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -446,7 +557,7 @@ public function testCachedFindReliesOnPurgeForDocumentSecurityCollections(): voi Query::limit(25), ]; - $first = $database->cachedFind('secureRules', $queries, '_39'); + $first = $this->findWithCache($database, 'secureRules', $queries, '_39'); $this->assertCount(1, $first); $database->getAuthorization()->skip(function () use ($database): void { @@ -458,17 +569,17 @@ public function testCachedFindReliesOnPurgeForDocumentSecurityCollections(): voi ])); }); - $cached = $database->cachedFind('secureRules', $queries, '_39'); + $cached = $this->findWithCache($database, 'secureRules', $queries, '_39'); $this->assertCount(1, $cached); - $this->assertTrue($database->purgeCachedFind('secureRules', '_39')); + $this->assertTrue($database->purgeFindCache('secureRules', '_39')); - $fresh = $database->cachedFind('secureRules', $queries, '_39'); + $fresh = $this->findWithCache($database, 'secureRules', $queries, '_39'); $this->assertSame([], $fresh); } - public function testCachedFindRehydratesNestedDocumentPayloads(): void + public function testFindCacheRehydratesNestedDocumentPayloads(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -498,14 +609,14 @@ public function testCachedFindRehydratesNestedDocumentPayloads(): void $database->getFindCacheField($collection, $queries), ); - $parents = $database->cachedFind('parents', $queries, '_39'); + $parents = $this->findWithCache($database, 'parents', $queries, '_39'); $this->assertCount(1, $parents); $this->assertInstanceOf(Document::class, $parents[0]->getAttribute('child')); $this->assertSame('child-a', $parents[0]->getAttribute('child')->getId()); } - public function testCachedFindRefreshesInvalidPayload(): void + public function testFindCacheRefreshesInvalidPayload(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -542,13 +653,13 @@ public function testCachedFindRefreshesInvalidPayload(): void $database->getFindCacheField($collection, $queries), ); - $rules = $database->cachedFind('wafRules', $queries, '_39'); + $rules = $this->findWithCache($database, 'wafRules', $queries, '_39'); $this->assertCount(1, $rules); $this->assertSame('rule-a', $rules[0]->getId()); } - public function testCachedFindRefreshesInvalidPayloadEntry(): void + public function testFindCacheRefreshesInvalidPayloadEntry(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -597,7 +708,7 @@ public function testCachedFindRefreshesInvalidPayloadEntry(): void $database->getFindCacheField($collection, $queries), ); - $rules = $database->cachedFind('wafRules', $queries, '_39'); + $rules = $this->findWithCache($database, 'wafRules', $queries, '_39'); $this->assertSame(['rule-a', 'rule-b'], \array_map( static fn (Document $document): string => $document->getId(), From 15ba92b03dd6c32bc6fccdff2b5a770176377a42 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 22 Jun 2026 10:01:25 +0100 Subject: [PATCH 40/51] Rename find cache helpers to list cache --- src/Database/Database.php | 36 ++++++++++++------------ tests/unit/CacheKeyTest.php | 54 ++++++++++++++++++------------------ tests/unit/ListCacheTest.php | 40 +++++++++++++------------- 3 files changed, 65 insertions(+), 65 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 74574f5e6..d07854faf 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8565,24 +8565,24 @@ public function find(string $collection, array $queries = [], string $forPermiss } /** - * Purge all cached find entries for a collection namespace. + * Purge all cached list entries for a collection namespace. * * @param string $collection * @param string|null $namespace * @return bool */ - public function purgeFindCache(string $collection, ?string $namespace = null): bool + public function purgeListCache(string $collection, ?string $namespace = null): bool { $collectionDocument = $this->silent(fn () => $this->getCollection($collection)); $collection = $collectionDocument->isEmpty() ? $collection : $collectionDocument->getId(); return $this->cache->purge( - $this->getFindCacheKey($collection, $namespace) + $this->getListCacheKey($collection, $namespace) ); } /** - * Restore a cached find document payload into database documents. + * Restore a cached list document payload into database documents. * * Returns false when the cached payload is invalid or stale and should be * refreshed by the caller. @@ -8595,7 +8595,7 @@ public function purgeFindCache(string $collection, ?string $namespace = null): b * @throws AuthorizationException * @throws Exception */ - public function restoreFindCacheDocuments( + public function restoreListCacheDocuments( string $collection, Document $collectionDocument, mixed $payload, @@ -9599,20 +9599,20 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a } /** - * Stable cache key for cached find entries on a collection. + * Stable cache key for cached list entries on a collection. * * @param string $collectionId * @param string|null $namespace * @return string */ - public function getFindCacheKey(string $collectionId, ?string $namespace = null): string + public function getListCacheKey(string $collectionId, ?string $namespace = null): string { $hostname = $this->adapter->getSupportForHostname() ? $this->adapter->getHostname() : ''; return \sprintf( - '%s-cache-%s:%s:%s:collection:%s:find', + '%s-cache-%s:%s:%s:collection:%s:list', $this->cacheName, $hostname, $namespace ?? $this->getNamespace(), @@ -9622,7 +9622,7 @@ public function getFindCacheKey(string $collectionId, ?string $namespace = null) } /** - * Stable cache field for cached find entries on a collection. + * Stable cache field for cached list entries on a collection. * * @param Document|null $collection * @param array $queries @@ -9630,7 +9630,7 @@ public function getFindCacheKey(string $collectionId, ?string $namespace = null) * @param string $forPermission * @return string */ - public function getFindCacheField( + public function getListCacheField( ?Document $collection = null, array $queries = [], string $field = 'documents', @@ -9650,7 +9650,7 @@ public function getFindCacheField( 'database' => $this->getDatabase(), 'permission' => $forPermission, 'queries' => \array_map( - fn (Query $query): array => $this->serializeFindCacheQuery($query), + fn (Query $query): array => $this->serializeListCacheQuery($query), $queries, ), 'relationships' => $this->resolveRelationships, @@ -9659,7 +9659,7 @@ public function getFindCacheField( return \sprintf( '%s:%s:%s', - $this->getFindCacheSchemaHash($collection), + $this->getListCacheSchemaHash($collection), \md5(\json_encode($queryPayload) ?: ''), $field, ); @@ -9668,7 +9668,7 @@ public function getFindCacheField( /** * @return array */ - private function serializeFindCacheQuery(Query $query): array + private function serializeListCacheQuery(Query $query): array { $serialized = [ 'method' => $query->getMethod(), @@ -9681,11 +9681,11 @@ private function serializeFindCacheQuery(Query $query): array $values = []; foreach ($query->getValues() as $value) { if ($value instanceof Query) { - $values[] = $this->serializeFindCacheQuery($value); + $values[] = $this->serializeListCacheQuery($value); continue; } - $values[] = $this->normalizeFindCacheQueryValue($value); + $values[] = $this->normalizeListCacheQueryValue($value); } $serialized['values'] = $values; @@ -9693,7 +9693,7 @@ private function serializeFindCacheQuery(Query $query): array return $serialized; } - private function normalizeFindCacheQueryValue(mixed $value): mixed + private function normalizeListCacheQueryValue(mixed $value): mixed { if ($value instanceof Document) { $value = $value->getArrayCopy(); @@ -9704,13 +9704,13 @@ private function normalizeFindCacheQueryValue(mixed $value): mixed } foreach ($value as $key => $item) { - $value[$key] = $this->normalizeFindCacheQueryValue($item); + $value[$key] = $this->normalizeListCacheQueryValue($item); } return $value; } - private function getFindCacheSchemaHash(?Document $collection): string + private function getListCacheSchemaHash(?Document $collection): string { if ($collection === null || $collection->isEmpty()) { return ''; diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index db09c7dfa..1d9f5d232 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -135,7 +135,7 @@ public function testFiltersDisabledEntirelyProducesDifferentCacheKey(): void $this->assertNotEquals($hashEnabled, $hashDisabled); } - public function testFindCacheKeyUsesListCacheShapeWithFindSuffix(): void + public function testListCacheKeyUsesListCacheShape(): void { $adapter = $this->createMock(Adapter::class); $adapter->method('getSupportForHostname')->willReturn(true); @@ -146,12 +146,12 @@ public function testFindCacheKeyUsesListCacheShapeWithFindSuffix(): void $db = new Database($adapter, new Cache(new None()), []); $this->assertSame( - 'default-cache-mysql-console:_39::collection:ttl_cache_table:find', - $db->getFindCacheKey('ttl_cache_table'), + 'default-cache-mysql-console:_39::collection:ttl_cache_table:list', + $db->getListCacheKey('ttl_cache_table'), ); } - public function testFindCacheKeyCanOverrideNamespaceSegment(): void + public function testListCacheKeyCanOverrideNamespaceSegment(): void { $adapter = $this->createMock(Adapter::class); $adapter->method('getSupportForHostname')->willReturn(true); @@ -162,12 +162,12 @@ public function testFindCacheKeyCanOverrideNamespaceSegment(): void $db = new Database($adapter, new Cache(new None()), []); $this->assertSame( - 'default-cache-mysql-console:_39::collection:wafrules:find', - $db->getFindCacheKey('wafrules', '_39'), + 'default-cache-mysql-console:_39::collection:wafrules:list', + $db->getListCacheKey('wafrules', '_39'), ); } - public function testFindCacheFieldUsesListCacheShape(): void + public function testListCacheFieldUsesListCacheShape(): void { $db = $this->createDatabase(); $collection = new Document([ @@ -192,18 +192,18 @@ public function testFindCacheFieldUsesListCacheShape(): void . (\json_encode($collection->getAttribute('$permissions', [])) ?: '') . (\json_encode($collection->getAttribute('documentSecurity', false)) ?: '') ); - $field = $db->getFindCacheField($collection, $queries); + $field = $db->getListCacheField($collection, $queries); $this->assertStringStartsWith("{$schemaHash}:", $field); $this->assertStringEndsWith(':documents', $field); $this->assertSame(2, \substr_count($field, ':')); } - public function testFindCacheFieldChangesWithInputs(): void + public function testListCacheFieldChangesWithInputs(): void { $db = $this->createDatabase(); - $field = $db->getFindCacheField( + $field = $db->getListCacheField( new Document([ 'attributes' => [new Document(['$id' => 'name', 'type' => Database::VAR_STRING])], 'indexes' => [], @@ -213,7 +213,7 @@ public function testFindCacheFieldChangesWithInputs(): void $this->assertNotSame( $field, - $db->getFindCacheField( + $db->getListCacheField( new Document([ 'attributes' => [new Document(['$id' => 'status', 'type' => Database::VAR_STRING])], 'indexes' => [], @@ -221,42 +221,42 @@ public function testFindCacheFieldChangesWithInputs(): void [Query::limit(10)], ), ); - $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(20)])); - $this->assertNotSame($field, $db->getFindCacheField(null, [Query::limit(10)], forPermission: Database::PERMISSION_UPDATE)); - $this->assertStringEndsWith(':total', $db->getFindCacheField(null, [Query::limit(10)], 'total')); + $this->assertNotSame($field, $db->getListCacheField(null, [Query::limit(20)])); + $this->assertNotSame($field, $db->getListCacheField(null, [Query::limit(10)], forPermission: Database::PERMISSION_UPDATE)); + $this->assertStringEndsWith(':total', $db->getListCacheField(null, [Query::limit(10)], 'total')); } - public function testFindCacheFieldChangesWithActiveAuthorizationContext(): void + public function testListCacheFieldChangesWithActiveAuthorizationContext(): void { $db = $this->createDatabase(); - $field = $db->getFindCacheField(null, [Query::limit(10)]); + $field = $db->getListCacheField(null, [Query::limit(10)]); $this->assertNotSame( $field, - $db->getAuthorization()->skip(fn () => $db->getFindCacheField(null, [Query::limit(10)])), + $db->getAuthorization()->skip(fn () => $db->getListCacheField(null, [Query::limit(10)])), ); $db->getAuthorization()->addRole('user:1'); $this->assertNotSame( $field, - $db->getFindCacheField(null, [Query::limit(10)]), + $db->getListCacheField(null, [Query::limit(10)]), ); } - public function testFindCacheFieldIncludesCursorDocumentPayload(): void + public function testListCacheFieldIncludesCursorDocumentPayload(): void { $db = $this->createDatabase(); - $fieldA = $db->getFindCacheField(null, [ + $fieldA = $db->getListCacheField(null, [ Query::orderAsc('name'), Query::cursorAfter(new Document([ '$id' => 'cursor', 'name' => 'alpha', ])), ]); - $fieldB = $db->getFindCacheField(null, [ + $fieldB = $db->getListCacheField(null, [ Query::orderAsc('name'), Query::cursorAfter(new Document([ '$id' => 'cursor', @@ -267,23 +267,23 @@ public function testFindCacheFieldIncludesCursorDocumentPayload(): void $this->assertNotSame($fieldA, $fieldB); } - public function testFindCacheFieldIncludesAmbientState(): void + public function testListCacheFieldIncludesAmbientState(): void { $db = $this->createDatabase(); - $field = $db->getFindCacheField(null, [Query::limit(10)]); + $field = $db->getListCacheField(null, [Query::limit(10)]); $this->assertNotSame( $field, - $db->skipFilters(fn () => $db->getFindCacheField(null, [Query::limit(10)]), ['json']), + $db->skipFilters(fn () => $db->getListCacheField(null, [Query::limit(10)]), ['json']), ); $this->assertNotSame( $field, - $db->skipRelationships(fn () => $db->getFindCacheField(null, [Query::limit(10)])), + $db->skipRelationships(fn () => $db->getListCacheField(null, [Query::limit(10)])), ); } - public function testFindCacheFieldValidatesQueryTypes(): void + public function testListCacheFieldValidatesQueryTypes(): void { $this->expectException(QueryException::class); @@ -291,7 +291,7 @@ public function testFindCacheFieldValidatesQueryTypes(): void $queries = ['invalid']; /** @phpstan-ignore-next-line intentionally passing invalid query type */ - $db->getFindCacheField(null, $queries); + $db->getListCacheField(null, $queries); } diff --git a/tests/unit/ListCacheTest.php b/tests/unit/ListCacheTest.php index 99524fff7..4c9e52c2b 100644 --- a/tests/unit/ListCacheTest.php +++ b/tests/unit/ListCacheTest.php @@ -47,8 +47,8 @@ private function findWithCache( } $collectionDocument = $database->getCollection($collection); - $cacheKey = $database->getFindCacheKey($collectionDocument->getId(), $namespace); - $cacheHash = $database->getFindCacheField($collectionDocument, $queries, 'documents', $forPermission); + $cacheKey = $database->getListCacheKey($collectionDocument->getId(), $namespace); + $cacheHash = $database->getListCacheField($collectionDocument, $queries, 'documents', $forPermission); return $database->withCache( key: $cacheKey, @@ -58,7 +58,7 @@ private function findWithCache( static fn (Document $document): array => $document->getArrayCopy(), $documents, ), - decode: fn (mixed $payload): array|false => $database->restoreFindCacheDocuments( + decode: fn (mixed $payload): array|false => $database->restoreListCacheDocuments( collection: $collection, collectionDocument: $collectionDocument, payload: $payload, @@ -301,7 +301,7 @@ public function testWithCacheRefreshesWhenDecodeRejectsCachedValue(): void $this->assertSame(2, $callbackCalls); } - public function testFindCacheUsesCacheUntilPurged(): void + public function testListCacheUsesCacheUntilPurged(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -344,7 +344,7 @@ public function testFindCacheUsesCacheUntilPurged(): void $this->assertCount(1, $cached); $this->assertSame('rule-a', $cached[0]->getId()); - $this->assertTrue($database->purgeFindCache('wafRules', '_39')); + $this->assertTrue($database->purgeListCache('wafRules', '_39')); $fresh = $this->findWithCache($database, 'wafRules', $queries, '_39'); $this->assertCount(2, $fresh); @@ -354,7 +354,7 @@ public function testFindCacheUsesCacheUntilPurged(): void )); } - public function testFindCacheSeparatesEntriesByPermissionMode(): void + public function testListCacheSeparatesEntriesByPermissionMode(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -399,7 +399,7 @@ public function testFindCacheSeparatesEntriesByPermissionMode(): void $this->assertCount(2, $permissionSeparated); } - public function testFindCacheRecastsCacheHits(): void + public function testListCacheRecastsCacheHits(): void { $cache = new JsonHashMemoryCache(); $database = $this->createDatabase($cache); @@ -435,7 +435,7 @@ public function testFindCacheRecastsCacheHits(): void $this->assertSame(1.0, $cached[0]->getAttribute('value')); } - public function testFindCacheDoesNotDoubleDecodeCustomFilters(): void + public function testListCacheDoesNotDoubleDecodeCustomFilters(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache, [ @@ -478,7 +478,7 @@ public function testFindCacheDoesNotDoubleDecodeCustomFilters(): void $this->assertSame('value', $cached[0]->getAttribute('secret')); } - public function testFindCacheBypassesCacheForRandomOrder(): void + public function testListCacheBypassesCacheForRandomOrder(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -520,7 +520,7 @@ public function testFindCacheBypassesCacheForRandomOrder(): void $this->assertCount(2, $second); } - public function testFindCacheReliesOnPurgeForDocumentSecurityCollections(): void + public function testListCacheReliesOnPurgeForDocumentSecurityCollections(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -573,13 +573,13 @@ public function testFindCacheReliesOnPurgeForDocumentSecurityCollections(): void $this->assertCount(1, $cached); - $this->assertTrue($database->purgeFindCache('secureRules', '_39')); + $this->assertTrue($database->purgeListCache('secureRules', '_39')); $fresh = $this->findWithCache($database, 'secureRules', $queries, '_39'); $this->assertSame([], $fresh); } - public function testFindCacheRehydratesNestedDocumentPayloads(): void + public function testListCacheRehydratesNestedDocumentPayloads(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -593,7 +593,7 @@ public function testFindCacheRehydratesNestedDocumentPayloads(): void $collection = $database->getCollection('parents'); $cache->save( - $database->getFindCacheKey($collection->getId(), '_39'), + $database->getListCacheKey($collection->getId(), '_39'), [ 'value' => [ [ @@ -606,7 +606,7 @@ public function testFindCacheRehydratesNestedDocumentPayloads(): void ], ], ], - $database->getFindCacheField($collection, $queries), + $database->getListCacheField($collection, $queries), ); $parents = $this->findWithCache($database, 'parents', $queries, '_39'); @@ -616,7 +616,7 @@ public function testFindCacheRehydratesNestedDocumentPayloads(): void $this->assertSame('child-a', $parents[0]->getAttribute('child')->getId()); } - public function testFindCacheRefreshesInvalidPayload(): void + public function testListCacheRefreshesInvalidPayload(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -648,9 +648,9 @@ public function testFindCacheRefreshesInvalidPayload(): void $collection = $database->getCollection('wafRules'); $cache->save( - $database->getFindCacheKey($collection->getId(), '_39'), + $database->getListCacheKey($collection->getId(), '_39'), ['value' => 'invalid'], - $database->getFindCacheField($collection, $queries), + $database->getListCacheField($collection, $queries), ); $rules = $this->findWithCache($database, 'wafRules', $queries, '_39'); @@ -659,7 +659,7 @@ public function testFindCacheRefreshesInvalidPayload(): void $this->assertSame('rule-a', $rules[0]->getId()); } - public function testFindCacheRefreshesInvalidPayloadEntry(): void + public function testListCacheRefreshesInvalidPayloadEntry(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -695,7 +695,7 @@ public function testFindCacheRefreshesInvalidPayloadEntry(): void $collection = $database->getCollection('wafRules'); $cache->save( - $database->getFindCacheKey($collection->getId(), '_39'), + $database->getListCacheKey($collection->getId(), '_39'), [ 'value' => [ [ @@ -705,7 +705,7 @@ public function testFindCacheRefreshesInvalidPayloadEntry(): void 'invalid', ], ], - $database->getFindCacheField($collection, $queries), + $database->getListCacheField($collection, $queries), ); $rules = $this->findWithCache($database, 'wafRules', $queries, '_39'); From e89017fca6a9fbb32999c4f968a627ee64add56f Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 22 Jun 2026 10:25:27 +0100 Subject: [PATCH 41/51] Simplify list cache document restore --- src/Database/Database.php | 18 ++++++++---------- tests/unit/ListCacheTest.php | 3 +-- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index d07854faf..f0d9b17f2 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8587,8 +8587,7 @@ public function purgeListCache(string $collection, ?string $namespace = null): b * Returns false when the cached payload is invalid or stale and should be * refreshed by the caller. * - * @param string $collection - * @param Document $collectionDocument + * @param Document $collection * @param mixed $payload * @param string $forPermission * @return array|false @@ -8596,8 +8595,7 @@ public function purgeListCache(string $collection, ?string $namespace = null): b * @throws Exception */ public function restoreListCacheDocuments( - string $collection, - Document $collectionDocument, + Document $collection, mixed $payload, string $forPermission = Database::PERMISSION_READ, ): array|false { @@ -8605,10 +8603,10 @@ public function restoreListCacheDocuments( return false; } - $documentSecurity = $collectionDocument->getAttribute('documentSecurity', false); - $skipAuth = $this->authorization->isValid(new Input($forPermission, $collectionDocument->getPermissionsByType($forPermission))); + $documentSecurity = $collection->getAttribute('documentSecurity', false); + $skipAuth = $this->authorization->isValid(new Input($forPermission, $collection->getPermissionsByType($forPermission))); - if (!$skipAuth && !$documentSecurity && $collectionDocument->getId() !== self::METADATA) { + if (!$skipAuth && !$documentSecurity && $collection->getId() !== self::METADATA) { throw new AuthorizationException($this->authorization->getDescription()); } @@ -8618,10 +8616,10 @@ public function restoreListCacheDocuments( return false; } - $document = $this->createDocumentInstance($collection, $document); - $document = $this->casting($collectionDocument, $document); + $document = $this->createDocumentInstance($collection->getId(), $document); + $document = $this->casting($collection, $document); - if ($this->isTtlExpired($collectionDocument, $document)) { + if ($this->isTtlExpired($collection, $document)) { return false; } diff --git a/tests/unit/ListCacheTest.php b/tests/unit/ListCacheTest.php index 4c9e52c2b..014ec6b46 100644 --- a/tests/unit/ListCacheTest.php +++ b/tests/unit/ListCacheTest.php @@ -59,8 +59,7 @@ private function findWithCache( $documents, ), decode: fn (mixed $payload): array|false => $database->restoreListCacheDocuments( - collection: $collection, - collectionDocument: $collectionDocument, + collection: $collectionDocument, payload: $payload, forPermission: $forPermission, ), From 40694778e7fb3ba1085678ce55541b395d5e7b84 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 22 Jun 2026 14:14:49 +0100 Subject: [PATCH 42/51] Rename list cache helpers to query cache --- src/Database/Database.php | 36 ++++++------- tests/unit/CacheKeyTest.php | 54 +++++++++---------- .../{ListCacheTest.php => QueryCacheTest.php} | 42 +++++++-------- 3 files changed, 66 insertions(+), 66 deletions(-) rename tests/unit/{ListCacheTest.php => QueryCacheTest.php} (94%) diff --git a/src/Database/Database.php b/src/Database/Database.php index f0d9b17f2..bbbd0f77a 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8565,24 +8565,24 @@ public function find(string $collection, array $queries = [], string $forPermiss } /** - * Purge all cached list entries for a collection namespace. + * Purge all cached query entries for a collection namespace. * * @param string $collection * @param string|null $namespace * @return bool */ - public function purgeListCache(string $collection, ?string $namespace = null): bool + public function purgeQueryCache(string $collection, ?string $namespace = null): bool { $collectionDocument = $this->silent(fn () => $this->getCollection($collection)); $collection = $collectionDocument->isEmpty() ? $collection : $collectionDocument->getId(); return $this->cache->purge( - $this->getListCacheKey($collection, $namespace) + $this->getQueryCacheKey($collection, $namespace) ); } /** - * Restore a cached list document payload into database documents. + * Restore a cached query document payload into database documents. * * Returns false when the cached payload is invalid or stale and should be * refreshed by the caller. @@ -8594,7 +8594,7 @@ public function purgeListCache(string $collection, ?string $namespace = null): b * @throws AuthorizationException * @throws Exception */ - public function restoreListCacheDocuments( + public function restoreQueryCacheDocuments( Document $collection, mixed $payload, string $forPermission = Database::PERMISSION_READ, @@ -9597,20 +9597,20 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a } /** - * Stable cache key for cached list entries on a collection. + * Stable cache key for cached query entries on a collection. * * @param string $collectionId * @param string|null $namespace * @return string */ - public function getListCacheKey(string $collectionId, ?string $namespace = null): string + public function getQueryCacheKey(string $collectionId, ?string $namespace = null): string { $hostname = $this->adapter->getSupportForHostname() ? $this->adapter->getHostname() : ''; return \sprintf( - '%s-cache-%s:%s:%s:collection:%s:list', + '%s-cache-%s:%s:%s:collection:%s:query', $this->cacheName, $hostname, $namespace ?? $this->getNamespace(), @@ -9620,7 +9620,7 @@ public function getListCacheKey(string $collectionId, ?string $namespace = null) } /** - * Stable cache field for cached list entries on a collection. + * Stable cache field for cached query entries on a collection. * * @param Document|null $collection * @param array $queries @@ -9628,7 +9628,7 @@ public function getListCacheKey(string $collectionId, ?string $namespace = null) * @param string $forPermission * @return string */ - public function getListCacheField( + public function getQueryCacheField( ?Document $collection = null, array $queries = [], string $field = 'documents', @@ -9648,7 +9648,7 @@ public function getListCacheField( 'database' => $this->getDatabase(), 'permission' => $forPermission, 'queries' => \array_map( - fn (Query $query): array => $this->serializeListCacheQuery($query), + fn (Query $query): array => $this->serializeQueryCacheQuery($query), $queries, ), 'relationships' => $this->resolveRelationships, @@ -9657,7 +9657,7 @@ public function getListCacheField( return \sprintf( '%s:%s:%s', - $this->getListCacheSchemaHash($collection), + $this->getQueryCacheSchemaHash($collection), \md5(\json_encode($queryPayload) ?: ''), $field, ); @@ -9666,7 +9666,7 @@ public function getListCacheField( /** * @return array */ - private function serializeListCacheQuery(Query $query): array + private function serializeQueryCacheQuery(Query $query): array { $serialized = [ 'method' => $query->getMethod(), @@ -9679,11 +9679,11 @@ private function serializeListCacheQuery(Query $query): array $values = []; foreach ($query->getValues() as $value) { if ($value instanceof Query) { - $values[] = $this->serializeListCacheQuery($value); + $values[] = $this->serializeQueryCacheQuery($value); continue; } - $values[] = $this->normalizeListCacheQueryValue($value); + $values[] = $this->normalizeQueryCacheQueryValue($value); } $serialized['values'] = $values; @@ -9691,7 +9691,7 @@ private function serializeListCacheQuery(Query $query): array return $serialized; } - private function normalizeListCacheQueryValue(mixed $value): mixed + private function normalizeQueryCacheQueryValue(mixed $value): mixed { if ($value instanceof Document) { $value = $value->getArrayCopy(); @@ -9702,13 +9702,13 @@ private function normalizeListCacheQueryValue(mixed $value): mixed } foreach ($value as $key => $item) { - $value[$key] = $this->normalizeListCacheQueryValue($item); + $value[$key] = $this->normalizeQueryCacheQueryValue($item); } return $value; } - private function getListCacheSchemaHash(?Document $collection): string + private function getQueryCacheSchemaHash(?Document $collection): string { if ($collection === null || $collection->isEmpty()) { return ''; diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index 1d9f5d232..aff48963e 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -135,7 +135,7 @@ public function testFiltersDisabledEntirelyProducesDifferentCacheKey(): void $this->assertNotEquals($hashEnabled, $hashDisabled); } - public function testListCacheKeyUsesListCacheShape(): void + public function testQueryCacheKeyUsesQueryCacheShape(): void { $adapter = $this->createMock(Adapter::class); $adapter->method('getSupportForHostname')->willReturn(true); @@ -146,12 +146,12 @@ public function testListCacheKeyUsesListCacheShape(): void $db = new Database($adapter, new Cache(new None()), []); $this->assertSame( - 'default-cache-mysql-console:_39::collection:ttl_cache_table:list', - $db->getListCacheKey('ttl_cache_table'), + 'default-cache-mysql-console:_39::collection:ttl_cache_table:query', + $db->getQueryCacheKey('ttl_cache_table'), ); } - public function testListCacheKeyCanOverrideNamespaceSegment(): void + public function testQueryCacheKeyCanOverrideNamespaceSegment(): void { $adapter = $this->createMock(Adapter::class); $adapter->method('getSupportForHostname')->willReturn(true); @@ -162,12 +162,12 @@ public function testListCacheKeyCanOverrideNamespaceSegment(): void $db = new Database($adapter, new Cache(new None()), []); $this->assertSame( - 'default-cache-mysql-console:_39::collection:wafrules:list', - $db->getListCacheKey('wafrules', '_39'), + 'default-cache-mysql-console:_39::collection:wafrules:query', + $db->getQueryCacheKey('wafrules', '_39'), ); } - public function testListCacheFieldUsesListCacheShape(): void + public function testQueryCacheFieldUsesQueryCacheShape(): void { $db = $this->createDatabase(); $collection = new Document([ @@ -192,18 +192,18 @@ public function testListCacheFieldUsesListCacheShape(): void . (\json_encode($collection->getAttribute('$permissions', [])) ?: '') . (\json_encode($collection->getAttribute('documentSecurity', false)) ?: '') ); - $field = $db->getListCacheField($collection, $queries); + $field = $db->getQueryCacheField($collection, $queries); $this->assertStringStartsWith("{$schemaHash}:", $field); $this->assertStringEndsWith(':documents', $field); $this->assertSame(2, \substr_count($field, ':')); } - public function testListCacheFieldChangesWithInputs(): void + public function testQueryCacheFieldChangesWithInputs(): void { $db = $this->createDatabase(); - $field = $db->getListCacheField( + $field = $db->getQueryCacheField( new Document([ 'attributes' => [new Document(['$id' => 'name', 'type' => Database::VAR_STRING])], 'indexes' => [], @@ -213,7 +213,7 @@ public function testListCacheFieldChangesWithInputs(): void $this->assertNotSame( $field, - $db->getListCacheField( + $db->getQueryCacheField( new Document([ 'attributes' => [new Document(['$id' => 'status', 'type' => Database::VAR_STRING])], 'indexes' => [], @@ -221,42 +221,42 @@ public function testListCacheFieldChangesWithInputs(): void [Query::limit(10)], ), ); - $this->assertNotSame($field, $db->getListCacheField(null, [Query::limit(20)])); - $this->assertNotSame($field, $db->getListCacheField(null, [Query::limit(10)], forPermission: Database::PERMISSION_UPDATE)); - $this->assertStringEndsWith(':total', $db->getListCacheField(null, [Query::limit(10)], 'total')); + $this->assertNotSame($field, $db->getQueryCacheField(null, [Query::limit(20)])); + $this->assertNotSame($field, $db->getQueryCacheField(null, [Query::limit(10)], forPermission: Database::PERMISSION_UPDATE)); + $this->assertStringEndsWith(':total', $db->getQueryCacheField(null, [Query::limit(10)], 'total')); } - public function testListCacheFieldChangesWithActiveAuthorizationContext(): void + public function testQueryCacheFieldChangesWithActiveAuthorizationContext(): void { $db = $this->createDatabase(); - $field = $db->getListCacheField(null, [Query::limit(10)]); + $field = $db->getQueryCacheField(null, [Query::limit(10)]); $this->assertNotSame( $field, - $db->getAuthorization()->skip(fn () => $db->getListCacheField(null, [Query::limit(10)])), + $db->getAuthorization()->skip(fn () => $db->getQueryCacheField(null, [Query::limit(10)])), ); $db->getAuthorization()->addRole('user:1'); $this->assertNotSame( $field, - $db->getListCacheField(null, [Query::limit(10)]), + $db->getQueryCacheField(null, [Query::limit(10)]), ); } - public function testListCacheFieldIncludesCursorDocumentPayload(): void + public function testQueryCacheFieldIncludesCursorDocumentPayload(): void { $db = $this->createDatabase(); - $fieldA = $db->getListCacheField(null, [ + $fieldA = $db->getQueryCacheField(null, [ Query::orderAsc('name'), Query::cursorAfter(new Document([ '$id' => 'cursor', 'name' => 'alpha', ])), ]); - $fieldB = $db->getListCacheField(null, [ + $fieldB = $db->getQueryCacheField(null, [ Query::orderAsc('name'), Query::cursorAfter(new Document([ '$id' => 'cursor', @@ -267,23 +267,23 @@ public function testListCacheFieldIncludesCursorDocumentPayload(): void $this->assertNotSame($fieldA, $fieldB); } - public function testListCacheFieldIncludesAmbientState(): void + public function testQueryCacheFieldIncludesAmbientState(): void { $db = $this->createDatabase(); - $field = $db->getListCacheField(null, [Query::limit(10)]); + $field = $db->getQueryCacheField(null, [Query::limit(10)]); $this->assertNotSame( $field, - $db->skipFilters(fn () => $db->getListCacheField(null, [Query::limit(10)]), ['json']), + $db->skipFilters(fn () => $db->getQueryCacheField(null, [Query::limit(10)]), ['json']), ); $this->assertNotSame( $field, - $db->skipRelationships(fn () => $db->getListCacheField(null, [Query::limit(10)])), + $db->skipRelationships(fn () => $db->getQueryCacheField(null, [Query::limit(10)])), ); } - public function testListCacheFieldValidatesQueryTypes(): void + public function testQueryCacheFieldValidatesQueryTypes(): void { $this->expectException(QueryException::class); @@ -291,7 +291,7 @@ public function testListCacheFieldValidatesQueryTypes(): void $queries = ['invalid']; /** @phpstan-ignore-next-line intentionally passing invalid query type */ - $db->getListCacheField(null, $queries); + $db->getQueryCacheField(null, $queries); } diff --git a/tests/unit/ListCacheTest.php b/tests/unit/QueryCacheTest.php similarity index 94% rename from tests/unit/ListCacheTest.php rename to tests/unit/QueryCacheTest.php index 014ec6b46..9d925fd2d 100644 --- a/tests/unit/ListCacheTest.php +++ b/tests/unit/QueryCacheTest.php @@ -12,7 +12,7 @@ use Utopia\Database\Helpers\Role; use Utopia\Database\Query; -class ListCacheTest extends TestCase +class QueryCacheTest extends TestCase { /** * @param array $filters @@ -47,8 +47,8 @@ private function findWithCache( } $collectionDocument = $database->getCollection($collection); - $cacheKey = $database->getListCacheKey($collectionDocument->getId(), $namespace); - $cacheHash = $database->getListCacheField($collectionDocument, $queries, 'documents', $forPermission); + $cacheKey = $database->getQueryCacheKey($collectionDocument->getId(), $namespace); + $cacheHash = $database->getQueryCacheField($collectionDocument, $queries, 'documents', $forPermission); return $database->withCache( key: $cacheKey, @@ -58,7 +58,7 @@ private function findWithCache( static fn (Document $document): array => $document->getArrayCopy(), $documents, ), - decode: fn (mixed $payload): array|false => $database->restoreListCacheDocuments( + decode: fn (mixed $payload): array|false => $database->restoreQueryCacheDocuments( collection: $collectionDocument, payload: $payload, forPermission: $forPermission, @@ -300,7 +300,7 @@ public function testWithCacheRefreshesWhenDecodeRejectsCachedValue(): void $this->assertSame(2, $callbackCalls); } - public function testListCacheUsesCacheUntilPurged(): void + public function testQueryCacheUsesCacheUntilPurged(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -343,7 +343,7 @@ public function testListCacheUsesCacheUntilPurged(): void $this->assertCount(1, $cached); $this->assertSame('rule-a', $cached[0]->getId()); - $this->assertTrue($database->purgeListCache('wafRules', '_39')); + $this->assertTrue($database->purgeQueryCache('wafRules', '_39')); $fresh = $this->findWithCache($database, 'wafRules', $queries, '_39'); $this->assertCount(2, $fresh); @@ -353,7 +353,7 @@ public function testListCacheUsesCacheUntilPurged(): void )); } - public function testListCacheSeparatesEntriesByPermissionMode(): void + public function testQueryCacheSeparatesEntriesByPermissionMode(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -398,7 +398,7 @@ public function testListCacheSeparatesEntriesByPermissionMode(): void $this->assertCount(2, $permissionSeparated); } - public function testListCacheRecastsCacheHits(): void + public function testQueryCacheRecastsCacheHits(): void { $cache = new JsonHashMemoryCache(); $database = $this->createDatabase($cache); @@ -434,7 +434,7 @@ public function testListCacheRecastsCacheHits(): void $this->assertSame(1.0, $cached[0]->getAttribute('value')); } - public function testListCacheDoesNotDoubleDecodeCustomFilters(): void + public function testQueryCacheDoesNotDoubleDecodeCustomFilters(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache, [ @@ -477,7 +477,7 @@ public function testListCacheDoesNotDoubleDecodeCustomFilters(): void $this->assertSame('value', $cached[0]->getAttribute('secret')); } - public function testListCacheBypassesCacheForRandomOrder(): void + public function testQueryCacheBypassesCacheForRandomOrder(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -519,7 +519,7 @@ public function testListCacheBypassesCacheForRandomOrder(): void $this->assertCount(2, $second); } - public function testListCacheReliesOnPurgeForDocumentSecurityCollections(): void + public function testQueryCacheReliesOnPurgeForDocumentSecurityCollections(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -572,13 +572,13 @@ public function testListCacheReliesOnPurgeForDocumentSecurityCollections(): void $this->assertCount(1, $cached); - $this->assertTrue($database->purgeListCache('secureRules', '_39')); + $this->assertTrue($database->purgeQueryCache('secureRules', '_39')); $fresh = $this->findWithCache($database, 'secureRules', $queries, '_39'); $this->assertSame([], $fresh); } - public function testListCacheRehydratesNestedDocumentPayloads(): void + public function testQueryCacheRehydratesNestedDocumentPayloads(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -592,7 +592,7 @@ public function testListCacheRehydratesNestedDocumentPayloads(): void $collection = $database->getCollection('parents'); $cache->save( - $database->getListCacheKey($collection->getId(), '_39'), + $database->getQueryCacheKey($collection->getId(), '_39'), [ 'value' => [ [ @@ -605,7 +605,7 @@ public function testListCacheRehydratesNestedDocumentPayloads(): void ], ], ], - $database->getListCacheField($collection, $queries), + $database->getQueryCacheField($collection, $queries), ); $parents = $this->findWithCache($database, 'parents', $queries, '_39'); @@ -615,7 +615,7 @@ public function testListCacheRehydratesNestedDocumentPayloads(): void $this->assertSame('child-a', $parents[0]->getAttribute('child')->getId()); } - public function testListCacheRefreshesInvalidPayload(): void + public function testQueryCacheRefreshesInvalidPayload(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -647,9 +647,9 @@ public function testListCacheRefreshesInvalidPayload(): void $collection = $database->getCollection('wafRules'); $cache->save( - $database->getListCacheKey($collection->getId(), '_39'), + $database->getQueryCacheKey($collection->getId(), '_39'), ['value' => 'invalid'], - $database->getListCacheField($collection, $queries), + $database->getQueryCacheField($collection, $queries), ); $rules = $this->findWithCache($database, 'wafRules', $queries, '_39'); @@ -658,7 +658,7 @@ public function testListCacheRefreshesInvalidPayload(): void $this->assertSame('rule-a', $rules[0]->getId()); } - public function testListCacheRefreshesInvalidPayloadEntry(): void + public function testQueryCacheRefreshesInvalidPayloadEntry(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -694,7 +694,7 @@ public function testListCacheRefreshesInvalidPayloadEntry(): void $collection = $database->getCollection('wafRules'); $cache->save( - $database->getListCacheKey($collection->getId(), '_39'), + $database->getQueryCacheKey($collection->getId(), '_39'), [ 'value' => [ [ @@ -704,7 +704,7 @@ public function testListCacheRefreshesInvalidPayloadEntry(): void 'invalid', ], ], - $database->getListCacheField($collection, $queries), + $database->getQueryCacheField($collection, $queries), ); $rules = $this->findWithCache($database, 'wafRules', $queries, '_39'); From 67761b60d8adceb4d08d9164eed88dfaf76da7b3 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 23 Jun 2026 08:38:30 +0100 Subject: [PATCH 43/51] Simplify query cache helper usage --- src/Database/Database.php | 113 ++++++++++++++++++++++++++++++---- tests/unit/QueryCacheTest.php | 101 +++++++++++++++--------------- 2 files changed, 152 insertions(+), 62 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index bbbd0f77a..fe01f482a 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8633,30 +8633,25 @@ public function restoreQueryCacheDocuments( * Execute a callback behind a cache-aside lookup. * * The callback runs on cache miss and its value is returned to the caller. - * The encode callback converts fresh values into cache payloads before save. - * The decode callback converts cached payloads back into return values; a - * literal false decode result rejects the cached payload and refreshes it. + * Query document payloads are converted to arrays before save and restored + * back into Documents on cache hits. A rejected document payload refreshes + * the cached value. * A literal false value is treated as a cache miss and is not cacheable. * * @template T - * @template C * @param string $key * @param callable(): T $callback * @param string $hash - * @param (callable(T): C)|null $encode - * @param (callable(C): (T|false))|null $decode * @return T + * @throws AuthorizationException + * @throws Exception */ public function withCache( string $key, callable $callback, string $hash = '', - ?callable $encode = null, - ?callable $decode = null, ): mixed { $shouldRefreshCache = false; - $encode ??= static fn (mixed $value): mixed => $value; - $decode ??= static fn (mixed $value): mixed => $value; try { $cached = $this->cache->load($key, self::TTL, $hash); @@ -8669,7 +8664,7 @@ public function withCache( $value = \is_array($cached) && \array_key_exists('value', $cached) ? $cached['value'] : false; if ($value !== false) { - $decoded = $decode($value); + $decoded = $this->decodeCacheValue($cached, $value); if ($decoded !== false) { return $decoded; @@ -8691,7 +8686,7 @@ public function withCache( if ($value !== false) { try { - $this->cache->save($key, ['value' => $encode($value)], $hash); + $this->cache->save($key, $this->encodeCacheValue($value), $hash); } catch (Throwable $e) { Console::warning('Warning: Failed to save cache value: ' . $e->getMessage()); } @@ -8700,6 +8695,100 @@ public function withCache( return $value; } + /** + * @param array $cached + */ + private function decodeCacheValue(array $cached, mixed $value): mixed + { + $collection = $cached['collection'] ?? null; + if (!\is_string($collection) || $collection === '') { + return $value; + } + + $collection = $this->silent(fn () => $this->getCollection($collection)); + if ($collection->isEmpty()) { + return false; + } + + if (($cached['type'] ?? null) === 'document') { + $documents = $this->restoreQueryCacheDocuments($collection, [$value]); + if ($documents === false) { + return false; + } + + return $documents[0] ?? false; + } + + return $this->restoreQueryCacheDocuments($collection, $value); + } + + /** + * @return array + */ + private function encodeCacheValue(mixed $value): array + { + if ($value instanceof Document) { + $collection = $value->getCollection(); + if ($collection === '') { + return ['value' => $value]; + } + + return [ + 'collection' => $collection, + 'type' => 'document', + 'value' => $value->getArrayCopy(), + ]; + } + + $collection = $this->getCacheValueCollection($value); + if ($collection === null || !\is_array($value)) { + return ['value' => $value]; + } + + return [ + 'collection' => $collection, + 'type' => 'documents', + 'value' => $this->encodeQueryCacheValue($value), + ]; + } + + private function getCacheValueCollection(mixed $value): ?string + { + if ($value instanceof Document) { + return $value->getCollection() ?: null; + } + + if (!\is_array($value)) { + return null; + } + + foreach ($value as $item) { + $collection = $this->getCacheValueCollection($item); + if ($collection !== null) { + return $collection; + } + } + + return null; + } + + private function encodeQueryCacheValue(mixed $value): mixed + { + if ($value instanceof Document) { + return $value->getArrayCopy(); + } + + if (!\is_array($value)) { + return $value; + } + + foreach ($value as $key => $item) { + $value[$key] = $this->encodeQueryCacheValue($item); + } + + return $value; + } + /** * Helper method to iterate documents in collection using callback pattern * Alterative is diff --git a/tests/unit/QueryCacheTest.php b/tests/unit/QueryCacheTest.php index 9d925fd2d..1caaf5d9d 100644 --- a/tests/unit/QueryCacheTest.php +++ b/tests/unit/QueryCacheTest.php @@ -54,15 +54,6 @@ private function findWithCache( key: $cacheKey, callback: fn (): array => $database->find($collection, $queries, $forPermission), hash: $cacheHash, - encode: static fn (array $documents): array => \array_map( - static fn (Document $document): array => $document->getArrayCopy(), - $documents, - ), - decode: fn (mixed $payload): array|false => $database->restoreQueryCacheDocuments( - collection: $collectionDocument, - payload: $payload, - forPermission: $forPermission, - ), ); } @@ -227,77 +218,79 @@ function () use (&$callbackCalls): string { $this->assertSame(2, $callbackCalls); } - public function testWithCacheEncodesSavedValueAndDecodesCachedValue(): void + public function testWithCacheCachesSingleDocument(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); + $database->createCollection('wafRules', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + + $database->createDocument('wafRules', new Document([ + '$id' => 'rule-a', + ])); $callbackCalls = 0; + $collection = $database->getCollection('wafRules'); + $key = $database->getQueryCacheKey($collection->getId(), '_39'); + $hash = $database->getQueryCacheField($collection, field: 'document'); $first = $database->withCache( - key: 'key', - callback: function () use (&$callbackCalls): array { + key: $key, + callback: function () use ($database, &$callbackCalls): Document { $callbackCalls++; - return ['fresh']; + return $database->getDocument('wafRules', 'rule-a'); }, - encode: static fn (array $value): array => ['encoded' => $value], - decode: static fn (array $value): array => $value['encoded'], + hash: $hash, ); - $second = $database->withCache( - key: 'key', - callback: function () use (&$callbackCalls): array { + key: $key, + callback: function () use ($database, &$callbackCalls): Document { $callbackCalls++; - return ['miss']; + return $database->getDocument('wafRules', 'missing'); }, - encode: static fn (array $value): array => ['encoded' => $value], - decode: static fn (array $value): array => $value['encoded'], + hash: $hash, ); - $this->assertSame(['fresh'], $first); - $this->assertSame(['fresh'], $second); + $this->assertSame('rule-a', $first->getId()); + $this->assertSame('rule-a', $second->getId()); $this->assertSame(1, $callbackCalls); } - public function testWithCacheRefreshesWhenDecodeRejectsCachedValue(): void + public function testWithCacheCachesStaticQueryValues(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); + $database->createCollection('wafRules', permissions: [ + Permission::read(Role::any()), + ]); $callbackCalls = 0; + $collection = $database->getCollection('wafRules'); + $key = $database->getQueryCacheKey($collection->getId(), '_39'); + $hash = $database->getQueryCacheField($collection, field: 'count'); - $database->withCache( - key: 'key', - callback: function () use (&$callbackCalls): array { - $callbackCalls++; - return ['stale']; - }, - encode: static fn (array $value): array => $value, - ); - - $fresh = $database->withCache( - key: 'key', - callback: function () use (&$callbackCalls): array { + $first = $database->withCache( + key: $key, + callback: function () use (&$callbackCalls): int { $callbackCalls++; - return ['fresh']; + return 10; }, - encode: static fn (array $value): array => $value, - decode: static fn (array $value): array|false => $value === ['stale'] ? false : $value, + hash: $hash, ); - - $cachedFresh = $database->withCache( - key: 'key', - callback: function () use (&$callbackCalls): array { + $second = $database->withCache( + key: $key, + callback: function () use (&$callbackCalls): int { $callbackCalls++; - return ['miss']; + return 20; }, - encode: static fn (array $value): array => $value, - decode: static fn (array $value): array|false => $value === ['stale'] ? false : $value, + hash: $hash, ); - $this->assertSame(['fresh'], $fresh); - $this->assertSame(['fresh'], $cachedFresh); - $this->assertSame(2, $callbackCalls); + $this->assertSame(10, $first); + $this->assertSame(10, $second); + $this->assertSame(1, $callbackCalls); } public function testQueryCacheUsesCacheUntilPurged(): void @@ -594,6 +587,8 @@ public function testQueryCacheRehydratesNestedDocumentPayloads(): void $cache->save( $database->getQueryCacheKey($collection->getId(), '_39'), [ + 'collection' => $collection->getId(), + 'type' => 'documents', 'value' => [ [ '$id' => 'parent-a', @@ -648,7 +643,11 @@ public function testQueryCacheRefreshesInvalidPayload(): void $collection = $database->getCollection('wafRules'); $cache->save( $database->getQueryCacheKey($collection->getId(), '_39'), - ['value' => 'invalid'], + [ + 'collection' => $collection->getId(), + 'type' => 'documents', + 'value' => 'invalid', + ], $database->getQueryCacheField($collection, $queries), ); @@ -696,6 +695,8 @@ public function testQueryCacheRefreshesInvalidPayloadEntry(): void $cache->save( $database->getQueryCacheKey($collection->getId(), '_39'), [ + 'collection' => $collection->getId(), + 'type' => 'documents', 'value' => [ [ '$id' => 'rule-a', From 122bc3bc88f7460c62e726832a0f955c1260a8c5 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 23 Jun 2026 09:46:25 +0100 Subject: [PATCH 44/51] Align query cache with read list cache --- src/Database/Database.php | 23 ++++++++++------------- tests/unit/CacheKeyTest.php | 1 - tests/unit/QueryCacheTest.php | 20 ++++++++++---------- 3 files changed, 20 insertions(+), 24 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index fe01f482a..6350ac148 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8589,22 +8589,20 @@ public function purgeQueryCache(string $collection, ?string $namespace = null): * * @param Document $collection * @param mixed $payload - * @param string $forPermission * @return array|false * @throws AuthorizationException * @throws Exception */ - public function restoreQueryCacheDocuments( + private function restoreQueryCacheDocuments( Document $collection, mixed $payload, - string $forPermission = Database::PERMISSION_READ, ): array|false { if (!\is_array($payload)) { return false; } $documentSecurity = $collection->getAttribute('documentSecurity', false); - $skipAuth = $this->authorization->isValid(new Input($forPermission, $collection->getPermissionsByType($forPermission))); + $skipAuth = $this->authorization->isValid(new Input(self::PERMISSION_READ, $collection->getRead())); if (!$skipAuth && !$documentSecurity && $collection->getId() !== self::METADATA) { throw new AuthorizationException($this->authorization->getDescription()); @@ -8711,17 +8709,19 @@ private function decodeCacheValue(array $cached, mixed $value): mixed } if (($cached['type'] ?? null) === 'document') { - $documents = $this->restoreQueryCacheDocuments($collection, [$value]); - if ($documents === false) { - return false; - } - - return $documents[0] ?? false; + return $this->restoreQueryCacheDocument($collection, $value); } return $this->restoreQueryCacheDocuments($collection, $value); } + private function restoreQueryCacheDocument(Document $collection, mixed $payload): Document|false + { + $documents = $this->restoreQueryCacheDocuments($collection, [$payload]); + + return $documents === false ? false : ($documents[0] ?? false); + } + /** * @return array */ @@ -9714,14 +9714,12 @@ public function getQueryCacheKey(string $collectionId, ?string $namespace = null * @param Document|null $collection * @param array $queries * @param string $field - * @param string $forPermission * @return string */ public function getQueryCacheField( ?Document $collection = null, array $queries = [], string $field = 'documents', - string $forPermission = self::PERMISSION_READ, ): string { $this->checkQueryTypes($queries); @@ -9735,7 +9733,6 @@ public function getQueryCacheField( 'roles' => $authorizationRoles, ], 'database' => $this->getDatabase(), - 'permission' => $forPermission, 'queries' => \array_map( fn (Query $query): array => $this->serializeQueryCacheQuery($query), $queries, diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index aff48963e..ed29179e9 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -222,7 +222,6 @@ public function testQueryCacheFieldChangesWithInputs(): void ), ); $this->assertNotSame($field, $db->getQueryCacheField(null, [Query::limit(20)])); - $this->assertNotSame($field, $db->getQueryCacheField(null, [Query::limit(10)], forPermission: Database::PERMISSION_UPDATE)); $this->assertStringEndsWith(':total', $db->getQueryCacheField(null, [Query::limit(10)], 'total')); } diff --git a/tests/unit/QueryCacheTest.php b/tests/unit/QueryCacheTest.php index 1caaf5d9d..87044e7b8 100644 --- a/tests/unit/QueryCacheTest.php +++ b/tests/unit/QueryCacheTest.php @@ -38,21 +38,20 @@ private function findWithCache( string $collection, array $queries = [], ?string $namespace = null, - string $forPermission = Database::PERMISSION_READ, ): array { foreach ($queries as $query) { if ($query instanceof Query && $query->getMethod() === Query::TYPE_ORDER_RANDOM) { - return $database->find($collection, $queries, $forPermission); + return $database->find($collection, $queries); } } $collectionDocument = $database->getCollection($collection); $cacheKey = $database->getQueryCacheKey($collectionDocument->getId(), $namespace); - $cacheHash = $database->getQueryCacheField($collectionDocument, $queries, 'documents', $forPermission); + $cacheHash = $database->getQueryCacheField($collectionDocument, $queries); return $database->withCache( key: $cacheKey, - callback: fn (): array => $database->find($collection, $queries, $forPermission), + callback: fn (): array => $database->find($collection, $queries), hash: $cacheHash, ); } @@ -346,7 +345,7 @@ public function testQueryCacheUsesCacheUntilPurged(): void )); } - public function testQueryCacheSeparatesEntriesByPermissionMode(): void + public function testQueryCacheSeparatesEntriesByAuthorizationContext(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); @@ -363,7 +362,6 @@ public function testQueryCacheSeparatesEntriesByPermissionMode(): void ], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - Permission::update(Role::any()), ]); $database->createDocument('wafRules', new Document([ @@ -377,18 +375,20 @@ public function testQueryCacheSeparatesEntriesByPermissionMode(): void Query::limit(25), ]; - $this->findWithCache($database, 'wafRules', $queries, '_39', forPermission: Database::PERMISSION_READ); + $this->findWithCache($database, 'wafRules', $queries, '_39'); $database->createDocument('wafRules', new Document([ '$id' => 'rule-b', 'projectId' => 'project-a', ])); - $cached = $this->findWithCache($database, 'wafRules', $queries, '_39', forPermission: Database::PERMISSION_READ); + $cached = $this->findWithCache($database, 'wafRules', $queries, '_39'); $this->assertCount(1, $cached); - $permissionSeparated = $this->findWithCache($database, 'wafRules', $queries, '_39', forPermission: Database::PERMISSION_UPDATE); - $this->assertCount(2, $permissionSeparated); + $database->getAuthorization()->addRole(Role::user('user-1')->toString()); + + $roleSeparated = $this->findWithCache($database, 'wafRules', $queries, '_39'); + $this->assertCount(2, $roleSeparated); } public function testQueryCacheRecastsCacheHits(): void From 24a00f76b6d463ef048ec60dbaf67c12bc4b83e1 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 23 Jun 2026 10:15:40 +0100 Subject: [PATCH 45/51] Bypass query cache for non-read permissions --- src/Database/Database.php | 10 ++++++++-- tests/unit/CacheKeyTest.php | 7 +++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 6350ac148..e3482c2d0 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -9714,15 +9714,21 @@ public function getQueryCacheKey(string $collectionId, ?string $namespace = null * @param Document|null $collection * @param array $queries * @param string $field - * @return string + * @param string $forPermission + * @return string|null */ public function getQueryCacheField( ?Document $collection = null, array $queries = [], string $field = 'documents', - ): string { + string $forPermission = self::PERMISSION_READ, + ): ?string { $this->checkQueryTypes($queries); + if ($forPermission !== self::PERMISSION_READ) { + return null; + } + $authorizationRoles = \array_values(\array_unique($this->authorization->getRoles())); \sort($authorizationRoles); diff --git a/tests/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index ed29179e9..92c3bc608 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -244,6 +244,13 @@ public function testQueryCacheFieldChangesWithActiveAuthorizationContext(): void ); } + public function testQueryCacheFieldReturnsNullForNonReadPermission(): void + { + $db = $this->createDatabase(); + + $this->assertNull($db->getQueryCacheField(forPermission: Database::PERMISSION_UPDATE)); + } + public function testQueryCacheFieldIncludesCursorDocumentPayload(): void { $db = $this->createDatabase(); From 5b87fc86e47b69c8dcb34038dbee2ca48745aa53 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 23 Jun 2026 10:31:23 +0100 Subject: [PATCH 46/51] Harden query cache bypass and payload encoding --- src/Database/Database.php | 42 +++++++++++++++++++++---- tests/unit/QueryCacheTest.php | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 6 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index e3482c2d0..037ae1961 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8639,7 +8639,7 @@ private function restoreQueryCacheDocuments( * @template T * @param string $key * @param callable(): T $callback - * @param string $hash + * @param string|null $hash * @return T * @throws AuthorizationException * @throws Exception @@ -8647,8 +8647,12 @@ private function restoreQueryCacheDocuments( public function withCache( string $key, callable $callback, - string $hash = '', + ?string $hash = '', ): mixed { + if ($hash === null) { + return $callback(); + } + $shouldRefreshCache = false; try { @@ -8684,7 +8688,10 @@ public function withCache( if ($value !== false) { try { - $this->cache->save($key, $this->encodeCacheValue($value), $hash); + $encoded = $this->encodeCacheValue($value); + if ($encoded !== false) { + $this->cache->save($key, $encoded, $hash); + } } catch (Throwable $e) { Console::warning('Warning: Failed to save cache value: ' . $e->getMessage()); } @@ -8723,14 +8730,14 @@ private function restoreQueryCacheDocument(Document $collection, mixed $payload) } /** - * @return array + * @return array|false */ - private function encodeCacheValue(mixed $value): array + private function encodeCacheValue(mixed $value): array|false { if ($value instanceof Document) { $collection = $value->getCollection(); if ($collection === '') { - return ['value' => $value]; + return false; } return [ @@ -8742,6 +8749,10 @@ private function encodeCacheValue(mixed $value): array $collection = $this->getCacheValueCollection($value); if ($collection === null || !\is_array($value)) { + if ($this->hasCacheValueDocument($value)) { + return false; + } + return ['value' => $value]; } @@ -8772,6 +8783,25 @@ private function getCacheValueCollection(mixed $value): ?string return null; } + private function hasCacheValueDocument(mixed $value): bool + { + if ($value instanceof Document) { + return true; + } + + if (!\is_array($value)) { + return false; + } + + foreach ($value as $item) { + if ($this->hasCacheValueDocument($item)) { + return true; + } + } + + return false; + } + private function encodeQueryCacheValue(mixed $value): mixed { if ($value instanceof Document) { diff --git a/tests/unit/QueryCacheTest.php b/tests/unit/QueryCacheTest.php index 87044e7b8..d31fb9568 100644 --- a/tests/unit/QueryCacheTest.php +++ b/tests/unit/QueryCacheTest.php @@ -217,6 +217,64 @@ function () use (&$callbackCalls): string { $this->assertSame(2, $callbackCalls); } + public function testWithCacheBypassesCacheForNullHash(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + + $callbackCalls = 0; + + $first = $database->withCache( + key: 'key', + callback: function () use (&$callbackCalls): string { + $callbackCalls++; + return 'first'; + }, + hash: null, + ); + $second = $database->withCache( + key: 'key', + callback: function () use (&$callbackCalls): string { + $callbackCalls++; + return 'second'; + }, + hash: null, + ); + + $this->assertSame('first', $first); + $this->assertSame('second', $second); + $this->assertSame(2, $callbackCalls); + $this->assertSame([], $cache->list('key')); + } + + public function testWithCacheDoesNotCacheCollectionlessDocuments(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + + $callbackCalls = 0; + + $first = $database->withCache( + key: 'key', + callback: function () use (&$callbackCalls): Document { + $callbackCalls++; + return new Document(['$id' => 'first']); + }, + ); + $second = $database->withCache( + key: 'key', + callback: function () use (&$callbackCalls): Document { + $callbackCalls++; + return new Document(['$id' => 'second']); + }, + ); + + $this->assertSame('first', $first->getId()); + $this->assertSame('second', $second->getId()); + $this->assertSame(2, $callbackCalls); + $this->assertSame([], $cache->list('key')); + } + public function testWithCacheCachesSingleDocument(): void { $cache = new HashMemoryCache(); From 97634629bf64fd092de7baa9f2a89d92de652dfe Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 23 Jun 2026 12:12:24 +0100 Subject: [PATCH 47/51] Simplify query cache value handling --- src/Database/Database.php | 309 +++++++++++++++------------------- tests/unit/QueryCacheTest.php | 36 ++++ 2 files changed, 170 insertions(+), 175 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 037ae1961..b22d4d63a 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8581,52 +8581,6 @@ public function purgeQueryCache(string $collection, ?string $namespace = null): ); } - /** - * Restore a cached query document payload into database documents. - * - * Returns false when the cached payload is invalid or stale and should be - * refreshed by the caller. - * - * @param Document $collection - * @param mixed $payload - * @return array|false - * @throws AuthorizationException - * @throws Exception - */ - private function restoreQueryCacheDocuments( - Document $collection, - mixed $payload, - ): array|false { - if (!\is_array($payload)) { - return false; - } - - $documentSecurity = $collection->getAttribute('documentSecurity', false); - $skipAuth = $this->authorization->isValid(new Input(self::PERMISSION_READ, $collection->getRead())); - - if (!$skipAuth && !$documentSecurity && $collection->getId() !== self::METADATA) { - throw new AuthorizationException($this->authorization->getDescription()); - } - - $documents = []; - foreach ($payload as $document) { - if (!\is_array($document)) { - return false; - } - - $document = $this->createDocumentInstance($collection->getId(), $document); - $document = $this->casting($collection, $document); - - if ($this->isTtlExpired($collection, $document)) { - return false; - } - - $documents[] = $document; - } - - return $documents; - } - /** * Execute a callback behind a cache-aside lookup. * @@ -8666,7 +8620,54 @@ public function withCache( $value = \is_array($cached) && \array_key_exists('value', $cached) ? $cached['value'] : false; if ($value !== false) { - $decoded = $this->decodeCacheValue($cached, $value); + $decoded = $value; + $collection = $cached['collection'] ?? null; + + if (\is_string($collection) && $collection !== '') { + // Cached document payloads are stored as arrays; restore them + // to the same Document shape that find()/getDocument() return. + $collection = $this->silent(fn () => $this->getCollection($collection)); + + if ($collection->isEmpty()) { + $decoded = false; + } else { + $documentSecurity = $collection->getAttribute('documentSecurity', false); + $skipAuth = $this->authorization->isValid(new Input(self::PERMISSION_READ, $collection->getRead())); + + if (!$skipAuth && !$documentSecurity && $collection->getId() !== self::METADATA) { + throw new AuthorizationException($this->authorization->getDescription()); + } + + $payload = ($cached['type'] ?? null) === 'document' ? [$value] : $value; + + if (!\is_array($payload)) { + $decoded = false; + } else { + $documents = []; + + foreach ($payload as $document) { + if (!\is_array($document)) { + $decoded = false; + break; + } + + $document = $this->createDocumentInstance($collection->getId(), $document); + $document = $this->casting($collection, $document); + + if ($this->isTtlExpired($collection, $document)) { + $decoded = false; + break; + } + + $documents[] = $document; + } + + if ($decoded !== false) { + $decoded = ($cached['type'] ?? null) === 'document' ? ($documents[0] ?? false) : $documents; + } + } + } + } if ($decoded !== false) { return $decoded; @@ -8688,134 +8689,95 @@ public function withCache( if ($value !== false) { try { - $encoded = $this->encodeCacheValue($value); - if ($encoded !== false) { - $this->cache->save($key, $encoded, $hash); - } - } catch (Throwable $e) { - Console::warning('Warning: Failed to save cache value: ' . $e->getMessage()); - } - } - - return $value; - } - - /** - * @param array $cached - */ - private function decodeCacheValue(array $cached, mixed $value): mixed - { - $collection = $cached['collection'] ?? null; - if (!\is_string($collection) || $collection === '') { - return $value; - } - - $collection = $this->silent(fn () => $this->getCollection($collection)); - if ($collection->isEmpty()) { - return false; - } - - if (($cached['type'] ?? null) === 'document') { - return $this->restoreQueryCacheDocument($collection, $value); - } - - return $this->restoreQueryCacheDocuments($collection, $value); - } - - private function restoreQueryCacheDocument(Document $collection, mixed $payload): Document|false - { - $documents = $this->restoreQueryCacheDocuments($collection, [$payload]); + $encoded = false; - return $documents === false ? false : ($documents[0] ?? false); - } - - /** - * @return array|false - */ - private function encodeCacheValue(mixed $value): array|false - { - if ($value instanceof Document) { - $collection = $value->getCollection(); - if ($collection === '') { - return false; - } + if ($value instanceof Document) { + $collection = $value->getCollection(); + + if ($collection !== '') { + $encoded = [ + 'collection' => $collection, + 'type' => 'document', + 'value' => $value->getArrayCopy(), + ]; + } + } elseif (!\is_array($value)) { + $encoded = ['value' => $value]; + } else { + // Only homogeneous top-level document lists are safe to restore + // from cache. Mixed or nested Document payloads keep callback shape. + $collection = null; + $hasDocuments = false; + $hasNonDocuments = false; + $cacheable = true; + $documents = []; + $containsDocument = function (mixed $item) use (&$containsDocument): bool { + if ($item instanceof Document) { + return true; + } - return [ - 'collection' => $collection, - 'type' => 'document', - 'value' => $value->getArrayCopy(), - ]; - } + if (!\is_array($item)) { + return false; + } - $collection = $this->getCacheValueCollection($value); - if ($collection === null || !\is_array($value)) { - if ($this->hasCacheValueDocument($value)) { - return false; - } + foreach ($item as $child) { + if ($containsDocument($child)) { + return true; + } + } - return ['value' => $value]; - } + return false; + }; - return [ - 'collection' => $collection, - 'type' => 'documents', - 'value' => $this->encodeQueryCacheValue($value), - ]; - } + foreach ($value as $item) { + if (!$item instanceof Document) { + if ($hasDocuments || $containsDocument($item)) { + $cacheable = false; + break; + } - private function getCacheValueCollection(mixed $value): ?string - { - if ($value instanceof Document) { - return $value->getCollection() ?: null; - } + $hasNonDocuments = true; + continue; + } - if (!\is_array($value)) { - return null; - } + if ($hasNonDocuments) { + $cacheable = false; + break; + } - foreach ($value as $item) { - $collection = $this->getCacheValueCollection($item); - if ($collection !== null) { - return $collection; - } - } + $documentCollection = $item->getCollection(); + if ($documentCollection === '') { + $cacheable = false; + break; + } - return null; - } + if ($collection !== null && $collection !== $documentCollection) { + $cacheable = false; + break; + } - private function hasCacheValueDocument(mixed $value): bool - { - if ($value instanceof Document) { - return true; - } + $collection = $documentCollection; + $hasDocuments = true; + $documents[] = $item->getArrayCopy(); + } - if (!\is_array($value)) { - return false; - } + if ($cacheable) { + $encoded = $hasDocuments ? [ + 'collection' => $collection, + 'type' => 'documents', + 'value' => $documents, + ] : ['value' => $value]; + } + } - foreach ($value as $item) { - if ($this->hasCacheValueDocument($item)) { - return true; + if ($encoded !== false) { + $this->cache->save($key, $encoded, $hash); + } + } catch (Throwable $e) { + Console::warning('Warning: Failed to save cache value: ' . $e->getMessage()); } } - return false; - } - - private function encodeQueryCacheValue(mixed $value): mixed - { - if ($value instanceof Document) { - return $value->getArrayCopy(); - } - - if (!\is_array($value)) { - return $value; - } - - foreach ($value as $key => $item) { - $value[$key] = $this->encodeQueryCacheValue($item); - } - return $value; } @@ -9777,9 +9739,20 @@ public function getQueryCacheField( 'filters' => $this->getActiveFilterSignatures(), ]; + $schemaHash = ''; + if ($collection !== null && !$collection->isEmpty()) { + // Schema-affecting changes must move callers onto a fresh cache field. + $schemaHash = \md5( + \json_encode($collection->getAttribute('attributes', [])) + . \json_encode($collection->getAttribute('indexes', [])) + . \json_encode($collection->getAttribute('$permissions', [])) + . \json_encode($collection->getAttribute('documentSecurity', false)) + ); + } + return \sprintf( '%s:%s:%s', - $this->getQueryCacheSchemaHash($collection), + $schemaHash, \md5(\json_encode($queryPayload) ?: ''), $field, ); @@ -9830,20 +9803,6 @@ private function normalizeQueryCacheQueryValue(mixed $value): mixed return $value; } - private function getQueryCacheSchemaHash(?Document $collection): string - { - if ($collection === null || $collection->isEmpty()) { - return ''; - } - - return \md5( - \json_encode($collection->getAttribute('attributes', [])) - . \json_encode($collection->getAttribute('indexes', [])) - . \json_encode($collection->getAttribute('$permissions', [])) - . \json_encode($collection->getAttribute('documentSecurity', false)) - ); - } - /** * @return array */ diff --git a/tests/unit/QueryCacheTest.php b/tests/unit/QueryCacheTest.php index d31fb9568..502393d75 100644 --- a/tests/unit/QueryCacheTest.php +++ b/tests/unit/QueryCacheTest.php @@ -275,6 +275,42 @@ public function testWithCacheDoesNotCacheCollectionlessDocuments(): void $this->assertSame([], $cache->list('key')); } + public function testWithCacheDoesNotCacheMixedDocumentArrays(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + $database->createCollection('wafRules', permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]); + + $database->createDocument('wafRules', new Document([ + '$id' => 'rule-a', + ])); + + $callbackCalls = 0; + + $first = $database->withCache( + key: 'key', + callback: function () use ($database, &$callbackCalls): array { + $callbackCalls++; + return ['prefix', $database->getDocument('wafRules', 'rule-a')]; + }, + ); + $second = $database->withCache( + key: 'key', + callback: function () use ($database, &$callbackCalls): array { + $callbackCalls++; + return ['prefix', $database->getDocument('wafRules', 'rule-a')]; + }, + ); + + $this->assertSame('rule-a', $first[1]->getId()); + $this->assertSame('rule-a', $second[1]->getId()); + $this->assertSame(2, $callbackCalls); + $this->assertSame([], $cache->list('key')); + } + public function testWithCacheCachesSingleDocument(): void { $cache = new HashMemoryCache(); From 2cc9fa3c8516a38e1ccecc2a3b9b6b79195aef87 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 23 Jun 2026 12:43:10 +0100 Subject: [PATCH 48/51] Fix query cache return type analysis --- src/Database/Database.php | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index b22d4d63a..5685cbe79 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8617,10 +8617,10 @@ public function withCache( } if ($cached !== false && $cached !== null) { - $value = \is_array($cached) && \array_key_exists('value', $cached) ? $cached['value'] : false; + $cachedValue = \is_array($cached) && \array_key_exists('value', $cached) ? $cached['value'] : false; - if ($value !== false) { - $decoded = $value; + if ($cachedValue !== false) { + $decoded = $cachedValue; $collection = $cached['collection'] ?? null; if (\is_string($collection) && $collection !== '') { @@ -8638,7 +8638,7 @@ public function withCache( throw new AuthorizationException($this->authorization->getDescription()); } - $payload = ($cached['type'] ?? null) === 'document' ? [$value] : $value; + $payload = ($cached['type'] ?? null) === 'document' ? [$cachedValue] : $cachedValue; if (!\is_array($payload)) { $decoded = false; @@ -8685,24 +8685,24 @@ public function withCache( } } - $value = $callback(); + $callbackValue = $callback(); - if ($value !== false) { + if ($callbackValue !== false) { try { $encoded = false; - if ($value instanceof Document) { - $collection = $value->getCollection(); + if ($callbackValue instanceof Document) { + $collection = $callbackValue->getCollection(); if ($collection !== '') { $encoded = [ 'collection' => $collection, 'type' => 'document', - 'value' => $value->getArrayCopy(), + 'value' => $callbackValue->getArrayCopy(), ]; } - } elseif (!\is_array($value)) { - $encoded = ['value' => $value]; + } elseif (!\is_array($callbackValue)) { + $encoded = ['value' => $callbackValue]; } else { // Only homogeneous top-level document lists are safe to restore // from cache. Mixed or nested Document payloads keep callback shape. @@ -8729,7 +8729,7 @@ public function withCache( return false; }; - foreach ($value as $item) { + foreach ($callbackValue as $item) { if (!$item instanceof Document) { if ($hasDocuments || $containsDocument($item)) { $cacheable = false; @@ -8766,7 +8766,7 @@ public function withCache( 'collection' => $collection, 'type' => 'documents', 'value' => $documents, - ] : ['value' => $value]; + ] : ['value' => $callbackValue]; } } @@ -8778,7 +8778,8 @@ public function withCache( } } - return $value; + /** @var T $callbackValue */ + return $callbackValue; } /** From b9c2e4696a6d17dc9450aaeb635422d8b6222472 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 23 Jun 2026 12:57:42 +0100 Subject: [PATCH 49/51] Clarify query cache document array comment --- src/Database/Database.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 5685cbe79..01afdf501 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8705,7 +8705,7 @@ public function withCache( $encoded = ['value' => $callbackValue]; } else { // Only homogeneous top-level document lists are safe to restore - // from cache. Mixed or nested Document payloads keep callback shape. + // from cache. Plain arrays containing Documents are left uncached. $collection = null; $hasDocuments = false; $hasNonDocuments = false; From 94d11546152f0c217fdc4f0c940859cdec2ec95d Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 23 Jun 2026 13:25:43 +0100 Subject: [PATCH 50/51] Filter document security cache hits --- src/Database/Database.php | 11 +++++++++ tests/unit/QueryCacheTest.php | 46 +++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/Database/Database.php b/src/Database/Database.php index 01afdf501..b0b28e566 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8659,6 +8659,17 @@ public function withCache( break; } + if (!$skipAuth && $documentSecurity && $collection->getId() !== self::METADATA) { + if (!$this->authorization->isValid(new Input(self::PERMISSION_READ, $document->getRead()))) { + if (($cached['type'] ?? null) === 'document') { + $decoded = false; + break; + } + + continue; + } + } + $documents[] = $document; } diff --git a/tests/unit/QueryCacheTest.php b/tests/unit/QueryCacheTest.php index 502393d75..c99c60988 100644 --- a/tests/unit/QueryCacheTest.php +++ b/tests/unit/QueryCacheTest.php @@ -665,6 +665,52 @@ public function testQueryCacheReliesOnPurgeForDocumentSecurityCollections(): voi $this->assertSame([], $fresh); } + public function testQueryCacheFiltersDocumentSecurityPayloadsOnHit(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + $database->getAuthorization()->skip(function () use ($database): void { + $database->createCollection('secureRules', permissions: [ + Permission::create(Role::any()), + ], documentSecurity: true); + }); + + $database->getAuthorization()->addRole(Role::user('user-1')->toString()); + + $collection = $database->getCollection('secureRules'); + $key = $database->getQueryCacheKey($collection->getId(), '_39'); + $hash = $database->getQueryCacheField($collection); + $database->getCache()->save($key, [ + 'collection' => $collection->getId(), + 'type' => 'documents', + 'value' => [ + [ + '$id' => 'rule-a', + '$permissions' => [ + Permission::read(Role::user('user-2')), + ], + ], + ], + ], $hash); + + $callbackCalls = 0; + $documents = $database->withCache( + key: $key, + callback: function () use (&$callbackCalls): array { + $callbackCalls++; + return [ + new Document([ + '$id' => 'fresh', + ]), + ]; + }, + hash: $hash, + ); + + $this->assertSame([], $documents); + $this->assertSame(0, $callbackCalls); + } + public function testQueryCacheRehydratesNestedDocumentPayloads(): void { $cache = new HashMemoryCache(); From 4f6ad39f85430ac59ab809798a22fb2cf41e8739 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 23 Jun 2026 14:19:25 +0100 Subject: [PATCH 51/51] Rename query cache purge method --- src/Database/Database.php | 2 +- tests/unit/QueryCacheTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index b0b28e566..46e7fd84f 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8571,7 +8571,7 @@ public function find(string $collection, array $queries = [], string $forPermiss * @param string|null $namespace * @return bool */ - public function purgeQueryCache(string $collection, ?string $namespace = null): bool + public function purgeCachedQueries(string $collection, ?string $namespace = null): bool { $collectionDocument = $this->silent(fn () => $this->getCollection($collection)); $collection = $collectionDocument->isEmpty() ? $collection : $collectionDocument->getId(); diff --git a/tests/unit/QueryCacheTest.php b/tests/unit/QueryCacheTest.php index c99c60988..8103a0dd2 100644 --- a/tests/unit/QueryCacheTest.php +++ b/tests/unit/QueryCacheTest.php @@ -429,7 +429,7 @@ public function testQueryCacheUsesCacheUntilPurged(): void $this->assertCount(1, $cached); $this->assertSame('rule-a', $cached[0]->getId()); - $this->assertTrue($database->purgeQueryCache('wafRules', '_39')); + $this->assertTrue($database->purgeCachedQueries('wafRules', '_39')); $fresh = $this->findWithCache($database, 'wafRules', $queries, '_39'); $this->assertCount(2, $fresh); @@ -659,7 +659,7 @@ public function testQueryCacheReliesOnPurgeForDocumentSecurityCollections(): voi $this->assertCount(1, $cached); - $this->assertTrue($database->purgeQueryCache('secureRules', '_39')); + $this->assertTrue($database->purgeCachedQueries('secureRules', '_39')); $fresh = $this->findWithCache($database, 'secureRules', $queries, '_39'); $this->assertSame([], $fresh);