diff --git a/src/Database/Database.php b/src/Database/Database.php index 4caac03ff..217351fed 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8577,6 +8577,235 @@ public function find(string $collection, array $queries = [], string $forPermiss return $results; } + /** + * Purge all cached query entries for a collection namespace. + * + * @param string $collection + * @param string|null $namespace + * @return bool + */ + public function purgeCachedQueries(string $collection, ?string $namespace = null): bool + { + $collectionDocument = $this->silent(fn () => $this->getCollection($collection)); + $collection = $collectionDocument->isEmpty() ? $collection : $collectionDocument->getId(); + + return $this->cache->purge( + $this->getQueryCacheKey($collection, $namespace) + ); + } + + /** + * Execute a callback behind a cache-aside lookup. + * + * The callback runs on cache miss and its value is returned to the caller. + * 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 + * @param string $key + * @param callable(): T $callback + * @param string|null $hash + * @return T + * @throws AuthorizationException + * @throws Exception + */ + public function withCache( + string $key, + callable $callback, + ?string $hash = '', + ): mixed { + if ($hash === null) { + return $callback(); + } + + $shouldRefreshCache = false; + + try { + $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) { + $cachedValue = \is_array($cached) && \array_key_exists('value', $cached) ? $cached['value'] : false; + + if ($cachedValue !== false) { + $decoded = $cachedValue; + $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' ? [$cachedValue] : $cachedValue; + + 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; + } + + 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; + } + + if ($decoded !== false) { + $decoded = ($cached['type'] ?? null) === 'document' ? ($documents[0] ?? false) : $documents; + } + } + } + } + + if ($decoded !== false) { + return $decoded; + } + } + + $shouldRefreshCache = true; + } + + if ($shouldRefreshCache) { + try { + $this->cache->purge($key, $hash); + } catch (Throwable $e) { + Console::warning('Warning: Failed to purge rejected cache value: ' . $e->getMessage()); + } + } + + $callbackValue = $callback(); + + if ($callbackValue !== false) { + try { + $encoded = false; + + if ($callbackValue instanceof Document) { + $collection = $callbackValue->getCollection(); + + if ($collection !== '') { + $encoded = [ + 'collection' => $collection, + 'type' => 'document', + 'value' => $callbackValue->getArrayCopy(), + ]; + } + } elseif (!\is_array($callbackValue)) { + $encoded = ['value' => $callbackValue]; + } else { + // Only homogeneous top-level document lists are safe to restore + // from cache. Plain arrays containing Documents are left uncached. + $collection = null; + $hasDocuments = false; + $hasNonDocuments = false; + $cacheable = true; + $documents = []; + $containsDocument = function (mixed $item) use (&$containsDocument): bool { + if ($item instanceof Document) { + return true; + } + + if (!\is_array($item)) { + return false; + } + + foreach ($item as $child) { + if ($containsDocument($child)) { + return true; + } + } + + return false; + }; + + foreach ($callbackValue as $item) { + if (!$item instanceof Document) { + if ($hasDocuments || $containsDocument($item)) { + $cacheable = false; + break; + } + + $hasNonDocuments = true; + continue; + } + + if ($hasNonDocuments) { + $cacheable = false; + break; + } + + $documentCollection = $item->getCollection(); + if ($documentCollection === '') { + $cacheable = false; + break; + } + + if ($collection !== null && $collection !== $documentCollection) { + $cacheable = false; + break; + } + + $collection = $documentCollection; + $hasDocuments = true; + $documents[] = $item->getArrayCopy(); + } + + if ($cacheable) { + $encoded = $hasDocuments ? [ + 'collection' => $collection, + 'type' => 'documents', + 'value' => $documents, + ] : ['value' => $callbackValue]; + } + } + + if ($encoded !== false) { + $this->cache->save($key, $encoded, $hash); + } + } catch (Throwable $e) { + Console::warning('Warning: Failed to save cache value: ' . $e->getMessage()); + } + } + + /** @var T $callbackValue */ + return $callbackValue; + } + /** * Helper method to iterate documents in collection using callback pattern * Alterative is @@ -9458,34 +9687,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); } @@ -9497,6 +9702,166 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a ]; } + /** + * Stable cache key for cached query entries on a collection. + * + * @param string $collectionId + * @param string|null $namespace + * @return 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:query', + $this->cacheName, + $hostname, + $namespace ?? $this->getNamespace(), + $this->adapter->getTenant(), + $collectionId, + ); + } + + /** + * Stable cache field for cached query entries on a collection. + * + * @param Document|null $collection + * @param array $queries + * @param string $field + * @param string $forPermission + * @return string|null + */ + public function getQueryCacheField( + ?Document $collection = null, + array $queries = [], + string $field = 'documents', + 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); + + $queryPayload = [ + 'version' => 1, + 'authorization' => [ + 'enabled' => $this->authorization->getStatus(), + 'roles' => $authorizationRoles, + ], + 'database' => $this->getDatabase(), + 'queries' => \array_map( + fn (Query $query): array => $this->serializeQueryCacheQuery($query), + $queries, + ), + 'relationships' => $this->resolveRelationships, + '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', + $schemaHash, + \md5(\json_encode($queryPayload) ?: ''), + $field, + ); + } + + /** + * @return array + */ + private function serializeQueryCacheQuery(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->serializeQueryCacheQuery($value); + continue; + } + + $values[] = $this->normalizeQueryCacheQueryValue($value); + } + + $serialized['values'] = $values; + + return $serialized; + } + + private function normalizeQueryCacheQueryValue(mixed $value): mixed + { + if ($value instanceof Document) { + $value = $value->getArrayCopy(); + } + + if (!\is_array($value)) { + return $value; + } + + foreach ($value as $key => $item) { + $value[$key] = $this->normalizeQueryCacheQueryValue($item); + } + + return $value; + } + + /** + * @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/unit/CacheKeyTest.php b/tests/unit/CacheKeyTest.php index 118bf4897..92c3bc608 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -7,18 +7,22 @@ use Utopia\Cache\Cache; 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 { /** * @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 +135,172 @@ public function testFiltersDisabledEntirelyProducesDifferentCacheKey(): void $this->assertNotEquals($hashEnabled, $hashDisabled); } + public function testQueryCacheKeyUsesQueryCacheShape(): 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:query', + $db->getQueryCacheKey('ttl_cache_table'), + ); + } + + public function testQueryCacheKeyCanOverrideNamespaceSegment(): 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:query', + $db->getQueryCacheKey('wafrules', '_39'), + ); + } + + public function testQueryCacheFieldUsesQueryCacheShape(): 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', [])) ?: '') + . (\json_encode($collection->getAttribute('$permissions', [])) ?: '') + . (\json_encode($collection->getAttribute('documentSecurity', false)) ?: '') + ); + $field = $db->getQueryCacheField($collection, $queries); + + $this->assertStringStartsWith("{$schemaHash}:", $field); + $this->assertStringEndsWith(':documents', $field); + $this->assertSame(2, \substr_count($field, ':')); + } + + public function testQueryCacheFieldChangesWithInputs(): void + { + $db = $this->createDatabase(); + + $field = $db->getQueryCacheField( + new Document([ + 'attributes' => [new Document(['$id' => 'name', 'type' => Database::VAR_STRING])], + 'indexes' => [], + ]), + [Query::limit(10)], + ); + + $this->assertNotSame( + $field, + $db->getQueryCacheField( + new Document([ + 'attributes' => [new Document(['$id' => 'status', 'type' => Database::VAR_STRING])], + 'indexes' => [], + ]), + [Query::limit(10)], + ), + ); + $this->assertNotSame($field, $db->getQueryCacheField(null, [Query::limit(20)])); + $this->assertStringEndsWith(':total', $db->getQueryCacheField(null, [Query::limit(10)], 'total')); + } + + public function testQueryCacheFieldChangesWithActiveAuthorizationContext(): void + { + $db = $this->createDatabase(); + + $field = $db->getQueryCacheField(null, [Query::limit(10)]); + + $this->assertNotSame( + $field, + $db->getAuthorization()->skip(fn () => $db->getQueryCacheField(null, [Query::limit(10)])), + ); + + $db->getAuthorization()->addRole('user:1'); + + $this->assertNotSame( + $field, + $db->getQueryCacheField(null, [Query::limit(10)]), + ); + } + + public function testQueryCacheFieldReturnsNullForNonReadPermission(): void + { + $db = $this->createDatabase(); + + $this->assertNull($db->getQueryCacheField(forPermission: Database::PERMISSION_UPDATE)); + } + + public function testQueryCacheFieldIncludesCursorDocumentPayload(): void + { + $db = $this->createDatabase(); + + $fieldA = $db->getQueryCacheField(null, [ + Query::orderAsc('name'), + Query::cursorAfter(new Document([ + '$id' => 'cursor', + 'name' => 'alpha', + ])), + ]); + $fieldB = $db->getQueryCacheField(null, [ + Query::orderAsc('name'), + Query::cursorAfter(new Document([ + '$id' => 'cursor', + 'name' => 'beta', + ])), + ]); + + $this->assertNotSame($fieldA, $fieldB); + } + + public function testQueryCacheFieldIncludesAmbientState(): void + { + $db = $this->createDatabase(); + + $field = $db->getQueryCacheField(null, [Query::limit(10)]); + + $this->assertNotSame( + $field, + $db->skipFilters(fn () => $db->getQueryCacheField(null, [Query::limit(10)]), ['json']), + ); + $this->assertNotSame( + $field, + $db->skipRelationships(fn () => $db->getQueryCacheField(null, [Query::limit(10)])), + ); + } + + public function testQueryCacheFieldValidatesQueryTypes(): void + { + $this->expectException(QueryException::class); + + $db = $this->createDatabase(); + $queries = ['invalid']; + + /** @phpstan-ignore-next-line intentionally passing invalid query type */ + $db->getQueryCacheField(null, $queries); + } + + public function testParseHostname(): void { $hostname = 'database_db_nyc3_self_hosted_0_0'; diff --git a/tests/unit/QueryCacheTest.php b/tests/unit/QueryCacheTest.php new file mode 100644 index 000000000..8103a0dd2 --- /dev/null +++ b/tests/unit/QueryCacheTest.php @@ -0,0 +1,1034 @@ + $filters + */ + private function createDatabase(Adapter $cache, array $filters = [], ?DatabaseMemory $adapter = null): Database + { + $database = new Database($adapter ?? new DatabaseMemory(), new Cache($cache), $filters); + $database + ->setDatabase('utopiaTests') + ->setNamespace('list_cache_' . \uniqid()); + + $database->create(); + + return $database; + } + + /** + * @param array $queries + * @return array + */ + private function findWithCache( + Database $database, + string $collection, + array $queries = [], + ?string $namespace = null, + ): array { + foreach ($queries as $query) { + if ($query instanceof Query && $query->getMethod() === Query::TYPE_ORDER_RANDOM) { + return $database->find($collection, $queries); + } + } + + $collectionDocument = $database->getCollection($collection); + $cacheKey = $database->getQueryCacheKey($collectionDocument->getId(), $namespace); + $cacheHash = $database->getQueryCacheField($collectionDocument, $queries); + + return $database->withCache( + key: $cacheKey, + callback: fn (): array => $database->find($collection, $queries), + hash: $cacheHash, + ); + } + + public function testWithCacheUsesCallbackOnMissAndCachesResult(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + + $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 testWithCacheCachesEmptyValues(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + + $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 testWithCacheCachesNullValues(): void + { + $cache = new HashMemoryCache(); + $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'; + }, + ); + + $this->assertNull($value); + $this->assertSame(1, $callbackCalls); + } + + public function testWithCacheSeparatesPayloadsByHashField(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + + $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', + ); + + $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 testWithCacheDoesNotCacheFalseValues(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + + $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); + } + + 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 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(); + $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 ($database, &$callbackCalls): Document { + $callbackCalls++; + return $database->getDocument('wafRules', 'rule-a'); + }, + hash: $hash, + ); + $second = $database->withCache( + key: $key, + callback: function () use ($database, &$callbackCalls): Document { + $callbackCalls++; + return $database->getDocument('wafRules', 'missing'); + }, + hash: $hash, + ); + + $this->assertSame('rule-a', $first->getId()); + $this->assertSame('rule-a', $second->getId()); + $this->assertSame(1, $callbackCalls); + } + + 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'); + + $first = $database->withCache( + key: $key, + callback: function () use (&$callbackCalls): int { + $callbackCalls++; + return 10; + }, + hash: $hash, + ); + $second = $database->withCache( + key: $key, + callback: function () use (&$callbackCalls): int { + $callbackCalls++; + return 20; + }, + hash: $hash, + ); + + $this->assertSame(10, $first); + $this->assertSame(10, $second); + $this->assertSame(1, $callbackCalls); + } + + public function testQueryCacheUsesCacheUntilPurged(): 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), + ]; + + $first = $this->findWithCache($database, 'wafRules', $queries, '_39'); + $this->assertCount(1, $first); + $this->assertSame('rule-a', $first[0]->getId()); + + $database->createDocument('wafRules', new Document([ + '$id' => 'rule-b', + 'projectId' => 'project-a', + ])); + + $cached = $this->findWithCache($database, 'wafRules', $queries, '_39'); + $this->assertCount(1, $cached); + $this->assertSame('rule-a', $cached[0]->getId()); + + $this->assertTrue($database->purgeCachedQueries('wafRules', '_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(), + $fresh, + )); + } + + public function testQueryCacheSeparatesEntriesByAuthorizationContext(): 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), + ]; + + $this->findWithCache($database, 'wafRules', $queries, '_39'); + + $database->createDocument('wafRules', new Document([ + '$id' => 'rule-b', + 'projectId' => 'project-a', + ])); + + $cached = $this->findWithCache($database, 'wafRules', $queries, '_39'); + $this->assertCount(1, $cached); + + $database->getAuthorization()->addRole(Role::user('user-1')->toString()); + + $roleSeparated = $this->findWithCache($database, 'wafRules', $queries, '_39'); + $this->assertCount(2, $roleSeparated); + } + + public function testQueryCacheRecastsCacheHits(): 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 = $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 testQueryCacheDoesNotDoubleDecodeCustomFilters(): 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 = $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 testQueryCacheBypassesCacheForRandomOrder(): 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 = $this->findWithCache($database, 'wafRules', $queries, '_39'); + $this->assertCount(1, $first); + + $database->createDocument('wafRules', new Document([ + '$id' => 'rule-b', + 'projectId' => 'project-a', + ])); + + $second = $this->findWithCache($database, 'wafRules', $queries, '_39'); + $this->assertCount(2, $second); + } + + public function testQueryCacheReliesOnPurgeForDocumentSecurityCollections(): void + { + $cache = new HashMemoryCache(); + $database = $this->createDatabase($cache); + $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 = $this->findWithCache($database, 'secureRules', $queries, '_39'); + $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 = $this->findWithCache($database, 'secureRules', $queries, '_39'); + + $this->assertCount(1, $cached); + + $this->assertTrue($database->purgeCachedQueries('secureRules', '_39')); + + $fresh = $this->findWithCache($database, 'secureRules', $queries, '_39'); + $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(); + $database = $this->createDatabase($cache); + $database->createCollection('parents', permissions: [ + Permission::read(Role::any()), + ]); + + $queries = [ + Query::limit(25), + ]; + + $collection = $database->getCollection('parents'); + $cache->save( + $database->getQueryCacheKey($collection->getId(), '_39'), + [ + 'collection' => $collection->getId(), + 'type' => 'documents', + 'value' => [ + [ + '$id' => 'parent-a', + 'child' => [ + '$id' => 'child-a', + '$collection' => 'children', + 'name' => 'Child A', + ], + ], + ], + ], + $database->getQueryCacheField($collection, $queries), + ); + + $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 testQueryCacheRefreshesInvalidPayload(): 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->getQueryCacheKey($collection->getId(), '_39'), + [ + 'collection' => $collection->getId(), + 'type' => 'documents', + 'value' => 'invalid', + ], + $database->getQueryCacheField($collection, $queries), + ); + + $rules = $this->findWithCache($database, 'wafRules', $queries, '_39'); + + $this->assertCount(1, $rules); + $this->assertSame('rule-a', $rules[0]->getId()); + } + + public function testQueryCacheRefreshesInvalidPayloadEntry(): 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->getQueryCacheKey($collection->getId(), '_39'), + [ + 'collection' => $collection->getId(), + 'type' => 'documents', + 'value' => [ + [ + '$id' => 'rule-a', + 'projectId' => 'project-a', + ], + 'invalid', + ], + ], + $database->getQueryCacheField($collection, $queries), + ); + + $rules = $this->findWithCache($database, 'wafRules', $queries, '_39'); + + $this->assertSame(['rule-a', 'rule-b'], \array_map( + static fn (Document $document): string => $document->getId(), + $rules, + )); + } +} + +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'; + } +} + +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'; + } +}