From d41c3cd87eeccc9e11838af8bd312e46629acb31 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 12 Oct 2026 17:08:41 +1300 Subject: [PATCH 01/10] test(search): expect an exact term to match the adjacent phrase on every engine The user decided on 2026-10-06 that search('"foo bar"') matches the adjacent phrase on every engine, as MariaDB, MySQL and SQLite always did, and 8.0.0 shipped that on PostgreSQL too. 8.0.1 bound the 7.x value again, so PostgreSQL returned 'trump donald' and 'donald john trump' for "donald trump". The e2e pair now runs on every engine with a fulltext index: the exact term returns only documents holding the phrase (notSearch excludes only those, except on MongoDB, whose notSearch is a regex over the raw term), and a term without quotes keeps matching any word in any order. The PostgreSQL unit bindings expect the phrase quotes and the 'or'-joined words of 8.0.0 again. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/Adapter/Scopes/DocumentTests.php | 60 ++++++++++++++++++---- tests/unit/Builder/SearchTermTest.php | 44 ++++++---------- 2 files changed, 66 insertions(+), 38 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 4f930cadf..ac583544a 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -2029,18 +2029,54 @@ public function testContainsAllOnAStringMatchesAnyWholeValueAs7xDid(): void $this->assertSame($adapter instanceof Mongo ? [] : ['lower', 'other'], $ids(['alpha', 'beta'])); } - public function testFindFulltextExactTermOnPostgresMatchesEveryWordInAnyOrder(): void + public function testFindFulltextExactTermMatchesTheAdjacentPhraseOnEveryEngine(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (! $database->getAdapter() instanceof Postgres) { + if (! $database->getAdapter()->supports(Capability::IndexFulltext)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = $this->createPhraseSearchCollection($database, 'full_text_exact_phrase'); + + $this->assertSame(['phrase', 'sentence'], $this->searchedIds($database, $collection, '"donald trump"')); + + if ($database->getAdapter() instanceof Mongo) { + return; + } + + $excluded = \array_map( + static fn (Document $document): string => $document->getId(), + $database->find($collection, [Query::notSearch('text', '"donald trump"')]), + ); + \sort($excluded); + $this->assertSame(['apart', 'one', 'other', 'reversed'], $excluded); + } + + public function testFindFulltextWordsWithoutQuotesMatchAnyWordInAnyOrder(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::IndexFulltext)) { $this->expectNotToPerformAssertions(); return; } - $collection = 'full_text_exact_words'; + $collection = $this->createPhraseSearchCollection($database, 'full_text_any_word'); + + $anyWord = ['apart', 'one', 'phrase', 'reversed', 'sentence']; + $this->assertSame($anyWord, $this->searchedIds($database, $collection, 'donald trump')); + $this->assertSame($anyWord, $this->searchedIds($database, $collection, 'trump donald')); + $this->assertSame($anyWord, $this->searchedIds($database, $collection, 'donald/trump')); + } + + private function createPhraseSearchCollection(Database $database, string $collection): string + { $database->createCollection(Collection::create(id: $collection, permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), @@ -2048,7 +2084,15 @@ public function testFindFulltextExactTermOnPostgresMatchesEveryWordInAnyOrder(): $database->createAttribute($collection, Attribute::string(key: 'text', size: 128, required: true)); $database->createIndex($collection, Index::fulltext(key: 'text-ft', attributes: ['text'])); - foreach (['phrase' => 'donald trump', 'reversed' => 'trump donald', 'apart' => 'donald j trump', 'one' => 'donald duck'] as $id => $text) { + $texts = [ + 'phrase' => 'donald trump', + 'sentence' => 'president donald trump spoke', + 'reversed' => 'trump donald', + 'apart' => 'donald john trump', + 'one' => 'donald duck', + 'other' => 'mickey mouse', + ]; + foreach ($texts as $id => $text) { $database->createDocument($collection, new Document([ '$id' => $id, '$permissions' => [Permission::read(Role::any())], @@ -2056,13 +2100,7 @@ public function testFindFulltextExactTermOnPostgresMatchesEveryWordInAnyOrder(): ])); } - $this->assertSame(['apart', 'phrase', 'reversed'], $this->searchedIds($database, $collection, '"donald trump"')); - - $excluded = \array_map( - static fn (Document $document): string => $document->getId(), - $database->find($collection, [Query::notSearch('text', '"donald trump"')]), - ); - $this->assertSame(['one'], $excluded); + return $collection; } /** diff --git a/tests/unit/Builder/SearchTermTest.php b/tests/unit/Builder/SearchTermTest.php index 7b39973d6..3c6ab33f5 100644 --- a/tests/unit/Builder/SearchTermTest.php +++ b/tests/unit/Builder/SearchTermTest.php @@ -18,23 +18,23 @@ final class SearchTermTest extends TestCase public static function postgreSQLTerms(): array { return [ - 'slash separates words' => ['foo/bar', "'foo or bar'"], - 'comma separates words' => ['foo,bar', "'foo or bar'"], - 'semicolon separates words' => ['foo;bar', "'foo or bar'"], - 'percent separates words' => ['foo%bar', "'foo or bar'"], - 'equals separates words' => ['foo=bar', "'foo or bar'"], - 'question mark separates words' => ['foo?bar', "'foo or bar'"], - 'hash separates words' => ['foo#bar', "'foo or bar'"], - 'colon separates words' => ['foo:bar', "'foo or bar'"], - 'pipe separates words' => ['foo|bar', "'foo or bar'"], - 'ampersand separates words' => ['foo&bar', "'foo or bar'"], - 'exclamation mark separates words' => ['foo!bar', "'foo or bar'"], - 'underscore stays inside a word' => ['foo_bar', "'foo_bar'"], - 'mixed separators and spaces' => ['baz, foo/bar; qux', "'baz or foo or bar or qux'"], - 'operators are dropped' => ['+foo -bar* @3 ~qux (quux)', "'foo or bar or 3 or baz or qux or quux'"], - 'accented words are kept' => ['@García!', "'García'"], - 'exact term matches every word in single quotes' => ['"foo/bar baz"', "'foo bar baz'"], - 'unbalanced quote is not exact' => ['"foo/bar', "'foo or bar'"], + 'slash separates words' => ['foo/bar', 'foo or bar'], + 'comma separates words' => ['foo,bar', 'foo or bar'], + 'semicolon separates words' => ['foo;bar', 'foo or bar'], + 'percent separates words' => ['foo%bar', 'foo or bar'], + 'equals separates words' => ['foo=bar', 'foo or bar'], + 'question mark separates words' => ['foo?bar', 'foo or bar'], + 'hash separates words' => ['foo#bar', 'foo or bar'], + 'colon separates words' => ['foo:bar', 'foo or bar'], + 'pipe separates words' => ['foo|bar', 'foo or bar'], + 'ampersand separates words' => ['foo&bar', 'foo or bar'], + 'exclamation mark separates words' => ['foo!bar', 'foo or bar'], + 'underscore stays inside a word' => ['foo_bar', 'foo_bar'], + 'mixed separators and spaces' => ['baz, foo/bar; qux', 'baz or foo or bar or qux'], + 'operators are dropped' => ['+foo -bar* @3 ~qux (quux)', 'foo or bar or 3 or baz or qux or quux'], + 'accented words are kept' => ['@García!', 'García'], + 'exact phrase keeps its quotes' => ['"foo/bar baz"', '"foo bar baz"'], + 'unbalanced quote is not exact' => ['"foo/bar', 'foo or bar'], ]; } @@ -77,16 +77,6 @@ public function testPostgreSQLSearchesEachWordOfATerm(string $term, string $boun $this->assertSame([$bound], $this->bindings(new Postgres(), Query::notSearch('title', $term))); } - public function testPostgreSQLMatchesTheTermWithWebsearchToTsquery(): void - { - $search = (new Postgres())->compileFilters([Query::search('title', '"foo bar"')]); - $notSearch = (new Postgres())->compileFilters([Query::notSearch('title', '"foo bar"')]); - - $this->assertStringContainsString("@@ websearch_to_tsquery(?)", $search->expression); - $this->assertStringStartsWith('NOT (', $notSearch->expression); - $this->assertSame(["'foo bar'"], $search->bindings); - } - #[DataProvider('mySQLTerms')] public function testMySQLSearchesEachWordOfATerm(string $term, string $bound): void { From b840f8bfb6389c521e948152d19038a606cea887 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 12 Oct 2026 17:10:35 +1300 Subject: [PATCH 02/10] fix(postgres): match an exact search term as the adjacent phrase again 8.0.0 matched search('"foo bar"') as the adjacent phrase on PostgreSQL, as MariaDB, MySQL and SQLite always had, and the user confirmed on 2026-10-06 that this is the intended behaviour on every engine. 8.0.1 took the 7.x any-order match for a regression and bound the term in single quotes again. Dropping that override hands the prepared term back to the query builder, which keeps an exact term's double quotes for websearch_to_tsquery(). The 7.4.1 word splitting (foo/bar and foo,bar search both words) still runs first, so that fix stays intact. UPGRADE.md and CHANGELOG.md (8.0.2) say 8.0.1 reverted the phrase match by mistake. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 +++++++++++ UPGRADE.md | 7 +++++-- src/Database/Builder/Postgres.php | 29 +++-------------------------- 3 files changed, 19 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 438cb3e51..c52ab103b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 8.0.2 + +### Fixed + +- On PostgreSQL an exact search (`search('title', '"foo bar"')`) matches the adjacent phrase again, as on MariaDB, + MySQL, SQLite, MongoDB, Memory and Redis, and `notSearch()` with an exact term excludes only that phrase. 8.0.1 + reverted it to every word in any order by mistake: the phrase match was a deliberate 8.0.0 change, not a + regression. Words in a search term are still split on every character other than a letter, a number or an + underscore, as in 7.x (`foo/bar` and `foo,bar` search `foo` and `bar`). + ## 8.0.1 8.0.1 restores the 7.4.1 behaviour that 8.0.0 changed for callers of features 7.x already had. New 8.0 features are @@ -24,6 +34,7 @@ unchanged. It also holds nested relationship links to the permission rule UPGRAD select attributes: $tenant`, as in 7.x. - The `UID` validator description is the 7.x text again, also inside `Invalid cursor: …`. - On PostgreSQL an exact search (`"foo bar"`) matches every word in any order again, not the adjacent phrase. + Reverted in 8.0.2: this was a deliberate 8.0.0 change. - `containsAll()` on an attribute that is not an array matches each value as a whole-value `LIKE` (`ILIKE` on PostgreSQL) pattern, any of them, on MariaDB, MySQL and PostgreSQL, as in 7.x. - A read whose orders include `orderRandom()` ignores its cursor instead of failing with `Order attribute '' is diff --git a/UPGRADE.md b/UPGRADE.md index cf03f2ae7..642d39f50 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -223,8 +223,11 @@ $permissions = [Permission::read(Role::user(Id::unique()))]; - A filter on a path into an object attribute (`meta.address.city`) takes keys of `a-z`, `A-Z`, `0-9`, `_` and `-` only. The query validator refuses any other key with `Utopia\Database\Exception\Query` on every adapter (7.x refused such keys on PostgreSQL only), and on PostgreSQL the query is refused also when validation is skipped. -- On PostgreSQL, an exact search (`search('title', '"foo bar"')`) matches documents holding every word in any order, - as in 7.x, where MariaDB, MySQL and SQLite match the adjacent phrase. +- On PostgreSQL, an exact search (`search('title', '"foo bar"')`) matches the words as an adjacent phrase, as on + MariaDB, MySQL and SQLite, and `notSearch()` with an exact term excludes only that phrase. In 7.x PostgreSQL + matched every word in any order, and so did 8.0.1, which reverted the phrase match by mistake; 8.0.0 and 8.0.2 + match the phrase. To match every word in any order, pass a `search()` for each word. A term without quotes still + matches any of its words. - `containsAll()` on an attribute that is not an array matches as in 7.x: on MariaDB, MySQL and PostgreSQL each value is a `LIKE` (`ILIKE`) pattern on the whole value and a document matching any of them is returned; on MongoDB it is `$all`. Use `containsString()` for substrings. diff --git a/src/Database/Builder/Postgres.php b/src/Database/Builder/Postgres.php index f5dec25f5..fac26ba8e 100644 --- a/src/Database/Builder/Postgres.php +++ b/src/Database/Builder/Postgres.php @@ -12,9 +12,9 @@ use Utopia\Query\Schema\ColumnType; /** - * The PostgreSQL builder, which also compiles filters on their own, prepares and matches search terms and containsAll() - * as 7.x did, writes a filter on a path into an object attribute only when every key of the path is a plain key, and - * lets a distanceLessThan() filter use the spatial index. + * The PostgreSQL builder, which also compiles filters on their own, prepares search terms and matches containsAll() as + * 7.x did, writes a filter on a path into an object attribute only when every key of the path is a plain key, and lets + * a distanceLessThan() filter use the spatial index. */ class Postgres extends Base implements Filtering, Scoping { @@ -49,29 +49,6 @@ public function deleteUsing(string $table, string $condition, mixed ...$bindings return parent::deleteUsing($table, $condition, ...$bindings); } - /** - * 7.x handed websearch_to_tsquery() an exact term in single quotes, which match every word in any order where - * double quotes would match the adjacent phrase, and any other term with its words joined by `or`. - * - * @param array $values - */ - #[\Override] - protected function compileSearchExpression(string $attribute, array $values, bool $not): string - { - $term = $values[0] ?? ''; - $term = \is_string($term) ? $term : ''; - $words = $this->searchWords($term); - - if ($words === '') { - return $not ? '1 = 1' : '1 = 0'; - } - - $this->addBinding("'".($this->isExactSearch($term) ? $words : \str_replace(' ', ' or ', $words))."'"); - $match = "to_tsvector(regexp_replace({$attribute}, '[^\\w]+', ' ', 'g')) @@ websearch_to_tsquery(?)"; - - return $not ? "NOT ({$match})" : $match; - } - /** * @throws QueryException */ From f92656cf6f7cac8cdb358bccbcf3130dd6c72270 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 12 Oct 2026 17:42:17 +1300 Subject: [PATCH 03/10] test: expect the 8.0 counter, operator, UID, stored-value and purge-event rules again The user decided these 8.0 rules stand, and 8.0.1 reverted them as if they were regressions (decisions 10-01, BUG-11 approved 10-07, re-confirmed 10-12): - increase/decreaseDocumentAttribute() refuse a fractional change, max or min on an integer attribute, and a change of 0 or less throws Exception\Type (BUG-11: catch (DatabaseException) must see it); - numeric operators refuse a fractional limit on an integer attribute, while numeric text such as power('2') stays accepted; - the UID description names the characters Key accepts (period, hyphen, underscore, none of them leading), not 7.x's narrower wrong text; - an update does not re-validate stored values it leaves unchanged, for every attribute type (J4C-011), so a narrowed range or enum never blocks an update of another attribute, while a value the update writes is held to the new rule; - document_purge fires after the outermost commit and never for a write that rolled back. The tests 8.0.1 rewrote go back to their 8.0.0 expectations. A new DocumentPurgeTest case pins the 10-08 decision that stays: the cache purge runs inside the write's transaction, so a cache failure rolls the write back and no purge event fires. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/Adapter/Scopes/DocumentTests.php | 80 ++++++----------- tests/e2e/Adapter/Scopes/OperatorTests.php | 82 +++-------------- tests/unit/Adapter/RedisAdapterPathsTest.php | 10 +-- tests/unit/Documents/FractionalBoundTest.php | 63 ++++++++----- .../Documents/FractionalOperatorLimitTest.php | 30 +++---- tests/unit/Documents/IncreaseDecreaseTest.php | 6 +- tests/unit/Documents/IncreaseValueTest.php | 9 +- .../Documents/NumericUpdateGuardsTest.php | 4 +- .../Documents/StoredValueRevalidationTest.php | 38 +++++--- tests/unit/Event/DocumentPurgeTest.php | 90 +++++++++++-------- tests/unit/Operator/OperatorLimitTest.php | 29 +++--- .../unit/Validator/OperatorValidatorTest.php | 9 +- .../Validator/QueriesCompatibilityTest.php | 12 ++- 13 files changed, 212 insertions(+), 250 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index ac583544a..5141e50b6 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -9,7 +9,6 @@ use Utopia\Cache\Adapter\None as NoneCacheAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Feature; -use Utopia\Database\Adapter\MariaDB; use Utopia\Database\Adapter\Memory; use Utopia\Database\Adapter\Mongo; use Utopia\Database\Adapter\Postgres; @@ -10905,11 +10904,10 @@ private function statementsOn(Database $database, string $table, callable $read) return [$result, $statements]; } - public function testIncreaseAndDecreaseCompareAFractionalBoundOnAnIntegerAs7xDid(): void + public function testIncreaseAndDecreaseRefuseAFractionalBoundOnAnInteger(): void { /** @var Database $database */ $database = $this->getDatabase(); - $adapter = $database->getAdapter(); $collection = 'fractional_bound_'.uniqid(); $database->createCollection(Collection::create(id: $collection, permissions: [ @@ -10918,42 +10916,35 @@ public function testIncreaseAndDecreaseCompareAFractionalBoundOnAnIntegerAs7xDid Permission::update(Role::any()), ], documentSecurity: false)); $database->createAttribute($collection, Attribute::integer(key: 'count', required: true)); - foreach (['up', 'past', 'down', 'whole'] as $id) { - $database->createDocument($collection, new Document(['$id' => $id, 'count' => 5])); - } + $database->createDocument($collection, new Document(['$id' => 'counter', 'count' => 100])); try { try { - $database->increaseDocumentAttribute($collection, 'past', 'count', 2, 6.4); - $this->fail('An increase past a fractional maximum was accepted'); - } catch (LimitException $error) { - $this->assertSame('Attribute value exceeds maximum limit: 6.4', $error->getMessage()); + $database->increaseDocumentAttribute($collection, 'counter', 'count', 1, 102.4); + $this->fail('A fractional maximum on an integer attribute was accepted'); + } catch (TypeException $error) { + $this->assertSame('Max must be an integer.', $error->getMessage()); } - $this->assertSame(5, $database->getDocument($collection, 'past')->getAttribute('count')); - if ($adapter instanceof Postgres) { - $this->assertInstanceOf(\PDOException::class, $this->failureOf(fn () => $database->increaseDocumentAttribute($collection, 'up', 'count', 1, 6.4))); - $this->assertInstanceOf(\PDOException::class, $this->failureOf(fn () => $database->decreaseDocumentAttribute($collection, 'down', 'count', 1, 0.5))); - $this->assertSame(5, $database->getDocument($collection, 'up')->getAttribute('count')); - } else { - $this->assertSame(6, $database->increaseDocumentAttribute($collection, 'up', 'count', 1, 6.4)->getAttribute('count')); - $this->assertSame(4, $database->decreaseDocumentAttribute($collection, 'down', 'count', 1, 0.5)->getAttribute('count')); - $this->assertSame(6, $database->getDocument($collection, 'up')->getAttribute('count')); - $this->assertSame(4, $database->getDocument($collection, 'down')->getAttribute('count')); + try { + $database->decreaseDocumentAttribute($collection, 'counter', 'count', 1, 0.5); + $this->fail('A fractional minimum on an integer attribute was accepted'); + } catch (TypeException $error) { + $this->assertSame('Min must be an integer.', $error->getMessage()); } - $this->assertSame(6, $database->increaseDocumentAttribute($collection, 'whole', 'count', 1, 6.0)->getAttribute('count')); - $this->assertSame(5, $database->decreaseDocumentAttribute($collection, 'whole', 'count', 1, 5.0)->getAttribute('count')); + $this->assertSame(100, $database->getDocument($collection, 'counter')->getAttribute('count')); + $this->assertSame(101, $database->increaseDocumentAttribute($collection, 'counter', 'count', 1, 102.0)->getAttribute('count')); + $this->assertSame(100, $database->decreaseDocumentAttribute($collection, 'counter', 'count', 1, 99.0)->getAttribute('count')); } finally { $database->deleteCollection($collection); } } - public function testIncreaseAndDecreaseApplyAFractionalChangeOnAnIntegerAs7xDid(): void + public function testIncreaseAndDecreaseRefuseAFractionalChangeValueOnAnInteger(): void { /** @var Database $database */ $database = $this->getDatabase(); - $adapter = $database->getAdapter(); $collection = 'fractional_change_'.uniqid(); $database->createCollection(Collection::create(id: $collection, permissions: [ @@ -10962,43 +10953,30 @@ public function testIncreaseAndDecreaseApplyAFractionalChangeOnAnIntegerAs7xDid( Permission::update(Role::any()), ], documentSecurity: false)); $database->createAttribute($collection, Attribute::integer(key: 'count', required: true)); - foreach (['up', 'down', 'whole'] as $id) { - $database->createDocument($collection, new Document(['$id' => $id, 'count' => 5])); - } + $database->createDocument($collection, new Document(['$id' => 'counter', 'count' => 100])); try { - if ($adapter instanceof Postgres) { - $this->assertInstanceOf(\PDOException::class, $this->failureOf(fn () => $database->increaseDocumentAttribute($collection, 'up', 'count', 1.5))); - $this->assertInstanceOf(\PDOException::class, $this->failureOf(fn () => $database->decreaseDocumentAttribute($collection, 'down', 'count', 0.5))); - $this->assertSame(5, $database->getDocument($collection, 'up')->getAttribute('count')); - } else { - $this->assertSame(6.5, $database->increaseDocumentAttribute($collection, 'up', 'count', 1.5)->getAttribute('count')); - $this->assertSame(4.5, $database->decreaseDocumentAttribute($collection, 'down', 'count', 0.5)->getAttribute('count')); + try { + $database->increaseDocumentAttribute($collection, 'counter', 'count', 1.5); + $this->fail('A fractional increase of an integer attribute was accepted'); + } catch (TypeException $error) { + $this->assertSame('Change value must be an integer.', $error->getMessage()); + } - if ($adapter instanceof MariaDB || $adapter instanceof Mongo) { - $this->assertSame(6, $database->getDocument($collection, 'up')->getAttribute('count')); - $this->assertSame(4, $database->getDocument($collection, 'down')->getAttribute('count')); - } + try { + $database->decreaseDocumentAttribute($collection, 'counter', 'count', 0.5); + $this->fail('A fractional decrease of an integer attribute was accepted'); + } catch (TypeException $error) { + $this->assertSame('Change value must be an integer.', $error->getMessage()); } - $this->assertSame(7.0, $database->increaseDocumentAttribute($collection, 'whole', 'count', 2.0)->getAttribute('count')); - $this->assertSame(7, $database->getDocument($collection, 'whole')->getAttribute('count')); + $this->assertSame(100, $database->getDocument($collection, 'counter')->getAttribute('count')); + $this->assertSame(102, $database->increaseDocumentAttribute($collection, 'counter', 'count', 2)->getAttribute('count')); } finally { $database->deleteCollection($collection); } } - private function failureOf(callable $call): ?Throwable - { - try { - $call(); - } catch (Throwable $error) { - return $error; - } - - return null; - } - public function testDistinctIsRefusedWhereTheAdapterCannotDeduplicate(): void { /** @var Database $database */ diff --git a/tests/e2e/Adapter/Scopes/OperatorTests.php b/tests/e2e/Adapter/Scopes/OperatorTests.php index 95b15a23a..0bf19d41a 100644 --- a/tests/e2e/Adapter/Scopes/OperatorTests.php +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -5230,13 +5230,19 @@ public function testOperatorWholeFloatLimitHoldsAndAFractionalLimitIsRefused(): $this->assertSame(PHP_INT_MAX - 5, $updated->getAttribute('counter')); $this->assertSame(PHP_INT_MAX - 5, $database->getDocument($collectionId, 'doc')->getAttribute('counter')); - $past = $database->updateDocument($collectionId, 'doc', new Document(['count' => Operator::increment(5, 102.4)])); - $this->assertSame($database->getDocument($collectionId, 'doc')->getAttribute('count'), $past->getAttribute('count')); - - if ($this->engineFamily($database) !== null) { - $this->assertSame(100, $past->getAttribute('count')); - $this->assertSame(102, $database->updateDocument($collectionId, 'doc', new Document(['count' => Operator::increment(2, 102.4)]))->getAttribute('count')); + try { + $database->updateDocument($collectionId, 'doc', new Document([ + 'count' => Operator::increment(5, 102.4), + ])); + $this->fail('A fractional limit on an integer attribute must be refused'); + } catch (StructureException $exception) { + $this->assertSame( + "Invalid document structure: Cannot apply increment operator: max/min limit must be a whole number for integer attribute 'count', got 102.4", + $exception->getMessage(), + ); } + + $this->assertSame(100, $database->getDocument($collectionId, 'doc')->getAttribute('count')); } finally { $database->deleteCollection($collectionId); } @@ -5287,68 +5293,4 @@ public function testPowerWithANumericTextExponent(): void $database->deleteCollection($collectionId); } } - - public function testFractionalNumericOperatorsOnAnIntegerReachTheEngineAs7xDid(): void - { - $database = static::getDatabase(); - $family = $this->engineFamily($database); - - if (! $database->getAdapter()->supports(Capability::Operators) || $family === null) { - $this->expectNotToPerformAssertions(); - - return; - } - - $collectionId = 'operator_fractional_changes'; - $database->createCollection(Collection::create(id: $collectionId, permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], documentSecurity: false)); - $database->createAttribute($collectionId, Attribute::integer(key: 'count')); - $database->createAttribute($collectionId, Attribute::integer(key: 'nums', array: true)); - - try { - $changes = [ - 'increment' => [Operator::increment(1.5), ['mariadb' => 6, 'mongo' => 6]], - 'multiply' => [Operator::multiply(1.5), ['mariadb' => 8, 'mongo' => 7]], - 'divide' => [Operator::divide(0.5), ['mariadb' => 10, 'mongo' => 10]], - ]; - foreach ($changes as $id => [$operator, $expected]) { - $database->createDocument($collectionId, new Document(['$id' => $id, 'count' => 5, 'nums' => [1]])); - - if ($family === 'postgres') { - $failure = null; - try { - $database->updateDocument($collectionId, $id, new Document(['count' => $operator])); - } catch (\Throwable $error) { - $failure = $error; - } - $this->assertInstanceOf(\PDOException::class, $failure, "PostgreSQL stored a fractional {$id} of an integer"); - $this->assertSame(5, $database->getDocument($collectionId, $id)->getAttribute('count'), $id); - - continue; - } - - $this->assertSame($expected[$family], $database->updateDocument($collectionId, $id, new Document(['count' => $operator]))->getAttribute('count'), $id); - $this->assertSame($expected[$family], $database->getDocument($collectionId, $id)->getAttribute('count'), $id); - } - - $database->createDocument($collectionId, new Document(['$id' => 'append', 'count' => 5, 'nums' => [1]])); - $this->assertSame([1, 1], $database->updateDocument($collectionId, 'append', new Document(['nums' => Operator::arrayAppend([1.5])]))->getAttribute('nums')); - } finally { - $database->deleteCollection($collectionId); - } - } - - /** - * The engine a 7.x comparison is pinned for: MariaDB and MySQL, PostgreSQL or MongoDB. - */ - private function engineFamily(Database $database): ?string - { - $adapter = $database->getAdapter(); - - return match (true) { - $adapter instanceof \Utopia\Database\Adapter\MariaDB => 'mariadb', - $adapter instanceof \Utopia\Database\Adapter\Postgres => 'postgres', - $adapter instanceof \Utopia\Database\Adapter\Mongo => 'mongo', - default => null, - }; - } } diff --git a/tests/unit/Adapter/RedisAdapterPathsTest.php b/tests/unit/Adapter/RedisAdapterPathsTest.php index 72344bbb3..78452fe7a 100644 --- a/tests/unit/Adapter/RedisAdapterPathsTest.php +++ b/tests/unit/Adapter/RedisAdapterPathsTest.php @@ -394,7 +394,7 @@ public function testUniqueIndexComparesArrayValuesByContent(): void $adapter->createDocument($this->notes(), new Document(['$id' => 'second', '$permissions' => [], 'tags' => ['a', 'b']])); } - public function testFractionalOperatorLimitOnABigIntegerIsRefusedBeforeTheWrite(): void + public function testFractionalOperatorLimitIsRefusedBeforeTheWrite(): void { $database = $this->database(); $database->create(); @@ -406,12 +406,12 @@ public function testFractionalOperatorLimitOnABigIntegerIsRefusedBeforeTheWrite( $database->createDocument(self::NOTES, new Document(['$id' => 'counter', 'count' => 100, 'big' => PHP_INT_MAX - 5])); try { - $database->updateDocument(self::NOTES, 'counter', new Document(['big' => Operator::increment(5, 4.0e15 + 0.5)])); - $this->fail('A fractional limit on a bigint attribute must be refused'); + $database->updateDocument(self::NOTES, 'counter', new Document(['count' => Operator::increment(5, 102.4)])); + $this->fail('A fractional limit on an integer attribute must be refused'); } catch (StructureException $exception) { - $this->assertStringStartsWith("Invalid document structure: Cannot apply increment operator: max/min limit must be a whole number for integer attribute 'big'", $exception->getMessage()); + $this->assertSame("Invalid document structure: Cannot apply increment operator: max/min limit must be a whole number for integer attribute 'count', got 102.4", $exception->getMessage()); } - $this->assertSame(PHP_INT_MAX - 5, $database->getDocument(self::NOTES, 'counter')->getAttribute('big')); + $this->assertSame(100, $database->getDocument(self::NOTES, 'counter')->getAttribute('count')); $database->updateDocument(self::NOTES, 'counter', new Document(['big' => Operator::increment(10, 9.0e18)])); $this->assertSame(PHP_INT_MAX - 5, $database->getDocument(self::NOTES, 'counter')->getAttribute('big')); diff --git a/tests/unit/Documents/FractionalBoundTest.php b/tests/unit/Documents/FractionalBoundTest.php index 1e14d929d..7b454a485 100644 --- a/tests/unit/Documents/FractionalBoundTest.php +++ b/tests/unit/Documents/FractionalBoundTest.php @@ -13,6 +13,7 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Limit as LimitException; +use Utopia\Database\Exception\Type as TypeException; use Utopia\Database\Operator; use Utopia\Database\Permission; use Utopia\Database\Role; @@ -34,38 +35,44 @@ public static function lanes(): iterable } #[DataProvider('lanes')] - public function testAFractionalMaximumOnAnIntegerIsComparedAs7xDid(bool $definedAttributes): void + public function testIncreaseWithAFractionalMaximumOnAnIntegerIsRefused(bool $definedAttributes): void { $database = $this->database($definedAttributes); - $this->assertSame(101, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 102.4)->getAttribute('count')); - $this->assertSame(102, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, '102.5')->getAttribute('count')); - try { - $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 102.5); - $this->fail('An increase past a fractional maximum was accepted'); - } catch (LimitException $error) { - $this->assertSame('Attribute value exceeds maximum limit: 102.5', $error->getMessage()); + $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 102.4); + $this->fail('A fractional maximum on an integer attribute was accepted'); + } catch (TypeException $error) { + $this->assertSame('Max must be an integer.', $error->getMessage()); } - $this->assertSame(102, $this->stored($database, 'count')); + $this->assertSame(100, $this->stored($database, 'count')); } #[DataProvider('lanes')] - public function testAFractionalMinimumOnAnIntegerIsComparedAs7xDid(bool $definedAttributes): void + public function testDecreaseWithAFractionalMinimumOnAnIntegerIsRefused(bool $definedAttributes): void { $database = $this->database($definedAttributes); - $this->assertSame(99, $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 0.5)->getAttribute('count')); - try { - $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 99, 0.5); - $this->fail('A decrease past a fractional minimum was accepted'); - } catch (LimitException $error) { - $this->assertSame('Attribute value exceeds minimum limit: 0.5', $error->getMessage()); + $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 0.5); + $this->fail('A fractional minimum on an integer attribute was accepted'); + } catch (TypeException $error) { + $this->assertSame('Min must be an integer.', $error->getMessage()); } - $this->assertSame(99, $this->stored($database, 'count')); + $this->assertSame(100, $this->stored($database, 'count')); + } + + #[DataProvider('lanes')] + public function testANonNumericBoundOnAnIntegerIsRefused(bool $definedAttributes): void + { + $database = $this->database($definedAttributes); + + $this->expectException(TypeException::class); + $this->expectExceptionMessage('Max must be an integer.'); + + $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, '102.5'); } #[DataProvider('lanes')] @@ -111,14 +118,24 @@ public function testWholeNumberStringBoundsOnAnIntegerAreAcceptedAsOperatorLimit } #[DataProvider('lanes')] - public function testAFractionalChangeOnAnIntegerReturnsTheSumAs7xDid(bool $definedAttributes): void + public function testAFractionalChangeValueOnAnIntegerIsRefused(bool $definedAttributes): void { - $this->assertSame(101.5, $this->database($definedAttributes)->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1.5)->getAttribute('count')); - $this->assertSame(99.5, $this->database($definedAttributes)->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 0.5)->getAttribute('count')); - $this->assertSame(101.5, $this->database($definedAttributes)->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', '1.5')->getAttribute('count')); - $this->assertSame(102.0, $this->database($definedAttributes)->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 2.0)->getAttribute('count')); - $database = $this->database($definedAttributes); + + foreach ([ + 'increase' => static fn (): Document => $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1.5), + 'decrease' => static fn (): Document => $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 0.5), + 'increase by a numeric string' => static fn (): Document => $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', '1.5'), + ] as $case => $change) { + try { + $change(); + $this->fail("A fractional change value on an integer attribute was accepted ({$case})"); + } catch (TypeException $error) { + $this->assertSame('Change value must be an integer.', $error->getMessage(), $case); + } + } + + $this->assertSame(100, $this->stored($database, 'count')); $this->assertSame(102, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 2)->getAttribute('count')); $this->assertSame(3.0, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'ratio', 1.5)->getAttribute('ratio')); } diff --git a/tests/unit/Documents/FractionalOperatorLimitTest.php b/tests/unit/Documents/FractionalOperatorLimitTest.php index d13cf367b..621e83a4c 100644 --- a/tests/unit/Documents/FractionalOperatorLimitTest.php +++ b/tests/unit/Documents/FractionalOperatorLimitTest.php @@ -19,36 +19,34 @@ final class FractionalOperatorLimitTest extends TestCase { private const string COLLECTION = 'counters'; + private const string REFUSAL = "Invalid document structure: Cannot apply increment operator: max/min limit must be a whole number for integer attribute 'count', got 102.4"; - public function testUpdateDocumentAppliesAFractionalLimitOnAnIntegerAs7xDid(): void + public function testUpdateDocumentRefusesAFractionalLimitBeforeTheWrite(): void { $database = $this->database(); - $updated = $database->updateDocument(self::COLLECTION, 'counter', new Document(['count' => Operator::increment(5, 102.4)])); - - $this->assertSame($database->getDocument(self::COLLECTION, 'counter')->getAttribute('count'), $updated->getAttribute('count')); - } - - public function testUpdateDocumentsAppliesAFractionalLimitOnAnIntegerAs7xDid(): void - { - $database = $this->database(); + try { + $database->updateDocument(self::COLLECTION, 'counter', new Document(['count' => Operator::increment(5, 102.4)])); + $this->fail('A fractional limit on an integer attribute must be refused'); + } catch (StructureException $exception) { + $this->assertSame(self::REFUSAL, $exception->getMessage()); + } - $this->assertSame(1, $database->updateDocuments(self::COLLECTION, new Document(['count' => Operator::increment(1, 102.4)]))); - $this->assertSame(101, $database->getDocument(self::COLLECTION, 'counter')->getAttribute('count')); + $this->assertSame(100, $database->getDocument(self::COLLECTION, 'counter')->getAttribute('count')); } - public function testUpdateDocumentRefusesAFractionalLimitOnABigIntegerBeforeTheWrite(): void + public function testUpdateDocumentsRefusesAFractionalLimitBeforeTheWrite(): void { $database = $this->database(); try { - $database->updateDocument(self::COLLECTION, 'counter', new Document(['big' => Operator::increment(5, 4.0e15 + 0.5)])); - $this->fail('A fractional limit on a bigint attribute must be refused'); + $database->updateDocuments(self::COLLECTION, new Document(['count' => Operator::increment(1, 102.4)])); + $this->fail('A fractional limit on an integer attribute must be refused'); } catch (StructureException $exception) { - $this->assertStringStartsWith("Invalid document structure: Cannot apply increment operator: max/min limit must be a whole number for integer attribute 'big'", $exception->getMessage()); + $this->assertSame(self::REFUSAL, $exception->getMessage()); } - $this->assertSame(0, $database->getDocument(self::COLLECTION, 'counter')->getAttribute('big')); + $this->assertSame(100, $database->getDocument(self::COLLECTION, 'counter')->getAttribute('count')); } public function testAWholeFloatLimitHoldsABigIntegerAtTheSignedEdge(): void diff --git a/tests/unit/Documents/IncreaseDecreaseTest.php b/tests/unit/Documents/IncreaseDecreaseTest.php index acc45c032..80b3eb746 100644 --- a/tests/unit/Documents/IncreaseDecreaseTest.php +++ b/tests/unit/Documents/IncreaseDecreaseTest.php @@ -432,7 +432,7 @@ public function testIncreaseDocumentAttributeExceedsMax(): void public function testIncreaseDocumentAttributeWithZeroValue(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(TypeException::class); $this->expectExceptionMessage('Value must be numeric and greater than 0'); $doc = new Document([ @@ -449,7 +449,7 @@ public function testIncreaseDocumentAttributeWithZeroValue(): void public function testIncreaseDocumentAttributeWithNegativeValue(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(TypeException::class); $this->expectExceptionMessage('Value must be numeric and greater than 0'); $doc = new Document([ @@ -526,7 +526,7 @@ public function testDecreaseDocumentAttributeExceedsMin(): void public function testDecreaseDocumentAttributeWithZeroValue(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(TypeException::class); $this->expectExceptionMessage('Value must be numeric and greater than 0'); $doc = new Document([ diff --git a/tests/unit/Documents/IncreaseValueTest.php b/tests/unit/Documents/IncreaseValueTest.php index 1fe750dcd..2d990ef4a 100644 --- a/tests/unit/Documents/IncreaseValueTest.php +++ b/tests/unit/Documents/IncreaseValueTest.php @@ -2,11 +2,12 @@ namespace Tests\Unit\Documents; -use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Tests\Unit\Event\HookFixture; use Utopia\Database\Database; +use Utopia\Database\Exception as DatabaseException; +use Utopia\Database\Exception\Type as TypeException; final class IncreaseValueTest extends TestCase { @@ -43,11 +44,11 @@ public function testDecreaseRefusesAChangeThatIsNotAPositiveNumber(int|float|str $this->assertSame(1, $database->getDocument(HookFixture::COLLECTION, 'first')->getAttribute('views')); } - public function testTheRefusalIsAnInvalidArgumentAs7xThrew(): void + public function testTheRefusalIsADatabaseException(): void { $database = $this->database(); - $this->expectException(InvalidArgumentException::class); + $this->expectException(DatabaseException::class); $database->increaseDocumentAttribute(HookFixture::COLLECTION, 'first', 'views', 0); } @@ -65,7 +66,7 @@ private function assertRefused(callable $operation): void try { $operation(); $this->fail('A change that is not a positive number was accepted'); - } catch (InvalidArgumentException $error) { + } catch (TypeException $error) { $this->assertSame('Value must be numeric and greater than 0', $error->getMessage()); } } diff --git a/tests/unit/Documents/NumericUpdateGuardsTest.php b/tests/unit/Documents/NumericUpdateGuardsTest.php index 5027971c4..afa3e5cc3 100644 --- a/tests/unit/Documents/NumericUpdateGuardsTest.php +++ b/tests/unit/Documents/NumericUpdateGuardsTest.php @@ -120,8 +120,8 @@ public function testAnIntegerStringChangeIsCheckedAsAnInteger(): void $this->assertSame(12, $database->decreaseDocumentAttribute(self::COLLECTION, 'ledger', 'count', '3')->getAttribute('count')); foreach (['0', '-4'] as $change) { - $this->assertRefused(\InvalidArgumentException::class, 'Value must be numeric and greater than 0', fn (): Document => $database->increaseDocumentAttribute(self::COLLECTION, 'ledger', 'count', $change)); - $this->assertRefused(\InvalidArgumentException::class, 'Value must be numeric and greater than 0', fn (): Document => $database->decreaseDocumentAttribute(self::COLLECTION, 'ledger', 'count', $change)); + $this->assertRefused(TypeException::class, 'Value must be numeric and greater than 0', fn (): Document => $database->increaseDocumentAttribute(self::COLLECTION, 'ledger', 'count', $change)); + $this->assertRefused(TypeException::class, 'Value must be numeric and greater than 0', fn (): Document => $database->decreaseDocumentAttribute(self::COLLECTION, 'ledger', 'count', $change)); } $this->assertSame(12, $database->getDocument(self::COLLECTION, 'ledger')->getAttribute('count')); } diff --git a/tests/unit/Documents/StoredValueRevalidationTest.php b/tests/unit/Documents/StoredValueRevalidationTest.php index 644b2ad51..e5193eccb 100644 --- a/tests/unit/Documents/StoredValueRevalidationTest.php +++ b/tests/unit/Documents/StoredValueRevalidationTest.php @@ -37,30 +37,44 @@ public static function setUpBeforeClass(): void }, ColumnType::String); } - public function testAnUpdateFailsOnAStoredValueANarrowedRangeNoLongerAdmits(): void + public function testAnUpdateKeepsAStoredValueANarrowedRangeNoLongerAdmits(): void { $database = $this->database(); $database->updateAttribute(self::COLLECTION, 'level', new AttributeUpdate(format: new Format('storedRange', ['min' => 0, 'max' => 10]))); - try { - $database->updateDocument(self::COLLECTION, 'row', new Document(['note' => 'after narrow'])); - $this->fail('An update kept a stored value the narrowed range refuses'); - } catch (StructureException $error) { - $this->assertSame('Invalid document structure: Attribute "level" has invalid format. Value must be a valid range between 0 and 10', $error->getMessage()); - } + $updated = $database->updateDocument(self::COLLECTION, 'row', new Document(['note' => 'after narrow'])); - $this->assertSame('before', $database->getDocument(self::COLLECTION, 'row')->getAttribute('note')); + $this->assertSame('after narrow', $updated->getAttribute('note')); + $stored = $database->getDocument(self::COLLECTION, 'row'); + $this->assertSame('after narrow', $stored->getAttribute('note')); + $this->assertSame(50, $stored->getAttribute('level')); } - public function testAnUpdateFailsOnAStoredValueAShrunkEnumNoLongerAdmits(): void + public function testAnUpdateKeepsAStoredValueAShrunkEnumNoLongerAdmits(): void { $database = $this->database(); $database->updateAttribute(self::COLLECTION, 'kind', new AttributeUpdate(format: new Format('storedEnum', ['elements' => ['a']]))); - $this->expectException(StructureException::class); - $this->expectExceptionMessage('Attribute "kind" has invalid format'); - $database->updateDocument(self::COLLECTION, 'row', new Document(['note' => 'after shrink'])); + + $stored = $database->getDocument(self::COLLECTION, 'row'); + $this->assertSame('after shrink', $stored->getAttribute('note')); + $this->assertSame('b', $stored->getAttribute('kind')); + } + + public function testAnUpdateThatWritesAValueTheNarrowedRangeRefusesFails(): void + { + $database = $this->database(); + $database->updateAttribute(self::COLLECTION, 'level', new AttributeUpdate(format: new Format('storedRange', ['min' => 0, 'max' => 10]))); + + try { + $database->updateDocument(self::COLLECTION, 'row', new Document(['level' => 60, 'note' => 'written'])); + $this->fail('An update wrote a value the narrowed range refuses'); + } catch (StructureException $error) { + $this->assertSame('Invalid document structure: Attribute "level" has invalid format. Value must be a valid range between 0 and 10', $error->getMessage()); + } + + $this->assertSame('before', $database->getDocument(self::COLLECTION, 'row')->getAttribute('note')); } private function database(): Database diff --git a/tests/unit/Event/DocumentPurgeTest.php b/tests/unit/Event/DocumentPurgeTest.php index d8f135e42..3e633cb51 100644 --- a/tests/unit/Event/DocumentPurgeTest.php +++ b/tests/unit/Event/DocumentPurgeTest.php @@ -233,7 +233,7 @@ public function testPurgeCachedDocumentPurgesTheDocument(Closure $database): voi $this->assertSame(['posts/first'], $this->purged($recorder)); } - public function testDocumentPurgeFiresForEveryAttemptThatReachesItsCommit(): void + public function testDocumentPurgeFiresOnceWhenTheTransactionIsRetried(): void { $adapter = new class () extends Memory { public int $commitFailures = 0; @@ -258,16 +258,13 @@ public function commitTransaction(): bool $database->deleteDocuments(HookFixture::COLLECTION); $this->assertSame([ - Event::DocumentPurge, Event::DocumentPurge, Event::DocumentUpdate, Event::DocumentPurge, Event::DocumentPurge, - Event::DocumentPurge, - Event::DocumentPurge, Event::DocumentsDelete, ], $recorder->getEvents()); - $this->assertSame(['posts/first', 'posts/first', 'posts/first', 'posts/second', 'posts/first', 'posts/second'], $this->purged($recorder)); + $this->assertSame(['posts/first', 'posts/first', 'posts/second'], $this->purged($recorder)); } /** @@ -289,26 +286,6 @@ public function testDocumentPurgeHookFailureReachesTheCaller(Closure $call): voi } } - /** - * @param Closure(Database): mixed $write - */ - #[DataProvider('writes')] - public function testAPurgeListenerFailureRollsTheWriteBack(Closure $write): void - { - $database = HookFixture::sqlite(); - HookFixture::seed($database, ['first', 'second']); - $stored = static fn (): array => \array_map( - static fn (Document $document): array => [$document->getId(), $document->getAttribute('title'), $document->getAttribute('views')], - $database->find(HookFixture::COLLECTION), - ); - $before = $stored(); - $failure = new RuntimeException('region broadcast failed'); - $database->addHook(new FailingLifecycle(Event::DocumentPurge, $failure)); - - $this->assertSame($failure, $this->failureOf(static fn () => $write($database))); - $this->assertSame($before, $stored()); - } - public function testDocumentPurgeThroughMirrorReachesTheCaller(): void { $source = HookFixture::sqlite(); @@ -383,17 +360,55 @@ public static function writes(): iterable * @param Closure(Database): mixed $write */ #[DataProvider('writes')] - public function testPurgeEventsInsideACallerTransactionFireBeforeItsCommit(Closure $write): void + public function testPurgeEventsInsideACallerTransactionFireAfterTheCommit(Closure $write): void { [$database, $recorder] = $this->seeded(HookFixture::sqlite(), ['first']); $inTransaction = $this->observePurges($database, static fn (): bool => $database->getAdapter()->inTransaction()); $database->withTransaction(static fn (): mixed => $write($database)); - $this->assertSame([true], $inTransaction->getArrayCopy()); + $this->assertSame([false], $inTransaction->getArrayCopy()); $this->assertSame(['posts/first'], $this->purged($recorder)); } + /** + * @param Closure(Database): mixed $write + */ + #[DataProvider('writes')] + public function testACachePurgeFailureRollsTheWriteBackAndFiresNoPurgeEvent(Closure $write): void + { + $cache = new class () extends MemoryCache { + public bool $unreachable = false; + + #[\Override] + public function purge(string $key, string $hash = ''): bool + { + if ($this->unreachable) { + throw new RuntimeException('cache went away'); + } + + return parent::purge($key, $hash); + } + }; + $database = HookFixture::sqlite(); + $database->setCache(new Cache($cache)); + [$database, $recorder] = $this->seeded($database, ['first']); + $stored = static fn (): array => \array_map( + static fn (Document $document): array => [$document->getId(), $document->getAttribute('title'), $document->getAttribute('views')], + $database->find(HookFixture::COLLECTION), + ); + $before = $stored(); + + $cache->unreachable = true; + $thrown = $this->failureOf(static fn (): mixed => $write($database)); + $cache->unreachable = false; + + $this->assertInstanceOf(RuntimeException::class, $thrown); + $this->assertSame('cache went away', $thrown->getMessage()); + $this->assertSame($before, $stored()); + $this->assertSame([], $this->purged($recorder)); + } + /** * @param Closure(Database): mixed $write */ @@ -489,7 +504,7 @@ static function () use ($database, $abandoned): never { * @param Closure(Database): mixed $write */ #[DataProvider('writes')] - public function testPurgeEventsOfARetriedCallerTransactionFireForEveryAttemptThatReachesItsCommit(Closure $write): void + public function testPurgeEventsOfARetriedCallerTransactionFireOnce(Closure $write): void { $adapter = new class (new PDO('sqlite::memory:')) extends SQLite { public int $commitFailures = 0; @@ -512,7 +527,7 @@ public function commitTransaction(): bool $database->withTransaction(static fn (): mixed => $write($database)); $this->assertSame(0, $adapter->commitFailures); - $this->assertSame(['posts/first', 'posts/first'], $this->purged($recorder)); + $this->assertSame(['posts/first'], $this->purged($recorder)); } public function testPurgeEventsOfWritesWithoutAnAdapterTransactionFireAtOnce(): void @@ -727,7 +742,7 @@ public function testPurgeEventsOfAnUnconfirmedCommitFire(Closure $write, bool $s } #[DataProvider('savepoints')] - public function testEveryAttemptOfARetriedTransactionThatReachesItsCommitAnnouncesItsPurgeEvents(bool $savepoints): void + public function testOnlyTheUnconfirmedAttemptOfARetriedTransactionAnnouncesItsPurgeEvents(bool $savepoints): void { [$database, $recorder] = $this->unconfirmed($savepoints, ['first', 'second'], commitFailures: 1); $attempts = 0; @@ -741,7 +756,7 @@ public function testEveryAttemptOfARetriedTransactionThatReachesItsCommitAnnounc $this->assertSame(2, $attempts); $this->assertInstanceOf(UnconfirmedException::class, $thrown); - $this->assertSame(['posts/first', 'posts/second'], $this->purged($recorder)); + $this->assertSame(['posts/second'], $this->purged($recorder)); } #[DataProvider('savepoints')] @@ -763,11 +778,10 @@ static function () use ($database, $abandoned): never { } #[DataProvider('savepoints')] - public function testAPurgeListenerFailureRollsTheTransactionBackBeforeItsCommit(bool $savepoints): void + public function testAnUnconfirmedCommitStaysTheFailureWhenAPurgeListenerFails(bool $savepoints): void { [$database, $recorder] = $this->unconfirmed($savepoints, ['first', 'second']); - $failure = new RuntimeException('region broadcast failed'); - $database->addHook(new FailingLifecycle(Event::DocumentPurge, $failure)); + $database->addHook(new FailingLifecycle(Event::DocumentPurge, new RuntimeException('region broadcast failed'))); $thrown = $this->failureOf(static function () use ($database): void { $database->withTransaction(static function () use ($database): void { @@ -776,10 +790,8 @@ public function testAPurgeListenerFailureRollsTheTransactionBackBeforeItsCommit( }); }); - $this->assertSame($failure, $thrown); + $this->assertInstanceOf(UnconfirmedException::class, $thrown); $this->assertSame(['posts/first', 'posts/second'], $this->purged($recorder)); - $this->assertSame('first', $database->getDocument(HookFixture::COLLECTION, 'first')->getAttribute('title')); - $this->assertSame('second', $database->getDocument(HookFixture::COLLECTION, 'second')->getAttribute('title')); } /** @@ -805,7 +817,7 @@ static function () use ($database, $foreign): never { } #[DataProvider('savepoints')] - public function testOnlyTheAttemptThatReachedItsCommitAnnouncesWhenARetriedCallbackThrowsAnotherCommitsUnconfirmed(bool $savepoints): void + public function testPurgeEventsAreDroppedWhenARetriedCallbackThrowsAnotherCommitsUnconfirmed(bool $savepoints): void { [$database, $recorder] = $this->unconfirmed($savepoints, ['first', 'second'], commitFailures: 1, confirmed: true); $foreign = new UnconfirmedException('Failed to commit transaction: the commit could not be confirmed'); @@ -824,7 +836,7 @@ public function testOnlyTheAttemptThatReachedItsCommitAnnouncesWhenARetriedCallb $this->assertSame($foreign, $this->failureOf(static fn (): mixed => $database->withTransaction($attempt))); $this->assertSame(2, $attempts); - $this->assertSame(['posts/first'], $this->purged($recorder)); + $this->assertSame([], $this->purged($recorder)); } #[DataProvider('savepoints')] diff --git a/tests/unit/Operator/OperatorLimitTest.php b/tests/unit/Operator/OperatorLimitTest.php index 886f1e78c..655693d19 100644 --- a/tests/unit/Operator/OperatorLimitTest.php +++ b/tests/unit/Operator/OperatorLimitTest.php @@ -14,36 +14,29 @@ final class OperatorLimitTest extends TestCase { /** - * @return array + * @return array */ public static function fractionalLimits(): array { return [ - 'increment max' => [OperatorType::Increment, 102.4], - 'decrement min' => [OperatorType::Decrement, -0.5], - 'multiply max' => [OperatorType::Multiply, 99.9], - 'divide min' => [OperatorType::Divide, 1.5], - 'power max' => [OperatorType::Power, 1000.01], - 'numeric string' => [OperatorType::Increment, '102.4'], + 'increment max' => [OperatorType::Increment, 'count', 102.4], + 'decrement min' => [OperatorType::Decrement, 'count', -0.5], + 'multiply max' => [OperatorType::Multiply, 'count', 99.9], + 'divide min' => [OperatorType::Divide, 'count', 1.5], + 'power max' => [OperatorType::Power, 'count', 1000.01], + 'numeric string' => [OperatorType::Increment, 'count', '102.4'], + 'big integer' => [OperatorType::Increment, 'big', 4.0e15 + 0.5], ]; } #[DataProvider('fractionalLimits')] - public function testAFractionalLimitOnAnIntegerAttributeIsLeftToTheEngineAs7xDid(OperatorType $method, int|float|string $limit): void + public function testAFractionalLimitOnAnIntegerAttributeIsRefused(OperatorType $method, string $attribute, int|float|string $limit): void { $validator = $this->validator(); - $this->assertTrue($validator->isValid(new Operator($method, 'count', [2, $limit])), $validator->getDescription()); - } - - public function testAFractionalLimitOnABigIntegerAttributeIsRefused(): void - { - $validator = $this->validator(); - $limit = 4.0e15 + 0.5; - - $this->assertFalse($validator->isValid(new Operator(OperatorType::Increment, 'big', [2, $limit]))); + $this->assertFalse($validator->isValid(new Operator($method, $attribute, [2, $limit]))); $this->assertSame( - "Cannot apply increment operator: max/min limit must be a whole number for integer attribute 'big', got {$limit}", + "Cannot apply {$method->value} operator: max/min limit must be a whole number for integer attribute '{$attribute}', got {$limit}", $validator->getDescription(), ); } diff --git a/tests/unit/Validator/OperatorValidatorTest.php b/tests/unit/Validator/OperatorValidatorTest.php index c55d775e0..894c38a98 100644 --- a/tests/unit/Validator/OperatorValidatorTest.php +++ b/tests/unit/Validator/OperatorValidatorTest.php @@ -163,11 +163,13 @@ public function testAStoredValueInsideTheAttributeRangeIsAccepted(): void $this->assertTrue($validator->isValid(new Operator(OperatorType::Increment, 'count', [1])), $validator->getDescription()); } - public function testAFractionalResultInRangeIsLeftToTheEngineAs7xDid(): void + public function testAResultThatCannotBePredictedIsRejected(): void { $validator = $this->numericValidator(new Document(['count' => 2])); - $this->assertTrue($validator->isValid(new Operator(OperatorType::Power, 'count', [-1])), $validator->getDescription()); + $this->assertFalse($validator->isValid(new Operator(OperatorType::Power, 'count', [-1]))); + $this->assertSame('Cannot apply power operator: result is outside the attribute range', $validator->getDescription()); + $this->assertTrue($validator->isValid(new Operator(OperatorType::Power, 'count', [3])), $validator->getDescription()); } @@ -251,8 +253,7 @@ public function testAWholeFloatOperandOnAnIntegerIsAcceptedAsTheInteger(): void $this->assertFalse($validator->isValid(new Operator(OperatorType::Increment, 'count', [6.0]))); $this->assertStringContainsString('would overflow maximum value', $validator->getDescription()); $this->assertFalse($validator->isValid(new Operator(OperatorType::Increment, 'count', [5.5]))); - $this->assertSame('Cannot apply increment operator: would overflow maximum value of 2147483647', $validator->getDescription()); - $this->assertTrue($validator->isValid(new Operator(OperatorType::Increment, 'count', [4.5])), $validator->getDescription()); + $this->assertStringStartsWith('Cannot apply increment operator: value must be numeric', $validator->getDescription()); $this->assertFalse($validator->isValid(new Operator(OperatorType::Divide, 'count', [0.0]))); $this->assertSame('Cannot apply divide operator: division by zero', $validator->getDescription()); } diff --git a/tests/unit/Validator/QueriesCompatibilityTest.php b/tests/unit/Validator/QueriesCompatibilityTest.php index c6c47a2c7..2d673e363 100644 --- a/tests/unit/Validator/QueriesCompatibilityTest.php +++ b/tests/unit/Validator/QueriesCompatibilityTest.php @@ -94,11 +94,17 @@ public function test_tenant_passes_select_validation_without_shared_tables(): vo $this->assertTrue($this->documents(sharedTables: true)->isValid(['{"method":"select","values":["$tenant"]}'])); } - public function test_uid_description(): void + public function test_uid_description_names_every_character_it_accepts(): void { + $uid = new UID(); + + $this->assertTrue($uid->isValid('a.b-c_d')); + $this->assertFalse($uid->isValid('.abc')); + $this->assertFalse($uid->isValid('-abc')); + $this->assertFalse($uid->isValid('_abc')); $this->assertSame( - 'UID must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, and underscore. Can\'t start with a leading underscore', - (new UID())->getDescription(), + 'UID must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a leading period, hyphen, or underscore', + $uid->getDescription(), ); } } From 8048ebf67ff2d7a760da8e33aaa70559f5ef9f5a Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 12 Oct 2026 17:45:49 +1300 Subject: [PATCH 04/10] fix(documents): refuse fractional counter changes on integers with Exception\Type again The user decided on 10-01 that the library refuses a fractional change, max or min on an integer attribute (Appwrite rounds the bounds itself), and BUG-11, approved on 10-07, made a change of 0 or less throw Exception\Type so that catch (DatabaseException) sees it. 8.0.1 handed fractional values to the engine again (rounded on MariaDB/MySQL, truncated on MongoDB, an error on PostgreSQL) and threw \InvalidArgumentException. This takes back the 8.0.0 checks in Documents and the Memory/Redis bound comparison. Co-Authored-By: Claude Opus 5.5 --- src/Database/Adapter/Memory.php | 6 +- src/Database/Adapter/Redis.php | 6 +- src/Database/Trait/Documents.php | 104 ++++++++++--------------------- 3 files changed, 38 insertions(+), 78 deletions(-) diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index e5fc1dab6..071f1fd2c 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -1884,10 +1884,8 @@ public function increaseDocumentAttribute(Document $collection, string $id, stri $previousValue = $this->data[$key]['documents'][$docKey][$column] ?? null; $previousUpdatedAt = $this->data[$key]['documents'][$docKey][Storage::UPDATED_AT] ?? null; $current = $previousValue ?? 0; - $exact = BigInt::isIntegerValue($current) - && BigInt::isIntegerValue($value) - && ($min === null || BigInt::isIntegerValue($min)) - && ($max === null || BigInt::isIntegerValue($max)); + $exact = (\is_int($current) || (\is_string($current) && BigInt::isIntegerString($current))) + && (\is_int($value) || (\is_string($value) && BigInt::isIntegerString($value))); // MariaDB encodes the bound check as part of the WHERE clause against // the current column value (`attr <= :max` / `attr >= :min`); when the diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index 2ebd43fd0..3935445bd 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -1589,10 +1589,8 @@ public function increaseDocumentAttribute(Document $collection, string $id, stri $document = $this->decode($payload); $current = $document->getAttribute($attribute); - $exact = BigInt::isIntegerValue($current) - && BigInt::isIntegerValue($value) - && ($min === null || BigInt::isIntegerValue($min)) - && ($max === null || BigInt::isIntegerValue($max)); + $exact = (\is_int($current) || (\is_string($current) && BigInt::isIntegerString($current))) + && (\is_int($value) || (\is_string($value) && BigInt::isIntegerString($value))); if ($exact) { $current = BigInt::toNative($current); $value = BigInt::toNative($value); diff --git a/src/Database/Trait/Documents.php b/src/Database/Trait/Documents.php index f37c2ae19..85f07e328 100644 --- a/src/Database/Trait/Documents.php +++ b/src/Database/Trait/Documents.php @@ -269,35 +269,11 @@ private function isDeclaredInteger(?Attribute $attribute): bool return $attribute !== null && ! $attribute->array && $attribute->isInteger(); } - /** - * A change or bound that is not a whole number on an integer attribute: 7.x applied it as given, so the - * engine stored the sum its own way (MariaDB and MySQL round it) and the returned document held the sum. - */ - private function isFractionalChange(int|float|string $value, int|float|string|null $bound): bool + private function assertIntegerChange(int|float|string $value): void { - return \is_float($value) - || (\is_string($value) && ! BigInt::isIntegerString($value)) - || ($bound !== null && BigInt::integralValue($bound) === null); - } - - /** - * A whole float as the integer 7.x bound it as, which PostgreSQL takes for an integer column. - */ - private function wholeOrNative(int|float $value): int|float - { - return \is_float($value) && \is_finite($value) && \floor($value) === $value && \abs($value) < \PHP_INT_MAX - ? (int) $value - : $value; - } - - private function getFractionalResult(mixed $current, int|float|string $value, bool $increase): int|float - { - if ($current !== null && ! \is_numeric($current)) { - throw new TypeException('Attribute value must be numeric.'); + if (! \is_int($value) && (! \is_string($value) || ! BigInt::isIntegerString($value))) { + throw new TypeException('Change value must be an integer.'); } - $current = $current === null ? 0 : $this->getNativeNumber($current); - - return $increase ? $current + $this->getNativeNumber($value) : $current - $this->getNativeNumber($value); } private function integerBound(int|float|string $bound, string $name): int|string @@ -2544,18 +2520,18 @@ public function increaseDocumentAttribute( $collection = $this->silent(fn () => $this->getCollection($collection)); $numericAttribute = $this->numericAttribute($collection, $attribute); - $declaredInteger = $this->isDeclaredInteger($numericAttribute ?? $this->declaredAttribute($collection, $attribute)); - $fractional = $declaredInteger && $this->isFractionalChange($value, $max); - if ($declaredInteger && ! $fractional && $max !== null) { - $max = $this->integerBound($max, 'Max'); + if ($this->isDeclaredInteger($numericAttribute ?? $this->declaredAttribute($collection, $attribute))) { + $this->assertIntegerChange($value); + if ($max !== null) { + $max = $this->integerBound($max, 'Max'); + } } - $integerArithmetic = $numericAttribute?->isInteger() === true && ! $fractional; $cacheTarget = $collection->getId() === self::METADATA ? new Document([Document::ID => $id, Document::COLLECTION => self::METADATA]) : $collection->getId(); - $document = $this->withMutation(Event::DocumentIncrease, $cacheTarget, function () use ($collection, $id, $attribute, $value, $max, $numericAttribute, $fractional, $integerArithmetic) { + $document = $this->withMutation(Event::DocumentIncrease, $cacheTarget, function () use ($collection, $id, $attribute, $value, $max, $numericAttribute) { /** @var Document $document */ $document = $this->authorization->skip(fn () => $this->silent(fn () => $this->readDocument($collection, $id, forUpdate: true))); // Skip ensures user does not need read permission for this @@ -2576,9 +2552,7 @@ public function increaseDocumentAttribute( $attributeExists = $document->offsetExists($attribute); $currentVal = $document->getAttribute($attribute); - if ($fractional) { - $result = $this->getFractionalResult($currentVal, $value, true); - } elseif ($numericAttribute instanceof Attribute) { + if ($numericAttribute instanceof Attribute) { $result = $this->getNumericResult($numericAttribute, $currentVal, $value, true); } else { if (! $attributeExists) { @@ -2590,7 +2564,7 @@ public function increaseDocumentAttribute( $result = $currentVal + $this->getNativeNumber($value); } $exceedsMaximum = ! \is_null($max) && ( - $integerArithmetic + $numericAttribute?->isInteger() === true ? BigInt::compare($result, $max) > 0 : $result > $max ); @@ -2602,22 +2576,18 @@ public function increaseDocumentAttribute( $updatedAt = $document->getUpdatedAt(); $updatedAt = (empty($updatedAt) || ! $this->datePreservation()->get()) ? $time : DateTime::setTimezone($updatedAt); if ($max !== null) { - $max = match (true) { - $integerArithmetic => BigInt::subtract($max, $value), - $fractional => $this->wholeOrNative($this->getNativeNumber($max) - $this->getNativeNumber($value)), - default => $this->getNativeNumber($max) - $this->getNativeNumber($value), - }; + $max = $numericAttribute?->isInteger() === true + ? BigInt::subtract($max, $value) + : $this->getNativeNumber($max) - $this->getNativeNumber($value); } $this->adapter->increaseDocumentAttribute( $collection, $id, $attribute, - match (true) { - $integerArithmetic => BigInt::toNative($value), - $fractional => $this->wholeOrNative($this->getNativeNumber($value)), - default => $this->getNativeNumber($value), - }, + $numericAttribute?->isInteger() === true + ? BigInt::toNative($value) + : $this->getNativeNumber($value), $updatedAt, max: $max ); @@ -2637,14 +2607,14 @@ public function increaseDocumentAttribute( } /** - * @throws \InvalidArgumentException + * @throws TypeException */ private function assertPositiveChange(int|float|string $value): void { if (! \is_numeric($value) || (\is_string($value) && BigInt::isIntegerString($value) ? BigInt::compare($value, 0) <= 0 : (float) $value <= 0)) { - throw new \InvalidArgumentException('Value must be numeric and greater than 0'); + throw new TypeException('Value must be numeric and greater than 0'); } } @@ -2660,7 +2630,7 @@ private function assertPositiveChange(int|float|string $value): void * * @throws AuthorizationException * @throws DatabaseException - * @throws \InvalidArgumentException When $value is not a number greater than 0 + * @throws TypeException When $value is not a number greater than 0 */ public function decreaseDocumentAttribute( string $collection, @@ -2674,18 +2644,18 @@ public function decreaseDocumentAttribute( $collection = $this->silent(fn () => $this->getCollection($collection)); $numericAttribute = $this->numericAttribute($collection, $attribute); - $declaredInteger = $this->isDeclaredInteger($numericAttribute ?? $this->declaredAttribute($collection, $attribute)); - $fractional = $declaredInteger && $this->isFractionalChange($value, $min); - if ($declaredInteger && ! $fractional && $min !== null) { - $min = $this->integerBound($min, 'Min'); + if ($this->isDeclaredInteger($numericAttribute ?? $this->declaredAttribute($collection, $attribute))) { + $this->assertIntegerChange($value); + if ($min !== null) { + $min = $this->integerBound($min, 'Min'); + } } - $integerArithmetic = $numericAttribute?->isInteger() === true && ! $fractional; $cacheTarget = $collection->getId() === self::METADATA ? new Document([Document::ID => $id, Document::COLLECTION => self::METADATA]) : $collection->getId(); - $document = $this->withMutation(Event::DocumentDecrease, $cacheTarget, function () use ($collection, $id, $attribute, $value, $min, $numericAttribute, $fractional, $integerArithmetic) { + $document = $this->withMutation(Event::DocumentDecrease, $cacheTarget, function () use ($collection, $id, $attribute, $value, $min, $numericAttribute) { /** @var Document $document */ $document = $this->authorization->skip(fn () => $this->silent(fn () => $this->readDocument($collection, $id, forUpdate: true))); // Skip ensures user does not need read permission for this @@ -2706,9 +2676,7 @@ public function decreaseDocumentAttribute( $attributeExists = $document->offsetExists($attribute); $currentDecVal = $document->getAttribute($attribute); - if ($fractional) { - $result = $this->getFractionalResult($currentDecVal, $value, false); - } elseif ($numericAttribute instanceof Attribute) { + if ($numericAttribute instanceof Attribute) { $result = $this->getNumericResult($numericAttribute, $currentDecVal, $value, false); } else { if (! $attributeExists) { @@ -2720,7 +2688,7 @@ public function decreaseDocumentAttribute( $result = $currentDecVal - $this->getNativeNumber($value); } $belowMinimum = ! \is_null($min) && ( - $integerArithmetic + $numericAttribute?->isInteger() === true ? BigInt::compare($result, $min) < 0 : $result < $min ); @@ -2732,22 +2700,18 @@ public function decreaseDocumentAttribute( $updatedAt = $document->getUpdatedAt(); $updatedAt = (empty($updatedAt) || ! $this->datePreservation()->get()) ? $time : DateTime::setTimezone($updatedAt); if ($min !== null) { - $min = match (true) { - $integerArithmetic => BigInt::add($min, $value), - $fractional => $this->wholeOrNative($this->getNativeNumber($min) + $this->getNativeNumber($value)), - default => $this->getNativeNumber($min) + $this->getNativeNumber($value), - }; + $min = $numericAttribute?->isInteger() === true + ? BigInt::add($min, $value) + : $this->getNativeNumber($min) + $this->getNativeNumber($value); } $this->adapter->increaseDocumentAttribute( $collection, $id, $attribute, - match (true) { - $integerArithmetic => BigInt::negate($value), - $fractional => $this->wholeOrNative($this->getNativeNumber($value) * -1), - default => $this->getNativeNumber($value) * -1, - }, + $numericAttribute?->isInteger() === true + ? BigInt::negate($value) + : $this->getNativeNumber($value) * -1, $updatedAt, min: $min ); From 2b6de637d766650da65d9bc2b43a9c14ea012304 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 12 Oct 2026 17:45:49 +1300 Subject: [PATCH 05/10] fix(operator): refuse fractional limits of numeric operators on integers again The user decided on 10-01 that operators refuse fractional bounds on integer attributes like increase does, with whole floats compared exactly, and that numeric text such as power('2') stays accepted. 8.0.1 replaced those checks with 7.x's numeric-only ones. Validator\Operator is the 8.0.0 validator again; numeric text keeps passing. Co-Authored-By: Claude Opus 5.5 --- src/Database/Validator/Operator.php | 97 +---------------------------- 1 file changed, 1 insertion(+), 96 deletions(-) diff --git a/src/Database/Validator/Operator.php b/src/Database/Validator/Operator.php index ec565483c..082895af4 100644 --- a/src/Database/Validator/Operator.php +++ b/src/Database/Validator/Operator.php @@ -129,93 +129,6 @@ private function isValidLimit(mixed $limit, Attribute $attribute, DatabaseOperat return true; } - /** - * A numeric operator on an integer or double attribute is checked as 7.x checked it: the change and the limit - * only have to be numeric, so a fractional change or limit on an integer reaches the engine, which stores the - * result its own way. Without a limit, the result predicted from the current document has to fit the attribute. - * - * @param array $values - */ - private function isValidNumericChange(DatabaseOperator $operator, Attribute $attribute, array $values): bool - { - $method = $operator->getMethod(); - $methodName = $method->value; - - if (! isset($values[0]) || ! \is_numeric($values[0]) || ! \is_finite((float) $values[0])) { - $this->message = "Cannot apply {$methodName} operator: value must be numeric, got ".\gettype($operator->getValue()); - - return false; - } - - if (($method === OperatorType::Divide || $method === OperatorType::Modulo) && (float) $values[0] === 0.0) { - $this->message = "Cannot apply {$methodName} operator: ".($method === OperatorType::Divide ? 'division' : 'modulo').' by zero'; - - return false; - } - - if (\count($values) > 1 && $values[1] !== null && (! \is_numeric($values[1]) || ! \is_finite((float) $values[1]))) { - $this->message = "Cannot apply {$methodName} operator: max/min limit must be numeric, got ".\gettype($values[1]); - - return false; - } - - if ($this->currentDocument === null || ! $attribute->isInteger() || isset($values[1])) { - return true; - } - - $stored = $this->getIntegerValue($this->currentDocument->getAttribute($operator->getAttribute()) ?? 0); - $bounds = $attribute->bounds(); - if ($stored === null || $bounds === null || ! $this->isNumericValueInBounds($stored, $attribute)) { - $this->message = "Cannot apply {$methodName} operator: current value is outside the attribute range"; - - return false; - } - - $current = $this->getNumericValue($stored); - $change = $this->getNumericValue($values[0]); - if ($current === null || $change === null) { - return true; - } - $predicted = match ($method) { - OperatorType::Increment => $current + $change, - OperatorType::Decrement => $current - $change, - OperatorType::Multiply => $current * $change, - OperatorType::Divide => $current / $change, - OperatorType::Modulo => (int) $change === 0 ? $current : (int) $current % (int) $change, - default => $current ** $change, - }; - - if ($predicted > (float) $bounds->max) { - $this->message = "Cannot apply {$methodName} operator: would overflow maximum value of {$bounds->max}"; - - return false; - } - - if ($predicted < (float) $bounds->min) { - $this->message = "Cannot apply {$methodName} operator: would underflow minimum value of {$bounds->min}"; - - return false; - } - - return true; - } - - /** - * 7.x checked a numeric item appended to an integer array against the range only, so a fractional item passes - * here; a bigint array, new in 8.0, checks the item is a whole number in range. - */ - private function isArrayItemInBounds(int|float|string $item, Attribute $attribute): bool - { - if ($attribute->type === ColumnType::BigInteger) { - return $this->isNumericValueInBounds($item, $attribute); - } - - $bounds = $attribute->bounds(); - $numeric = $this->getNumericValue($item); - - return $bounds !== null && $numeric !== null && $numeric <= (float) $bounds->max && $numeric >= (float) $bounds->min; - } - private function getIntegerValue(mixed $value): int|string|null { if (\is_int($value)) { @@ -361,14 +274,6 @@ private function validateOperatorForAttribute( return false; } - if ($type !== ColumnType::BigInteger) { - if (! $this->isValidNumericChange($operator, $attribute, $values)) { - return false; - } - - break; - } - if (! isset($values[0]) || ! $this->isNumericValueInBounds($values[0], $attribute)) { $this->message = "Cannot apply {$methodName} operator: value must be numeric, got ".gettype($operator->getValue()); @@ -440,7 +345,7 @@ private function validateOperatorForAttribute( if (! empty($values) && $attribute->isInteger()) { $newItems = \is_array($values[0]) ? $values[0] : $values; foreach ($newItems as $item) { - if (\is_numeric($item) && ! $this->isArrayItemInBounds($item, $attribute)) { + if (\is_numeric($item) && ! $this->isNumericValueInBounds($item, $attribute)) { $bounds = $attribute->bounds(); if ($bounds === null) { return false; From 081e4eb2608e3d2fe98fd6e5ab84d6d64ce6e859 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 12 Oct 2026 17:45:50 +1300 Subject: [PATCH 06/10] fix(validator): describe the characters a UID accepts again 8.0.0 corrected the UID description to the rule Key enforces (period, hyphen and underscore, none of them leading). 8.0.1 returned 7.x's text, which leaves out periods and hyphens and so tells callers a wrong rule while acceptance stayed the same. Co-Authored-By: Claude Opus 5.5 --- src/Database/Validator/UID.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Validator/UID.php b/src/Database/Validator/UID.php index e55d55137..7ee9d6e8d 100644 --- a/src/Database/Validator/UID.php +++ b/src/Database/Validator/UID.php @@ -14,6 +14,6 @@ public function __construct(int $maxLength = Database::MAX_UID_DEFAULT_LENGTH) #[\Override] public function getDescription(): string { - return 'UID must contain at most '.$this->maxLength.' chars. Valid chars are a-z, A-Z, 0-9, and underscore. Can\'t start with a leading underscore'; + return 'UID must contain at most '.$this->maxLength.' chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a leading period, hyphen, or underscore'; } } From fbd5df64db3b048dffd93e3997253fa137c03071 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 12 Oct 2026 17:45:50 +1300 Subject: [PATCH 07/10] fix(documents): leave stored values an update does not change unvalidated again 8.0.0 (J4C-011) stopped re-validating the stored values an update leaves unchanged, because a value stored under older or looser rules then failed every later update of any attribute and could not be repaired. 8.0.1 validated them again and kept the leniency for object values only, so a narrowed range or enum blocked unrelated updates once more. The user decided the 8.0 rule stands for every type: a value the update writes is still held to the current definition. Co-Authored-By: Claude Opus 5.5 --- src/Database/Validator/ObjectValue.php | 12 ------------ src/Database/Validator/Structure.php | 11 +++++++---- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/src/Database/Validator/ObjectValue.php b/src/Database/Validator/ObjectValue.php index fbfb6edfa..3c335c07d 100644 --- a/src/Database/Validator/ObjectValue.php +++ b/src/Database/Validator/ObjectValue.php @@ -6,14 +6,6 @@ class ObjectValue extends Validator { - /** - * @param bool $stored Whether the value is one already stored, which 7.x accepted as any JSON string or any - * empty value too - */ - public function __construct(private readonly bool $stored = false) - { - } - #[\Override] public function getDescription(): string { @@ -23,10 +15,6 @@ public function getDescription(): string #[\Override] public function isValid(mixed $value): bool { - if ($this->stored && (empty($value) || (\is_string($value) && \json_validate($value)))) { - return true; - } - if (is_string($value)) { $decoded = json_decode($value); diff --git a/src/Database/Validator/Structure.php b/src/Database/Validator/Structure.php index b65375877..c249ce8c6 100644 --- a/src/Database/Validator/Structure.php +++ b/src/Database/Validator/Structure.php @@ -60,9 +60,8 @@ class Structure extends Validator /** * @param list $storedAttributes Attributes whose values are the stored ones, unchanged by the - * write. They are validated again, as 7.x did, so a value a - * narrowed definition no longer admits fails the update, but an - * object value is held to what 7.x accepted when it was stored. + * write: they are not validated again, as the rules may have + * tightened since those values were stored. */ public function __construct( protected readonly Document $collection, @@ -276,6 +275,10 @@ protected function checkForInvalidAttributeValues(Document $document, array $str continue; } + if (isset($this->storedAttributes[$key])) { + continue; + } + $attribute = $definitions[$key] ?? null; if ($attribute === null) { continue; @@ -361,7 +364,7 @@ protected function checkForInvalidAttributeValues(Document $document, array $str break; case ColumnType::Object: - $validators[] = new ObjectValue(stored: isset($this->storedAttributes[$key])); + $validators[] = new ObjectValue(); break; case ColumnType::Point: From 1571d0597c7abbc3ebcbd45e9d83e97ad6e53e2f Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 12 Oct 2026 17:45:50 +1300 Subject: [PATCH 08/10] fix(events): fire document_purge after the outermost commit again 8.0.0 fired document_purge after the outermost transaction committed, so no listener ever hears of a write that rolled back. 8.0.1 fired it just before the commit. The user decided the 8.0 order stands. The cache entry itself is still purged inside the write's transaction (the 10-08 decision that keeps a cache outage from committing a write with a stale entry), and the DocumentPurgeTest case added with the tests pins both. Co-Authored-By: Claude Opus 5.5 --- src/Database/Trait/Transactions.php | 33 +++++------------------------ 1 file changed, 5 insertions(+), 28 deletions(-) diff --git a/src/Database/Trait/Transactions.php b/src/Database/Trait/Transactions.php index 7c218eb66..da8eefdea 100644 --- a/src/Database/Trait/Transactions.php +++ b/src/Database/Trait/Transactions.php @@ -166,17 +166,13 @@ private function withAdapterTransaction(callable $callback): mixed $discard = function () use ($context, $queued): void { \array_splice($this->documentPurgeEvents[$context], $queued); }; - $outermost = ! $this->adapter->inTransaction(); $returned = false; try { - return $this->adapter->withTransaction(function () use ($callback, $discard, $outermost, $context, &$returned): mixed { + return $this->adapter->withTransaction(function () use ($callback, $discard, &$returned): mixed { $returned = false; $discard(); $result = $callback(); - if ($outermost) { - $this->announceBeforeCommit($context); - } $returned = true; return $result; @@ -196,24 +192,6 @@ private function withAdapterTransaction(callable $callback): mixed } } - /** - * Fire the document purge events queued in the outermost transaction before it commits, as 7.x fired them - * from inside the write's transaction: a listener that fails rolls the write back and its failure is thrown, - * rather than the write committing and the request failing after it. - * - * @throws Throwable The first listener failure - */ - private function announceBeforeCommit(int $context): void - { - $events = $this->documentPurgeEvents[$context]; - $this->documentPurgeEvents[$context] = []; - - $failure = $this->announceDocumentPurges($events); - if ($failure !== null) { - throw $failure; - } - } - /** * Fire every queued document purge event, even after one of them fails. * @@ -236,11 +214,10 @@ private function announceDocumentPurges(array $events): ?Throwable /** * Keep all nested mutation tombstones blocked, and purge every written document - * again, once the outer transaction has committed or rolled back. The outermost adapter - * transaction fires the document purge events queued in it before it commits; any queued - * outside one fire after the commit, even when the invalidation after it fails, and after a - * commit that could not be confirmed, whose failure is still the one thrown. A rollback - * drops them, also when its callback threw Exception\Unconfirmed. + * again, once the outer transaction has committed or rolled back. Document purge + * events queued in the scope fire after a commit, even when the invalidation after it + * fails, and after a commit that could not be confirmed, whose failure is still the + * one thrown. A rollback drops them, also when its callback threw Exception\Unconfirmed. * * @template T * From 195d72d16e31ce8f3c828ee2805dbb9e21112c3e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 12 Oct 2026 17:45:50 +1300 Subject: [PATCH 09/10] docs: record the 8.0.2 return of the 8.0.0 rules that 8.0.1 reverted CHANGELOG.md gains the full 8.0.2 section and marks each reverted 8.0.1 entry. UPGRADE.md states the 8.0.0 rules for fractional counters, operator limits, stored-value validation and document_purge again, and notes that 8.0.1 reverted them by mistake. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 33 +++++++++++++++++++------- UPGRADE.md | 65 +++++++++++++++++++++++++++++++--------------------- 2 files changed, 64 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c52ab103b..d5989601c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,30 @@ ## 8.0.2 +8.0.1 reverted six deliberate 8.0.0 changes as if they were regressions. 8.0.2 brings them back; everything else in +8.0.1 stays. + ### Fixed - On PostgreSQL an exact search (`search('title', '"foo bar"')`) matches the adjacent phrase again, as on MariaDB, MySQL, SQLite, MongoDB, Memory and Redis, and `notSearch()` with an exact term excludes only that phrase. 8.0.1 - reverted it to every word in any order by mistake: the phrase match was a deliberate 8.0.0 change, not a - regression. Words in a search term are still split on every character other than a letter, a number or an - underscore, as in 7.x (`foo/bar` and `foo,bar` search `foo` and `bar`). + reverted it to every word in any order by mistake. Words in a search term are still split on every character + other than a letter, a number or an underscore, as in 7.x (`foo/bar` and `foo,bar` search `foo` and `bar`). +- `increaseDocumentAttribute()` and `decreaseDocumentAttribute()` refuse a fractional change value, `max` or `min` + on an integer attribute with `Exception\Type` again, before anything is written, and a change value of 0 or less + throws `Exception\Type` (`Value must be numeric and greater than 0`) again, which `catch (DatabaseException)` + catches. 8.0.1 passed fractional values to the engine (rounded on MariaDB and MySQL, truncated on MongoDB, an + error on PostgreSQL) and threw `\InvalidArgumentException` by mistake. +- Numeric operators on integer attributes refuse a fractional `max` or `min` limit with `Exception\Structure` again, + as 8.0.0 did. Numeric text such as `power('2')` stays accepted. 8.0.1 accepted fractional limits by mistake. +- The `UID` validator description names every character it accepts again (`a-z, A-Z, 0-9, period, hyphen, and + underscore`, none of the last three leading). 8.0.1 returned 7.x's text, which leaves out periods and hyphens. +- An update does not validate again the stored values it leaves unchanged, whatever their type, so a value stored + before a range, enum or other rule was narrowed never blocks an update of another attribute. 8.0.1 validated them + again (leaving only object values alone) by mistake. +- `document_purge` fires after the outermost transaction commits again, and never for a write that rolled back. + The cache entry is still purged inside the write's transaction, so a cache failure rolls the write back. 8.0.1 + fired the event just before the commit by mistake. ## 8.0.1 @@ -32,7 +49,7 @@ unchanged. It also holds nested relationship links to the permission rule UPGRAD raw query from a string reads `Invalid query method: raw`. - `select('$tenant')` passes query validation again without shared tables, and the read refuses it with `Cannot select attributes: $tenant`, as in 7.x. -- The `UID` validator description is the 7.x text again, also inside `Invalid cursor: …`. +- The `UID` validator description is the 7.x text again, also inside `Invalid cursor: …`. Reverted in 8.0.2. - On PostgreSQL an exact search (`"foo bar"`) matches every word in any order again, not the adjacent phrase. Reverted in 8.0.2: this was a deliberate 8.0.0 change. - `containsAll()` on an attribute that is not an array matches each value as a whole-value `LIKE` (`ILIKE` on @@ -40,12 +57,12 @@ unchanged. It also holds nested relationship links to the permission rule UPGRAD - A read whose orders include `orderRandom()` ignores its cursor instead of failing with `Order attribute '' is empty`. - `document_purge` fires inside the write's outermost transaction, just before it commits, so a listener that throws - rolls the write back and its failure reaches the caller, as in 7.x. + rolls the write back and its failure reaches the caller, as in 7.x. Reverted in 8.0.2. - `increaseDocumentAttribute()` and `decreaseDocumentAttribute()` pass a fractional change, `max` or `min` on an integer attribute to the engine again and return the exact sum, and refuse a change that is not above zero with - `\InvalidArgumentException`, as in 7.x. + `\InvalidArgumentException`, as in 7.x. Reverted in 8.0.2. - Numeric operators on integer and double attributes are checked as in 7.x: a fractional change or limit, or a - fractional item appended to an integer array, is no longer refused. + fractional item appended to an integer array, is no longer refused. Reverted in 8.0.2. - A mismatched attribute default reads `Default value x does not match given type ` (and `is not a valid integer string for type bigint`) without JSON quoting, as in 7.x. - `Index::fromArray()` refuses an unknown index type with `Exception\Index` (`Unknown index type: …`) instead of @@ -59,7 +76,7 @@ unchanged. It also holds nested relationship links to the permission rule UPGRAD as in 7.x, and `select(['*', '*.*'])` reads every column instead of failing on MariaDB, MySQL and PostgreSQL. - A single read returns `$sequence` ahead of `$id`, in 7.x's key order, on every adapter. - An update validates the stored values it leaves unchanged again, as 7.x did; a stored object value is held to - 7.x's object rules. + 7.x's object rules. Reverted in 8.0.2. - On MongoDB a transaction that lost a write conflict runs again up to 20 times, so concurrent increments of one document all land. - With dates preserved, linking existing children through their one-to-many parent keeps each child's stored diff --git a/UPGRADE.md b/UPGRADE.md index 642d39f50..46109389f 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -702,14 +702,17 @@ It fires once per written document from `updateDocument()` (for both the old and `Event\Document\Purged` with the document's `collection` and `id`. As in 7.x, `createDocument()` and `createDocuments()` do not fire it. Attribute schema changes fire it for the collection's metadata document (`$collection` = `_metadata`). -As in 7.x, a write fires it inside its transaction, so a listener that throws rolls the write back and its failure -reaches the caller. The events wait for the end of the outermost transaction and fire just before it commits: inside -`withTransaction()` the events of every write fire together before the outer commit, a rollback before that point -drops them, and every attempt of a retried transaction that reaches its commit announces them. Each event runs under -the tenant and the `silent()` scope in force when its document was written. If the cache invalidation after the -commit fails, the write throws with its data committed. `purgeCachedDocument()` fires it at once. On an adapter -without savepoints (MongoDB), a nested `withTransaction()` that fails is not rolled back on its own: when the caller -catches the failure, the nested writes commit with the caller and their events fire before that commit. +A write fires it after the outermost transaction commits: inside `withTransaction()` the events of every write wait +for the outer commit, a rollback drops them, and a retried attempt announces once. Each event runs under the tenant +and the `silent()` scope in force when its document was written. If the cache invalidation after the commit fails, +the write throws with its data committed, after `document_purge` has fired. `purgeCachedDocument()` fires it at once. The +cache entry itself is purged inside the write's transaction, so a cache failure rolls the write back and no event +fires. +On an adapter without savepoints (MongoDB), a nested `withTransaction()` that fails is not rolled back on its own: +when the caller catches the failure, the nested writes commit with the caller and their events fire after that +commit. + +8.0.1 fired the event just before the commit instead, by mistake; 8.0.2 fires it after the commit again. ### `document_update` for related documents a delete changed @@ -885,20 +888,30 @@ method as a no-op, so a hook overrides only what it needs: counted and emitted it). Of a batch that repeats an id, only the first copy is written. Permissions are written only for inserted documents: in 7.x the skipped copy's permissions were added to the stored document on MariaDB, MySQL and SQLite, so `find()`, `count()` and `sum()` could return it to roles its own permissions do not grant. -- **Fractional numbers on integer attributes.** As in 7.x, `increaseDocumentAttribute()` and - `decreaseDocumentAttribute()` pass a fractional change value, `max` or `min` on an integer attribute to the - engine and return the document with the exact sum: MariaDB and MySQL store it rounded, MongoDB stores it and reads - it back truncated, and PostgreSQL fails. A whole float such as `2.0` is bound as the integer. A whole change value - and whole bounds are compared with exact integer arithmetic, so 64-bit and unsigned values never pass through a - float. A change value that is not a number greater than 0 throws `\InvalidArgumentException` - (`Value must be numeric and greater than 0`), as in 7.x. -- **Operator limits on integer attributes.** As in 7.x, the change and the `max` or `min` limit of - `Operator::increment()`, `decrement()`, `multiply()`, `divide()` and `power()` on an integer or double attribute - only have to be numeric and finite: a fractional change or limit on an integer reaches the engine, which stores - the result as for a fractional counter. Without a limit, a result predicted outside the attribute's range is - refused with `Utopia\Database\Exception\Structure` (`Cannot apply operator: would overflow maximum value - of `). On a big integer attribute the limit has to be a whole number in the attribute's range (`Cannot apply - operator: max/min limit must be a whole number for integer attribute '', got `). +- **Fractional numbers on integer attributes.** `increaseDocumentAttribute()` and `decreaseDocumentAttribute()` + throw `Utopia\Database\Exception\Type` before anything is written, on every adapter and also on schemaless + collections that declare the attribute as an integer: + - for a fractional change value (`Change value must be an integer.`). 7.x passed it to the engine, which rounded + it on MariaDB and MySQL, failed on PostgreSQL, and stored a float in the integer attribute on SQLite, MongoDB, + Memory and Redis. Pass an integer change value, or use a float attribute for fractional counters. + - for a fractional `max` or `min` (`Max must be an integer.`, `Min must be an integer.`). Integer bounds are + compared with exact integer arithmetic, so 64-bit and unsigned values never pass through a float. The bound + accepts the same whole numbers as an operator limit: an integer, an integer string, a string with only zero + decimals such as `'102.0'`, or a float without a fractional part such as `102.0`, each converted exactly. Pass + a whole bound, for example `floor($max)` or `ceil($min)`, which admits the same integer values. + + Change values and bounds on float and double attributes may still be fractional. +- **Operator limits on integer attributes.** The `max` or `min` limit of `Operator::increment()`, `decrement()`, + `multiply()`, `divide()` and `power()` on an integer or big integer attribute has to be a whole number: an + integer, an integer string, or a float without a fractional part such as `9.0e18`. A fractional limit such as + `102.4` is refused before anything is written with `Utopia\Database\Exception\Structure` + (`Cannot apply operator: max/min limit must be a whole number for integer attribute '', got + `). With validation skipped that check does not run, and Memory and Redis refuse such a limit with + `Utopia\Database\Exception\Operator` when the result leaves PHP's integer range. Limits on float and double + attributes are unchanged. Numeric text such as `power('2')` is accepted. +- 8.0.1 passed fractional change values and bounds on integer attributes to the engine, accepted fractional operator + limits on integers, and threw `\InvalidArgumentException` for a change value of 0 or less, by mistake; 8.0.2 + refuses them again as described above. - **Operators on upserts that create a document.** An upsert that creates a document applies every operator to the attribute's default, as it does for an existing document: `dateAddDays()` and `dateSubDays()` shift the date, `arrayFilter()` filters the array, and the maximum or minimum of increment, decrement, multiply, divide and power @@ -2022,10 +2035,10 @@ $validator = new IndexDefinition($attributes, $indexes, $database->profile()); it starts; `setStatus()`, `enable()`, `disable()` and `reset()` change the shared status unless called inside such a scope (see [Coroutines](#coroutines)). `setDefaultStatus()` is a constructor argument, `new Authorization(bool $defaultStatus = true)`. `restore()` is internal. -- `Validator\Structure` takes `array $storedAttributes = []`: the attributes whose values are the stored ones. They - are validated as in 7.x, so an update fails on a stored value a narrowed definition no longer admits, but a stored - object value is held to what 7.x accepted (any JSON string or empty value). `Database::updateDocument()` passes - it. +- `Validator\Structure` takes `array $storedAttributes = []`: the attributes whose values are the stored ones, which + it does not validate again, whatever their type, so a stored value a narrowed definition no longer admits never + blocks an update of another attribute. `Database::updateDocument()` passes it. (8.0.1 validated them again, by + mistake; 8.0.2 does not.) - `Database::convertQueries()` takes an optional `array $joinedCollections` (join alias => collection). With it, filters on `alias.attribute`, the filters of join ON lists and `having()` conditions in the list are converted too; aggregates and selects in the list are left as they are. Without it the method converts as before. From 9fc8c2c5a6461e796ce51fd811d5f4278de26cc9 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 12 Oct 2026 17:51:06 +1300 Subject: [PATCH 10/10] test(documents): keep the engine helper the float binding test uses The 8.0.0 OperatorTests came back with the operator rules, and with them the engineFamily() helper that 8.0.1 had added there went away, although the float binding test in DocumentTests still calls it. The helper now lives in DocumentTests next to its only caller. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/Adapter/Scopes/DocumentTests.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 5141e50b6..dec413e7a 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -9,6 +9,7 @@ use Utopia\Cache\Adapter\None as NoneCacheAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Feature; +use Utopia\Database\Adapter\MariaDB; use Utopia\Database\Adapter\Memory; use Utopia\Database\Adapter\Mongo; use Utopia\Database\Adapter\Postgres; @@ -2102,6 +2103,18 @@ private function createPhraseSearchCollection(Database $database, string $collec return $collection; } + private function engineFamily(Database $database): ?string + { + $adapter = $database->getAdapter(); + + return match (true) { + $adapter instanceof MariaDB => 'mariadb', + $adapter instanceof Postgres => 'postgres', + $adapter instanceof Mongo => 'mongo', + default => null, + }; + } + /** * @return list */