From 241cd900c62721c65310915442cb21a57de768bb Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 16 Feb 2026 23:56:44 +1300 Subject: [PATCH 01/14] Use utopia base --- Dockerfile | 43 ++----------------------------------------- 1 file changed, 2 insertions(+), 41 deletions(-) diff --git a/Dockerfile b/Dockerfile index a3392d45d..3c764c75b 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,13 +12,9 @@ RUN composer install \ --no-scripts \ --prefer-dist -FROM php:8.4.18-cli-alpine3.22 AS compile +FROM utopia-php/base:php-8.4-0.2.1 AS compile -ENV PHP_REDIS_VERSION="6.3.0" \ - PHP_SWOOLE_VERSION="v6.1.6" \ - PHP_XDEBUG_VERSION="3.4.2" \ - PHP_MONGODB_VERSION="2.1.1" -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone +ENV PHP_MONGODB_VERSION="2.1.1" RUN apk update && apk add --no-cache \ libpq \ @@ -28,8 +24,6 @@ RUN apk update && apk add --no-cache \ autoconf \ gcc \ g++ \ - git \ - brotli-dev \ linux-headers \ docker-cli \ docker-cli-compose \ @@ -48,24 +42,6 @@ RUN apk update && apk add --no-cache \ && apk del libpq-dev \ && rm -rf /var/cache/apk/* -# Redis Extension -FROM compile AS redis -RUN \ - git clone --depth 1 --branch $PHP_REDIS_VERSION https://github.com/phpredis/phpredis.git \ - && cd phpredis \ - && phpize \ - && ./configure \ - && make && make install - -## Swoole Extension -FROM compile AS swoole -RUN \ - git clone --depth 1 --branch $PHP_SWOOLE_VERSION https://github.com/swoole/swoole-src.git \ - && cd swoole-src \ - && phpize \ - && ./configure --enable-http2 \ - && make && make install - ## PCOV Extension FROM compile AS pcov RUN \ @@ -75,15 +51,6 @@ RUN \ && ./configure --enable-pcov \ && make && make install -## XDebug Extension -FROM compile AS xdebug -RUN \ - git clone --depth 1 --branch $PHP_XDEBUG_VERSION https://github.com/xdebug/xdebug && \ - cd xdebug && \ - phpize && \ - ./configure && \ - make && make install - FROM compile AS final LABEL maintainer="team@appwrite.io" @@ -93,10 +60,7 @@ ENV DEBUG=$DEBUG WORKDIR /usr/src/code -RUN echo extension=redis.so >> /usr/local/etc/php/conf.d/redis.ini -RUN echo extension=swoole.so >> /usr/local/etc/php/conf.d/swoole.ini RUN echo extension=pcov.so >> /usr/local/etc/php/conf.d/pcov.ini -RUN echo extension=xdebug.so >> /usr/local/etc/php/conf.d/xdebug.ini RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" @@ -105,10 +69,7 @@ RUN echo "opcache.enable_cli=1" >> $PHP_INI_DIR/php.ini RUN echo "memory_limit=1024M" >> $PHP_INI_DIR/php.ini COPY --from=composer /usr/local/src/vendor /usr/src/code/vendor -COPY --from=swoole /usr/local/lib/php/extensions/no-debug-non-zts-20240924/swoole.so /usr/local/lib/php/extensions/no-debug-non-zts-20240924/ -COPY --from=redis /usr/local/lib/php/extensions/no-debug-non-zts-20240924/redis.so /usr/local/lib/php/extensions/no-debug-non-zts-20240924/ COPY --from=pcov /usr/local/lib/php/extensions/no-debug-non-zts-20240924/pcov.so /usr/local/lib/php/extensions/no-debug-non-zts-20240924/ -COPY --from=xdebug /usr/local/lib/php/extensions/no-debug-non-zts-20240924/xdebug.so /usr/local/lib/php/extensions/no-debug-non-zts-20240924/ COPY ./bin /usr/src/code/bin COPY ./src /usr/src/code/src From 0e7c43e63e73f81c664d802da4cd939a292e66d2 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 17 Feb 2026 00:05:41 +1300 Subject: [PATCH 02/14] Fix cache clobber --- src/Database/Database.php | 15 ++++--- tests/e2e/Adapter/Scopes/DocumentTests.php | 48 ++++++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index b842053de..981828972 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -4271,11 +4271,14 @@ public function getDocument(string $collection, string $id, array $queries = [], $selections ); - try { - $cached = $this->cache->load($documentKey, self::TTL, $hashKey); - } catch (Exception $e) { - Console::warning('Warning: Failed to get document from cache: ' . $e->getMessage()); - $cached = null; + $cached = null; + if (!$forUpdate) { + try { + $cached = $this->cache->load($documentKey, self::TTL, $hashKey); + } catch (Exception $e) { + Console::warning('Warning: Failed to get document from cache: ' . $e->getMessage()); + $cached = null; + } } if ($cached) { @@ -4348,7 +4351,7 @@ public function getDocument(string $collection, string $id, array $queries = [], ); // Don't save to cache if it's part of a relationship - if (empty($relationships)) { + if (!$forUpdate && empty($relationships)) { try { $this->cache->save($documentKey, $document->getArrayCopy(), $hashKey); $this->cache->save($collectionKey, 'empty', $documentKey); diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index d1241ad26..7c0fc4cd3 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -7456,6 +7456,54 @@ public function testRegexInjection(): void $database->deleteCollection($collectionName); } + public function testUpdateDocumentUsesFreshForUpdateReadWhenCacheIsStale(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $collectionId = 'for_update_cache'; + $database->createCollection($collectionId); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->assertEquals(true, $database->createAttribute($collectionId, 'a', Database::VAR_STRING, 255, false)); + $this->assertEquals(true, $database->createAttribute($collectionId, 'b', Database::VAR_STRING, 255, false)); + } + + $database->createDocument($collectionId, new Document([ + '$id' => 'doc1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'a' => 'A1', + 'b' => 'B1', + ])); + + // Prime cache with initial values. + $cached = $database->getDocument($collectionId, 'doc1'); + $this->assertEquals('B1', $cached->getAttribute('b')); + + $collection = $database->getCollection($collectionId); + + // Simulate an out-of-band write that bypasses cache invalidation. + $outOfBand = $database->getAdapter()->getDocument($collection, 'doc1'); + $outOfBand->setAttribute('b', 'B2'); + $database->getAdapter()->updateDocument($collection, 'doc1', $outOfBand, true); + + // Partial update should not overwrite untouched fields with stale cached values. + $updated = $database->updateDocument($collectionId, 'doc1', new Document([ + 'a' => 'A2', + ])); + + $this->assertEquals('A2', $updated->getAttribute('a')); + $this->assertEquals('B2', $updated->getAttribute('b')); + + $fresh = $database->getDocument($collectionId, 'doc1'); + $this->assertEquals('B2', $fresh->getAttribute('b')); + + $database->deleteCollection($collectionId); + } + /** * Test ReDoS (Regular Expression Denial of Service) with timeout protection * This test verifies that ReDoS patterns either timeout properly or complete quickly, From 2aa26e693b09cc7c56ce9bf1c2de25719b1e7ecc Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 17 Feb 2026 00:14:48 +1300 Subject: [PATCH 03/14] fix: Revert Dockerfile to use php:8.4-cli-alpine base image The utopia-php/base:php-8.4-0.2.1 image doesn't exist on Docker Hub, causing the CI Docker build to fail. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 43 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3c764c75b..a3392d45d 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,9 +12,13 @@ RUN composer install \ --no-scripts \ --prefer-dist -FROM utopia-php/base:php-8.4-0.2.1 AS compile +FROM php:8.4.18-cli-alpine3.22 AS compile -ENV PHP_MONGODB_VERSION="2.1.1" +ENV PHP_REDIS_VERSION="6.3.0" \ + PHP_SWOOLE_VERSION="v6.1.6" \ + PHP_XDEBUG_VERSION="3.4.2" \ + PHP_MONGODB_VERSION="2.1.1" +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone RUN apk update && apk add --no-cache \ libpq \ @@ -24,6 +28,8 @@ RUN apk update && apk add --no-cache \ autoconf \ gcc \ g++ \ + git \ + brotli-dev \ linux-headers \ docker-cli \ docker-cli-compose \ @@ -42,6 +48,24 @@ RUN apk update && apk add --no-cache \ && apk del libpq-dev \ && rm -rf /var/cache/apk/* +# Redis Extension +FROM compile AS redis +RUN \ + git clone --depth 1 --branch $PHP_REDIS_VERSION https://github.com/phpredis/phpredis.git \ + && cd phpredis \ + && phpize \ + && ./configure \ + && make && make install + +## Swoole Extension +FROM compile AS swoole +RUN \ + git clone --depth 1 --branch $PHP_SWOOLE_VERSION https://github.com/swoole/swoole-src.git \ + && cd swoole-src \ + && phpize \ + && ./configure --enable-http2 \ + && make && make install + ## PCOV Extension FROM compile AS pcov RUN \ @@ -51,6 +75,15 @@ RUN \ && ./configure --enable-pcov \ && make && make install +## XDebug Extension +FROM compile AS xdebug +RUN \ + git clone --depth 1 --branch $PHP_XDEBUG_VERSION https://github.com/xdebug/xdebug && \ + cd xdebug && \ + phpize && \ + ./configure && \ + make && make install + FROM compile AS final LABEL maintainer="team@appwrite.io" @@ -60,7 +93,10 @@ ENV DEBUG=$DEBUG WORKDIR /usr/src/code +RUN echo extension=redis.so >> /usr/local/etc/php/conf.d/redis.ini +RUN echo extension=swoole.so >> /usr/local/etc/php/conf.d/swoole.ini RUN echo extension=pcov.so >> /usr/local/etc/php/conf.d/pcov.ini +RUN echo extension=xdebug.so >> /usr/local/etc/php/conf.d/xdebug.ini RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" @@ -69,7 +105,10 @@ RUN echo "opcache.enable_cli=1" >> $PHP_INI_DIR/php.ini RUN echo "memory_limit=1024M" >> $PHP_INI_DIR/php.ini COPY --from=composer /usr/local/src/vendor /usr/src/code/vendor +COPY --from=swoole /usr/local/lib/php/extensions/no-debug-non-zts-20240924/swoole.so /usr/local/lib/php/extensions/no-debug-non-zts-20240924/ +COPY --from=redis /usr/local/lib/php/extensions/no-debug-non-zts-20240924/redis.so /usr/local/lib/php/extensions/no-debug-non-zts-20240924/ COPY --from=pcov /usr/local/lib/php/extensions/no-debug-non-zts-20240924/pcov.so /usr/local/lib/php/extensions/no-debug-non-zts-20240924/ +COPY --from=xdebug /usr/local/lib/php/extensions/no-debug-non-zts-20240924/xdebug.so /usr/local/lib/php/extensions/no-debug-non-zts-20240924/ COPY ./bin /usr/src/code/bin COPY ./src /usr/src/code/src From 1727eaf97737dbc6e00890dd5fdf678db0b30c58 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 17 Feb 2026 00:15:42 +1300 Subject: [PATCH 04/14] Revert "fix: Revert Dockerfile to use php:8.4-cli-alpine base image" This reverts commit 2aa26e693b09cc7c56ce9bf1c2de25719b1e7ecc. --- Dockerfile | 43 ++----------------------------------------- 1 file changed, 2 insertions(+), 41 deletions(-) diff --git a/Dockerfile b/Dockerfile index a3392d45d..3c764c75b 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,13 +12,9 @@ RUN composer install \ --no-scripts \ --prefer-dist -FROM php:8.4.18-cli-alpine3.22 AS compile +FROM utopia-php/base:php-8.4-0.2.1 AS compile -ENV PHP_REDIS_VERSION="6.3.0" \ - PHP_SWOOLE_VERSION="v6.1.6" \ - PHP_XDEBUG_VERSION="3.4.2" \ - PHP_MONGODB_VERSION="2.1.1" -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone +ENV PHP_MONGODB_VERSION="2.1.1" RUN apk update && apk add --no-cache \ libpq \ @@ -28,8 +24,6 @@ RUN apk update && apk add --no-cache \ autoconf \ gcc \ g++ \ - git \ - brotli-dev \ linux-headers \ docker-cli \ docker-cli-compose \ @@ -48,24 +42,6 @@ RUN apk update && apk add --no-cache \ && apk del libpq-dev \ && rm -rf /var/cache/apk/* -# Redis Extension -FROM compile AS redis -RUN \ - git clone --depth 1 --branch $PHP_REDIS_VERSION https://github.com/phpredis/phpredis.git \ - && cd phpredis \ - && phpize \ - && ./configure \ - && make && make install - -## Swoole Extension -FROM compile AS swoole -RUN \ - git clone --depth 1 --branch $PHP_SWOOLE_VERSION https://github.com/swoole/swoole-src.git \ - && cd swoole-src \ - && phpize \ - && ./configure --enable-http2 \ - && make && make install - ## PCOV Extension FROM compile AS pcov RUN \ @@ -75,15 +51,6 @@ RUN \ && ./configure --enable-pcov \ && make && make install -## XDebug Extension -FROM compile AS xdebug -RUN \ - git clone --depth 1 --branch $PHP_XDEBUG_VERSION https://github.com/xdebug/xdebug && \ - cd xdebug && \ - phpize && \ - ./configure && \ - make && make install - FROM compile AS final LABEL maintainer="team@appwrite.io" @@ -93,10 +60,7 @@ ENV DEBUG=$DEBUG WORKDIR /usr/src/code -RUN echo extension=redis.so >> /usr/local/etc/php/conf.d/redis.ini -RUN echo extension=swoole.so >> /usr/local/etc/php/conf.d/swoole.ini RUN echo extension=pcov.so >> /usr/local/etc/php/conf.d/pcov.ini -RUN echo extension=xdebug.so >> /usr/local/etc/php/conf.d/xdebug.ini RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" @@ -105,10 +69,7 @@ RUN echo "opcache.enable_cli=1" >> $PHP_INI_DIR/php.ini RUN echo "memory_limit=1024M" >> $PHP_INI_DIR/php.ini COPY --from=composer /usr/local/src/vendor /usr/src/code/vendor -COPY --from=swoole /usr/local/lib/php/extensions/no-debug-non-zts-20240924/swoole.so /usr/local/lib/php/extensions/no-debug-non-zts-20240924/ -COPY --from=redis /usr/local/lib/php/extensions/no-debug-non-zts-20240924/redis.so /usr/local/lib/php/extensions/no-debug-non-zts-20240924/ COPY --from=pcov /usr/local/lib/php/extensions/no-debug-non-zts-20240924/pcov.so /usr/local/lib/php/extensions/no-debug-non-zts-20240924/ -COPY --from=xdebug /usr/local/lib/php/extensions/no-debug-non-zts-20240924/xdebug.so /usr/local/lib/php/extensions/no-debug-non-zts-20240924/ COPY ./bin /usr/src/code/bin COPY ./src /usr/src/code/src From 0f3aeec18128ff2ab979fe7bf92d81e8f9c0682a Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 17 Feb 2026 00:17:57 +1300 Subject: [PATCH 05/14] fix: Use correct Docker Hub image name for utopia base Changed from utopia-php/base to appwrite/utopia-base. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 3c764c75b..a340dcfdd 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN composer install \ --no-scripts \ --prefer-dist -FROM utopia-php/base:php-8.4-0.2.1 AS compile +FROM appwrite/utopia-base:php-8.4-0.2.1 AS compile ENV PHP_MONGODB_VERSION="2.1.1" From b51805228ec7deb166e3d6dc19a7537af7b5c84e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 17 Feb 2026 00:42:24 +1300 Subject: [PATCH 06/14] debug: Log attribute differences in updateDocument Temporary debug to identify cache vs DB data differences. Co-Authored-By: Claude Opus 4.6 --- src/Database/Database.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Database/Database.php b/src/Database/Database.php index 981828972..d4bf21b3e 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5701,6 +5701,7 @@ public function updateDocument(string $collection, string $id, Document $documen // If values are not equal we need to update document. if ($value !== $oldValue) { $shouldUpdate = true; + error_log("DEBUG shouldUpdate: key={$key} type_new=" . gettype($value) . " type_old=" . gettype($oldValue) . " val_new=" . json_encode($value) . " val_old=" . json_encode($oldValue)); break; } } From 278151f22e43bb9a41acf6f70767422c71dfb4a9 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 17 Feb 2026 00:49:24 +1300 Subject: [PATCH 07/14] debug: Use stdout for attribute difference logging Co-Authored-By: Claude Opus 4.6 --- src/Database/Database.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index d4bf21b3e..e4a5f5ccf 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5701,7 +5701,7 @@ public function updateDocument(string $collection, string $id, Document $documen // If values are not equal we need to update document. if ($value !== $oldValue) { $shouldUpdate = true; - error_log("DEBUG shouldUpdate: key={$key} type_new=" . gettype($value) . " type_old=" . gettype($oldValue) . " val_new=" . json_encode($value) . " val_old=" . json_encode($oldValue)); + fwrite(STDOUT, "\nDEBUG shouldUpdate: key={$key} type_new=" . gettype($value) . " type_old=" . gettype($oldValue) . " val_new=" . json_encode($value) . " val_old=" . json_encode($oldValue) . "\n"); break; } } From a4d1d341914faa47125272fbe066aa26e8ba748a Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 17 Feb 2026 00:59:35 +1300 Subject: [PATCH 08/14] debug: Include diff info in AuthorizationException message Co-Authored-By: Claude Opus 4.6 --- src/Database/Database.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index e4a5f5ccf..da675ee9c 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5701,7 +5701,11 @@ public function updateDocument(string $collection, string $id, Document $documen // If values are not equal we need to update document. if ($value !== $oldValue) { $shouldUpdate = true; - fwrite(STDOUT, "\nDEBUG shouldUpdate: key={$key} type_new=" . gettype($value) . " type_old=" . gettype($oldValue) . " val_new=" . json_encode($value) . " val_old=" . json_encode($oldValue) . "\n"); + $debugDiffKey = $key; + $debugDiffNew = json_encode($value); + $debugDiffOld = json_encode($oldValue); + $debugTypeNew = gettype($value); + $debugTypeOld = gettype($oldValue); break; } } @@ -5718,7 +5722,7 @@ public function updateDocument(string $collection, string $id, Document $documen if ($shouldUpdate) { if (!$this->authorization->isValid(new Input(self::PERMISSION_UPDATE, $updatePermissions))) { - throw new AuthorizationException($this->authorization->getDescription()); + throw new AuthorizationException($this->authorization->getDescription() . " [DEBUG diff_key={$debugDiffKey} type_new={$debugTypeNew} type_old={$debugTypeOld} val_new={$debugDiffNew} val_old={$debugDiffOld}]"); } } else { if (!$this->authorization->isValid(new Input(self::PERMISSION_READ, $readPermissions))) { From 3f6ccc259afd47cdbafb9780540b6595c5185b60 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 17 Feb 2026 01:12:58 +1300 Subject: [PATCH 09/14] fix: Handle int/float type coercion in updateDocument comparison When a document goes through cache (JSON serialize/deserialize), float values like 1.0 become int 1. PHP strict comparison then incorrectly detects a change, triggering unnecessary UPDATE permission checks. Use loose numeric comparison to handle this edge case. Co-Authored-By: Claude Opus 4.6 --- src/Database/Database.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index da675ee9c..8e1ae6ac0 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5700,12 +5700,11 @@ public function updateDocument(string $collection, string $id, Document $documen // If values are not equal we need to update document. if ($value !== $oldValue) { + // Handle int/float type coercion (e.g. cache JSON round-trip turns 1.0 into 1) + if (\is_numeric($value) && \is_numeric($oldValue) && $value == $oldValue) { + continue; + } $shouldUpdate = true; - $debugDiffKey = $key; - $debugDiffNew = json_encode($value); - $debugDiffOld = json_encode($oldValue); - $debugTypeNew = gettype($value); - $debugTypeOld = gettype($oldValue); break; } } @@ -5722,7 +5721,7 @@ public function updateDocument(string $collection, string $id, Document $documen if ($shouldUpdate) { if (!$this->authorization->isValid(new Input(self::PERMISSION_UPDATE, $updatePermissions))) { - throw new AuthorizationException($this->authorization->getDescription() . " [DEBUG diff_key={$debugDiffKey} type_new={$debugTypeNew} type_old={$debugTypeOld} val_new={$debugDiffNew} val_old={$debugDiffOld}]"); + throw new AuthorizationException($this->authorization->getDescription()); } } else { if (!$this->authorization->isValid(new Input(self::PERMISSION_READ, $readPermissions))) { From e5d0d3f6ba48e93c241464d5abb4b755ac7492e9 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 17 Feb 2026 01:27:37 +1300 Subject: [PATCH 10/14] fix: Flaky testSingleDocumentDateOperations timing issue Add small sleep between document create and update to ensure timestamps differ in millisecond-precision datetime checks. Co-Authored-By: Claude Opus 4.6 --- tests/e2e/Adapter/Scopes/DocumentTests.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 7c0fc4cd3..0ff5ece28 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -5921,6 +5921,8 @@ public function testSingleDocumentDateOperations(): void $newUpdatedAt = $doc11->getUpdatedAt(); + \usleep(2000); // Ensure updatedAt timestamp differs from creation time + $newDoc11 = new Document([ 'string' => 'no_dates_update', ]); From 0380e4a1d6cc3c2b2f8a2ba27cd7bdbd5dbd47bf Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 17 Feb 2026 01:44:48 +1300 Subject: [PATCH 11/14] fix: Cast merged document types before comparison in updateDocument Apply casting() to the merged document before comparing with $old. This normalizes types (e.g. int 1 back to float 1.0) so the comparison against $old (which was cast during getDocument) uses consistent types. Fixes false-positive change detection caused by JSON cache round-trips degrading float types to int. Co-Authored-By: Claude Opus 4.6 --- src/Database/Database.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 8e1ae6ac0..038d6e65e 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5596,6 +5596,7 @@ public function updateDocument(string $collection, string $id, Document $documen $document['$tenant'] = $old->getTenant(); // Make sure user doesn't switch tenant } $document = new Document($document); + $document = $this->casting($collection, $document); $relationships = \array_filter($collection->getAttribute('attributes', []), function ($attribute) { return $attribute['type'] === Database::VAR_RELATIONSHIP; @@ -5700,10 +5701,6 @@ public function updateDocument(string $collection, string $id, Document $documen // If values are not equal we need to update document. if ($value !== $oldValue) { - // Handle int/float type coercion (e.g. cache JSON round-trip turns 1.0 into 1) - if (\is_numeric($value) && \is_numeric($oldValue) && $value == $oldValue) { - continue; - } $shouldUpdate = true; break; } From 142b8ddd81e43cfc1bce23e2d5bf53db44a69cf5 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 17 Feb 2026 01:56:16 +1300 Subject: [PATCH 12/14] fix: Cast comparison values by attribute type in updateDocument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cast individual values to their declared attribute type before comparing with $old, skipping Operator instances. This normalizes types degraded by cache JSON round-trips (e.g. float 1.0 → int 1) without affecting the document that gets persisted. Co-Authored-By: Claude Opus 4.6 --- src/Database/Database.php | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 038d6e65e..506ff5642 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5596,12 +5596,17 @@ public function updateDocument(string $collection, string $id, Document $documen $document['$tenant'] = $old->getTenant(); // Make sure user doesn't switch tenant } $document = new Document($document); - $document = $this->casting($collection, $document); $relationships = \array_filter($collection->getAttribute('attributes', []), function ($attribute) { return $attribute['type'] === Database::VAR_RELATIONSHIP; }); + // Build attribute type map for type-safe comparison + $attributeTypes = []; + foreach ($collection->getAttribute('attributes', []) as $attr) { + $attributeTypes[$attr['$id'] ?? ''] = $attr['type'] ?? ''; + } + $shouldUpdate = false; if ($collection->getId() !== self::METADATA) { @@ -5699,6 +5704,18 @@ public function updateDocument(string $collection, string $id, Document $documen $oldValue = $old->getAttribute($key); + // Cast value to attribute type for consistent comparison + // (e.g. cache JSON round-trip turns float 1.0 into int 1) + $attrType = $attributeTypes[$key] ?? null; + if ($attrType !== null && !\is_null($value) && !($value instanceof Operator)) { + $value = match ($attrType) { + self::VAR_FLOAT => (float)$value, + self::VAR_INTEGER => (int)$value, + self::VAR_BOOLEAN => (bool)$value, + default => $value, + }; + } + // If values are not equal we need to update document. if ($value !== $oldValue) { $shouldUpdate = true; From 451d5235910197f146fcac884016fbb086858e24 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 17 Feb 2026 02:06:29 +1300 Subject: [PATCH 13/14] fix: Cast both sides of comparison and fix flaky timing test Cast both $value and $oldValue by attribute type before comparison to handle adapters like MongoDB that don't cast floats on read. Add usleep before doc4 update to prevent same-millisecond timestamps. Co-Authored-By: Claude Opus 4.6 --- src/Database/Database.php | 25 +++++++++++++++------- tests/e2e/Adapter/Scopes/DocumentTests.php | 1 + 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 506ff5642..eebd73873 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5704,16 +5704,25 @@ public function updateDocument(string $collection, string $id, Document $documen $oldValue = $old->getAttribute($key); - // Cast value to attribute type for consistent comparison - // (e.g. cache JSON round-trip turns float 1.0 into int 1) + // Cast both values to attribute type for consistent comparison + // (e.g. cache JSON round-trip turns float 1.0 into int 1, + // and some adapters may not cast floats on read) $attrType = $attributeTypes[$key] ?? null; - if ($attrType !== null && !\is_null($value) && !($value instanceof Operator)) { - $value = match ($attrType) { - self::VAR_FLOAT => (float)$value, - self::VAR_INTEGER => (int)$value, - self::VAR_BOOLEAN => (bool)$value, - default => $value, + if ($attrType !== null && !($value instanceof Operator)) { + $castFn = match ($attrType) { + self::VAR_FLOAT => fn($v) => (float)$v, + self::VAR_INTEGER => fn($v) => (int)$v, + self::VAR_BOOLEAN => fn($v) => (bool)$v, + default => null, }; + if ($castFn !== null) { + if (!\is_null($value)) { + $value = $castFn($value); + } + if (!\is_null($oldValue)) { + $oldValue = $castFn($oldValue); + } + } } // If values are not equal we need to update document. diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 0ff5ece28..dfbdcc660 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -5834,6 +5834,7 @@ public function testSingleDocumentDateOperations(): void $doc4->setAttribute('$updatedAt', null); $doc4->setAttribute('$createdAt', null); + \usleep(2000); // Ensure updatedAt timestamp differs from creation time $updatedDoc4 = $database->updateDocument($collection, 'doc4', document: $doc4); $this->assertEquals($originalCreatedAt4, $updatedDoc4->getAttribute('$createdAt')); From 389e166cabad049719be5c73825f10ab9dab804e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 17 Feb 2026 02:12:18 +1300 Subject: [PATCH 14/14] fix: Replace arrow functions with switch for PSR-12 compliance Co-Authored-By: Claude Opus 4.6 --- src/Database/Database.php | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index eebd73873..eaf31eaa5 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5709,19 +5709,19 @@ public function updateDocument(string $collection, string $id, Document $documen // and some adapters may not cast floats on read) $attrType = $attributeTypes[$key] ?? null; if ($attrType !== null && !($value instanceof Operator)) { - $castFn = match ($attrType) { - self::VAR_FLOAT => fn($v) => (float)$v, - self::VAR_INTEGER => fn($v) => (int)$v, - self::VAR_BOOLEAN => fn($v) => (bool)$v, - default => null, - }; - if ($castFn !== null) { - if (!\is_null($value)) { - $value = $castFn($value); - } - if (!\is_null($oldValue)) { - $oldValue = $castFn($oldValue); - } + switch ($attrType) { + case self::VAR_FLOAT: + $value = \is_null($value) ? null : (float)$value; + $oldValue = \is_null($oldValue) ? null : (float)$oldValue; + break; + case self::VAR_INTEGER: + $value = \is_null($value) ? null : (int)$value; + $oldValue = \is_null($oldValue) ? null : (int)$oldValue; + break; + case self::VAR_BOOLEAN: + $value = \is_null($value) ? null : (bool)$value; + $oldValue = \is_null($oldValue) ? null : (bool)$oldValue; + break; } }