diff --git a/src/Database/Database.php b/src/Database/Database.php index 5a6cd97d7..960f04f71 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8353,10 +8353,35 @@ public function purgeCachedCollection(string $collectionId): bool } $this->cache->purge($collectionKey); + $this->purgeCachedFinds($collectionId); return true; } + public function purgeCachedFinds(string $collectionId): bool + { + [$findIndexKey] = $this->getCachedFindKeys($collectionId); + + foreach ($this->cache->list($findIndexKey) as $findKey) { + $this->cache->purge($findKey); + } + + return $this->cache->purge($findIndexKey); + } + + /** + * @param array $queries + */ + public function purgeCachedFind(string $collectionId, ?string $key = null, array $queries = []): bool + { + $collection = $this->silent(fn () => $this->getCollection($collectionId)); + [$findIndexKey, $findKey] = $this->authorization->skip(fn () => $this->getCachedFindKeys($collectionId, $queries, $key, $collection)); + + $this->cache->purge($findIndexKey, $findKey); + + return $this->cache->purge($findKey); + } + /** * Cleans a specific document from cache * And related document reference in the collection cache. @@ -8564,6 +8589,176 @@ 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 + { + 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'); + } + + [$findIndexKey, $findKey] = $this->getCachedFindKeys($collectionDocument->getId(), $queries, $key, $collectionDocument); + $cached = $this->loadCachedFind($findKey, $ttl); + + if (!\is_array($cached)) { + return $this->findAndCache($collectionDocument, $queries, $forPermission, $findIndexKey, $findKey); + } + + [$documents, $hasExpiredDocuments] = $this->decodeCachedFindPayload($collectionDocument, $cached); + + if ($hasExpiredDocuments) { + $this->purgeCachedFindKey($findIndexKey, $findKey); + + return $this->findAndCache($collectionDocument, $queries, $forPermission, $findIndexKey, $findKey); + } + + if ($touchOnHit) { + $this->touchCachedFind($findKey); + } + + $this->trigger(self::EVENT_DOCUMENT_FIND, $documents); + + return $documents; + } + + /** + * @param array $queries + * @return array + * @throws DatabaseException + * @throws QueryException + * @throws TimeoutException + * @throws Exception + */ + private function findAndCache(Document $collection, array $queries, string $forPermission, string $findIndexKey, string $findKey): array + { + $documents = $this->find($collection->getId(), $queries, $forPermission); + $this->saveCachedFind($findIndexKey, $findKey, $documents); + + return $documents; + } + + private function loadCachedFind(string $findKey, int $ttl): mixed + { + try { + return $this->cache->load($findKey, $ttl); + } catch (Exception $e) { + Console::warning('Failed to get find result from cache: ' . $e->getMessage()); + + return null; + } + } + + /** + * @param array $documents + */ + private function saveCachedFind(string $findIndexKey, string $findKey, array $documents): void + { + try { + $this->cache->save($findIndexKey, $findKey, $findKey); + $this->cache->save($findKey, [ + 'version' => 1, + 'documents' => \array_map( + static fn (Document $document): array => $document->getArrayCopy(), + $documents + ), + ]); + } catch (Exception $e) { + Console::warning('Failed to save find result to cache: ' . $e->getMessage()); + } + } + + private function touchCachedFind(string $findKey): void + { + try { + $this->cache->touch($findKey); + } catch (Exception $e) { + Console::warning('Failed to touch find result cache: ' . $e->getMessage()); + } + } + + private function purgeCachedFindKey(string $findIndexKey, string $findKey): void + { + try { + $this->cache->purge($findIndexKey, $findKey); + $this->cache->purge($findKey); + } catch (Exception $e) { + Console::warning('Failed to purge expired find result cache: ' . $e->getMessage()); + } + } + + /** + * @param array $payload + * @return array{0: array, 1: bool} + */ + private function decodeCachedFindPayload(Document $collection, array $payload): array + { + $results = []; + $hasExpiredDocuments = false; + $documents = \is_array($payload['documents'] ?? null) ? $payload['documents'] : $payload; + + foreach ($documents 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 @@ -9445,34 +9640,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->getActiveFilterSignatures(), ]) ?: ''; $documentHashKey = $documentKey . ':' . \md5($payload); } @@ -9484,6 +9655,153 @@ 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 + * @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); + $findIndexKey = "{$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(), + ]; + + if ($key !== null) { + return [$findIndexKey, $collectionKey . ':' . $key]; + } + + return [$findIndexKey, $findIndexKey . ':' . \md5(\json_encode($payload) ?: '')]; + } + + /** + * @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; + } + + $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 ''; + } + + return \md5( + \json_encode($collection->getAttribute('attributes', [])) + . \json_encode($collection->getAttribute('indexes', [])) + ); + } + + /** + * @return array + */ + private function getActiveFilterSignatures(): 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/Base.php b/tests/e2e/Adapter/Base.php index 4baeba35b..105c1af62 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 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 23d779db0..86ad85c63 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 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 4f998372d..6758190da 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -103,6 +103,47 @@ 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 118bf4897..7d1cbc3e3 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,153 @@ 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 testCallerFindCacheKeyUsesReadableEntryKey(): void + { + $db = $this->createDatabase(); + + [$indexKey, $entryKey] = $db->getCachedFindKeys('wafrules', key: 'rules_v1'); + + $this->assertEquals('default-cache-:test::collection:wafrules:find', $indexKey); + $this->assertEquals('default-cache-:test::collection:wafrules:rules_v1', $entryKey); + } + + 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'); + $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 { $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..7c2df8b31 --- /dev/null +++ b/tests/unit/FindCacheTest.php @@ -0,0 +1,438 @@ +database = $this->createDatabase(new HashMemoryCache()); + } + + 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 testFindCachedStoresEmptyResults(): void + { + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); + $this->assertCount(0, $documents); + + $this->seedProject($this->database, 'first', 'First'); + + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); + $this->assertCount(0, $documents); + + $this->database->purgeCachedFinds('projects'); + + $documents = $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', [Query::orderAsc('name')], ttl: 3600)); + $this->assertCount(1, $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 testFindCachedDelegatesMalformedQueriesToFind(): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Invalid query type'); + + /** @var array $queries Intentionally malformed to exercise runtime validation. */ + $queries = ['not-a-query']; + + $this->database->getAuthorization()->skip(fn () => $this->database->findCached('projects', $queries, ttl: 3600)); + } + + 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 testPurgeCachedFindsRemovesAllCallerKeys(): 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->purgeCachedFinds('projects'); + + $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(2, $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); + } +} + +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 + { + $usesEntryKey = isset($this->store[$hash][$hash]); + $cacheKey = $usesEntryKey ? $hash : $key; + $cacheHash = $hash; + $payload = $this->store[$cacheKey][$cacheHash]['data'] ?? []; + $documents = \is_array($payload) && \is_array($payload['documents'] ?? null) ? $payload['documents'] : $payload; + if (!\is_array($documents)) { + return; + } + + foreach ($documents as $index => $document) { + if (!\is_array($document) || ($document['$id'] ?? '') !== $documentId) { + continue; + } + + $documents[$index][$attribute] = $value; + if (\is_array($payload) && \array_key_exists('documents', $payload)) { + $payload['documents'] = $documents; + $this->store[$cacheKey][$cacheHash]['data'] = $payload; + } else { + $this->store[$cacheKey][$cacheHash]['data'] = $documents; + } + 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; + } +}