diff --git a/src/Database/Database.php b/src/Database/Database.php index d581529fa..9d9542822 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -387,11 +387,34 @@ class Database */ protected static array $filters = []; + protected static bool $defaultFiltersRegistered = false; + + protected static int $filtersVersion = 0; + + /** + * @var array>|null + */ + private static ?array $tenantlessInternalAttributes = null; + /** * @var array */ protected array $instanceFilters = []; + /** + * @var array + */ + private array $filterSignatures = []; + + private string $filterSignaturesEncoded = ''; + + private int $filterSignaturesVersion = -1; + + /** + * @var array + */ + private array $filterSignaturesSource = []; + /** * @var array> */ @@ -494,13 +517,30 @@ public function __construct( $this->setAuthorization(new Authorization()); + self::registerDefaultFilters(); + } + + /** + * Registers the built-in filters on first touch of the registry, so an + * explicit addFilter() always wins regardless of whether it ran before or + * after the first instance. The flag is set first: addFilter() calls back + * into this, and the guard is what terminates that recursion. + */ + private static function registerDefaultFilters(): void + { + if (self::$defaultFiltersRegistered) { + return; + } + + self::$defaultFiltersRegistered = true; + self::addFilter( 'json', /** * @param mixed $value * @return mixed */ - function (mixed $value) { + static function (mixed $value) { $value = ($value instanceof Document) ? $value->getArrayCopy() : $value; if (!is_array($value) && !$value instanceof \stdClass) { @@ -514,7 +554,7 @@ function (mixed $value) { * @return mixed * @throws Exception */ - function (mixed $value) { + static function (mixed $value) { if (!is_string($value)) { return $value; } @@ -524,7 +564,7 @@ function (mixed $value) { if (array_key_exists('$id', $value)) { return new Document($value); } else { - $value = array_map(function ($item) { + $value = array_map(static function ($item) { if (is_array($item) && array_key_exists('$id', $item)) { // if `$id` exists, create a Document instance return new Document($item); } @@ -542,7 +582,7 @@ function (mixed $value) { * @param mixed $value * @return mixed */ - function (mixed $value) { + static function (mixed $value) { if (is_null($value)) { return; } @@ -558,7 +598,7 @@ function (mixed $value) { * @param string|null $value * @return string|null */ - function (?string $value) { + static function (?string $value) { return DateTime::formatTz($value); } ); @@ -567,27 +607,31 @@ function (?string $value) { Database::VAR_POINT, /** * @param mixed $value + * @param Document $document + * @param Database $database * @return mixed */ - function (mixed $value) { + static function (mixed $value, Document $document, Database $database) { if (!is_array($value)) { return $value; } try { - return self::encodeSpatialData($value, Database::VAR_POINT); + return $database->encodeSpatialData($value, Database::VAR_POINT); } catch (\Throwable) { return $value; } }, /** * @param string|null $value + * @param Document $document + * @param Database $database * @return array|null */ - function (?string $value) { + static function (?string $value, Document $document, Database $database) { if ($value === null) { return null; } - return $this->adapter->decodePoint($value); + return $database->adapter->decodePoint($value); } ); @@ -595,27 +639,31 @@ function (?string $value) { Database::VAR_LINESTRING, /** * @param mixed $value + * @param Document $document + * @param Database $database * @return mixed */ - function (mixed $value) { + static function (mixed $value, Document $document, Database $database) { if (!is_array($value)) { return $value; } try { - return self::encodeSpatialData($value, Database::VAR_LINESTRING); + return $database->encodeSpatialData($value, Database::VAR_LINESTRING); } catch (\Throwable) { return $value; } }, /** * @param string|null $value + * @param Document $document + * @param Database $database * @return array|null */ - function (?string $value) { + static function (?string $value, Document $document, Database $database) { if (is_null($value)) { return null; } - return $this->adapter->decodeLinestring($value); + return $database->adapter->decodeLinestring($value); } ); @@ -623,27 +671,31 @@ function (?string $value) { Database::VAR_POLYGON, /** * @param mixed $value + * @param Document $document + * @param Database $database * @return mixed */ - function (mixed $value) { + static function (mixed $value, Document $document, Database $database) { if (!is_array($value)) { return $value; } try { - return self::encodeSpatialData($value, Database::VAR_POLYGON); + return $database->encodeSpatialData($value, Database::VAR_POLYGON); } catch (\Throwable) { return $value; } }, /** * @param string|null $value + * @param Document $document + * @param Database $database * @return array|null */ - function (?string $value) { + static function (?string $value, Document $document, Database $database) { if (is_null($value)) { return null; } - return $this->adapter->decodePolygon($value); + return $database->adapter->decodePolygon($value); } ); @@ -653,7 +705,7 @@ function (?string $value) { * @param mixed $value * @return mixed */ - function (mixed $value) { + static function (mixed $value) { if (!\is_array($value)) { return $value; } @@ -672,7 +724,7 @@ function (mixed $value) { * @param string|null $value * @return mixed */ - function (?string $value) { + static function (?string $value) { if (is_null($value)) { return null; } @@ -690,7 +742,7 @@ function (?string $value) { * @param mixed $value * @return mixed */ - function (mixed $value) { + static function (mixed $value) { if (!\is_array($value) && !$value instanceof \stdClass) { return $value; } @@ -701,7 +753,7 @@ function (mixed $value) { * @param mixed $value * @return array|null */ - function (mixed $value) { + static function (mixed $value) { if (is_null($value)) { return; } @@ -9285,11 +9337,15 @@ public function sum(string $collection, string $attribute, array $queries = [], */ public static function addFilter(string $name, callable $encode, callable $decode): void { + self::registerDefaultFilters(); + self::$filters[$name] = [ 'encode' => $encode, 'decode' => $decode, 'signature' => self::computeCallableSignature($encode) . ':' . self::computeCallableSignature($decode), ]; + + self::$filtersVersion++; } /** @@ -9911,15 +9967,14 @@ public function convertQuery(Document $collection, Query $query): Query */ public function getInternalAttributes(): array { - $attributes = self::INTERNAL_ATTRIBUTES; - - if (!$this->adapter->getSharedTables()) { - $attributes = \array_filter(Database::INTERNAL_ATTRIBUTES, function ($attribute) { - return $attribute['$id'] !== '$tenant'; - }); + if ($this->adapter->getSharedTables()) { + return self::INTERNAL_ATTRIBUTES; } - return $attributes; + return self::$tenantlessInternalAttributes ??= \array_values(\array_filter( + self::INTERNAL_ATTRIBUTES, + fn (array $attribute): bool => $attribute['$id'] !== '$tenant', + )); } /** @@ -9990,11 +10045,10 @@ public function getCacheKeys(string $collectionId, ?string $documentId = null, a $sortedSelects = $selects; \sort($sortedSelects); - $payload = \json_encode([ - 'selects' => $sortedSelects, - 'relationships' => $this->resolveRelationships, - 'filters' => $this->getActiveFilterSignatures(), - ]) ?: ''; + $payload = ($this->resolveRelationships ? '1' : '0') + . ':' . $this->getFilterSignatureKey() + . ':' . ($sortedSelects === [] ? '' : (\json_encode($sortedSelects) ?: '')); + $documentHashKey = $documentKey . ':' . \md5($payload); } @@ -10136,33 +10190,60 @@ private function normalizeQueryCacheQueryValue(mixed $value): mixed */ private function getActiveFilterSignatures(): array { - $filterSignatures = []; if (!$this->filter) { - return $filterSignatures; + return []; + } + + $this->refreshFilterSignatures(); + + return $this->disabledFilters + ? \array_diff_key($this->filterSignatures, $this->disabledFilters) + : $this->filterSignatures; + } + + private function refreshFilterSignatures(): void + { + if ( + $this->filterSignaturesVersion === self::$filtersVersion + && $this->filterSignaturesSource === $this->instanceFilters + ) { + return; } - $disabled = $this->disabledFilters ?? []; + $signatures = []; foreach (self::$filters as $name => $callbacks) { - if (isset($disabled[$name])) { - continue; - } if (\array_key_exists($name, $this->instanceFilters)) { continue; } - $filterSignatures[$name] = $callbacks['signature']; + $signatures[$name] = $callbacks['signature']; } foreach ($this->instanceFilters as $name => $callbacks) { - if (isset($disabled[$name])) { - continue; - } - $filterSignatures[$name] = $callbacks['signature']; + $signatures[$name] = $callbacks['signature']; + } + + \ksort($signatures); + + $this->filterSignatures = $signatures; + $this->filterSignaturesEncoded = \json_encode($signatures) ?: ''; + $this->filterSignaturesVersion = self::$filtersVersion; + $this->filterSignaturesSource = $this->instanceFilters; + } + + private function getFilterSignatureKey(): string + { + if (!$this->filter) { + return ''; + } + + if ($this->disabledFilters) { + return \json_encode($this->getActiveFilterSignatures()) ?: ''; } - \ksort($filterSignatures); + $this->refreshFilterSignatures(); - return $filterSignatures; + return $this->filterSignaturesEncoded; } private static function computeCallableSignature(callable $callable): string diff --git a/src/Database/PDO.php b/src/Database/PDO.php index 6981ca7cb..b8c98e260 100644 --- a/src/Database/PDO.php +++ b/src/Database/PDO.php @@ -14,6 +14,8 @@ class PDO { protected \PDO $pdo; + private ?string $hostname = null; + /** * @param string $dsn * @param ?string $username @@ -136,14 +138,18 @@ public function reconnect(): void */ public function getHostname(): string { - $parts = $this->parseDsn($this->dsn); + if ($this->hostname === null) { + $parts = $this->parseDsn($this->dsn); + + /** + * @var string $host + */ + $host = $parts['host'] ?? throw new \Exception('No host found in DSN'); - /** - * @var string $host - */ - $host = $parts['host'] ?? throw new \Exception('No host found in DSN'); + $this->hostname = $host; + } - return $host; + return $this->hostname; } /** diff --git a/tests/unit/FilterRegistryTest.php b/tests/unit/FilterRegistryTest.php new file mode 100644 index 000000000..f817efce7 --- /dev/null +++ b/tests/unit/FilterRegistryTest.php @@ -0,0 +1,179 @@ + + */ + private array $registry; + + protected function setUp(): void + { + $this->adapter = new DatabaseMemory(); + $this->cache = new Cache(new HashAwareMemoryCache()); + $this->namespace = 'filter_registry_' . \uniqid(); + + $this->database = $this->createDatabase(); + + // Snapshot once the constructor has registered the built-ins, so the + // restore in tearDown puts back a populated registry rather than an + // empty one. + $this->registry = (new \ReflectionProperty(Database::class, 'filters'))->getValue(); + + $this->database->create(); + $this->database->createCollection('projects'); + $this->database->createAttribute('projects', 'name', Database::VAR_STRING, 255, false); + $this->database->createDocument('projects', new Document([ + '$id' => 'project', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'cached', + ])); + } + + protected function tearDown(): void + { + // addFilter() writes to a static registry with no removal API, so a test + // registering one would otherwise leak into every later test. + (new \ReflectionProperty(Database::class, 'filters'))->setValue(null, $this->registry); + (new \ReflectionProperty(Database::class, 'defaultFiltersRegistered'))->setValue(null, true); + } + + private function createDatabase(): Database + { + $database = new Database($this->adapter, $this->cache); + + return $database + ->setDatabase('utopiaTests') + ->setNamespace($this->namespace); + } + + /** + * Write through the adapter, bypassing Database and therefore the cache + * purge, so the cache holds a copy the source no longer agrees with. A read + * returning 'cached' was served from the cache; one returning 'fresh' missed + * and went to the adapter. + */ + private function writeBehindTheCache(string $value): void + { + $collection = $this->database->getCollection('projects'); + $document = $this->adapter->getDocument($collection, 'project'); + $document->setAttribute('name', $value); + $this->adapter->updateDocument($collection, 'project', $document, true); + } + + private function read(?Database $database = null): string + { + return ($database ?? $this->database) + ->getDocument('projects', 'project') + ->getAttribute('name'); + } + + public function testRegisteringAGlobalFilterStopsStaleEntriesBeingServed(): void + { + $this->assertSame('cached', $this->read()); + + $this->writeBehindTheCache('fresh'); + $this->assertSame('cached', $this->read(), 'read should still be served from cache'); + + $noop = fn (mixed $value) => $value; + Database::addFilter(__FUNCTION__, $noop, $noop); + + $this->assertSame( + 'fresh', + $this->read(), + 'a document cached under the previous filter set must not be served after it changes', + ); + } + + public function testChangingInstanceFiltersStopsStaleEntriesBeingServed(): void + { + $database = new class ($this->adapter, $this->cache) extends Database { + public function swapInstanceFilter(string $signature): void + { + $noop = fn (mixed $value) => $value; + + $this->instanceFilters = [ + 'probe' => ['encode' => $noop, 'decode' => $noop, 'signature' => $signature], + ]; + } + }; + $database->setDatabase('utopiaTests')->setNamespace($this->namespace); + + $this->assertSame('cached', $this->read($database)); + + $this->writeBehindTheCache('fresh'); + $this->assertSame('cached', $this->read($database), 'read should still be served from cache'); + + $database->swapInstanceFilter('v2'); + + $this->assertSame( + 'fresh', + $this->read($database), + 'a subclass replacing its instance filters must not keep serving the previous entry', + ); + } + + public function testOverridingABuiltInFilterBeforeTheFirstInstanceStillWins(): void + { + // A fresh process: nothing has constructed a Database yet, so the + // built-ins are not in the registry. + (new \ReflectionProperty(Database::class, 'filters'))->setValue(null, []); + (new \ReflectionProperty(Database::class, 'defaultFiltersRegistered'))->setValue(null, false); + + $identity = fn (mixed $value) => $value; + Database::addFilter('datetime', $identity, $identity); + + $decoded = $this->createDatabase()->decode( + new Document([ + '$id' => 'events', + 'attributes' => [ + new Document([ + '$id' => 'occurredAt', + 'type' => Database::VAR_DATETIME, + 'array' => false, + 'filters' => ['datetime'], + ]), + ], + ]), + new Document(['$id' => 'event', 'occurredAt' => '2026-09-21 10:00:00.000']), + ); + + // The built-in decode would hand back '2026-09-21T10:00:00.000+00:00'. + $this->assertSame( + '2026-09-21 10:00:00.000', + $decoded->getAttribute('occurredAt'), + 'the override registered before the first instance must be the filter that runs', + ); + } + + public function testInstancesSharingAConfigShareCachedDocuments(): void + { + $this->assertSame('cached', $this->read()); + + $this->writeBehindTheCache('fresh'); + + $this->assertSame( + 'cached', + $this->read($this->createDatabase()), + 'a later instance with the same config must hit the entry the first one cached', + ); + } +} diff --git a/tests/unit/HashAwareMemoryCache.php b/tests/unit/HashAwareMemoryCache.php new file mode 100644 index 000000000..7a7218604 --- /dev/null +++ b/tests/unit/HashAwareMemoryCache.php @@ -0,0 +1,56 @@ +field($key, $hash), $ttl); + } + + /** + * @param array|string $data + * @return bool|string|array + */ + public function save(string $key, array|string $data, string $hash = ''): bool|string|array + { + return parent::save($this->field($key, $hash), $data); + } + + public function touch(string $key, string $hash = ''): bool + { + return parent::touch($this->field($key, $hash)); + } + + public function purge(string $key, string $hash = ''): bool + { + if ($hash !== '') { + return parent::purge($this->field($key, $hash)); + } + + $purged = false; + + foreach (\array_keys($this->store) as $stored) { + if ($stored === $key || \str_starts_with($stored, $key . "\0")) { + unset($this->store[$stored]); + $purged = true; + } + } + + return $purged; + } + + private function field(string $key, string $hash): string + { + return $hash === '' ? $key : $key . "\0" . $hash; + } +} diff --git a/tests/unit/SpatialFilterTest.php b/tests/unit/SpatialFilterTest.php new file mode 100644 index 000000000..01ead2082 --- /dev/null +++ b/tests/unit/SpatialFilterTest.php @@ -0,0 +1,64 @@ + $point + */ + private function createDatabase(array $point): Database + { + $adapter = $this->createMock(Adapter::class); + $adapter->method('getSupportForHostname')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('getNamespace')->willReturn('test'); + $adapter->method('getSharedTables')->willReturn(false); + $adapter->method('filter')->willReturnArgument(0); + $adapter->method('decodePoint')->willReturn($point); + + return new Database($adapter, new Cache(new None())); + } + + private function pointCollection(): Document + { + return new Document([ + '$id' => 'places', + 'attributes' => [ + new Document([ + '$id' => 'location', + 'type' => Database::VAR_POINT, + 'array' => false, + 'filters' => [Database::VAR_POINT], + ]), + ], + ]); + } + + private function decode(Database $database, string $id): mixed + { + return $database + ->decode($this->pointCollection(), new Document(['$id' => $id, 'location' => 'POINT(0 0)'])) + ->getAttribute('location'); + } + + public function testSpatialDecodeUsesTheCallingDatabaseAdapter(): void + { + $first = $this->createDatabase([1.0, 2.0]); + $second = $this->createDatabase([9.0, 9.0]); + + $this->assertSame([1.0, 2.0], $this->decode($first, 'a')); + $this->assertSame([9.0, 9.0], $this->decode($second, 'b')); + + // The decode filters live in a static registry shared by both instances, + // so the first must still reach its own adapter after the second exists. + $this->assertSame([1.0, 2.0], $this->decode($first, 'c')); + } +}