From 4dee763d06793f90a8e29777d3acd4409055fa50 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 14 Oct 2026 17:57:48 +1300 Subject: [PATCH 01/18] refactor(api): remove dead 8.x surface for 8.1.2 Database::rawQuery() duplicated query() with a Statement; the permission event classes were never dispatched; AllowNullUid only wrapped Column\AllowNull; getRelationshipHook(), getCacheWriterTimeout() and the index-length, varchar and datetime getters had no consumers and repeat what limits() and the caller already hold. Removing them shrinks the 8.x API back to what callers use. Feature\RawQuery, query()/mutate() and the kept limit getters stay. Tests observe the relationship hook through snapshot() and the hook's own scoped state instead of the removed getter, and the Mirror writer timeout through the query cache scope. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 14 +++++ UPGRADE.md | 39 ++++++++---- src/Database/Adapter/SQL.php | 3 +- .../SQL/Hook/Permission/AllowNullUid.php | 26 -------- src/Database/Database.php | 34 ---------- src/Database/Event.php | 8 +-- src/Database/Event/Permission/Created.php | 24 ------- src/Database/Event/Permission/Deleted.php | 24 ------- src/Database/Event/Permission/Read.php | 24 ------- src/Database/Trait/Documents.php | 28 --------- tests/e2e/Adapter/Scopes/AttributeTests.php | 1 - tests/e2e/Adapter/Scopes/GeneralTests.php | 3 +- tests/unit/Adapter/LimitsTest.php | 18 ------ tests/unit/Adapter/QueryCommentsTest.php | 3 +- .../SQL/Hook/Permission/AllowNullUidTest.php | 62 ------------------- tests/unit/Cache/DatabaseTest.php | 1 - tests/unit/DatabaseGuardsTest.php | 23 +++---- tests/unit/Event/TypedEventTest.php | 21 ++----- tests/unit/Hook/AttachTest.php | 15 +++-- tests/unit/Hook/JoinAliasQuotingTest.php | 5 +- tests/unit/Mirror/RelationshipsHookTest.php | 16 +++-- tests/unit/MirrorTest.php | 15 +++-- .../Relationships/ParallelPopulationTest.php | 14 +---- .../Relationships/RelationshipHookTest.php | 13 ++-- tests/unit/Support/ScopedDatabase.php | 14 +++++ tests/unit/Validator/QueryValidationTest.php | 15 +++-- 26 files changed, 125 insertions(+), 338 deletions(-) delete mode 100644 src/Database/Adapter/SQL/Hook/Permission/AllowNullUid.php delete mode 100644 src/Database/Event/Permission/Created.php delete mode 100644 src/Database/Event/Permission/Deleted.php delete mode 100644 src/Database/Event/Permission/Read.php delete mode 100644 tests/unit/Adapter/SQL/Hook/Permission/AllowNullUidTest.php create mode 100644 tests/unit/Support/ScopedDatabase.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 513070975..e3d170338 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 8.1.2 + +### Removed + +- `Database::rawQuery()`. Use `Database::query(new Utopia\Query\Builder\Statement($query, $bindings))`, which runs + the same statement under the same authorization guard. `Feature\RawQuery` and the adapters' `rawQuery()` stay. +- `Database::getRelationshipHook()`. Keep the `Hook\Relationships` given to `addHook()`. +- `Database::getMaxIndexLength()`, `getMaxVarcharLength()`, `getMinDateTime()` and `getMaxDateTime()`. Read + `indexLength`, `varchar`, `minDateTime` and `maxDateTime` from `$database->getAdapter()->limits()`. +- `Database::getCacheWriterTimeout()`. +- `Event\Permission\Created`, `Read` and `Deleted`, which `Database` never dispatched. `Event::PermissionsCreate`, + `PermissionsRead` and `PermissionsDelete` stay as statement labels; their `domain()` is `null`. +- `Adapter\SQL\Hook\Permission\AllowNullUid`. Use `Adapter\SQL\Hook\Column\AllowNull`, which it wrapped. + ## 8.1.1 ### Added diff --git a/UPGRADE.md b/UPGRADE.md index 612bb0e6f..a9a902332 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,3 +1,17 @@ +# Upgrading from 8.1.1 to 8.1.2 + +8.1.2 removes public API that nothing outside the library called. Each removal has a replacement: + +| Removed | Use instead | +|---|---| +| `Database::rawQuery(string $query, array $bindings = []): array` | `Database::query(new \Utopia\Query\Builder\Statement($query, $bindings))`, inside `getAuthorization()->skip()` as before. It returns a list of `Document`. The adapter's `Feature\RawQuery::rawQuery()` stays. | +| `Database::getRelationshipHook()` (internal) | Keep the `Hook\Relationships` you pass to `addHook()` | +| `Database::getMaxIndexLength()`, `Database::getMaxVarcharLength()` | `$database->getAdapter()->limits()->indexLength`, `->varchar` | +| `Database::getMinDateTime()`, `Database::getMaxDateTime()` | `$database->getAdapter()->limits()->minDateTime`, `->maxDateTime`; clone the `DateTime` before modifying it | +| `Database::getCacheWriterTimeout()` | The value given to `setCacheWriterTimeout()`; without one, `Cache\Scope::WRITER_TIMEOUT` (3600 seconds) | +| `Event\Permission\Created`, `Event\Permission\Read`, `Event\Permission\Deleted` | None: `Database` never dispatched them. `Event::PermissionsCreate`, `PermissionsRead` and `PermissionsDelete` stay as the labels of the statements a permissions write runs, for transforms and timeouts, and their `domain()` is `null`. | +| `Adapter\SQL\Hook\Permission\AllowNullUid` | `Adapter\SQL\Hook\Column\AllowNull`, which takes the same `(Filter $filter, string $column, string $quoteCharacter)` arguments | + # Upgrading from 8.0 to 8.1 8.1 is a minor release. Every change in it is additive or opt-in, with one exception: it runs @@ -978,7 +992,7 @@ Each event's payload is a typed property; 7.x passed arrays, strings, `false` an | `document_aggregate` | `Event\Document\Aggregated` | `collection`, `rows` | | `document_count`, `document_sum` | `Event\Document\Counted`, `Summed` | `collection`, `count`; `attribute`, `sum` | | `document_purge` | `Event\Document\Purged` | `collection`, `id` | -| `permissions_create`, `permissions_read`, `permissions_delete` | `Event\Permission\Created`, `Read`, `Deleted` | `collection`, `document`, `permissions` | +| `permissions_create`, `permissions_read`, `permissions_delete` | None: they only label the statements a permissions write runs, for transforms and timeouts, and reach no hook | | `Event::domain()` names the class of a case. New events: `upsertDocument()` fires `document_upsert` (not `documents_upsert`), `renameAttribute()` fires `attribute_rename` (it fired `attribute_update`), `createIndexes()` @@ -1134,8 +1148,8 @@ method as a no-op, so a hook overrides only what it needs: - The protected `trigger(string $event, mixed $args = null)` is removed. Event dispatch (`listens()`, `dispatch()`) is internal: register a `Hook\Lifecycle` to act on events. - The protected `createDocumentInstance()` is `newDocument()`. -- `casting()` and `applySelectFiltersToDocuments()` are internal, and so are `getRelationshipHook()` and the public - helpers of `Hook\Relationships` that `Database` calls. +- `casting()` and `applySelectFiltersToDocuments()` are internal, and so are the public helpers of + `Hook\Relationships` that `Database` calls. - `increaseDocumentAttribute()` and `decreaseDocumentAttribute()` accept numeric strings as well (`string|int|float`), for unsigned 64-bit values, so an override has to widen its parameter types. - `Database` is now composed of the traits in `Utopia\Database\Trait`. Its public methods are unchanged by that. @@ -1817,9 +1831,8 @@ The 16 per-limit getters of `Adapter` are one `Adapter\Limits` value, `$adapter- | `getIdAttributeType()` | `idType` (a `ColumnType`) | | `getKeywords()`, `getInternalIndexesKeys()` | `keywords`, `internalIndexKeys` | -`Database` keeps `getLimitForAttributes()`, `getLimitForIndexes()`, `getMaxIndexLength()`, `getMaxVarcharLength()`, -`getMinDateTime()`, `getMaxDateTime()`, `getIdAttributeType()` and `getMaxUidLength()` (7.x adapters spelled it -`getMaxUIDLength()`), all read from `limits()`. +`Database` keeps `getLimitForAttributes()`, `getLimitForIndexes()`, `getIdAttributeType()` and `getMaxUidLength()` +(7.x adapters spelled it `getMaxUIDLength()`), all read from `limits()`. `$database->profile(): Adapter\Profile` snapshots the adapter's `limits`, `capabilities`, `features`, `sharedTables` and `migrating` once per `Database`, and is rebuilt by `setSharedTables()`, `setMigrating()` and `setSchemaless()`. @@ -2432,9 +2445,9 @@ from those builds changed before 8.0.0; none of them exists in 7.x. | `Hook\Write` extending `Utopia\Query\Hook\Write`, its row-level `after*(string $table, ...)` methods, the closure-bag write context, `Document::SKIP_PERMISSIONS_UPDATE` | `decorateRow(array $row, Hook\RowMetadata $metadata)`, the document-level `afterDocument*()` methods and `Hook\WriteContext::skipPermissions()` | | `new Relationships($database)` | `new Relationships()`, attached by `addHook()` | | `Adapter::hasTenantHook()`, `hasPermissionHook()` | Removed | -| `Hook\PermissionFilter`, `PermissionJoinFilter`, `PermissionAllowNullUid`, `OuterJoinPermissionFilter` | `Adapter\SQL\Hook\Permission\Filter`, `Join`, `AllowNullUid`, `OuterJoin` | +| `Hook\PermissionFilter`, `PermissionJoinFilter`, `OuterJoinPermissionFilter` | `Adapter\SQL\Hook\Permission\Filter`, `Join`, `OuterJoin` | | `Hook\TenantFilter`, `RawTenantFilter`, `OuterJoinTenantFilter`, `RawOuterJoinTenantFilter` | `Adapter\SQL\Hook\Tenant\Filter`, `Raw`, `OuterJoin`, `RawOuterJoin` | -| `Hook\JoinChain`, `OuterJoinChainFilter`, `AllowNullColumn` | `Adapter\SQL\Hook\Join\Chain`, `Join\OuterChain`, `Column\AllowNull` | +| `Hook\JoinChain`, `OuterJoinChainFilter`, `AllowNullColumn`, `PermissionAllowNullUid` | `Adapter\SQL\Hook\Join\Chain`, `Join\OuterChain`, `Column\AllowNull`, `Column\AllowNull` | | `Hook\Read`, `Hook\Mongo\PermissionFilter`, `Hook\Mongo\TenantFilter` | `Hook\Mongo\Read`, `Hook\Mongo\Permission`, `Hook\Mongo\Tenant` | | `Traits\`, `Builder\PostgreSQL`, `State\Scope` | `Trait\`, `Builder\Postgres`, `State\Frame` | | `Validator\Query\JoinedCollection`, `JoinedAttributes` | `Validator\Query\Joined\Collection`, `Joined\Attributes` | @@ -2676,7 +2689,7 @@ A fulltext `search()` only filters, as in 7.x. A search read is ordered by its e `$sequence`, and a cursor pages along them. Results are not ranked by relevance and carry no `_relevance` attribute. -### Query builder: `from()`, `query()`, `mutate()` and `rawQuery()` +### Query builder: `from()`, `query()` and `mutate()` `Database::from($collection)` returns a utopia-php/query builder over the collection's table. `Database::query($statement)` runs a read as written and returns a list of `Document`, and @@ -2689,8 +2702,8 @@ write no permission rows, skip validation and run no hooks or events (a `Mirror` `Utopia\Database\Exception\Authorization` unless authorization is disabled: build and run them inside `$database->getAuthorization()->skip(fn () => ...)`. -`Database::rawQuery($sql, $bindings)` is the same escape hatch for SQL you write yourself: it runs the statement as -written, checks no permissions, applies no tenant scope (under shared tables it reads every tenant's rows unless the +For SQL you write yourself, pass `query()` a `Utopia\Query\Builder\Statement`, `new Statement($sql, $bindings)`: it +runs the statement as written, checks no permissions, applies no tenant scope (under shared tables it reads every tenant's rows unless the SQL limits them) and runs no hooks or events. It throws `Utopia\Database\Exception\Authorization` unless authorization is disabled: run it inside `$database->getAuthorization()->skip(fn () => ...)`. @@ -2759,7 +2772,7 @@ back. Each `Utopia\Database\Profiler\Log` carries the statement's bound values (`bindings`), the collection it reads (`collection`, for the statements `getDocument()`, `find()`, `count()` and `sum()` run) and the operation that ran it (`operation`, the `Utopia\Database\Event` value, such as `document_find`). Statements that bind by - hand (`rawQuery()`, schema changes) log no bindings, and writes log no collection. `Profiler\Log` has no + hand (`query()`, `mutate()`, schema changes) log no bindings, and writes log no collection. `Profiler\Log` has no `explainPlan`. - **Capability questions behind a `Pool`.** `supports()`, `capabilities()` and `hasFeature()` ask a connection the first time and are then answered without one, for every handle built over the same `Utopia\Pools\Pool`. @@ -2775,7 +2788,7 @@ back. - **Read/write splitting (`Adapter\ReadWritePool`).** Reads go to the read pool and writes to the write pool. After a write returns, or a `withTransaction()` block finishes, reads stay on the write pool for the sticky window (`setStickyDuration()`, default 5000 ms; `setSticky(false)` turns it off), so a caller reads its own writes. - `getDocument(..., forUpdate: true)` and `rawQuery()` always use the write pool, since a locking read must run on + `getDocument(..., forUpdate: true)` and `query()` always use the write pool, since a locking read must run on the primary and raw SQL may write; both also open the sticky window. So do the reads that decide a write: the batch `updateDocuments()` and `deleteDocuments()` select, and the document `upsertDocuments()` compares against. Calls that touch no data (capability checks such as `supports()`, `capabilities()` and `hasFeature()`, `limits()`, value casting, and configuration such as diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 43ba8d347..631061a8f 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -14,6 +14,7 @@ use Utopia\Database\Adapter; use Utopia\Database\Adapter\SQL\BoundedPage; use Utopia\Database\Adapter\SQL\Expression; +use Utopia\Database\Adapter\SQL\Hook\Column\AllowNull; use Utopia\Database\Adapter\SQL\Hook\Join; use Utopia\Database\Adapter\SQL\Hook\Permission; use Utopia\Database\Adapter\SQL\Hook\Tenant; @@ -4818,7 +4819,7 @@ private function applyFindFilters( $permissionHook = $this->newPermissionHook($name, $roles, $forPermission->value, $docCol); if ($preserving) { $permissionConditions[$alias] = $permissionHook->filter($alias); - $permissionHook = new Permission\AllowNullUid( + $permissionHook = new AllowNull( $permissionHook, $docCol, $this->getIdentifierQuote(), diff --git a/src/Database/Adapter/SQL/Hook/Permission/AllowNullUid.php b/src/Database/Adapter/SQL/Hook/Permission/AllowNullUid.php deleted file mode 100644 index dba7fead0..000000000 --- a/src/Database/Adapter/SQL/Hook/Permission/AllowNullUid.php +++ /dev/null @@ -1,26 +0,0 @@ -inner = new AllowNull($filter, $documentColumn, $quoteCharacter); - } - - #[\Override] - public function filter(string $table): Condition - { - return $this->inner->filter($table); - } -} diff --git a/src/Database/Database.php b/src/Database/Database.php index 02b425500..120a3c91d 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1062,14 +1062,6 @@ public function clearTimeout(Event $event = Event::All): void $this->adapter->clearTimeout($event); } - /** - * @internal - */ - public function getRelationshipHook(): ?Relationships - { - return $this->relationshipHook; - } - /** * Set whether to preserve original date values instead of overwriting with current timestamps. * @@ -2435,37 +2427,11 @@ public function getLimitForIndexes(): int return $limits->indexes - $limits->defaultIndexes; } - public function getMaxIndexLength(): int - { - return $this->adapter->limits()->indexLength; - } - - public function getMaxVarcharLength(): int - { - return $this->adapter->limits()->varchar; - } - public function getMaxUidLength(): int { return $this->adapter->limits()->uidLength; } - /** - * A copy, so a caller that modifies it leaves the adapter's limit as it is. - */ - public function getMinDateTime(): NativeDateTime - { - return clone $this->adapter->limits()->minDateTime; - } - - /** - * A copy, so a caller that modifies it leaves the adapter's limit as it is. - */ - public function getMaxDateTime(): NativeDateTime - { - return clone $this->adapter->limits()->maxDateTime; - } - /** * Convert each filter to what its attribute stores. With the collections a query set's joins * read, a filter on `alias.attribute` is converted by that collection's attribute, and the diff --git a/src/Database/Event.php b/src/Database/Event.php index 2b59b93c1..7bd3b6c1a 100644 --- a/src/Database/Event.php +++ b/src/Database/Event.php @@ -47,14 +47,15 @@ enum Event: string case IndexDelete = 'index_delete'; /** - * The typed event a lifecycle hook receives for this case; All only selects timeouts. + * The typed event a lifecycle hook receives for this case. All only selects timeouts, and the + * permission cases only label the statements a permissions write runs: neither reaches a hook. * * @return class-string|null */ public function domain(): ?string { return match ($this) { - self::All => null, + self::All, self::PermissionsCreate, self::PermissionsRead, self::PermissionsDelete => null, self::DatabaseList => Event\Database\Listed::class, self::DatabaseCreate => Event\Database\Created::class, self::DatabaseUpdate => Event\Database\Updated::class, @@ -89,9 +90,6 @@ public function domain(): ?string self::DocumentCount => Event\Document\Counted::class, self::DocumentSum => Event\Document\Summed::class, self::DocumentPurge => Event\Document\Purged::class, - self::PermissionsCreate => Event\Permission\Created::class, - self::PermissionsRead => Event\Permission\Read::class, - self::PermissionsDelete => Event\Permission\Deleted::class, }; } } diff --git a/src/Database/Event/Permission/Created.php b/src/Database/Event/Permission/Created.php deleted file mode 100644 index c9b00ef5a..000000000 --- a/src/Database/Event/Permission/Created.php +++ /dev/null @@ -1,24 +0,0 @@ - $permissions - */ - public function __construct( - public string $collection, - public string $document, - public array $permissions, - ) { - parent::__construct(Event::PermissionsCreate); - } -} diff --git a/src/Database/Event/Permission/Deleted.php b/src/Database/Event/Permission/Deleted.php deleted file mode 100644 index e723a4718..000000000 --- a/src/Database/Event/Permission/Deleted.php +++ /dev/null @@ -1,24 +0,0 @@ - $permissions - */ - public function __construct( - public string $collection, - public string $document, - public array $permissions, - ) { - parent::__construct(Event::PermissionsDelete); - } -} diff --git a/src/Database/Event/Permission/Read.php b/src/Database/Event/Permission/Read.php deleted file mode 100644 index af03c04a5..000000000 --- a/src/Database/Event/Permission/Read.php +++ /dev/null @@ -1,24 +0,0 @@ - $permissions - */ - public function __construct( - public string $collection, - public string $document, - public array $permissions, - ) { - parent::__construct(Event::PermissionsRead); - } -} diff --git a/src/Database/Trait/Documents.php b/src/Database/Trait/Documents.php index 84af3c9b6..9a3aea67d 100644 --- a/src/Database/Trait/Documents.php +++ b/src/Database/Trait/Documents.php @@ -118,11 +118,6 @@ public function setCacheWriterTimeout(int $seconds): static return $this; } - public function getCacheWriterTimeout(): int - { - return $this->cacheWriterTimeout; - } - private function getNumericResult(Attribute $attribute, mixed $current, int|float|string $value, bool $increase): int|float|string { $current ??= 0; @@ -4326,29 +4321,6 @@ private function fetch(Collection $collection, array $queries, PermissionType $f return $results; } - /** - * Execute a raw query bypassing the query builder. The statement runs as written, with no - * permission or tenant scope, so like from(), query() and mutate() it runs only while authorization is - * disabled: inside getAuthorization()->skip(). - * - * @param string $query The raw query string - * @param array $bindings Parameter bindings - * @return array - * - * @throws AuthorizationException While authorization is enabled - * @throws DatabaseException - */ - public function rawQuery(string $query, array $bindings = []): array - { - $this->requireSkippedAuthorization(); - - if (! $this->adapterHasFeature(Feature\RawQuery::class)) { - throw new DatabaseException('Raw queries are not supported by this adapter'); - } - - return $this->adapter->rawQuery($query, $bindings); - } - /** * Find a single document matching the given queries. * diff --git a/tests/e2e/Adapter/Scopes/AttributeTests.php b/tests/e2e/Adapter/Scopes/AttributeTests.php index 5ee79fb15..040a22cdb 100644 --- a/tests/e2e/Adapter/Scopes/AttributeTests.php +++ b/tests/e2e/Adapter/Scopes/AttributeTests.php @@ -1835,7 +1835,6 @@ public function testArrayAttribute(): void if ($database->getAdapter()->supports(Capability::IndexArray)) { if ($database->getAdapter()->supports(Capability::DefinedAttributes) && $database->getAdapter()->limits()->indexLength > 0) { - // If getMaxIndexLength() > 0 We clear length for array attributes $database->createIndex($collection, Index::key(key: 'indx1', attributes: ['long_size'])); $database->deleteIndex($collection, 'indx1'); $database->createIndex($collection, Index::key(key: 'indx2', attributes: ['long_size'], lengths: [1000])); diff --git a/tests/e2e/Adapter/Scopes/GeneralTests.php b/tests/e2e/Adapter/Scopes/GeneralTests.php index 352f502b5..614f9b38c 100644 --- a/tests/e2e/Adapter/Scopes/GeneralTests.php +++ b/tests/e2e/Adapter/Scopes/GeneralTests.php @@ -38,6 +38,7 @@ use Utopia\Database\Profiler; use Utopia\Database\Query; use Utopia\Database\Role; +use Utopia\Query\Builder\Statement; trait GeneralTests { @@ -1643,7 +1644,7 @@ public function testLostConnectionInsideANestedTransactionFailsTheOuterTransacti ? 'SELECT pg_terminate_backend(pg_backend_pid())' : 'KILL '.$database->getConnectionId(); try { - $database->getAuthorization()->skip(fn (): array => $database->rawQuery($end)); + $database->getAuthorization()->skip(fn (): array => $database->query(new Statement($end, []))); } catch (Throwable) { // The server ends the session that runs the statement, so the statement itself may fail. } diff --git a/tests/unit/Adapter/LimitsTest.php b/tests/unit/Adapter/LimitsTest.php index c7f409db2..7d054fce7 100644 --- a/tests/unit/Adapter/LimitsTest.php +++ b/tests/unit/Adapter/LimitsTest.php @@ -99,20 +99,6 @@ public function testSettingSharedTablesToTheirCurrentValueKeepsTheLimits(): void $this->assertSame($limits->indexLength - 1, $adapter->limits()->indexLength); } - public function testChangingADatetimeLimitTheDatabaseReturnsLeavesTheLimit(): void - { - $database = new Database(new MariaDB(new stdClass()), new Cache(new None())); - $min = $database->getMinDateTime()->format('Y-m-d H:i:s'); - $max = $database->getMaxDateTime()->format('Y-m-d H:i:s'); - - $database->getMinDateTime()->modify('+1 year'); - $database->getMaxDateTime()->modify('-1 year'); - - $this->assertSame($min, $database->getMinDateTime()->format('Y-m-d H:i:s')); - $this->assertSame($max, $database->getMaxDateTime()->format('Y-m-d H:i:s')); - $this->assertSame($min, $database->profile()->limits->minDateTime->format('Y-m-d H:i:s')); - } - public function testSQLiteReservesItsOwnKeywords(): void { $mariadb = (new MariaDB(new stdClass()))->limits()->keywords; @@ -174,11 +160,7 @@ public function testTheDatabaseForwardsCountOnlyTheAttributesAndIndexesACollecti $this->assertSame($limits->attributes - $limits->defaultAttributes, $database->getLimitForAttributes()); $this->assertSame($limits->indexes - $limits->defaultIndexes, $database->getLimitForIndexes()); - $this->assertSame(1024, $database->getMaxIndexLength()); - $this->assertSame(16381, $database->getMaxVarcharLength()); $this->assertSame(255, $database->getMaxUidLength()); - $this->assertSame('0001-01-01', $database->getMinDateTime()->format('Y-m-d')); - $this->assertSame('9999-12-31', $database->getMaxDateTime()->format('Y-m-d')); $this->assertSame(ColumnType::Integer, $database->getIdAttributeType()); } diff --git a/tests/unit/Adapter/QueryCommentsTest.php b/tests/unit/Adapter/QueryCommentsTest.php index d0ea2da32..736ed8a05 100644 --- a/tests/unit/Adapter/QueryCommentsTest.php +++ b/tests/unit/Adapter/QueryCommentsTest.php @@ -30,6 +30,7 @@ use Utopia\Database\Validator\Authorization; use Utopia\Pools\Adapter\Stack; use Utopia\Pools\Pool as UtopiaPool; +use Utopia\Query\Builder\Statement; final class QueryCommentsTest extends TestCase { @@ -123,7 +124,7 @@ public function testStatementsPreparedWithoutAnEventCarryTheComments(): void $this->assertPingCarries('/* user: user-1 */'); $this->statements = []; - $rows = $this->database->getAuthorization()->skip(fn (): array => $this->database->rawQuery('SELECT ? AS answer', [42])); + $rows = $this->database->getAuthorization()->skip(fn (): array => $this->database->query(new Statement('SELECT ? AS answer', [42]))); $this->assertSame(42, $rows[0]->getAttribute('answer')); $this->assertEveryStatementStartsWith("/* user: user-1 */\n"); diff --git a/tests/unit/Adapter/SQL/Hook/Permission/AllowNullUidTest.php b/tests/unit/Adapter/SQL/Hook/Permission/AllowNullUidTest.php deleted file mode 100644 index 7a9b02f48..000000000 --- a/tests/unit/Adapter/SQL/Hook/Permission/AllowNullUidTest.php +++ /dev/null @@ -1,62 +0,0 @@ - 'perms_'.$table, - documentColumn: 'table_main.'.Storage::UID, - ); - $hook = new AllowNullUid($inner, 'table_main.'.Storage::UID); - - $condition = $hook->filter('movies'); - - $this->assertSame( - '('.$inner->filter('movies')->expression.' OR `table_main`.`'.Storage::UID.'` IS NULL)', - $condition->expression, - ); - $this->assertSame($inner->filter('movies')->bindings, $condition->bindings); - } - - public function testQuotesPostgresStyleIdentifiers(): void - { - $inner = new class () implements Filter { - #[\Override] - public function filter(string $table): Condition - { - return new Condition('inner_expr', ['role']); - } - }; - $hook = new AllowNullUid($inner, 'table_main.'.Storage::UID, '"'); - - $condition = $hook->filter('movies'); - - $this->assertSame('(inner_expr OR "table_main"."'.Storage::UID.'" IS NULL)', $condition->expression); - $this->assertSame(['role'], $condition->bindings); - } - - public function testRejectsInvalidDocumentColumn(): void - { - $this->expectException(InvalidArgumentException::class); - - new AllowNullUid( - new PermissionFilter( - roles: ['any'], - permissionsTable: static fn (string $table): string => 'perms_'.$table, - ), - 'table_main._uid; DROP TABLE', - ); - } -} diff --git a/tests/unit/Cache/DatabaseTest.php b/tests/unit/Cache/DatabaseTest.php index 530d7f678..05748001c 100644 --- a/tests/unit/Cache/DatabaseTest.php +++ b/tests/unit/Cache/DatabaseTest.php @@ -852,7 +852,6 @@ private function scope(Database $database): Scope namespace: $adapter->getNamespace(), tenant: $adapter->getTenant(), name: $database->getCacheName(), - writerTimeout: $database->getCacheWriterTimeout(), ); } diff --git a/tests/unit/DatabaseGuardsTest.php b/tests/unit/DatabaseGuardsTest.php index fb3be39f3..b3a9a689b 100644 --- a/tests/unit/DatabaseGuardsTest.php +++ b/tests/unit/DatabaseGuardsTest.php @@ -126,10 +126,12 @@ public function testSkipRelationshipsWithoutTheHookRunsTheCallback(): void { $database = $this->database(new Memory()); - $this->assertNull($database->getRelationshipHook()); $result = new stdClass(); - $this->assertSame($result, $database->skipRelationships(static fn (): stdClass => $result)); - $this->assertNull($database->getRelationshipHook(), 'skipping relationships does not install the hook'); + $this->assertSame($result, $database->skipRelationships(function () use ($database, $result): stdClass { + $this->assertTrue($database->snapshot()->relationships, 'skipping relationships does not install the hook'); + + return $result; + })); } public function testSkipRelationshipsExistCheckWithoutTheHookRunsTheCallback(): void @@ -137,18 +139,11 @@ public function testSkipRelationshipsExistCheckWithoutTheHookRunsTheCallback(): $database = $this->database(new Memory()); $result = new stdClass(); - $this->assertSame($result, $database->skipRelationshipsExistCheck(static fn (): stdClass => $result)); - $this->assertNull($database->getRelationshipHook()); - } - - public function testRawQueryIsRefusedWithoutRawQueries(): void - { - $database = $this->database(new Memory()); - - $this->expectException(DatabaseException::class); - $this->expectExceptionMessage('Raw queries are not supported by this adapter'); + $this->assertSame($result, $database->skipRelationshipsExistCheck(function () use ($database, $result): stdClass { + $this->assertTrue($database->snapshot()->existCheck, 'skipping the exist check does not install the hook'); - $database->getAuthorization()->skip(fn (): array => $database->rawQuery('SELECT 1')); + return $result; + })); } private function database(Adapter $adapter): Database diff --git a/tests/unit/Event/TypedEventTest.php b/tests/unit/Event/TypedEventTest.php index 06fb3b6f9..51b6a5a29 100644 --- a/tests/unit/Event/TypedEventTest.php +++ b/tests/unit/Event/TypedEventTest.php @@ -33,12 +33,14 @@ protected function setUp(): void $this->database->addHook($this->recorder); } - public function testEveryCaseButAllNamesAFinalEventClassThatCarriesIt(): void + public function testEveryCaseAHookReceivesNamesAFinalEventClassThatCarriesIt(): void { + $unhooked = [Event::All, Event::PermissionsCreate, Event::PermissionsRead, Event::PermissionsDelete]; + foreach (Event::cases() as $case) { $class = $case->domain(); - if ($case === Event::All) { - $this->assertNull($class); + if (\in_array($case, $unhooked, true)) { + $this->assertNull($class, $case->value.' names an event class'); continue; } @@ -207,19 +209,6 @@ public function testBatchUpsertCountsCreatedAndUpdatedDocuments(): void $this->assertSame([], $this->recorder->received(Event::DocumentUpsert)); } - public function testPermissionEventsCarryTheirCase(): void - { - $created = new Event\Permission\Created('posts', 'first', ['read("any")']); - $read = new Event\Permission\Read('posts', 'first', ['read("any")']); - $deleted = new Event\Permission\Deleted('posts', 'first', []); - - $this->assertSame( - [Event::PermissionsCreate, Event::PermissionsRead, Event::PermissionsDelete], - [$created->event, $read->event, $deleted->event], - ); - $this->assertSame(['posts', 'first', ['read("any")']], [$created->collection, $created->document, $created->permissions]); - } - public function testNoEventIsBuiltForAnEventNoHookHandles(): void { $database = HookFixture::sqlite(); diff --git a/tests/unit/Hook/AttachTest.php b/tests/unit/Hook/AttachTest.php index 7a9a50e5a..9e3f2062e 100644 --- a/tests/unit/Hook/AttachTest.php +++ b/tests/unit/Hook/AttachTest.php @@ -126,8 +126,8 @@ public function testTheRelationshipsHookRefusesToMoveToAnotherDatabase(): void $this->assertSame('This relationships hook is already attached to another database; attach a clone of it instead', $error->getMessage()); } - $this->assertSame($hook, $first->getRelationshipHook()); - $this->assertNull($second->getRelationshipHook()); + $this->assertTrue(self::relatesThrough($first, $hook)); + $this->assertFalse(self::relatesThrough($second, $hook)); } public function testTheRelationshipsHookCanBeAddedAgainToItsOwnDatabase(): void @@ -137,7 +137,7 @@ public function testTheRelationshipsHookCanBeAddedAgainToItsOwnDatabase(): void $database->addHook($hook)->addHook($hook); - $this->assertSame($hook, $database->getRelationshipHook()); + $this->assertTrue(self::relatesThrough($database, $hook)); } public function testACloneOfAnAttachedRelationshipsHookAttachesToAnotherDatabase(): void @@ -150,8 +150,13 @@ public function testACloneOfAnAttachedRelationshipsHookAttachesToAnotherDatabase $clone = clone $hook; $second->addHook($clone); - $this->assertSame($hook, $first->getRelationshipHook()); - $this->assertSame($clone, $second->getRelationshipHook()); + $this->assertTrue(self::relatesThrough($first, $hook)); + $this->assertTrue(self::relatesThrough($second, $clone)); + $this->assertFalse(self::relatesThrough($second, $hook)); } + private static function relatesThrough(Database $database, Relationships $hook): bool + { + return $hook->withEnabled(false, static fn (): bool => ! $database->snapshot()->relationships); + } } diff --git a/tests/unit/Hook/JoinAliasQuotingTest.php b/tests/unit/Hook/JoinAliasQuotingTest.php index c420b5a52..eb4738825 100644 --- a/tests/unit/Hook/JoinAliasQuotingTest.php +++ b/tests/unit/Hook/JoinAliasQuotingTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Utopia\Database\Adapter\SQL\Hook\Column\AllowNull; use Utopia\Database\Adapter\SQL\Hook\Join; use Utopia\Database\Adapter\SQL\Hook\Permission; use Utopia\Database\Adapter\SQL\Hook\Tenant; @@ -148,9 +149,9 @@ public function testPermissionJoinFilterQuotesTheAliasOfEveryJoin(JoinType $join } #[DataProvider('quoteCharacters')] - public function testPermissionAllowNullUidQuotesTheMainTableOnBothSides(string $quote): void + public function testAllowNullPermissionQuotesTheMainTableOnBothSides(string $quote): void { - $hook = new Permission\AllowNullUid($this->permission(self::SOURCE, $quote), self::SOURCE.'.'.Storage::UID, $quote); + $hook = new AllowNull($this->permission(self::SOURCE, $quote), self::SOURCE.'.'.Storage::UID, $quote); $this->assertSame( $this->quoted('("Main"."_uid" IN (SELECT _document FROM "database"."namespace_books_perms" WHERE _permission IN (?) AND _type = ?) OR "Main"."_uid" IS NULL)', $quote), diff --git a/tests/unit/Mirror/RelationshipsHookTest.php b/tests/unit/Mirror/RelationshipsHookTest.php index 5faf79984..ce3d0b5a9 100644 --- a/tests/unit/Mirror/RelationshipsHookTest.php +++ b/tests/unit/Mirror/RelationshipsHookTest.php @@ -36,7 +36,7 @@ public function testTheMirrorKeepsTheGivenHook(): void $mirror->addHook($hook); - $this->assertSame($hook, $mirror->getRelationshipHook()); + $this->assertTrue(self::relatesThrough($mirror, $hook)); } public function testBothSidesRelateDocumentsWithACopyOfTheGivenHook(): void @@ -49,10 +49,11 @@ public function testBothSidesRelateDocumentsWithACopyOfTheGivenHook(): void $mirror->addHook($hook); - $this->assertInstanceOf($hook::class, $source->getRelationshipHook()); - $this->assertInstanceOf($hook::class, $destination->getRelationshipHook()); - $this->assertNotSame($hook, $source->getRelationshipHook()); - $this->assertNotSame($source->getRelationshipHook(), $destination->getRelationshipHook()); + foreach ([$source, $destination] as $database) { + $this->assertFalse($database->skipRelationships(static fn (): bool => $database->snapshot()->relationships)); + $this->assertFalse(self::relatesThrough($database, $hook)); + } + $this->assertTrue($source->skipRelationships(static fn (): bool => $destination->snapshot()->relationships)); } #[DataProvider('preparing')] @@ -128,4 +129,9 @@ private function family(Memory $adapter): Database return $database; } + + private static function relatesThrough(Database $database, Relationships $hook): bool + { + return $hook->withEnabled(false, static fn (): bool => ! $database->snapshot()->relationships); + } } diff --git a/tests/unit/MirrorTest.php b/tests/unit/MirrorTest.php index fa29f00ac..ebd66aa15 100644 --- a/tests/unit/MirrorTest.php +++ b/tests/unit/MirrorTest.php @@ -12,6 +12,7 @@ use Swoole\Runtime; use Tests\Unit\Event\RecordingLifecycle; use Tests\Unit\Support\CountingMemory; +use Tests\Unit\Support\ScopedDatabase; use Throwable; use Utopia\Cache\Adapter\Memory as MemoryCache; use Utopia\Cache\Adapter\None; @@ -494,14 +495,14 @@ public static function scopedSetters(): iterable ]; yield 'skipRelationships' => [ static fn (Mirror $mirror, Closure $callback): mixed => $mirror->skipRelationships($callback), - static fn (Database $database): mixed => $database->getRelationshipHook()?->isEnabled(), + static fn (Database $database): mixed => $database->snapshot()->relationships, false, true, true, ]; yield 'skipRelationshipsExistCheck' => [ static fn (Mirror $mirror, Closure $callback): mixed => $mirror->skipRelationshipsExistCheck($callback), - static fn (Database $database): mixed => $database->getRelationshipHook()?->shouldCheckExist(), + static fn (Database $database): mixed => $database->snapshot()->existCheck, false, true, true, @@ -982,14 +983,12 @@ public function setLocks(bool $locks): static public function testCacheWriterTimeoutReachesSourceAndDestination(): void { - [$mirror, $source, $destination] = $this->pair(); + $source = new ScopedDatabase(new Memory(), new Cache(new None())); + $destination = new ScopedDatabase(new Memory(), new Cache(new None())); - $mirror->setCacheWriterTimeout(30); + (new Mirror($source, $destination))->setCacheWriterTimeout(30); - $this->assertSame( - [30, 30, 30], - [$mirror->getCacheWriterTimeout(), $source->getCacheWriterTimeout(), $destination->getCacheWriterTimeout()], - ); + $this->assertSame([30, 30], [$source->getScope()->writerTimeout, $destination->getScope()->writerTimeout]); } /** diff --git a/tests/unit/Relationships/ParallelPopulationTest.php b/tests/unit/Relationships/ParallelPopulationTest.php index b493d87eb..6f182d60e 100644 --- a/tests/unit/Relationships/ParallelPopulationTest.php +++ b/tests/unit/Relationships/ParallelPopulationTest.php @@ -88,7 +88,7 @@ public function testParallelPopulationLeavesAuthorizationAndRelationshipsEnabled for ($round = 1; $round <= self::ROUNDS; $round++) { $this->assertCount(self::DOCUMENTS, $this->findParents($database)); $this->assertTrue($database->getAuthorization()->getStatus(), "Authorization is left disabled after round {$round}"); - $this->assertTrue($this->hook($database)->isEnabled(), "Relationships are left disabled after round {$round}"); + $this->assertTrue($database->snapshot()->relationships, "Relationships are left disabled after round {$round}"); } }); } @@ -143,7 +143,7 @@ public function testPopulationOnAnAdapterWithoutAPoolReadsOneChunkAtATime(): voi $this->assertSame([], $this->populatedSecrets($parents)); $this->assertTrue($database->getAuthorization()->getStatus()); - $this->assertTrue($this->hook($database)->isEnabled()); + $this->assertTrue($database->snapshot()->relationships); } $this->assertSame(1, $this->peak, 'Chunk reads shared one connection at the same time'); @@ -248,7 +248,7 @@ public function testPopulationOutsideACoroutineKeepsTheCallersState(): void $this->assertCount(self::DOCUMENTS, $parents); $this->assertSame([], $this->populatedSecrets($parents)); $this->assertTrue($database->getAuthorization()->getStatus()); - $this->assertTrue($this->hook($database)->isEnabled()); + $this->assertTrue($database->snapshot()->relationships); $this->assertSame([], $this->ids($database->find('secrets'))); $this->finds = 0; @@ -416,14 +416,6 @@ private function record(Event $event): void } } - private function hook(Database $database): Relationships - { - $hook = $database->getRelationshipHook(); - $this->assertNotNull($hook); - - return $hook; - } - /** * @param array $parents * @return array diff --git a/tests/unit/Relationships/RelationshipHookTest.php b/tests/unit/Relationships/RelationshipHookTest.php index 9cb657781..d948bf96c 100644 --- a/tests/unit/Relationships/RelationshipHookTest.php +++ b/tests/unit/Relationships/RelationshipHookTest.php @@ -580,7 +580,7 @@ private function nestedCascadeDatabase(Closure $adapter, array $grandchildPermis /** * @param Closure(): Adapter $adapter */ - private function database(Closure $adapter, bool $sharedTables = false): Database + private function database(Closure $adapter, bool $sharedTables = false, Relationships $hook = new Relationships()): Database { $authorization = new Authorization(); $authorization->addRole(Role::any()->toString()); @@ -599,7 +599,7 @@ private function database(Closure $adapter, bool $sharedTables = false): Databas } $database->create(); - $database->addHook(new Relationships()); + $database->addHook($hook); $database->addHook(new Permissions()); return $database; @@ -696,12 +696,13 @@ private function relatedIds(Document $document, string $key): array #[DataProvider('adapters')] public function testAFailedNestedOneToOneWriteLeavesNoWriteStackEntry(Closure $adapter): void { - $database = $this->writeStackDatabase($adapter); + $hook = new Relationships(); + $database = $this->writeStackDatabase($adapter, $hook); foreach ([1, 2] as $attempt) { $this->failNestedOneToOneWrite($database, $attempt); - $this->assertSame(0, $database->getRelationshipHook()?->getWriteStackCount(), "Attempt {$attempt} left an entry on the write stack"); + $this->assertSame(0, $hook->getWriteStackCount(), "Attempt {$attempt} left an entry on the write stack"); } } @@ -729,9 +730,9 @@ public function testANestedCreateAfterAFailedNestedWriteStoresItsRelatedDocument /** * @param Closure(): Adapter $adapter */ - private function writeStackDatabase(Closure $adapter): Database + private function writeStackDatabase(Closure $adapter, Relationships $hook = new Relationships()): Database { - $database = $this->database($adapter); + $database = $this->database($adapter, hook: $hook); $database->createCollection(Collection::create(id: 'owner', permissions: $this->permissions(), documentSecurity: false)); $database->createCollection(Collection::create(id: 'solo', attributes: [Attribute::string(key: 'name', size: 64)], permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: false)); $database->createCollection(Collection::create(id: 'item', permissions: $this->permissions(), documentSecurity: false)); diff --git a/tests/unit/Support/ScopedDatabase.php b/tests/unit/Support/ScopedDatabase.php new file mode 100644 index 000000000..f97bdff72 --- /dev/null +++ b/tests/unit/Support/ScopedDatabase.php @@ -0,0 +1,14 @@ +getQueryCacheScope(); + } +} diff --git a/tests/unit/Validator/QueryValidationTest.php b/tests/unit/Validator/QueryValidationTest.php index 6e1207b09..dfcea86ea 100644 --- a/tests/unit/Validator/QueryValidationTest.php +++ b/tests/unit/Validator/QueryValidationTest.php @@ -30,6 +30,7 @@ use Utopia\Database\Validator\Queries\Document as DocumentQueries; use Utopia\Database\Validator\Query\Aggregate; use Utopia\Database\Validator\Query\Join; +use Utopia\Query\Builder\Statement; use Utopia\Query\Exception\ValidationException; use Utopia\Query\Method; use Utopia\Query\Schema\ColumnType; @@ -417,20 +418,18 @@ public function testGetDocumentRefusesAJoinOnAnAdapterWithoutJoins(): void $this->assertSame('Ann', $database->getDocument('customers', 'c1', [Query::select(['name'])])->getAttribute('name')); } - /** - * rawQuery() runs SQL as written, with no permission or tenant scope, so like from() and - * execute() it runs only while authorization is skipped. - */ - public function testRawQueryRunsOnlyWhileAuthorizationIsSkipped(): void + public function testQueryRunsOnlyWhileAuthorizationIsSkipped(): void { + $statement = new Statement('SELECT ? AS answer', [42]); + try { - $this->database->rawQuery('SELECT ? AS answer', [42]); - $this->fail('rawQuery() ran with authorization enabled'); + $this->database->query($statement); + $this->fail('query() ran with authorization enabled'); } catch (AuthorizationException $error) { $this->assertStringContainsString('getAuthorization()->skip()', $error->getMessage()); } - $rows = $this->database->getAuthorization()->skip(fn (): array => $this->database->rawQuery('SELECT ? AS answer', [42])); + $rows = $this->database->getAuthorization()->skip(fn (): array => $this->database->query($statement)); $this->assertCount(1, $rows); $this->assertSame(42, $rows[0]['answer']); From 888a20d9c687cf44c0829a6bc6f9bd01060b9d7c Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 14 Oct 2026 18:06:06 +1300 Subject: [PATCH 02/18] fix(pool): honour setHostname() and expose the pooled connections A hostname given to Pool::setHostname() was ignored: hostname() always asked the borrowed adapter, so Database::getHostname() and the cache keys built from it named the pooled host. Appwrite subclassed Pool only to work around that and to reach the protected $pool. hostname() now returns the set hostname when it is not empty and delegates otherwise, and getPool() returns the utopia-php/pools pool, so the subclass can go. Co-Authored-By: Claude Opus 5.5 --- src/Database/Adapter/Pool.php | 12 ++++ tests/unit/Adapter/PoolHostnameTest.php | 79 +++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 tests/unit/Adapter/PoolHostnameTest.php diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 2878360d4..3a7552bc0 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -87,6 +87,14 @@ public function __construct(UtopiaPool $pool) $this->pool = $pool; } + /** + * @return UtopiaPool + */ + public function getPool(): UtopiaPool + { + return $this->pool; + } + /** * Required because __call() can't be used to implement abstract methods. * @@ -487,6 +495,10 @@ public function inTransaction(): bool public function hostname(): string { + if ($this->hostname !== '') { + return $this->hostname; + } + /** @var string $result */ $result = $this->delegateFeature(Feature\Connection::class, __FUNCTION__, \func_get_args()); diff --git a/tests/unit/Adapter/PoolHostnameTest.php b/tests/unit/Adapter/PoolHostnameTest.php new file mode 100644 index 000000000..65c5ac682 --- /dev/null +++ b/tests/unit/Adapter/PoolHostnameTest.php @@ -0,0 +1,79 @@ +assertSame('db-1', $this->pool(new HostnameSQLite('db-1'))->hostname()); + } + + public function testAHostnameSetOnThePoolWinsOverThePooledAdaptersHostname(): void + { + $pool = $this->pool(new HostnameSQLite('db-1')); + + $this->assertSame($pool, $pool->setHostname('edge-1')); + $this->assertSame('edge-1', $pool->hostname()); + } + + public function testAPoolClearedOfItsHostnameDelegatesAgain(): void + { + $pool = $this->pool(new HostnameSQLite('db-1')); + + $pool->setHostname('edge-1'); + $pool->setHostname(''); + + $this->assertSame('db-1', $pool->hostname()); + } + + public function testADatabaseOverAPoolNamesThePoolsHostname(): void + { + $pool = $this->pool(new HostnameSQLite('db-1')); + $database = new Database($pool, new Cache(new None())); + $database->setAuthorization(new Authorization()); + + $this->assertSame('db-1', $database->getHostname()); + + $pool->setHostname('edge-1'); + + $this->assertSame('edge-1', $database->getHostname()); + } + + public function testThePoolHandsBackTheConnectionsItWasBuiltWith(): void + { + $connections = $this->connections(new HostnameSQLite('db-1')); + + $this->assertSame($connections, (new Pool($connections))->getPool()); + } + + private function pool(Adapter $connection): Pool + { + $pool = new Pool($this->connections($connection)); + $pool->setAuthorization(new Authorization()); + + return $pool; + } + + /** + * @return UtopiaPool&Stub + */ + private function connections(Adapter $connection): UtopiaPool + { + /** @var UtopiaPool&Stub $connections */ + $connections = self::createStub(UtopiaPool::class); + $connections->method('use')->willReturnCallback(static fn (callable $callback): mixed => $callback($connection)); + + return $connections; + } +} From 029ebf25ec4aad8c8f13acf0a7ab86eae749417d Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 14 Oct 2026 18:06:06 +1300 Subject: [PATCH 03/18] refactor(documents)!: remove read() in favour of find() and aggregate() read() routed a list to find() or aggregate() by inspecting its queries, which is a caller's decision: appwrite's list endpoints now call find() or aggregate() explicitly, so the router leaves the library before anyone else depends on it. fetch() and nextPageCheck() share ParsedQuery::isAggregation() instead of repeating the aggregations/groupBy check; find() still refuses aggregate and groupBy queries exactly as before. The isAggregation() assertions move from the deleted ReadTest into QueryTest, and the join tests read their ungrouped count through aggregate(). Co-Authored-By: Claude Opus 5.5 --- src/Database/Trait/Documents.php | 34 +--- tests/e2e/Adapter/Base.php | 2 - .../Adapter/Scopes/JoinRelationshipTests.php | 4 +- tests/e2e/Adapter/Scopes/ReadTests.php | 162 ------------------ tests/unit/Documents/ReadTest.php | 152 ---------------- tests/unit/Joins/RelationshipJoinTest.php | 6 +- tests/unit/QueryTest.php | 8 + 7 files changed, 15 insertions(+), 353 deletions(-) delete mode 100644 tests/e2e/Adapter/Scopes/ReadTests.php delete mode 100644 tests/unit/Documents/ReadTest.php diff --git a/src/Database/Trait/Documents.php b/src/Database/Trait/Documents.php index 84af3c9b6..5b52ff5d8 100644 --- a/src/Database/Trait/Documents.php +++ b/src/Database/Trait/Documents.php @@ -3982,36 +3982,6 @@ public function find(string $collection, array $queries = [], PermissionType $fo return $this->fetch($this->silent(fn () => $this->getCollection($collection)), $queries, $forPermission, false); } - /** - * Read a list the way its queries ask: the documents find() reads, or, when the queries aggregate or group, the - * rows aggregate() reads, each as a document. - * - * @param array $queries - * @param PermissionType $forPermission The permission documents are read under; aggregate rows read under the read permission only - * @return list - * - * @throws DatabaseException - * @throws QueryException When aggregate or groupBy queries are read under another permission than read - * @throws TimeoutException - */ - public function read(string $collection, array $queries = [], PermissionType $forPermission = PermissionType::Read): array - { - $this->checkQueryTypes($queries); - - if (! Query::groupByType($queries)->isAggregation()) { - return \array_values($this->find($collection, $queries, $forPermission)); - } - - if ($forPermission !== PermissionType::Read) { - throw new QueryException('Aggregate and groupBy queries read under the read permission only, not '.$forPermission->value); - } - - return \array_map( - static fn (array $row): Document => new Document($row), - $this->aggregate($collection, $queries), - ); - } - /** * @param array $queries * @param bool $aggregate Whether the queries must aggregate (aggregate()) or must not (find()) @@ -4063,7 +4033,7 @@ private function fetch(Collection $collection, array $queries, PermissionType $f $cursor = $parsed->cursor; $cursorDirection = $parsed->cursorDirection ?? CursorDirection::After; - $isAggregation = ! empty($aggregations) || ! empty($groupByAttrs); + $isAggregation = $parsed->isAggregation(); if ($isAggregation !== $aggregate) { throw new QueryException($aggregate @@ -4893,7 +4863,7 @@ private function addTieBreaks(array $orderAttributes, array $orderTypes, array $ private function nextPageCheck(string $collection, array $queries): Closure { $parsed = Query::groupByType($queries); - if (($parsed->joins === [] && ! $parsed->distinct) || $parsed->aggregations !== [] || $parsed->groupBy !== []) { + if (($parsed->joins === [] && ! $parsed->distinct) || $parsed->isAggregation()) { return static function (Document $cursor): void { }; } diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index fe70bc2b2..f42d17a7a 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -23,7 +23,6 @@ use Tests\E2E\Adapter\Scopes\ObjectAttributeTests; use Tests\E2E\Adapter\Scopes\OperatorTests; use Tests\E2E\Adapter\Scopes\PermissionTests; -use Tests\E2E\Adapter\Scopes\ReadTests; use Tests\E2E\Adapter\Scopes\RelationshipTests; use Tests\E2E\Adapter\Scopes\SchemalessTests; use Tests\E2E\Adapter\Scopes\SchemaReconciliationTests; @@ -68,7 +67,6 @@ abstract class Base extends TestCase use ObjectAttributeTests; use OperatorTests; use PermissionTests; - use ReadTests; use RelationshipTests; use SchemaReconciliationTests; use SchemalessTests; diff --git a/tests/e2e/Adapter/Scopes/JoinRelationshipTests.php b/tests/e2e/Adapter/Scopes/JoinRelationshipTests.php index c634eac38..7589a14ea 100644 --- a/tests/e2e/Adapter/Scopes/JoinRelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/JoinRelationshipTests.php @@ -148,8 +148,8 @@ public function testJoinRelationshipIsMatchedByEveryRead(): void $this->assertSame( [4], \array_map( - static fn (Document $row): ?int => self::joinRelationshipInteger($row->getAttribute('rows')), - $database->read(self::JOIN_RELATIONSHIP_CUSTOMERS, [$join, Query::count('*', 'rows')]), + static fn (array $row): ?int => self::joinRelationshipInteger($row['rows']), + $database->aggregate(self::JOIN_RELATIONSHIP_CUSTOMERS, [$join, Query::count('*', 'rows')]), ), ); $this->assertSame( diff --git a/tests/e2e/Adapter/Scopes/ReadTests.php b/tests/e2e/Adapter/Scopes/ReadTests.php deleted file mode 100644 index 840321261..000000000 --- a/tests/e2e/Adapter/Scopes/ReadTests.php +++ /dev/null @@ -1,162 +0,0 @@ -getDatabase(); - $this->seedReadPosts($database); - - try { - $queries = [Query::greaterThan('views', 1), Query::orderDesc('views')]; - $read = $database->read(self::READ_POSTS, $queries); - - $this->assertSame(['third', 'second'], \array_map(static fn (Document $document): string => $document->getId(), $read)); - $this->assertSame( - \array_map(static fn (Document $document): array => $document->getArrayCopy(), $database->find(self::READ_POSTS, $queries)), - \array_map(static fn (Document $document): array => $document->getArrayCopy(), $read), - ); - } finally { - $this->deleteReadPosts($database); - } - } - - public function testReadUnderAnotherPermissionReadsTheDocumentsHeldUnderIt(): void - { - $database = $this->getDatabase(); - $this->seedReadPosts($database); - - try { - $this->assertSame( - ['first', 'third'], - \array_map(static fn (Document $document): string => $document->getId(), $database->read(self::READ_POSTS, [Query::orderAsc('views')], PermissionType::Update)), - ); - } finally { - $this->deleteReadPosts($database); - } - } - - public function testReadReturnsAggregateRowsAsDocuments(): void - { - $database = $this->getDatabase(); - if (! $database->getAdapter()->supports(Capability::Aggregations)) { - $this->expectNotToPerformAssertions(); - - return; - } - - $this->seedReadPosts($database); - - try { - $totals = $database->read(self::READ_POSTS, [Query::count('*', 'posts'), Query::sum('views', 'views')]); - $this->assertCount(1, $totals); - $this->assertSame([3, 6], [self::readInteger($totals[0]->getAttribute('posts')), self::readInteger($totals[0]->getAttribute('views'))]); - - $groups = $database->read(self::READ_POSTS, [Query::groupBy(['category']), Query::count('*', 'posts'), Query::orderAsc('category')]); - $this->assertSame( - [['news', 2], ['sport', 1]], - \array_map(static fn (Document $row): array => [$row->getAttribute('category'), self::readInteger($row->getAttribute('posts'))], $groups), - ); - } finally { - $this->deleteReadPosts($database); - } - } - - public function testReadRefusesAnAggregationUnderAnotherPermissionThanRead(): void - { - $database = $this->getDatabase(); - $this->seedReadPosts($database); - - try { - $database->read(self::READ_POSTS, [Query::count()], PermissionType::Update); - $this->fail('An aggregation under the update permission must be refused'); - } catch (QueryException $exception) { - $this->assertStringContainsString('read under the read permission only', $exception->getMessage()); - } finally { - $this->deleteReadPosts($database); - } - } - - public function testFindStillRefusesAnAggregation(): void - { - $database = $this->getDatabase(); - $this->seedReadPosts($database); - - $this->expectException(QueryException::class); - if ($database->getAdapter()->supports(Capability::Aggregations)) { - $this->expectExceptionMessage('find() does not run aggregate or groupBy queries: use aggregate()'); - } - - try { - $database->find(self::READ_POSTS, [Query::count()]); - } finally { - $this->deleteReadPosts($database); - } - } - - /** - * Posts first (news, 1 view), second (news, 2 views) and third (sport, 3 views), all readable, first and third - * updatable by any caller. - */ - private function seedReadPosts(Database $database): void - { - $this->deleteReadPosts($database); - - $database->createCollection(Collection::create( - id: self::READ_POSTS, - attributes: [ - Attribute::string(key: 'category', size: 16), - Attribute::integer(key: 'views'), - ], - permissions: [Permission::create(Role::any())], - documentSecurity: true, - )); - - foreach ([ - 'first' => ['news', 1, true], - 'second' => ['news', 2, false], - 'third' => ['sport', 3, true], - ] as $id => [$category, $views, $updatable]) { - $database->createDocument(self::READ_POSTS, new Document([ - '$id' => $id, - '$permissions' => [ - Permission::read(Role::any()), - ...($updatable ? [Permission::update(Role::any())] : []), - ], - 'category' => $category, - 'views' => $views, - ])); - } - } - - private function deleteReadPosts(Database $database): void - { - if ($database->collectionExists(self::READ_POSTS)) { - $database->deleteCollection(self::READ_POSTS); - } - } - - private static function readInteger(mixed $value): ?int - { - return \is_numeric($value) ? (int) $value : null; - } -} diff --git a/tests/unit/Documents/ReadTest.php b/tests/unit/Documents/ReadTest.php deleted file mode 100644 index 6290c5454..000000000 --- a/tests/unit/Documents/ReadTest.php +++ /dev/null @@ -1,152 +0,0 @@ -addRole(Role::user('editor')->toString()); - - $this->database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None())); - $this->database - ->setAuthorization($authorization) - ->setDatabase('read') - ->setNamespace('read_'.\uniqid()); - $this->database->addHook(new Permissions()); - $this->database->create(); - - $this->database->createCollection(Collection::create( - id: self::COLLECTION, - attributes: [ - Attribute::string(key: 'category', size: 16), - Attribute::integer(key: 'views'), - ], - permissions: [Permission::create(Role::any())], - documentSecurity: true, - )); - - foreach ([ - 'first' => ['news', 1, true], - 'second' => ['news', 2, false], - 'third' => ['sport', 3, true], - ] as $id => [$category, $views, $editable]) { - $this->database->createDocument(self::COLLECTION, new Document([ - Document::ID => $id, - Document::PERMISSIONS => [ - Permission::read(Role::any()), - ...($editable ? [Permission::update(Role::user('editor'))] : []), - ], - 'category' => $category, - 'views' => $views, - ])); - } - } - - public function testDocumentQueriesReadWhatFindReads(): void - { - $queries = [Query::greaterThan('views', 1), Query::orderDesc('views')]; - - $read = $this->database->read(self::COLLECTION, $queries); - - $this->assertSame(['third', 'second'], \array_map(static fn (Document $document): string => $document->getId(), $read)); - $this->assertSame( - \array_map(static fn (Document $document): array => $document->getArrayCopy(), $this->database->find(self::COLLECTION, $queries)), - \array_map(static fn (Document $document): array => $document->getArrayCopy(), $read), - ); - } - - public function testNoQueriesReadEveryDocument(): void - { - $this->assertSame(['first', 'second', 'third'], \array_map(static fn (Document $document): string => $document->getId(), $this->database->read(self::COLLECTION))); - } - - public function testAggregateQueriesReadTheAggregateRowsAsDocuments(): void - { - $queries = [Query::count('*', 'total'), Query::sum('views')]; - - $read = $this->database->read(self::COLLECTION, $queries); - - $this->assertContainsOnlyInstancesOf(Document::class, $read); - $this->assertSame([['total' => 3, 'sum_views' => 6]], \array_map(static fn (Document $row): array => $row->getArrayCopy(), $read)); - $this->assertSame($this->database->aggregate(self::COLLECTION, $queries), \array_map(static fn (Document $row): array => $row->getArrayCopy(), $read)); - } - - public function testGroupByQueriesReadOneDocumentPerGroup(): void - { - $read = $this->database->read(self::COLLECTION, [Query::groupBy(['category']), Query::count('*', 'posts'), Query::orderAsc('category')]); - - $this->assertSame( - [['posts' => 2, 'category' => 'news'], ['posts' => 1, 'category' => 'sport']], - \array_map(static fn (Document $row): array => $row->getArrayCopy(), $read), - ); - } - - public function testThePermissionIsPassedToFind(): void - { - $this->assertSame( - ['first', 'third'], - \array_map(static fn (Document $document): string => $document->getId(), $this->database->read(self::COLLECTION, [], PermissionType::Update)), - ); - } - - public function testAnAggregationUnderAnotherPermissionThanReadIsRefused(): void - { - $this->expectException(QueryException::class); - $this->expectExceptionMessage('Aggregate and groupBy queries read under the read permission only'); - - $this->database->read(self::COLLECTION, [Query::count()], PermissionType::Update); - } - - public function testGroupedQueriesTellAnAggregationFromADocumentList(): void - { - $this->assertFalse(Query::groupByType([Query::equal('category', ['news']), Query::orderAsc('views'), Query::limit(2)])->isAggregation()); - $this->assertFalse(Query::groupByType([])->isAggregation()); - $this->assertTrue(Query::groupByType([Query::count()])->isAggregation()); - $this->assertTrue(Query::groupByType([Query::groupBy(['category'])])->isAggregation(), 'a groupBy reads one row per group without an aggregate'); - } - - public function testFindStillRefusesAggregateQueries(): void - { - $this->expectException(QueryException::class); - $this->expectExceptionMessage('find() does not run aggregate or groupBy queries: use aggregate()'); - - $this->database->find(self::COLLECTION, [Query::count()]); - } - - public function testAQueryOfAnotherTypeIsRefused(): void - { - $this->expectException(QueryException::class); - $this->expectExceptionMessage('Invalid query type: "string"'); - - /** @phpstan-ignore argument.type */ - $this->database->read(self::COLLECTION, ['count()']); - } -} diff --git a/tests/unit/Joins/RelationshipJoinTest.php b/tests/unit/Joins/RelationshipJoinTest.php index c408d0f97..caa37c6d6 100644 --- a/tests/unit/Joins/RelationshipJoinTest.php +++ b/tests/unit/Joins/RelationshipJoinTest.php @@ -244,10 +244,10 @@ public static function reads(): iterable ), [['Ada', 30], ['Bob', 70]], ]; - yield 'read' => [ + yield 'ungrouped aggregate' => [ static fn (Database $database): array => \array_map( - static fn (Document $row): ?int => self::integer($row->getAttribute('rows')), - $database->read('customers', [$join, Query::count('*', 'rows')]), + static fn (array $row): ?int => self::integer($row['rows']), + $database->aggregate('customers', [$join, Query::count('*', 'rows')]), ), [4], ]; diff --git a/tests/unit/QueryTest.php b/tests/unit/QueryTest.php index f2adebb77..87ca38bc4 100644 --- a/tests/unit/QueryTest.php +++ b/tests/unit/QueryTest.php @@ -649,6 +649,7 @@ public function testShapeRefusesATreeThatWouldExplodeTheWalk(): void $node->shape(); } + public function testGroupByTypeKeepsTheOrdersInTheOrderGiven(): void { $cursor = new Document([Document::ID => 'first']); @@ -675,4 +676,11 @@ public function testGroupByTypeRefusesACursorThatIsNotADocument(): void Query::groupByType([Query::cursorAfter([Document::ID => 'first'])]); } + public function testGroupByTypeTellsAnAggregationFromADocumentList(): void + { + $this->assertFalse(Query::groupByType([Query::equal('category', ['news']), Query::orderAsc('views'), Query::limit(2)])->isAggregation()); + $this->assertFalse(Query::groupByType([])->isAggregation()); + $this->assertTrue(Query::groupByType([Query::count()])->isAggregation()); + $this->assertTrue(Query::groupByType([Query::groupBy(['category'])])->isAggregation(), 'a groupBy reads one row per group without an aggregate'); + } } From 34b1b9d0f919d1e9a7f60263e14b9f2041fe0faf Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 14 Oct 2026 18:06:06 +1300 Subject: [PATCH 04/18] docs: document read() removal and pool hostname for 8.1.2 The 8.0 to 8.1 guide pointed callers at read(), which 8.1.2 removes, so it now points at find()/aggregate() and isAggregation(), and a self-contained 8.1.1 to 8.1.2 section explains the replacement and the cache-key effect of a pool hostname. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 20 ++++++++++++++++++++ UPGRADE.md | 49 +++++++++++++++++++++++++++++++++++++------------ 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 513070975..e1b089618 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 8.1.2 + +See [Upgrading from 8.1.1 to 8.1.2](UPGRADE.md#upgrading-from-811-to-812). + +### Removed + +- `Database::read()`, added in 8.1.0. Call `find()` for documents and `aggregate()` for aggregate or `groupBy()` + queries; `Query::groupByType($queries)->isAggregation()` tells which of the two a list is. `find()` still refuses + aggregate and `groupBy()` queries. + +### Added + +- `Adapter\Pool::getPool(): Utopia\Pools\Pool` returns the pool of adapters the handle borrows from. + +### Fixed + +- `Adapter\Pool::hostname()` returns the hostname given to `setHostname()` when it is not empty, and asks the pooled + adapter only otherwise. 8.1.1 ignored it, so `Database::getHostname()` and the cache keys built from it named the + pooled adapter's host instead. + ## 8.1.1 ### Added diff --git a/UPGRADE.md b/UPGRADE.md index 612bb0e6f..ccc57603b 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,3 +1,33 @@ +# Upgrading from 8.1.1 to 8.1.2 + +8.1.2 is a patch release. It removes `Database::read()`, and a `Pool` answers with the hostname it is given. +[CHANGELOG.md](CHANGELOG.md) lists every change. + +- [`Database::read()` is removed](#databaseread-is-removed) +- [Hostname of a pool](#hostname-of-a-pool) + +## `Database::read()` is removed + +Call `find()` for documents and `aggregate()` for aggregate or `groupBy()` queries. A list that serves both picks one +with `ParsedQuery::isAggregation()`: + +```php +$rows = Query::groupByType($queries)->isAggregation() + ? \array_map(static fn (array $row): Document => new Document($row), $database->aggregate('posts', $queries)) + : $database->find('posts', $queries, $forPermission); +``` + +`aggregate()` reads under the read permission only. `read()` refused aggregate queries under another permission with +`Exception\Query`; a caller that relied on that refusal checks `$forPermission` itself. + +## Hostname of a pool + +- `Pool::hostname()` returns the hostname given to `Pool::setHostname()` when it is not empty. Otherwise it asks the + pooled adapter, as 8.1.1 always did. `Database::getHostname()` and the document and query cache keys follow it, so a + pool given a hostname reads and writes cache entries under that hostname from 8.1.2 on. +- `Pool::getPool(): Utopia\Pools\Pool` returns the pool of adapters the handle borrows from. A subclass that + exposed the protected `$pool` property, or overrode the hostname, for either purpose can be removed. + # Upgrading from 8.0 to 8.1 8.1 is a minor release. Every change in it is additive or opt-in, with one exception: it runs @@ -10,7 +40,7 @@ exactly as in 8.0.3 apart from that join. - [Grant check on related documents](#grant-check-on-related-documents) - [Fractional operands on integer attributes](#fractional-operands-on-integer-attributes) - [Cursor from an ID](#cursor-from-an-id) -- [One read for documents and aggregates](#one-read-for-documents-and-aggregates) +- [Documents or aggregates](#documents-or-aggregates) - [Joins on a one-to-many relationship](#joins-on-a-one-to-many-relationship) - [Spatial support behind a pool](#spatial-support-behind-a-pool) - [`State\Snapshot`](#statesnapshot) @@ -276,17 +306,12 @@ if ($cursor->isEmpty()) { $page = $database->find('posts', [...$queries, Query::cursorAfter($cursor)]); ``` -## One read for documents and aggregates - -Additive. `find()` still refuses aggregate and `groupBy()` queries, as 8.0 decided. A caller that serves both through -one list can use `Database::read(string $collection, array $queries = [], PermissionType $forPermission = -PermissionType::Read): array`. It returns a list of `Document`: - -- aggregate or `groupBy()` queries return the rows of `aggregate()`, each wrapped in a `Document`. Aggregates are read - under the read permission only, so another `$forPermission` throws `Exception\Query`. -- any other queries return what `find($collection, $queries, $forPermission)` returns. +## Documents or aggregates -`ParsedQuery::isAggregation(): bool` (from `Query::groupByType($queries)`) tells which of the two a list is. +No change. `find()` still refuses aggregate and `groupBy()` queries, as 8.0 decided; `aggregate()` runs them. +`ParsedQuery::isAggregation(): bool` (from `Query::groupByType($queries)`) tells which of the two a list is. 8.1.0 and +8.1.1 also had `Database::read()`, which 8.1.2 removes (see +[Upgrading from 8.1.1 to 8.1.2](#databaseread-is-removed)). ## Joins on a one-to-many relationship @@ -305,7 +330,7 @@ $rows = $database->find('customers', [ - The condition keeps its operator, and its right column becomes the two-way key. Other conditions of the join are unchanged, and the caller's `Query` objects are not modified. -- `getDocument()`, `find()`, `findOne()`, `read()`, `aggregate()`, `count()`, `sum()` and `resolveCursor()` run it, so +- `getDocument()`, `find()`, `findOne()`, `aggregate()`, `count()`, `sum()` and `resolveCursor()` run it, so `cursorAfter()` and `cursorBefore()` page over the joined rows. - Only an unqualified left column that is the parent side of a one-to-many with a two-way key is resolved. Every other relationship attribute joins, or is refused, exactly as in 8.0.3; so is a left column under a join's alias. From 799085798254f07d435b2abee9cad320fd69f9ab Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 14 Oct 2026 18:06:22 +1300 Subject: [PATCH 05/18] refactor(documents): round fractional integer changes instead of emulating 7.4.1 The round-fractions mode reproduced each engine's 7.4.1 arithmetic through Adapter::arithmetic(), the Arithmetic classes and a locked read of every stored value, for a behaviour the user summed up as "it's just rounding". Integer attributes now take one rule, always on and applied once in PHP before validation: a fractional increase/decrease change or increment/decrement amount rounds half away from zero, a fractional max is floored and a fractional min ceiled. Decimal text rounds exactly at any size. A counter change that rounds to zero writes nothing and fires no event. Fractional multiply, divide, modulo and power stay refused, and float attributes are unchanged. This removes setRoundFractions()/isRoundingFractions()/withRoundFractions(), the Mirror overrides, the snapshot flag, Adapter::arithmetic() and its overrides and pool cache, the validators' $fractions argument and the Fractions trait, and restores the single 8.0.3 counter path and the single updateDocuments() write. IntegerRoundingTest (Memory, schemaless Memory, SQLite) and the IntegerRoundingTests e2e scope replace the emulation tests, keeping the whole-bound cases of FractionalBoundTest and FractionalOperatorLimitTest. Co-Authored-By: Claude Opus 5.5 --- src/Database/Adapter.php | 16 - src/Database/Adapter/Memory.php | 18 - src/Database/Adapter/Mongo.php | 16 - src/Database/Adapter/Pool.php | 22 - src/Database/Adapter/Postgres.php | 21 - src/Database/Adapter/ReadWritePool.php | 1 - src/Database/Adapter/Redis.php | 18 - src/Database/Adapter/SQLite.php | 20 - src/Database/Arithmetic.php | 43 - src/Database/Arithmetic/Decimal.php | 216 ---- src/Database/Arithmetic/Fractional.php | 411 ------- src/Database/Arithmetic/Rounding.php | 69 -- src/Database/Database.php | 52 +- src/Database/Mirror.php | 17 - src/Database/State/Snapshot.php | 2 - src/Database/Trait/Documents.php | 234 ++-- src/Database/Trait/Fractions.php | 246 ---- src/Database/Validator/Operator.php | 48 +- src/Database/Validator/Structure.php | 4 - tests/e2e/Adapter/Base.php | 13 +- tests/e2e/Adapter/MemoryTest.php | 7 - tests/e2e/Adapter/MongoDBTest.php | 7 - tests/e2e/Adapter/PostgresTest.php | 7 - tests/e2e/Adapter/RedisTest.php | 7 - tests/e2e/Adapter/SQLiteTest.php | 7 - tests/e2e/Adapter/Schemaless/MongoDBTest.php | 7 - tests/e2e/Adapter/Scopes/DocumentTests.php | 45 +- .../Adapter/Scopes/FractionalIntegerTests.php | 343 ------ .../Adapter/Scopes/IntegerRoundingTests.php | 192 +++ tests/e2e/Adapter/Scopes/OperatorTests.php | 22 +- .../e2e/Adapter/SharedTables/MongoDBTest.php | 7 - .../e2e/Adapter/SharedTables/PostgresTest.php | 7 - tests/e2e/Adapter/SharedTables/SQLiteTest.php | 7 - tests/unit/Adapter/ArithmeticTest.php | 170 --- .../Adapter/ReadWritePoolMetadataTest.php | 4 - tests/unit/Adapter/ReadWritePoolTest.php | 2 - tests/unit/Adapter/RedisAdapterPathsTest.php | 12 +- tests/unit/Arithmetic/DecimalTest.php | 157 --- tests/unit/Arithmetic/FractionalTest.php | 501 -------- tests/unit/Arithmetic/RoundingTest.php | 83 -- tests/unit/Documents/FractionalBoundTest.php | 200 --- .../Documents/FractionalOperatorLimitTest.php | 87 -- tests/unit/Documents/IntegerRoundingTest.php | 330 +++++ tests/unit/Documents/RoundFractionsTest.php | 1091 ----------------- tests/unit/Support/FractionalEngine.php | 15 - tests/unit/Support/FractionalResults.php | 338 ----- .../unit/Validator/OperatorFractionsTest.php | 87 -- 47 files changed, 708 insertions(+), 4521 deletions(-) delete mode 100644 src/Database/Arithmetic.php delete mode 100644 src/Database/Arithmetic/Decimal.php delete mode 100644 src/Database/Arithmetic/Fractional.php delete mode 100644 src/Database/Arithmetic/Rounding.php delete mode 100644 src/Database/Trait/Fractions.php delete mode 100644 tests/e2e/Adapter/Scopes/FractionalIntegerTests.php create mode 100644 tests/e2e/Adapter/Scopes/IntegerRoundingTests.php delete mode 100644 tests/unit/Adapter/ArithmeticTest.php delete mode 100644 tests/unit/Arithmetic/DecimalTest.php delete mode 100644 tests/unit/Arithmetic/FractionalTest.php delete mode 100644 tests/unit/Arithmetic/RoundingTest.php delete mode 100644 tests/unit/Documents/FractionalBoundTest.php delete mode 100644 tests/unit/Documents/FractionalOperatorLimitTest.php create mode 100644 tests/unit/Documents/IntegerRoundingTest.php delete mode 100644 tests/unit/Documents/RoundFractionsTest.php delete mode 100644 tests/unit/Support/FractionalEngine.php delete mode 100644 tests/unit/Support/FractionalResults.php delete mode 100644 tests/unit/Validator/OperatorFractionsTest.php diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index a62620729..769bba3ea 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -5,7 +5,6 @@ use Exception; use Throwable; use Utopia\Database\Adapter\Limits; -use Utopia\Database\Arithmetic\Rounding; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Contention as ContentionException; use Utopia\Database\Exception\Duplicate as DuplicateException; @@ -812,21 +811,6 @@ abstract public function getSequences(Document $collection, array $documents): a abstract public function limits(): Limits; - /** - * How this adapter's engine stored a fractional result in an integer column on database 7.4.1. MariaDB and - * MySQL computed a fractional operand in DOUBLE, rounded half to even, and a division by a whole number in - * exact DECIMAL, rounded half away from zero; an adapter that had no 7.4.1 engine answers the same. - */ - public function arithmetic(): Arithmetic - { - return new Arithmetic( - counter: Rounding::HalfEven, - operators: Rounding::HalfEven, - division: Rounding::HalfAwayFromZero, - power: Rounding::HalfEven, - ); - } - /** * Get Collection Size of the raw data * diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index 654c91779..071f1fd2c 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -3,8 +3,6 @@ namespace Utopia\Database\Adapter; use Utopia\Database\Adapter; -use Utopia\Database\Arithmetic; -use Utopia\Database\Arithmetic\Rounding; use Utopia\Database\Attribute; use Utopia\Database\Capability; use Utopia\Database\Database; @@ -1958,22 +1956,6 @@ public function getSizeOfCollectionOnDisk(string $collection): int return $this->getSizeOfCollection($collection); } - /** - * Memory stored a fractional result as a float and every read cast it to an integer, cutting it toward zero; - * a fractional modulus was cut toward zero before the remainder was taken. - */ - #[\Override] - public function arithmetic(): Arithmetic - { - return new Arithmetic( - counter: Rounding::TowardZero, - operators: Rounding::TowardZero, - division: Rounding::TowardZero, - power: Rounding::TowardZero, - truncatesModulus: true, - ); - } - /** * No index is bounded by bytes here, but index validation subtracts from the index length, so it is * Mongo's positive cap. diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 9b4a717b8..1f87731d6 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -12,8 +12,6 @@ use Swoole\Coroutine; use Throwable; use Utopia\Database\Adapter; -use Utopia\Database\Arithmetic; -use Utopia\Database\Arithmetic\Rounding; use Utopia\Database\Attribute; use Utopia\Database\Capability; use Utopia\Database\Change; @@ -2914,20 +2912,6 @@ private function sequenceKey(mixed $tenant, string $id): string return $tenant."\0".$id; } - /** - * MongoDB stored a fractional result as a double, and every read cast it to an integer, cutting it toward zero. - */ - #[\Override] - public function arithmetic(): Arithmetic - { - return new Arithmetic( - counter: Rounding::TowardZero, - operators: Rounding::TowardZero, - division: Rounding::TowardZero, - power: Rounding::TowardZero, - ); - } - /** * Collections hold any number of attributes and documents of any width, so both caps are 0. */ diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 2878360d4..4b2c66301 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -5,7 +5,6 @@ use Throwable; use Utopia\Database\Adapter; use Utopia\Database\Adapter\SQL\Wkt; -use Utopia\Database\Arithmetic; use Utopia\Database\Attribute; use Utopia\Database\Builder\Scoping; use Utopia\Database\Capability; @@ -69,11 +68,6 @@ class Pool extends Adapter implements Feature\Timeouts */ private static ?\WeakMap $features = null; - /** - * @var \WeakMap, Arithmetic>|null - */ - private static ?\WeakMap $arithmetic = null; - /** * @var \WeakMap, array>|null */ @@ -906,22 +900,6 @@ public function getSizeOfCollectionOnDisk(string $collection): int return $result; } - #[\Override] - public function arithmetic(): Arithmetic - { - $remembered = self::$arithmetic[$this->pool] ?? null; - if ($remembered !== null) { - return $remembered; - } - - /** @var Arithmetic $result */ - $result = $this->delegate(__FUNCTION__, \func_get_args()); - self::$arithmetic ??= new \WeakMap(); - self::$arithmetic[$this->pool] = $result; - - return $result; - } - #[\Override] public function limits(): Limits { diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index f1d8cfb4c..7f8d5757a 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -11,8 +11,6 @@ use Utopia\Database\Adapter\SQL\Expression; use Utopia\Database\Adapter\SQL\Hook\Permission; use Utopia\Database\Adapter\SQL\Wkt; -use Utopia\Database\Arithmetic; -use Utopia\Database\Arithmetic\Rounding; use Utopia\Database\Attribute; use Utopia\Database\Builder\Postgres as PostgresBuilder; use Utopia\Database\Builder\Scoping; @@ -75,25 +73,6 @@ class Postgres extends SQL implements Feature\Spatial, Feature\Timeouts ', ' => ',', ]; - /** - * PostgreSQL computed in NUMERIC, rounding half away from zero, except POWER() in double precision, rounded half - * to even. Next to an integer column it read a bound amount as an integer, so it refused a fractional counter - * change, and a fractional increment, decrement, multiplication or division without a limit. - */ - #[\Override] - public function arithmetic(): Arithmetic - { - return new Arithmetic( - counter: Rounding::HalfEven, - operators: Rounding::HalfAwayFromZero, - division: Rounding::HalfAwayFromZero, - power: Rounding::HalfEven, - refusesCounterFractions: true, - exactOperators: [OperatorType::Multiply, OperatorType::Divide, OperatorType::Modulo], - refusedWithoutLimit: [OperatorType::Increment, OperatorType::Decrement, OperatorType::Multiply, OperatorType::Divide], - ); - } - /** * @return array */ diff --git a/src/Database/Adapter/ReadWritePool.php b/src/Database/Adapter/ReadWritePool.php index f071a6c6d..18016ea19 100644 --- a/src/Database/Adapter/ReadWritePool.php +++ b/src/Database/Adapter/ReadWritePool.php @@ -31,7 +31,6 @@ class ReadWritePool extends Pool * the sticky window, so a call that writes, or must see the latest write, never belongs here. */ final protected const array METADATA_METHODS = [ - 'arithmetic', 'supports', 'capabilities', 'hasFeature', diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index 786de9eb8..3935445bd 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -7,8 +7,6 @@ use Redis as RedisClient; use Utopia\Database\Adapter; use Utopia\Database\Adapter\Redis\Write; -use Utopia\Database\Arithmetic; -use Utopia\Database\Arithmetic\Rounding; use Utopia\Database\Attribute; use Utopia\Database\Capability; use Utopia\Database\Change; @@ -1632,22 +1630,6 @@ public function increaseDocumentAttribute(Document $collection, string $id, stri }); } - /** - * Redis stored a fractional result as a float and every read cast it to an integer, cutting it toward zero; a - * fractional modulus was cut toward zero before the remainder was taken. - */ - #[\Override] - public function arithmetic(): Arithmetic - { - return new Arithmetic( - counter: Rounding::TowardZero, - operators: Rounding::TowardZero, - division: Rounding::TowardZero, - power: Rounding::TowardZero, - truncatesModulus: true, - ); - } - #[\Override] public function limits(): Limits { diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index e1dc81f70..f198125b2 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -13,8 +13,6 @@ use Utopia\Database\Adapter\SQL\Expression; use Utopia\Database\Adapter\SQL\Hook\Permission; use Utopia\Database\Adapter\SQL\JoinAlias; -use Utopia\Database\Arithmetic; -use Utopia\Database\Arithmetic\Rounding; use Utopia\Database\Attribute; use Utopia\Database\Builder\Scoping; use Utopia\Database\Builder\SQLite as SQLiteBuilder; @@ -146,24 +144,6 @@ public function reconnect(): void $this->registerUserFunctions(); } - /** - * SQLite kept a fractional result as a REAL in the INTEGER column and every read cast it to an integer, cutting - * it toward zero, as it cut the quotient of two integers; % cast a fractional modulus to an integer first. Its - * comparison of a fractional operator limit as text, which never held a maximum and always held a minimum, is - * not reproduced: limits hold as on Memory and Redis. - */ - #[\Override] - public function arithmetic(): Arithmetic - { - return new Arithmetic( - counter: Rounding::TowardZero, - operators: Rounding::TowardZero, - division: Rounding::TowardZero, - power: Rounding::TowardZero, - truncatesModulus: true, - ); - } - /** * SQLite has no server-side connection id, so the handle's object id names the connection: unique only within * the process and only while the handle lives. diff --git a/src/Database/Arithmetic.php b/src/Database/Arithmetic.php deleted file mode 100644 index 7fa34ce88..000000000 --- a/src/Database/Arithmetic.php +++ /dev/null @@ -1,43 +0,0 @@ - $exactOperators - * @param list $refusedWithoutLimit - */ - public function __construct( - public Rounding $counter, - public Rounding $operators, - public Rounding $division, - public Rounding $power, - public bool $refusesCounterFractions = false, - public array $exactOperators = [], - public array $refusedWithoutLimit = [], - public bool $truncatesModulus = false, - ) { - } - - /** - * The rounding of an operator's result, by its method and whether its operand is a whole number. - */ - public function rounding(OperatorType $method, bool $whole): Rounding - { - return match (true) { - $method === OperatorType::Power => $this->power, - $method === OperatorType::Divide && $whole => $this->division, - default => $this->operators, - }; - } -} diff --git a/src/Database/Arithmetic/Decimal.php b/src/Database/Arithmetic/Decimal.php deleted file mode 100644 index 2a90173ec..000000000 --- a/src/Database/Arithmetic/Decimal.php +++ /dev/null @@ -1,216 +0,0 @@ - \strlen(BigInt::UNSIGNED_MAX)) { - return new self($negative, self::SATURATED, false); - } - - $whole = $point > 0 ? \str_pad(\substr($digits, 0, $point), $point, '0') : '0'; - $fractional = $point <= 0 || \ltrim(\substr($digits, $point), '0') !== ''; - - return new self($negative, $whole, $fractional); - } - - public function floor(): int|string - { - return $this->fractional && $this->negative - ? BigInt::subtract($this->truncated(), 1) - : BigInt::toNative($this->truncated()); - } - - public function ceil(): int|string - { - return $this->fractional && ! $this->negative - ? BigInt::add($this->truncated(), 1) - : BigInt::toNative($this->truncated()); - } - - /** - * The integer a whole number writes; null for a number with a fraction. - */ - public function integer(): int|string|null - { - return $this->fractional ? null : BigInt::toNative($this->truncated()); - } - - /** - * A number as an integer numerator and the power of ten that divides it, from its text; null for text that is - * not numeric, a float that is not finite, a number whose leading digit is more than MAXIMUM_DIGITS powers of - * ten from the units, or one with more than MAXIMUM_SIGNIFICANT_DIGITS significant digits. - * - * @return array{int|string, int}|null - */ - public static function fraction(int|float|string $value): ?array - { - if ((\is_string($value) && ! \is_numeric($value)) || (\is_float($value) && ! \is_finite($value))) { - return null; - } - - $order = self::order($value); - if ($order === null) { - return [0, 0]; - } - if (\abs($order) > self::MAXIMUM_DIGITS || self::isOverlong($value)) { - return null; - } - - [$mantissa, $exponent] = self::split($value); - [$whole, $fraction] = \explode('.', $mantissa, 2) + [1 => '']; - $fraction = \rtrim($fraction, '0'); - $scale = \strlen($fraction) - (int) $exponent; - $digits = \ltrim($whole, '+') . $fraction; - - if ($scale < 0) { - $digits .= \str_repeat('0', -$scale); - $scale = 0; - } - - return [BigInt::toNative(BigInt::normalizeInteger($digits)), $scale]; - } - - /** - * Whether a number carries more significant digits than fraction() reads. - */ - public static function isOverlong(int|float|string $value): bool - { - [$mantissa] = self::split($value); - - return \strlen(\trim(\str_replace('.', '', \ltrim($mantissa, '+-')), '0')) > self::MAXIMUM_SIGNIFICANT_DIGITS; - } - - public static function isNegative(int|float|string $value): bool - { - return \str_starts_with(\ltrim((string) $value, self::WHITESPACE), '-'); - } - - /** - * Whether a number fraction() leaves out is closer to zero than any result rather than beyond every result. - */ - public static function isTiny(int|float|string $value): bool - { - $order = self::order($value); - - return $order !== null && $order < -self::MAXIMUM_DIGITS; - } - - /** - * The power of ten of a number's leading digit (2 for 123, -3 for 0.001), from its text; null for zero. An - * exponent too long to read as an integer saturates. - */ - public static function order(int|float|string $value): ?int - { - [$mantissa, $exponent] = self::split($value); - [$whole, $fraction] = \explode('.', \ltrim($mantissa, '+-'), 2) + [1 => '']; - if (\ltrim($whole . $fraction, '0') === '') { - return null; - } - - $magnitude = \ltrim(\ltrim($exponent, '+-'), '0'); - if (\strlen($magnitude) > self::MAXIMUM_EXPONENT_DIGITS) { - return \str_starts_with($exponent, '-') ? \PHP_INT_MIN : \PHP_INT_MAX; - } - - $significant = \ltrim($whole, '0'); - $leading = $significant !== '' ? \strlen($significant) - 1 : -(\strspn($fraction, '0') + 1); - - return $leading + (int) $exponent; - } - - /** - * @return array{string, string} - */ - private static function split(int|float|string $value): array - { - $parts = \explode('e', \strtolower(\trim((string) $value, self::WHITESPACE)), 2); - - return [$parts[0], $parts[1] ?? '0']; - } - - private function truncated(): string - { - return ($this->negative ? '-' : '') . $this->whole; - } - - private static function unsigned(string $value): string - { - return \str_starts_with($value, '+') || \str_starts_with($value, '-') ? \substr($value, 1) : $value; - } - - private static function isDigits(string $value): bool - { - return $value === '' || \ctype_digit($value); - } - - private static function exponent(string $exponent, int $limit): int - { - $digits = \ltrim($exponent, '+-0'); - $magnitude = \strlen($digits) > \strlen((string) $limit) ? $limit : \min((int) $digits, $limit); - - return \str_starts_with($exponent, '-') ? -$magnitude : $magnitude; - } -} diff --git a/src/Database/Arithmetic/Fractional.php b/src/Database/Arithmetic/Fractional.php deleted file mode 100644 index 63a1c5c17..000000000 --- a/src/Database/Arithmetic/Fractional.php +++ /dev/null @@ -1,411 +0,0 @@ - \is_finite($value) && \floor($value) !== $value, - \is_string($value) && \is_numeric($value) => ! BigInt::isIntegerString($value) && \floor((float) $value) !== (float) $value, - default => false, - }; - } - - /** - * Whether a value is an integer, integer text or a finite float without a fractional part. - */ - public static function isWhole(int|float|string $value): bool - { - return match (true) { - \is_int($value) => true, - \is_string($value) => BigInt::isIntegerString($value), - default => \is_finite($value) && \floor($value) === $value, - }; - } - - /** - * The integer a whole number writes: an integer, a finite float without a fractional part, or numeric text - * such as 1e3; null for anything else. - */ - public static function wholeValue(int|float|string $value): int|string|null - { - return BigInt::integralValue($value) - ?? (\is_string($value) && \is_numeric($value) ? Decimal::parse($value)?->integer() : null); - } - - /** - * Whether operator() needs the stored value: a numeric operator with a fractional amount or limit. - */ - public static function needsStored(Operator $operator): bool - { - if (! $operator->isNumericOperation()) { - return false; - } - - foreach (\array_slice($operator->getValues(), 0, 2) as $value) { - if (self::isFractional($value)) { - return true; - } - } - - return false; - } - - /** - * The whole-number increment or decrement that stores what the engine stored for $operator applied to - * $stored, or an increment by 0 where its limit held the value back. The operator comes back unchanged where - * the engine refused it, an operand or limit is not a finite number, or the result is not finite, so the caller - * refuses it. - */ - public function operator(Operator $operator, int|float|string|null $stored): Operator - { - $method = $operator->getMethod(); - $values = $operator->getValues(); - $amount = self::number($values[0] ?? 1); - $limit = isset($values[1]) ? self::number($values[1]) : null; - $current = self::integer($stored ?? 0); - - if (! $method->isNumeric() || $current === null || $amount === null || ($limit === null && isset($values[1]))) { - return $operator; - } - - $whole = ! self::isFractional($amount); - if ($method === OperatorType::Modulo && ! $whole && $this->arithmetic->truncatesModulus) { - $modulus = self::integer($amount); - - return $modulus === null || BigInt::compare($modulus, 0) === 0 - ? $operator - : self::change($operator, BigInt::subtract(BigInt::modulo($current, $modulus), $current)); - } - if ($limit === null && ! $whole && \in_array($method, $this->arithmetic->refusedWithoutLimit, true)) { - return $operator; - } - - $rounding = $this->arithmetic->rounding($method, $whole); - if (\in_array($method, $this->arithmetic->exactOperators, true)) { - $exact = self::rational($method, $current, $amount); - if ($exact === null || ($limit !== null && Decimal::isOverlong($limit))) { - return $operator; - } - if ($limit !== null && self::isBeyondRational($method, $exact, $amount, $limit)) { - return self::change($operator, 0); - } - $stores = $rounding->ratio(...$exact); - } else { - $exact = self::compute($method, (float) $current, (float) $amount); - if ($limit !== null && (self::isUndefinedPower($method, $current, $amount) || self::isBeyond($method, $exact, (float) $amount, (float) $limit))) { - return self::change($operator, 0); - } - if (! \is_finite($exact)) { - return $operator; - } - $stores = \in_array($method, self::SHIFTS, true) - ? self::shift($rounding, $current, (float) $amount, $method === OperatorType::Increment) - : $rounding->round($exact); - } - - return self::change($operator, BigInt::subtract($stores, $current)); - } - - /** - * The whole-number change, never negative, that stores $stored plus or minus $value as the engine stored a - * counter change. $stored never passes through a float; only the fraction of $value decides the rounding. - * - * @throws TypeException when $value is not a number greater than 0, or is fractional and the engine refused - * a fractional counter change - */ - public function counter(int|string $stored, int|float|string $value, bool $increase): int|string - { - if (! \is_numeric($value) || (BigInt::isIntegerValue($value) ? BigInt::compare($value, 0) <= 0 : (float) $value <= 0)) { - throw new TypeException('Value must be numeric and greater than 0'); - } - - $whole = self::wholeValue($value); - if ($whole !== null) { - return $whole; - } - - if (! self::isFractional($value) || $this->arithmetic->refusesCounterFractions) { - throw new TypeException('Change value must be an integer.'); - } - - $value = (float) $value; - $fraction = $value - \floor($value); - $wholePart = self::integral(\floor($value)); - $result = $increase - ? $this->arithmetic->counter->apply(BigInt::add($stored, $wholePart), $fraction) - : $this->arithmetic->counter->apply(BigInt::subtract(BigInt::subtract($stored, $wholePart), 1), 1 - $fraction); - - return BigInt::compare($result, $stored) >= 0 ? BigInt::subtract($result, $stored) : BigInt::subtract($stored, $result); - } - - /** - * Whether $stored plus or minus $value passes $bound, compared exactly at any size. A number Decimal::fraction() - * leaves out is compared in floating point. - */ - public static function exceeds(int|string $stored, int|float|string $value, int|float|string $bound, bool $increase): bool - { - $change = Decimal::fraction($value); - $limit = Decimal::fraction($bound); - if ($change === null || $limit === null) { - $result = $increase ? (float) $stored + (float) $value : (float) $stored - (float) $value; - - return $increase ? $result > (float) $bound : $result < (float) $bound; - } - - [$amount, $scale] = $change; - [$boundary, $boundaryScale] = $limit; - $scaled = BigInt::multiply($stored, self::powerOfTen($scale)); - $result = $increase ? BigInt::add($scaled, $amount) : BigInt::subtract($scaled, $amount); - $comparison = BigInt::compare( - BigInt::multiply($result, self::powerOfTen($boundaryScale)), - BigInt::multiply($boundary, self::powerOfTen($scale)), - ); - - return $increase ? $comparison > 0 : $comparison < 0; - } - - /** - * A maximum on an integer column rounded down to the whole number it allows. Integer text and anything that - * is not a finite number come back unchanged. - */ - public static function maximum(int|float|string|null $bound): int|float|string|null - { - return match (true) { - \is_string($bound) => self::decimal($bound)?->floor() ?? $bound, - \is_float($bound) && \is_finite($bound) => self::integral(\floor($bound)), - default => $bound, - }; - } - - /** - * A minimum on an integer column rounded up to the whole number it allows. Integer text and anything that - * is not a finite number come back unchanged. - */ - public static function minimum(int|float|string|null $bound): int|float|string|null - { - return match (true) { - \is_string($bound) => self::decimal($bound)?->ceil() ?? $bound, - \is_float($bound) && \is_finite($bound) => self::integral(\ceil($bound)), - default => $bound, - }; - } - - /** - * An operator adding items to an integer array, with each fractional item cut toward zero as every engine - * read it back. - */ - public static function whole(Operator $operator): Operator - { - $values = $operator->getValues(); - - return match ($operator->getMethod()) { - OperatorType::ArrayAppend, OperatorType::ArrayPrepend => (clone $operator)->setValues(\array_map( - static fn (mixed $value): mixed => \is_array($value) ? \array_map(self::truncate(...), $value) : self::truncate($value), - $values, - )), - OperatorType::ArrayInsert => \array_key_exists(1, $values) - ? (clone $operator)->setValues([$values[0], self::truncate($values[1])]) - : $operator, - default => $operator, - }; - } - - private static function truncate(mixed $item): mixed - { - return (\is_float($item) || \is_string($item)) && self::isFractional($item) ? (int) (float) $item : $item; - } - - private static function change(Operator $operator, int|string $change): Operator - { - return BigInt::compare($change, 0) >= 0 - ? new Operator(OperatorType::Increment, $operator->getAttribute(), [$change]) - : new Operator(OperatorType::Decrement, $operator->getAttribute(), [BigInt::negate($change)]); - } - - private static function number(mixed $value): int|float|string|null - { - return \is_int($value) || (\is_float($value) && \is_finite($value)) || (\is_string($value) && \is_numeric($value)) ? $value : null; - } - - /** - * A stored integer, or a stored fraction cut toward zero as every read of it was; null for anything else. - */ - private static function integer(int|float|string $stored): int|string|null - { - if (\is_string($stored) && ! \is_numeric($stored)) { - return null; - } - - return BigInt::integralValue($stored) ?? (self::isFractional($stored) ? self::integral((float) $stored < 0 ? \ceil((float) $stored) : \floor((float) $stored)) : null); - } - - private static function compute(OperatorType $method, float $current, float $amount): float - { - return match ($method) { - OperatorType::Increment => $current + $amount, - OperatorType::Decrement => $current - $amount, - OperatorType::Multiply => $current * $amount, - OperatorType::Divide => $amount === 0.0 ? \NAN : $current / $amount, - OperatorType::Modulo => $amount === 0.0 ? \NAN : \fmod($current, $amount), - OperatorType::Power => $current === 0.0 && $amount < 0 ? \NAN : $current ** $amount, - default => \NAN, - }; - } - - /** - * The exact result as a numerator and a positive denominator, or null for a division or modulo by zero or an - * operand too long to write out. - * - * @return array{int|string, int|string}|null - */ - private static function rational(OperatorType $method, int|string $current, int|float|string $amount): ?array - { - $decimal = Decimal::fraction($amount); - if ($decimal === null) { - return Decimal::isTiny($amount) && $method !== OperatorType::Divide ? [0, 1] : null; - } - [$numerator, $scale] = $decimal; - $power = self::powerOfTen($scale); - - if ($method === OperatorType::Multiply) { - return [BigInt::multiply($current, $numerator), $power]; - } - if (BigInt::compare($numerator, 0) === 0) { - return null; - } - if ($method === OperatorType::Modulo) { - return [BigInt::modulo(BigInt::multiply($current, $power), $numerator), $power]; - } - - $dividend = BigInt::multiply($current, $power); - - return BigInt::compare($numerator, 0) < 0 - ? [BigInt::negate($dividend), BigInt::negate($numerator)] - : [$dividend, $numerator]; - } - - /** - * @param array{int|string, int|string} $exact - */ - private static function isBeyondRational(OperatorType $method, array $exact, int|float|string $amount, int|float|string $limit): bool - { - $factor = Decimal::fraction($amount); - if ($factor === null || BigInt::compare($factor[0], 0) === 0) { - return false; - } - - [$numerator, $denominator] = $exact; - $bound = Decimal::fraction($limit); - $comparison = $bound === null - ? self::compareWithOversized($numerator, $limit) - : BigInt::compare( - BigInt::multiply($numerator, self::powerOfTen($bound[1])), - BigInt::multiply($bound[0], $denominator), - ); - - return match (true) { - \in_array($method, self::LIMITED_BY_MAXIMUM, true) => $comparison > 0, - \in_array($method, self::LIMITED_BY_MINIMUM, true) => $comparison < 0, - default => false, - }; - } - - /** - * How a result compares with a limit Decimal::fraction() leaves out: one beyond every result in magnitude, or - * one closer to zero than any of them but zero. - */ - private static function compareWithOversized(int|string $numerator, int|float|string $limit): int - { - $negative = Decimal::isNegative($limit); - if (! Decimal::isTiny($limit)) { - return $negative ? 1 : -1; - } - - $sign = BigInt::compare($numerator, 0); - - return $sign !== 0 ? $sign : ($negative ? 1 : -1); - } - - private static function isBeyond(OperatorType $method, float $exact, float $amount, float $limit): bool - { - return match (true) { - $method === OperatorType::Multiply && $amount === 0.0, $method === OperatorType::Divide && $amount === 0.0 => false, - \in_array($method, self::LIMITED_BY_MAXIMUM, true) => $exact > $limit, - \in_array($method, self::LIMITED_BY_MINIMUM, true) => $exact < $limit, - default => false, - }; - } - - /** - * Where a limit came with it, 7.4.1 left the value as it was for a power without a value: zero to a negative - * power, or a negative base to a fractional one. - */ - private static function isUndefinedPower(OperatorType $method, int|string $current, int|float|string $amount): bool - { - return $method === OperatorType::Power - && ((BigInt::compare($current, 0) === 0 && (float) $amount < 0) || (BigInt::compare($current, 0) < 0 && self::isFractional($amount))); - } - - /** - * The stored value plus or minus a fractional amount, exact at any size of the stored value. - */ - private static function shift(Rounding $rounding, int|string $current, float $amount, bool $increase): int|string - { - $whole = self::integral(\floor($amount)); - $fraction = $amount - \floor($amount); - - if ($increase) { - return $rounding->apply(BigInt::add($current, $whole), $fraction); - } - - return $fraction === 0.0 - ? BigInt::subtract($current, $whole) - : $rounding->apply(BigInt::subtract(BigInt::subtract($current, $whole), 1), 1 - $fraction); - } - - private static function decimal(string $bound): ?Decimal - { - return BigInt::isIntegerString($bound) ? null : Decimal::parse($bound); - } - - private static function powerOfTen(int $exponent): int|string - { - return BigInt::toNative('1' . \str_repeat('0', $exponent)); - } - - private static function integral(float $whole): int|string - { - return BigInt::toNative(\sprintf('%.0f', $whole)); - } -} diff --git a/src/Database/Arithmetic/Rounding.php b/src/Database/Arithmetic/Rounding.php deleted file mode 100644 index df1c31db9..000000000 --- a/src/Database/Arithmetic/Rounding.php +++ /dev/null @@ -1,69 +0,0 @@ - $fraction > 0.5 || ($fraction === 0.5 && self::isOdd($base)), - self::HalfAwayFromZero => $fraction > 0.5 || ($fraction === 0.5 && BigInt::compare($base, 0) >= 0), - self::TowardZero => BigInt::compare($base, 0) < 0, - }; - - return $up ? BigInt::add($base, 1) : $base; - } - - /** - * The whole number stored for a fractional result computed in floating point. - */ - public function round(float $value): int|string - { - $floor = \floor($value); - - return $this->apply(BigInt::toNative(\sprintf('%.0f', $floor)), $value - $floor); - } - - /** - * The whole number stored for $numerator / $denominator, computed exactly; $denominator is positive. - */ - public function ratio(int|string $numerator, int|string $denominator): int|string - { - $quotient = BigInt::divide($numerator, $denominator); - $remainder = BigInt::modulo($numerator, $denominator); - if (BigInt::compare($remainder, 0) === 0 || $this === self::TowardZero) { - return $quotient; - } - - $half = BigInt::compare(BigInt::multiply(\ltrim((string) $remainder, '-'), 2), $denominator); - $away = $half > 0 || ($half === 0 && ($this === self::HalfAwayFromZero || self::isOdd($quotient))); - if (! $away) { - return $quotient; - } - - return BigInt::compare($numerator, 0) < 0 ? BigInt::subtract($quotient, 1) : BigInt::add($quotient, 1); - } - - private static function isOdd(int|string $value): bool - { - return (int) \substr((string) $value, -1) % 2 === 1; - } -} diff --git a/src/Database/Database.php b/src/Database/Database.php index 02b425500..1d03e15ce 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -56,7 +56,6 @@ class Database use Trait\Cursors; use Trait\Databases; use Trait\Documents; - use Trait\Fractions; use Trait\Indexes; use Trait\Relationships; use Trait\Transactions; @@ -276,9 +275,6 @@ class Database /** @var Value|null */ private ?Value $grantChecking = null; - /** @var Value|null */ - private ?Value $fractionRounding = null; - protected ?Relationships $relationshipHook = null; protected bool $dropUnknownAttributes = false; @@ -1205,42 +1201,6 @@ public function withCheckGrants(bool $check, callable $callback): mixed return $this->grantChecking()->with($check, $callback); } - /** - * Set whether a fractional amount or limit on a single-valued integer attribute is stored as the adapter's - * engine stored it on database 7.4.1 (see Adapter::arithmetic()), instead of being refused. This covers - * increaseDocumentAttribute() and decreaseDocumentAttribute(), whose float change returns its exact result, and - * the numeric operators given to updateDocument(), updateDocuments(), upsertDocument() and upsertDocuments(), - * each answered from the stored value it changes, read locked in the write's transaction. Fractional items - * added to an integer array are cut toward zero. What the engine refused stays refused. - * - * @return $this - */ - public function setRoundFractions(bool $round): static - { - $this->fractionRounding()->set($round); - - return $this; - } - - public function isRoundingFractions(): bool - { - return $this->fractionRounding()->get(); - } - - /** - * Run the callback with fractions on integer attributes rounded or refused for the calling coroutine and the - * coroutines it starts. - * - * @template T - * - * @param callable(): T $callback - * @return T - */ - public function withRoundFractions(bool $round, callable $callback): mixed - { - return $this->fractionRounding()->with($round, $callback); - } - /** * Set the migration mode flag, which relaxes certain constraints during data migrations. * @@ -1654,7 +1614,6 @@ public function snapshot(): Snapshot ignoreDuplicates: $this->ignoringDuplicates()->get(), requestTimestamp: $this->requestTimestamp()->get(), checkGrants: $this->grantChecking()->get(), - roundFractions: $this->fractionRounding()->get(), ); } @@ -1695,8 +1654,7 @@ public function withSnapshot(Snapshot $snapshot, callable $callback): mixed */ private function withToggles(Snapshot $snapshot, callable $callback): mixed { - $rounded = fn (): mixed => $this->fractionRounding()->with($snapshot->roundFractions, $callback); - $checked = fn (): mixed => $this->grantChecking()->with($snapshot->checkGrants, $rounded); + $checked = fn (): mixed => $this->grantChecking()->with($snapshot->checkGrants, $callback); $timestamped = fn (): mixed => $this->requestTimestamp()->with($snapshot->requestTimestamp, $checked); $deduplicated = fn (): mixed => $this->ignoringDuplicates()->with($snapshot->ignoreDuplicates, $timestamped); $sequenced = fn (): mixed => $this->sequencePreservation()->with($snapshot->preserveSequence, $deduplicated); @@ -1784,14 +1742,6 @@ private function grantChecking(): Value return $this->grantChecking ??= new Value(false); } - /** - * @return Value - */ - private function fractionRounding(): Value - { - return $this->fractionRounding ??= new Value(false); - } - protected function isIgnoringDuplicates(): bool { return $this->ignoringDuplicates()->get(); diff --git a/src/Database/Mirror.php b/src/Database/Mirror.php index f54529a94..615dadf0d 100644 --- a/src/Database/Mirror.php +++ b/src/Database/Mirror.php @@ -495,14 +495,6 @@ public function setCheckGrants(bool $check): static return parent::setCheckGrants($check); } - #[\Override] - public function setRoundFractions(bool $round): static - { - $this->delegate(__FUNCTION__, \func_get_args()); - - return parent::setRoundFractions($round); - } - #[\Override] public function setValidation(bool $validation): static { @@ -557,15 +549,6 @@ public function withCheckGrants(bool $check, callable $callback): mixed return parent::withCheckGrants($check, fn (): mixed => $this->source->withCheckGrants($check, $callback)); } - #[\Override] - public function withRoundFractions(bool $round, callable $callback): mixed - { - $destination = $this->destination; - $scoped = $destination === null ? $callback : fn (): mixed => $destination->withRoundFractions($round, $callback); - - return parent::withRoundFractions($round, fn (): mixed => $this->source->withRoundFractions($round, $scoped)); - } - #[\Override] public function skipRelationships(callable $callback): mixed { diff --git a/src/Database/State/Snapshot.php b/src/Database/State/Snapshot.php index aa45b9391..d477ee2ed 100644 --- a/src/Database/State/Snapshot.php +++ b/src/Database/State/Snapshot.php @@ -28,7 +28,6 @@ * @param bool $ignoreDuplicates Whether creating a document that exists is skipped instead of failing * @param DateTime|null $requestTimestamp The time an update conflicts after * @param bool $checkGrants Whether a related document may only be given permissions for roles the caller holds - * @param bool $roundFractions Whether a fractional amount or limit on an integer attribute is stored as the engine rounded it */ public function __construct( public bool $authorization, @@ -47,7 +46,6 @@ public function __construct( public bool $ignoreDuplicates, public ?DateTime $requestTimestamp, public bool $checkGrants = false, - public bool $roundFractions = false, ) { } } diff --git a/src/Database/Trait/Documents.php b/src/Database/Trait/Documents.php index 84af3c9b6..391ccef30 100644 --- a/src/Database/Trait/Documents.php +++ b/src/Database/Trait/Documents.php @@ -12,7 +12,6 @@ use Utopia\Database\Adapter; use Utopia\Database\Adapter\Feature; use Utopia\Database\Adapter\ReadWritePool; -use Utopia\Database\Arithmetic\Fractional; use Utopia\Database\Attribute; use Utopia\Database\Cache\Epoch; use Utopia\Database\Cache\Owners; @@ -42,6 +41,7 @@ use Utopia\Database\Hook\Preparation; use Utopia\Database\Id; use Utopia\Database\Operator; +use Utopia\Database\OperatorType; use Utopia\Database\PermissionType; use Utopia\Database\Query; use Utopia\Database\RelationshipSide; @@ -283,6 +283,107 @@ private function integerBound(int|float|string $bound, string $name): int|string return BigInt::integralValue($bound) ?? throw new TypeException($name.' must be an integer.'); } + /** + * An integer attribute takes a fractional increment or decrement amount rounded half away from zero, a + * fractional maximum floored and a fractional minimum ceiled. + */ + private function roundOperators(Collection $collection, Document $document): void + { + foreach ($collection->attributes() as $attribute) { + $operator = $document->getAttribute($attribute->key); + if (! $operator instanceof Operator || ! $this->isDeclaredInteger($attribute)) { + continue; + } + + $roundLimit = match ($operator->getMethod()) { + OperatorType::Increment => self::roundMaximum(...), + OperatorType::Decrement => self::roundMinimum(...), + default => null, + }; + if ($roundLimit === null) { + continue; + } + + $values = $operator->getValues(); + if (\array_key_exists(0, $values)) { + $values[0] = self::roundAmount($values[0]); + } + if (isset($values[1])) { + $values[1] = $roundLimit($values[1]); + } + $document->setAttribute($attribute->key, (clone $operator)->setValues($values)); + } + } + + /** + * @template T + * + * @param T $value + * @return T|int|string + */ + private static function roundAmount(mixed $value): mixed + { + return self::wholeNumber($value, static fn (bool $negative, bool $half, bool $fractional): bool => $half); + } + + /** + * @template T + * + * @param T $value + * @return T|int|string + */ + private static function roundMaximum(mixed $value): mixed + { + return self::wholeNumber($value, static fn (bool $negative, bool $half, bool $fractional): bool => $negative && $fractional); + } + + /** + * @template T + * + * @param T $value + * @return T|int|string + */ + private static function roundMinimum(mixed $value): mixed + { + return self::wholeNumber($value, static fn (bool $negative, bool $half, bool $fractional): bool => ! $negative && $fractional); + } + + /** + * $value cut toward zero, then moved one further from zero where $away says so, exactly at any size. Anything + * but a finite float or a decimal string is returned as given, for the caller to accept or refuse. + * + * @template T + * + * @param T $value + * @param Closure(bool, bool, bool): bool $away Given whether $value is negative, whether its fraction is at + * least a half and whether it has one + * @return T|int|string + */ + private static function wholeNumber(mixed $value, Closure $away): mixed + { + if (\is_float($value) && \is_finite($value)) { + $negative = $value < 0; + $magnitude = \floor(\abs($value)); + $fraction = \abs($value) - $magnitude; + $whole = BigInt::integralValue($negative ? -$magnitude : $magnitude); + $half = $fraction >= 0.5; + $fractional = $fraction > 0; + } elseif (\is_string($value) && \preg_match('/^(-?)(\d+)\.(\d+)$/', $value, $parts) === 1) { + $negative = $parts[1] === '-'; + $whole = BigInt::toNative($parts[1].$parts[2]); + $half = $parts[3][0] >= '5'; + $fractional = \trim($parts[3], '0') !== ''; + } else { + return $value; + } + + if ($whole === null || ! $away($negative, $half, $fractional)) { + return $whole ?? $value; + } + + return BigInt::add($whole, $negative ? -1 : 1); + } + /** * Cached validator instances keyed by context and a * stable schema/authorization fingerprint. @@ -1610,11 +1711,10 @@ public function updateDocument(string $collection, string $id, Document $documen $preparation = $this->nestedPreparation(lock: true); $newUpdatedAt = $document->getUpdatedAt(); $hasOperators = false; - $round = $this->fractionRounding()->get(); $cacheTarget = $collection->getId() === self::METADATA ? new Document([Document::ID => $id, Document::COLLECTION => self::METADATA]) : $collection->getId(); - $document = $this->withMutation(Event::DocumentUpdate, $cacheTarget, function () use ($collection, $id, $document, $preparation, $newUpdatedAt, $round, &$hasOperators) { + $document = $this->withMutation(Event::DocumentUpdate, $cacheTarget, function () use ($collection, $id, $document, $preparation, $newUpdatedAt, &$hasOperators) { $old = $this->authorization->skip(fn () => $this->silent( fn () => $this->readDocument($collection, $id, forUpdate: true) )); @@ -1655,9 +1755,7 @@ public function updateDocument(string $collection, string $id, Document $documen // for a write that leaves the stored document identical. $document = $this->removeUnknownAttributes($collection, $document); - if ($round) { - $this->resolveFractions($collection, $document, $old); - } + $this->roundOperators($collection, $document); $shouldUpdate = false; @@ -1984,8 +2082,7 @@ public function updateDocuments( $updatedAt = $updates->getUpdatedAt(); $updates[Document::UPDATED_AT] = ($updatedAt === null || ! $this->datePreservation()->get()) ? DateTime::now() : $updatedAt; - $fractionalKeys = $this->fractionRounding()->get() ? $this->prepareFractions($collection, $updates) : []; - $fractional = $fractionalKeys === [] ? null : new Fractional($this->adapter->arithmetic()); + $this->roundOperators($collection, $updates); $decodedUpdates = clone $updates; $updates = $this->encode( @@ -1998,7 +2095,6 @@ public function updateDocuments( $validator = new PartialStructure( collection: $collection, profile: $this->profile(), - fractions: $fractional !== null, ); if (! $validator->isValid($updates)) { @@ -2057,9 +2153,8 @@ public function updateDocuments( $cacheTarget = $collection->getId() === self::METADATA ? $batch : $collection->getId(); $found = $batch; - $this->withMutation(Event::DocumentsUpdate, $cacheTarget, function () use ($collection, $updates, $decodedUpdates, $adapterUpdates, $adapterData, $fractionalKeys, $fractional, &$batch, $found, $currentPermissions) { + $this->withMutation(Event::DocumentsUpdate, $cacheTarget, function () use ($collection, $updates, $decodedUpdates, $adapterUpdates, &$batch, $found, $currentPermissions) { $keepsPermissions = []; - $groups = []; foreach ($found as $index => $document) { $skipPermissionsUpdate = true; @@ -2079,17 +2174,9 @@ public function updateDocuments( // keeps the permission rows of every document under that id rewritten. $keepsPermissions[$document->getId()] = $skipPermissionsUpdate && ($keepsPermissions[$document->getId()] ?? true); - $resolved = []; - if ($fractional !== null) { - $resolved = $this->lockedFractions($collection, $decodedUpdates, $fractionalKeys, $document, $fractional); - $signature = \serialize(\array_map(static fn (Operator $operator): array => $operator->toArray(), $resolved)); - $groups[$signature]['operators'] = $resolved; - $groups[$signature]['indexes'][] = $index; - } - $updateData = []; foreach ($decodedUpdates->getArrayCopy() as $key => $value) { - $updateData[$key] = $resolved[$key] ?? ($value instanceof Operator ? clone $value : $value); + $updateData[$key] = $value instanceof Operator ? clone $value : $value; } $new = new Document(\array_merge($document->getArrayCopy(), $updateData)); @@ -2115,23 +2202,12 @@ public function updateDocuments( $batch[$index] = $this->castBefore($collection, $encoded); } - if ($groups === []) { - $this->adapter->updateDocuments( - $collection, - $adapterUpdates, - $batch, - \array_filter($keepsPermissions), - ); - } - - foreach ($groups as ['operators' => $operators, 'indexes' => $indexes]) { - $this->adapter->updateDocuments( - $collection, - $this->castBefore($collection, new Document([...$adapterData, ...$operators])), - \array_values(\array_intersect_key($batch, \array_flip($indexes))), - \array_filter($keepsPermissions), - ); - } + $this->adapter->updateDocuments( + $collection, + $adapterUpdates, + $batch, + \array_filter($keepsPermissions), + ); foreach ($batch as $document) { $this->withDocumentTenant( @@ -2318,15 +2394,12 @@ private function upsert( self::internalAttributesFor(true) )); $existing = $this->findDocumentsToUpsert($collection->getId(), $documents); - $round = $this->fractionRounding()->get(); - $fractionalKeys = []; foreach ($documents as $key => $document) { $old = $existing[$this->upsertKey($document)] ?? new Document(); $document = $this->removeUnknownAttributes($collection, $document); - - $keys = $round ? $this->prepareFractions($collection, $document) : []; + $this->roundOperators($collection, $document); // Extract operators early to avoid comparison issues $documentArray = $document->getArrayCopy(); @@ -2455,8 +2528,7 @@ private function upsert( $validator = new Structure( collection: $collection, profile: $this->profile(), - currentDocument: $old->isEmpty() ? null : $old, - fractions: $keys !== [], + currentDocument: $old->isEmpty() ? null : $old ); if (! $validator->isValid($document)) { @@ -2488,9 +2560,6 @@ private function upsert( if (! empty($operators)) { $operatorIds[$identity] = true; } - if ($keys !== []) { - $fractionalKeys[$identity] = $keys; - } $old = $this->castBefore($collection, $old); $document = $this->castBefore($collection, $document); @@ -2520,17 +2589,12 @@ private function upsert( $batch = $this->withMutation( Event::DocumentsUpsert, \array_map(static fn (Change $change): Document => $change->new, $chunk), - function () use ($collection, $increase, $chunk, $fractionalKeys): array { + function () use ($collection, $increase, $chunk): array { if (! $this->adapterHasFeature(Feature\Upserts::class)) { throw new DatabaseException('Adapter does not support upserts'); } $adapter = $this->adapter; - - if ($fractionalKeys !== []) { - $this->resolveUpsertFractions($collection, $chunk, $fractionalKeys); - } - $batch = $this->authorization->skip(fn () => $adapter->upsertDocuments( $collection, $chunk, @@ -2674,8 +2738,10 @@ private function upsertKey(Document $document): string * @param string $collection The collection ID * @param string $id The document ID * @param string $attribute The attribute to increase - * @param int|float|string $value The value to increase the attribute by, a number greater than 0 - * @param int|float|string|null $max The maximum value the attribute can reach after the increase, null means no limit + * @param int|float|string $value The value to increase the attribute by, a number greater than 0; on an integer + * attribute it is rounded half away from zero, and below a half changes nothing + * @param int|float|string|null $max The maximum value the attribute can reach after the increase, null means no + * limit; floored on an integer attribute * * @throws AuthorizationException * @throws DatabaseException @@ -2695,23 +2761,19 @@ public function increaseDocumentAttribute( $collection = $this->silent(fn () => $this->getCollection($collection)); $numericAttribute = $this->numericAttribute($collection, $attribute); - $exact = false; if ($this->isDeclaredInteger($numericAttribute ?? $this->declaredAttribute($collection, $attribute))) { - if ($this->fractionRounding()->get()) { - [$exact, $max] = $this->prepareIntegerChange($value, $max, true); - } else { - $this->assertIntegerChange($value); - if ($max !== null) { - $max = $this->integerBound($max, 'Max'); - } + $value = self::roundAmount($value); + $this->assertIntegerChange($value); + if ($max !== null) { + $max = $this->integerBound(self::roundMaximum($max), 'Max'); } } $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, $exact) { + $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 @@ -2730,13 +2792,12 @@ public function increaseDocumentAttribute( } } + if ($value === 0) { + return $document; + } + $attributeExists = $document->offsetExists($attribute); $currentVal = $document->getAttribute($attribute); - $returned = null; - if ($exact) { - [$value, $returned] = $this->exactChange($currentVal, $value, $max, true); - $max = null; - } if ($numericAttribute instanceof Attribute) { $result = $this->getNumericResult($numericAttribute, $currentVal, $value, true); } else { @@ -2780,11 +2841,11 @@ public function increaseDocumentAttribute( $this->purgeCachedDocumentInternal($collection->getId(), $id); $this->queueDocumentPurge($collection->getId(), $id); - return $document->setAttribute($attribute, $returned ?? $result); + return $document->setAttribute($attribute, $result); }); $listeners = $this->listens(Event::DocumentIncrease); - if ($listeners !== []) { + if ($listeners !== [] && $value !== 0) { $this->dispatch(new Event\Document\Increased($collection->getId(), $document, $attribute), $listeners); } @@ -2809,8 +2870,10 @@ private function assertPositiveChange(int|float|string $value): void * @param string $collection The collection identifier * @param string $id The document identifier * @param string $attribute The attribute to decrease - * @param int|float|string $value The value to decrease the attribute by, a number greater than 0 - * @param int|float|string|null $min The minimum value the attribute can reach, null means no limit + * @param int|float|string $value The value to decrease the attribute by, a number greater than 0; on an integer + * attribute it is rounded half away from zero, and below a half changes nothing + * @param int|float|string|null $min The minimum value the attribute can reach, null means no limit; ceiled on an + * integer attribute * @return Document The updated document * * @throws AuthorizationException @@ -2829,23 +2892,19 @@ public function decreaseDocumentAttribute( $collection = $this->silent(fn () => $this->getCollection($collection)); $numericAttribute = $this->numericAttribute($collection, $attribute); - $exact = false; if ($this->isDeclaredInteger($numericAttribute ?? $this->declaredAttribute($collection, $attribute))) { - if ($this->fractionRounding()->get()) { - [$exact, $min] = $this->prepareIntegerChange($value, $min, false); - } else { - $this->assertIntegerChange($value); - if ($min !== null) { - $min = $this->integerBound($min, 'Min'); - } + $value = self::roundAmount($value); + $this->assertIntegerChange($value); + if ($min !== null) { + $min = $this->integerBound(self::roundMinimum($min), 'Min'); } } $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, $exact) { + $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 @@ -2864,13 +2923,12 @@ public function decreaseDocumentAttribute( } } + if ($value === 0) { + return $document; + } + $attributeExists = $document->offsetExists($attribute); $currentDecVal = $document->getAttribute($attribute); - $returned = null; - if ($exact) { - [$value, $returned] = $this->exactChange($currentDecVal, $value, $min, false); - $min = null; - } if ($numericAttribute instanceof Attribute) { $result = $this->getNumericResult($numericAttribute, $currentDecVal, $value, false); } else { @@ -2914,11 +2972,11 @@ public function decreaseDocumentAttribute( $this->purgeCachedDocumentInternal($collection->getId(), $id); $this->queueDocumentPurge($collection->getId(), $id); - return $document->setAttribute($attribute, $returned ?? $result); + return $document->setAttribute($attribute, $result); }); $listeners = $this->listens(Event::DocumentDecrease); - if ($listeners !== []) { + if ($listeners !== [] && $value !== 0) { $this->dispatch(new Event\Document\Decreased($collection->getId(), $document, $attribute), $listeners); } diff --git a/src/Database/Trait/Fractions.php b/src/Database/Trait/Fractions.php deleted file mode 100644 index 577a89213..000000000 --- a/src/Database/Trait/Fractions.php +++ /dev/null @@ -1,246 +0,0 @@ -isExactChange($value, $bound)) { - if ($bound !== null) { - $this->assertNumericBound($bound, $increase ? 'Max' : 'Min'); - } - - return [true, $bound]; - } - - $this->assertIntegerChange($value); - - return [false, $bound === null ? null : $this->integerLimit($bound, $increase)]; - } - - /** - * A whole change whose bound is not whole is answered from the stored value only where the engine refused a - * fractional counter, which refused that bound after the limit check. - */ - private function isExactChange(int|float|string $value, int|float|string|null $bound): bool - { - if (Fractional::wholeValue($value) === null && ! Fractional::isFractional($value)) { - return false; - } - - if (! BigInt::isIntegerValue($value)) { - return true; - } - - return $bound !== null - && \is_numeric($bound) - && Fractional::wholeValue($bound) === null - && $this->adapter->arithmetic()->refusesCounterFractions; - } - - /** - * @throws TypeException - */ - private function integerLimit(int|float|string $bound, bool $maximum): int|string - { - $whole = ($maximum ? Fractional::maximum($bound) : Fractional::minimum($bound)) ?? $bound; - - return $this->integerBound($whole, $maximum ? 'Max' : 'Min'); - } - - /** - * @throws TypeException - */ - private function assertNumericBound(int|float|string $bound, string $name): void - { - if (! \is_numeric($bound) || (\is_float($bound) && \is_nan($bound))) { - throw new TypeException($name.' must be an integer.'); - } - } - - /** - * The whole-number change to apply and the exact result to return. The bound is checked before the engine's - * own refusals, in the order database 7.4.1 checked them. - * - * @return array{int|string, float} - * - * @throws LimitException - * @throws TypeException - */ - private function exactChange(mixed $stored, int|float|string $value, int|float|string|null $bound, bool $increase): array - { - $stored ??= 0; - if (! BigInt::isIntegerValue($stored)) { - throw new TypeException('Attribute value must be an integer.'); - } - $stored = BigInt::toNative($stored); - - if ($bound !== null && Fractional::exceeds($stored, $value, $bound, $increase)) { - throw new LimitException('Attribute value exceeds '.($increase ? 'maximum' : 'minimum').' limit: '.$bound); - } - - $arithmetic = $this->adapter->arithmetic(); - $change = (new Fractional($arithmetic))->counter($stored, $value, $increase); - - if ($bound !== null && $arithmetic->refusesCounterFractions && Fractional::wholeValue($bound) === null) { - throw new TypeException(($increase ? 'Max' : 'Min').' must be an integer.'); - } - - return [$change, $increase ? (float) $stored + (float) $value : (float) $stored - (float) $value]; - } - - /** - * Cuts the fractional items a document adds to an integer array toward zero, as every engine read them back, - * and lists the keys of its operators on single integers that need the stored value. - * - * @return list - */ - private function prepareFractions(Collection $collection, Document $document): array - { - $keys = []; - foreach ($collection->attributes() as $attribute) { - $value = $document->getAttribute($attribute->key); - if (! $value instanceof Operator || ! $attribute->isInteger()) { - continue; - } - - if ($attribute->array) { - $document->setAttribute($attribute->key, Fractional::whole($value)); - } elseif (Fractional::needsStored($value)) { - $keys[] = $attribute->key; - } - } - - return $keys; - } - - /** - * Answers a document's fractional operators from the stored document it changes; strict validation refuses - * any the engine refused. - */ - private function resolveFractions(Collection $collection, Document $document, Document $stored): void - { - $keys = $this->prepareFractions($collection, $document); - if ($keys !== []) { - $document->setAttributes(self::answerFractions($document, $keys, $stored, new Fractional($this->adapter->arithmetic()))); - } - } - - /** - * The operators of $changes under $keys answered from the document $target names, read locked for the write; - * a document not stored yet is answered from zero. Each document is its own locked read: no adapter reads - * several documents locked. - * - * @param list $keys - * @return array - * - * @throws StructureException when the engine refused an operator - */ - private function lockedFractions(Collection $collection, Document $changes, array $keys, Document $target, Fractional $fractional): array - { - $stored = $this->readLocked($collection, $target); - $resolved = self::answerFractions($changes, $keys, $stored, $fractional); - $this->assertResolvedFractions($collection, $resolved, $stored); - - return $resolved; - } - - /** - * @param array $chunk - * @param array> $keys The fractional keys of each upserted document, by its identity - * - * @throws StructureException when the engine refused an operator - */ - private function resolveUpsertFractions(Collection $collection, array $chunk, array $keys): void - { - $fractional = new Fractional($this->adapter->arithmetic()); - foreach ($chunk as $change) { - $documentKeys = $keys[$this->getDocumentIdentity($change->new)] ?? []; - if ($documentKeys === []) { - continue; - } - - $resolved = $this->lockedFractions($collection, $change->new, $documentKeys, $change->new, $fractional); - $change->new->setAttributes($this->castBefore($collection, new Document($resolved))->getArrayCopy()); - } - } - - /** - * @param list $keys - * @return array - */ - private static function answerFractions(Document $changes, array $keys, Document $stored, Fractional $fractional): array - { - $resolved = []; - foreach ($keys as $key) { - $operator = $changes->getAttribute($key); - $current = $stored->getAttribute($key); - if ($operator instanceof Operator) { - $answerable = $current === null || \is_int($current) || \is_float($current) || \is_string($current); - $resolved[$key] = ($answerable ? $fractional->operator($operator, $current) : $operator)->setAttribute($key); - } - } - - return $resolved; - } - - /** - * @param array $operators - * - * @throws StructureException - */ - private function assertResolvedFractions(Collection $collection, array $operators, Document $stored): void - { - if (! $this->validation()->get()) { - return; - } - - $validator = new PartialStructure( - collection: $collection, - profile: $this->profile(), - currentDocument: $stored->isEmpty() ? null : $stored, - ); - if (! $validator->isValid(new Document($operators))) { - throw new StructureException($validator->getDescription()); - } - } - - /** - * Read under the document's own tenant and whatever the caller may read. - */ - private function readLocked(Collection $collection, Document $document): Document - { - $read = fn (): Document => $this->authorization->skip(fn () => $this->silent( - fn () => $this->readDocument($collection, $document->getId(), forUpdate: true) - )); - $tenant = $document->getTenant(); - - return $this->hasSharedTables() && $this->isTenantPerDocument() && $tenant !== null - ? $this->withTenant($tenant, $read) - : $read(); - } -} diff --git a/src/Database/Validator/Operator.php b/src/Database/Validator/Operator.php index 2ce45740d..6ada79777 100644 --- a/src/Database/Validator/Operator.php +++ b/src/Database/Validator/Operator.php @@ -3,7 +3,6 @@ namespace Utopia\Database\Validator; use Throwable; -use Utopia\Database\Arithmetic\Fractional; use Utopia\Database\Attribute; use Utopia\Database\Collection; use Utopia\Database\Document; @@ -29,15 +28,11 @@ class Operator extends Validator /** * @param Document|null $currentDocument Current document for runtime validation (e.g., array bounds checking) - * @param bool $fractions Whether a numeric operator on an integer attribute may carry a finite fractional - * amount or limit within the attribute's range, which the caller resolves into a whole - * one from the stored value before it is written */ public function __construct( Document $collection, ?Document $currentDocument = null, private readonly bool $supportUnsignedBigInt = true, - private readonly bool $fractions = false, ) { $this->collection = $collection; $this->currentDocument = $currentDocument; @@ -95,39 +90,9 @@ private function isNumericValueInBounds(mixed $value, Attribute $attribute): boo return $numeric >= $bounds->min && $numeric <= $bounds->max; } - /** - * @phpstan-assert-if-true =float|numeric-string $value - */ - private function isFraction(mixed $value, Attribute $attribute): bool - { - return $this->fractions && $attribute->isInteger() && Fractional::isFractional($value); - } - - /** - * @param int|float|string $value A finite fractional number - */ - private function isFractionInBounds(int|float|string $value, Attribute $attribute): bool - { - $bounds = $attribute->bounds(); - - return $bounds !== null && (float) $value >= (float) $bounds->min && (float) $value <= (float) $bounds->max; - } - private function isValidLimit(mixed $limit, Attribute $attribute, DatabaseOperator $operator): bool { $methodName = $operator->getMethod()->value; - - if ($this->isFraction($limit, $attribute)) { - if ($this->isFractionInBounds($limit, $attribute)) { - return true; - } - - $bounds = $attribute->bounds(); - $this->message = "Cannot apply {$methodName} operator: max/min limit must be between {$bounds?->min} and {$bounds?->max}"; - - return false; - } - $finite = \is_int($limit) || (\is_float($limit) && \is_finite($limit)) || (\is_string($limit) && \is_numeric($limit)); if (! $attribute->isInteger() || ! $finite) { @@ -316,19 +281,16 @@ private function validateOperatorForAttribute( return false; } - $fraction = isset($values[0]) && $this->isFraction($values[0], $attribute); - if (! isset($values[0]) || ! ($fraction ? $this->isFractionInBounds($values[0], $attribute) : $this->isNumericValueInBounds($values[0], $attribute))) { + if (! isset($values[0]) || ! $this->isNumericValueInBounds($values[0], $attribute)) { $this->message = "Cannot apply {$methodName} operator: value must be numeric, got ".gettype($operator->getValue()); return false; } $integerType = $attribute->isInteger(); - $operatorValue = match (true) { - $fraction => (float) $values[0], - $integerType => $this->getIntegerValue($values[0]), - default => $this->getNumericValue($values[0]), - }; + $operatorValue = $integerType + ? $this->getIntegerValue($values[0]) + : $this->getNumericValue($values[0]); if ($operatorValue === null) { return false; } @@ -343,7 +305,7 @@ private function validateOperatorForAttribute( return false; } - if ($this->currentDocument !== null && $integerType && ! $fraction && ! isset($values[1])) { + if ($this->currentDocument !== null && $integerType && ! isset($values[1])) { $currentValue = $this->getIntegerValue($this->currentDocument->getAttribute($operator->getAttribute()) ?? 0); if ($currentValue === null || ! $this->isNumericValueInBounds($currentValue, $attribute)) { diff --git a/src/Database/Validator/Structure.php b/src/Database/Validator/Structure.php index 7bf5ab801..c249ce8c6 100644 --- a/src/Database/Validator/Structure.php +++ b/src/Database/Validator/Structure.php @@ -62,15 +62,12 @@ class Structure extends Validator * @param list $storedAttributes Attributes whose values are the stored ones, unchanged by the * write: they are not validated again, as the rules may have * tightened since those values were stored. - * @param bool $fractions Whether numeric operators on integer attributes may carry fractional amounts and - * limits, see Validator\Operator */ public function __construct( protected readonly Document $collection, private readonly Profile $profile, private readonly ?Document $currentDocument = null, array $storedAttributes = [], - private readonly bool $fractions = false, ) { $this->storedAttributes = \array_fill_keys($storedAttributes, true); $this->supportForAttributes = $profile->supports(Capability::DefinedAttributes); @@ -268,7 +265,6 @@ protected function checkForInvalidAttributeValues(Document $document, array $str $this->collection, $this->currentDocument, $this->supportUnsignedBigInt, - $this->fractions, ); if (! $operatorValidator->isValid($value)) { $this->message = $operatorValidator->getDescription(); diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index fe70bc2b2..7802aaf54 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -12,9 +12,9 @@ use Tests\E2E\Adapter\Scopes\DatabaseTests; use Tests\E2E\Adapter\Scopes\DocumentTests; use Tests\E2E\Adapter\Scopes\FeatureTests; -use Tests\E2E\Adapter\Scopes\FractionalIntegerTests; use Tests\E2E\Adapter\Scopes\GeneralTests; use Tests\E2E\Adapter\Scopes\IndexTests; +use Tests\E2E\Adapter\Scopes\IntegerRoundingTests; use Tests\E2E\Adapter\Scopes\JoinComboTests; use Tests\E2E\Adapter\Scopes\JoinReadRuleTests; use Tests\E2E\Adapter\Scopes\JoinRelationshipTests; @@ -29,7 +29,6 @@ use Tests\E2E\Adapter\Scopes\SchemaReconciliationTests; use Tests\E2E\Adapter\Scopes\SpatialTests; use Tests\E2E\Adapter\Scopes\VectorTests; -use Tests\Unit\Support\FractionalEngine; use Utopia\Database\Adapter; use Utopia\Database\Adapter\MariaDB; use Utopia\Database\Adapter\Memory; @@ -57,9 +56,9 @@ abstract class Base extends TestCase use DatabaseTests; use DocumentTests; use FeatureTests; - use FractionalIntegerTests; use GeneralTests; use IndexTests; + use IntegerRoundingTests; use JoinComboTests; use JoinReadRuleTests; use JoinRelationshipTests; @@ -127,14 +126,6 @@ protected static function getTestToken(): string return getenv('TEST_TOKEN') ?: getenv('UNIQUE_TEST_TOKEN') ?: (string) getmypid(); } - /** - * The engine family whose database 7.4.1 arithmetic the adapter under test reproduces. - */ - protected static function fractionalEngine(): FractionalEngine - { - return FractionalEngine::MariaDB; - } - /** * @param list> $rows * @return list diff --git a/tests/e2e/Adapter/MemoryTest.php b/tests/e2e/Adapter/MemoryTest.php index 810a14b0d..1d63dfbf7 100644 --- a/tests/e2e/Adapter/MemoryTest.php +++ b/tests/e2e/Adapter/MemoryTest.php @@ -3,7 +3,6 @@ namespace Tests\E2E\Adapter; use Redis; -use Tests\Unit\Support\FractionalEngine; use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Memory; @@ -920,10 +919,4 @@ public function testNegationOperatorsExcludeNullRows(): void Query::notSearch('bio', 'unrelated'), ])); } - - #[\Override] - protected static function fractionalEngine(): FractionalEngine - { - return FractionalEngine::Memory; - } } diff --git a/tests/e2e/Adapter/MongoDBTest.php b/tests/e2e/Adapter/MongoDBTest.php index 09f5b0a97..15886f82d 100644 --- a/tests/e2e/Adapter/MongoDBTest.php +++ b/tests/e2e/Adapter/MongoDBTest.php @@ -5,7 +5,6 @@ use Exception; use Redis; use Tests\E2E\Adapter\Scopes\MongoReadFilterTests; -use Tests\Unit\Support\FractionalEngine; use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Mongo; @@ -385,10 +384,4 @@ public function testReadsDropAStoredNonStringPermission(): void $this->assertSame('single', $updated->getAttribute('title')); $this->assertSame($permissions, $updated->getPermissions()); } - - #[\Override] - protected static function fractionalEngine(): FractionalEngine - { - return FractionalEngine::MongoDB; - } } diff --git a/tests/e2e/Adapter/PostgresTest.php b/tests/e2e/Adapter/PostgresTest.php index 169c1b198..7cb728001 100644 --- a/tests/e2e/Adapter/PostgresTest.php +++ b/tests/e2e/Adapter/PostgresTest.php @@ -3,7 +3,6 @@ namespace Tests\E2E\Adapter; use Redis; -use Tests\Unit\Support\FractionalEngine; use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Postgres; @@ -243,10 +242,4 @@ public function testVectorSearchUsesTheIndex(): void $database->deleteCollection('vectorPlan'); } - - #[\Override] - protected static function fractionalEngine(): FractionalEngine - { - return FractionalEngine::PostgreSQL; - } } diff --git a/tests/e2e/Adapter/RedisTest.php b/tests/e2e/Adapter/RedisTest.php index 91a176632..99f84f614 100644 --- a/tests/e2e/Adapter/RedisTest.php +++ b/tests/e2e/Adapter/RedisTest.php @@ -3,7 +3,6 @@ namespace Tests\E2E\Adapter; use Redis; -use Tests\Unit\Support\FractionalEngine; use Utopia\Cache\Adapter\None as NoneCacheAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Redis as RedisAdapter; @@ -204,10 +203,4 @@ private function findStoredPayloads(Redis $client, string $id): array return $payloads; } - - #[\Override] - protected static function fractionalEngine(): FractionalEngine - { - return FractionalEngine::Memory; - } } diff --git a/tests/e2e/Adapter/SQLiteTest.php b/tests/e2e/Adapter/SQLiteTest.php index 74462e2d6..4060d3efb 100644 --- a/tests/e2e/Adapter/SQLiteTest.php +++ b/tests/e2e/Adapter/SQLiteTest.php @@ -3,7 +3,6 @@ namespace Tests\E2E\Adapter; use Redis; -use Tests\Unit\Support\FractionalEngine; use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\SQLite; @@ -132,10 +131,4 @@ protected function deleteIndex(string $collection, string $index): bool return true; } - - #[\Override] - protected static function fractionalEngine(): FractionalEngine - { - return FractionalEngine::Memory; - } } diff --git a/tests/e2e/Adapter/Schemaless/MongoDBTest.php b/tests/e2e/Adapter/Schemaless/MongoDBTest.php index 5dc78ebfa..a48d59fbd 100644 --- a/tests/e2e/Adapter/Schemaless/MongoDBTest.php +++ b/tests/e2e/Adapter/Schemaless/MongoDBTest.php @@ -6,7 +6,6 @@ use Redis; use Tests\E2E\Adapter\Base; use Tests\E2E\Adapter\Scopes\MongoReadFilterTests; -use Tests\Unit\Support\FractionalEngine; use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Mongo; @@ -101,10 +100,4 @@ protected function deleteIndex(string $collection, string $index): bool { return true; } - - #[\Override] - protected static function fractionalEngine(): FractionalEngine - { - return FractionalEngine::MongoDB; - } } diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index dec413e7a..f32656909 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -10917,7 +10917,7 @@ private function statementsOn(Database $database, string $table, callable $read) return [$result, $statements]; } - public function testIncreaseAndDecreaseRefuseAFractionalBoundOnAnInteger(): void + public function testIncreaseAndDecreaseFloorAndCeilAFractionalBoundOnAnInteger(): void { /** @var Database $database */ $database = $this->getDatabase(); @@ -10932,18 +10932,22 @@ public function testIncreaseAndDecreaseRefuseAFractionalBoundOnAnInteger(): void $database->createDocument($collection, new Document(['$id' => 'counter', 'count' => 100])); try { + $this->assertSame(101, $database->increaseDocumentAttribute($collection, 'counter', 'count', 1, 101.4)->getAttribute('count')); + try { - $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()); + $database->increaseDocumentAttribute($collection, 'counter', 'count', 1, 101.4); + $this->fail('An increase past a floored fractional maximum was accepted'); + } catch (LimitException $error) { + $this->assertSame('Attribute value exceeds maximum limit: 101', $error->getMessage()); } + $this->assertSame(100, $database->decreaseDocumentAttribute($collection, 'counter', 'count', 1, 99.5)->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()); + $database->decreaseDocumentAttribute($collection, 'counter', 'count', 1, 99.5); + $this->fail('A decrease past a ceiled fractional minimum was accepted'); + } catch (LimitException $error) { + $this->assertSame('Attribute value exceeds minimum limit: 100', $error->getMessage()); } $this->assertSame(100, $database->getDocument($collection, 'counter')->getAttribute('count')); @@ -10954,7 +10958,7 @@ public function testIncreaseAndDecreaseRefuseAFractionalBoundOnAnInteger(): void } } - public function testIncreaseAndDecreaseRefuseAFractionalChangeValueOnAnInteger(): void + public function testIncreaseAndDecreaseRoundAFractionalChangeValueOnAnInteger(): void { /** @var Database $database */ $database = $this->getDatabase(); @@ -10969,22 +10973,11 @@ public function testIncreaseAndDecreaseRefuseAFractionalChangeValueOnAnInteger() $database->createDocument($collection, new Document(['$id' => 'counter', 'count' => 100])); try { - 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()); - } - - 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(100, $database->getDocument($collection, 'counter')->getAttribute('count')); - $this->assertSame(102, $database->increaseDocumentAttribute($collection, 'counter', 'count', 2)->getAttribute('count')); + $this->assertSame(102, $database->increaseDocumentAttribute($collection, 'counter', 'count', 1.5)->getAttribute('count')); + $this->assertSame(101, $database->decreaseDocumentAttribute($collection, 'counter', 'count', 0.5)->getAttribute('count')); + $this->assertSame(101, $database->decreaseDocumentAttribute($collection, 'counter', 'count', 0.4)->getAttribute('count')); + $this->assertSame(101, $database->getDocument($collection, 'counter')->getAttribute('count')); + $this->assertSame(103, $database->increaseDocumentAttribute($collection, 'counter', 'count', 2)->getAttribute('count')); } finally { $database->deleteCollection($collection); } diff --git a/tests/e2e/Adapter/Scopes/FractionalIntegerTests.php b/tests/e2e/Adapter/Scopes/FractionalIntegerTests.php deleted file mode 100644 index 2b69468fb..000000000 --- a/tests/e2e/Adapter/Scopes/FractionalIntegerTests.php +++ /dev/null @@ -1,343 +0,0 @@ -getDatabase(); - - if (! $database->getAdapter()->supports(Capability::Operators)) { - $this->expectNotToPerformAssertions(); - - return; - } - - $collection = $this->fractionalCollection($database, 'fractional_operators'); - $engine = static::fractionalEngine(); - $row = 0; - - try { - $database->withRoundFractions(true, function () use ($database, $collection, $engine, &$row): void { - foreach (FractionalResults::operators() as $name => [$family, $stored, $method, $values, $expected]) { - if ($family !== $engine) { - continue; - } - - $id = 'operator'.$row++; - $database->createDocument($collection, new Document([Document::ID => $id, 'count' => $stored])); - $update = fn (): Document => $database->updateDocument($collection, $id, new Document(['count' => new Operator($method, 'count', $values)])); - - if ($expected === null) { - try { - $update(); - $this->fail("{$name}: the engine refused it, so it must be refused"); - } catch (StructureException) { - } - - $this->assertSame($stored, $database->getDocument($collection, $id)->getAttribute('count'), $name); - - continue; - } - - $this->assertSame($expected, $update()->getAttribute('count'), $name); - $this->assertSame($expected, $database->getDocument($collection, $id)->getAttribute('count'), $name); - } - }); - - $this->assertSame(68, $row, 'Every operator row of the engine family ran'); - } finally { - $database->deleteCollection($collection); - } - } - - public function testFractionalCountersStoreWhatTheEngineStored(): void - { - $database = $this->getDatabase(); - $collection = $this->fractionalCollection($database, 'fractional_counters'); - $engine = static::fractionalEngine(); - $rows = ['count' => 0, 'big' => 0]; - - try { - $database->withRoundFractions(true, function () use ($database, $collection, $engine, &$rows): void { - foreach (FractionalResults::counters() as $name => [$family, $increase, $start, $value, $bound, $stored]) { - if ($family !== $engine) { - continue; - } - - foreach (\array_keys($rows) as $attribute) { - if ($attribute === 'count' && \abs($start) > Database::MAX_INT) { - continue; - } - - $id = $attribute.$rows[$attribute]++; - $database->createDocument($collection, new Document([Document::ID => $id, $attribute => $start])); - $change = fn (): Document => $increase - ? $database->increaseDocumentAttribute($collection, $id, $attribute, $value, $bound) - : $database->decreaseDocumentAttribute($collection, $id, $attribute, $value, $bound); - - if ($stored === null) { - try { - $change(); - $this->fail("{$name} on {$attribute}: the engine refused it, so it must be refused"); - } catch (TypeException $exception) { - $this->assertSame('Change value must be an integer.', $exception->getMessage(), "{$name} on {$attribute}"); - } - - $this->assertSame($start, $database->getDocument($collection, $id)->getAttribute($attribute), "{$name} on {$attribute}"); - - continue; - } - - $this->assertSame($increase ? $start + $value : $start - $value, $change()->getAttribute($attribute), "{$name} on {$attribute}"); - $this->assertSame($stored, $database->getDocument($collection, $id)->getAttribute($attribute), "{$name} on {$attribute}"); - } - } - }); - - $this->assertSame(['count' => 15, 'big' => 21], $rows, 'Every counter row of the engine family ran on an INT and a BIGINT column'); - } finally { - $database->deleteCollection($collection); - } - } - - public function testFractionalCounterPastItsBoundIsRefusedAndRolledBack(): void - { - $database = $this->getDatabase(); - $collection = $this->fractionalCollection($database, 'fractional_past_bound'); - - try { - $database->withRoundFractions(true, function () use ($database, $collection): void { - foreach (['count', 'big'] as $attribute) { - foreach ([[true, 6], [false, 4]] as [$increase, $bound]) { - $id = $attribute.($increase ? 'Increase' : 'Decrease'); - $database->createDocument($collection, new Document([Document::ID => $id, $attribute => 5])); - - try { - $increase - ? $database->increaseDocumentAttribute($collection, $id, $attribute, 1.5, $bound) - : $database->decreaseDocumentAttribute($collection, $id, $attribute, 1.5, $bound); - $this->fail("A fractional change past its bound on {$attribute} must be refused"); - } catch (LimitException $exception) { - $this->assertSame('Attribute value exceeds '.($increase ? 'maximum' : 'minimum').' limit: '.$bound, $exception->getMessage()); - } - - $this->assertSame(5, $database->getDocument($collection, $id)->getAttribute($attribute), $id); - } - } - }); - } finally { - $database->deleteCollection($collection); - } - } - - public function testWholeCounterChangeUnderAFractionalBoundIsAnsweredAsTheEngineAnswered(): void - { - $database = $this->getDatabase(); - $collection = $this->fractionalCollection($database, 'fractional_whole_change'); - $refuses = static::fractionalEngine() === FractionalEngine::PostgreSQL; - $cases = [ - 'within a fractional maximum' => [true, 2, 10.5, $refuses ? 'Max must be an integer.' : 10], - 'past a fractional maximum' => [true, 3, 10.5, null], - 'within a fractional minimum' => [false, 2, 5.5, $refuses ? 'Min must be an integer.' : 6], - 'past a fractional minimum' => [false, 3, 5.5, null], - ]; - - try { - $database->withRoundFractions(true, function () use ($database, $collection, $cases): void { - $row = 0; - foreach ($cases as $name => [$increase, $value, $bound, $outcome]) { - $id = 'whole'.$row++; - $database->createDocument($collection, new Document([Document::ID => $id, 'count' => 8])); - $change = fn (): Document => $increase - ? $database->increaseDocumentAttribute($collection, $id, 'count', $value, $bound) - : $database->decreaseDocumentAttribute($collection, $id, 'count', $value, $bound); - - if (\is_int($outcome)) { - $this->assertSame($outcome, $change()->getAttribute('count'), $name); - $this->assertSame($outcome, $database->getDocument($collection, $id)->getAttribute('count'), $name); - - continue; - } - - try { - $change(); - $this->fail("{$name}: the change must be refused"); - } catch (LimitException|TypeException $exception) { - $outcome === null - ? $this->assertInstanceOf(LimitException::class, $exception, $name) - : $this->assertSame($outcome, $exception->getMessage(), $name); - } - - $this->assertSame(8, $database->getDocument($collection, $id)->getAttribute('count'), $name); - } - }); - } finally { - $database->deleteCollection($collection); - } - } - - public function testFractionalOperatorOnABigIntegerBeyondFloatPrecisionStaysExact(): void - { - $database = $this->getDatabase(); - - if (! $database->getAdapter()->supports(Capability::Operators)) { - $this->expectNotToPerformAssertions(); - - return; - } - - $collection = $this->fractionalCollection($database, 'fractional_big'); - $expected = match (static::fractionalEngine()) { - FractionalEngine::MariaDB, FractionalEngine::MongoDB, FractionalEngine::Memory => 9007199254740994, - FractionalEngine::PostgreSQL => 9007199254740995, - }; - - try { - $database->createDocument($collection, new Document([Document::ID => 'huge', 'big' => 9007199254740993])); - - $database->withRoundFractions(true, fn (): Document => $database->updateDocument($collection, 'huge', new Document([ - 'big' => Operator::increment(1.5, 9007199254741000), - ]))); - - $this->assertSame($expected, $database->getDocument($collection, 'huge')->getAttribute('big')); - } finally { - $database->deleteCollection($collection); - } - } - - public function testFractionalBulkUpdateRoundsEachDocumentFromItsOwnStoredValue(): void - { - $database = $this->getDatabase(); - - if (! $database->getAdapter()->supports(Capability::Operators)) { - $this->expectNotToPerformAssertions(); - - return; - } - - $collection = $this->fractionalCollection($database, 'fractional_bulk'); - $expected = match (static::fractionalEngine()) { - FractionalEngine::MariaDB => ['first' => 12, 'second' => 12, 'third' => 14], - FractionalEngine::PostgreSQL => ['first' => 12, 'second' => 13, 'third' => 14], - FractionalEngine::MongoDB, FractionalEngine::Memory => ['first' => 11, 'second' => 12, 'third' => 13], - }; - - try { - foreach (['first' => 10, 'second' => 11, 'third' => 12] as $id => $count) { - $database->createDocument($collection, new Document([Document::ID => $id, 'count' => $count])); - } - - $updated = $database->withRoundFractions(true, fn (): int => $database->updateDocuments( - $collection, - new Document(['count' => Operator::increment(1.5, 100)]), - )); - - $stored = []; - foreach (\array_keys($expected) as $id) { - $stored[$id] = $database->getDocument($collection, $id)->getAttribute('count'); - } - - $this->assertSame(3, $updated); - $this->assertSame($expected, $stored); - } finally { - $database->deleteCollection($collection); - } - } - - public function testFractionalUpsertRoundsAStoredDocumentFromItsValueAndANewOneFromZero(): void - { - $database = $this->getDatabase(); - - if (! $database->getAdapter()->supports(Capability::Operators) || ! $database->getAdapter()->hasFeature(Feature\Upserts::class)) { - $this->expectNotToPerformAssertions(); - - return; - } - - $collection = $this->fractionalCollection($database, 'fractional_upsert'); - $expected = match (static::fractionalEngine()) { - FractionalEngine::MariaDB, FractionalEngine::MongoDB, FractionalEngine::Memory => ['first' => 12, 'second' => 2], - FractionalEngine::PostgreSQL => ['first' => 13, 'second' => 3], - }; - - try { - $database->createDocument($collection, new Document([Document::ID => 'first', 'count' => 10])); - - $database->withRoundFractions(true, fn (): int => $database->upsertDocuments($collection, [ - new Document([Document::ID => 'first', 'count' => Operator::increment(2.5, 100)]), - new Document([Document::ID => 'second', 'count' => Operator::increment(2.5, 100)]), - ])); - - $this->assertSame($expected['first'], $database->getDocument($collection, 'first')->getAttribute('count')); - $this->assertSame($expected['second'], $database->getDocument($collection, 'second')->getAttribute('count')); - } finally { - $database->deleteCollection($collection); - } - } - - public function testFractionsOnIntegersAreRefusedWithoutTheMode(): void - { - $database = $this->getDatabase(); - $collection = $this->fractionalCollection($database, 'fractional_refused'); - - try { - $database->createDocument($collection, new Document([Document::ID => 'meter', 'count' => 10])); - - try { - $database->increaseDocumentAttribute($collection, 'meter', 'count', 1.5); - $this->fail('A fractional change on an integer must be refused without the mode'); - } catch (TypeException $exception) { - $this->assertSame('Change value must be an integer.', $exception->getMessage()); - } - - if ($database->getAdapter()->supports(Capability::Operators)) { - try { - $database->updateDocument($collection, 'meter', new Document(['count' => Operator::increment(1.5, 100)])); - $this->fail('A fractional operator on an integer must be refused without the mode'); - } catch (StructureException) { - } - } - - $this->assertFalse($database->isRoundingFractions()); - $this->assertSame(10, $database->getDocument($collection, 'meter')->getAttribute('count')); - } finally { - $database->deleteCollection($collection); - } - } - - private function fractionalCollection(Database $database, string $id): string - { - $database->createCollection(Collection::create( - id: $id, - attributes: [ - Attribute::integer(key: 'count'), - Attribute::bigInteger(key: 'big'), - ], - permissions: [ - Permission::create(Role::any()), - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - documentSecurity: false, - )); - - return $id; - } -} diff --git a/tests/e2e/Adapter/Scopes/IntegerRoundingTests.php b/tests/e2e/Adapter/Scopes/IntegerRoundingTests.php new file mode 100644 index 000000000..2a6aa9409 --- /dev/null +++ b/tests/e2e/Adapter/Scopes/IntegerRoundingTests.php @@ -0,0 +1,192 @@ +getDatabase(); + $collection = $this->roundingCollection($database, 'rounding_amounts'); + + try { + $this->assertSame(102, $database->increaseDocumentAttribute($collection, 'counter', 'count', 1.5)->getAttribute('count')); + $this->assertSame(105, $database->increaseDocumentAttribute($collection, 'counter', 'count', '2.5')->getAttribute('count')); + $this->assertSame(102, $database->decreaseDocumentAttribute($collection, 'counter', 'count', 2.5)->getAttribute('count')); + $this->assertSame(102, $database->increaseDocumentAttribute($collection, 'counter', 'count', 0.4)->getAttribute('count')); + $this->assertSame(102, $database->decreaseDocumentAttribute($collection, 'counter', 'count', '0.49')->getAttribute('count')); + $this->assertSame(102, $database->getDocument($collection, 'counter')->getAttribute('count')); + + $this->assertSame(9007199254740994, $database->increaseDocumentAttribute($collection, 'counter', 'big', '9007199254740993.5')->getAttribute('big')); + $this->assertSame(0, $database->decreaseDocumentAttribute($collection, 'counter', 'big', '9007199254740993.5')->getAttribute('big')); + $this->assertSame(0, $database->getDocument($collection, 'counter')->getAttribute('big')); + + $this->assertSame(2.75, $database->increaseDocumentAttribute($collection, 'counter', 'ratio', 1.25)->getAttribute('ratio')); + $this->assertSame(2.75, $database->getDocument($collection, 'counter')->getAttribute('ratio')); + } finally { + $database->deleteCollection($collection); + } + } + + public function testIntegerRoundingOfCounterBounds(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $collection = $this->roundingCollection($database, 'rounding_bounds'); + + try { + $this->assertSame(101, $database->increaseDocumentAttribute($collection, 'counter', 'count', 1, 101.9)->getAttribute('count')); + $this->assertRoundingLimited('Attribute value exceeds maximum limit: 101', fn () => $database->increaseDocumentAttribute($collection, 'counter', 'count', 1, '101.9')); + $this->assertSame(100, $database->decreaseDocumentAttribute($collection, 'counter', 'count', 1, 99.1)->getAttribute('count')); + $this->assertRoundingLimited('Attribute value exceeds minimum limit: 100', fn () => $database->decreaseDocumentAttribute($collection, 'counter', 'count', 1, '99.1')); + $this->assertSame(100, $database->getDocument($collection, 'counter')->getAttribute('count')); + } finally { + $database->deleteCollection($collection); + } + } + + public function testIntegerRoundingOfOperatorsInUpdates(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::Operators)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = $this->roundingCollection($database, 'rounding_operators'); + + try { + $update = fn (array $changes): Document => $database->updateDocument($collection, 'counter', new Document($changes)); + + $this->assertSame(102, $update(['count' => Operator::increment(1.5)])->getAttribute('count')); + $this->assertSame(99, $update(['count' => Operator::decrement('2.5')])->getAttribute('count')); + $this->assertSame(96, $update(['count' => Operator::increment(-2.5)])->getAttribute('count')); + $this->assertSame(98, $update(['count' => Operator::increment(2, 98.7)])->getAttribute('count')); + $this->assertSame(98, $update(['count' => Operator::increment(1, 98.7)])->getAttribute('count')); + $this->assertSame(98, $update(['count' => Operator::decrement(4, '94.2')])->getAttribute('count')); + $this->assertSame(95, $update(['count' => Operator::decrement(3, '94.2')])->getAttribute('count')); + $this->assertSame(9007199254740994, $update(['big' => Operator::increment('9007199254740993.5')])->getAttribute('big')); + $this->assertSame(2.0, $update(['ratio' => Operator::increment(0.5)])->getAttribute('ratio')); + + $database->createDocument($collection, new Document([Document::ID => 'second', 'count' => 10, 'big' => 0, 'ratio' => 0.0])); + $this->assertSame(2, $database->updateDocuments($collection, new Document(['count' => Operator::increment(2.5)]))); + $this->assertSame(98, $database->getDocument($collection, 'counter')->getAttribute('count')); + $this->assertSame(13, $database->getDocument($collection, 'second')->getAttribute('count')); + + $database->updateDocuments($collection, new Document(['count' => Operator::increment(7, 20.9)])); + $this->assertSame(98, $database->getDocument($collection, 'counter')->getAttribute('count')); + $this->assertSame(20, $database->getDocument($collection, 'second')->getAttribute('count')); + } finally { + $database->deleteCollection($collection); + } + } + + public function testIntegerRoundingOfOperatorsInUpserts(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::Operators) || ! $database->getAdapter()->hasFeature(Feature\Upserts::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = $this->roundingCollection($database, 'rounding_upserts'); + + try { + $database->createDocument($collection, new Document([Document::ID => 'second', 'count' => 10, 'big' => 0, 'ratio' => 0.0])); + + $upserted = $database->upsertDocument($collection, new Document([Document::ID => 'counter', 'count' => Operator::increment(1.5)])); + $count = $database->upsertDocuments($collection, [ + new Document([Document::ID => 'counter', 'count' => Operator::decrement(2.5, 98.5)]), + new Document([Document::ID => 'second', 'count' => Operator::increment('1.5', '12.7')]), + ]); + + $this->assertSame(102, $upserted->getAttribute('count')); + $this->assertSame(2, $count); + $this->assertSame(99, $database->getDocument($collection, 'counter')->getAttribute('count')); + $this->assertSame(12, $database->getDocument($collection, 'second')->getAttribute('count')); + } finally { + $database->deleteCollection($collection); + } + } + + public function testIntegerRoundingLeavesAFractionalMultiplyOrDivideRefused(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::Operators)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = $this->roundingCollection($database, 'rounding_refused'); + + try { + foreach (['multiply' => Operator::multiply(1.5), 'divide' => Operator::divide(2.5)] as $method => $operator) { + try { + $database->updateDocument($collection, 'counter', new Document(['count' => $operator])); + $this->fail("A fractional {$method} on an integer attribute was accepted"); + } catch (StructureException $error) { + $this->assertSame("Invalid document structure: Cannot apply {$method} operator: value must be numeric, got double", $error->getMessage()); + } + } + + $this->assertSame(100, $database->getDocument($collection, 'counter')->getAttribute('count')); + $this->assertSame(0.75, $database->updateDocument($collection, 'counter', new Document(['ratio' => Operator::multiply(0.5)]))->getAttribute('ratio')); + } finally { + $database->deleteCollection($collection); + } + } + + private function roundingCollection(Database $database, string $id): string + { + $id .= '_'.\uniqid(); + $database->createCollection(Collection::create( + id: $id, + attributes: [ + Attribute::integer(key: 'count'), + Attribute::bigInteger(key: 'big'), + Attribute::double(key: 'ratio'), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + documentSecurity: false, + )); + $database->createDocument($id, new Document([Document::ID => 'counter', 'count' => 100, 'big' => 0, 'ratio' => 1.5])); + + return $id; + } + + private function assertRoundingLimited(string $message, callable $change): void + { + try { + $change(); + $this->fail("The change was not limited: {$message}"); + } catch (LimitException $error) { + $this->assertSame($message, $error->getMessage()); + } + } +} diff --git a/tests/e2e/Adapter/Scopes/OperatorTests.php b/tests/e2e/Adapter/Scopes/OperatorTests.php index 86d616fd3..d8a2b0672 100644 --- a/tests/e2e/Adapter/Scopes/OperatorTests.php +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -5238,7 +5238,7 @@ public function testUpdateDocumentsWithOperatorsCacheInvalidation(): void $database->deleteCollection($collectionId); } - public function testOperatorWholeFloatLimitHoldsAndAFractionalLimitIsRefused(): void + public function testOperatorWholeFloatLimitHoldsAndAFractionalLimitIsFloored(): void { $database = static::getDatabase(); @@ -5268,19 +5268,15 @@ public function testOperatorWholeFloatLimitHoldsAndAFractionalLimitIsRefused(): $this->assertSame(PHP_INT_MAX - 5, $updated->getAttribute('counter')); $this->assertSame(PHP_INT_MAX - 5, $database->getDocument($collectionId, 'doc')->getAttribute('counter')); - 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(), - ); - } - + $database->updateDocument($collectionId, 'doc', new Document([ + 'count' => Operator::increment(3, 102.4), + ])); $this->assertSame(100, $database->getDocument($collectionId, 'doc')->getAttribute('count')); + + $database->updateDocument($collectionId, 'doc', new Document([ + 'count' => Operator::increment(2, 102.4), + ])); + $this->assertSame(102, $database->getDocument($collectionId, 'doc')->getAttribute('count')); } finally { $database->deleteCollection($collectionId); } diff --git a/tests/e2e/Adapter/SharedTables/MongoDBTest.php b/tests/e2e/Adapter/SharedTables/MongoDBTest.php index 1f4e310ea..5cc98d193 100644 --- a/tests/e2e/Adapter/SharedTables/MongoDBTest.php +++ b/tests/e2e/Adapter/SharedTables/MongoDBTest.php @@ -6,7 +6,6 @@ use Redis; use Tests\E2E\Adapter\Base; use Tests\E2E\Adapter\Scopes\MongoReadFilterTests; -use Tests\Unit\Support\FractionalEngine; use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Mongo; @@ -206,10 +205,4 @@ protected function deleteIndex(string $collection, string $index): bool { return true; } - - #[\Override] - protected static function fractionalEngine(): FractionalEngine - { - return FractionalEngine::MongoDB; - } } diff --git a/tests/e2e/Adapter/SharedTables/PostgresTest.php b/tests/e2e/Adapter/SharedTables/PostgresTest.php index 254b62742..f58924ebc 100644 --- a/tests/e2e/Adapter/SharedTables/PostgresTest.php +++ b/tests/e2e/Adapter/SharedTables/PostgresTest.php @@ -4,7 +4,6 @@ use Redis; use Tests\E2E\Adapter\Base; -use Tests\Unit\Support\FractionalEngine; use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\Postgres; @@ -93,10 +92,4 @@ protected function deleteIndex(string $collection, string $index): bool return true; } - - #[\Override] - protected static function fractionalEngine(): FractionalEngine - { - return FractionalEngine::PostgreSQL; - } } diff --git a/tests/e2e/Adapter/SharedTables/SQLiteTest.php b/tests/e2e/Adapter/SharedTables/SQLiteTest.php index 736f65cb5..de5d81ddd 100644 --- a/tests/e2e/Adapter/SharedTables/SQLiteTest.php +++ b/tests/e2e/Adapter/SharedTables/SQLiteTest.php @@ -4,7 +4,6 @@ use Redis; use Tests\E2E\Adapter\Base; -use Tests\Unit\Support\FractionalEngine; use Utopia\Cache\Adapter\Redis as RedisAdapter; use Utopia\Cache\Cache; use Utopia\Database\Adapter\SQLite; @@ -155,10 +154,4 @@ protected function deleteIndex(string $collection, string $index): bool return true; } - - #[\Override] - protected static function fractionalEngine(): FractionalEngine - { - return FractionalEngine::Memory; - } } diff --git a/tests/unit/Adapter/ArithmeticTest.php b/tests/unit/Adapter/ArithmeticTest.php deleted file mode 100644 index 2df06d189..000000000 --- a/tests/unit/Adapter/ArithmeticTest.php +++ /dev/null @@ -1,170 +0,0 @@ -}> - */ - public static function adapters(): iterable - { - $mariaDB = [Rounding::HalfEven, Rounding::HalfEven, Rounding::HalfAwayFromZero, Rounding::HalfEven, false, [], [], false]; - $memory = [Rounding::TowardZero, Rounding::TowardZero, Rounding::TowardZero, Rounding::TowardZero, false, [], [], true]; - - yield 'MariaDB' => [static fn (): Adapter => new MariaDB(new stdClass()), $mariaDB]; - yield 'MySQL' => [static fn (): Adapter => new MySQL(new stdClass()), $mariaDB]; - yield 'SQLite' => [static fn (): Adapter => new SQLite(new PDO('sqlite::memory:')), $memory]; - yield 'Memory' => [static fn (): Adapter => new Memory(), $memory]; - yield 'Redis' => [static fn (): Adapter => new Redis(new RedisClient()), $memory]; - yield 'PostgreSQL' => [static fn (): Adapter => new Postgres(new stdClass()), [ - Rounding::HalfEven, - Rounding::HalfAwayFromZero, - Rounding::HalfAwayFromZero, - Rounding::HalfEven, - true, - [OperatorType::Multiply, OperatorType::Divide, OperatorType::Modulo], - [OperatorType::Increment, OperatorType::Decrement, OperatorType::Multiply, OperatorType::Divide], - false, - ]]; - yield 'MongoDB' => [self::mongo(...), [Rounding::TowardZero, Rounding::TowardZero, Rounding::TowardZero, Rounding::TowardZero, false, [], [], false]]; - } - - /** - * @param callable(): Adapter $adapter - * @param list $expected - */ - #[DataProvider('adapters')] - public function testEachAdapterDeclaresHowItsEngineStoredAFractionalResult(callable $adapter, array $expected): void - { - $this->assertSame($expected, self::describe($adapter()->arithmetic())); - } - - /** - * @param callable(): Adapter $adapter - * @param list $expected - */ - #[DataProvider('adapters')] - public function testAPoolAnswersForItsPooledAdapter(callable $adapter, array $expected): void - { - $pool = new Pool($this->connections($adapter())); - $pool->setAuthorization(new Authorization()); - - $this->assertSame($expected, self::describe($pool->arithmetic())); - $this->assertSame($expected, self::describe($pool->arithmetic())); - } - - /** - * @param callable(): Adapter $adapter - * @param list $expected - */ - #[DataProvider('adapters')] - public function testAReadWritePoolAnswersForItsPooledAdapter(callable $adapter, array $expected): void - { - $pool = new ReadWritePool($this->connections($adapter()), $this->connections($adapter())); - $pool->setAuthorization(new Authorization()); - - $this->assertSame($expected, self::describe($pool->arithmetic())); - } - - /** - * @return iterable - */ - public static function methods(): iterable - { - yield 'a power' => [OperatorType::Power, false, Rounding::TowardZero]; - yield 'a power to a whole exponent' => [OperatorType::Power, true, Rounding::TowardZero]; - yield 'a division by a whole number' => [OperatorType::Divide, true, Rounding::HalfAwayFromZero]; - yield 'a division by a fraction' => [OperatorType::Divide, false, Rounding::HalfEven]; - yield 'a multiplication by a whole number' => [OperatorType::Multiply, true, Rounding::HalfEven]; - yield 'an increment' => [OperatorType::Increment, false, Rounding::HalfEven]; - yield 'a modulo' => [OperatorType::Modulo, false, Rounding::HalfEven]; - } - - #[DataProvider('methods')] - public function testAnOperatorIsRoundedByItsMethodAndOperand(OperatorType $method, bool $whole, Rounding $expected): void - { - $arithmetic = new Arithmetic( - counter: Rounding::TowardZero, - operators: Rounding::HalfEven, - division: Rounding::HalfAwayFromZero, - power: Rounding::TowardZero, - ); - - $this->assertSame($expected, $arithmetic->rounding($method, $whole)); - } - - /** - * @return list - */ - private static function describe(Arithmetic $arithmetic): array - { - return [ - $arithmetic->counter, - $arithmetic->operators, - $arithmetic->division, - $arithmetic->power, - $arithmetic->refusesCounterFractions, - $arithmetic->exactOperators, - $arithmetic->refusedWithoutLimit, - $arithmetic->truncatesModulus, - ]; - } - - private static function mongo(): Mongo - { - return new Mongo(new class () extends Client { - public function __construct() - { - } - - #[\Override] - public function connect(): self - { - return $this; - } - - #[\Override] - public function close(): void - { - } - }); - } - - /** - * @return UtopiaPool - */ - private function connections(Adapter $adapter): UtopiaPool - { - /** @var UtopiaPool&Stub $connections */ - $connections = self::createStub(UtopiaPool::class); - $connections->method('use')->willReturnCallback( - static fn (callable $callback): mixed => $callback($adapter), - ); - - return $connections; - } -} diff --git a/tests/unit/Adapter/ReadWritePoolMetadataTest.php b/tests/unit/Adapter/ReadWritePoolMetadataTest.php index a80c35d6b..fa81f7200 100644 --- a/tests/unit/Adapter/ReadWritePoolMetadataTest.php +++ b/tests/unit/Adapter/ReadWritePoolMetadataTest.php @@ -41,7 +41,6 @@ final class ReadWritePoolMetadataTest extends TestCase public static function metadataCalls(): iterable { $calls = [ - 'arithmetic' => [], 'supports' => [Capability::IndexKey], 'capabilities' => [], 'getAttributeWidth' => [new Document()], @@ -96,9 +95,6 @@ public function testMetadataCallIsAnsweredWhereReadsGoWithoutOpeningTheStickyWin if ($method === 'builder') { $answer->willReturn(new SQLiteBuilder()); } - if ($method === 'arithmetic') { - $answer->willReturn((new Memory())->arithmetic()); - } $primary->expects($this->never())->method($method); $replica->expects($this->once())->method('ping')->willReturn(true); $primary->expects($this->never())->method('ping'); diff --git a/tests/unit/Adapter/ReadWritePoolTest.php b/tests/unit/Adapter/ReadWritePoolTest.php index 2251d3145..34d72ec08 100644 --- a/tests/unit/Adapter/ReadWritePoolTest.php +++ b/tests/unit/Adapter/ReadWritePoolTest.php @@ -110,7 +110,6 @@ public function testReadMethodsRouteToReadPool(): void 'getCountOfAttributes', 'getCountOfIndexes', 'limits', - 'arithmetic', 'supports', 'capabilities', 'hasFeature', @@ -607,7 +606,6 @@ private function getDefaultReturnForMethod(string $method): mixed 'exists', 'collectionExists', 'ping', 'supports', 'hasFeature' => true, 'id' => 'string', 'limits' => (new Memory())->limits(), - 'arithmetic' => (new Memory())->arithmetic(), 'getSchemaAttributes', 'getSchemaIndexes', 'capabilities', 'decode' => [], 'encode' => 'POINT(1 2)', 'builder' => new \Utopia\Database\Builder\SQLite(), diff --git a/tests/unit/Adapter/RedisAdapterPathsTest.php b/tests/unit/Adapter/RedisAdapterPathsTest.php index 78452fe7a..57cc3a074 100644 --- a/tests/unit/Adapter/RedisAdapterPathsTest.php +++ b/tests/unit/Adapter/RedisAdapterPathsTest.php @@ -15,7 +15,6 @@ use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Exception\Query as QueryException; -use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Exception\Transaction as TransactionException; use Utopia\Database\Exception\Unique as UniqueException; use Utopia\Database\Hook\Relationships; @@ -394,7 +393,7 @@ public function testUniqueIndexComparesArrayValuesByContent(): void $adapter->createDocument($this->notes(), new Document(['$id' => 'second', '$permissions' => [], 'tags' => ['a', 'b']])); } - public function testFractionalOperatorLimitIsRefusedBeforeTheWrite(): void + public function testAFractionalOperatorLimitIsFlooredBeforeTheWrite(): void { $database = $this->database(); $database->create(); @@ -405,13 +404,10 @@ public function testFractionalOperatorLimitIsRefusedBeforeTheWrite(): void )); $database->createDocument(self::NOTES, new Document(['$id' => 'counter', 'count' => 100, 'big' => PHP_INT_MAX - 5])); - try { - $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->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()); - } + $database->updateDocument(self::NOTES, 'counter', new Document(['count' => Operator::increment(3, 102.4)])); $this->assertSame(100, $database->getDocument(self::NOTES, 'counter')->getAttribute('count')); + $database->updateDocument(self::NOTES, 'counter', new Document(['count' => Operator::increment(2, 102.4)])); + $this->assertSame(102, $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/Arithmetic/DecimalTest.php b/tests/unit/Arithmetic/DecimalTest.php deleted file mode 100644 index 7f7e0124f..000000000 --- a/tests/unit/Arithmetic/DecimalTest.php +++ /dev/null @@ -1,157 +0,0 @@ - - */ - public static function nonDecimals(): iterable - { - yield 'empty' => ['']; - yield 'whitespace' => [' ']; - yield 'sign without digits' => ['+']; - yield 'point without digits' => ['.']; - yield 'exponent without a mantissa' => ['e5']; - yield 'exponent without digits' => ['1e']; - yield 'exponent without a mantissa digit' => ['.e5']; - yield 'exponent with two signs' => ['1e-+5']; - yield 'two exponent markers' => ['1ee5']; - yield 'fractional exponent' => ['1e5.0']; - yield 'hexadecimal' => ['0x1A']; - yield 'digit separators' => ['1_000']; - yield 'two decimal points' => ['1.2.3']; - yield 'two signs' => ['--5']; - yield 'mixed signs' => ['+-1']; - yield 'infinity' => ['INF']; - yield 'padded with a null byte' => ["10.5\0"]; - yield 'integer padded with a null byte' => ["1\0"]; - } - - #[DataProvider('nonDecimals')] - public function testTextThatIsNotADecimalIsNotParsed(string $value): void - { - $this->assertNull(Decimal::parse($value)); - } - - /** - * @return iterable - */ - public static function decimals(): iterable - { - yield 'fractional' => ['10.5', 10, 11]; - yield 'negative fraction of one' => ['-0.5', -1, 0]; - yield 'whole' => ['10.000', 10, 10]; - yield 'padded with every numeric whitespace' => [" \t\n\r\v\f10.5 \t\n\r\v\f", 10, 11]; - yield 'zero with an exponent' => ['-0.000e50', 0, 0]; - yield 'explicitly positive exponent' => ['1e+5', 100000, 100000]; - yield 'upper case exponent marker' => ['1.5E+2', 150, 150]; - yield 'point before the exponent' => ['1.e5', 100000, 100000]; - yield 'fraction without a whole part and an exponent' => ['-.5e1', -5, -5]; - yield 'negative exponent' => ['1.5e-1', 0, 1]; - yield 'fractional at the unsigned limit' => ['18446744073709551615.9', '18446744073709551615', '18446744073709551616']; - yield 'beyond the unsigned limit' => ['1e400', '184467440737095516150', '184467440737095516150']; - yield 'below the negative unsigned limit' => ['-1e400', '-184467440737095516150', '-184467440737095516150']; - yield 'an integer' => [-7, -7, -7]; - yield 'a float' => [-2.5, -3, -2]; - } - - #[DataProvider('decimals')] - public function testADecimalRoundsDownByFloorAndUpByCeiling(int|float|string $value, int|string $floor, int|string $ceiling): void - { - $decimal = Decimal::parse($value); - - $this->assertInstanceOf(Decimal::class, $decimal); - $this->assertSame($floor, $decimal->floor()); - $this->assertSame($ceiling, $decimal->ceil()); - } - - /** - * @return iterable - */ - public static function fractions(): iterable - { - yield 'fractional text' => ['1.5', [15, 1]]; - yield 'fractional float' => [1.5, [15, 1]]; - yield 'negative fraction' => ['-2.75', [-275, 2]]; - yield 'signed fraction' => ['+2.75', [275, 2]]; - yield 'exponent scaling a fraction up' => ['1.5e2', [150, 0]]; - yield 'exponent scaling down' => ['15e-3', [15, 3]]; - yield 'integer' => [10, [10, 0]]; - yield 'zero' => ['0.000', [0, 0]]; - yield 'beyond float precision' => ['9007199254740993.5', [90071992547409935, 1]]; - yield 'beyond the native integer range' => ['18446744073709551615.5', ['184467440737095516155', 1]]; - yield 'too large to write out' => ['1e1000000000', null]; - yield 'too close to zero to write out' => ['1e-1000000000', null]; - yield 'padded with every numeric whitespace' => [" \t\n\r\v\f-2.75 \t\n\r\v\f", [-275, 2]]; - yield 'led by a form feed' => ["\f1.5", [15, 1]]; - yield 'trailing zeros past the fraction' => ['1.5' . \str_repeat('0', 10000), [15, 1]]; - yield 'as many significant digits as are read' => ['1.' . \str_repeat('3', 127), ['1' . \str_repeat('3', 127), 127]]; - yield 'more significant digits than are read' => ['1.' . \str_repeat('3', 128), null]; - yield 'infinite' => [\INF, null]; - yield 'negative infinite' => [-\INF, null]; - yield 'not a number' => [\NAN, null]; - } - - /** - * @param array{int|string, int}|null $expected - */ - #[DataProvider('fractions')] - public function testANumberIsAnIntegerOverAPowerOfTen(int|float|string $value, ?array $expected): void - { - $this->assertSame($expected, Decimal::fraction($value)); - } - - /** - * @return iterable - */ - public static function orders(): iterable - { - yield 'hundreds' => [123, 2, false]; - yield 'thousandths' => ['0.001', -3, false]; - yield 'a negative fraction' => [-0.5, -1, false]; - yield 'zero' => [0, null, false]; - yield 'a zero with an exponent' => ['0e5', null, false]; - yield 'a huge exponent' => ['1e1000000000', 1000000000, false]; - yield 'a vanishing exponent' => ['1e-1000000000', -1000000000, true]; - yield 'just inside the written-out range' => ['1e-64', -64, false]; - yield 'just past the written-out range' => ['1e-65', -65, true]; - yield 'an exponent too long for an integer' => ['1e99999999999999999999', \PHP_INT_MAX, false]; - yield 'a negative exponent too long for an integer' => ['1e-99999999999999999999', \PHP_INT_MIN, true]; - yield 'led by a form feed' => ["\f0.001", -3, false]; - } - - /** - * @return iterable - */ - public static function integers(): iterable - { - yield 'an integer' => [7, 7]; - yield 'integer text' => ['-7', -7]; - yield 'exponent text' => ['1e3', 1000]; - yield 'whole decimal exponent text' => ['2.50e1', 25]; - yield 'whole decimal text' => ['10.000', 10]; - yield 'exponent text beyond the native range' => ['1e19', '10000000000000000000']; - yield 'fractional text' => ['1.5', null]; - yield 'fractional exponent text' => ['1e-3', null]; - yield 'a fractional float' => [2.5, null]; - } - - #[DataProvider('integers')] - public function testOnlyAWholeNumberIsAnInteger(int|float|string $value, int|string|null $expected): void - { - $this->assertSame($expected, Decimal::parse($value)?->integer()); - } - - #[DataProvider('orders')] - public function testTheOrderOfANumberIsThePowerOfTenOfItsLeadingDigit(int|float|string $value, ?int $order, bool $tiny): void - { - $this->assertSame($order, Decimal::order($value)); - $this->assertSame($tiny, Decimal::isTiny($value)); - } -} diff --git a/tests/unit/Arithmetic/FractionalTest.php b/tests/unit/Arithmetic/FractionalTest.php deleted file mode 100644 index 252c1d6b5..000000000 --- a/tests/unit/Arithmetic/FractionalTest.php +++ /dev/null @@ -1,501 +0,0 @@ -, int|null}> - */ - public static function operators(): iterable - { - return FractionalResults::operators(); - } - - /** - * @param list $values - */ - #[DataProvider('operators')] - public function testAFractionalOperandStoresWhatTheEngineStored(FractionalEngine $engine, int $stored, OperatorType $method, array $values, ?int $expected): void - { - $operator = new Operator($method, 'count', $values); - $this->assertTrue(Fractional::needsStored($operator)); - - $resolved = self::fractional($engine)->operator($operator, $stored); - - if ($expected === null) { - $this->assertSame($operator, $resolved, 'The engine refused it, so the caller must see it unchanged'); - - return; - } - - $this->assertContains($resolved->getMethod(), [OperatorType::Increment, OperatorType::Decrement]); - $this->assertSame('count', $resolved->getAttribute()); - $this->assertCount(1, $resolved->getValues(), 'A resolved change carries no limit'); - $this->assertSame($expected, self::stores($stored, $resolved)); - } - - public function testAWholeOperandAndLimitNeedNoStoredValue(): void - { - $this->assertFalse(Fractional::needsStored(Operator::increment(2, 10))); - $this->assertFalse(Fractional::needsStored(Operator::multiply(2.0))); - $this->assertFalse(Fractional::needsStored(Operator::divide('4'))); - $this->assertFalse(Fractional::needsStored(Operator::arrayAppend([1.5]))); - $this->assertFalse(Fractional::needsStored(Operator::increment(\INF))); - $this->assertTrue(Fractional::needsStored(Operator::increment(2, 10.5))); - $this->assertTrue(Fractional::needsStored(Operator::divide('2.5'))); - } - - public function testAMissingStoredValueCountsAsZero(): void - { - $resolved = self::fractional(FractionalEngine::MariaDB)->operator(Operator::increment(1.5), null); - - $this->assertSame(OperatorType::Increment, $resolved->getMethod()); - $this->assertSame(2, $resolved->getValue()); - } - - public function testAFractionalChangeBeyondFloatPrecisionStaysExact(): void - { - $resolved = self::fractional(FractionalEngine::MariaDB)->operator(Operator::increment(1.5), 9007199254740993); - - $this->assertSame(1, $resolved->getValue(), '9007199254740993 + 1.5 rounds half to even to 9007199254740994'); - } - - /** - * @return iterable - */ - public static function numericOnPostgreSQLBeyondFloatPrecision(): iterable - { - yield 'multiply' => [Operator::multiply(1.5, 99999999999999999), 9007199254740993, 13510798882111490]; - yield 'divide' => [Operator::divide(0.5, 0), 9007199254740993, 18014398509481986]; - yield 'modulo' => [Operator::modulo(2.5), 9007199254740993, 1]; - } - - #[DataProvider('numericOnPostgreSQLBeyondFloatPrecision')] - public function testPostgreSQLComputesFractionalFactorsExactlyAsNumericDid(Operator $operator, int $stored, int $expected): void - { - $resolved = self::fractional(FractionalEngine::PostgreSQL)->operator($operator, $stored); - - $this->assertSame($expected, self::stores($stored, $resolved)); - } - - public function testAnOperandTooLongForAnIntegerIsLeftForTheCallerToRefuse(): void - { - $fractional = self::fractional(FractionalEngine::PostgreSQL); - - foreach ([Operator::multiply('1e1000000000', 1.5), new Operator(OperatorType::Divide, 'count', ['1e-1000000000', 0.5]), Operator::multiply('1e99999999999999999999', 1.5)] as $operator) { - $this->assertSame($operator, $fractional->operator($operator, 10)); - } - } - - public function testAResultThatIsNotFiniteIsLeftForTheCallerToRefuse(): void - { - $operator = Operator::power(100.5); - - $this->assertSame($operator, self::fractional(FractionalEngine::MariaDB)->operator($operator, \PHP_INT_MAX)); - } - - /** - * @return iterable - */ - public static function nonFiniteOperands(): iterable - { - foreach (['an infinite' => \INF, 'a negative infinite' => -\INF, 'a not-a-number' => \NAN] as $name => $value) { - yield "{$name} increment under a fractional maximum" => [new Operator(OperatorType::Increment, 'count', [$value, 10.5])]; - yield "{$name} multiplication under a fractional maximum" => [new Operator(OperatorType::Multiply, 'count', [$value, 10.5])]; - yield "{$name} division above a fractional minimum" => [new Operator(OperatorType::Divide, 'count', [$value, 0.5])]; - yield "a fractional increment under {$name} maximum" => [new Operator(OperatorType::Increment, 'count', [1.5, $value])]; - yield "a fractional multiplication under {$name} maximum" => [new Operator(OperatorType::Multiply, 'count', [1.5, $value])]; - yield "a fractional division above {$name} minimum" => [new Operator(OperatorType::Divide, 'count', [0.5, $value])]; - yield "a fractional power under {$name} maximum" => [new Operator(OperatorType::Power, 'count', [0.5, $value])]; - } - } - - #[DataProvider('nonFiniteOperands')] - public function testANonFiniteOperandOrLimitIsLeftForTheCallerToRefuse(Operator $operator): void - { - foreach (FractionalEngine::cases() as $engine) { - $this->assertSame($operator, self::fractional($engine)->operator($operator, 10), $engine->name); - } - } - - /** - * @return iterable - */ - public static function overlongOperands(): iterable - { - yield 'a factor' => [Operator::multiply('1.' . \str_repeat('3', 10000), 100)]; - yield 'a divisor' => [Operator::divide('1.' . \str_repeat('3', 10000), 0)]; - yield 'a modulus' => [Operator::modulo('1.' . \str_repeat('3', 10000))]; - yield 'a maximum' => [Operator::multiply(1.5, '15.' . \str_repeat('9', 200))]; - yield 'a minimum' => [Operator::divide(0.5, '-1.' . \str_repeat('9', 200))]; - } - - #[DataProvider('overlongOperands')] - public function testANumberWithMoreDigitsThanAnExactEngineReadIsLeftForTheCallerToRefuse(Operator $operator): void - { - $this->assertSame($operator, self::fractional(FractionalEngine::PostgreSQL)->operator($operator, 10)); - } - - public function testTrailingZerosPastTheFractionAreReadByTheirValue(): void - { - $resolved = self::fractional(FractionalEngine::PostgreSQL)->operator(Operator::multiply('1.5' . \str_repeat('0', 10000), 100), 10); - - $this->assertSame(15, self::stores(10, $resolved)); - } - - /** - * @return iterable - */ - public static function whitespacePaddedOperands(): iterable - { - yield 'a factor led by a form feed' => [Operator::multiply("\f1.5", 100), 15]; - yield 'a factor padded with every numeric whitespace' => [Operator::multiply(" \t\n\r\v\f1.5 \t\n\r\v\f", 100), 15]; - yield 'a huge negative maximum led by a form feed' => [Operator::multiply(1.5, "\f-1e1000000000"), 10]; - yield 'a tiny negative minimum led by a form feed' => [Operator::divide(0.5, "\f-1e-1000000000"), 20]; - } - - #[DataProvider('whitespacePaddedOperands')] - public function testNumericTextPaddedWithWhitespaceIsReadByItsValue(Operator $operator, int $expected): void - { - $this->assertSame($expected, self::stores(10, self::fractional(FractionalEngine::PostgreSQL)->operator($operator, 10))); - } - - /** - * @return iterable - */ - public static function truncatedModuli(): iterable - { - yield 'a modulus cut toward zero' => [11, Operator::modulo(4.5), 3]; - yield 'a negative modulus cut toward zero' => [7, Operator::modulo(-2.5), 1]; - yield 'a negative value keeps its sign' => [-11, Operator::modulo(3.5), -2]; - yield 'modulus text cut toward zero' => [13, Operator::modulo('3.5'), 1]; - yield 'a value beyond float precision' => ['9007199254740993', Operator::modulo(2.5), 1]; - yield 'a modulus cut to zero is left for the caller to refuse' => [10, Operator::modulo(0.5), null]; - yield 'a negative modulus cut to zero is left for the caller to refuse' => [10, Operator::modulo('-0.75'), null]; - } - - #[DataProvider('truncatedModuli')] - public function testAnEngineThatCutAFractionalModulusTakesTheRemainderOfItsWholePart(int|string $stored, Operator $operator, int|string|null $expected): void - { - $resolved = self::fractional(FractionalEngine::Memory)->operator($operator, $stored); - - if ($expected === null) { - $this->assertSame($operator, $resolved); - - return; - } - - $change = $resolved->getValue(); - $this->assertTrue(BigInt::isIntegerValue($change)); - $this->assertSame($expected, $resolved->getMethod() === OperatorType::Increment ? BigInt::add($stored, $change) : BigInt::subtract($stored, $change)); - } - - /** - * @return iterable - */ - public static function oversizedLimits(): iterable - { - yield 'a huge maximum holds nothing back' => [Operator::multiply(1.5, '1e1000000000'), 15]; - yield 'a huge negative maximum holds everything back' => [Operator::multiply(1.5, '-1e1000000000'), 10]; - yield 'a tiny positive maximum holds back a positive result' => [Operator::multiply(1.5, '1e-1000000000'), 10]; - yield 'a huge negative minimum holds nothing back' => [Operator::divide(0.5, '-1e1000000000'), 20]; - yield 'a tiny negative minimum lets a positive result through' => [Operator::divide(0.5, '-1e-1000000000'), 20]; - yield 'a maximum with many fractional digits just above one holds back 15' => [Operator::multiply(1.5, '1.' . \str_repeat('0', 80) . '1'), 10]; - yield 'a maximum with many fractional digits just below sixteen lets 15 through' => [Operator::multiply(1.5, '15.' . \str_repeat('9', 80)), 15]; - yield 'a tiny factor leaves zero' => [Operator::multiply('1e-1000000000', 100), 0]; - yield 'an exponent written with leading zeros is read by its value' => [Operator::multiply('1.5e-00000', 100), 15]; - yield 'a long fraction scaled up by its exponent is read by its value' => [Operator::multiply('0.' . \str_repeat('0', 99) . '15e100', 100), 15]; - } - - #[DataProvider('oversizedLimits')] - public function testALimitTooLongToWriteOutStillBoundsTheResult(Operator $operator, int $expected): void - { - $resolved = self::fractional(FractionalEngine::PostgreSQL)->operator($operator, 10); - - $this->assertSame($expected, self::stores(10, $resolved)); - } - - public function testFractionalArrayItemsAreCutTowardZero(): void - { - $this->assertSame([1, -1, -2, 2, 0, 7], Fractional::whole(Operator::arrayAppend([1.5, -1.5, -2.5, 2.5, 0.9999, 7]))->getValues()); - $this->assertSame([0, 3], Fractional::whole(Operator::arrayPrepend([0.5, 3.7]))->getValues()); - $this->assertSame([[1, 2], 3], Fractional::whole(Operator::arrayAppend([[1.5, 2], '3.9']))->getValues()); - $this->assertSame([1, 3], Fractional::whole(Operator::arrayInsert(1, 3.5))->getValues()); - $this->assertSame(['a', 2], Fractional::whole(Operator::arrayAppend(['a', 2]))->getValues()); - $this->assertSame([1.5], Fractional::whole(Operator::increment(1.5))->getValues()); - } - - public function testCuttingArrayItemsLeavesTheGivenOperatorAsItWas(): void - { - $operator = Operator::arrayAppend([1.5]); - - Fractional::whole($operator); - - $this->assertSame([1.5], $operator->getValues()); - } - - /** - * @return iterable - */ - public static function counters(): iterable - { - return FractionalResults::counters(); - } - - #[DataProvider('counters')] - public function testAFractionalCounterChangeStoresWhatTheEngineStored(FractionalEngine $engine, bool $increase, int $start, float $value, int|float|null $bound, ?int $stored): void - { - $fractional = self::fractional($engine); - - if ($stored === null) { - $this->expectException(TypeException::class); - $this->expectExceptionMessage('Change value must be an integer.'); - } - - $change = $fractional->counter($start, $value, $increase); - - $this->assertSame($stored, $increase ? BigInt::add($start, $change) : BigInt::subtract($start, $change)); - } - - /** - * @return iterable - */ - public static function wholeCounterChanges(): iterable - { - yield 'integer' => [2, 2]; - yield 'whole float' => [2.0, 2]; - yield 'integer text' => ['2', 2]; - yield 'integer text beyond the native range' => ['18446744073709551615', '18446744073709551615']; - yield 'exponent text' => ['1e3', 1000]; - yield 'whole decimal exponent text' => ['2.50e1', 25]; - yield 'exponent text beyond the native range' => ['1e19', '10000000000000000000']; - } - - #[DataProvider('wholeCounterChanges')] - public function testAWholeCounterChangeIsTheChangeOnEveryEngine(int|float|string $value, int|string $expected): void - { - foreach (FractionalEngine::cases() as $engine) { - $this->assertSame($expected, self::fractional($engine)->counter(5, $value, true), $engine->name); - } - } - - public function testFractionalTextChangesAsItsNumber(): void - { - $this->assertSame(1, self::fractional(FractionalEngine::MariaDB)->counter(5, '1.5', true)); - } - - /** - * @return iterable - */ - public static function unusableCounterChanges(): iterable - { - yield 'infinite' => [\INF, 'Change value must be an integer.']; - yield 'not a number' => [\NAN, 'Change value must be an integer.']; - yield 'non-numeric text' => ['ten', 'Value must be numeric and greater than 0']; - yield 'zero' => [0, 'Value must be numeric and greater than 0']; - yield 'negative' => [-1, 'Value must be numeric and greater than 0']; - yield 'negative fraction' => [-1.5, 'Value must be numeric and greater than 0']; - yield 'negative fractional text' => ['-0.5', 'Value must be numeric and greater than 0']; - yield 'negative integer text' => ['-18446744073709551615', 'Value must be numeric and greater than 0']; - } - - #[DataProvider('unusableCounterChanges')] - public function testACounterChangeThatIsNotAPositiveNumberIsRefused(int|float|string $value, string $message): void - { - $this->expectException(TypeException::class); - $this->expectExceptionMessage($message); - - self::fractional(FractionalEngine::MariaDB)->counter(5, $value, true); - } - - public function testAFractionalStoredValueIsReadCutTowardZero(): void - { - $resolved = self::fractional(FractionalEngine::MongoDB)->operator(Operator::increment(1.5), 6.5); - - $this->assertSame(7, self::stores(6, $resolved)); - } - - public function testAnOperatorThatIsNotNumericOrHasANonNumericOperandIsUnchanged(): void - { - $fractional = self::fractional(FractionalEngine::MariaDB); - - foreach ([Operator::arrayAppend([1.5]), Operator::increment('ten', 10.5), Operator::multiply(1.5, 'ten')] as $operator) { - $this->assertSame($operator, $fractional->operator($operator, 10)); - } - - $operator = Operator::increment(1.5); - $this->assertSame($operator, $fractional->operator($operator, 'ten'), 'A stored value that is not a number is left for the caller'); - } - - /** - * @return iterable - */ - public static function changes(): iterable - { - yield 'integer' => [2, true]; - yield 'whole float' => [2.0, true]; - yield 'integer text' => ['2', true]; - yield 'fractional' => [1.5, false]; - yield 'fractional text' => ['1.5', false]; - yield 'infinite' => [\INF, false]; - } - - #[DataProvider('changes')] - public function testOnlyAWholeChangeIsWhole(int|float|string $value, bool $whole): void - { - $this->assertSame($whole, Fractional::isWhole($value)); - } - - /** - * @return iterable - */ - public static function values(): iterable - { - yield 'fractional float' => [1.5, true]; - yield 'fractional text' => ['-0.25', true]; - yield 'fractional exponent text' => ['1.25e1', true]; - yield 'integer' => [2, false]; - yield 'whole float' => [2.0, false]; - yield 'whole decimal text' => ['2.000', false]; - yield 'integer text' => ['2', false]; - yield 'infinite' => [\INF, false]; - yield 'not a number' => [\NAN, false]; - yield 'non-numeric text' => ['1.5x', false]; - yield 'null' => [null, false]; - yield 'array' => [[1.5], false]; - } - - #[DataProvider('values')] - public function testOnlyAFiniteNumberWithAFractionIsFractional(mixed $value, bool $fractional): void - { - $this->assertSame($fractional, Fractional::isFractional($value)); - } - - /** - * @return iterable - */ - public static function integerBounds(): iterable - { - yield 'fractional' => [10.5, 10, 11]; - yield 'negative fractional' => [-2.5, -3, -2]; - yield 'just under a whole number' => [9.999, 9, 10]; - yield 'whole float' => [10.0, 10, 10]; - yield 'integer' => [10, 10, 10]; - yield 'fractional text' => ['10.5', 10, 11]; - yield 'integer text' => ['10', '10', '10']; - yield 'beyond the native integer range' => [1.0e19, '10000000000000000000', '10000000000000000000']; - yield 'fractional text beyond float precision' => ['9007199254740993.5', 9007199254740993, 9007199254740994]; - yield 'negative fractional text beyond float precision' => ['-9007199254740993.5', -9007199254740994, -9007199254740993]; - yield 'negative fractional text' => ['-5.5', -6, -5]; - yield 'negative fraction of one text' => ['-0.5', -1, 0]; - yield 'fraction of one text' => ['0.5', 0, 1]; - yield 'whole decimal text' => ['10.000', 10, 10]; - yield 'leading zeros text' => ['0000000000000000000005.5', 5, 6]; - yield 'fractional text above the native integer range' => ['9223372036854775807.5', 9223372036854775807, '9223372036854775808']; - yield 'fractional text below the native integer range' => ['-9223372036854775808.5', '-9223372036854775809', \PHP_INT_MIN]; - yield 'fractional text at the unsigned limit' => ['18446744073709551615.9', '18446744073709551615', '18446744073709551616']; - yield 'exponent text' => ['1e3', 1000, 1000]; - yield 'capital exponent text' => ['1E3', 1000, 1000]; - yield 'fractional exponent text' => ['1.25e1', 12, 13]; - yield 'negative fractional exponent text' => ['-1.25e1', -13, -12]; - yield 'negative exponent text' => ['1e-3', 0, 1]; - yield 'negative value with a negative exponent text' => ['-1e-3', -1, 0]; - yield 'vanishing exponent text' => ['1e-999999999', 0, 1]; - yield 'negative value with a vanishing exponent text' => ['-1e-999999999', -1, 0]; - yield 'exponent beyond the native integer range text' => ['5e-9223372036854775808', 0, 1]; - yield 'zero exponent text beyond float precision' => ['9007199254740993.5e0', 9007199254740993, 9007199254740994]; - yield 'padded fractional text' => [' 10.5 ', 10, 11]; - yield 'fractional text padded with form feed and vertical tab' => ["\f10.5\v", 10, 11]; - yield 'signed fractional text' => ['+10.5', 10, 11]; - yield 'fraction without a whole part text' => ['.5', 0, 1]; - yield 'whole part without a fraction text' => ['5.', 5, 5]; - yield 'negative zero text' => ['-0.0', 0, 0]; - yield 'zero with a huge exponent text' => ['0e999999999', 0, 0]; - yield 'zero decimal with an exponent text' => ['0.000e50', 0, 0]; - yield 'negative zero with an exponent text' => ['-0e5', 0, 0]; - yield 'no bound' => [null, null, null]; - } - - #[DataProvider('integerBounds')] - public function testAFractionalMaximumRoundsDownAndAMinimumUp(int|float|string|null $bound, int|float|string|null $maximum, int|float|string|null $minimum): void - { - $this->assertSame($maximum, Fractional::maximum($bound)); - $this->assertSame($minimum, Fractional::minimum($bound)); - } - - /** - * @return iterable - */ - public static function nonNumericBounds(): iterable - { - yield 'empty' => ['']; - yield 'exponent without digits' => ['1e']; - yield 'hexadecimal' => ['0x1A']; - yield 'digit separators' => ['1_000']; - yield 'two decimal points' => ['1.2.3']; - yield 'two signs' => ['--5']; - yield 'a word' => ['ten']; - yield 'infinite' => [\INF]; - } - - #[DataProvider('nonNumericBounds')] - public function testABoundThatIsNotAFiniteNumberIsPassedOnUnchanged(int|float|string $bound): void - { - $this->assertSame($bound, Fractional::maximum($bound)); - $this->assertSame($bound, Fractional::minimum($bound)); - } - - private static function fractional(FractionalEngine $engine): Fractional - { - $adapter = match ($engine) { - FractionalEngine::MariaDB => new MariaDB(new stdClass()), - FractionalEngine::PostgreSQL => new Postgres(new stdClass()), - FractionalEngine::Memory => new Memory(), - FractionalEngine::MongoDB => new Mongo(new class () extends Client { - public function __construct() - { - } - - #[\Override] - public function connect(): self - { - return $this; - } - - #[\Override] - public function close(): void - { - } - }), - }; - - return new Fractional($adapter->arithmetic()); - } - - private static function stores(int $stored, Operator $resolved): int|string - { - $change = $resolved->getValue(); - self::assertTrue(BigInt::isIntegerValue($change), 'A resolved change is a whole number'); - - return $resolved->getMethod() === OperatorType::Increment - ? BigInt::add($stored, $change) - : BigInt::subtract($stored, $change); - } -} diff --git a/tests/unit/Arithmetic/RoundingTest.php b/tests/unit/Arithmetic/RoundingTest.php deleted file mode 100644 index 9d08be752..000000000 --- a/tests/unit/Arithmetic/RoundingTest.php +++ /dev/null @@ -1,83 +0,0 @@ - - */ - public static function values(): iterable - { - yield 'a half below an even number' => [2.5, 2, 3, 2]; - yield 'a half below an odd number' => [3.5, 4, 4, 3]; - yield 'a negative half' => [-2.5, -2, -3, -2]; - yield 'a negative half below an odd number' => [-3.5, -4, -4, -3]; - yield 'above a half' => [2.6, 3, 3, 2]; - yield 'below a half' => [2.4, 2, 2, 2]; - yield 'a negative value above a half' => [-2.6, -3, -3, -2]; - yield 'a negative value below a half' => [-2.4, -2, -2, -2]; - yield 'a whole number' => [7.0, 7, 7, 7]; - yield 'a half of zero' => [0.5, 0, 1, 0]; - yield 'a negative half of zero' => [-0.5, 0, -1, 0]; - } - - #[DataProvider('values')] - public function testAFloatIsRoundedAsEachEngineStoredIt(float $value, int $halfEven, int $halfAwayFromZero, int $towardZero): void - { - $this->assertSame($halfEven, Rounding::HalfEven->round($value)); - $this->assertSame($halfAwayFromZero, Rounding::HalfAwayFromZero->round($value)); - $this->assertSame($towardZero, Rounding::TowardZero->round($value)); - } - - /** - * @return iterable - */ - public static function ratios(): iterable - { - yield 'a half below an even number' => [5, 2, 2, 3, 2]; - yield 'a half below an odd number' => [7, 2, 4, 4, 3]; - yield 'a negative half' => [-5, 2, -2, -3, -2]; - yield 'a negative half below an odd number' => [-7, 2, -4, -4, -3]; - yield 'above a half' => [11, 4, 3, 3, 2]; - yield 'below a half' => [9, 4, 2, 2, 2]; - yield 'a negative value above a half' => [-11, 4, -3, -3, -2]; - yield 'exact' => [6, 3, 2, 2, 2]; - yield 'a half beyond float precision' => ['18014398509481987', 2, 9007199254740994, 9007199254740994, 9007199254740993]; - yield 'a half beyond the native integer range' => ['36893488147419103231', 2, '18446744073709551616', '18446744073709551616', '18446744073709551615']; - } - - #[DataProvider('ratios')] - public function testARatioIsRoundedExactly(int|string $numerator, int|string $denominator, int|string $halfEven, int|string $halfAwayFromZero, int|string $towardZero): void - { - $this->assertSame($halfEven, Rounding::HalfEven->ratio($numerator, $denominator)); - $this->assertSame($halfAwayFromZero, Rounding::HalfAwayFromZero->ratio($numerator, $denominator)); - $this->assertSame($towardZero, Rounding::TowardZero->ratio($numerator, $denominator)); - } - - /** - * @return iterable - */ - public static function fractions(): iterable - { - yield 'no fraction keeps the base' => [9007199254740993, 0.0, 9007199254740993, 9007199254740993, 9007199254740993]; - yield 'a half above an odd base beyond float precision' => [9007199254740993, 0.5, 9007199254740994, 9007199254740994, 9007199254740993]; - yield 'a half above an even base beyond float precision' => [9007199254740994, 0.5, 9007199254740994, 9007199254740995, 9007199254740994]; - yield 'above a half beyond float precision' => [9007199254740993, 0.75, 9007199254740994, 9007199254740994, 9007199254740993]; - yield 'a half above a negative base' => [-9007199254740995, 0.5, -9007199254740994, -9007199254740995, -9007199254740994]; - yield 'a half above the unsigned limit' => ['18446744073709551615', 0.5, '18446744073709551616', '18446744073709551616', '18446744073709551615']; - yield 'a half above the native integer maximum' => [\PHP_INT_MAX, 0.5, '9223372036854775808', '9223372036854775808', \PHP_INT_MAX]; - } - - #[DataProvider('fractions')] - public function testABaseAndItsFractionAreRoundedWithoutPassingThroughAFloat(int|string $base, float $fraction, int|string $halfEven, int|string $halfAwayFromZero, int|string $towardZero): void - { - $this->assertSame($halfEven, Rounding::HalfEven->apply($base, $fraction)); - $this->assertSame($halfAwayFromZero, Rounding::HalfAwayFromZero->apply($base, $fraction)); - $this->assertSame($towardZero, Rounding::TowardZero->apply($base, $fraction)); - } -} diff --git a/tests/unit/Documents/FractionalBoundTest.php b/tests/unit/Documents/FractionalBoundTest.php deleted file mode 100644 index 7b454a485..000000000 --- a/tests/unit/Documents/FractionalBoundTest.php +++ /dev/null @@ -1,200 +0,0 @@ - - */ - public static function lanes(): iterable - { - yield 'defined attributes' => [true]; - yield 'schemaless' => [false]; - } - - #[DataProvider('lanes')] - public function testIncreaseWithAFractionalMaximumOnAnIntegerIsRefused(bool $definedAttributes): void - { - $database = $this->database($definedAttributes); - - try { - $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(100, $this->stored($database, 'count')); - } - - #[DataProvider('lanes')] - public function testDecreaseWithAFractionalMinimumOnAnIntegerIsRefused(bool $definedAttributes): void - { - $database = $this->database($definedAttributes); - - try { - $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(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')] - public function testWholeFloatBoundsOnAnIntegerAreComparedExactly(bool $definedAttributes): void - { - $database = $this->database($definedAttributes); - - $this->assertSame(101, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 101.0)->getAttribute('count')); - - try { - $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 101.0); - $this->fail('An increase past a whole float maximum was accepted'); - } catch (LimitException $error) { - $this->assertSame('Attribute value exceeds maximum limit: 101', $error->getMessage()); - } - - $this->assertSame(102, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 9.0e18)->getAttribute('count')); - $this->assertSame(101, $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, -9.0e18)->getAttribute('count')); - $this->assertSame(102, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 1.0e19)->getAttribute('count')); - $this->assertSame(101, $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, -1.0e19)->getAttribute('count')); - $this->assertSame(101, $this->stored($database, 'count')); - } - - #[DataProvider('lanes')] - public function testWholeNumberStringBoundsOnAnIntegerAreAcceptedAsOperatorLimitsAre(bool $definedAttributes): void - { - $database = $this->database($definedAttributes); - - $this->assertSame(101, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, '101.0')->getAttribute('count')); - - try { - $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, '101.00'); - $this->fail('An increase past a whole number string maximum was accepted'); - } catch (LimitException $error) { - $this->assertSame('Attribute value exceeds maximum limit: 101', $error->getMessage()); - } - - $this->assertSame(100, $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, '-5.0')->getAttribute('count')); - $this->assertSame(101, $database->updateDocument(self::COLLECTION, self::DOCUMENT, new Document([ - 'count' => Operator::increment(1, '101.0'), - ]))->getAttribute('count')); - $this->assertSame(101, $this->stored($database, 'count')); - } - - #[DataProvider('lanes')] - public function testAFractionalChangeValueOnAnIntegerIsRefused(bool $definedAttributes): void - { - $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')); - } - - #[DataProvider('lanes')] - public function testFractionalBoundsOnADoubleAreAccepted(bool $definedAttributes): void - { - $database = $this->database($definedAttributes); - - $this->assertSame(2.5, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'ratio', 1, 2.5)->getAttribute('ratio')); - $this->assertSame(2.0, $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'ratio', 0.5, 2.0)->getAttribute('ratio')); - $this->assertSame(2.0, $this->stored($database, 'ratio')); - } - - private function database(bool $definedAttributes): Database - { - $authorization = new Authorization(); - $authorization->addRole(Role::any()->toString()); - - $adapter = $definedAttributes ? new Memory() : new class () extends Memory { - #[\Override] - public function capabilities(): array - { - return \array_values(\array_filter( - parent::capabilities(), - static fn (Capability $capability): bool => $capability !== Capability::DefinedAttributes, - )); - } - }; - - $database = (new Database($adapter, new Cache(new None()))) - ->setAuthorization($authorization) - ->setDatabase('fractional_bound') - ->setNamespace('fractional_bound'); - $database->create(); - $database->createCollection(Collection::create( - id: self::COLLECTION, - attributes: [ - Attribute::integer(key: 'count'), - Attribute::double(key: 'ratio'), - ], - permissions: [ - Permission::create(Role::any()), - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - documentSecurity: false, - )); - $database->createDocument(self::COLLECTION, new Document([ - '$id' => self::DOCUMENT, - 'count' => 100, - 'ratio' => 1.5, - ])); - - return $database; - } - - private function stored(Database $database, string $attribute): mixed - { - return $database->getDocument(self::COLLECTION, self::DOCUMENT)->getAttribute($attribute); - } -} diff --git a/tests/unit/Documents/FractionalOperatorLimitTest.php b/tests/unit/Documents/FractionalOperatorLimitTest.php deleted file mode 100644 index 621e83a4c..000000000 --- a/tests/unit/Documents/FractionalOperatorLimitTest.php +++ /dev/null @@ -1,87 +0,0 @@ -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(100, $database->getDocument(self::COLLECTION, 'counter')->getAttribute('count')); - } - - public function testUpdateDocumentsRefusesAFractionalLimitBeforeTheWrite(): void - { - $database = $this->database(); - - try { - $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->assertSame(self::REFUSAL, $exception->getMessage()); - } - - $this->assertSame(100, $database->getDocument(self::COLLECTION, 'counter')->getAttribute('count')); - } - - public function testAWholeFloatLimitHoldsABigIntegerAtTheSignedEdge(): void - { - $database = $this->database(); - $database->updateDocument(self::COLLECTION, 'counter', new Document(['big' => PHP_INT_MAX - 5])); - - $updated = $database->updateDocument(self::COLLECTION, 'counter', new Document(['big' => Operator::increment(10, 9.0e18)])); - - $this->assertSame(PHP_INT_MAX - 5, $updated->getAttribute('big')); - $this->assertSame(PHP_INT_MAX - 5, $database->getDocument(self::COLLECTION, 'counter')->getAttribute('big')); - } - - private function database(): Database - { - $database = new Database(new Memory(), new Cache(new None())); - $database - ->setDatabase('fractional_limits') - ->setNamespace('fractional_limits_'.\uniqid()); - $database->getAuthorization()->addRole(Role::any()->toString()); - $database->create(); - $database->createCollection(Collection::create( - id: self::COLLECTION, - attributes: [ - Attribute::integer(key: 'count'), - Attribute::bigInteger(key: 'big'), - ], - permissions: [ - Permission::create(Role::any()), - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - )); - $database->createDocument(self::COLLECTION, new Document(['$id' => 'counter', 'count' => 100, 'big' => 0])); - - return $database; - } -} diff --git a/tests/unit/Documents/IntegerRoundingTest.php b/tests/unit/Documents/IntegerRoundingTest.php new file mode 100644 index 000000000..3583503c7 --- /dev/null +++ b/tests/unit/Documents/IntegerRoundingTest.php @@ -0,0 +1,330 @@ + + */ + public static function adapters(): iterable + { + yield self::MEMORY => [self::MEMORY]; + yield self::SCHEMALESS => [self::SCHEMALESS]; + yield self::SQLITE => [self::SQLITE]; + } + + #[DataProvider('adapters')] + public function testAFractionalAmountIsRoundedHalfAwayFromZero(string $adapter): void + { + $database = $this->database($adapter); + + $this->assertSame(102, $this->increase($database, 'count', 1.5)->getAttribute('count')); + $this->assertSame(105, $this->increase($database, 'count', 2.5)->getAttribute('count')); + $this->assertSame(106, $this->increase($database, 'count', '0.5')->getAttribute('count')); + $this->assertSame(107, $this->increase($database, 'count', 1.49)->getAttribute('count')); + $this->assertSame(109, $this->increase($database, 'count', 2.0)->getAttribute('count')); + $this->assertSame(106, $this->decrease($database, 'count', 2.5)->getAttribute('count')); + $this->assertSame(104, $this->decrease($database, 'count', '1.5')->getAttribute('count')); + $this->assertSame(103, $this->decrease($database, 'count', 0.5)->getAttribute('count')); + $this->assertSame(103, $this->stored($database, 'count')); + } + + #[DataProvider('adapters')] + public function testAnAmountBelowAHalfLeavesTheDocumentUnchanged(string $adapter): void + { + $database = $this->database($adapter); + $updatedAt = $database->getDocument(self::COLLECTION, self::DOCUMENT)->getUpdatedAt(); + + $this->assertSame(100, $this->increase($database, 'count', 0.4)->getAttribute('count')); + $this->assertSame(100, $this->decrease($database, 'count', '0.49')->getAttribute('count')); + $this->assertSame(100, $this->increase($database, 'count', 0.4, 50)->getAttribute('count')); + + $stored = $database->getDocument(self::COLLECTION, self::DOCUMENT); + $this->assertSame(100, $stored->getAttribute('count')); + $this->assertSame($updatedAt, $stored->getUpdatedAt()); + } + + #[DataProvider('adapters')] + public function testBigIntegerTextIsRoundedExactly(string $adapter): void + { + $database = $this->database($adapter); + + $this->assertSame(9007199254740994, $this->increase($database, 'big', '9007199254740993.5')->getAttribute('big')); + $this->assertSame(9007199254740995, $this->increase($database, 'big', 1, '9007199254740995.9')->getAttribute('big')); + $this->assertLimited('Attribute value exceeds maximum limit: 9007199254740995', fn () => $this->increase($database, 'big', 1, '9007199254740995.9')); + $this->assertSame(0, $this->decrease($database, 'big', '9007199254740994.5')->getAttribute('big')); + $this->assertSame(-9007199254740995, $this->decrease($database, 'big', '9007199254740994.5', '-9007199254740995.2')->getAttribute('big')); + + $database->updateDocument(self::COLLECTION, self::DOCUMENT, new Document(['big' => \PHP_INT_MAX - 1])); + $this->assertSame(\PHP_INT_MAX, $this->increase($database, 'big', '0.5')->getAttribute('big')); + $this->assertSame(\PHP_INT_MAX, $this->stored($database, 'big')); + } + + #[DataProvider('adapters')] + public function testAFractionalMaximumIsFlooredAndAFractionalMinimumCeiled(string $adapter): void + { + $database = $this->database($adapter); + + $this->assertSame(101, $this->increase($database, 'count', 1, 101.9)->getAttribute('count')); + $this->assertLimited('Attribute value exceeds maximum limit: 101', fn () => $this->increase($database, 'count', 1, 101.9)); + $this->assertLimited('Attribute value exceeds maximum limit: 101', fn () => $this->increase($database, 'count', 1, '101.5')); + $this->assertSame(100, $this->decrease($database, 'count', 1, 99.1)->getAttribute('count')); + $this->assertLimited('Attribute value exceeds minimum limit: 100', fn () => $this->decrease($database, 'count', 1, 99.1)); + $this->assertLimited('Attribute value exceeds minimum limit: 100', fn () => $this->decrease($database, 'count', 1, '99.5')); + + $database->updateDocument(self::COLLECTION, self::DOCUMENT, new Document(['count' => 1])); + $this->assertSame(0, $this->decrease($database, 'count', 1, -0.5)->getAttribute('count')); + $this->assertLimited('Attribute value exceeds minimum limit: 0', fn () => $this->decrease($database, 'count', 1, -0.5)); + $this->assertLimited('Attribute value exceeds maximum limit: -1', fn () => $this->increase($database, 'count', 1, -0.5)); + $this->assertSame(0, $this->stored($database, 'count')); + } + + #[DataProvider('adapters')] + public function testWholeBoundsAreComparedExactly(string $adapter): void + { + $database = $this->database($adapter); + + $this->assertSame(101, $this->increase($database, 'count', 1, 101.0)->getAttribute('count')); + $this->assertLimited('Attribute value exceeds maximum limit: 101', fn () => $this->increase($database, 'count', 1, '101.00')); + $this->assertSame(102, $this->increase($database, 'count', 1, 9.0e18)->getAttribute('count')); + $this->assertSame(101, $this->decrease($database, 'count', 1, -9.0e18)->getAttribute('count')); + $this->assertSame(102, $this->increase($database, 'count', 1, 1.0e19)->getAttribute('count')); + $this->assertSame(101, $this->decrease($database, 'count', 1, '-5.0')->getAttribute('count')); + $this->assertSame(101, $this->stored($database, 'count')); + } + + #[DataProvider('adapters')] + public function testWhatIsNotAFiniteNumberIsStillRefused(string $adapter): void + { + $database = $this->database($adapter); + + $this->assertRefused('Max must be an integer.', fn () => $this->increase($database, 'count', 1, 'ten')); + $this->assertRefused('Min must be an integer.', fn () => $this->decrease($database, 'count', 1, \NAN)); + $this->assertRefused('Change value must be an integer.', fn () => $this->increase($database, 'count', \INF)); + $this->assertSame(100, $this->stored($database, 'count')); + } + + #[DataProvider('adapters')] + public function testUpdateDocumentRoundsIncrementAndDecrementOperators(string $adapter): void + { + $database = $this->database($adapter); + + $this->assertSame(102, $this->update($database, ['count' => Operator::increment(1.5)])->getAttribute('count')); + $this->assertSame(99, $this->update($database, ['count' => Operator::decrement('2.5')])->getAttribute('count')); + $this->assertSame(96, $this->update($database, ['count' => Operator::increment(-2.5)])->getAttribute('count')); + $this->assertSame(96, $this->update($database, ['count' => Operator::increment(0.4)])->getAttribute('count')); + $this->assertSame(98, $this->update($database, ['count' => Operator::increment(2, 98.7)])->getAttribute('count')); + $this->assertSame(98, $this->update($database, ['count' => Operator::increment(1, 98.7)])->getAttribute('count')); + $this->assertSame(98, $this->update($database, ['count' => Operator::decrement(4, '94.2')])->getAttribute('count')); + $this->assertSame(95, $this->update($database, ['count' => Operator::decrement(3, '94.2')])->getAttribute('count')); + $this->assertSame(9007199254740994, $this->update($database, ['big' => Operator::increment('9007199254740993.5')])->getAttribute('big')); + $this->assertSame(95, $this->stored($database, 'count')); + $this->assertSame(9007199254740994, $this->stored($database, 'big')); + } + + #[DataProvider('adapters')] + public function testUpdateDocumentsRoundsIncrementAndDecrementOperators(string $adapter): void + { + $database = $this->database($adapter); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'second', 'count' => 10, 'big' => 0, 'ratio' => 0.0])); + + $this->assertSame(2, $database->updateDocuments(self::COLLECTION, new Document(['count' => Operator::increment(2.5)]))); + $this->assertSame(103, $this->stored($database, 'count')); + $this->assertSame(13, $this->stored($database, 'count', 'second')); + + $database->updateDocuments(self::COLLECTION, new Document(['count' => Operator::increment(7, 20.9)])); + $this->assertSame(103, $this->stored($database, 'count')); + $this->assertSame(20, $this->stored($database, 'count', 'second')); + + $database->updateDocuments(self::COLLECTION, new Document(['count' => Operator::decrement(0.5, 15.5)])); + $this->assertSame(102, $this->stored($database, 'count')); + $this->assertSame(19, $this->stored($database, 'count', 'second')); + } + + public function testUpsertsRoundIncrementAndDecrementOperators(): void + { + $database = $this->database(self::SQLITE); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'second', 'count' => 10, 'big' => 0, 'ratio' => 0.0])); + + $upserted = $database->upsertDocument(self::COLLECTION, new Document([Document::ID => self::DOCUMENT, 'count' => Operator::increment(1.5)])); + $count = $database->upsertDocuments(self::COLLECTION, [ + new Document([Document::ID => self::DOCUMENT, 'count' => Operator::decrement(2.5, 98.5)]), + new Document([Document::ID => 'second', 'count' => Operator::increment('1.5', '12.7')]), + ]); + + $this->assertSame(102, $upserted->getAttribute('count')); + $this->assertSame(2, $count); + $this->assertSame(99, $this->stored($database, 'count')); + $this->assertSame(12, $this->stored($database, 'count', 'second')); + } + + /** + * @return iterable + */ + public static function refusedOperators(): iterable + { + foreach ([self::MEMORY, self::SQLITE] as $adapter) { + yield "{$adapter} multiply" => [$adapter, Operator::multiply(1.5), 'multiply']; + yield "{$adapter} divide" => [$adapter, Operator::divide(2.5), 'divide']; + yield "{$adapter} modulo" => [$adapter, Operator::modulo(1.5), 'modulo']; + yield "{$adapter} power" => [$adapter, Operator::power(0.5), 'power']; + } + } + + #[DataProvider('refusedOperators')] + public function testAFractionalMultiplyDivideModuloOrPowerOnAnIntegerIsRefused(string $adapter, Operator $operator, string $method): void + { + $database = $this->database($adapter); + + try { + $this->update($database, ['count' => $operator]); + $this->fail("A fractional {$method} on an integer attribute was accepted"); + } catch (StructureException $error) { + $this->assertSame("Invalid document structure: Cannot apply {$method} operator: value must be numeric, got double", $error->getMessage()); + } + + $this->assertSame(100, $this->stored($database, 'count')); + } + + #[DataProvider('adapters')] + public function testFloatAttributesAreNotRounded(string $adapter): void + { + $database = $this->database($adapter); + + $this->assertSame(2.75, $this->increase($database, 'ratio', 1.25)->getAttribute('ratio')); + $this->assertSame(2.25, $this->decrease($database, 'ratio', 0.5, 2.25)->getAttribute('ratio')); + $this->assertLimited('Attribute value exceeds maximum limit: 2.5', fn () => $this->increase($database, 'ratio', 0.5, 2.5)); + $this->assertSame(2.75, $this->update($database, ['ratio' => Operator::increment(0.5)])->getAttribute('ratio')); + $this->assertSame(3.0, $this->update($database, ['ratio' => Operator::increment(0.25, 3.25)])->getAttribute('ratio')); + $this->assertSame(3.0, $this->update($database, ['ratio' => Operator::increment(0.5, 3.25)])->getAttribute('ratio')); + $this->assertSame(1.5, $this->update($database, ['ratio' => Operator::multiply(0.5)])->getAttribute('ratio')); + $this->assertSame(1.5, $this->stored($database, 'ratio')); + } + + private function database(string $adapter): Database + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + + $database = (new Database(self::adapter($adapter), new Cache(new None()))) + ->setAuthorization($authorization) + ->setDatabase('integer_rounding') + ->setNamespace('integer_rounding_'.\uniqid()); + $database->create(); + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [ + Attribute::integer(key: 'count'), + Attribute::bigInteger(key: 'big'), + Attribute::double(key: 'ratio'), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + documentSecurity: false, + )); + $database->createDocument(self::COLLECTION, new Document([ + Document::ID => self::DOCUMENT, + 'count' => 100, + 'big' => 0, + 'ratio' => 1.5, + ])); + + return $database; + } + + private static function adapter(string $adapter): Adapter + { + return match ($adapter) { + self::MEMORY => new Memory(), + self::SCHEMALESS => new class () extends Memory { + #[\Override] + public function capabilities(): array + { + return \array_values(\array_filter( + parent::capabilities(), + static fn (Capability $capability): bool => $capability !== Capability::DefinedAttributes, + )); + } + }, + self::SQLITE => new SQLite(new PDO('sqlite::memory:')), + }; + } + + private function increase(Database $database, string $attribute, int|float|string $value, int|float|string|null $max = null): Document + { + return $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, $attribute, $value, $max); + } + + private function decrease(Database $database, string $attribute, int|float|string $value, int|float|string|null $min = null): Document + { + return $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, $attribute, $value, $min); + } + + /** + * @param array $changes + */ + private function update(Database $database, array $changes): Document + { + return $database->updateDocument(self::COLLECTION, self::DOCUMENT, new Document($changes)); + } + + private function stored(Database $database, string $attribute, string $id = self::DOCUMENT): mixed + { + return $database->getDocument(self::COLLECTION, $id)->getAttribute($attribute); + } + + private function assertLimited(string $message, callable $change): void + { + try { + $change(); + $this->fail("The change was not limited: {$message}"); + } catch (LimitException $error) { + $this->assertSame($message, $error->getMessage()); + } + } + + private function assertRefused(string $message, callable $change): void + { + try { + $change(); + $this->fail("The change was not refused: {$message}"); + } catch (TypeException $error) { + $this->assertSame($message, $error->getMessage()); + } + } +} diff --git a/tests/unit/Documents/RoundFractionsTest.php b/tests/unit/Documents/RoundFractionsTest.php deleted file mode 100644 index 26da29c69..000000000 --- a/tests/unit/Documents/RoundFractionsTest.php +++ /dev/null @@ -1,1091 +0,0 @@ - - */ - public static function integerBounds(): iterable - { - yield 'fractional' => [10.5, 10, 11]; - yield 'negative fractional' => [-2.5, -3, -2]; - yield 'just under a whole number' => [9.999, 9, 10]; - yield 'whole float' => [10.0, 10, 10]; - yield 'integer' => [10, 10, 10]; - yield 'fractional text' => ['10.5', 10, 11]; - yield 'integer text' => ['10', '10', '10']; - yield 'beyond the native integer range' => [1.0e19, '10000000000000000000', '10000000000000000000']; - yield 'fractional text beyond float precision' => ['9007199254740993.5', 9007199254740993, 9007199254740994]; - yield 'negative fractional text beyond float precision' => ['-9007199254740993.5', -9007199254740994, -9007199254740993]; - yield 'negative fractional text' => ['-5.5', -6, -5]; - yield 'negative fraction of one text' => ['-0.5', -1, 0]; - yield 'fraction of one text' => ['0.5', 0, 1]; - yield 'whole decimal text' => ['10.000', 10, 10]; - yield 'leading zeros text' => ['0000000000000000000005.5', 5, 6]; - yield 'fractional text above the native integer range' => ['9223372036854775807.5', \PHP_INT_MAX, '9223372036854775808']; - yield 'fractional text below the native integer range' => ['-9223372036854775808.5', '-9223372036854775809', \PHP_INT_MIN]; - yield 'fractional text at the unsigned limit' => ['18446744073709551615.9', '18446744073709551615', '18446744073709551616']; - yield 'exponent text' => ['1e3', 1000, 1000]; - yield 'capital exponent text' => ['1E3', 1000, 1000]; - yield 'fractional exponent text' => ['1.25e1', 12, 13]; - yield 'negative fractional exponent text' => ['-1.25e1', -13, -12]; - yield 'negative exponent text' => ['1e-3', 0, 1]; - yield 'negative value with a negative exponent text' => ['-1e-3', -1, 0]; - yield 'vanishing exponent text' => ['1e-999999999', 0, 1]; - yield 'negative value with a vanishing exponent text' => ['-1e-999999999', -1, 0]; - yield 'exponent beyond the native integer range text' => ['5e-9223372036854775808', 0, 1]; - yield 'zero exponent text beyond float precision' => ['9007199254740993.5e0', 9007199254740993, 9007199254740994]; - yield 'padded fractional text' => [' 10.5 ', 10, 11]; - yield 'fractional text padded with form feed and vertical tab' => ["\f10.5\v", 10, 11]; - yield 'signed fractional text' => ['+10.5', 10, 11]; - yield 'fraction without a whole part text' => ['.5', 0, 1]; - yield 'whole part without a fraction text' => ['5.', 5, 5]; - yield 'negative zero text' => ['-0.0', 0, 0]; - yield 'zero with a huge exponent text' => ['0e999999999', 0, 0]; - yield 'zero decimal with an exponent text' => ['0.000e50', 0, 0]; - yield 'negative zero with an exponent text' => ['-0e5', 0, 0]; - yield 'no bound' => [null, null, null]; - } - - #[DataProvider('integerBounds')] - public function testAWholeChangeIsHeldAtTheWholeNumberAFractionalBoundRoundsTo(int|float|string|null $bound, int|string|null $maximum, int|string|null $minimum): void - { - $database = $this->database(); - - if ($bound === null || $maximum === null || $minimum === null) { - $this->create($database, ['big' => 5]); - $this->assertSame(6, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'big', 1, $bound)->getAttribute('big')); - $this->assertSame(5, $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'big', 1, $bound)->getAttribute('big')); - - return; - } - - $this->assertHeldAtMaximum($database, $bound, $maximum); - $this->assertHeldAtMinimum($database, $bound, $minimum); - } - - public function testAnIntegerAttributeRoundsItsBoundsLikeABigInteger(): void - { - $database = $this->database(); - $this->create($database, ['count' => 9], 'up'); - $this->create($database, ['count' => 12], 'down'); - - $this->assertSame(10, $database->increaseDocumentAttribute(self::COLLECTION, 'up', 'count', 1, 10.5)->getAttribute('count')); - $this->assertLimited(fn () => $database->increaseDocumentAttribute(self::COLLECTION, 'up', 'count', 1, 10.5)); - $this->assertSame(11, $database->decreaseDocumentAttribute(self::COLLECTION, 'down', 'count', 1, 10.5)->getAttribute('count')); - $this->assertLimited(fn () => $database->decreaseDocumentAttribute(self::COLLECTION, 'down', 'count', 1, 10.5)); - - $this->assertSame(10, $this->stored($database, 'count', 'up')); - $this->assertSame(11, $this->stored($database, 'count', 'down')); - } - - /** - * @return iterable|null}> - */ - public static function unroundedAttributes(): iterable - { - yield 'double' => ['ratio', null]; - yield 'integer array' => ['scores', TypeException::class]; - yield 'undeclared' => ['missing', NotFoundException::class]; - } - - /** - * @param class-string|null $refusal - */ - #[DataProvider('unroundedAttributes')] - public function testOtherAttributesKeepTheirBoundsAndChangeValues(string $attribute, ?string $refusal): void - { - $database = $this->database(); - $this->create($database, ['ratio' => 5.25]); - - if ($refusal !== null) { - $this->expectException($refusal); - } - - $this->assertSame(6.75, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, $attribute, 1.5, 6.8)->getAttribute($attribute)); - $this->assertSame(5.25, $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, $attribute, 1.5, 5.2)->getAttribute($attribute)); - $this->assertSame(5.25, $this->stored($database, $attribute)); - } - - public function testBoundsTheLibraryRefusesAnywayArePassedOnUnchanged(): void - { - $database = $this->database(); - $this->create($database, ['count' => 5]); - - $this->assertRefused('Max must be an integer.', fn () => $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, \INF)); - $this->assertRefused('Min must be an integer.', fn () => $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 'ten')); - $this->assertSame(5, $this->stored($database, 'count')); - } - - /** - * @return iterable - */ - public static function nonNumericBounds(): iterable - { - yield 'empty' => ['']; - yield 'exponent without digits' => ['1e']; - yield 'hexadecimal' => ['0x1A']; - yield 'digit separators' => ['1_000']; - yield 'two decimal points' => ['1.2.3']; - yield 'two signs' => ['--5']; - } - - #[DataProvider('nonNumericBounds')] - public function testNonNumericTextBoundsArePassedOnUnchanged(string $bound): void - { - $database = $this->database(); - $this->create($database, ['count' => 5]); - - $this->assertRefused('Max must be an integer.', fn () => $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, $bound)); - $this->assertRefused('Min must be an integer.', fn () => $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, $bound)); - $this->assertRefused('Max must be an integer.', fn () => $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1.5, $bound)); - $this->assertSame(5, $this->stored($database, 'count')); - } - - /** - * @return iterable - */ - public static function integerChanges(): iterable - { - yield 'integer' => [2, 7]; - yield 'whole float' => [2.0, 7]; - yield 'integer text' => ['2', 7]; - yield 'fractional' => [1.5, 6]; - yield 'fractional text' => ['1.5', 6]; - yield 'infinite' => [\INF, null]; - } - - #[DataProvider('integerChanges')] - public function testAnIntegerAttributeStoresEveryFiniteChangeAsAWholeNumber(int|float|string $value, ?int $stored): void - { - $database = $this->database(); - $this->create($database, ['count' => 5]); - - if ($stored === null) { - $this->assertRefused('Change value must be an integer.', fn () => $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', $value)); - $this->assertSame(5, $this->stored($database, 'count')); - - return; - } - - $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', $value); - - $this->assertSame($stored, $this->stored($database, 'count')); - } - - public function testAWholeChangeValueOnAnIntegerAttributeIsPassedAsAnInteger(): void - { - $database = $this->database(); - $this->create($database, ['count' => 8]); - - $this->assertSame(10, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 2)->getAttribute('count')); - $this->assertSame(12, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', '2')->getAttribute('count')); - $this->assertSame(12, $this->stored($database, 'count')); - } - - /** - * @return iterable - */ - public static function metadataAttributes(): iterable - { - yield 'integer' => ['count', 5, null, 6.5, 6]; - yield 'big integer as stored' => ['big', 5, null, 6.5, 6]; - yield 'double' => ['ratio', 5, null, 6.5, 6.5]; - yield 'integer array' => ['scores', null, 'Attribute value must be numeric.', null, null]; - yield 'undeclared' => ['missing', null, null, 1.5, 1.5]; - } - - #[DataProvider('metadataAttributes')] - public function testCollectionMetadataDeclaresTheIntegerAttributesWithoutDefinedAttributes(string $attribute, ?int $start, ?string $refusal, ?float $returned, int|float|null $stored): void - { - $database = $this->database(new class () extends Memory { - #[\Override] - public function capabilities(): array - { - return \array_values(\array_filter( - parent::capabilities(), - static fn (Capability $capability): bool => $capability !== Capability::DefinedAttributes, - )); - } - }); - $this->create($database, $start === null ? [] : [$attribute => $start]); - $increase = fn (): Document => $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, $attribute, 1.5, 10.5); - - if ($refusal !== null) { - $this->assertRefused($refusal, $increase); - - return; - } - - $this->assertSame($returned, $increase()->getAttribute($attribute)); - $this->assertSame($stored, $this->stored($database, $attribute), 'Only a declared single-valued integer stores the change rounded'); - } - - public function testAMissingCollectionIsNotFound(): void - { - $this->expectException(NotFoundException::class); - - $this->database()->increaseDocumentAttribute('missing', self::DOCUMENT, 'count', 1.5, 10.5); - } - - /** - * MariaDB and MySQL on database 7.4.1: the exact result is returned and the column stores it rounded half to - * even. - * - * @return iterable - */ - public static function fractionalChanges(): iterable - { - yield 'increase rounds a half down to even' => [true, 'count', 5, 1.5, null, 6.5, 6]; - yield 'increase rounds a half up to even' => [true, 'count', 6, 1.5, null, 7.5, 8]; - yield 'increase rounds below a half down' => [true, 'count', 5, 1.4, null, 6.4, 6]; - yield 'increase rounds above a half up' => [true, 'count', 5, 1.6, null, 6.6, 7]; - yield 'increase rounds a negative half to even' => [true, 'count', -10, 2.5, null, -7.5, -8]; - yield 'increase by a half that rounds back to the value' => [true, 'count', 4, 0.5, null, 4.5, 4]; - yield 'increase by a half that rounds up' => [true, 'count', 5, 0.5, null, 5.5, 6]; - yield 'increase within a fractional maximum' => [true, 'count', 5, 1.5, 6.5, 6.5, 6]; - yield 'increase within a whole maximum' => [true, 'count', 5, 1.5, 7, 6.5, 6]; - yield 'increase a big integer' => [true, 'big', 5, 2.5, null, 7.5, 8]; - yield 'decrease rounds a half up to even' => [false, 'count', 5, 1.5, null, 3.5, 4]; - yield 'decrease rounds a half down to even' => [false, 'count', 6, 1.5, null, 4.5, 4]; - yield 'decrease by less than a half keeps the value' => [false, 'count', 5, 0.4, null, 4.6, 5]; - yield 'decrease within a fractional minimum' => [false, 'count', 5, 1.5, 3.5, 3.5, 4]; - yield 'a whole float change returns a float' => [true, 'count', 8, 2.0, null, 10.0, 10]; - } - - #[DataProvider('fractionalChanges')] - public function testAFractionalChangeOnAnIntegerMatchesTheEngine(bool $increase, string $attribute, int $start, float $value, int|float|null $bound, float $returned, int $stored): void - { - $database = $this->database(); - $this->create($database, [$attribute => $start]); - - $document = $increase - ? $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, $attribute, $value, $bound) - : $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, $attribute, $value, $bound); - - $this->assertSame($returned, $document->getAttribute($attribute), 'The exact result of a fractional change is returned'); - $this->assertSame($stored, $this->stored($database, $attribute), 'The engine stored the change rounded half to even'); - } - - /** - * @return iterable - */ - public static function fractionalChangesPastTheirBound(): iterable - { - yield 'increase past the maximum' => [true, 6]; - yield 'decrease past the minimum' => [false, 4]; - } - - #[DataProvider('fractionalChangesPastTheirBound')] - public function testAFractionalChangePastItsBoundIsRefusedAndRolledBack(bool $increase, int $bound): void - { - $database = $this->database(); - $this->create($database, ['count' => 5]); - - try { - $increase - ? $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1.5, $bound) - : $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1.5, $bound); - $this->fail('A fractional change past its bound must be refused'); - } catch (LimitException $exception) { - $this->assertSame('Attribute value exceeds '.($increase ? 'maximum' : 'minimum').' limit: '.$bound, $exception->getMessage()); - } - - $this->assertSame(5, $this->stored($database, 'count')); - } - - /** - * @return iterable - */ - public static function boundsBeyondFloatPrecision(): iterable - { - yield 'an increase past a maximum' => [true, 9007199254740993, null]; - yield 'an increase past a maximum given as text' => [true, '9007199254740993', null]; - yield 'a decrease past a minimum' => [false, 9007199254740993, null]; - yield 'an increase within a maximum' => [true, 9007199254740994, 9007199254740994]; - yield 'a decrease within a minimum' => [false, 9007199254740992, 9007199254740992]; - } - - #[DataProvider('boundsBeyondFloatPrecision')] - public function testAFractionalChangeIsHeldToABoundBeyondFloatPrecisionExactly(bool $increase, int|string $bound, int|string|null $stored): void - { - $database = $this->database(); - $this->create($database, ['big' => 9007199254740993]); - $change = fn (): Document => $increase - ? $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'big', 0.5, $bound) - : $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'big', 0.5, $bound); - - if ($stored === null) { - $this->assertLimited($change); - $this->assertSame(9007199254740993, $this->stored($database, 'big')); - - return; - } - - $change(); - $this->assertSame($stored, $this->stored($database, 'big')); - } - - /** - * @return iterable - */ - public static function engines(): iterable - { - foreach (FractionalEngine::cases() as $engine) { - yield $engine->name => [$engine]; - } - } - - #[DataProvider('engines')] - public function testAWholeChangeWrittenWithAnExponentChangesByItsValue(FractionalEngine $engine): void - { - $database = $this->database(self::engine($engine)); - $this->create($database, ['count' => 5]); - - $this->assertSame(1005.0, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', '1e3', '2e3')->getAttribute('count')); - $this->assertSame(980.0, $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', '2.50e1')->getAttribute('count')); - $this->assertSame(980, $this->stored($database, 'count')); - } - - /** - * @return iterable - */ - public static function nonFiniteOperators(): iterable - { - foreach (FractionalEngine::cases() as $engine) { - yield "{$engine->name}: an infinite factor under a fractional maximum" => [$engine, Operator::multiply(\INF, 10.5)]; - yield "{$engine->name}: a fractional factor under a not-a-number maximum" => [$engine, Operator::multiply(1.5, \NAN)]; - yield "{$engine->name}: a fractional increment under an infinite maximum" => [$engine, Operator::increment(1.5, \INF)]; - yield "{$engine->name}: a fractional decrement above a negative infinite minimum" => [$engine, Operator::decrement(1.5, -\INF)]; - } - } - - #[DataProvider('nonFiniteOperators')] - public function testANonFiniteOperandOrLimitIsRefused(FractionalEngine $engine, Operator $operator): void - { - $database = $this->database(self::engine($engine)); - $this->create($database, ['count' => 10]); - - $this->assertStructureRefused(fn () => $database->updateDocument(self::COLLECTION, self::DOCUMENT, new Document(['count' => $operator]))); - $this->assertStructureRefused(fn () => $database->updateDocuments(self::COLLECTION, new Document(['count' => clone $operator]))); - $this->assertSame(10, $this->stored($database, 'count')); - } - - public function testAFractionalChangeOnAMissingDocumentIsNotFound(): void - { - $this->expectException(NotFoundException::class); - - $this->database()->increaseDocumentAttribute(self::COLLECTION, 'missing', 'count', 1.5); - } - - /** - * @return iterable - */ - public static function nonPositiveChanges(): iterable - { - yield 'zero' => [0]; - yield 'negative' => [-1]; - yield 'negative fraction' => [-1.5]; - } - - #[DataProvider('nonPositiveChanges')] - public function testANonPositiveChangeIsATypeError(int|float $value): void - { - $database = $this->database(); - $this->create($database, ['count' => 5]); - - $this->assertRefused('Value must be numeric and greater than 0', fn () => $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', $value)); - $this->assertSame(5, $this->stored($database, 'count')); - } - - public function testAFractionalChangeOnADoubleIsPassedThrough(): void - { - $database = $this->database(); - $this->create($database, ['ratio' => 5.25]); - - $this->assertSame(6.75, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'ratio', 1.5)->getAttribute('ratio')); - $this->assertSame(6.75, $this->stored($database, 'ratio')); - } - - public function testAFractionalMaximumOnAnIntegerBoundsTheIncrementAtItsWholePart(): void - { - $database = $this->database(); - $this->create($database, ['count' => 8]); - - $this->assertSame(10, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 2, 10.5)->getAttribute('count')); - $this->assertLimited(fn () => $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 10.5)); - $this->assertSame(10, $this->stored($database, 'count')); - } - - public function testAFractionalMinimumOnAnIntegerBoundsTheDecrementAtItsWholePart(): void - { - $database = $this->database(); - $this->create($database, ['count' => 10]); - - $this->assertSame(3, $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 7, 2.5)->getAttribute('count')); - $this->assertLimited(fn () => $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 2.5)); - $this->assertSame(3, $this->stored($database, 'count')); - } - - public function testWithoutTheModeTheLibraryRefusesTheFractionalBoundItself(): void - { - $database = $this->database(round: false); - $this->create($database, ['count' => 8]); - - $this->assertRefused('Max must be an integer.', fn () => $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 10.5)); - $this->assertSame(8, $this->stored($database, 'count')); - } - - /** - * @return iterable - */ - public static function fractionalChangesBeyondFloatPrecision(): iterable - { - yield 'increase rounds half to even' => [9007199254740993, 1.5, true, 9007199254740994]; - yield 'increase rounds up past the half' => [9007199254740993, 1.75, true, 9007199254740995]; - yield 'increase rounds down below the half' => [9007199254740993, 1.25, true, 9007199254740994]; - yield 'decrease rounds half to even' => [9007199254740995, 1.5, false, 9007199254740994]; - yield 'decrease rounds down past the half' => [9007199254740995, 1.75, false, 9007199254740993]; - yield 'decrease rounds up below the half' => [9007199254740995, 1.25, false, 9007199254740994]; - } - - #[DataProvider('fractionalChangesBeyondFloatPrecision')] - public function testAFractionalChangeOnABigIntegerBeyondFloatPrecisionStoresTheExactRoundedResult(int $before, float $change, bool $increase, int $expected): void - { - $database = $this->database(); - $this->create($database, ['big' => $before]); - - $increase - ? $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'big', $change) - : $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'big', $change); - - $this->assertSame($expected, $this->stored($database, 'big')); - } - - public function testAFractionalTextMaximumBeyondFloatPrecisionBoundsTheIncrementAtItsWholePart(): void - { - $database = $this->database(); - $this->create($database, ['big' => 9007199254740992]); - - $this->assertSame(9007199254740993, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'big', 1, '9007199254740993.5')->getAttribute('big')); - $this->assertLimited(fn () => $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'big', 1, '9007199254740993.5')); - $this->assertSame(9007199254740993, $this->stored($database, 'big')); - } - - public function testAFractionalTextMinimumBeyondFloatPrecisionBoundsTheDecrementAtItsWholePart(): void - { - $database = $this->database(); - $this->create($database, ['big' => -9007199254740992]); - - $this->assertSame(-9007199254740993, $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'big', 1, '-9007199254740993.5')->getAttribute('big')); - $this->assertLimited(fn () => $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'big', 1, '-9007199254740993.5')); - $this->assertSame(-9007199254740993, $this->stored($database, 'big')); - } - - public function testAMaximumTextBeyondTheIntegerRangeAllowsTheIncrement(): void - { - $database = $this->database(); - $this->create($database, ['big' => 5]); - - $this->assertSame(6, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'big', 1, '1e400')->getAttribute('big')); - } - - public function testAMaximumTextBelowTheIntegerRangeRefusesTheIncrement(): void - { - $database = $this->database(); - $this->create($database, ['big' => 5]); - - $this->assertLimited(fn () => $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'big', 1, '-1e400')); - $this->assertSame(5, $this->stored($database, 'big')); - } - - public function testAMinimumTextWithAHugeExponentAllowsTheDecrement(): void - { - $database = $this->database(); - $this->create($database, ['big' => 5]); - - $this->assertSame(-5, $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'big', 10, '-1e999999999')->getAttribute('big')); - } - - /** - * @return iterable - */ - public static function counters(): iterable - { - return FractionalResults::counters(); - } - - #[DataProvider('counters')] - public function testAFractionalCounterChangeStoresWhatTheEngineStored(FractionalEngine $engine, bool $increase, int $start, float $value, int|float|null $bound, ?int $stored): void - { - $database = $this->database(self::engine($engine)); - $this->create($database, ['big' => $start]); - $change = fn (): Document => $increase - ? $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'big', $value, $bound) - : $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'big', $value, $bound); - - if ($stored === null) { - $this->assertRefused('Change value must be an integer.', $change); - $this->assertSame($start, $this->stored($database, 'big')); - - return; - } - - $this->assertSame($increase ? $start + $value : $start - $value, $change()->getAttribute('big')); - $this->assertSame($stored, $this->stored($database, 'big')); - } - - /** - * PostgreSQL on 7.4.1 compared the result with a bound in PHP first, then bound the bound next to an integer - * column, which it read as an integer and refused when fractional. - * - * @return iterable - */ - public static function postgresBounds(): iterable - { - yield 'a whole change within a fractional maximum' => [true, 2, 10.5, 'Max must be an integer.']; - yield 'a whole change past a fractional maximum' => [true, 3, 10.5, null]; - yield 'a whole change within a fractional minimum' => [false, 2, 5.5, 'Min must be an integer.']; - yield 'a whole change past a fractional minimum' => [false, 3, 5.5, null]; - yield 'a fractional change past a whole maximum' => [true, 2.5, 10, null]; - yield 'a fractional change past a whole minimum' => [false, 2.5, 6, null]; - yield 'a fractional change within a fractional maximum' => [true, 1.5, 10.5, 'Change value must be an integer.']; - } - - #[DataProvider('postgresBounds')] - public function testPostgresRefusesAFractionalBoundAfterTheLimitCheck(bool $increase, int|float $value, int|float $bound, ?string $refusal): void - { - $database = $this->database(self::engine(FractionalEngine::PostgreSQL)); - $this->create($database, ['count' => 8]); - $change = fn (): Document => $increase - ? $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', $value, $bound) - : $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', $value, $bound); - - $refusal === null ? $this->assertLimited($change) : $this->assertRefused($refusal, $change); - $this->assertSame(8, $this->stored($database, 'count')); - } - - public function testPostgresStoresAWholeChangeUnderAWholeBound(): void - { - $database = $this->database(self::engine(FractionalEngine::PostgreSQL)); - $this->create($database, ['count' => 8]); - - $this->assertSame(10.0, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 2.0, 10)->getAttribute('count')); - $this->assertSame(7, $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 3, 7)->getAttribute('count')); - $this->assertSame(7, $this->stored($database, 'count')); - } - - public function testAChangeThatRoundsBackToTheValueStillWritesAndFiresItsEvents(): void - { - $database = $this->database(); - $this->create($database, ['count' => 4]); - $before = $database->getDocument(self::COLLECTION, self::DOCUMENT)->getUpdatedAt(); - $recorder = new class () implements Lifecycle { - /** @var list */ - public array $events = []; - - #[\Override] - public function handle(Domain $event): void - { - $this->events[] = $event->event; - } - }; - $database->addHook($recorder); - - $document = $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 0.5); - - $this->assertSame([Event::DocumentPurge, Event::DocumentIncrease], $recorder->events); - $this->assertSame(4.5, $document->getAttribute('count')); - $this->assertSame(4, $this->stored($database, 'count')); - $this->assertNotSame($before, $database->getDocument(self::COLLECTION, self::DOCUMENT)->getUpdatedAt(), 'The write still bumps $updatedAt'); - } - - /** - * @return iterable, int|null}> - */ - public static function operators(): iterable - { - return FractionalResults::operators(); - } - - /** - * @param list $values - */ - #[DataProvider('operators')] - public function testAFractionalOperatorOnUpdateStoresWhatTheEngineStored(FractionalEngine $engine, int $stored, OperatorType $method, array $values, ?int $expected): void - { - $database = $this->database(self::engine($engine)); - $this->create($database, ['count' => $stored]); - $update = fn (): Document => $database->updateDocument(self::COLLECTION, self::DOCUMENT, new Document(['count' => new Operator($method, 'count', $values)])); - - if ($expected === null) { - $this->assertStructureRefused($update); - $this->assertSame($stored, $this->stored($database, 'count')); - - return; - } - - $this->assertSame($expected, $update()->getAttribute('count')); - $this->assertSame($expected, $this->stored($database, 'count')); - } - - public function testFractionalItemsAddedToAnIntegerArrayAreCutTowardZero(): void - { - $database = $this->database(); - $this->create($database, ['scores' => [1]]); - - $database->updateDocument(self::COLLECTION, self::DOCUMENT, new Document(['scores' => Operator::arrayAppend([2.7, -3.5])])); - $database->updateDocument(self::COLLECTION, self::DOCUMENT, new Document(['scores' => Operator::arrayPrepend(['0.9'])])); - $database->updateDocument(self::COLLECTION, self::DOCUMENT, new Document(['scores' => Operator::arrayInsert(1, 4.2)])); - - $this->assertSame([0, 4, 1, 2, -3], $this->stored($database, 'scores')); - } - - public function testABulkUpdateRoundsEachDocumentFromItsOwnStoredValue(): void - { - $database = $this->database(); - $this->create($database, ['count' => 10, 'scores' => []], 'first'); - $this->create($database, ['count' => 11, 'scores' => []], 'second'); - $this->create($database, ['count' => 12, 'scores' => []], 'third'); - $updated = []; - - $count = $database->updateDocuments( - self::COLLECTION, - new Document(['count' => Operator::increment(1.5), 'scores' => Operator::arrayAppend([7.9])]), - onNext: function (Document $document) use (&$updated): void { - $updated[$document->getId()] = $document->getAttribute('count'); - }, - ); - - $this->assertSame(3, $count); - $this->assertSame(['first' => 12, 'second' => 12, 'third' => 14], $updated); - $this->assertSame(12, $this->stored($database, 'count', 'first')); - $this->assertSame(12, $this->stored($database, 'count', 'second')); - $this->assertSame(14, $this->stored($database, 'count', 'third')); - $this->assertSame([7], $this->stored($database, 'scores', 'third')); - } - - public function testAnUpsertAcrossTenantsRoundsEachDocumentFromItsOwnTenantsValue(): void - { - $authorization = new Authorization(); - $authorization->addRole(Role::any()->toString()); - $database = (new Database(self::sqlite(), new Cache(new None()))) - ->setAuthorization($authorization) - ->setDatabase('round_fractions') - ->setNamespace('round_fractions_'.\uniqid()) - ->setSharedTables(true) - ->setTenant(null) - ->setTenantPerDocument(true) - ->setRoundFractions(true); - $database->create(); - $database->createCollection(Collection::create( - id: self::COLLECTION, - attributes: [Attribute::integer(key: 'count')], - permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], - documentSecurity: false, - )); - foreach ([5 => 10, 6 => 11] as $tenant => $count) { - $database->createDocument(self::COLLECTION, new Document([Document::ID => self::DOCUMENT, Document::TENANT => $tenant, 'count' => $count])); - } - - $database->upsertDocuments(self::COLLECTION, [ - new Document([Document::ID => self::DOCUMENT, Document::TENANT => 5, 'count' => Operator::increment(1.5)]), - new Document([Document::ID => self::DOCUMENT, Document::TENANT => 6, 'count' => Operator::increment(1.5)]), - ]); - - $this->assertSame(12, $database->withTenant(5, fn (): mixed => $this->stored($database, 'count'))); - $this->assertSame(12, $database->withTenant(6, fn (): mixed => $this->stored($database, 'count'))); - } - - public function testABulkUpdateWithAFractionalLimitHoldsEachDocumentAtItsLimit(): void - { - $database = $this->database(); - $this->create($database, ['count' => 10], 'first'); - $this->create($database, ['count' => 12], 'second'); - - $database->updateDocuments(self::COLLECTION, new Document(['count' => Operator::increment(2, 13.9)]), [Query::orderAsc('count')]); - - $this->assertSame(12, $this->stored($database, 'count', 'first')); - $this->assertSame(12, $this->stored($database, 'count', 'second')); - } - - public function testABulkUpdateTheEngineRefusedWritesNothing(): void - { - $database = $this->database(self::engine(FractionalEngine::PostgreSQL)); - $this->create($database, ['count' => 10], 'first'); - $this->create($database, ['count' => 11], 'second'); - - $this->assertStructureRefused(fn () => $database->updateDocuments(self::COLLECTION, new Document(['count' => Operator::increment(1.5)]))); - - $this->assertSame(10, $this->stored($database, 'count', 'first')); - $this->assertSame(11, $this->stored($database, 'count', 'second')); - } - - public function testAnUpsertRoundsAStoredDocumentFromItsValueAndANewOneFromZero(): void - { - $database = $this->database(self::sqlite()); - $this->create($database, ['count' => 10], 'first'); - $this->create($database, ['count' => 11], 'second'); - - $upserted = $database->upsertDocument(self::COLLECTION, new Document(['$id' => 'first', 'count' => Operator::increment(1.5)])); - $count = $database->upsertDocuments(self::COLLECTION, [ - new Document(['$id' => 'second', 'count' => Operator::increment(1.5)]), - new Document(['$id' => 'third', 'count' => Operator::increment(2.5), 'scores' => Operator::arrayAppend([3.9])]), - ]); - - $this->assertSame(2, $count); - $this->assertSame(12, $upserted->getAttribute('count')); - $this->assertSame(12, $this->stored($database, 'count', 'first')); - $this->assertSame(12, $this->stored($database, 'count', 'second')); - $this->assertSame(2, $this->stored($database, 'count', 'third')); - } - - public function testAnUpsertTheEngineRefusedWritesNothing(): void - { - $database = $this->database(self::sqlite(self::arithmetic(FractionalEngine::PostgreSQL))); - $this->create($database, ['count' => 10], 'first'); - - $this->assertStructureRefused(fn () => $database->upsertDocuments(self::COLLECTION, [ - new Document(['$id' => 'second', 'count' => 4]), - new Document(['$id' => 'first', 'count' => Operator::increment(1.5)]), - ])); - - $this->assertSame(10, $this->stored($database, 'count', 'first')); - $this->assertTrue($database->getDocument(self::COLLECTION, 'second')->isEmpty()); - } - - public function testWithoutTheModeEveryFractionIsRefusedAsBefore(): void - { - $database = $this->database(self::sqlite(), round: false); - $this->create($database, ['count' => 10, 'scores' => []]); - - $this->assertRefused('Change value must be an integer.', fn () => $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1.5)); - $this->assertRefused('Min must be an integer.', fn () => $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 2.5)); - $this->assertRefused('Change value must be an integer.', fn () => $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 2.0)); - $this->assertStructureRefused(fn () => $database->updateDocument(self::COLLECTION, self::DOCUMENT, new Document(['count' => Operator::increment(1.5)]))); - $this->assertStructureRefused(fn () => $database->updateDocument(self::COLLECTION, self::DOCUMENT, new Document(['count' => Operator::increment(1, 10.5)]))); - $this->assertStructureRefused(fn () => $database->updateDocument(self::COLLECTION, self::DOCUMENT, new Document(['scores' => Operator::arrayAppend([1.5])]))); - $this->assertStructureRefused(fn () => $database->updateDocuments(self::COLLECTION, new Document(['count' => Operator::multiply(1.5)]))); - $this->assertStructureRefused(fn () => $database->upsertDocuments(self::COLLECTION, [new Document(['$id' => self::DOCUMENT, 'count' => Operator::increment(1.5)])])); - - $this->assertSame(10, $this->stored($database, 'count')); - $this->assertSame([], $this->stored($database, 'scores')); - } - - public function testTheModeIsScopedAndOffByDefault(): void - { - $database = $this->database(round: false); - - $this->assertFalse($database->isRoundingFractions()); - $this->assertTrue($database->withRoundFractions(true, $database->isRoundingFractions(...))); - $this->assertFalse($database->isRoundingFractions()); - $this->assertTrue($database->setRoundFractions(true)->isRoundingFractions()); - $this->assertFalse($database->withRoundFractions(false, $database->isRoundingFractions(...))); - } - - public function testAMirrorSetsTheModeOnItsSourceAndDestination(): void - { - $source = new Database(new Memory(), new Cache(new None())); - $destination = new Database(new Memory(), new Cache(new None())); - $mirror = new Mirror($source, $destination); - - $this->assertSame([true, true, true], $mirror->withRoundFractions(true, fn (): array => [ - $mirror->isRoundingFractions(), - $source->isRoundingFractions(), - $destination->isRoundingFractions(), - ])); - $this->assertFalse($source->isRoundingFractions()); - - $mirror->setRoundFractions(true); - - $this->assertTrue($mirror->isRoundingFractions()); - $this->assertTrue($source->isRoundingFractions()); - $this->assertTrue($destination->isRoundingFractions()); - } - - public function testASnapshotCarriesTheModeIntoAChildCoroutine(): void - { - if (! \extension_loaded('swoole')) { - $this->markTestSkipped('ext-swoole is required for coroutine-scoped toggles'); - } - - $database = $this->database(round: false); - $this->create($database, ['count' => 5]); - $outcomes = null; - $hookFlags = Runtime::getHookFlags(); - - try { - run(function () use ($database, &$outcomes): void { - $released = new Channel(1); - $results = new Channel(1); - - $database->withRoundFractions(true, function () use ($database, $released, $results): void { - $snapshot = $database->snapshot(); - - Coroutine::create(function () use ($database, $snapshot, $released, $results): void { - $released->pop(); - $results->push([ - 'unscoped' => $this->refusal(fn () => $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1.5)), - 'scoped' => $database->withSnapshot($snapshot, fn (): ?string => $this->refusal(fn () => $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1.5))), - ]); - }); - }); - - $released->push(true); - $outcomes = $results->pop(5); - }); - } finally { - Runtime::setHookFlags($hookFlags); - } - - $this->assertSame(['unscoped' => 'Change value must be an integer.', 'scoped' => null], $outcomes); - $this->assertSame(6, $this->stored($database, 'count'), 'Only the change under the snapshot rounded 5 + 1.5'); - $this->assertFalse($database->isRoundingFractions()); - } - - private function database(?Adapter $adapter = null, bool $round = true): Database - { - $authorization = new Authorization(); - $authorization->addRole(Role::any()->toString()); - - $database = (new Database($adapter ?? self::engine(FractionalEngine::MariaDB), new Cache(new None()))) - ->setAuthorization($authorization) - ->setDatabase('round_fractions') - ->setNamespace('round_fractions_'.\uniqid()); - $database->create(); - $database->createCollection(Collection::create( - id: self::COLLECTION, - attributes: [ - Attribute::integer(key: 'count'), - Attribute::bigInteger(key: 'big'), - Attribute::double(key: 'ratio'), - Attribute::integer(key: 'scores', array: true), - ], - permissions: [ - Permission::create(Role::any()), - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - documentSecurity: false, - )); - - return $database->setRoundFractions($round); - } - - private static function engine(FractionalEngine $engine): Memory - { - return new class (self::arithmetic($engine)) extends Memory { - public function __construct(private readonly Arithmetic $declared) - { - parent::__construct(); - } - - #[\Override] - public function arithmetic(): Arithmetic - { - return $this->declared; - } - }; - } - - private static function sqlite(?Arithmetic $arithmetic = null): SQLite - { - return new class (new PDO('sqlite::memory:'), $arithmetic ?? self::arithmetic(FractionalEngine::MariaDB)) extends SQLite { - public function __construct(PDO $pdo, private readonly Arithmetic $declared) - { - parent::__construct($pdo); - } - - #[\Override] - public function arithmetic(): Arithmetic - { - return $this->declared; - } - }; - } - - private static function arithmetic(FractionalEngine $engine): Arithmetic - { - return match ($engine) { - FractionalEngine::MariaDB => (new MariaDB(new stdClass()))->arithmetic(), - FractionalEngine::Memory => (new Memory())->arithmetic(), - FractionalEngine::PostgreSQL => (new Postgres(new stdClass()))->arithmetic(), - FractionalEngine::MongoDB => (new Mongo(new class () extends Client { - public function __construct() - { - } - - #[\Override] - public function connect(): self - { - return $this; - } - - #[\Override] - public function close(): void - { - } - }))->arithmetic(), - }; - } - - /** - * @param array $values - */ - private function create(Database $database, array $values, string $id = self::DOCUMENT): void - { - $database->createDocument(self::COLLECTION, new Document([Document::ID => $id, ...$values])); - } - - private function stored(Database $database, string $attribute, string $id = self::DOCUMENT): mixed - { - return $database->getDocument(self::COLLECTION, $id)->getAttribute($attribute); - } - - private function assertHeldAtMaximum(Database $database, int|float|string $bound, int|string $maximum): void - { - $increase = fn (): Document => $database->increaseDocumentAttribute(self::COLLECTION, 'maximum', 'big', 1, $bound); - - if (BigInt::compare($maximum, \PHP_INT_MAX) > 0) { - $this->create($database, ['big' => 5], 'maximum'); - $this->assertSame(6, $increase()->getAttribute('big')); - - return; - } - - if (BigInt::compare($maximum, \PHP_INT_MIN) <= 0) { - $this->create($database, ['big' => 5], 'maximum'); - $this->assertLimited($increase); - $this->assertSame(5, $this->stored($database, 'big', 'maximum')); - - return; - } - - $this->create($database, ['big' => BigInt::subtract($maximum, 1)], 'maximum'); - $this->assertSame(BigInt::toNative($maximum), $increase()->getAttribute('big')); - $this->assertLimited($increase); - $this->assertSame(BigInt::toNative($maximum), $this->stored($database, 'big', 'maximum')); - } - - private function assertHeldAtMinimum(Database $database, int|float|string $bound, int|string $minimum): void - { - $decrease = fn (): Document => $database->decreaseDocumentAttribute(self::COLLECTION, 'minimum', 'big', 1, $bound); - - if (BigInt::compare($minimum, \PHP_INT_MIN) < 0) { - $this->create($database, ['big' => 5], 'minimum'); - $this->assertSame(4, $decrease()->getAttribute('big')); - - return; - } - - if (BigInt::compare($minimum, \PHP_INT_MAX) >= 0) { - $this->create($database, ['big' => 5], 'minimum'); - $this->assertLimited($decrease); - $this->assertSame(5, $this->stored($database, 'big', 'minimum')); - - return; - } - - $this->create($database, ['big' => BigInt::add($minimum, 1)], 'minimum'); - $this->assertSame(BigInt::toNative($minimum), $decrease()->getAttribute('big')); - $this->assertLimited($decrease); - $this->assertSame(BigInt::toNative($minimum), $this->stored($database, 'big', 'minimum')); - } - - /** - * @param callable(): mixed $call - */ - private function assertLimited(callable $call): void - { - try { - $call(); - $this->fail('A change past its bound must be refused'); - } catch (LimitException) { - $this->addToAssertionCount(1); - } - } - - /** - * @param callable(): mixed $call - */ - private function assertRefused(string $message, callable $call): void - { - $this->assertSame($message, $this->refusal($call)); - } - - /** - * @param callable(): mixed $call - */ - private function assertStructureRefused(callable $call): void - { - try { - $call(); - $this->fail('The write must be refused as an invalid structure'); - } catch (StructureException) { - $this->addToAssertionCount(1); - } - } - - /** - * @param callable(): mixed $call - */ - private function refusal(callable $call): ?string - { - try { - $call(); - } catch (TypeException $exception) { - return $exception->getMessage(); - } - - return null; - } -} diff --git a/tests/unit/Support/FractionalEngine.php b/tests/unit/Support/FractionalEngine.php deleted file mode 100644 index 9e7e3953c..000000000 --- a/tests/unit/Support/FractionalEngine.php +++ /dev/null @@ -1,15 +0,0 @@ -, int|null}> - */ - public static function operators(): iterable - { - yield 'MariaDB: inc 11+1.5' => [FractionalEngine::MariaDB, 11, OperatorType::Increment, [1.5], 12]; - yield 'MariaDB: inc 10+1.5' => [FractionalEngine::MariaDB, 10, OperatorType::Increment, [1.5], 12]; - yield 'MariaDB: inc 10+1.25' => [FractionalEngine::MariaDB, 10, OperatorType::Increment, [1.25], 11]; - yield 'MariaDB: inc 10+1.75' => [FractionalEngine::MariaDB, 10, OperatorType::Increment, [1.75], 12]; - yield 'MariaDB: inc -11+0.5' => [FractionalEngine::MariaDB, -11, OperatorType::Increment, [0.5], -10]; - yield 'MariaDB: inc -12+0.5' => [FractionalEngine::MariaDB, -12, OperatorType::Increment, [0.5], -12]; - yield 'MariaDB: dec 11-0.5' => [FractionalEngine::MariaDB, 11, OperatorType::Decrement, [0.5], 10]; - yield 'MariaDB: dec 12-0.5' => [FractionalEngine::MariaDB, 12, OperatorType::Decrement, [0.5], 12]; - yield 'MariaDB: dec -10-0.5' => [FractionalEngine::MariaDB, -10, OperatorType::Decrement, [0.5], -10]; - yield 'MariaDB: inc 10+2 max13.9' => [FractionalEngine::MariaDB, 10, OperatorType::Increment, [2, 13.9], 12]; - yield 'MariaDB: inc 12+2 max13.9' => [FractionalEngine::MariaDB, 12, OperatorType::Increment, [2, 13.9], 12]; - yield 'MariaDB: inc 12+1.5 max13.9' => [FractionalEngine::MariaDB, 12, OperatorType::Increment, [1.5, 13.9], 14]; - yield 'MariaDB: inc 12+1.5 max13.5' => [FractionalEngine::MariaDB, 12, OperatorType::Increment, [1.5, 13.5], 14]; - yield 'MariaDB: dec 12-1 min11.1' => [FractionalEngine::MariaDB, 12, OperatorType::Decrement, [1, 11.1], 12]; - yield 'MariaDB: dec 13-1 min11.1' => [FractionalEngine::MariaDB, 13, OperatorType::Decrement, [1, 11.1], 12]; - yield 'MariaDB: dec 12-0.5 min11.5' => [FractionalEngine::MariaDB, 12, OperatorType::Decrement, [0.5, 11.5], 12]; - yield 'MariaDB: mul 11*1.5' => [FractionalEngine::MariaDB, 11, OperatorType::Multiply, [1.5], 16]; - yield 'MariaDB: mul 5*2.5' => [FractionalEngine::MariaDB, 5, OperatorType::Multiply, [2.5], 12]; - yield 'MariaDB: mul 7*1.5' => [FractionalEngine::MariaDB, 7, OperatorType::Multiply, [1.5], 10]; - yield 'MariaDB: mul 7*-1.5' => [FractionalEngine::MariaDB, 7, OperatorType::Multiply, [-1.5], -10]; - yield 'MariaDB: mul 3*1.25' => [FractionalEngine::MariaDB, 3, OperatorType::Multiply, [1.25], 4]; - yield 'MariaDB: mul 12*2 max25.5' => [FractionalEngine::MariaDB, 12, OperatorType::Multiply, [2, 25.5], 24]; - yield 'MariaDB: mul 13*2 max25.5' => [FractionalEngine::MariaDB, 13, OperatorType::Multiply, [2, 25.5], 13]; - yield 'MariaDB: mul 10*1.5 max15' => [FractionalEngine::MariaDB, 10, OperatorType::Multiply, [1.5, 15], 15]; - yield 'MariaDB: div 10/1.5' => [FractionalEngine::MariaDB, 10, OperatorType::Divide, [1.5], 7]; - yield 'MariaDB: div 9/2.5' => [FractionalEngine::MariaDB, 9, OperatorType::Divide, [2.5], 4]; - yield 'MariaDB: div 10/2 min5.5' => [FractionalEngine::MariaDB, 10, OperatorType::Divide, [2, 5.5], 10]; - yield 'MariaDB: div 10/0.5' => [FractionalEngine::MariaDB, 10, OperatorType::Divide, [0.5], 20]; - yield 'MariaDB: mod 10%2.75' => [FractionalEngine::MariaDB, 10, OperatorType::Modulo, [2.75], 2]; - yield 'MariaDB: mod 11%4.5' => [FractionalEngine::MariaDB, 11, OperatorType::Modulo, [4.5], 2]; - yield 'MariaDB: pow 16^0.5' => [FractionalEngine::MariaDB, 16, OperatorType::Power, [0.5], 4]; - yield 'MariaDB: pow 10^0.5' => [FractionalEngine::MariaDB, 10, OperatorType::Power, [0.5], 3]; - yield 'MariaDB: pow 2^2.5' => [FractionalEngine::MariaDB, 2, OperatorType::Power, [2.5], 6]; - yield 'MariaDB: pow 4^1.5' => [FractionalEngine::MariaDB, 4, OperatorType::Power, [1.5], 8]; - yield 'MariaDB: pow 3^2 max8.5' => [FractionalEngine::MariaDB, 3, OperatorType::Power, [2, 8.5], 3]; - yield 'MariaDB: pow 2^3 max8.5' => [FractionalEngine::MariaDB, 2, OperatorType::Power, [3, 8.5], 8]; - yield 'MariaDB: inc 11+1.5 max20' => [FractionalEngine::MariaDB, 11, OperatorType::Increment, [1.5, 20], 12]; - yield 'MariaDB: inc 10+1.5 max20' => [FractionalEngine::MariaDB, 10, OperatorType::Increment, [1.5, 20], 12]; - yield 'MariaDB: inc -12+0.5 max20' => [FractionalEngine::MariaDB, -12, OperatorType::Increment, [0.5, 20], -12]; - yield 'MariaDB: inc 10+1.25 max20' => [FractionalEngine::MariaDB, 10, OperatorType::Increment, [1.25, 20], 11]; - yield 'MariaDB: dec 11-0.5 min0' => [FractionalEngine::MariaDB, 11, OperatorType::Decrement, [0.5, 0], 10]; - yield 'MariaDB: dec 12-0.5 min0' => [FractionalEngine::MariaDB, 12, OperatorType::Decrement, [0.5, 0], 12]; - yield 'MariaDB: dec -10-0.5 min-100' => [FractionalEngine::MariaDB, -10, OperatorType::Decrement, [0.5, -100], -10]; - yield 'MariaDB: mul 5*2.5 max100' => [FractionalEngine::MariaDB, 5, OperatorType::Multiply, [2.5, 100], 12]; - yield 'MariaDB: mul 11*1.5 max100' => [FractionalEngine::MariaDB, 11, OperatorType::Multiply, [1.5, 100], 16]; - yield 'MariaDB: mul 7*-1.5 max100' => [FractionalEngine::MariaDB, 7, OperatorType::Multiply, [-1.5, 100], -10]; - yield 'MariaDB: mul 3*1.25 max100' => [FractionalEngine::MariaDB, 3, OperatorType::Multiply, [1.25, 100], 4]; - yield 'MariaDB: div 9/2.5 min0' => [FractionalEngine::MariaDB, 9, OperatorType::Divide, [2.5, 0], 4]; - yield 'MariaDB: div 10/1.5 min0' => [FractionalEngine::MariaDB, 10, OperatorType::Divide, [1.5, 0], 7]; - yield 'MariaDB: div 5/0.4 min0' => [FractionalEngine::MariaDB, 5, OperatorType::Divide, [0.4, 0], 12]; - yield 'MariaDB: pow 2^2.5 max100' => [FractionalEngine::MariaDB, 2, OperatorType::Power, [2.5, 100], 6]; - yield 'MariaDB: pow 10^0.5 max100' => [FractionalEngine::MariaDB, 10, OperatorType::Power, [0.5, 100], 3]; - yield 'MariaDB: mod -10%2.75' => [FractionalEngine::MariaDB, -10, OperatorType::Modulo, [2.75], -2]; - yield 'MariaDB: mod 10%2.5' => [FractionalEngine::MariaDB, 10, OperatorType::Modulo, [2.5], 0]; - yield 'MariaDB: mod 11%2.5' => [FractionalEngine::MariaDB, 11, OperatorType::Modulo, [2.5], 1]; - yield 'MariaDB: mod 7%-2.5' => [FractionalEngine::MariaDB, 7, OperatorType::Modulo, [-2.5], 2]; - yield 'MariaDB: pow 4^-0.5' => [FractionalEngine::MariaDB, 4, OperatorType::Power, [-0.5], 0]; - yield 'MariaDB: pow 4^-0.5 max100' => [FractionalEngine::MariaDB, 4, OperatorType::Power, [-0.5, 100], 0]; - yield 'MariaDB: pow 16^-0.25' => [FractionalEngine::MariaDB, 16, OperatorType::Power, [-0.25], 0]; - yield 'MariaDB: div 5/2 min0.5' => [FractionalEngine::MariaDB, 5, OperatorType::Divide, [2, 0.5], 3]; - yield 'MariaDB: div 7/2 min0.5' => [FractionalEngine::MariaDB, 7, OperatorType::Divide, [2, 0.5], 4]; - yield 'MariaDB: div -5/2 min-10.5' => [FractionalEngine::MariaDB, -5, OperatorType::Divide, [2, -10.5], -3]; - yield 'MariaDB: div 5/2 min2.6' => [FractionalEngine::MariaDB, 5, OperatorType::Divide, [2, 2.6], 5]; - yield 'MariaDB: mod 11%3.5' => [FractionalEngine::MariaDB, 11, OperatorType::Modulo, [3.5], 0]; - yield 'MariaDB: mod 13%3.5' => [FractionalEngine::MariaDB, 13, OperatorType::Modulo, [3.5], 2]; - yield 'MariaDB: mod -11%3.5' => [FractionalEngine::MariaDB, -11, OperatorType::Modulo, [3.5], 0]; - yield 'MariaDB: inc 10+2 max10.5' => [FractionalEngine::MariaDB, 10, OperatorType::Increment, [2, 10.5], 10]; - yield 'MariaDB: mul 5*3 max15.5' => [FractionalEngine::MariaDB, 5, OperatorType::Multiply, [3, 15.5], 15]; - yield 'PostgreSQL: inc 11+1.5' => [FractionalEngine::PostgreSQL, 11, OperatorType::Increment, [1.5], null]; - yield 'PostgreSQL: inc 10+1.5' => [FractionalEngine::PostgreSQL, 10, OperatorType::Increment, [1.5], null]; - yield 'PostgreSQL: inc 10+1.25' => [FractionalEngine::PostgreSQL, 10, OperatorType::Increment, [1.25], null]; - yield 'PostgreSQL: inc 10+1.75' => [FractionalEngine::PostgreSQL, 10, OperatorType::Increment, [1.75], null]; - yield 'PostgreSQL: inc -11+0.5' => [FractionalEngine::PostgreSQL, -11, OperatorType::Increment, [0.5], null]; - yield 'PostgreSQL: inc -12+0.5' => [FractionalEngine::PostgreSQL, -12, OperatorType::Increment, [0.5], null]; - yield 'PostgreSQL: dec 11-0.5' => [FractionalEngine::PostgreSQL, 11, OperatorType::Decrement, [0.5], null]; - yield 'PostgreSQL: dec 12-0.5' => [FractionalEngine::PostgreSQL, 12, OperatorType::Decrement, [0.5], null]; - yield 'PostgreSQL: dec -10-0.5' => [FractionalEngine::PostgreSQL, -10, OperatorType::Decrement, [0.5], null]; - yield 'PostgreSQL: inc 10+2 max13.9' => [FractionalEngine::PostgreSQL, 10, OperatorType::Increment, [2, 13.9], 12]; - yield 'PostgreSQL: inc 12+2 max13.9' => [FractionalEngine::PostgreSQL, 12, OperatorType::Increment, [2, 13.9], 12]; - yield 'PostgreSQL: inc 12+1.5 max13.9' => [FractionalEngine::PostgreSQL, 12, OperatorType::Increment, [1.5, 13.9], 14]; - yield 'PostgreSQL: inc 12+1.5 max13.5' => [FractionalEngine::PostgreSQL, 12, OperatorType::Increment, [1.5, 13.5], 14]; - yield 'PostgreSQL: dec 12-1 min11.1' => [FractionalEngine::PostgreSQL, 12, OperatorType::Decrement, [1, 11.1], 12]; - yield 'PostgreSQL: dec 13-1 min11.1' => [FractionalEngine::PostgreSQL, 13, OperatorType::Decrement, [1, 11.1], 12]; - yield 'PostgreSQL: dec 12-0.5 min11.5' => [FractionalEngine::PostgreSQL, 12, OperatorType::Decrement, [0.5, 11.5], 12]; - yield 'PostgreSQL: mul 11*1.5' => [FractionalEngine::PostgreSQL, 11, OperatorType::Multiply, [1.5], null]; - yield 'PostgreSQL: mul 5*2.5' => [FractionalEngine::PostgreSQL, 5, OperatorType::Multiply, [2.5], null]; - yield 'PostgreSQL: mul 7*1.5' => [FractionalEngine::PostgreSQL, 7, OperatorType::Multiply, [1.5], null]; - yield 'PostgreSQL: mul 7*-1.5' => [FractionalEngine::PostgreSQL, 7, OperatorType::Multiply, [-1.5], null]; - yield 'PostgreSQL: mul 3*1.25' => [FractionalEngine::PostgreSQL, 3, OperatorType::Multiply, [1.25], null]; - yield 'PostgreSQL: mul 12*2 max25.5' => [FractionalEngine::PostgreSQL, 12, OperatorType::Multiply, [2, 25.5], 24]; - yield 'PostgreSQL: mul 13*2 max25.5' => [FractionalEngine::PostgreSQL, 13, OperatorType::Multiply, [2, 25.5], 13]; - yield 'PostgreSQL: mul 10*1.5 max15' => [FractionalEngine::PostgreSQL, 10, OperatorType::Multiply, [1.5, 15], 15]; - yield 'PostgreSQL: div 10/1.5' => [FractionalEngine::PostgreSQL, 10, OperatorType::Divide, [1.5], null]; - yield 'PostgreSQL: div 9/2.5' => [FractionalEngine::PostgreSQL, 9, OperatorType::Divide, [2.5], null]; - yield 'PostgreSQL: div 10/2 min5.5' => [FractionalEngine::PostgreSQL, 10, OperatorType::Divide, [2, 5.5], 10]; - yield 'PostgreSQL: div 10/0.5' => [FractionalEngine::PostgreSQL, 10, OperatorType::Divide, [0.5], null]; - yield 'PostgreSQL: mod 10%2.75' => [FractionalEngine::PostgreSQL, 10, OperatorType::Modulo, [2.75], 2]; - yield 'PostgreSQL: mod 11%4.5' => [FractionalEngine::PostgreSQL, 11, OperatorType::Modulo, [4.5], 2]; - yield 'PostgreSQL: pow 16^0.5' => [FractionalEngine::PostgreSQL, 16, OperatorType::Power, [0.5], 4]; - yield 'PostgreSQL: pow 10^0.5' => [FractionalEngine::PostgreSQL, 10, OperatorType::Power, [0.5], 3]; - yield 'PostgreSQL: pow 2^2.5' => [FractionalEngine::PostgreSQL, 2, OperatorType::Power, [2.5], 6]; - yield 'PostgreSQL: pow 4^1.5' => [FractionalEngine::PostgreSQL, 4, OperatorType::Power, [1.5], 8]; - yield 'PostgreSQL: pow 3^2 max8.5' => [FractionalEngine::PostgreSQL, 3, OperatorType::Power, [2, 8.5], 3]; - yield 'PostgreSQL: pow 2^3 max8.5' => [FractionalEngine::PostgreSQL, 2, OperatorType::Power, [3, 8.5], 8]; - yield 'PostgreSQL: inc 11+1.5 max20' => [FractionalEngine::PostgreSQL, 11, OperatorType::Increment, [1.5, 20], 13]; - yield 'PostgreSQL: inc 10+1.5 max20' => [FractionalEngine::PostgreSQL, 10, OperatorType::Increment, [1.5, 20], 12]; - yield 'PostgreSQL: inc -12+0.5 max20' => [FractionalEngine::PostgreSQL, -12, OperatorType::Increment, [0.5, 20], -12]; - yield 'PostgreSQL: inc 10+1.25 max20' => [FractionalEngine::PostgreSQL, 10, OperatorType::Increment, [1.25, 20], 11]; - yield 'PostgreSQL: dec 11-0.5 min0' => [FractionalEngine::PostgreSQL, 11, OperatorType::Decrement, [0.5, 0], 11]; - yield 'PostgreSQL: dec 12-0.5 min0' => [FractionalEngine::PostgreSQL, 12, OperatorType::Decrement, [0.5, 0], 12]; - yield 'PostgreSQL: dec -10-0.5 min-100' => [FractionalEngine::PostgreSQL, -10, OperatorType::Decrement, [0.5, -100], -11]; - yield 'PostgreSQL: mul 5*2.5 max100' => [FractionalEngine::PostgreSQL, 5, OperatorType::Multiply, [2.5, 100], 13]; - yield 'PostgreSQL: mul 11*1.5 max100' => [FractionalEngine::PostgreSQL, 11, OperatorType::Multiply, [1.5, 100], 17]; - yield 'PostgreSQL: mul 7*-1.5 max100' => [FractionalEngine::PostgreSQL, 7, OperatorType::Multiply, [-1.5, 100], -11]; - yield 'PostgreSQL: mul 3*1.25 max100' => [FractionalEngine::PostgreSQL, 3, OperatorType::Multiply, [1.25, 100], 4]; - yield 'PostgreSQL: div 9/2.5 min0' => [FractionalEngine::PostgreSQL, 9, OperatorType::Divide, [2.5, 0], 4]; - yield 'PostgreSQL: div 10/1.5 min0' => [FractionalEngine::PostgreSQL, 10, OperatorType::Divide, [1.5, 0], 7]; - yield 'PostgreSQL: div 5/0.4 min0' => [FractionalEngine::PostgreSQL, 5, OperatorType::Divide, [0.4, 0], 13]; - yield 'PostgreSQL: pow 2^2.5 max100' => [FractionalEngine::PostgreSQL, 2, OperatorType::Power, [2.5, 100], 6]; - yield 'PostgreSQL: pow 10^0.5 max100' => [FractionalEngine::PostgreSQL, 10, OperatorType::Power, [0.5, 100], 3]; - yield 'PostgreSQL: mod -10%2.75' => [FractionalEngine::PostgreSQL, -10, OperatorType::Modulo, [2.75], -2]; - yield 'PostgreSQL: mod 10%2.5' => [FractionalEngine::PostgreSQL, 10, OperatorType::Modulo, [2.5], 0]; - yield 'PostgreSQL: mod 11%2.5' => [FractionalEngine::PostgreSQL, 11, OperatorType::Modulo, [2.5], 1]; - yield 'PostgreSQL: mod 7%-2.5' => [FractionalEngine::PostgreSQL, 7, OperatorType::Modulo, [-2.5], 2]; - yield 'PostgreSQL: pow 4^-0.5' => [FractionalEngine::PostgreSQL, 4, OperatorType::Power, [-0.5], 0]; - yield 'PostgreSQL: pow 4^-0.5 max100' => [FractionalEngine::PostgreSQL, 4, OperatorType::Power, [-0.5, 100], 0]; - yield 'PostgreSQL: pow 16^-0.25' => [FractionalEngine::PostgreSQL, 16, OperatorType::Power, [-0.25], 0]; - yield 'PostgreSQL: div 5/2 min0.5' => [FractionalEngine::PostgreSQL, 5, OperatorType::Divide, [2, 0.5], 3]; - yield 'PostgreSQL: div 7/2 min0.5' => [FractionalEngine::PostgreSQL, 7, OperatorType::Divide, [2, 0.5], 4]; - yield 'PostgreSQL: div -5/2 min-10.5' => [FractionalEngine::PostgreSQL, -5, OperatorType::Divide, [2, -10.5], -3]; - yield 'PostgreSQL: div 5/2 min2.6' => [FractionalEngine::PostgreSQL, 5, OperatorType::Divide, [2, 2.6], 5]; - yield 'PostgreSQL: mod 11%3.5' => [FractionalEngine::PostgreSQL, 11, OperatorType::Modulo, [3.5], 1]; - yield 'PostgreSQL: mod 13%3.5' => [FractionalEngine::PostgreSQL, 13, OperatorType::Modulo, [3.5], 3]; - yield 'PostgreSQL: mod -11%3.5' => [FractionalEngine::PostgreSQL, -11, OperatorType::Modulo, [3.5], -1]; - yield 'PostgreSQL: inc 10+2 max10.5' => [FractionalEngine::PostgreSQL, 10, OperatorType::Increment, [2, 10.5], 10]; - yield 'PostgreSQL: mul 5*3 max15.5' => [FractionalEngine::PostgreSQL, 5, OperatorType::Multiply, [3, 15.5], 15]; - yield 'MongoDB: inc 11+1.5' => [FractionalEngine::MongoDB, 11, OperatorType::Increment, [1.5], 12]; - yield 'MongoDB: inc 10+1.5' => [FractionalEngine::MongoDB, 10, OperatorType::Increment, [1.5], 11]; - yield 'MongoDB: inc 10+1.25' => [FractionalEngine::MongoDB, 10, OperatorType::Increment, [1.25], 11]; - yield 'MongoDB: inc 10+1.75' => [FractionalEngine::MongoDB, 10, OperatorType::Increment, [1.75], 11]; - yield 'MongoDB: inc -11+0.5' => [FractionalEngine::MongoDB, -11, OperatorType::Increment, [0.5], -10]; - yield 'MongoDB: inc -12+0.5' => [FractionalEngine::MongoDB, -12, OperatorType::Increment, [0.5], -11]; - yield 'MongoDB: dec 11-0.5' => [FractionalEngine::MongoDB, 11, OperatorType::Decrement, [0.5], 10]; - yield 'MongoDB: dec 12-0.5' => [FractionalEngine::MongoDB, 12, OperatorType::Decrement, [0.5], 11]; - yield 'MongoDB: dec -10-0.5' => [FractionalEngine::MongoDB, -10, OperatorType::Decrement, [0.5], -10]; - yield 'MongoDB: inc 10+2 max13.9' => [FractionalEngine::MongoDB, 10, OperatorType::Increment, [2, 13.9], 12]; - yield 'MongoDB: inc 12+2 max13.9' => [FractionalEngine::MongoDB, 12, OperatorType::Increment, [2, 13.9], 12]; - yield 'MongoDB: inc 12+1.5 max13.9' => [FractionalEngine::MongoDB, 12, OperatorType::Increment, [1.5, 13.9], 13]; - yield 'MongoDB: inc 12+1.5 max13.5' => [FractionalEngine::MongoDB, 12, OperatorType::Increment, [1.5, 13.5], 13]; - yield 'MongoDB: dec 12-1 min11.1' => [FractionalEngine::MongoDB, 12, OperatorType::Decrement, [1, 11.1], 12]; - yield 'MongoDB: dec 13-1 min11.1' => [FractionalEngine::MongoDB, 13, OperatorType::Decrement, [1, 11.1], 12]; - yield 'MongoDB: dec 12-0.5 min11.5' => [FractionalEngine::MongoDB, 12, OperatorType::Decrement, [0.5, 11.5], 11]; - yield 'MongoDB: mul 11*1.5' => [FractionalEngine::MongoDB, 11, OperatorType::Multiply, [1.5], 16]; - yield 'MongoDB: mul 5*2.5' => [FractionalEngine::MongoDB, 5, OperatorType::Multiply, [2.5], 12]; - yield 'MongoDB: mul 7*1.5' => [FractionalEngine::MongoDB, 7, OperatorType::Multiply, [1.5], 10]; - yield 'MongoDB: mul 7*-1.5' => [FractionalEngine::MongoDB, 7, OperatorType::Multiply, [-1.5], -10]; - yield 'MongoDB: mul 3*1.25' => [FractionalEngine::MongoDB, 3, OperatorType::Multiply, [1.25], 3]; - yield 'MongoDB: mul 12*2 max25.5' => [FractionalEngine::MongoDB, 12, OperatorType::Multiply, [2, 25.5], 24]; - yield 'MongoDB: mul 13*2 max25.5' => [FractionalEngine::MongoDB, 13, OperatorType::Multiply, [2, 25.5], 13]; - yield 'MongoDB: mul 10*1.5 max15' => [FractionalEngine::MongoDB, 10, OperatorType::Multiply, [1.5, 15], 15]; - yield 'MongoDB: div 10/1.5' => [FractionalEngine::MongoDB, 10, OperatorType::Divide, [1.5], 6]; - yield 'MongoDB: div 9/2.5' => [FractionalEngine::MongoDB, 9, OperatorType::Divide, [2.5], 3]; - yield 'MongoDB: div 10/2 min5.5' => [FractionalEngine::MongoDB, 10, OperatorType::Divide, [2, 5.5], 10]; - yield 'MongoDB: div 10/0.5' => [FractionalEngine::MongoDB, 10, OperatorType::Divide, [0.5], 20]; - yield 'MongoDB: mod 10%2.75' => [FractionalEngine::MongoDB, 10, OperatorType::Modulo, [2.75], 1]; - yield 'MongoDB: mod 11%4.5' => [FractionalEngine::MongoDB, 11, OperatorType::Modulo, [4.5], 2]; - yield 'MongoDB: pow 16^0.5' => [FractionalEngine::MongoDB, 16, OperatorType::Power, [0.5], 4]; - yield 'MongoDB: pow 10^0.5' => [FractionalEngine::MongoDB, 10, OperatorType::Power, [0.5], 3]; - yield 'MongoDB: pow 2^2.5' => [FractionalEngine::MongoDB, 2, OperatorType::Power, [2.5], 5]; - yield 'MongoDB: pow 4^1.5' => [FractionalEngine::MongoDB, 4, OperatorType::Power, [1.5], 8]; - yield 'MongoDB: pow 3^2 max8.5' => [FractionalEngine::MongoDB, 3, OperatorType::Power, [2, 8.5], 3]; - yield 'MongoDB: pow 2^3 max8.5' => [FractionalEngine::MongoDB, 2, OperatorType::Power, [3, 8.5], 8]; - yield 'MongoDB: inc 11+1.5 max20' => [FractionalEngine::MongoDB, 11, OperatorType::Increment, [1.5, 20], 12]; - yield 'MongoDB: inc 10+1.5 max20' => [FractionalEngine::MongoDB, 10, OperatorType::Increment, [1.5, 20], 11]; - yield 'MongoDB: inc -12+0.5 max20' => [FractionalEngine::MongoDB, -12, OperatorType::Increment, [0.5, 20], -11]; - yield 'MongoDB: inc 10+1.25 max20' => [FractionalEngine::MongoDB, 10, OperatorType::Increment, [1.25, 20], 11]; - yield 'MongoDB: dec 11-0.5 min0' => [FractionalEngine::MongoDB, 11, OperatorType::Decrement, [0.5, 0], 10]; - yield 'MongoDB: dec 12-0.5 min0' => [FractionalEngine::MongoDB, 12, OperatorType::Decrement, [0.5, 0], 11]; - yield 'MongoDB: dec -10-0.5 min-100' => [FractionalEngine::MongoDB, -10, OperatorType::Decrement, [0.5, -100], -10]; - yield 'MongoDB: mul 5*2.5 max100' => [FractionalEngine::MongoDB, 5, OperatorType::Multiply, [2.5, 100], 12]; - yield 'MongoDB: mul 11*1.5 max100' => [FractionalEngine::MongoDB, 11, OperatorType::Multiply, [1.5, 100], 16]; - yield 'MongoDB: mul 7*-1.5 max100' => [FractionalEngine::MongoDB, 7, OperatorType::Multiply, [-1.5, 100], -10]; - yield 'MongoDB: mul 3*1.25 max100' => [FractionalEngine::MongoDB, 3, OperatorType::Multiply, [1.25, 100], 3]; - yield 'MongoDB: div 9/2.5 min0' => [FractionalEngine::MongoDB, 9, OperatorType::Divide, [2.5, 0], 3]; - yield 'MongoDB: div 10/1.5 min0' => [FractionalEngine::MongoDB, 10, OperatorType::Divide, [1.5, 0], 6]; - yield 'MongoDB: div 5/0.4 min0' => [FractionalEngine::MongoDB, 5, OperatorType::Divide, [0.4, 0], 12]; - yield 'MongoDB: pow 2^2.5 max100' => [FractionalEngine::MongoDB, 2, OperatorType::Power, [2.5, 100], 5]; - yield 'MongoDB: pow 10^0.5 max100' => [FractionalEngine::MongoDB, 10, OperatorType::Power, [0.5, 100], 3]; - yield 'MongoDB: mod -10%2.75' => [FractionalEngine::MongoDB, -10, OperatorType::Modulo, [2.75], -1]; - yield 'MongoDB: mod 10%2.5' => [FractionalEngine::MongoDB, 10, OperatorType::Modulo, [2.5], 0]; - yield 'MongoDB: mod 11%2.5' => [FractionalEngine::MongoDB, 11, OperatorType::Modulo, [2.5], 1]; - yield 'MongoDB: mod 7%-2.5' => [FractionalEngine::MongoDB, 7, OperatorType::Modulo, [-2.5], 2]; - yield 'MongoDB: pow 4^-0.5' => [FractionalEngine::MongoDB, 4, OperatorType::Power, [-0.5], 0]; - yield 'MongoDB: pow 4^-0.5 max100' => [FractionalEngine::MongoDB, 4, OperatorType::Power, [-0.5, 100], 0]; - yield 'MongoDB: pow 16^-0.25' => [FractionalEngine::MongoDB, 16, OperatorType::Power, [-0.25], 0]; - yield 'MongoDB: div 5/2 min0.5' => [FractionalEngine::MongoDB, 5, OperatorType::Divide, [2, 0.5], 2]; - yield 'MongoDB: div 7/2 min0.5' => [FractionalEngine::MongoDB, 7, OperatorType::Divide, [2, 0.5], 3]; - yield 'MongoDB: div -5/2 min-10.5' => [FractionalEngine::MongoDB, -5, OperatorType::Divide, [2, -10.5], -2]; - yield 'MongoDB: div 5/2 min2.6' => [FractionalEngine::MongoDB, 5, OperatorType::Divide, [2, 2.6], 5]; - yield 'MongoDB: mod 11%3.5' => [FractionalEngine::MongoDB, 11, OperatorType::Modulo, [3.5], 0]; - yield 'MongoDB: mod 13%3.5' => [FractionalEngine::MongoDB, 13, OperatorType::Modulo, [3.5], 2]; - yield 'MongoDB: mod -11%3.5' => [FractionalEngine::MongoDB, -11, OperatorType::Modulo, [3.5], 0]; - yield 'MongoDB: inc 10+2 max10.5' => [FractionalEngine::MongoDB, 10, OperatorType::Increment, [2, 10.5], 10]; - yield 'MongoDB: mul 5*3 max15.5' => [FractionalEngine::MongoDB, 5, OperatorType::Multiply, [3, 15.5], 15]; - yield 'Memory: inc 11+1.5' => [FractionalEngine::Memory, 11, OperatorType::Increment, [1.5], 12]; - yield 'Memory: inc 10+1.5' => [FractionalEngine::Memory, 10, OperatorType::Increment, [1.5], 11]; - yield 'Memory: inc 10+1.25' => [FractionalEngine::Memory, 10, OperatorType::Increment, [1.25], 11]; - yield 'Memory: inc 10+1.75' => [FractionalEngine::Memory, 10, OperatorType::Increment, [1.75], 11]; - yield 'Memory: inc -11+0.5' => [FractionalEngine::Memory, -11, OperatorType::Increment, [0.5], -10]; - yield 'Memory: inc -12+0.5' => [FractionalEngine::Memory, -12, OperatorType::Increment, [0.5], -11]; - yield 'Memory: dec 11-0.5' => [FractionalEngine::Memory, 11, OperatorType::Decrement, [0.5], 10]; - yield 'Memory: dec 12-0.5' => [FractionalEngine::Memory, 12, OperatorType::Decrement, [0.5], 11]; - yield 'Memory: dec -10-0.5' => [FractionalEngine::Memory, -10, OperatorType::Decrement, [0.5], -10]; - yield 'Memory: inc 10+2 max13.9' => [FractionalEngine::Memory, 10, OperatorType::Increment, [2, 13.9], 12]; - yield 'Memory: inc 12+2 max13.9' => [FractionalEngine::Memory, 12, OperatorType::Increment, [2, 13.9], 12]; - yield 'Memory: inc 12+1.5 max13.9' => [FractionalEngine::Memory, 12, OperatorType::Increment, [1.5, 13.9], 13]; - yield 'Memory: inc 12+1.5 max13.5' => [FractionalEngine::Memory, 12, OperatorType::Increment, [1.5, 13.5], 13]; - yield 'Memory: dec 12-1 min11.1' => [FractionalEngine::Memory, 12, OperatorType::Decrement, [1, 11.1], 12]; - yield 'Memory: dec 13-1 min11.1' => [FractionalEngine::Memory, 13, OperatorType::Decrement, [1, 11.1], 12]; - yield 'Memory: dec 12-0.5 min11.5' => [FractionalEngine::Memory, 12, OperatorType::Decrement, [0.5, 11.5], 11]; - yield 'Memory: mul 11*1.5' => [FractionalEngine::Memory, 11, OperatorType::Multiply, [1.5], 16]; - yield 'Memory: mul 5*2.5' => [FractionalEngine::Memory, 5, OperatorType::Multiply, [2.5], 12]; - yield 'Memory: mul 7*1.5' => [FractionalEngine::Memory, 7, OperatorType::Multiply, [1.5], 10]; - yield 'Memory: mul 7*-1.5' => [FractionalEngine::Memory, 7, OperatorType::Multiply, [-1.5], -10]; - yield 'Memory: mul 3*1.25' => [FractionalEngine::Memory, 3, OperatorType::Multiply, [1.25], 3]; - yield 'Memory: mul 12*2 max25.5' => [FractionalEngine::Memory, 12, OperatorType::Multiply, [2, 25.5], 24]; - yield 'Memory: mul 13*2 max25.5' => [FractionalEngine::Memory, 13, OperatorType::Multiply, [2, 25.5], 13]; - yield 'Memory: mul 10*1.5 max15' => [FractionalEngine::Memory, 10, OperatorType::Multiply, [1.5, 15], 15]; - yield 'Memory: div 10/1.5' => [FractionalEngine::Memory, 10, OperatorType::Divide, [1.5], 6]; - yield 'Memory: div 9/2.5' => [FractionalEngine::Memory, 9, OperatorType::Divide, [2.5], 3]; - yield 'Memory: div 10/2 min5.5' => [FractionalEngine::Memory, 10, OperatorType::Divide, [2, 5.5], 10]; - yield 'Memory: div 10/0.5' => [FractionalEngine::Memory, 10, OperatorType::Divide, [0.5], 20]; - yield 'Memory: mod 10%2.75' => [FractionalEngine::Memory, 10, OperatorType::Modulo, [2.75], 0]; - yield 'Memory: mod 11%4.5' => [FractionalEngine::Memory, 11, OperatorType::Modulo, [4.5], 3]; - yield 'Memory: pow 16^0.5' => [FractionalEngine::Memory, 16, OperatorType::Power, [0.5], 4]; - yield 'Memory: pow 10^0.5' => [FractionalEngine::Memory, 10, OperatorType::Power, [0.5], 3]; - yield 'Memory: pow 2^2.5' => [FractionalEngine::Memory, 2, OperatorType::Power, [2.5], 5]; - yield 'Memory: pow 4^1.5' => [FractionalEngine::Memory, 4, OperatorType::Power, [1.5], 8]; - yield 'Memory: pow 3^2 max8.5' => [FractionalEngine::Memory, 3, OperatorType::Power, [2, 8.5], 3]; - yield 'Memory: pow 2^3 max8.5' => [FractionalEngine::Memory, 2, OperatorType::Power, [3, 8.5], 8]; - yield 'Memory: inc 11+1.5 max20' => [FractionalEngine::Memory, 11, OperatorType::Increment, [1.5, 20], 12]; - yield 'Memory: inc 10+1.5 max20' => [FractionalEngine::Memory, 10, OperatorType::Increment, [1.5, 20], 11]; - yield 'Memory: inc -12+0.5 max20' => [FractionalEngine::Memory, -12, OperatorType::Increment, [0.5, 20], -11]; - yield 'Memory: inc 10+1.25 max20' => [FractionalEngine::Memory, 10, OperatorType::Increment, [1.25, 20], 11]; - yield 'Memory: dec 11-0.5 min0' => [FractionalEngine::Memory, 11, OperatorType::Decrement, [0.5, 0], 10]; - yield 'Memory: dec 12-0.5 min0' => [FractionalEngine::Memory, 12, OperatorType::Decrement, [0.5, 0], 11]; - yield 'Memory: dec -10-0.5 min-100' => [FractionalEngine::Memory, -10, OperatorType::Decrement, [0.5, -100], -10]; - yield 'Memory: mul 5*2.5 max100' => [FractionalEngine::Memory, 5, OperatorType::Multiply, [2.5, 100], 12]; - yield 'Memory: mul 11*1.5 max100' => [FractionalEngine::Memory, 11, OperatorType::Multiply, [1.5, 100], 16]; - yield 'Memory: mul 7*-1.5 max100' => [FractionalEngine::Memory, 7, OperatorType::Multiply, [-1.5, 100], -10]; - yield 'Memory: mul 3*1.25 max100' => [FractionalEngine::Memory, 3, OperatorType::Multiply, [1.25, 100], 3]; - yield 'Memory: div 9/2.5 min0' => [FractionalEngine::Memory, 9, OperatorType::Divide, [2.5, 0], 3]; - yield 'Memory: div 10/1.5 min0' => [FractionalEngine::Memory, 10, OperatorType::Divide, [1.5, 0], 6]; - yield 'Memory: div 5/0.4 min0' => [FractionalEngine::Memory, 5, OperatorType::Divide, [0.4, 0], 12]; - yield 'Memory: pow 2^2.5 max100' => [FractionalEngine::Memory, 2, OperatorType::Power, [2.5, 100], 5]; - yield 'Memory: pow 10^0.5 max100' => [FractionalEngine::Memory, 10, OperatorType::Power, [0.5, 100], 3]; - yield 'Memory: mod -10%2.75' => [FractionalEngine::Memory, -10, OperatorType::Modulo, [2.75], 0]; - yield 'Memory: mod 10%2.5' => [FractionalEngine::Memory, 10, OperatorType::Modulo, [2.5], 0]; - yield 'Memory: mod 11%2.5' => [FractionalEngine::Memory, 11, OperatorType::Modulo, [2.5], 1]; - yield 'Memory: mod 7%-2.5' => [FractionalEngine::Memory, 7, OperatorType::Modulo, [-2.5], 1]; - yield 'Memory: pow 4^-0.5' => [FractionalEngine::Memory, 4, OperatorType::Power, [-0.5], 0]; - yield 'Memory: pow 4^-0.5 max100' => [FractionalEngine::Memory, 4, OperatorType::Power, [-0.5, 100], 0]; - yield 'Memory: pow 16^-0.25' => [FractionalEngine::Memory, 16, OperatorType::Power, [-0.25], 0]; - yield 'Memory: div 5/2 min0.5' => [FractionalEngine::Memory, 5, OperatorType::Divide, [2, 0.5], 2]; - yield 'Memory: div 7/2 min0.5' => [FractionalEngine::Memory, 7, OperatorType::Divide, [2, 0.5], 3]; - yield 'Memory: div -5/2 min-10.5' => [FractionalEngine::Memory, -5, OperatorType::Divide, [2, -10.5], -2]; - yield 'Memory: div 5/2 min2.6' => [FractionalEngine::Memory, 5, OperatorType::Divide, [2, 2.6], 5]; - yield 'Memory: mod 11%3.5' => [FractionalEngine::Memory, 11, OperatorType::Modulo, [3.5], 2]; - yield 'Memory: mod 13%3.5' => [FractionalEngine::Memory, 13, OperatorType::Modulo, [3.5], 1]; - yield 'Memory: mod -11%3.5' => [FractionalEngine::Memory, -11, OperatorType::Modulo, [3.5], -2]; - yield 'Memory: inc 10+2 max10.5' => [FractionalEngine::Memory, 10, OperatorType::Increment, [2, 10.5], 10]; - yield 'Memory: mul 5*3 max15.5' => [FractionalEngine::Memory, 5, OperatorType::Multiply, [3, 15.5], 15]; - } - - /** - * Fractional counter changes: the engine, whether it increases, the start, the change, the bound and the value - * stored, or null where the engine refused the change. MariaDB rounded half to even. PostgreSQL bound the - * change next to an integer column and refused any fraction. MongoDB and Memory stored the double and every - * read cut it toward zero; these rows cut each change on its own, which matches them while one change is - * applied at a time. - * - * @return iterable - */ - public static function counters(): iterable - { - $changes = [ - 'increase rounds a half down to even' => [true, 5, 1.5, null, [6, 6]], - 'increase rounds a half up to even' => [true, 6, 1.5, null, [8, 7]], - 'increase rounds below a half down' => [true, 5, 1.4, null, [6, 6]], - 'increase rounds above a half up' => [true, 5, 1.6, null, [7, 6]], - 'increase rounds a negative half to even' => [true, -10, 2.5, null, [-8, -7]], - 'increase by a half that rounds back to the value' => [true, 4, 0.5, null, [4, 4]], - 'increase by a half that rounds up' => [true, 5, 0.5, null, [6, 5]], - 'increase within a fractional maximum' => [true, 5, 1.5, 6.5, [6, 6]], - 'increase within a whole maximum' => [true, 5, 1.5, 7, [6, 6]], - 'increase a big integer' => [true, 5, 2.5, null, [8, 7]], - 'decrease rounds a half up to even' => [false, 5, 1.5, null, [4, 3]], - 'decrease rounds a half down to even' => [false, 6, 1.5, null, [4, 4]], - 'decrease by less than a half keeps the value' => [false, 5, 0.4, null, [5, 4]], - 'decrease within a fractional minimum' => [false, 5, 1.5, 3.5, [4, 3]], - 'increase beyond float precision rounds half to even' => [true, 9007199254740993, 1.5, null, [9007199254740994, 9007199254740994]], - 'increase beyond float precision rounds up past the half' => [true, 9007199254740993, 1.75, null, [9007199254740995, 9007199254740994]], - 'increase beyond float precision rounds down below the half' => [true, 9007199254740993, 1.25, null, [9007199254740994, 9007199254740994]], - 'decrease beyond float precision rounds half to even' => [false, 9007199254740995, 1.5, null, [9007199254740994, 9007199254740993]], - 'decrease beyond float precision rounds down past the half' => [false, 9007199254740995, 1.75, null, [9007199254740993, 9007199254740993]], - 'decrease beyond float precision rounds up below the half' => [false, 9007199254740995, 1.25, null, [9007199254740994, 9007199254740993]], - ]; - - foreach ($changes as $name => [$increase, $start, $value, $bound, [$mariaDB, $mongoDB]]) { - yield "MariaDB: {$name}" => [FractionalEngine::MariaDB, $increase, $start, $value, $bound, $mariaDB]; - yield "PostgreSQL: {$name}" => [FractionalEngine::PostgreSQL, $increase, $start, $value, $bound, null]; - yield "MongoDB: {$name}" => [FractionalEngine::MongoDB, $increase, $start, $value, $bound, $mongoDB]; - yield "Memory: {$name}" => [FractionalEngine::Memory, $increase, $start, $value, $bound, $mongoDB]; - } - - foreach (FractionalEngine::cases() as $engine) { - yield "{$engine->name}: a whole float change is stored whole" => [$engine, true, 8, 2.0, null, 10]; - } - } -} diff --git a/tests/unit/Validator/OperatorFractionsTest.php b/tests/unit/Validator/OperatorFractionsTest.php deleted file mode 100644 index 69cbde48d..000000000 --- a/tests/unit/Validator/OperatorFractionsTest.php +++ /dev/null @@ -1,87 +0,0 @@ - - */ - public static function fractionalOperators(): iterable - { - yield 'fractional increment' => [Operator::increment(1.5)]; - yield 'fractional text decrement' => [Operator::decrement('0.5')]; - yield 'whole increment with a fractional limit' => [Operator::increment(2, 13.9)]; - yield 'fractional increment with a fractional limit' => [Operator::increment(1.5, 13.5)]; - yield 'fractional multiply' => [Operator::multiply(2.5)]; - yield 'fractional divide with a fractional limit' => [Operator::divide(1.5, 5.5)]; - yield 'fractional modulo' => [Operator::modulo(2.75)]; - yield 'fractional power' => [Operator::power(0.5)]; - } - - #[DataProvider('fractionalOperators')] - public function testAFractionalAmountOrLimitOnAnIntegerIsAcceptedWithTheFlag(Operator $operator): void - { - $validator = $this->validator(fractions: true); - - $this->assertTrue($validator->isValid($operator->setAttribute('count')), $validator->getDescription()); - } - - #[DataProvider('fractionalOperators')] - public function testAFractionalAmountOrLimitOnAnIntegerIsRefusedWithoutTheFlag(Operator $operator): void - { - $this->assertFalse($this->validator()->isValid($operator->setAttribute('count'))); - } - - public function testAFractionOutsideTheIntegerRangeIsRefused(): void - { - $validator = $this->validator(fractions: true); - - $this->assertFalse($validator->isValid(Operator::increment(2147483647.5)->setAttribute('count'))); - $this->assertSame('Cannot apply increment operator: value must be numeric, got double', $validator->getDescription()); - - $this->assertFalse($validator->isValid(Operator::increment(1, -2147483648.5)->setAttribute('count'))); - $this->assertSame('Cannot apply increment operator: max/min limit must be between -2147483648 and 2147483647', $validator->getDescription()); - } - - public function testAFractionIsLeftToTheCallerToResolveAgainstTheStoredValue(): void - { - $validator = $this->validator(new Document(['count' => 2147483647]), fractions: true); - - $this->assertTrue($validator->isValid(Operator::increment(1.5)->setAttribute('count'))); - $this->assertFalse($validator->isValid(Operator::increment(1)->setAttribute('count')), 'A whole amount is still checked against the stored value'); - } - - public function testTheFlagLeavesOtherOperandsAsTheyAre(): void - { - $validator = $this->validator(fractions: true); - - $this->assertFalse($validator->isValid(Operator::increment(\INF)->setAttribute('count'))); - $this->assertFalse($validator->isValid(Operator::increment('ten')->setAttribute('count'))); - $this->assertFalse($validator->isValid(Operator::arrayAppend([1.5])->setAttribute('scores'))); - $this->assertTrue($validator->isValid(Operator::increment(1.5, 10.25)->setAttribute('ratio'))); - } - - private function validator(?Document $current = null, bool $fractions = false): OperatorValidator - { - return new OperatorValidator(new Document([ - Document::ID => 'operands', - Document::COLLECTION => Database::METADATA, - 'name' => 'operands', - 'attributes' => [ - Attribute::integer(key: 'count')->toDocument(), - Attribute::integer(key: 'scores', array: true)->toDocument(), - Attribute::double(key: 'ratio')->toDocument(), - ], - 'indexes' => [], - ]), $current, fractions: $fractions); - } -} From a6a67b03d5a201f6d683644df5cc8df42ffe655f Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 14 Oct 2026 18:06:22 +1300 Subject: [PATCH 06/18] docs: describe the 8.1.2 integer rounding rule Callers of setRoundFractions() and Adapter::arithmetic() need to know they are gone and what replaced them, and the 8.0 to 8.1 guide must no longer send readers to the removed toggle. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 30 +++++++++++ UPGRADE.md | 147 ++++++++++++++++++--------------------------------- 2 files changed, 80 insertions(+), 97 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 513070975..ee8b74daf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## 8.1.2 + +See [Upgrading from 8.1.1 to 8.1.2](UPGRADE.md#upgrading-from-811-to-812). + +### Removed + +- `Database::setRoundFractions()`, `isRoundingFractions()` and `withRoundFractions()`, with the `Mirror` overrides. + Integer rounding is always on (listed under Changed). +- `Adapter::arithmetic()` and its overrides in `Postgres`, `Mongo`, `SQLite`, `Memory`, `Redis` and `Pool`; + `ReadWritePool` no longer lists it among the methods it answers wherever reads go. +- `Utopia\Database\Arithmetic`, `Utopia\Database\Arithmetic\Rounding` and the internal `Arithmetic\Decimal` and + `Arithmetic\Fractional`. +- The trailing `bool $fractions` argument of `Validator\Operator` and `Validator\Structure` (and so + `Validator\PartialStructure`). +- The trailing `bool $roundFractions` argument of the `State\Snapshot` constructor. + +### Changed + +- On a single-valued integer or big integer attribute, `increaseDocumentAttribute()` and `decreaseDocumentAttribute()` + round a fractional change half away from zero (1.5 adds 2, 2.5 subtracts 3), floor a fractional `max` and ceil a + fractional `min`; `Operator::increment()` and `Operator::decrement()` given to `updateDocument()`, + `updateDocuments()`, `upsertDocument()` and `upsertDocuments()` round their amount and limit the same way, so + `increment(-2.5)` subtracts 3. Rounding happens once, in PHP, before validation and on every adapter alike. Decimal + text such as `'9007199254740993.5'` is rounded exactly at any size; text with an exponent is still refused. A + counter change that rounds to 0, such as 0.4, leaves the document unchanged: it writes nothing and fires no event. + Before, these were refused, unless `setRoundFractions(true)` stored what the adapter's engine stored on 7.4.1. +- Still refused with `Exception\Structure`, as in 8.0.3: a fractional amount or limit of `multiply()`, `divide()`, + `modulo()` or `power()` on an integer attribute, and a fractional item added to an integer array. Float and double + attributes are unchanged. + ## 8.1.1 ### Added diff --git a/UPGRADE.md b/UPGRADE.md index 612bb0e6f..f3cc42852 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,28 +1,67 @@ +# Upgrading from 8.1.1 to 8.1.2 + +8.1.2 replaces the opt-in emulation of 7.4.1's fractional arithmetic on integer attributes with one rounding rule that +always applies. [CHANGELOG.md](CHANGELOG.md#812) lists every removed signature. + +## Integer rounding + +On a single-valued integer or big integer attribute: + +| Value | Rule | Example | +|---|---|---| +| The change of `increaseDocumentAttribute()` or `decreaseDocumentAttribute()` | Rounded half away from zero | 1.5 adds 2; 2.5 subtracts 3 | +| The amount of `Operator::increment()` or `Operator::decrement()` | Rounded half away from zero | `increment(-2.5)` subtracts 3 | +| A `max`, or the limit of `increment()` | Floored | 101.9 is 101 | +| A `min`, or the limit of `decrement()` | Ceiled | 99.1 is 100 | + +The operators are rounded in `updateDocument()`, `updateDocuments()`, `upsertDocument()` and `upsertDocuments()`. +Rounding happens once, in PHP, before validation, so every adapter stores the same result. Decimal text is rounded +exactly at any size: `'9007199254740993.5'` adds 9007199254740994. Text with an exponent, such as `'1.5e2'`, and +values that are not finite are still refused as in 8.1.1. + +A counter change that rounds to 0, such as 0.4, leaves the document unchanged: it writes nothing, keeps `$updatedAt` +and fires no event. An operator amount that rounds to 0 changes nothing either. + +Still refused with `Exception\Structure`, as by default in 8.1.1: a fractional amount or limit of `multiply()`, +`divide()`, `modulo()` or `power()` on an integer attribute, and a fractional item added to an integer array. Float and +double attributes are unchanged. + +If you relied on fractions being refused, check the value before you call. If you used `setRoundFractions(true)`: + +- Delete the `setRoundFractions()` and `withRoundFractions()` calls and any `isRoundingFractions()` checks. The rule + above applies on every adapter instead of each engine's 7.4.1 rounding. +- A counter returns the stored whole number, not the exact fractional result as a float. +- A fractional `max` or `min` is floored or ceiled before the comparison, instead of being compared as given. +- The stored value is no longer read locked to resolve an operator, and `updateDocuments()` sends one update for all + rows again. + +Code that called `Adapter::arithmetic()`, built an `Arithmetic` or `Arithmetic\Rounding`, passed `$fractions` to +`Validator\Operator`, `Validator\Structure` or `Validator\PartialStructure`, or passed `$roundFractions` to the +`State\Snapshot` constructor must drop it. + # Upgrading from 8.0 to 8.1 8.1 is a minor release. Every change in it is additive or opt-in, with one exception: it runs [joins on a one-to-many relationship](#joins-on-a-one-to-many-relationship) that 8.0.3 refused. With no `Hook\Nested` -hook registered and with `setCheckGrants()` and `setRoundFractions()` off, which is the default, a `Database` behaves -exactly as in 8.0.3 apart from that join. +hook registered and with `setCheckGrants()` off, which is the default, a `Database` behaves exactly as in 8.0.3 apart +from that join and the [integer rounding](#integer-rounding) of 8.1.2. [CHANGELOG.md](CHANGELOG.md) lists every new signature. - [Preparing nested related documents](#preparing-nested-related-documents) - [Grant check on related documents](#grant-check-on-related-documents) -- [Fractional operands on integer attributes](#fractional-operands-on-integer-attributes) - [Cursor from an ID](#cursor-from-an-id) - [One read for documents and aggregates](#one-read-for-documents-and-aggregates) - [Joins on a one-to-many relationship](#joins-on-a-one-to-many-relationship) - [Spatial support behind a pool](#spatial-support-behind-a-pool) - [`State\Snapshot`](#statesnapshot) -The two new toggles follow the 8.0 toggle idiom (see [Configuration toggles](#configuration-toggles)). Each one is -scoped to the calling coroutine and the coroutines it starts. `withSnapshot()` carries it into relationship writes and +The new toggle follows the 8.0 toggle idiom (see [Configuration toggles](#configuration-toggles)). It is scoped to +the calling coroutine and the coroutines it starts. `withSnapshot()` carries it into relationship writes and other work that runs in another coroutine. | Toggle | Set | Read | Scope | |---|---|---|---| | Grant check | `setCheckGrants(bool $check): static` | `isCheckingGrants(): bool` | `withCheckGrants(bool $check, callable $callback): mixed` | -| Fractions | `setRoundFractions(bool $round): static` | `isRoundingFractions(): bool` | `withRoundFractions(bool $round, callable $callback): mixed` | ## Preparing nested related documents @@ -164,93 +203,6 @@ check passes and the relationship write refuses the document for its read or upd `Exception\Authorization`. If that difference matters to you, answer every `Exception\Authorization` with the same response. -## Fractional operands on integer attributes - -Opt-in. By default 8.1 refuses fractions on integer attributes exactly as 8.0.2 and 8.0.3 do: -`increaseDocumentAttribute()` and `decreaseDocumentAttribute()` throw `Exception\Type`, and fractional operator limits -throw `Exception\Structure`. - -With `setRoundFractions(true)`, fractions on single-valued integer and big integer attributes are stored as the -adapter's engine stored them on 7.4.1, which handed them to the engine. This covers: - -- the change value and the `max` or `min` of `increaseDocumentAttribute()` and `decreaseDocumentAttribute()`; -- the amount and limit of `Operator::increment()`, `decrement()`, `multiply()`, `divide()`, `modulo()` and `power()` - given to `updateDocument()`, `updateDocuments()`, `upsertDocument()` and `upsertDocuments()`; -- fractional items added to an integer array by `arrayAppend()`, `arrayPrepend()` or `arrayInsert()`, which are cut - toward zero, as every engine read them back. - -Float and double attributes are unchanged. - -Each fractional operator is resolved from the stored value into the whole-number increment or decrement that stores -the engine's result. The stored value is read locked, inside the write's transaction. `updateDocuments()` and the -upserts read each row locked inside its batch's transaction, then send one update per group of rows with the same -resolved change. A row an upsert creates is resolved from 0. A write without a fractional operator on an integer -attribute takes the 8.0 path. Big integers stay exact beyond 2^53: the stored value never passes through a float. - -`Adapter::arithmetic()` declares each engine's rules: - -| Adapter | Counter change | Divide by a whole number | Power | Other operators | Refused | -|---|---|---|---|---|---| -| MariaDB, MySQL | Half to even | Exact, half away from zero | Half to even | Computed in `DOUBLE`, half to even | — | -| PostgreSQL | Refused | Exact, half away from zero | Double precision, half to even | `NUMERIC`, half away from zero; multiply, divide and modulo exact | A fractional counter change; a fractional amount of increment, decrement, multiply or divide without a limit | -| MongoDB | Toward zero | Toward zero | Toward zero | Toward zero | — | -| SQLite, Memory, Redis | Toward zero | Toward zero | Toward zero | Toward zero; a fractional modulus is cut toward zero first | A modulus that cuts to zero (`modulo(0.5)`) | - -An adapter that did not exist in 7.4.1 answers with the MariaDB and MySQL rules unless it overrides `arithmetic()`. -`Pool` asks the pooled adapter once per pool, and `ReadWritePool` answers wherever reads go. - -**Operator limits.** A limit is compared with the exact result, as given, and is never rounded. A result past the limit -leaves the stored value unchanged, as on 7.4.1. So does a power that has no value under a limit: 0 to a negative power, -or a negative base to a fractional power. Because the engine rounded after comparing, a stored whole number can pass a -fractional limit: on MariaDB, `increment(1.5, 13.5)` on 12 stores 14. - -**Counters.** - -- A whole change is an integer, integer text, numeric text with a whole value such as `'1e3'`, or a float without a - fraction. With a whole change, a fractional `max` is rounded down and a fractional `min` rounded up, reading text - exactly. Then the change runs as in 8.0. On PostgreSQL, a whole change with a fractional bound is refused as 7.4.1 - refused it: `Exception\Limit` if the exact result passes the bound, otherwise `Exception\Type` (`Max must be an - integer.` or `Min must be an integer.`). -- With a fractional change, the bound is used as given. The stored value is read locked. If the exact result passes - the bound, the call throws `Exception\Limit` before writing. Otherwise it writes the whole-number change the engine - would have stored, and the returned document carries the exact result as a float. On MariaDB, 5 + 1.5 with - `max` 6.5 returns 6.5 and stores 6, and 5 − 1.5 with `min` 3.5 returns 3.5 and stores 4. -- A whole-number change of 0 still writes. On MariaDB, 4 + 0.5 stores 4, moves `$updatedAt`, purges the caches and - fires the events. -- On PostgreSQL a fractional change throws `Exception\Type` (`Change value must be an integer.`), with or without a - bound. A bound the exact result passes throws `Exception\Limit` first. -- A change of 0 or less still throws `Exception\Type` (`Value must be numeric and greater than 0`). - -**Refusals.** An operator an engine refused, or one whose amount or limit is infinite or not a number, comes back -unresolved. Validation then refuses it with `Exception\Structure`, as by default. So does an amount or limit on -PostgreSQL's exact operators that is too long to compute exactly (more than 128 significant digits). A counter change -that is infinite or not a number throws `Exception\Type`. - -**MongoDB.** 7.4.1 stored the fractional double, every read cut it toward zero, and later operations continued from the -double. 8.1 stores the whole number after each operation, which is what every 7.4.1 read returned. This approximates -7.4.1 rather than reproducing it: two increments of 0.5 from 1 stored 2.0 on 7.4.1 and store 1 on 8.1. **SQLite.** -7.4.1 compared a fractional operator limit as text, so a fractional maximum never held and a fractional minimum always -held. 8.1 does not reproduce this: limits hold as on Memory and Redis. - -**Mirror.** `Mirror` forwards the toggle to its source and destination. - -- `increaseDocumentAttribute()`, `decreaseDocumentAttribute()`, `updateDocuments()` and the upserts are replayed on - the destination with the caller's arguments. The destination resolves them with its own adapter's rules and its own - stored values. If it refuses one, for example a fractional counter on a PostgreSQL destination, the failure only - reaches `onError()`. -- `updateDocument()` replicates the values the source stored. - -`Validator\Operator` and `Validator\Structure` (and `Validator\PartialStructure`) take a trailing -`bool $fractions = false` for code that validates operators itself. When it is set, a finite fractional amount or limit -within the attribute's range passes, and the caller resolves it into a whole one before writing. - -```php -$database->setRoundFractions(true); - -// MariaDB: stores 6 and returns 6.5. PostgreSQL: throws Exception\Type, as 7.4.1 did. -$document = $database->increaseDocumentAttribute('posts', 'p1', 'likes', 1.5, max: 6.5); -``` - ## Cursor from an ID Additive. `Database::resolveCursor(string $collection, string $id, CursorDirection $direction, array $queries = []): @@ -321,9 +273,9 @@ source. Creating a `point` attribute fails exactly when `hasFeature(Feature\Spat ## `State\Snapshot` -Additive. The `State\Snapshot` constructor takes two more trailing arguments, `bool $checkGrants = false` and -`bool $roundFractions = false`, which `Database::snapshot()` fills in. Code that builds a `Snapshot` itself still -works; a snapshot built without them runs its callback with both toggles off. +Additive. The `State\Snapshot` constructor takes one more trailing argument, `bool $checkGrants = false`, which +`Database::snapshot()` fills in. Code that builds a `Snapshot` itself still works; a snapshot built without it runs its +callback with the grant check off. # Upgrading from 7.x to 8.0 @@ -1221,7 +1173,8 @@ 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.** `increaseDocumentAttribute()` and `decreaseDocumentAttribute()` +- **Fractional numbers on integer attributes.** Since 8.1.2 these are rounded instead (see + [Integer rounding](#integer-rounding)). In 8.0, `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 From e1cc40c1da0a991e5ab75af9db76f81c5e701cd1 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 14 Oct 2026 18:08:10 +1300 Subject: [PATCH 07/18] test: name the rounding tests after the rule they check The adapted and new tests still carried the removed mode's vocabulary in their names; naming them after the rounding rule keeps a search for the removed subsystem pointing only at what is really gone. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/Adapter/Scopes/DocumentTests.php | 4 ++-- tests/e2e/Adapter/Scopes/IntegerRoundingTests.php | 2 +- tests/e2e/Adapter/Scopes/OperatorTests.php | 2 +- tests/unit/Adapter/RedisAdapterPathsTest.php | 2 +- tests/unit/Documents/IntegerRoundingTest.php | 6 +++--- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index f32656909..9bde54ffc 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -10917,7 +10917,7 @@ private function statementsOn(Database $database, string $table, callable $read) return [$result, $statements]; } - public function testIncreaseAndDecreaseFloorAndCeilAFractionalBoundOnAnInteger(): void + public function testIncreaseAndDecreaseFloorAMaximumAndCeilAMinimumOnAnInteger(): void { /** @var Database $database */ $database = $this->getDatabase(); @@ -10958,7 +10958,7 @@ public function testIncreaseAndDecreaseFloorAndCeilAFractionalBoundOnAnInteger() } } - public function testIncreaseAndDecreaseRoundAFractionalChangeValueOnAnInteger(): void + public function testIncreaseAndDecreaseRoundTheChangeValueOnAnInteger(): void { /** @var Database $database */ $database = $this->getDatabase(); diff --git a/tests/e2e/Adapter/Scopes/IntegerRoundingTests.php b/tests/e2e/Adapter/Scopes/IntegerRoundingTests.php index 2a6aa9409..5beac52e7 100644 --- a/tests/e2e/Adapter/Scopes/IntegerRoundingTests.php +++ b/tests/e2e/Adapter/Scopes/IntegerRoundingTests.php @@ -128,7 +128,7 @@ public function testIntegerRoundingOfOperatorsInUpserts(): void } } - public function testIntegerRoundingLeavesAFractionalMultiplyOrDivideRefused(): void + public function testIntegerRoundingLeavesMultiplyOrDivideByAFractionRefused(): void { /** @var Database $database */ $database = $this->getDatabase(); diff --git a/tests/e2e/Adapter/Scopes/OperatorTests.php b/tests/e2e/Adapter/Scopes/OperatorTests.php index d8a2b0672..c8e1e3951 100644 --- a/tests/e2e/Adapter/Scopes/OperatorTests.php +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -5238,7 +5238,7 @@ public function testUpdateDocumentsWithOperatorsCacheInvalidation(): void $database->deleteCollection($collectionId); } - public function testOperatorWholeFloatLimitHoldsAndAFractionalLimitIsFloored(): void + public function testOperatorWholeFloatLimitHoldsAndALimitWithAFractionIsFloored(): void { $database = static::getDatabase(); diff --git a/tests/unit/Adapter/RedisAdapterPathsTest.php b/tests/unit/Adapter/RedisAdapterPathsTest.php index 57cc3a074..eb09e4c38 100644 --- a/tests/unit/Adapter/RedisAdapterPathsTest.php +++ b/tests/unit/Adapter/RedisAdapterPathsTest.php @@ -393,7 +393,7 @@ public function testUniqueIndexComparesArrayValuesByContent(): void $adapter->createDocument($this->notes(), new Document(['$id' => 'second', '$permissions' => [], 'tags' => ['a', 'b']])); } - public function testAFractionalOperatorLimitIsFlooredBeforeTheWrite(): void + public function testAnOperatorLimitWithAFractionIsFlooredBeforeTheWrite(): void { $database = $this->database(); $database->create(); diff --git a/tests/unit/Documents/IntegerRoundingTest.php b/tests/unit/Documents/IntegerRoundingTest.php index 3583503c7..efcea197e 100644 --- a/tests/unit/Documents/IntegerRoundingTest.php +++ b/tests/unit/Documents/IntegerRoundingTest.php @@ -46,7 +46,7 @@ public static function adapters(): iterable } #[DataProvider('adapters')] - public function testAFractionalAmountIsRoundedHalfAwayFromZero(string $adapter): void + public function testAnAmountIsRoundedHalfAwayFromZero(string $adapter): void { $database = $this->database($adapter); @@ -93,7 +93,7 @@ public function testBigIntegerTextIsRoundedExactly(string $adapter): void } #[DataProvider('adapters')] - public function testAFractionalMaximumIsFlooredAndAFractionalMinimumCeiled(string $adapter): void + public function testAMaximumIsFlooredAndAMinimumCeiled(string $adapter): void { $database = $this->database($adapter); @@ -204,7 +204,7 @@ public static function refusedOperators(): iterable } #[DataProvider('refusedOperators')] - public function testAFractionalMultiplyDivideModuloOrPowerOnAnIntegerIsRefused(string $adapter, Operator $operator, string $method): void + public function testMultiplyDivideModuloOrPowerByAFractionOnAnIntegerIsRefused(string $adapter, Operator $operator, string $method): void { $database = $this->database($adapter); From 01c891b5e456bbf515f44cb329e39e136241bcb6 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 14 Oct 2026 18:17:45 +1300 Subject: [PATCH 08/18] refactor(schema)!: move the update models into their model namespaces AttributeUpdate, CollectionUpdate and RelationshipUpdate repeated the name of the model they update in the class name. They now live beside that model as Attribute\Update, Collection\Update and Relationship\Update, so the namespace carries the context. There is no class_alias and no composer conflict entry: consumers switch their imports in the releases that follow (migration 3.1.2, then appwrite and cloud). Files that use more than one of the models import them under aliases so their call sites stay unchanged; files that use one import it as Update. The historical CHANGELOG and UPGRADE sections keep the names those releases shipped with; the new 8.1.2 sections map old to new. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- CHANGELOG.md | 14 +++ README.md | 16 ++-- UPGRADE.md | 20 +++++ .../Adapter/Feature/Relationships.php | 4 +- src/Database/Adapter/Memory.php | 4 +- src/Database/Adapter/Mongo.php | 4 +- src/Database/Adapter/Pool.php | 4 +- src/Database/Adapter/Redis.php | 4 +- src/Database/Adapter/SQL.php | 4 +- src/Database/Adapter/SQLite.php | 4 +- src/Database/Attribute.php | 2 + .../Update.php} | 7 +- .../Update.php} | 4 +- src/Database/Mirror.php | 3 + src/Database/Relationship.php | 3 +- .../Update.php} | 6 +- src/Database/Trait/Attributes.php | 10 +-- src/Database/Trait/Collections.php | 4 +- src/Database/Trait/Relationships.php | 4 +- tests/e2e/Adapter/MirrorTest.php | 4 +- tests/e2e/Adapter/Scopes/AttributeTests.php | 70 +++++++-------- tests/e2e/Adapter/Scopes/CollectionTests.php | 4 +- tests/e2e/Adapter/Scopes/DocumentTests.php | 4 +- tests/e2e/Adapter/Scopes/IndexTests.php | 6 +- tests/e2e/Adapter/Scopes/JoinComboTests.php | 8 +- .../Adapter/Scopes/JoinRelationshipTests.php | 6 +- .../e2e/Adapter/Scopes/MetadataCacheTests.php | 4 +- .../Adapter/Scopes/MongoReadFilterTests.php | 4 +- tests/e2e/Adapter/Scopes/PermissionTests.php | 6 +- .../e2e/Adapter/Scopes/RelationshipTests.php | 4 +- .../Scopes/Relationships/ChildSideTests.php | 8 +- .../Scopes/Relationships/ManyToManyTests.php | 22 ++--- .../Scopes/Relationships/ManyToOneTests.php | 14 +-- .../Scopes/Relationships/OneToManyTests.php | 18 ++-- .../Scopes/Relationships/OneToOneTests.php | 22 ++--- .../Scopes/SchemaReconciliationTests.php | 4 +- tests/e2e/Adapter/Scopes/SpatialTests.php | 34 +++---- tests/e2e/Adapter/Scopes/VectorTests.php | 4 +- tests/unit/Adapter/RedisAdapterPathsTest.php | 10 +-- .../SQLChildSideRelationshipRenameTest.php | 6 +- .../SQLiteChildSideRelationshipTest.php | 6 +- ...SQLiteManyToManyRelationshipRenameTest.php | 14 +-- tests/unit/Attributes/RelaxRequiredTest.php | 8 +- tests/unit/Attributes/RenamePathsTest.php | 6 +- tests/unit/Attributes/SharedRenameTest.php | 6 +- tests/unit/Attributes/TypeTableTest.php | 8 +- tests/unit/Attributes/UpdateAttributeTest.php | 34 +++---- .../UpdateAttributeValidationTest.php | 72 +++++++-------- tests/unit/BigIntegerSpellingTest.php | 12 +-- tests/unit/Cache/DatabaseTest.php | 4 +- .../unit/Collections/CollectionGuardsTest.php | 4 +- .../Collections/CollectionValidationTest.php | 8 +- .../Collections/DefinitionModelCacheTest.php | 4 +- .../Collections/GetCollectionThrowsTest.php | 8 +- .../UpdateCollectionValidationTest.php | 6 +- tests/unit/CoreMinorsTest.php | 4 +- .../DocumentCacheInvalidationTest.php | 4 +- .../Documents/DocumentCacheRoundTripTest.php | 6 +- tests/unit/Documents/QueriesValidatorTest.php | 6 +- .../Documents/StoredValueRevalidationTest.php | 8 +- .../Documents/TransactionCacheReadTest.php | 4 +- .../TransactionDefinitionReadTest.php | 10 +-- tests/unit/Event/HookErrorCleanupTest.php | 4 +- tests/unit/Event/HookFailureTest.php | 4 +- tests/unit/Event/TypedEventTest.php | 4 +- tests/unit/Joins/RelationshipJoinTest.php | 4 +- .../unit/Mirror/UpdateAttributeFilterTest.php | 8 +- tests/unit/MirrorReplicationTest.php | 6 +- tests/unit/MirrorTest.php | 4 +- .../Model/AttributeLegacyHydrationTest.php | 4 +- tests/unit/Model/AttributeUpdateTest.php | 90 +++++++++---------- tests/unit/Model/CollectionUpdateTest.php | 12 +-- tests/unit/Model/RelationshipUpdateTest.php | 16 ++-- tests/unit/MongoAdapterPathsTest.php | 6 +- tests/unit/RefusedSchemaChangeTest.php | 4 +- .../Relationships/OneWayChildSideTest.php | 4 +- .../unit/Relationships/PreparedCreateTest.php | 6 +- .../Relationships/RelationshipHookTest.php | 4 +- .../RelationshipRenameRecoveryTest.php | 10 +-- .../Relationships/RelationshipSchemaTest.php | 40 ++++----- .../RelationshipValidationTest.php | 4 +- tests/unit/Relationships/TypedOptionsTest.php | 6 +- tests/unit/Support/VerdictMemory.php | 4 +- .../unit/Validator/IndexRevalidationTest.php | 4 +- 85 files changed, 461 insertions(+), 416 deletions(-) rename src/Database/{AttributeUpdate.php => Attribute/Update.php} (88%) rename src/Database/{CollectionUpdate.php => Collection/Update.php} (76%) rename src/Database/{RelationshipUpdate.php => Relationship/Update.php} (66%) diff --git a/AGENTS.md b/AGENTS.md index f20b48c74..37d792ae8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests/u - `Capability.php` -- behaviour flags an adapter reports through `supports()` - `Document.php` -- JSON document model (extends ArrayObject) - `Collection.php` -- the collection definition (a `Document`, built with `Collection::create()`) - - `Attribute.php`, `Index.php`, `Relationship.php` -- `final readonly` schema value objects with a factory per type; `AttributeUpdate`, `CollectionUpdate`, `RelationshipUpdate` are the update models + - `Attribute.php`, `Index.php`, `Relationship.php` -- `final readonly` schema value objects with a factory per type; `Attribute\Update`, `Collection\Update`, `Relationship\Update` are the update models - `Schema/` -- `Column` and `Index`, the physical schema read back from an engine - `Mirror.php`, `Mirror/` -- database mirroring/replication, its write `Filter` and `Failure` - `Query.php` -- query builder extension @@ -66,7 +66,7 @@ docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests/u **Hook system:** Pluggable hooks for permissions, relationships, tenancy filtering, and lifecycle events, registered with `Database::addHook()`. A database registers neither `Hook\Permissions` nor `Hook\Relationships` on its own. -**Schema API:** Collection-first methods take value objects: `createCollection(Collection::create(...))`, `createAttribute($collection, Attribute::string(...))`, `createIndex($collection, Index::key(...))`, `createRelationship($collection, Relationship::oneToMany(...))`, and updates take `AttributeUpdate`, `CollectionUpdate`, `RelationshipUpdate`. +**Schema API:** Collection-first methods take value objects: `createCollection(Collection::create(...))`, `createAttribute($collection, Attribute::string(...))`, `createIndex($collection, Index::key(...))`, `createRelationship($collection, Relationship::oneToMany(...))`, and updates take `Attribute\Update`, `Collection\Update`, `Relationship\Update`. **Adapter model:** Ask `supports(Capability::X)` for behaviour flags and `hasFeature(Feature\X::class)` for optional features (never `instanceof`: `Pool` forwards features without implementing them). Limits come from `limits()`; `Database::profile()` snapshots them for the validators. diff --git a/CHANGELOG.md b/CHANGELOG.md index 513070975..338544439 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 8.1.2 + +### Changed (breaking) + +- The update models moved into the namespace of the model they update. Replace the imports: + + | 8.1.1 | 8.1.2 | + |---|---| + | `Utopia\Database\AttributeUpdate` | `Utopia\Database\Attribute\Update` | + | `Utopia\Database\CollectionUpdate` | `Utopia\Database\Collection\Update` | + | `Utopia\Database\RelationshipUpdate` | `Utopia\Database\Relationship\Update` | + + Their constructors and fields are unchanged. See [Upgrading from 8.1.1 to 8.1.2](UPGRADE.md#upgrading-from-811-to-812). + ## 8.1.1 ### Added diff --git a/README.md b/README.md index 597773371..1dde33154 100644 --- a/README.md +++ b/README.md @@ -399,7 +399,7 @@ $profile->supports(Capability::Joins); ```php use Utopia\Database\Attribute; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update; use Utopia\Database\Index; use Utopia\Database\Permission; use Utopia\Database\Role; @@ -425,7 +425,7 @@ $database->createCollection(Collection::create( )); // Update Collection Permissions; a field left null keeps its stored value -$database->updateCollection('users', new CollectionUpdate( +$database->updateCollection('users', new Update( permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), @@ -461,7 +461,7 @@ $database->deleteCollection('drafts'); ```php use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update; use Utopia\Database\Collection; use Utopia\Database\Format; use Utopia\Database\IntegerWidth; @@ -512,12 +512,12 @@ Structure::addFormat( ); // Updates an attribute. Only the fields given change; default: null and format: null remove them. -$database->updateAttribute('movies', 'year', new AttributeUpdate( +$database->updateAttribute('movies', 'year', new Update( required: true, format: new Format('year', ['min' => 1888, 'max' => 2100]), )); -$database->updateAttribute('movies', 'director', new AttributeUpdate(default: 'Unknown')); +$database->updateAttribute('movies', 'director', new Update(default: 'Unknown')); // Check if attribute can be added to a collection $database->checkAttribute('movies', Attribute::integer('rating')); @@ -581,8 +581,8 @@ use Utopia\Database\Attribute; use Utopia\Database\Collection; use Utopia\Database\Permission; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipDeleteAction; -use Utopia\Database\RelationshipUpdate; use Utopia\Database\Role; // Each relationship type has a factory: Relationship::oneToOne(), oneToMany(), manyToOne() and manyToMany(). @@ -609,12 +609,12 @@ $database->createRelationship('movies', Relationship::oneToMany( )); // Update the relationship with the default reference attributes -$database->updateRelationship('movies', 'users', new RelationshipUpdate( +$database->updateRelationship('movies', 'users', new Update( onDelete: RelationshipDeleteAction::SetNull )); // Update the relationship with custom reference attributes -$database->updateRelationship('movies', 'users', new RelationshipUpdate( +$database->updateRelationship('movies', 'users', new Update( key: 'viewer', twoWayKey: 'favoriteMovie', twoWay: true diff --git a/UPGRADE.md b/UPGRADE.md index 612bb0e6f..a45e43849 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,3 +1,23 @@ +# Upgrading from 8.1.1 to 8.1.2 + +## Update models + +The update models moved into the namespace of the model they update. Their constructors and fields are unchanged, so +only the imports change: + +| 8.1.1 | 8.1.2 | +|---|---| +| `Utopia\Database\AttributeUpdate` | `Utopia\Database\Attribute\Update` | +| `Utopia\Database\CollectionUpdate` | `Utopia\Database\Collection\Update` | +| `Utopia\Database\RelationshipUpdate` | `Utopia\Database\Relationship\Update` | + +A file that uses more than one of them imports each under an alias, which keeps the call sites unchanged: + +```php +use Utopia\Database\Attribute\Update as AttributeUpdate; +use Utopia\Database\Relationship\Update as RelationshipUpdate; +``` + # Upgrading from 8.0 to 8.1 8.1 is a minor release. Every change in it is additive or opt-in, with one exception: it runs diff --git a/src/Database/Adapter/Feature/Relationships.php b/src/Database/Adapter/Feature/Relationships.php index a31d82ea1..3d33f8f25 100644 --- a/src/Database/Adapter/Feature/Relationships.php +++ b/src/Database/Adapter/Feature/Relationships.php @@ -3,8 +3,8 @@ namespace Utopia\Database\Adapter\Feature; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipSide; -use Utopia\Database\RelationshipUpdate; /** * Every relationship is given from the side of $collection, with both keys resolved. @@ -19,7 +19,7 @@ public function createRelationship(string $collection, Relationship $relationshi /** * Rename the stored keys of a relationship as $update says, from the side of $collection that $side names. */ - public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool; + public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, Update $update): bool; /** * Drop the storage of a relationship, from the side of $collection that $side names. diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index 654c91779..40ce9eef2 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -22,9 +22,9 @@ use Utopia\Database\PermissionType; use Utopia\Database\Query; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipSide; use Utopia\Database\RelationshipType; -use Utopia\Database\RelationshipUpdate; use Utopia\Database\Storage; use Utopia\Database\Validator\BigInt; use Utopia\Query\CursorDirection; @@ -858,7 +858,7 @@ public function createRelationship(string $collection, Relationship $relationshi } #[\Override] - public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool + public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, Update $update): bool { $relatedCollection = $relationship->relatedCollection; $key = $this->filter($relationship->key ?? ''); diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 9b4a717b8..69dd13cf0 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -42,9 +42,9 @@ use Utopia\Database\PermissionType; use Utopia\Database\Query; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipSide; use Utopia\Database\RelationshipType; -use Utopia\Database\RelationshipUpdate; use Utopia\Database\Storage; use Utopia\Database\Validator\BigInt; use Utopia\Mongo\Client; @@ -1214,7 +1214,7 @@ public function createRelationship(string $collection, Relationship $relationshi * @throws MongoException */ #[\Override] - public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool + public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, Update $update): bool { $collectionName = $this->getNamespace().'_'.$this->filter($collection); $relatedCollectionName = $this->getNamespace().'_'.$this->filter($relationship->relatedCollection); diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 2878360d4..d391126ab 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -17,8 +17,8 @@ use Utopia\Database\Index; use Utopia\Database\PermissionType; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipSide; -use Utopia\Database\RelationshipUpdate; use Utopia\Database\Schema\Column as SchemaColumn; use Utopia\Database\Schema\Index as SchemaIndex; use Utopia\Database\State\Value; @@ -754,7 +754,7 @@ public function createRelationship(string $collection, Relationship $relationshi return $result; } - public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool + public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, Update $update): bool { /** @var bool $result */ $result = $this->delegateFeature(Feature\Relationships::class, __FUNCTION__, \func_get_args()); diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index 786de9eb8..be675badb 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -30,9 +30,9 @@ use Utopia\Database\PermissionType; use Utopia\Database\Query; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipSide; use Utopia\Database\RelationshipType; -use Utopia\Database\RelationshipUpdate; use Utopia\Database\Validator\BigInt; use Utopia\Query\CursorDirection; use Utopia\Query\Method; @@ -638,7 +638,7 @@ public function createRelationship(string $collection, Relationship $relationshi } #[\Override] - public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool + public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, Update $update): bool { $relatedCollection = $relationship->relatedCollection; $key = $this->filter($relationship->key ?? ''); diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 43ba8d347..d7c2822ef 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -46,9 +46,9 @@ use Utopia\Database\PermissionType; use Utopia\Database\Query; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipSide; use Utopia\Database\RelationshipType; -use Utopia\Database\RelationshipUpdate; use Utopia\Database\Storage; use Utopia\Database\Validator\BigInt; use Utopia\Database\Validator\Query\Join as JoinValidator; @@ -2938,7 +2938,7 @@ public function createRelationship(string $collection, Relationship $relationshi * @throws DatabaseException */ #[\Override] - public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool + public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, Update $update): bool { $name = $this->filter($collection); $relatedName = $this->filter($relationship->relatedCollection); diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index e1dc81f70..a97bc1e53 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -42,9 +42,9 @@ use Utopia\Database\PermissionType; use Utopia\Database\Query; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipSide; use Utopia\Database\RelationshipType; -use Utopia\Database\RelationshipUpdate; use Utopia\Database\Schema\Column as SchemaColumn; use Utopia\Database\Schema\Index as SchemaIndex; use Utopia\Database\Storage; @@ -2502,7 +2502,7 @@ public function createRelationship(string $collection, Relationship $relationshi } #[\Override] - public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool + public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, Update $update): bool { $table = $this->getTable($this->filter($collection)); $relatedTable = $this->getTable($this->filter($relationship->relatedCollection)); diff --git a/src/Database/Attribute.php b/src/Database/Attribute.php index dfe0bb0b4..5e1aa2808 100644 --- a/src/Database/Attribute.php +++ b/src/Database/Attribute.php @@ -4,8 +4,10 @@ use Utopia\Database\Adapter\Feature; use Utopia\Database\Adapter\Profile; +use Utopia\Database\Attribute\Update as AttributeUpdate; use Utopia\Database\Exception\Relationship as RelationshipException; use Utopia\Database\Exception\Structure; +use Utopia\Database\Relationship\Update as RelationshipUpdate; use Utopia\Database\Validator\BigInt; use Utopia\Query\Schema\ColumnType; diff --git a/src/Database/AttributeUpdate.php b/src/Database/Attribute/Update.php similarity index 88% rename from src/Database/AttributeUpdate.php rename to src/Database/Attribute/Update.php index 784ad43d1..e40531aa8 100644 --- a/src/Database/AttributeUpdate.php +++ b/src/Database/Attribute/Update.php @@ -1,10 +1,13 @@ |null $filters diff --git a/src/Database/CollectionUpdate.php b/src/Database/Collection/Update.php similarity index 76% rename from src/Database/CollectionUpdate.php rename to src/Database/Collection/Update.php index c87094390..c59be5c8b 100644 --- a/src/Database/CollectionUpdate.php +++ b/src/Database/Collection/Update.php @@ -1,8 +1,8 @@ |null $permissions diff --git a/src/Database/Mirror.php b/src/Database/Mirror.php index f54529a94..26ec54070 100644 --- a/src/Database/Mirror.php +++ b/src/Database/Mirror.php @@ -9,8 +9,10 @@ use Throwable; use Utopia\Async\Promise; use Utopia\Cache\Cache; +use Utopia\Database\Attribute\Update as AttributeUpdate; use Utopia\Database\Cache\Invalidator; use Utopia\Database\Cache\Query as ResultCache; +use Utopia\Database\Collection\Update as CollectionUpdate; use Utopia\Database\Event\Domain; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Limit; @@ -21,6 +23,7 @@ use Utopia\Database\Hook\Write; use Utopia\Database\Mirror\Failure; use Utopia\Database\Mirror\Filter; +use Utopia\Database\Relationship\Update as RelationshipUpdate; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Queries\Base; use Utopia\Query\OrderDirection; diff --git a/src/Database/Relationship.php b/src/Database/Relationship.php index 9e25d4532..6f0e1857a 100644 --- a/src/Database/Relationship.php +++ b/src/Database/Relationship.php @@ -3,6 +3,7 @@ namespace Utopia\Database; use Utopia\Database\Exception\Relationship as RelationshipException; +use Utopia\Database\Relationship\Update; use Utopia\Query\Schema\ForeignKeyAction; final readonly class Relationship @@ -71,7 +72,7 @@ public static function manyToMany( return new self($relatedCollection, RelationshipType::ManyToMany, $twoWay, $key, $twoWayKey, $onDelete); } - public function apply(RelationshipUpdate $update): self + public function apply(Update $update): self { return new self( $this->relatedCollection, diff --git a/src/Database/RelationshipUpdate.php b/src/Database/Relationship/Update.php similarity index 66% rename from src/Database/RelationshipUpdate.php rename to src/Database/Relationship/Update.php index 1b11364af..3f94787a2 100644 --- a/src/Database/RelationshipUpdate.php +++ b/src/Database/Relationship/Update.php @@ -1,8 +1,10 @@ silent(fn () => $this->getCollection($collection)); @@ -208,7 +208,7 @@ public function updateAttribute(string $collection, string $key, AttributeUpdate $updated = $stored->apply($update); if ($update->required === true && $updated->default !== null) { - $updated = $updated->apply(new AttributeUpdate(default: null)); + $updated = $updated->apply(new Update(default: null)); } $newKey = $updated->key; @@ -431,7 +431,7 @@ public function renameAttribute(string $collection, string $old, string $new): v } } - $renamed = $attributes[$position]->apply(new AttributeUpdate(key: $new)); + $renamed = $attributes[$position]->apply(new Update(key: $new)); $attributes = self::replacing($attributes, $old, $renamed); try { @@ -851,7 +851,7 @@ private static function attributePosition(array $attributes, string $key): ?int */ private static function normalise(Attribute $attribute): Attribute { - return $attribute->apply(new AttributeUpdate()); + return $attribute->apply(new Update()); } /** diff --git a/src/Database/Trait/Collections.php b/src/Database/Trait/Collections.php index ee06d09b5..261880cb2 100644 --- a/src/Database/Trait/Collections.php +++ b/src/Database/Trait/Collections.php @@ -8,7 +8,7 @@ use Utopia\Database\Attribute; use Utopia\Database\Capability; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update; use Utopia\Database\Document; use Utopia\Database\Event; use Utopia\Database\Exception as DatabaseException; @@ -158,7 +158,7 @@ public function createCollection(Collection $collection): Collection * @throws DatabaseException * @throws NotFoundException */ - public function updateCollection(string $collection, CollectionUpdate $update): Collection + public function updateCollection(string $collection, Update $update): Collection { if ($update->permissions !== null && $this->validation()->get()) { $validator = new Permissions(); diff --git a/src/Database/Trait/Relationships.php b/src/Database/Trait/Relationships.php index 2755694d0..20296afaa 100644 --- a/src/Database/Trait/Relationships.php +++ b/src/Database/Trait/Relationships.php @@ -6,7 +6,7 @@ use Utopia\Console; use Utopia\Database\Adapter\Feature; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update as AttributeUpdate; use Utopia\Database\Capability; use Utopia\Database\Collection; use Utopia\Database\Document; @@ -21,9 +21,9 @@ use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Index; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update as RelationshipUpdate; use Utopia\Database\RelationshipSide; use Utopia\Database\RelationshipType; -use Utopia\Database\RelationshipUpdate; use Utopia\Database\SetType; trait Relationships diff --git a/tests/e2e/Adapter/MirrorTest.php b/tests/e2e/Adapter/MirrorTest.php index 4b34a99a1..2d3d0725a 100644 --- a/tests/e2e/Adapter/MirrorTest.php +++ b/tests/e2e/Adapter/MirrorTest.php @@ -8,7 +8,7 @@ use Utopia\Database\Adapter\MariaDB; use Utopia\Database\Attribute; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception; @@ -181,7 +181,7 @@ public function test_update_mirrored_collection(): void $database->updateCollection( 'testUpdateMirroredCollection', - new CollectionUpdate( + new Update( permissions: [ Permission::read(Role::users()), ], diff --git a/tests/e2e/Adapter/Scopes/AttributeTests.php b/tests/e2e/Adapter/Scopes/AttributeTests.php index 5ee79fb15..bcabd5a93 100644 --- a/tests/e2e/Adapter/Scopes/AttributeTests.php +++ b/tests/e2e/Adapter/Scopes/AttributeTests.php @@ -12,7 +12,7 @@ use Utopia\Database\Adapter\SQL; use Utopia\Database\Adapter\SQLite; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update; use Utopia\Database\Capability; use Utopia\Database\Collection; use Utopia\Database\Database; @@ -601,7 +601,7 @@ public function testUpdateAttributeDefault(): void $this->assertNull($doc->getAttribute('inStock')); - $database->updateAttribute($this->getFlowersCollection(), 'inStock', new AttributeUpdate(default: 100)); + $database->updateAttribute($this->getFlowersCollection(), 'inStock', new Update(default: 100)); $doc = $database->createDocument($this->getFlowersCollection(), new Document([ '$permissions' => [ @@ -616,7 +616,7 @@ public function testUpdateAttributeDefault(): void $this->assertIsNumeric($doc->getAttribute('inStock')); $this->assertEquals(100, $doc->getAttribute('inStock')); - $database->updateAttribute($this->getFlowersCollection(), 'inStock', new AttributeUpdate(default: null)); + $database->updateAttribute($this->getFlowersCollection(), 'inStock', new Update(default: null)); } public function testRenameAttribute(): void @@ -720,7 +720,7 @@ public function testUpdateAttributeRequired(): void return; } - $database->updateAttribute($this->getFlowersCollection(), 'inStock', new AttributeUpdate(required: true)); + $database->updateAttribute($this->getFlowersCollection(), 'inStock', new Update(required: true)); $this->expectExceptionMessage('Invalid document structure: Missing required attribute "inStock"'); @@ -783,7 +783,7 @@ public function testIdAttributeCanBeUpdated(): void 'reference' => '7', ])); - $updated = $database->updateAttribute($collection, 'reference', new AttributeUpdate(key: 'target')); + $updated = $database->updateAttribute($collection, 'reference', new Update(key: 'target')); $this->assertSame('target', $updated->key); $this->assertSame(ColumnType::Id, $updated->type); @@ -806,7 +806,7 @@ public function testRequiredOnlyChangeKeepsDatetimeColumnsWritable(): void 'at' => '2024-01-01T00:00:00.000+00:00', ])); - $updated = $database->updateAttribute($collection, 'at', new AttributeUpdate(required: false)); + $updated = $database->updateAttribute($collection, 'at', new Update(required: false)); $this->assertFalse($updated->required); $document = $database->createDocument($collection, new Document([ @@ -843,7 +843,7 @@ public function testUpdateAttributeFilter(): void $this->assertIsString($doc->getAttribute('cartModel')); $this->assertEquals('{"color":"string","size":"number"}', $doc->getAttribute('cartModel')); - $database->updateAttribute($this->getFlowersCollection(), 'cartModel', new AttributeUpdate(filters: [Filter::Json])); + $database->updateAttribute($this->getFlowersCollection(), 'cartModel', new Update(filters: [Filter::Json])); $doc = $database->getDocument($this->getFlowersCollection(), $doc->getId()); $this->assertIsArray($doc->getAttribute('cartModel')); @@ -893,7 +893,7 @@ public function testUpdateAttributeFormat(): void Structure::addFormat('priceRange', $this->priceRangeFormat(...), ColumnType::Integer); - $database->updateAttribute($this->getFlowersCollection(), 'price', new AttributeUpdate(format: new Format('priceRange', ['min' => 1, 'max' => 10000]))); + $database->updateAttribute($this->getFlowersCollection(), 'price', new Update(format: new Format('priceRange', ['min' => 1, 'max' => 10000]))); $this->expectExceptionMessage('Invalid document structure: Attribute "price" has invalid format. Value must be a valid range between 1 and 10,000'); @@ -962,7 +962,7 @@ protected function initFlowersWithPriceFixture(): void Structure::addFormat('priceRange', $this->priceRangeFormat(...), ColumnType::Integer); - $database->updateAttribute($this->getFlowersCollection(), 'price', new AttributeUpdate(format: new Format('priceRange', ['min' => 1, 'max' => 10000]))); + $database->updateAttribute($this->getFlowersCollection(), 'price', new Update(format: new Format('priceRange', ['min' => 1, 'max' => 10000]))); self::$flowersWithPriceFixtureInit = true; } @@ -989,7 +989,7 @@ public function testUpdateAttributeStructure(): void $this->assertEquals('priceRange', $attribute->format?->name); $this->assertEquals(['min' => 1, 'max' => 10000], $attribute->format->options ?? []); - $database->updateAttribute($this->getFlowersCollection(), 'price', new AttributeUpdate(default: 100)); + $database->updateAttribute($this->getFlowersCollection(), 'price', new Update(default: 100)); $collection = $database->getCollection($this->getFlowersCollection()); $attribute = $collection->attributes()[4]; $this->assertEquals(ColumnType::Integer, $attribute->type); @@ -1001,7 +1001,7 @@ public function testUpdateAttributeStructure(): void $this->assertEquals('priceRange', $attribute->format?->name); $this->assertEquals(['min' => 1, 'max' => 10000], $attribute->format->options ?? []); - $database->updateAttribute($this->getFlowersCollection(), 'price', new AttributeUpdate(format: new Format('priceRangeNew', ['min' => 1, 'max' => 10000]))); + $database->updateAttribute($this->getFlowersCollection(), 'price', new Update(format: new Format('priceRangeNew', ['min' => 1, 'max' => 10000]))); $collection = $database->getCollection($this->getFlowersCollection()); $attribute = $collection->attributes()[4]; $this->assertEquals(ColumnType::Integer, $attribute->type); @@ -1013,7 +1013,7 @@ public function testUpdateAttributeStructure(): void $this->assertEquals('priceRangeNew', $attribute->format?->name); $this->assertEquals(['min' => 1, 'max' => 10000], $attribute->format->options ?? []); - $database->updateAttribute($this->getFlowersCollection(), 'price', new AttributeUpdate(format: new Format('priceRangeNew', ['min' => 1, 'max' => 999]))); + $database->updateAttribute($this->getFlowersCollection(), 'price', new Update(format: new Format('priceRangeNew', ['min' => 1, 'max' => 999]))); $collection = $database->getCollection($this->getFlowersCollection()); $attribute = $collection->attributes()[4]; $this->assertEquals(ColumnType::Integer, $attribute->type); @@ -1025,7 +1025,7 @@ public function testUpdateAttributeStructure(): void $this->assertEquals('priceRangeNew', $attribute->format?->name); $this->assertEquals(['min' => 1, 'max' => 999], $attribute->format->options ?? []); - $database->updateAttribute($this->getFlowersCollection(), 'price', new AttributeUpdate(format: new Format('priceRangeNew'))); + $database->updateAttribute($this->getFlowersCollection(), 'price', new Update(format: new Format('priceRangeNew'))); $collection = $database->getCollection($this->getFlowersCollection()); $attribute = $collection->attributes()[4]; $this->assertEquals(ColumnType::Integer, $attribute->type); @@ -1037,7 +1037,7 @@ public function testUpdateAttributeStructure(): void $this->assertEquals('priceRangeNew', $attribute->format?->name); $this->assertEquals([], $attribute->format->options ?? []); - $database->updateAttribute($this->getFlowersCollection(), 'price', new AttributeUpdate(format: null)); + $database->updateAttribute($this->getFlowersCollection(), 'price', new Update(format: null)); $collection = $database->getCollection($this->getFlowersCollection()); $attribute = $collection->attributes()[4]; $this->assertEquals(ColumnType::Integer, $attribute->type); @@ -1048,7 +1048,7 @@ public function testUpdateAttributeStructure(): void $this->assertEquals(false, $attribute->required); $this->assertNull($attribute->format); - $database->updateAttribute($this->getFlowersCollection(), 'price', new AttributeUpdate(signed: false)); + $database->updateAttribute($this->getFlowersCollection(), 'price', new Update(signed: false)); $collection = $database->getCollection($this->getFlowersCollection()); $attribute = $collection->attributes()[4]; $this->assertEquals(ColumnType::Integer, $attribute->type); @@ -1059,7 +1059,7 @@ public function testUpdateAttributeStructure(): void $this->assertEquals(false, $attribute->required); $this->assertNull($attribute->format); - $database->updateAttribute($this->getFlowersCollection(), 'price', new AttributeUpdate(required: true)); + $database->updateAttribute($this->getFlowersCollection(), 'price', new Update(required: true)); $collection = $database->getCollection($this->getFlowersCollection()); $attribute = $collection->attributes()[4]; $this->assertEquals(ColumnType::Integer, $attribute->type); @@ -1070,7 +1070,7 @@ public function testUpdateAttributeStructure(): void $this->assertEquals(true, $attribute->required); $this->assertNull($attribute->format); - $database->updateAttribute($this->getFlowersCollection(), 'price', new AttributeUpdate(type: ColumnType::String, size: Database::LENGTH_KEY, format: null)); + $database->updateAttribute($this->getFlowersCollection(), 'price', new Update(type: ColumnType::String, size: Database::LENGTH_KEY, format: null)); $collection = $database->getCollection($this->getFlowersCollection()); $attribute = $collection->attributes()[4]; $this->assertEquals(ColumnType::String, $attribute->type); @@ -1086,7 +1086,7 @@ public function testUpdateAttributeStructure(): void $this->assertEquals(ColumnType::String, $attribute->type); $this->assertEquals(null, $attribute->default); - $database->updateAttribute($this->getFlowersCollection(), 'date', new AttributeUpdate(type: ColumnType::Datetime, size: 0, filters: [Filter::Datetime])); + $database->updateAttribute($this->getFlowersCollection(), 'date', new Update(type: ColumnType::Datetime, size: 0, filters: [Filter::Datetime])); $collection = $database->getCollection($this->getFlowersCollection()); $attribute = $collection->attributes()[2]; $this->assertEquals(ColumnType::Datetime, $attribute->type); @@ -1138,7 +1138,7 @@ public function testUpdateAttributeRename(): void $database->updateAttribute( collection: 'rename_test', key: 'rename_me', - update: new AttributeUpdate(key: 'renamed'), + update: new Update(key: 'renamed'), ); $doc = $database->getDocument('rename_test', $doc->getId()); @@ -1167,7 +1167,7 @@ public function testUpdateAttributeRename(): void $database->updateAttribute( collection: 'rename_test', key: 'renamed', - update: new AttributeUpdate(type: ColumnType::String), + update: new Update(type: ColumnType::String), ); if (! $supportsIdenticalIndexes) { @@ -1237,7 +1237,7 @@ public function testUpdateAttributeRename(): void $database->updateAttribute( collection: 'rename_test', key: 'renamed', - update: new AttributeUpdate(key: 'renamed-test'), + update: new Update(key: 'renamed-test'), ); $doc = $database->getDocument('rename_test', $doc->getId()); @@ -1400,21 +1400,21 @@ public function testUpdateAttributeSize(): void // Test going down in size with data that is too big (Expect Failure) try { - $database->updateAttribute('resize_test', 'resize_me', new AttributeUpdate(type: ColumnType::String, size: 128, required: true)); + $database->updateAttribute('resize_test', 'resize_me', new Update(type: ColumnType::String, size: 128, required: true)); $this->fail('Succeeded updating attribute size to smaller size with data that is too big'); } catch (TruncateException $e) { } // Test going down in size when data isn't too big. $database->updateDocument('resize_test', $document->getId(), $document->setAttribute('resize_me', $this->createRandomString(128))); - $database->updateAttribute('resize_test', 'resize_me', new AttributeUpdate(type: ColumnType::String, size: 128, required: true)); + $database->updateAttribute('resize_test', 'resize_me', new Update(type: ColumnType::String, size: 128, required: true)); // VARCHAR -> VARCHAR Truncation Test - $database->updateAttribute('resize_test', 'resize_me', new AttributeUpdate(type: ColumnType::String, size: 1000, required: true)); + $database->updateAttribute('resize_test', 'resize_me', new Update(type: ColumnType::String, size: 1000, required: true)); $database->updateDocument('resize_test', $document->getId(), $document->setAttribute('resize_me', $this->createRandomString(1000))); try { - $database->updateAttribute('resize_test', 'resize_me', new AttributeUpdate(type: ColumnType::String, size: 128, required: true)); + $database->updateAttribute('resize_test', 'resize_me', new Update(type: ColumnType::String, size: 128, required: true)); $this->fail('Succeeded updating attribute size to smaller size with data that is too big'); } catch (TruncateException $e) { } @@ -1431,7 +1431,7 @@ public function testUpdateAttributeSize(): void $database->createIndex('resize_test', Index::key(key: 'index1', attributes: ['attr1', 'attr2'])); try { - $database->updateAttribute('resize_test', 'attr1', new AttributeUpdate(type: ColumnType::String, size: 5000)); + $database->updateAttribute('resize_test', 'attr1', new Update(type: ColumnType::String, size: 5000)); $this->fail('Failed to throw exception'); } catch (Throwable $e) { $this->assertEquals('Index length is longer than the maximum: '.$database->getAdapter()->limits()->indexLength, $e->getMessage()); @@ -1451,7 +1451,7 @@ public function testUpdateAttributeSize(): void $this->assertEquals(null, $indexes[0]->lengths[1]); try { - $database->updateAttribute('resize_test', 'attr1', new AttributeUpdate(type: ColumnType::String, size: 5000)); + $database->updateAttribute('resize_test', 'attr1', new Update(type: ColumnType::String, size: 5000)); $this->fail('Failed to throw exception'); } catch (Throwable $e) { $this->assertEquals('Index length is longer than the maximum: '.$database->getAdapter()->limits()->indexLength, $e->getMessage()); @@ -1470,7 +1470,7 @@ public function testUpdateAttributeSize(): void $this->assertEquals(50, $indexes[0]->lengths[0]); $this->assertEquals(50, $indexes[0]->lengths[1]); - $database->updateAttribute('resize_test', 'attr1', new AttributeUpdate(type: ColumnType::String, size: 5000)); + $database->updateAttribute('resize_test', 'attr1', new Update(type: ColumnType::String, size: 5000)); } } @@ -1619,7 +1619,7 @@ public function updateStringAttributeSize(int $size, Document $document): Docume /** @var Database $database */ $database = $this->getDatabase(); - $database->updateAttribute('resize_test', 'resize_me', new AttributeUpdate(type: ColumnType::String, size: $size, required: true)); + $database->updateAttribute('resize_test', 'resize_me', new Update(type: ColumnType::String, size: $size, required: true)); $document = $document->setAttribute('resize_me', $this->createRandomString($size)); @@ -1680,7 +1680,7 @@ public function testArrayAttribute(): void } } - $database->updateAttribute($collection, 'booleans', new AttributeUpdate(required: false)); + $database->updateAttribute($collection, 'booleans', new Update(required: false)); $doc = $database->getCollection($collection); $attribute = $doc->attributes()[0]; @@ -1791,7 +1791,7 @@ public function testArrayAttribute(): void * Update attribute */ try { - $database->updateAttribute($collection, key: 'cards', update: new AttributeUpdate(key: 'cards_new')); + $database->updateAttribute($collection, key: 'cards', update: new Update(key: 'cards_new')); $this->fail('Failed to throw exception'); } catch (Throwable $e) { $this->assertInstanceOf(DependencyException::class, $e); @@ -2249,7 +2249,7 @@ public function testCreateAttributesBigIntIgnoresSizeMetadata(): void $this->assertSame('foo', $attribute->key); $this->assertNull($attribute->size); - $database->updateAttribute($collectionName, 'foo', new AttributeUpdate(type: ColumnType::BigInteger, size: 1)); + $database->updateAttribute($collectionName, 'foo', new Update(type: ColumnType::BigInteger, size: 1)); $collection = $database->getCollection($collectionName); $attrs = $collection->attributes(); $this->assertCount(1, $attrs); @@ -2323,7 +2323,7 @@ public function testBigIntegerAttributesPersistTheBigintSpelling(): void $database->createAttributes($collectionName, [Attribute::bigInteger(key: 'batch')]); $expected['batch'] = 'bigint'; - $database->updateAttribute($collectionName, 'single', new AttributeUpdate(required: true)); + $database->updateAttribute($collectionName, 'single', new Update(required: true)); $stored = $database->skipFilters(fn (): Document => $database->getAuthorization()->skip( fn (): Document => $database->getDocument(Database::METADATA, $collectionName), @@ -2916,7 +2916,7 @@ public function testSharedTablesTenantsRenameAnAttributeInTurn(): void foreach ([$first, $second] as $tenant) { $database->setTenant($tenant); $database->renameAttribute($collection, 'age', 'years'); - $this->assertSame('fullName', $database->updateAttribute($collection, 'name', new AttributeUpdate(size: 128, key: 'fullName'))->key); + $this->assertSame('fullName', $database->updateAttribute($collection, 'name', new Update(size: 128, key: 'fullName'))->key); } foreach ([$first, $second] as $index => $tenant) { @@ -3059,7 +3059,7 @@ public function testSharedTablesRenameOntoAnotherTenantsAttributeIsRefused(): vo } try { - $database->updateAttribute($collection, 'nick', new AttributeUpdate(key: 'title')); + $database->updateAttribute($collection, 'nick', new Update(key: 'title')); $this->fail('A key update onto another tenant\'s column must be refused while the old column holds values'); } catch (DuplicateException $error) { $this->assertSame('Attribute already exists', $error->getMessage()); diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index fa5d5f640..5dc87d1b5 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -15,10 +15,10 @@ use Utopia\Database\Adapter\SQL; use Utopia\Database\Adapter\SQLite; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update as AttributeUpdate; use Utopia\Database\Capability; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update as CollectionUpdate; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Event; diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index dec413e7a..f6dec19c0 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -17,10 +17,10 @@ use Utopia\Database\Adapter\SQL; use Utopia\Database\Adapter\SQLite; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update as AttributeUpdate; use Utopia\Database\Capability; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update as CollectionUpdate; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; diff --git a/tests/e2e/Adapter/Scopes/IndexTests.php b/tests/e2e/Adapter/Scopes/IndexTests.php index 63ec28bfb..23e1a7705 100644 --- a/tests/e2e/Adapter/Scopes/IndexTests.php +++ b/tests/e2e/Adapter/Scopes/IndexTests.php @@ -18,7 +18,7 @@ use Utopia\Database\Adapter\Postgres; use Utopia\Database\Adapter\SQLite; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update; use Utopia\Database\Capability; use Utopia\Database\Collection; use Utopia\Database\Database; @@ -265,7 +265,7 @@ public function testIndexLengthZero(): void $database->createIndex(__FUNCTION__, Index::key(key: 'index_title2', attributes: ['title2'], lengths: [0])); try { - $database->updateAttribute(__FUNCTION__, 'title2', new AttributeUpdate(type: ColumnType::String, size: $database->getAdapter()->limits()->indexLength + 300, required: true)); + $database->updateAttribute(__FUNCTION__, 'title2', new Update(type: ColumnType::String, size: $database->getAdapter()->limits()->indexLength + 300, required: true)); $this->fail('Failed to throw exception'); } catch (Throwable $e) { $this->assertEquals('Index length is longer than the maximum: '.$database->getAdapter()->limits()->indexLength, $e->getMessage()); @@ -1369,7 +1369,7 @@ public function testUpdateAttributeCoveredByAKeyIndexSucceeds(): void )); try { - $updated = $database->updateAttribute($collection, 'name', new AttributeUpdate(size: 128)); + $updated = $database->updateAttribute($collection, 'name', new Update(size: 128)); $this->assertSame(128, $updated->size); $this->assertSame(128, $database->getCollection($collection)->attributes()[0]->size); diff --git a/tests/e2e/Adapter/Scopes/JoinComboTests.php b/tests/e2e/Adapter/Scopes/JoinComboTests.php index f80803224..b4c58eed5 100644 --- a/tests/e2e/Adapter/Scopes/JoinComboTests.php +++ b/tests/e2e/Adapter/Scopes/JoinComboTests.php @@ -5,7 +5,7 @@ use Utopia\Database\Attribute; use Utopia\Database\Capability; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Query as QueryException; @@ -450,8 +450,8 @@ public function testJoinComboChainDocSecOffThenOnHidesSecretC(): void $any = [Permission::create(Role::any()), Permission::read(Role::any())]; $documentLevel = [Permission::create(Role::any())]; - $database->updateCollection($selfCol, new CollectionUpdate(permissions: $any, documentSecurity: false)); - $database->updateCollection($cCol, new CollectionUpdate(permissions: $any, documentSecurity: false)); + $database->updateCollection($selfCol, new Update(permissions: $any, documentSecurity: false)); + $database->updateCollection($cCol, new Update(permissions: $any, documentSecurity: false)); $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $selfCol, $cCol, $documentLevel): void { $visible = $database->find($mCol, [ @@ -468,7 +468,7 @@ public function testJoinComboChainDocSecOffThenOnHidesSecretC(): void $this->assertNotFalse($visibleEncoded); $this->assertSame(true, \str_contains($visibleEncoded, 'c-combo-secret')); - $database->updateCollection($cCol, new CollectionUpdate(permissions: $documentLevel, documentSecurity: true)); + $database->updateCollection($cCol, new Update(permissions: $documentLevel, documentSecurity: true)); $hidden = $database->find($mCol, [ Query::join($selfCol, 'mid', [Query::on('$id', 'mainId')]), diff --git a/tests/e2e/Adapter/Scopes/JoinRelationshipTests.php b/tests/e2e/Adapter/Scopes/JoinRelationshipTests.php index c634eac38..65027d968 100644 --- a/tests/e2e/Adapter/Scopes/JoinRelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/JoinRelationshipTests.php @@ -6,7 +6,7 @@ use Utopia\Database\Attribute; use Utopia\Database\Capability; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Authorization as AuthorizationException; @@ -178,7 +178,7 @@ public function testJoinRelationshipReadsOnlyTheJoinedRowsTheCallerCanRead(): vo try { $authorization = $database->getAuthorization(); - $database->updateCollection(self::JOIN_RELATIONSHIP_ORDERS, new CollectionUpdate(permissions: [Permission::create(Role::any())], documentSecurity: true)); + $database->updateCollection(self::JOIN_RELATIONSHIP_ORDERS, new Update(permissions: [Permission::create(Role::any())], documentSecurity: true)); $authorization->skip(fn (): Document => $database->updateDocument(self::JOIN_RELATIONSHIP_ORDERS, 'o2', new Document(['$permissions' => [Permission::read(Role::user('bob'))]]))); $queries = [Query::leftJoin(self::JOIN_RELATIONSHIP_ORDERS, 'purchase', [Query::on('orders', '$id')])]; @@ -190,7 +190,7 @@ public function testJoinRelationshipReadsOnlyTheJoinedRowsTheCallerCanRead(): vo $this->assertSame(['c1/o1', 'c1/o2', 'c2/o3', 'c2/o4', 'c3/-'], $this->joinRelationshipPairs($database, self::JOIN_RELATIONSHIP_CUSTOMERS, $queries, 'purchase')); }); - $database->updateCollection(self::JOIN_RELATIONSHIP_ORDERS, new CollectionUpdate(permissions: [Permission::create(Role::any())], documentSecurity: false)); + $database->updateCollection(self::JOIN_RELATIONSHIP_ORDERS, new Update(permissions: [Permission::create(Role::any())], documentSecurity: false)); $this->withJoinRelationshipRoles($database, [Role::any()->toString()], function () use ($database, $queries): void { try { $database->find(self::JOIN_RELATIONSHIP_CUSTOMERS, $queries); diff --git a/tests/e2e/Adapter/Scopes/MetadataCacheTests.php b/tests/e2e/Adapter/Scopes/MetadataCacheTests.php index 938716da7..a18c83369 100644 --- a/tests/e2e/Adapter/Scopes/MetadataCacheTests.php +++ b/tests/e2e/Adapter/Scopes/MetadataCacheTests.php @@ -6,10 +6,10 @@ use Throwable; use Utopia\Database\Adapter\Feature; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update as AttributeUpdate; use Utopia\Database\Capability; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update as CollectionUpdate; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\NotFound as NotFoundException; diff --git a/tests/e2e/Adapter/Scopes/MongoReadFilterTests.php b/tests/e2e/Adapter/Scopes/MongoReadFilterTests.php index 958c91e55..8ede7b8b2 100644 --- a/tests/e2e/Adapter/Scopes/MongoReadFilterTests.php +++ b/tests/e2e/Adapter/Scopes/MongoReadFilterTests.php @@ -6,7 +6,7 @@ use Utopia\Cache\Cache; use Utopia\Database\Adapter\Mongo; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update; use Utopia\Database\Capability; use Utopia\Database\Collection; use Utopia\Database\Database; @@ -170,7 +170,7 @@ public function testDottedAttributesSurviveRenameAndDelete(): void )); $database->createDocument($collection, new Document(['$id' => 'first', 'a.b' => 'renamed', 'x.y' => 'deleted'])); - $database->updateAttribute($collection, 'a.b', new AttributeUpdate(key: 'c')); + $database->updateAttribute($collection, 'a.b', new Update(key: 'c')); $this->assertSame('renamed', $database->getDocument($collection, 'first')->getAttribute('c')); $database->deleteAttribute($collection, 'x.y'); diff --git a/tests/e2e/Adapter/Scopes/PermissionTests.php b/tests/e2e/Adapter/Scopes/PermissionTests.php index ead0a5157..cf819924a 100644 --- a/tests/e2e/Adapter/Scopes/PermissionTests.php +++ b/tests/e2e/Adapter/Scopes/PermissionTests.php @@ -10,7 +10,7 @@ use Utopia\Database\Capability; use Utopia\Database\Change; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; @@ -668,7 +668,7 @@ protected function initCollectionUpdateFixture(): array Permission::delete(Role::users()), ], documentSecurity: false)); - $database->updateCollection($this->getCollUpdateCollection(), new CollectionUpdate(permissions: [], documentSecurity: true)); + $database->updateCollection($this->getCollUpdateCollection(), new Update(permissions: [], documentSecurity: true)); self::$collUpdateFixtureInit = true; self::$collUpdateFixtureData = [ @@ -1756,7 +1756,7 @@ public function testCollectionUpdatePermissionsThrowException(): void /** @var Database $database */ $database = $this->getDatabase(); - $database->updateCollection($collectionId, new CollectionUpdate(permissions: [ + $database->updateCollection($collectionId, new Update(permissions: [ 'i dont work' ], documentSecurity: false)); } diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index 9ae2be121..6d004c1b1 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -18,7 +18,7 @@ use Utopia\Database\Attribute; use Utopia\Database\Capability; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update as CollectionUpdate; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Event; @@ -30,9 +30,9 @@ use Utopia\Database\Permission; use Utopia\Database\Query; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update as RelationshipUpdate; use Utopia\Database\RelationshipDeleteAction; use Utopia\Database\RelationshipType; -use Utopia\Database\RelationshipUpdate; use Utopia\Database\Role; use Utopia\Query\Method; diff --git a/tests/e2e/Adapter/Scopes/Relationships/ChildSideTests.php b/tests/e2e/Adapter/Scopes/Relationships/ChildSideTests.php index cd62cabff..b6aa65968 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/ChildSideTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/ChildSideTests.php @@ -11,10 +11,10 @@ use Utopia\Database\Id; use Utopia\Database\Permission; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipDeleteAction; use Utopia\Database\RelationshipSide; use Utopia\Database\RelationshipType; -use Utopia\Database\RelationshipUpdate; use Utopia\Database\Role; trait ChildSideTests @@ -43,7 +43,7 @@ public function testChildSideUpdateRenamesBothKeys(RelationshipType $type): void [$parents, $children] = $this->createChildSideRelationship($database, $type); - $updated = $database->updateRelationship($children, 'back', new RelationshipUpdate(key: 'owner', twoWayKey: 'items')); + $updated = $database->updateRelationship($children, 'back', new Update(key: 'owner', twoWayKey: 'items')); $this->assertSame($parents, $updated->relatedCollection); $this->assertSame($type, $updated->type); @@ -77,7 +77,7 @@ public function testChildSideUpdateChangesTheDeleteActionOfBothSides(Relationshi [$parents, $children] = $this->createChildSideRelationship($database, $type); - $updated = $database->updateRelationship($children, 'back', new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade)); + $updated = $database->updateRelationship($children, 'back', new Update(onDelete: RelationshipDeleteAction::Cascade)); $this->assertSame(RelationshipDeleteAction::Cascade, $updated->onDelete); $this->assertSame('back', $updated->key); @@ -140,7 +140,7 @@ public function testOneWayOneToOneChildSideUpdateRenamesTheParentKey(): void [$parents, $children] = $this->createOneWayOneToOne($database); - $updated = $database->updateRelationship($children, 'back', new RelationshipUpdate(key: 'owner', twoWayKey: 'item')); + $updated = $database->updateRelationship($children, 'back', new Update(key: 'owner', twoWayKey: 'item')); $this->assertSame('owner', $updated->key); $this->assertSame('item', $updated->twoWayKey); diff --git a/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php index 904672d78..4c0650cf9 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php @@ -17,9 +17,9 @@ use Utopia\Database\Permission; use Utopia\Database\Query; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipDeleteAction; use Utopia\Database\RelationshipType; -use Utopia\Database\RelationshipUpdate; use Utopia\Database\Role; use Utopia\Query\Schema\ColumnType; @@ -55,7 +55,7 @@ public function testRenamingAManyToManyRelationshipCreatedBy7x(): void })); $database->purgeCachedCollection($junction); - $database->updateRelationship('legacy_books', 'tags', new RelationshipUpdate(key: 'labels')); + $database->updateRelationship('legacy_books', 'tags', new Update(key: 'labels')); $keys = \array_map(static fn ($index): string => $index->key, $database->getCollection($junction)->indexes()); $this->assertContains('_index_labels', $keys); @@ -303,7 +303,7 @@ public function testManyToManyOneWayRelationship(): void $database->updateRelationship( 'playlist', 'songs', - new RelationshipUpdate(key: 'newSongs') + new Update(key: 'newSongs') ); // Get document with new relationship key @@ -342,7 +342,7 @@ public function testManyToManyOneWayRelationship(): void $database->updateRelationship( collection: 'playlist', key: 'newSongs', - update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull) + update: new Update(onDelete: RelationshipDeleteAction::SetNull) ); $playlist1 = $database->getDocument('playlist', 'playlist1'); @@ -365,7 +365,7 @@ public function testManyToManyOneWayRelationship(): void $database->updateRelationship( collection: 'playlist', key: 'newSongs', - update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade) + update: new Update(onDelete: RelationshipDeleteAction::Cascade) ); // Delete parent, will delete child @@ -755,7 +755,7 @@ public function testManyToManyTwoWayRelationship(): void $database->updateRelationship( 'students', 'classes', - new RelationshipUpdate( + new Update( key: 'newClasses', twoWayKey: 'newStudents', ) @@ -803,7 +803,7 @@ public function testManyToManyTwoWayRelationship(): void $database->updateRelationship( collection: 'students', key: 'newClasses', - update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull) + update: new Update(onDelete: RelationshipDeleteAction::SetNull) ); $student1 = $database->getDocument('students', 'student1'); @@ -826,7 +826,7 @@ public function testManyToManyTwoWayRelationship(): void $database->updateRelationship( collection: 'students', key: 'newClasses', - update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade) + update: new Update(onDelete: RelationshipDeleteAction::Cascade) ); // Delete parent, will delete child @@ -2531,7 +2531,7 @@ public function testRenamingAOneWayManyToManyTwoWayKeyKeepsRelations(): void try { $database->createDocument($books, new Document(['$id' => 'dune', 'authors' => [new Document(['$id' => 'herbert'])]])); - $this->assertSame('works', $database->updateRelationship($books, 'authors', new RelationshipUpdate(twoWayKey: 'works'))->twoWayKey); + $this->assertSame('works', $database->updateRelationship($books, 'authors', new Update(twoWayKey: 'works'))->twoWayKey); $this->assertSame(['herbert'], $this->relatedDocumentIds($database, $books, 'dune', 'authors')); @@ -2540,7 +2540,7 @@ public function testRenamingAOneWayManyToManyTwoWayKeyKeepsRelations(): void $this->assertSame(['austen', 'herbert'], $this->relatedDocumentIds($database, $books, 'emma', 'authors')); $this->assertSame(['herbert'], $this->relatedDocumentIds($database, $books, 'dune', 'authors')); - $this->assertSame('writers', $database->updateRelationship($books, 'authors', new RelationshipUpdate(key: 'writers'))->key); + $this->assertSame('writers', $database->updateRelationship($books, 'authors', new Update(key: 'writers'))->key); $this->assertSame(['austen', 'herbert'], $this->relatedDocumentIds($database, $books, 'emma', 'writers')); } finally { @@ -2568,7 +2568,7 @@ public function testRenamingATwoWayManyToManyKeyFromTheChildSideKeepsRelations() try { $database->createDocument($books, new Document(['$id' => 'dune', 'authors' => [new Document(['$id' => 'herbert'])]])); - $this->assertSame('works', $database->updateRelationship($authors, 'books', new RelationshipUpdate(key: 'works'))->key); + $this->assertSame('works', $database->updateRelationship($authors, 'books', new Update(key: 'works'))->key); $this->assertSame(['dune'], $this->relatedDocumentIds($database, $authors, 'herbert', 'works')); $this->assertSame(['herbert'], $this->relatedDocumentIds($database, $books, 'dune', 'authors')); diff --git a/tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php b/tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php index 25cd09b17..a28fbb733 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php @@ -14,9 +14,9 @@ use Utopia\Database\Permission; use Utopia\Database\Query; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipDeleteAction; use Utopia\Database\RelationshipType; -use Utopia\Database\RelationshipUpdate; use Utopia\Database\Role; use Utopia\Query\Schema\ColumnType; @@ -268,7 +268,7 @@ public function testManyToOneOneWayRelationship(): void $database->updateRelationship( 'review', 'movie', - new RelationshipUpdate(key: 'newMovie'), + new Update(key: 'newMovie'), ); // Get document with new relationship key @@ -316,7 +316,7 @@ public function testManyToOneOneWayRelationship(): void $database->updateRelationship( collection: 'review', key: 'newMovie', - update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull) + update: new Update(onDelete: RelationshipDeleteAction::SetNull) ); // Delete child, set parent relationship to null @@ -330,7 +330,7 @@ public function testManyToOneOneWayRelationship(): void $database->updateRelationship( collection: 'review', key: 'newMovie', - update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade) + update: new Update(onDelete: RelationshipDeleteAction::Cascade) ); // Delete child, will delete parent @@ -711,7 +711,7 @@ public function testManyToOneTwoWayRelationship(): void $database->updateRelationship( 'product', 'store', - new RelationshipUpdate( + new Update( key: 'newStore', twoWayKey: 'newProducts', ) @@ -770,7 +770,7 @@ public function testManyToOneTwoWayRelationship(): void $database->updateRelationship( collection: 'product', key: 'newStore', - update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull) + update: new Update(onDelete: RelationshipDeleteAction::SetNull) ); // Delete child, set parent relationship to null @@ -802,7 +802,7 @@ public function testManyToOneTwoWayRelationship(): void $database->updateRelationship( collection: 'product', key: 'newStore', - update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade) + update: new Update(onDelete: RelationshipDeleteAction::Cascade) ); // Delete child, will delete parent diff --git a/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php b/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php index 46bc761bc..a1df08d7a 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php @@ -16,9 +16,9 @@ use Utopia\Database\Permission; use Utopia\Database\Query; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipDeleteAction; use Utopia\Database\RelationshipType; -use Utopia\Database\RelationshipUpdate; use Utopia\Database\Role; use Utopia\Query\Schema\ColumnType; @@ -297,7 +297,7 @@ public function testOneToManyOneWayRelationship(): void $database->updateRelationship( 'artist', 'albums', - new RelationshipUpdate(key: 'newAlbums') + new Update(key: 'newAlbums') ); // Get document with new relationship key @@ -335,7 +335,7 @@ public function testOneToManyOneWayRelationship(): void $database->updateRelationship( collection: 'artist', key: 'newAlbums', - update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull) + update: new Update(onDelete: RelationshipDeleteAction::SetNull) ); // Delete parent, set child relationship to null @@ -356,7 +356,7 @@ public function testOneToManyOneWayRelationship(): void $database->updateRelationship( collection: 'artist', key: 'newAlbums', - update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade) + update: new Update(onDelete: RelationshipDeleteAction::Cascade) ); // Delete parent, will delete child @@ -784,7 +784,7 @@ public function testOneToManyTwoWayRelationship(): void $database->updateRelationship( 'customer', 'accounts', - new RelationshipUpdate( + new Update( key: 'newAccounts', twoWayKey: 'newCustomer', ) @@ -830,7 +830,7 @@ public function testOneToManyTwoWayRelationship(): void $database->updateRelationship( collection: 'customer', key: 'newAccounts', - update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull) + update: new Update(onDelete: RelationshipDeleteAction::SetNull) ); // Delete parent, set child relationship to null @@ -851,7 +851,7 @@ public function testOneToManyTwoWayRelationship(): void $database->updateRelationship( collection: 'customer', key: 'newAccounts', - update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade) + update: new Update(onDelete: RelationshipDeleteAction::Cascade) ); // Delete parent, will delete child @@ -1787,7 +1787,7 @@ public function testDeleteBulkDocumentsOneToManyRelationship(): void $this->getDatabase()->updateRelationship( collection: 'bulk_delete_person_o2m', key: 'bulk_delete_library_o2m', - update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull) + update: new Update(onDelete: RelationshipDeleteAction::SetNull) ); $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2m', new Document([ @@ -1841,7 +1841,7 @@ public function testDeleteBulkDocumentsOneToManyRelationship(): void $this->getDatabase()->updateRelationship( collection: 'bulk_delete_person_o2m', key: 'bulk_delete_library_o2m', - update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade) + update: new Update(onDelete: RelationshipDeleteAction::Cascade) ); $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2m', new Document([ diff --git a/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php b/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php index 72b4846d0..a89469b37 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php @@ -17,9 +17,9 @@ use Utopia\Database\Permission; use Utopia\Database\Query; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipDeleteAction; use Utopia\Database\RelationshipType; -use Utopia\Database\RelationshipUpdate; use Utopia\Database\Role; use Utopia\Query\Schema\ColumnType; @@ -329,7 +329,7 @@ public function testOneToOneOneWayRelationship(): void $database->updateRelationship( collection: 'person', key: 'library', - update: new RelationshipUpdate(key: 'newLibrary') + update: new Update(key: 'newLibrary') ); // Get document with again @@ -391,7 +391,7 @@ public function testOneToOneOneWayRelationship(): void $database->updateRelationship( collection: 'person', key: 'newLibrary', - update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull) + update: new Update(onDelete: RelationshipDeleteAction::SetNull) ); // Delete parent, no effect on children for one-way @@ -415,7 +415,7 @@ public function testOneToOneOneWayRelationship(): void $database->updateRelationship( collection: 'person', key: 'newLibrary', - update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade) + update: new Update(onDelete: RelationshipDeleteAction::Cascade) ); // Delete parent, will delete child @@ -867,7 +867,7 @@ public function testOneToOneTwoWayRelationship(): void $database->updateRelationship( 'country', 'city', - new RelationshipUpdate( + new Update( key: 'newCity', twoWayKey: 'newCountry', ) @@ -913,7 +913,7 @@ public function testOneToOneTwoWayRelationship(): void $database->updateRelationship( collection: 'country', key: 'newCity', - update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull) + update: new Update(onDelete: RelationshipDeleteAction::SetNull) ); $database->updateDocument('city', 'city1', new Document(['newCountry' => null, '$id' => 'city1'])); @@ -950,7 +950,7 @@ public function testOneToOneTwoWayRelationship(): void $database->updateRelationship( collection: 'country', key: 'newCity', - update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade) + update: new Update(onDelete: RelationshipDeleteAction::Cascade) ); // Delete parent, will delete child @@ -1074,7 +1074,7 @@ public function testIdenticalTwoWayKeyRelationship(): void $database->updateRelationship( collection: 'parent', key: 'children', - update: new RelationshipUpdate(key: 'child1') + update: new Update(key: 'child1') ); $this->fail('Failed to throw Exception'); } catch (Exception $e) { @@ -1085,7 +1085,7 @@ public function testIdenticalTwoWayKeyRelationship(): void $database->updateRelationship( collection: 'parent', key: 'children', - update: new RelationshipUpdate(twoWayKey: 'parent') + update: new Update(twoWayKey: 'parent') ); $this->fail('Failed to throw Exception'); } catch (Exception $e) { @@ -1898,7 +1898,7 @@ public function testDeleteBulkDocumentsOneToOneRelationship(): void $this->getDatabase()->updateRelationship( collection: 'bulk_delete_person_o2o', key: 'bulk_delete_library_o2o', - update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull) + update: new Update(onDelete: RelationshipDeleteAction::SetNull) ); $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2o', new Document([ @@ -1946,7 +1946,7 @@ public function testDeleteBulkDocumentsOneToOneRelationship(): void $this->getDatabase()->updateRelationship( collection: 'bulk_delete_person_o2o', key: 'bulk_delete_library_o2o', - update: new RelationshipUpdate(onDelete: RelationshipDeleteAction::Cascade) + update: new Update(onDelete: RelationshipDeleteAction::Cascade) ); $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2o', new Document([ diff --git a/tests/e2e/Adapter/Scopes/SchemaReconciliationTests.php b/tests/e2e/Adapter/Scopes/SchemaReconciliationTests.php index ff93d4233..e497aedf4 100644 --- a/tests/e2e/Adapter/Scopes/SchemaReconciliationTests.php +++ b/tests/e2e/Adapter/Scopes/SchemaReconciliationTests.php @@ -13,7 +13,7 @@ use Utopia\Database\Index; use Utopia\Database\Permission; use Utopia\Database\Relationship; -use Utopia\Database\RelationshipUpdate; +use Utopia\Database\Relationship\Update; use Utopia\Database\Role; use Utopia\Database\Schema\Index as SchemaIndex; use Utopia\Database\Storage; @@ -126,7 +126,7 @@ public function testARelationshipRenameThatFailsPartWayConvergesOnRetry(): void $database->createDocument($authors, new Document([Document::ID => 'herbert', 'name' => 'Herbert'])); $database->createDocument($books, new Document([Document::ID => 'dune', 'name' => 'Dune', 'author' => 'herbert'])); $adapter->createAttribute($authors, Attribute::string(key: 'novel', size: 64)); - $update = new RelationshipUpdate(key: 'writer', twoWayKey: 'novel'); + $update = new Update(key: 'writer', twoWayKey: 'novel'); try { $database->updateRelationship($books, 'author', $update); diff --git a/tests/e2e/Adapter/Scopes/SpatialTests.php b/tests/e2e/Adapter/Scopes/SpatialTests.php index b05c72006..93f5c168e 100644 --- a/tests/e2e/Adapter/Scopes/SpatialTests.php +++ b/tests/e2e/Adapter/Scopes/SpatialTests.php @@ -6,7 +6,7 @@ use Utopia\Database\Adapter\MariaDB; use Utopia\Database\Adapter\Postgres; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update; use Utopia\Database\Capability; use Utopia\Database\Collection; use Utopia\Database\Database; @@ -334,7 +334,7 @@ public function testSpatialRequiredDropAcceptsNull(): void $database->createAttribute($collectionName, Attribute::string(key: 'name', required: true)); $database->createAttribute($collectionName, Attribute::point(key: 'location', required: true)); - $updated = $database->updateAttribute($collectionName, 'location', new AttributeUpdate(required: false)); + $updated = $database->updateAttribute($collectionName, 'location', new Update(required: false)); $this->assertFalse($updated->required, 'the stored definition should no longer be required'); // The stored definition flipping is not enough: the column keeps @@ -962,7 +962,7 @@ public function testSpatialIndex(): void $database->createIndex($collUpdateNull, Index::spatial(key: 'idx_loc', attribute: 'loc')); } - $database->updateAttribute($collUpdateNull, 'loc', new AttributeUpdate(required: true)); + $database->updateAttribute($collUpdateNull, 'loc', new Update(required: true)); $database->createIndex($collUpdateNull, Index::spatial(key: 'idx_loc_req', attribute: 'loc')); } finally { @@ -985,7 +985,7 @@ public function testSpatialIndex(): void $database->createIndex($collUpdateNull, Index::spatial(key: 'idx_loc', attribute: 'loc')); } - $database->updateAttribute($collUpdateNull, 'loc', new AttributeUpdate(required: true)); + $database->updateAttribute($collUpdateNull, 'loc', new Update(required: true)); $database->createIndex($collUpdateNull, Index::spatial(key: 'new index', attribute: 'loc')); } finally { @@ -2007,14 +2007,14 @@ public function testUpdateSpatialAttributes(): void // 1) Disallow size and array updates on spatial attributes: expect DatabaseException try { - $database->updateAttribute($collectionName, 'geom', new AttributeUpdate(size: 10)); + $database->updateAttribute($collectionName, 'geom', new Update(size: 10)); $this->fail('Expected DatabaseException when updating size on spatial attribute'); } catch (\Throwable $e) { $this->assertInstanceOf(Exception::class, $e); } try { - $database->updateAttribute($collectionName, 'geom', new AttributeUpdate(array: true)); + $database->updateAttribute($collectionName, 'geom', new Update(array: true)); $this->fail('Expected DatabaseException when updating array on spatial attribute'); } catch (\Throwable $e) { $this->assertInstanceOf(Exception::class, $e); @@ -2024,7 +2024,7 @@ public function testUpdateSpatialAttributes(): void $nullSupported = $database->getAdapter()->supports(Capability::IndexSpatialNull); if ($nullSupported) { // Should succeed on adapters that allow nullable spatial indexes - $database->updateAttribute($collectionName, 'geom', new AttributeUpdate(required: false)); + $database->updateAttribute($collectionName, 'geom', new Update(required: false)); $meta = $database->getCollection($collectionName); $attributes = $meta->attributes(); $this->assertCount(1, $attributes); @@ -2034,7 +2034,7 @@ public function testUpdateSpatialAttributes(): void // Should error (index constraint) when making required=false while spatial index exists $threw = false; try { - $database->updateAttribute($collectionName, 'geom', new AttributeUpdate(required: false)); + $database->updateAttribute($collectionName, 'geom', new Update(required: false)); } catch (\Throwable $e) { $threw = true; } @@ -2129,12 +2129,12 @@ public function testSpatialAttributeDefaults(): void ])); $this->assertSpatialDefaults($overrides, $overridden, 'overridden'); - $database->updateAttribute($collectionName, 'pt', new AttributeUpdate(default: [5.0, 6.0])); - $database->updateAttribute($collectionName, 'ln', new AttributeUpdate(default: [[10.0, 10.0], [20.0, 20.0]])); - $database->updateAttribute($collectionName, 'pg', new AttributeUpdate(default: [[[5.0, 5.0], [5.0, 7.0], [7.0, 7.0], [5.0, 5.0]]])); - $database->updateAttribute($collectionName, 'title', new AttributeUpdate(default: 'Updated')); - $database->updateAttribute($collectionName, 'count', new AttributeUpdate(default: 10)); - $database->updateAttribute($collectionName, 'active', new AttributeUpdate(default: false)); + $database->updateAttribute($collectionName, 'pt', new Update(default: [5.0, 6.0])); + $database->updateAttribute($collectionName, 'ln', new Update(default: [[10.0, 10.0], [20.0, 20.0]])); + $database->updateAttribute($collectionName, 'pg', new Update(default: [[[5.0, 5.0], [5.0, 7.0], [7.0, 7.0], [5.0, 5.0]]])); + $database->updateAttribute($collectionName, 'title', new Update(default: 'Updated')); + $database->updateAttribute($collectionName, 'count', new Update(default: 10)); + $database->updateAttribute($collectionName, 'active', new Update(default: false)); $newDefaults = [ 'pt' => [5.0, 6.0], @@ -2552,10 +2552,10 @@ public function testSpatialIndexRequiredToggling(): void } catch (\Throwable $e) { $this->assertInstanceOf(Exception::class, $e); } - $database->updateAttribute($collUpdateNull, 'loc', new AttributeUpdate(required: true)); + $database->updateAttribute($collUpdateNull, 'loc', new Update(required: true)); $database->createIndex($collUpdateNull, Index::spatial(key: 'new index', attribute: 'loc')); $database->deleteIndex($collUpdateNull, 'new index'); - $database->updateAttribute($collUpdateNull, 'loc', new AttributeUpdate(required: false)); + $database->updateAttribute($collUpdateNull, 'loc', new Update(required: false)); $database->createDocument($collUpdateNull, new Document(['loc' => null])); } finally { @@ -3033,7 +3033,7 @@ public function testCreateCollectionWithRequiredSpatialColumns(): void try { foreach (\array_keys($shapes) as $key) { - $database->updateAttribute($updated, $key, new AttributeUpdate(required: true)); + $database->updateAttribute($updated, $key, new Update(required: true)); } foreach ([$declared, $updated] as $collection) { diff --git a/tests/e2e/Adapter/Scopes/VectorTests.php b/tests/e2e/Adapter/Scopes/VectorTests.php index 83c1398aa..5f72a3a6b 100644 --- a/tests/e2e/Adapter/Scopes/VectorTests.php +++ b/tests/e2e/Adapter/Scopes/VectorTests.php @@ -3,7 +3,7 @@ namespace Tests\E2E\Adapter\Scopes; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update; use Utopia\Database\Capability; use Utopia\Database\Collection; use Utopia\Database\Database; @@ -1597,7 +1597,7 @@ public function testVectorDimensionUpdate(): void // Try to update attribute dimensions - should fail (immutable) try { - $database->updateAttribute('vectorDimUpdate', 'embedding', new AttributeUpdate(type: ColumnType::Vector, size: 5, required: true)); + $database->updateAttribute('vectorDimUpdate', 'embedding', new Update(type: ColumnType::Vector, size: 5, required: true)); $this->fail('Should not allow changing vector dimensions'); } catch (\Throwable $e) { $this->assertNotSame('', $e->getMessage()); diff --git a/tests/unit/Adapter/RedisAdapterPathsTest.php b/tests/unit/Adapter/RedisAdapterPathsTest.php index 78452fe7a..5eff09101 100644 --- a/tests/unit/Adapter/RedisAdapterPathsTest.php +++ b/tests/unit/Adapter/RedisAdapterPathsTest.php @@ -24,9 +24,9 @@ use Utopia\Database\Permission; use Utopia\Database\Query; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipSide; use Utopia\Database\RelationshipType; -use Utopia\Database\RelationshipUpdate; use Utopia\Database\Role; use Utopia\Database\Validator\Authorization; use Utopia\Query\CursorDirection; @@ -79,7 +79,7 @@ public function testOneToManyKeyRenamedFromTheChildSide(): void new Document(['$id' => 'rex', 'name' => 'Rex']), ]])); - $database->updateRelationship('pets', 'owner', new RelationshipUpdate(key: 'keeper')); + $database->updateRelationship('pets', 'owner', new Update(key: 'keeper')); $pet = $database->getDocument('pets', 'rex'); $this->assertNull($pet->getAttribute('owner')); @@ -93,7 +93,7 @@ public function testManyToOneTwoWayKeyRenamedFromTheChildSide(): void $database->createDocument('owners', new Document(['$id' => 'alice', 'name' => 'Alice'])); $database->createDocument('pets', new Document(['$id' => 'rex', 'name' => 'Rex', 'owner' => 'alice'])); - $database->updateRelationship('owners', 'pets', new RelationshipUpdate(twoWayKey: 'master')); + $database->updateRelationship('owners', 'pets', new Update(twoWayKey: 'master')); $pet = $database->getDocument('pets', 'rex'); $this->assertNull($pet->getAttribute('owner')); @@ -236,7 +236,7 @@ public function testRenamingAManyToManyKeyOfATenantlessDefinition(): void 'books', Relationship::manyToMany(relatedCollection: 'authors', twoWay: true, key: 'authors', twoWayKey: 'books'), RelationshipSide::Parent, - new RelationshipUpdate(key: 'writers'), + new Update(key: 'writers'), )); $link = $adapter->getDocument(new Document(['$id' => $junction]), 'link'); @@ -441,7 +441,7 @@ public function testRenamingAManyToManyKeyBetweenCollectionsWithoutDefinitionsRe self::NOTES, Relationship::manyToMany(relatedCollection: 'tags', twoWay: true, key: 'tags', twoWayKey: 'notes'), RelationshipSide::Parent, - new RelationshipUpdate(key: 'labels', twoWayKey: 'entries'), + new Update(key: 'labels', twoWayKey: 'entries'), )); $this->assertSame($before, [$this->strings, $this->sets, $this->hashes], 'without stored definitions there is no junction to rename'); diff --git a/tests/unit/Adapter/SQLChildSideRelationshipRenameTest.php b/tests/unit/Adapter/SQLChildSideRelationshipRenameTest.php index 1d9398b6e..6d2fe9f6b 100644 --- a/tests/unit/Adapter/SQLChildSideRelationshipRenameTest.php +++ b/tests/unit/Adapter/SQLChildSideRelationshipRenameTest.php @@ -10,9 +10,9 @@ use Utopia\Database\Adapter\Postgres; use Utopia\Database\Adapter\SQL; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipSide; use Utopia\Database\RelationshipType; -use Utopia\Database\RelationshipUpdate; final class SQLChildSideRelationshipRenameTest extends TestCase { @@ -46,7 +46,7 @@ public function testARenameTouchesTheColumnTheSideStores(string $class, Relation { [$collection, $relationship] = $this->relationship($type, $side); - $this->assertTrue($this->adapter($class)->updateRelationship($collection, $relationship, $side, new RelationshipUpdate(key: $newKey, twoWayKey: $newTwoWayKey))); + $this->assertTrue($this->adapter($class)->updateRelationship($collection, $relationship, $side, new Update(key: $newKey, twoWayKey: $newTwoWayKey))); $this->assertSame([$expected], $this->statements); } @@ -74,7 +74,7 @@ public function testARenameOfAColumnTheSideDoesNotStoreSendsNothing(string $clas { [$collection, $relationship] = $this->relationship($type, $side); - $this->assertTrue($this->adapter($class)->updateRelationship($collection, $relationship, $side, new RelationshipUpdate(key: $newKey, twoWayKey: $newTwoWayKey))); + $this->assertTrue($this->adapter($class)->updateRelationship($collection, $relationship, $side, new Update(key: $newKey, twoWayKey: $newTwoWayKey))); $this->assertSame([], $this->statements); } diff --git a/tests/unit/Adapter/SQLiteChildSideRelationshipTest.php b/tests/unit/Adapter/SQLiteChildSideRelationshipTest.php index d3d157984..093d0cde2 100644 --- a/tests/unit/Adapter/SQLiteChildSideRelationshipTest.php +++ b/tests/unit/Adapter/SQLiteChildSideRelationshipTest.php @@ -14,7 +14,7 @@ use Utopia\Database\Hook\Relationships; use Utopia\Database\Permission; use Utopia\Database\Relationship; -use Utopia\Database\RelationshipUpdate; +use Utopia\Database\Relationship\Update; use Utopia\Database\Role; use Utopia\Database\Validator\Authorization; @@ -61,7 +61,7 @@ public function testAOneToManyKeyRenamedFromTheChildSideRenamesItsColumn(): void twoWayKey: 'author', )); - $this->database->updateRelationship('books', 'author', new RelationshipUpdate(key: 'writer')); + $this->database->updateRelationship('books', 'author', new Update(key: 'writer')); $this->assertContains('writer', $this->columns('books')); $this->assertNotContains('author', $this->columns('books')); @@ -85,7 +85,7 @@ public function testAManyToOneTwoWayKeyRenamedFromTheChildSideRenamesTheParentCo twoWayKey: 'books', )); - $this->database->updateRelationship('authors', 'books', new RelationshipUpdate(twoWayKey: 'writer')); + $this->database->updateRelationship('authors', 'books', new Update(twoWayKey: 'writer')); $this->assertContains('writer', $this->columns('books')); $this->assertNotContains('author', $this->columns('books')); diff --git a/tests/unit/Adapter/SQLiteManyToManyRelationshipRenameTest.php b/tests/unit/Adapter/SQLiteManyToManyRelationshipRenameTest.php index 401c47bd2..70c6f4a88 100644 --- a/tests/unit/Adapter/SQLiteManyToManyRelationshipRenameTest.php +++ b/tests/unit/Adapter/SQLiteManyToManyRelationshipRenameTest.php @@ -16,7 +16,7 @@ use Utopia\Database\Hook\Relationships; use Utopia\Database\Permission; use Utopia\Database\Relationship; -use Utopia\Database\RelationshipUpdate; +use Utopia\Database\Relationship\Update; use Utopia\Database\Role; use Utopia\Database\Validator\Authorization; @@ -61,7 +61,7 @@ public function testAOneWayTwoWayKeyRenameKeepsExistingAndNewRelations(): void $this->createRelationship(twoWay: false); $this->createBook('dune', ['herbert']); - $this->database->updateRelationship('books', 'authors', new RelationshipUpdate(twoWayKey: 'works')); + $this->database->updateRelationship('books', 'authors', new Update(twoWayKey: 'works')); $this->assertSame(['herbert'], $this->relatedIds('books', 'dune', 'authors')); @@ -76,7 +76,7 @@ public function testAOneWayKeyRenameKeepsExistingAndNewRelations(): void $this->createRelationship(twoWay: false); $this->createBook('dune', ['herbert']); - $this->database->updateRelationship('books', 'authors', new RelationshipUpdate(key: 'writers')); + $this->database->updateRelationship('books', 'authors', new Update(key: 'writers')); $this->assertSame(['herbert'], $this->relatedIds('books', 'dune', 'writers')); @@ -94,7 +94,7 @@ public function testAOneWayRenameOfBothKeysKeepsExistingAndNewRelations(): void $this->createRelationship(twoWay: false); $this->createBook('dune', ['herbert']); - $this->database->updateRelationship('books', 'authors', new RelationshipUpdate(key: 'writers', twoWayKey: 'works')); + $this->database->updateRelationship('books', 'authors', new Update(key: 'writers', twoWayKey: 'works')); $this->assertSame(['herbert'], $this->relatedIds('books', 'dune', 'writers')); @@ -112,7 +112,7 @@ public function testATwoWayTwoWayKeyRenameKeepsBothSidesRelated(): void $this->createRelationship(twoWay: true); $this->createBook('dune', ['herbert']); - $this->database->updateRelationship('books', 'authors', new RelationshipUpdate(twoWayKey: 'works')); + $this->database->updateRelationship('books', 'authors', new Update(twoWayKey: 'works')); $this->assertSame(['herbert'], $this->relatedIds('books', 'dune', 'authors')); $this->assertSame(['dune'], $this->relatedIds('authors', 'herbert', 'works')); @@ -127,7 +127,7 @@ public function testATwoWayKeyRenameFromTheChildSideKeepsBothSidesRelated(): voi $this->createRelationship(twoWay: true); $this->createBook('dune', ['herbert']); - $this->database->updateRelationship('authors', 'books', new RelationshipUpdate(key: 'works')); + $this->database->updateRelationship('authors', 'books', new Update(key: 'works')); $this->assertSame(['dune'], $this->relatedIds('authors', 'herbert', 'works')); $this->assertSame(['herbert'], $this->relatedIds('books', 'dune', 'authors')); @@ -143,7 +143,7 @@ public function testATwoWayKeyRenameToAnExistingRelatedAttributeIsRejected(): vo $this->createBook('dune', ['herbert']); try { - $this->database->updateRelationship('books', 'authors', new RelationshipUpdate(twoWayKey: 'name')); + $this->database->updateRelationship('books', 'authors', new Update(twoWayKey: 'name')); $this->fail('Renaming the two-way key onto an existing attribute should be rejected'); } catch (DuplicateException $error) { $this->assertSame('Related attribute already exists', $error->getMessage()); diff --git a/tests/unit/Attributes/RelaxRequiredTest.php b/tests/unit/Attributes/RelaxRequiredTest.php index 6b61f6eee..3b640f6de 100644 --- a/tests/unit/Attributes/RelaxRequiredTest.php +++ b/tests/unit/Attributes/RelaxRequiredTest.php @@ -12,7 +12,7 @@ use Utopia\Database\Adapter\Memory; use Utopia\Database\Adapter\SQLite; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update; use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; @@ -35,7 +35,7 @@ public function relaxAttributeRequired(string $collection, string $id): bool }); try { - $database->updateAttribute('items', 'name', new AttributeUpdate(required: false)); + $database->updateAttribute('items', 'name', new Update(required: false)); $this->fail('Expected the failed relax to surface'); } catch (DatabaseException $error) { $this->assertSame('Relaxing the column failed', $error->getMessage()); @@ -55,7 +55,7 @@ public function relaxAttributeRequired(string $collection, string $id): bool }); try { - $database->updateAttribute('items', 'name', new AttributeUpdate(required: false)); + $database->updateAttribute('items', 'name', new Update(required: false)); $this->fail('Expected the unconfirmed relax to surface'); } catch (RefusedException $error) { $this->assertSame('Failed to update attribute', $error->getMessage()); @@ -83,7 +83,7 @@ public function testRelaxedAttributeAcceptsANull(Closure $adapter): void { $database = $this->database($adapter()); - $updated = $database->updateAttribute('items', 'name', new AttributeUpdate(required: false)); + $updated = $database->updateAttribute('items', 'name', new Update(required: false)); $this->assertFalse($updated->required); $this->assertFalse($this->storedAttribute($database, 'name')->required); diff --git a/tests/unit/Attributes/RenamePathsTest.php b/tests/unit/Attributes/RenamePathsTest.php index dc85cd440..7e1a25231 100644 --- a/tests/unit/Attributes/RenamePathsTest.php +++ b/tests/unit/Attributes/RenamePathsTest.php @@ -7,7 +7,7 @@ use Utopia\Cache\Cache; use Utopia\Database\Adapter\Memory; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update; use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; @@ -32,7 +32,7 @@ public function testUpdateAttributeWithAKeyRenames(): void { $database = $this->database(); - $updated = $database->updateAttribute('items', 'name', new AttributeUpdate(key: 'title')); + $updated = $database->updateAttribute('items', 'name', new Update(key: 'title')); $this->assertSame('title', $updated->key); $this->assertRenamed($database); @@ -53,7 +53,7 @@ public function testUpdateAttributeRefusesAKeyInUse(): void $this->expectException(DuplicateException::class); - $database->updateAttribute('items', 'name', new AttributeUpdate(key: 'code')); + $database->updateAttribute('items', 'name', new Update(key: 'code')); } public function testThereIsNoOtherPublicRenamePath(): void diff --git a/tests/unit/Attributes/SharedRenameTest.php b/tests/unit/Attributes/SharedRenameTest.php index 46f01a305..87cb11091 100644 --- a/tests/unit/Attributes/SharedRenameTest.php +++ b/tests/unit/Attributes/SharedRenameTest.php @@ -8,7 +8,7 @@ use Utopia\Cache\Cache; use Utopia\Database\Adapter\SQLite; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update; use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; @@ -42,7 +42,7 @@ public function testTenantsUpdateAnAttributeKeyInTurn(): void foreach (self::TENANTS as $tenant) { $database->setTenant($tenant); - $this->assertSame('years', $database->updateAttribute(self::COLLECTION, 'age', new AttributeUpdate(required: true, key: 'years'))->key); + $this->assertSame('years', $database->updateAttribute(self::COLLECTION, 'age', new Update(required: true, key: 'years'))->key); } $this->assertEachTenantReadsItsValuesUnder($database, 'years', 'age'); @@ -63,7 +63,7 @@ public function testARenameOntoAColumnBesideTheOldOneIsRefused(): void } try { - $database->updateAttribute(self::COLLECTION, 'nick', new AttributeUpdate(key: 'title')); + $database->updateAttribute(self::COLLECTION, 'nick', new Update(key: 'title')); $this->fail('A key update onto another attribute\'s column must be refused while the old column holds values'); } catch (DuplicateException $e) { $this->assertSame('Attribute already exists', $e->getMessage()); diff --git a/tests/unit/Attributes/TypeTableTest.php b/tests/unit/Attributes/TypeTableTest.php index e2c458bf1..42b60ed24 100644 --- a/tests/unit/Attributes/TypeTableTest.php +++ b/tests/unit/Attributes/TypeTableTest.php @@ -16,7 +16,7 @@ use Utopia\Database\Adapter\Memory; use Utopia\Database\Adapter\SQLite; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update; use Utopia\Database\Capability; use Utopia\Database\Collection; use Utopia\Database\Database; @@ -389,7 +389,7 @@ public function testTypeCanBeUpdated(string $factory, string $adapter): void $database->createAttribute('items', $this->attribute($factory)); $this->write($database, ['value' => $sample->value]); - $updated = $database->updateAttribute('items', 'value', new AttributeUpdate(key: 'renamed')); + $updated = $database->updateAttribute('items', 'value', new Update(key: 'renamed')); $this->assertSame('renamed', $updated->key); $this->assertSame($this->attribute($factory)->type, $updated->type); @@ -410,7 +410,7 @@ public function testTypeTakesADefault(string $factory, string $adapter): void $this->assertStored($sample->readType, $sample->default, $database->getDocument('items', 'one')->getAttribute('value')); - $updated = $database->updateAttribute('items', 'value', new AttributeUpdate(default: $sample->value)); + $updated = $database->updateAttribute('items', 'value', new Update(default: $sample->value)); $this->assertSame($sample->value, $updated->default); } @@ -426,7 +426,7 @@ public function testDefaultOnATypeWithoutScalarDefaultsIsAMismatch(string $adapt $this->assertRejected( 'Default value 5 does not match given type id', - fn () => $database->updateAttribute('items', 'value', new AttributeUpdate(default: '5')), + fn () => $database->updateAttribute('items', 'value', new Update(default: '5')), ); } diff --git a/tests/unit/Attributes/UpdateAttributeTest.php b/tests/unit/Attributes/UpdateAttributeTest.php index 18856a7ac..197cbd91d 100644 --- a/tests/unit/Attributes/UpdateAttributeTest.php +++ b/tests/unit/Attributes/UpdateAttributeTest.php @@ -8,7 +8,7 @@ use Utopia\Database\Adapter; use Utopia\Database\Adapter\Memory; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update; use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; @@ -27,7 +27,7 @@ public function testNullFieldsKeepTheirValues(): void { $database = $this->database(); - $updated = $database->updateAttribute('items', 'name', new AttributeUpdate(size: 128)); + $updated = $database->updateAttribute('items', 'name', new Update(size: 128)); $this->assertSame(128, $updated->size); $this->assertSame('none', $updated->default); @@ -40,7 +40,7 @@ public function testANullDefaultClearsTheDefault(): void { $database = $this->database(); - $updated = $database->updateAttribute('items', 'name', new AttributeUpdate(default: null)); + $updated = $database->updateAttribute('items', 'name', new Update(default: null)); $this->assertNull($updated->default); $this->assertNull($this->stored($database, 'name')->default); @@ -50,7 +50,7 @@ public function testADefaultIsSet(): void { $database = $this->database(); - $database->updateAttribute('items', 'name', new AttributeUpdate(default: 'other')); + $database->updateAttribute('items', 'name', new Update(default: 'other')); $this->assertSame('other', $this->stored($database, 'name')->default); } @@ -60,7 +60,7 @@ public function testADefaultOnARequiredAttributeIsRefused(): void $database = $this->database(); try { - $database->updateAttribute('items', 'code', new AttributeUpdate(default: 'x')); + $database->updateAttribute('items', 'code', new Update(default: 'x')); $this->fail('Expected a default on a required attribute to be refused'); } catch (DatabaseException $error) { $this->assertSame('Cannot set a default value on a required attribute', $error->getMessage()); @@ -73,7 +73,7 @@ public function testARequiredAttributeTakesADefaultWhenMadeOptionalInTheSameUpda { $database = $this->database(); - $updated = $database->updateAttribute('items', 'code', new AttributeUpdate(required: false, default: 'x')); + $updated = $database->updateAttribute('items', 'code', new Update(required: false, default: 'x')); $this->assertFalse($updated->required); $this->assertSame('x', $this->stored($database, 'code')->default); @@ -83,7 +83,7 @@ public function testMakingAnAttributeRequiredClearsItsDefault(): void { $database = $this->database(); - $updated = $database->updateAttribute('items', 'name', new AttributeUpdate(required: true)); + $updated = $database->updateAttribute('items', 'name', new Update(required: true)); $this->assertTrue($updated->required); $this->assertNull($updated->default); @@ -114,7 +114,7 @@ public function createDocument(Document $collection, Document $document): Docume } }); - $database->updateAttribute('items', 'code', new AttributeUpdate(required: false)); + $database->updateAttribute('items', 'code', new Update(required: false)); $created = $database->createDocument('items', new Document([ '$permissions' => [Permission::read(Role::any())], @@ -130,7 +130,7 @@ public function testAnExplicitDefaultWhileMakingAnAttributeRequiredIsRefused(): $database = $this->database(); try { - $database->updateAttribute('items', 'name', new AttributeUpdate(required: true, default: 'kept')); + $database->updateAttribute('items', 'name', new Update(required: true, default: 'kept')); $this->fail('Expected a default together with required: true to be refused'); } catch (DatabaseException $error) { $this->assertSame('Cannot set a default value on a required attribute', $error->getMessage()); @@ -145,7 +145,7 @@ public function testMakingAnAttributeRequiredWithANullDefaultIsAccepted(): void { $database = $this->database(); - $updated = $database->updateAttribute('items', 'name', new AttributeUpdate(required: true, default: null)); + $updated = $database->updateAttribute('items', 'name', new Update(required: true, default: null)); $this->assertTrue($updated->required); $this->assertNull($this->stored($database, 'name')->default); @@ -155,7 +155,7 @@ public function testFormatAndFiltersAreReplaced(): void { $database = $this->database(); - $database->updateAttribute('items', 'name', new AttributeUpdate(format: null, filters: ['json'])); + $database->updateAttribute('items', 'name', new Update(format: null, filters: ['json'])); $stored = $this->stored($database, 'name'); $this->assertNull($stored->format); @@ -166,21 +166,21 @@ public function testAnUnknownFormatIsRefused(): void { $this->expectException(DatabaseException::class); - $this->database()->updateAttribute('items', 'name', new AttributeUpdate(format: new Format('not-registered'))); + $this->database()->updateAttribute('items', 'name', new Update(format: new Format('not-registered'))); } public function testAMissingAttributeIsNotFound(): void { $this->expectException(NotFoundException::class); - $this->database()->updateAttribute('items', 'missing', new AttributeUpdate(required: true)); + $this->database()->updateAttribute('items', 'missing', new Update(required: true)); } public function testAMissingCollectionIsNotFound(): void { $this->expectException(NotFoundException::class); - $this->database()->updateAttribute('missing', 'name', new AttributeUpdate(required: true)); + $this->database()->updateAttribute('missing', 'name', new Update(required: true)); } public function testAnEmptyUpdateChangesNothing(): void @@ -188,7 +188,7 @@ public function testAnEmptyUpdateChangesNothing(): void $database = $this->database(); $before = $this->stored($database, 'name')->toDocument()->getArrayCopy(); - $updated = $database->updateAttribute('items', 'name', new AttributeUpdate()); + $updated = $database->updateAttribute('items', 'name', new Update()); $this->assertSame($before, $updated->toDocument()->getArrayCopy()); $this->assertSame($before, $this->stored($database, 'name')->toDocument()->getArrayCopy()); @@ -206,8 +206,8 @@ public function testTheMirrorReplicatesAnUpdate(): void $mirror->create(); $mirror->createCollection($this->items()); - $mirror->updateAttribute('items', 'code', new AttributeUpdate(required: false, default: 'x')); - $mirror->updateAttribute('items', 'name', new AttributeUpdate(default: null)); + $mirror->updateAttribute('items', 'code', new Update(required: false, default: 'x')); + $mirror->updateAttribute('items', 'name', new Update(default: null)); $this->assertSame('x', $this->stored($destination, 'code')->default); $this->assertFalse($this->stored($destination, 'code')->required); diff --git a/tests/unit/Attributes/UpdateAttributeValidationTest.php b/tests/unit/Attributes/UpdateAttributeValidationTest.php index ef22fe556..03d9d477b 100644 --- a/tests/unit/Attributes/UpdateAttributeValidationTest.php +++ b/tests/unit/Attributes/UpdateAttributeValidationTest.php @@ -14,7 +14,7 @@ use Utopia\Database\Adapter\Memory; use Utopia\Database\Adapter\SQLite; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update; use Utopia\Database\Capability; use Utopia\Database\Collection; use Utopia\Database\Database; @@ -56,12 +56,12 @@ public static function adapters(): array private static function updaters(): array { return [ - 'required' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttribute($collection, $id, new AttributeUpdate(required: false)), - 'format' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttribute($collection, $id, new AttributeUpdate(format: new Format('text'))), - 'format options' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttribute($collection, $id, new AttributeUpdate(format: new Format('text', ['maximum' => 1]))), - 'filters' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttribute($collection, $id, new AttributeUpdate(filters: [])), - 'default' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttribute($collection, $id, new AttributeUpdate(default: 'x')), - 'structure' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttribute($collection, $id, new AttributeUpdate(size: 128)), + 'required' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttribute($collection, $id, new Update(required: false)), + 'format' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttribute($collection, $id, new Update(format: new Format('text'))), + 'format options' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttribute($collection, $id, new Update(format: new Format('text', ['maximum' => 1]))), + 'filters' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttribute($collection, $id, new Update(filters: [])), + 'default' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttribute($collection, $id, new Update(default: 'x')), + 'structure' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttribute($collection, $id, new Update(size: 128)), ]; } @@ -133,11 +133,11 @@ public function testAFormatOfAnotherTypeIsRefused(\Closure $adapter): void $before = $this->definitions($database); $this->assertRefused( 'Format ("'.self::FORMAT.'") not available for this attribute type ("string")', - fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(format: new Format(self::FORMAT))), + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new Update(format: new Format(self::FORMAT))), ); $this->assertSame($before, $this->definitions($database)); - $this->assertSame(self::FORMAT, $database->updateAttribute(self::COLLECTION, 'count', new AttributeUpdate(format: new Format(self::FORMAT)))->format?->name); + $this->assertSame(self::FORMAT, $database->updateAttribute(self::COLLECTION, 'count', new Update(format: new Format(self::FORMAT)))->format?->name); } finally { Structure::removeFormat(self::FORMAT); } @@ -154,11 +154,11 @@ public function testADefaultOnARequiredAttributeIsRefused(\Closure $adapter): vo $this->assertRefused( 'Cannot set a default value on a required attribute', - fn (): mixed => $database->updateAttribute(self::COLLECTION, 'name', new AttributeUpdate(default: 'x')), + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'name', new Update(default: 'x')), ); $this->assertSame($before, $this->definitions($database)); - $cleared = $database->updateAttribute(self::COLLECTION, 'name', new AttributeUpdate(default: null)); + $cleared = $database->updateAttribute(self::COLLECTION, 'name', new Update(default: null)); $this->assertTrue($cleared->required); $this->assertNull($cleared->default); } @@ -171,8 +171,8 @@ public function testAStructureUpdateDropsTheDefaultOfARequiredAttribute(\Closure { $database = $this->database($adapter()); - $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(default: 'x')); - $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(required: true)); + $database->updateAttribute(self::COLLECTION, 'label', new Update(default: 'x')); + $database->updateAttribute(self::COLLECTION, 'label', new Update(required: true)); $definitions = $this->definitions($database); foreach (['name', 'label'] as $key) { @@ -214,7 +214,7 @@ public function testAnUpdatedDefaultOfTheWrongTypeIsAMismatch(\Closure $adapter, $database = $this->database($adapter()); $before = $this->definitions($database); - $this->assertRefused($message, fn (): mixed => $database->updateAttribute(self::COLLECTION, $attribute, new AttributeUpdate(default: $default))); + $this->assertRefused($message, fn (): mixed => $database->updateAttribute(self::COLLECTION, $attribute, new Update(default: $default))); $this->assertSame($before, $this->definitions($database)); } @@ -227,7 +227,7 @@ public function testAnArrayDefaultOfTheRightTypeIsAccepted(\Closure $adapter): v { $database = $this->database($adapter()); - $this->assertSame(['a', 'b'], $database->updateAttribute(self::COLLECTION, 'tags', new AttributeUpdate(default: ['a', 'b']))->default); + $this->assertSame(['a', 'b'], $database->updateAttribute(self::COLLECTION, 'tags', new Update(default: ['a', 'b']))->default); $this->assertSame(['a', 'b'], $this->definitions($database)['tags']['default']); } @@ -239,11 +239,11 @@ public function testAnUpdatedVectorDefaultNeedsNumericComponents(): void $this->assertRefused( 'Vector default value must contain only numeric elements', - fn (): mixed => $database->updateAttribute(self::COLLECTION, 'embedding', new AttributeUpdate(default: ['a', 'b', 'c'])), + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'embedding', new Update(default: ['a', 'b', 'c'])), ); $this->assertSame($before, $this->definitions($database)); - $this->assertSame([1, 2.5, 3], $database->updateAttribute(self::COLLECTION, 'embedding', new AttributeUpdate(default: [1, 2.5, 3]))->default); + $this->assertSame([1, 2.5, 3], $database->updateAttribute(self::COLLECTION, 'embedding', new Update(default: [1, 2.5, 3]))->default); } /** @@ -271,7 +271,7 @@ public function testATypeTheAdapterCannotStoreIsAnUnknownTypeOnUpdate(\Closure $ $before = $this->definitions($database); try { - $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(type: $type)); + $database->updateAttribute(self::COLLECTION, 'label', new Update(type: $type)); $this->fail('A type the adapter cannot store must be refused'); } catch (StructureException $error) { $this->assertSame("Unknown attribute type: {$type->value}", $error->getMessage()); @@ -290,7 +290,7 @@ public function testARelationshipCannotBeUpdatedAsAnAttribute(\Closure $adapter) $this->assertRefused( 'A relationship attribute cannot change type; use updateRelationship()', - fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(type: ColumnType::Relationship)), + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new Update(type: ColumnType::Relationship)), ); } @@ -304,23 +304,23 @@ public function testTheSizeRulesApplyOnUpdate(\Closure $adapter): void $limits = $database->getAdapter()->limits(); $before = $this->definitions($database); - $this->assertRefused('Size length is required', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(size: 0))); + $this->assertRefused('Size length is required', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new Update(size: 0))); $this->assertRefused( 'Max size allowed for string is: '.\number_format($limits->string), - fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(size: $limits->string + 1)), + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new Update(size: $limits->string + 1)), ); - $this->assertRefused('Size length is required', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(type: ColumnType::Varchar, size: 0))); + $this->assertRefused('Size length is required', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new Update(type: ColumnType::Varchar, size: 0))); $this->assertRefused( 'Max size allowed for varchar is: '.\number_format($limits->varchar), - fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(type: ColumnType::Varchar, size: $limits->varchar + 1)), + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new Update(type: ColumnType::Varchar, size: $limits->varchar + 1)), ); $signedLimit = $limits->integer / 2; $this->assertRefused( 'Max size allowed for int is: '.\number_format($signedLimit), - fn (): mixed => $database->updateAttribute(self::COLLECTION, 'count', new AttributeUpdate(size: (int) $signedLimit + 1)), + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'count', new Update(size: (int) $signedLimit + 1)), ); - $this->assertRefused('Size must be empty', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'ratio', new AttributeUpdate(size: 8))); - $this->assertRefused('Size must be empty', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'flag', new AttributeUpdate(size: 1))); + $this->assertRefused('Size must be empty', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'ratio', new Update(size: 8))); + $this->assertRefused('Size must be empty', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'flag', new Update(size: 1))); $this->assertSame($before, $this->definitions($database)); } @@ -335,14 +335,14 @@ public function testTheObjectRulesApplyOnUpdate(\Closure $adapter): void $before = $this->definitions($database); if (! $database->getAdapter()->supports(Capability::Objects)) { - $this->assertRefused('Object attributes are not supported', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(type: ColumnType::Object, size: 0))); + $this->assertRefused('Object attributes are not supported', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new Update(type: ColumnType::Object, size: 0))); $this->assertSame($before, $this->definitions($database)); return; } - $this->assertRefused('Size must be empty for object attributes', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(type: ColumnType::Object))); - $this->assertRefused('Object attributes cannot be arrays', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(type: ColumnType::Object, size: 0, array: true))); + $this->assertRefused('Size must be empty for object attributes', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new Update(type: ColumnType::Object))); + $this->assertRefused('Object attributes cannot be arrays', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new Update(type: ColumnType::Object, size: 0, array: true))); $this->assertSame($before, $this->definitions($database)); } @@ -355,11 +355,11 @@ public function testSpatialAndVectorTypesNeedTheirSupportOnUpdate(\Closure $adap $database = $this->database($adapter()); foreach ([ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon] as $spatial) { - $this->assertRefused('Spatial attributes are not supported', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(type: $spatial, size: 0))); + $this->assertRefused('Spatial attributes are not supported', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new Update(type: $spatial, size: 0))); } $this->assertRefused( 'Vector types are not supported by the current database', - fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(type: ColumnType::Vector, size: 3)), + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', new Update(type: ColumnType::Vector, size: 3)), ); } @@ -395,7 +395,7 @@ public function testTheVectorRulesApplyOnUpdate(array $change, string $message): $this->assertRefused($message, fn (): mixed => $database->updateAttribute( self::COLLECTION, 'embedding', - new AttributeUpdate( + new Update( size: $size, default: \array_key_exists('default', $change) ? $change['default'] : Unchanged::Value, array: $array, @@ -413,7 +413,7 @@ public function testADatetimeAttributeKeepsItsRequiredFilterOnUpdate(\Closure $a { $database = $this->database($adapter()); - $updated = $database->updateAttribute(self::COLLECTION, 'occurredAt', new AttributeUpdate(filters: [])); + $updated = $database->updateAttribute(self::COLLECTION, 'occurredAt', new Update(filters: [])); $this->assertSame(['datetime'], $updated->filters); $this->assertSame(['datetime'], $this->definitions($database)['occurredAt']['filters']); @@ -456,7 +456,7 @@ public function getAttributeWidth(Document $collection): int $before = $this->definitions($database); try { - $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(size: 128)); + $database->updateAttribute(self::COLLECTION, 'label', new Update(size: 128)); $this->fail('an update past the row width limit must be refused'); } catch (LimitException $error) { $this->assertSame('Row width limit reached. Cannot update attribute.', $error->getMessage()); @@ -477,7 +477,7 @@ public function updateAttribute(string $collection, string $key, Attribute $attr $before = $this->definitions($database); try { - $database->updateAttribute(self::COLLECTION, 'label', new AttributeUpdate(size: 128)); + $database->updateAttribute(self::COLLECTION, 'label', new Update(size: 128)); $this->fail('an update the adapter does not apply must be refused'); } catch (RefusedException $error) { $this->assertSame('Failed to update attribute', $error->getMessage()); @@ -596,7 +596,7 @@ public function testAValidVectorUpdateIsStored(): void $database = $this->database($this->vectorMemory()); $database->createAttribute(self::COLLECTION, Attribute::vector(key: 'embedding', dimensions: 3)); - $updated = $database->updateAttribute(self::COLLECTION, 'embedding', new AttributeUpdate(default: [0.5, 1.5, 2.5])); + $updated = $database->updateAttribute(self::COLLECTION, 'embedding', new Update(default: [0.5, 1.5, 2.5])); $this->assertSame(ColumnType::Vector, $updated->type); $this->assertSame([0.5, 1.5, 2.5], $updated->default); diff --git a/tests/unit/BigIntegerSpellingTest.php b/tests/unit/BigIntegerSpellingTest.php index 910be6c35..c02f07012 100644 --- a/tests/unit/BigIntegerSpellingTest.php +++ b/tests/unit/BigIntegerSpellingTest.php @@ -14,7 +14,7 @@ use Utopia\Database\Adapter\Redis as RedisAdapter; use Utopia\Database\Adapter\SQLite; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update; use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; @@ -70,11 +70,11 @@ public function testAttributeModelsHoldThePersistedSpelling(): void 'type' => self::PERSISTED, ]))->type); - $changed = Attribute::integer(key: 'count')->apply(new AttributeUpdate(type: ColumnType::BigInteger)); + $changed = Attribute::integer(key: 'count')->apply(new Update(type: ColumnType::BigInteger)); $this->assertSame(ColumnType::BigInteger, $changed->type); $this->assertSame(self::PERSISTED, $changed->toDocument()->getAttribute('type')); - $changed = $changed->apply(new AttributeUpdate(type: ColumnType::Integer)); + $changed = $changed->apply(new Update(type: ColumnType::Integer)); $this->assertSame(ColumnType::Integer, $changed->type); $this->assertSame(ColumnType::Integer->value, $changed->toDocument()->getAttribute('type')); @@ -106,8 +106,8 @@ public function testNewBigIntegerAttributesPersistTheBigintSpelling(Closure $ada $database->createAttribute('ledger', Attribute::bigInteger(key: 'single')); $database->createAttributes('ledger', [Attribute::bigInteger(key: 'batch')]); $database->createAttribute('ledger', Attribute::integer(key: 'widened')); - $database->updateAttribute('ledger', 'widened', new AttributeUpdate(type: ColumnType::BigInteger)); - $database->updateAttribute('ledger', 'single', new AttributeUpdate(required: true)); + $database->updateAttribute('ledger', 'widened', new Update(type: ColumnType::BigInteger)); + $database->updateAttribute('ledger', 'single', new Update(required: true)); $this->assertSame([ 'inline' => self::PERSISTED, @@ -143,7 +143,7 @@ public function testStoredBigIntegerSpellingIsWrittenBackAsBigint(Closure $adapt $this->assertSame(ColumnType::BigInteger, $attribute->type, $attribute->key); } - $database->updateAttribute('ledger', 'total', new AttributeUpdate(required: true)); + $database->updateAttribute('ledger', 'total', new Update(required: true)); $this->assertSame([ 'total' => self::PERSISTED, diff --git a/tests/unit/Cache/DatabaseTest.php b/tests/unit/Cache/DatabaseTest.php index 530d7f678..ea2172ff0 100644 --- a/tests/unit/Cache/DatabaseTest.php +++ b/tests/unit/Cache/DatabaseTest.php @@ -12,11 +12,11 @@ use Utopia\Database\Adapter\Memory as DatabaseMemory; use Utopia\Database\Adapter\Pool; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update as AttributeUpdate; use Utopia\Database\Cache\Query as ResultCache; use Utopia\Database\Cache\Scope; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update as CollectionUpdate; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Authorization as AuthorizationException; diff --git a/tests/unit/Collections/CollectionGuardsTest.php b/tests/unit/Collections/CollectionGuardsTest.php index 453d6943a..1c8515e4e 100644 --- a/tests/unit/Collections/CollectionGuardsTest.php +++ b/tests/unit/Collections/CollectionGuardsTest.php @@ -13,7 +13,7 @@ use Utopia\Database\Adapter\SQLite; use Utopia\Database\Attribute; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; @@ -106,7 +106,7 @@ public function testATenantCannotChangeATenantlessCollection(): void $database->setTenant(self::TENANT); try { - $database->updateCollection(self::COLLECTION, new CollectionUpdate(permissions: [Permission::read(Role::user('intruder'))], documentSecurity: true)); + $database->updateCollection(self::COLLECTION, new Update(permissions: [Permission::read(Role::user('intruder'))], documentSecurity: true)); $this->fail('a tenant must not change a collection it does not own'); } catch (NotFoundException $error) { $this->assertSame('Collection not found', $error->getMessage()); diff --git a/tests/unit/Collections/CollectionValidationTest.php b/tests/unit/Collections/CollectionValidationTest.php index c6f178ccd..7ec99e317 100644 --- a/tests/unit/Collections/CollectionValidationTest.php +++ b/tests/unit/Collections/CollectionValidationTest.php @@ -13,7 +13,7 @@ use Utopia\Database\Attribute; use Utopia\Database\Capability; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; @@ -413,7 +413,7 @@ function (Document $col, string $docId) use ($existingCol, $metaCollection) { $this->adapter->method('updateDocument')->willReturnArgument(2); $newPermissions = [Permission::read(Role::any()), Permission::create(Role::user('admin'))]; - $result = $this->database->updateCollection('testCol', new CollectionUpdate(permissions: $newPermissions, documentSecurity: true)); + $result = $this->database->updateCollection('testCol', new Update(permissions: $newPermissions, documentSecurity: true)); $this->assertTrue($result->getAttribute('documentSecurity')); } @@ -461,7 +461,7 @@ function (Document $col, string $docId) use ($existingCol, $metaCollection) { ); $this->adapter->method('updateDocument')->willReturnArgument(2); - $result = $this->database->updateCollection('testCol', new CollectionUpdate(permissions: [Permission::read(Role::any())], documentSecurity: true)); + $result = $this->database->updateCollection('testCol', new Update(permissions: [Permission::read(Role::any())], documentSecurity: true)); $this->assertTrue($result->getAttribute('documentSecurity')); } @@ -469,7 +469,7 @@ public function testUpdateCollectionThrowsOnNotFound(): void { $this->adapter->method('getDocument')->willReturn(new Document()); $this->expectException(NotFoundException::class); - $this->database->updateCollection('nonexistent', new CollectionUpdate(permissions: [Permission::read(Role::any())], documentSecurity: true)); + $this->database->updateCollection('nonexistent', new Update(permissions: [Permission::read(Role::any())], documentSecurity: true)); } public function testListCollectionsReturnsCollectionDocuments(): void diff --git a/tests/unit/Collections/DefinitionModelCacheTest.php b/tests/unit/Collections/DefinitionModelCacheTest.php index 3505329cd..11185ade2 100644 --- a/tests/unit/Collections/DefinitionModelCacheTest.php +++ b/tests/unit/Collections/DefinitionModelCacheTest.php @@ -7,9 +7,9 @@ use Utopia\Cache\Adapter\Memory as MemoryCache; use Utopia\Cache\Cache; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update as AttributeUpdate; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update as CollectionUpdate; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Relationship as RelationshipException; diff --git a/tests/unit/Collections/GetCollectionThrowsTest.php b/tests/unit/Collections/GetCollectionThrowsTest.php index f8849c783..ba30237b0 100644 --- a/tests/unit/Collections/GetCollectionThrowsTest.php +++ b/tests/unit/Collections/GetCollectionThrowsTest.php @@ -7,7 +7,7 @@ use Utopia\Cache\Cache; use Utopia\Database\Adapter\Memory; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update; use Utopia\Database\Database; use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Permission; @@ -36,7 +36,7 @@ public function testUpdateCollectionThrowsForAMissingCollection(): void { $this->expectException(NotFoundException::class); - $this->database()->updateCollection('missing', new CollectionUpdate(documentSecurity: false)); + $this->database()->updateCollection('missing', new Update(documentSecurity: false)); } public function testDeleteCollectionThrowsForAMissingCollection(): void @@ -59,7 +59,7 @@ public function testUpdateCollectionChangesOnlyTheGivenFields(): void $permissions = [Permission::read(Role::any())]; $database->createCollection(Collection::create('books', permissions: $permissions, documentSecurity: true)); - $updated = $database->updateCollection('books', new CollectionUpdate(documentSecurity: false)); + $updated = $database->updateCollection('books', new Update(documentSecurity: false)); $this->assertFalse($updated->documentSecurity()); $this->assertSame($permissions, $updated->getPermissions()); @@ -73,7 +73,7 @@ public function testUpdateCollectionReplacesPermissions(): void $database->createCollection(Collection::create('books', documentSecurity: false)); $permissions = [Permission::read(Role::users())]; - $database->updateCollection('books', new CollectionUpdate(permissions: $permissions)); + $database->updateCollection('books', new Update(permissions: $permissions)); $stored = $database->getCollection('books'); $this->assertSame($permissions, $stored->getPermissions()); diff --git a/tests/unit/Collections/UpdateCollectionValidationTest.php b/tests/unit/Collections/UpdateCollectionValidationTest.php index d15487908..d93394fec 100644 --- a/tests/unit/Collections/UpdateCollectionValidationTest.php +++ b/tests/unit/Collections/UpdateCollectionValidationTest.php @@ -13,7 +13,7 @@ use Utopia\Database\Adapter\SQLite; use Utopia\Database\Attribute; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update; use Utopia\Database\Database; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Structure as StructureException; @@ -55,7 +55,7 @@ public function testUpdateCollectionValidatesTheMetadataDocumentLikeCreateCollec $this->expectException(StructureException::class); - $database->updateCollection('unvalidated', new CollectionUpdate(permissions: [Permission::read(Role::any())], documentSecurity: true)); + $database->updateCollection('unvalidated', new Update(permissions: [Permission::read(Role::any())], documentSecurity: true)); } /** @@ -84,7 +84,7 @@ public function testUpdateCollectionPersistsHydratedAttributesIndexesAndRelation )); $permissions = [Permission::read(Role::any()), Permission::update(Role::any())]; - $database->updateCollection('books', new CollectionUpdate(permissions: $permissions, documentSecurity: false)); + $database->updateCollection('books', new Update(permissions: $permissions, documentSecurity: false)); $books = $database->getCollection('books'); $this->assertSame($permissions, $books->getPermissions()); diff --git a/tests/unit/CoreMinorsTest.php b/tests/unit/CoreMinorsTest.php index 3903d140b..871de5a6b 100644 --- a/tests/unit/CoreMinorsTest.php +++ b/tests/unit/CoreMinorsTest.php @@ -17,7 +17,7 @@ use Utopia\Database\Adapter\Memory; use Utopia\Database\Adapter\SQLite; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update; use Utopia\Database\Capability; use Utopia\Database\Collection; use Utopia\Database\Database; @@ -485,7 +485,7 @@ public function testTypeMismatchMessagesSayBigint(): void $this->configure($database); $database->createCollection(Collection::create(id: 'logs', attributes: [Attribute::bigInteger(key: 'total')])); - $error = $this->attempt(fn (): Attribute => $database->updateAttribute('logs', 'total', new AttributeUpdate(default: 'many'))); + $error = $this->attempt(fn (): Attribute => $database->updateAttribute('logs', 'total', new Update(default: 'many'))); $this->assertInstanceOf(DatabaseException::class, $error); $this->assertSame('Default value many is not a valid integer string for type bigint', $error->getMessage()); diff --git a/tests/unit/Documents/DocumentCacheInvalidationTest.php b/tests/unit/Documents/DocumentCacheInvalidationTest.php index 1d854f9fa..ef941d500 100644 --- a/tests/unit/Documents/DocumentCacheInvalidationTest.php +++ b/tests/unit/Documents/DocumentCacheInvalidationTest.php @@ -19,7 +19,7 @@ use Utopia\Database\Adapter\SQLite; use Utopia\Database\Attribute; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Permission; @@ -157,7 +157,7 @@ public function testACollectionDefinitionWriteKeepsTheOtherDefinitionsCached(): $database->getCollection('webhooks'); $database->getCollection('logs'); - $database->updateCollection('logs', new CollectionUpdate(permissions: [Permission::read(Role::any()), Permission::create(Role::any())], documentSecurity: true)); + $database->updateCollection('logs', new Update(permissions: [Permission::read(Role::any()), Permission::create(Role::any())], documentSecurity: true)); $adapter->reset(); $this->assertNotNull($database->findCollection('webhooks')); diff --git a/tests/unit/Documents/DocumentCacheRoundTripTest.php b/tests/unit/Documents/DocumentCacheRoundTripTest.php index 6e5027a77..4c2679334 100644 --- a/tests/unit/Documents/DocumentCacheRoundTripTest.php +++ b/tests/unit/Documents/DocumentCacheRoundTripTest.php @@ -12,7 +12,7 @@ use Utopia\Cache\Cache; use Utopia\Database\Attribute; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Permission; @@ -262,8 +262,8 @@ public function testPurgingTheMetadataCollectionRetiresEveryCachedDefinition(): $this->assertTrue($database->getCollection('logs')->getAttribute('documentSecurity')); $uncached = UncachedTwin::of($database); - $uncached->updateCollection('webhooks', new CollectionUpdate(permissions: [Permission::read(Role::any())], documentSecurity: false)); - $uncached->updateCollection('logs', new CollectionUpdate(permissions: [Permission::read(Role::any())], documentSecurity: false)); + $uncached->updateCollection('webhooks', new Update(permissions: [Permission::read(Role::any())], documentSecurity: false)); + $uncached->updateCollection('logs', new Update(permissions: [Permission::read(Role::any())], documentSecurity: false)); $this->assertTrue($database->getCollection('webhooks')->getAttribute('documentSecurity'), 'A definition written without this cache leaves the cached definition in place'); $database->purgeCachedCollection(Database::METADATA); diff --git a/tests/unit/Documents/QueriesValidatorTest.php b/tests/unit/Documents/QueriesValidatorTest.php index c26b34614..50ac9730a 100644 --- a/tests/unit/Documents/QueriesValidatorTest.php +++ b/tests/unit/Documents/QueriesValidatorTest.php @@ -7,7 +7,7 @@ use Utopia\Cache\Cache; use Utopia\Database\Adapter\Memory; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update; use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; @@ -53,7 +53,7 @@ public function testANarrowReadFollowsEverySchemaChange(): void $this->assertSame(1, $database->count(self::COLLECTION, $byYear)); - $database->updateAttribute(self::COLLECTION, 'year', new AttributeUpdate(type: ColumnType::String, size: 8)); + $database->updateAttribute(self::COLLECTION, 'year', new Update(type: ColumnType::String, size: 8)); $this->assertRefused($database, $byYear, 'Invalid query: Query value is invalid for attribute "year"'); $this->assertSame(0, $database->count(self::COLLECTION, [Query::equal('year', ['six'])])); @@ -61,7 +61,7 @@ public function testANarrowReadFollowsEverySchemaChange(): void $this->assertRefused($database, [Query::equal('year', ['six'])], 'Invalid query: Attribute not found in schema: year'); $this->assertSame(0, $database->count(self::COLLECTION, [Query::equal('published', ['six'])])); - $database->updateAttribute(self::COLLECTION, 'published', new AttributeUpdate(filters: ['encrypt'])); + $database->updateAttribute(self::COLLECTION, 'published', new Update(filters: ['encrypt'])); $this->assertRefused($database, [Query::equal('published', ['six'])], 'Invalid query: Cannot query encrypted attribute: published'); $database->deleteAttribute(self::COLLECTION, 'published'); diff --git a/tests/unit/Documents/StoredValueRevalidationTest.php b/tests/unit/Documents/StoredValueRevalidationTest.php index e5193eccb..789252b95 100644 --- a/tests/unit/Documents/StoredValueRevalidationTest.php +++ b/tests/unit/Documents/StoredValueRevalidationTest.php @@ -7,7 +7,7 @@ use Utopia\Cache\Cache; use Utopia\Database\Adapter\Memory; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update; use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; @@ -40,7 +40,7 @@ public static function setUpBeforeClass(): void public function testAnUpdateKeepsAStoredValueANarrowedRangeNoLongerAdmits(): void { $database = $this->database(); - $database->updateAttribute(self::COLLECTION, 'level', new AttributeUpdate(format: new Format('storedRange', ['min' => 0, 'max' => 10]))); + $database->updateAttribute(self::COLLECTION, 'level', new Update(format: new Format('storedRange', ['min' => 0, 'max' => 10]))); $updated = $database->updateDocument(self::COLLECTION, 'row', new Document(['note' => 'after narrow'])); @@ -53,7 +53,7 @@ public function testAnUpdateKeepsAStoredValueANarrowedRangeNoLongerAdmits(): voi public function testAnUpdateKeepsAStoredValueAShrunkEnumNoLongerAdmits(): void { $database = $this->database(); - $database->updateAttribute(self::COLLECTION, 'kind', new AttributeUpdate(format: new Format('storedEnum', ['elements' => ['a']]))); + $database->updateAttribute(self::COLLECTION, 'kind', new Update(format: new Format('storedEnum', ['elements' => ['a']]))); $database->updateDocument(self::COLLECTION, 'row', new Document(['note' => 'after shrink'])); @@ -65,7 +65,7 @@ public function testAnUpdateKeepsAStoredValueAShrunkEnumNoLongerAdmits(): void public function testAnUpdateThatWritesAValueTheNarrowedRangeRefusesFails(): void { $database = $this->database(); - $database->updateAttribute(self::COLLECTION, 'level', new AttributeUpdate(format: new Format('storedRange', ['min' => 0, 'max' => 10]))); + $database->updateAttribute(self::COLLECTION, 'level', new Update(format: new Format('storedRange', ['min' => 0, 'max' => 10]))); try { $database->updateDocument(self::COLLECTION, 'row', new Document(['level' => 60, 'note' => 'written'])); diff --git a/tests/unit/Documents/TransactionCacheReadTest.php b/tests/unit/Documents/TransactionCacheReadTest.php index ef20c28c0..65caa35ae 100644 --- a/tests/unit/Documents/TransactionCacheReadTest.php +++ b/tests/unit/Documents/TransactionCacheReadTest.php @@ -14,7 +14,7 @@ use Utopia\Database\Adapter\SQLite; use Utopia\Database\Attribute; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Hook\Relationships; @@ -154,7 +154,7 @@ public function testATransactionReadsTheCollectionDefinitionItChanged(): void [$writer, $reader] = $this->createSharedMemoryDatabases(); $read = $writer->withTransaction(function () use ($writer, $reader): Document { - $writer->updateCollection('webhooks', new CollectionUpdate(permissions: [Permission::read(Role::any()), Permission::update(Role::any())], documentSecurity: false)); + $writer->updateCollection('webhooks', new Update(permissions: [Permission::read(Role::any()), Permission::update(Role::any())], documentSecurity: false)); $this->assertTrue($reader->getCollection('webhooks')->getAttribute('documentSecurity')); return $writer->getCollection('webhooks'); diff --git a/tests/unit/Documents/TransactionDefinitionReadTest.php b/tests/unit/Documents/TransactionDefinitionReadTest.php index df627e605..184fa36fb 100644 --- a/tests/unit/Documents/TransactionDefinitionReadTest.php +++ b/tests/unit/Documents/TransactionDefinitionReadTest.php @@ -20,7 +20,7 @@ use Utopia\Database\Adapter\SQLite; use Utopia\Database\Attribute; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Event; @@ -80,7 +80,7 @@ public function testADefinitionChangedAfterATransactionReadItIsReadAfresh(): voi $database->withTransaction(fn (): Collection => $database->getCollection(self::COLLECTION)); $this->assertSame(0, $this->transactionReads($database, $adapter, fn (): Collection => $database->getCollection(self::COLLECTION)), 'the transaction did not cache its definition'); - $database->updateCollection(self::COLLECTION, new CollectionUpdate(permissions: [Permission::read(Role::any()), Permission::update(Role::any())], documentSecurity: true)); + $database->updateCollection(self::COLLECTION, new Update(permissions: [Permission::read(Role::any()), Permission::update(Role::any())], documentSecurity: true)); $collection = $database->withTransaction(fn (): Collection => $database->getCollection(self::COLLECTION)); @@ -319,7 +319,7 @@ public function testADefinitionChangedWhileItIsCachedAfterCommitIsNotCachedStale $database->withTransaction(function () use ($database, $writer, $adapter): void { $database->getCollection(self::COLLECTION); $adapter->afterNextDefinitionRead(static function () use ($writer): void { - $writer->updateCollection(self::COLLECTION, new CollectionUpdate(permissions: [Permission::read(Role::any()), Permission::update(Role::any())], documentSecurity: true)); + $writer->updateCollection(self::COLLECTION, new Update(permissions: [Permission::read(Role::any()), Permission::update(Role::any())], documentSecurity: true)); }); }); @@ -360,7 +360,7 @@ public function testASchemaChangeInsideTheTransactionIsRead(): void [$before, $after] = $database->withTransaction(function () use ($database): array { $database->updateDocument(self::COLLECTION, 'ada', new Document(['balance' => 2])); $before = $database->getCollection(self::COLLECTION); - $database->updateCollection(self::COLLECTION, new CollectionUpdate(permissions: [Permission::read(Role::users())], documentSecurity: true)); + $database->updateCollection(self::COLLECTION, new Update(permissions: [Permission::read(Role::users())], documentSecurity: true)); return [$before, $database->getCollection(self::COLLECTION)]; }); @@ -380,7 +380,7 @@ public function testADefinitionReadInOneTransactionIsNotReusedByTheNext(): void $database->getCollection(self::COLLECTION); }); - $database->updateCollection(self::COLLECTION, new CollectionUpdate(permissions: [Permission::read(Role::any()), Permission::update(Role::any())], documentSecurity: true)); + $database->updateCollection(self::COLLECTION, new Update(permissions: [Permission::read(Role::any()), Permission::update(Role::any())], documentSecurity: true)); $database->purgeCachedDocument(Database::METADATA, self::COLLECTION); $collection = $database->withTransaction(function () use ($database): Collection { diff --git a/tests/unit/Event/HookErrorCleanupTest.php b/tests/unit/Event/HookErrorCleanupTest.php index 7ae2cc936..40cae02be 100644 --- a/tests/unit/Event/HookErrorCleanupTest.php +++ b/tests/unit/Event/HookErrorCleanupTest.php @@ -18,8 +18,8 @@ use Utopia\Database\Event; use Utopia\Database\Permission; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipDeleteAction; -use Utopia\Database\RelationshipUpdate; use Utopia\Database\Role; use Utopia\Database\Validator\Authorization; @@ -81,7 +81,7 @@ public function testRelationshipRenameKeepsMetadataAndColumnsTogetherWhenAHookFa $this->assertInstanceOf( TypeError::class, - $this->failureOf(fn () => $database->updateRelationship('parent', 'children', new RelationshipUpdate(twoWayKey: 'owner'))), + $this->failureOf(fn () => $database->updateRelationship('parent', 'children', new Update(twoWayKey: 'owner'))), ); $owner = $database->getDocument('child', 'member')->getAttribute('owner'); diff --git a/tests/unit/Event/HookFailureTest.php b/tests/unit/Event/HookFailureTest.php index ff1d8c3bb..e80013aa4 100644 --- a/tests/unit/Event/HookFailureTest.php +++ b/tests/unit/Event/HookFailureTest.php @@ -10,9 +10,9 @@ use Throwable; use TypeError; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update as AttributeUpdate; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update as CollectionUpdate; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Event; diff --git a/tests/unit/Event/TypedEventTest.php b/tests/unit/Event/TypedEventTest.php index 06fb3b6f9..cd9737e17 100644 --- a/tests/unit/Event/TypedEventTest.php +++ b/tests/unit/Event/TypedEventTest.php @@ -4,9 +4,9 @@ use PHPUnit\Framework\TestCase; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update as AttributeUpdate; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update as CollectionUpdate; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Event; diff --git a/tests/unit/Joins/RelationshipJoinTest.php b/tests/unit/Joins/RelationshipJoinTest.php index c408d0f97..80c4085e7 100644 --- a/tests/unit/Joins/RelationshipJoinTest.php +++ b/tests/unit/Joins/RelationshipJoinTest.php @@ -11,7 +11,7 @@ use Utopia\Database\Adapter\SQLite; use Utopia\Database\Attribute; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Order as OrderException; @@ -290,7 +290,7 @@ public function testCallersJoinIsLeftAsGiven(): void public function testJoinedRowsAreTheOnesTheCallerCanRead(): void { $authorization = $this->database->getAuthorization(); - $this->database->updateCollection('orders', new CollectionUpdate(permissions: [Permission::create(Role::any())], documentSecurity: true)); + $this->database->updateCollection('orders', new Update(permissions: [Permission::create(Role::any())], documentSecurity: true)); $authorization->skip(fn (): Document => $this->database->updateDocument('orders', 'o2', new Document(['$permissions' => [Permission::read(Role::user('bob'))]]))); $join = Query::leftJoin('orders', 'ord', [Query::on('orders', '$id')]); diff --git a/tests/unit/Mirror/UpdateAttributeFilterTest.php b/tests/unit/Mirror/UpdateAttributeFilterTest.php index 1fb2729bc..f3ea6a983 100644 --- a/tests/unit/Mirror/UpdateAttributeFilterTest.php +++ b/tests/unit/Mirror/UpdateAttributeFilterTest.php @@ -8,7 +8,7 @@ use Tests\Unit\Event\HookFixture; use Utopia\Database\Adapter\Memory; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; @@ -28,7 +28,7 @@ public function testAFilterThatChangesTheAttributeInPlaceIsReplicated(): void }); $errors = $this->errors($mirror); - $mirror->updateAttribute(HookFixture::COLLECTION, 'title', new AttributeUpdate(default: 'source')); + $mirror->updateAttribute(HookFixture::COLLECTION, 'title', new Update(default: 'source')); $this->assertSame([], $errors->getArrayCopy()); $this->assertSame('source', $this->stored($mirror->getSource(), 'title')->default); @@ -52,7 +52,7 @@ public function updateAttribute(string $collection, string $key, Attribute $attr }); $errors = $this->errors($mirror); - $mirror->updateAttribute(HookFixture::COLLECTION, 'title', new AttributeUpdate(default: 'source', required: false)); + $mirror->updateAttribute(HookFixture::COLLECTION, 'title', new Update(default: 'source', required: false)); $this->assertSame([], $errors->getArrayCopy()); $this->assertSame('mirrored', $this->stored($destination, 'title')->default); @@ -69,7 +69,7 @@ public function testAFilterThatChangesAFieldTheUpdateLeftAloneReplicatesIt(): vo }); $errors = $this->errors($mirror); - $mirror->updateAttribute(HookFixture::COLLECTION, 'title', new AttributeUpdate(default: 'source')); + $mirror->updateAttribute(HookFixture::COLLECTION, 'title', new Update(default: 'source')); $this->assertSame([], $errors->getArrayCopy()); $this->assertSame(64, $this->stored($mirror->getSource(), 'title')->size); diff --git a/tests/unit/MirrorReplicationTest.php b/tests/unit/MirrorReplicationTest.php index 4fd012a3d..ad3e61c95 100644 --- a/tests/unit/MirrorReplicationTest.php +++ b/tests/unit/MirrorReplicationTest.php @@ -16,10 +16,10 @@ use Utopia\Cache\Cache; use Utopia\Database\Adapter\SQLite; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update as AttributeUpdate; use Utopia\Database\Change; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update as CollectionUpdate; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Event; @@ -38,7 +38,7 @@ use Utopia\Database\Permission; use Utopia\Database\Query; use Utopia\Database\Relationship; -use Utopia\Database\RelationshipUpdate; +use Utopia\Database\Relationship\Update as RelationshipUpdate; use Utopia\Database\Role; use Utopia\Database\Validator\Authorization; diff --git a/tests/unit/MirrorTest.php b/tests/unit/MirrorTest.php index fa29f00ac..93d09c053 100644 --- a/tests/unit/MirrorTest.php +++ b/tests/unit/MirrorTest.php @@ -21,13 +21,13 @@ use Utopia\Database\Adapter\SQLite; use Utopia\Database\Adapter\Timeout; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update as AttributeUpdate; use Utopia\Database\Cache\Invalidator; use Utopia\Database\Cache\Query as ResultCache; use Utopia\Database\Capability; use Utopia\Database\Change; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update as CollectionUpdate; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Event; diff --git a/tests/unit/Model/AttributeLegacyHydrationTest.php b/tests/unit/Model/AttributeLegacyHydrationTest.php index e253f8383..b972fd99f 100644 --- a/tests/unit/Model/AttributeLegacyHydrationTest.php +++ b/tests/unit/Model/AttributeLegacyHydrationTest.php @@ -5,7 +5,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update; use Utopia\Database\Document; use Utopia\Database\Format; use Utopia\Database\IntegerWidth; @@ -138,7 +138,7 @@ public function testLegacyStoredShapeHydratesAlreadyNormalised(array $stored, At { $hydrated = Attribute::fromArray($stored); - $this->assertEquals($hydrated->apply(new AttributeUpdate()), $hydrated); + $this->assertEquals($hydrated->apply(new Update()), $hydrated); $this->assertEquals($created, $hydrated); } diff --git a/tests/unit/Model/AttributeUpdateTest.php b/tests/unit/Model/AttributeUpdateTest.php index 3639b7f76..858e111d5 100644 --- a/tests/unit/Model/AttributeUpdateTest.php +++ b/tests/unit/Model/AttributeUpdateTest.php @@ -5,7 +5,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update; use Utopia\Database\Exception\Structure; use Utopia\Database\Filter; use Utopia\Database\Format; @@ -19,7 +19,7 @@ final class AttributeUpdateTest extends TestCase { public function testDefaultsLeaveEverythingUnchanged(): void { - $update = new AttributeUpdate(); + $update = new Update(); $this->assertTrue($update->isEmpty()); $this->assertFalse($update->changesDefault()); @@ -30,7 +30,7 @@ public function testDefaultsLeaveEverythingUnchanged(): void public function testNullDefaultIsAChange(): void { - $update = new AttributeUpdate(default: null); + $update = new Update(default: null); $this->assertTrue($update->changesDefault()); $this->assertFalse($update->isEmpty()); @@ -39,16 +39,16 @@ public function testNullDefaultIsAChange(): void public function testAnySingleFieldMakesTheUpdateNonEmpty(): void { $updates = [ - new AttributeUpdate(type: ColumnType::Text), - new AttributeUpdate(size: 10), - new AttributeUpdate(required: false), - new AttributeUpdate(default: 'x'), - new AttributeUpdate(signed: false), - new AttributeUpdate(array: false), - new AttributeUpdate(format: new Format('email')), - new AttributeUpdate(format: null), - new AttributeUpdate(filters: []), - new AttributeUpdate(key: 'renamed'), + new Update(type: ColumnType::Text), + new Update(size: 10), + new Update(required: false), + new Update(default: 'x'), + new Update(signed: false), + new Update(array: false), + new Update(format: new Format('email')), + new Update(format: null), + new Update(filters: []), + new Update(key: 'renamed'), ]; foreach ($updates as $update) { @@ -60,7 +60,7 @@ public function testEmptyUpdateKeepsEveryField(): void { $original = Attribute::string('name', 64, required: true, default: 'x', array: true, format: new Format('email', ['a' => 1]), filters: ['lowercase']); - $updated = $original->apply(new AttributeUpdate()); + $updated = $original->apply(new Update()); $this->assertSame($original->toDocument()->getArrayCopy(), $updated->toDocument()->getArrayCopy()); } @@ -69,7 +69,7 @@ public function testApplyChangesOnlyTheGivenFields(): void { $original = Attribute::string('name', 64, required: true, default: 'x', filters: ['lowercase']); - $updated = $original->apply(new AttributeUpdate(size: 128, required: false)); + $updated = $original->apply(new Update(size: 128, required: false)); $this->assertSame(128, $updated->size); $this->assertFalse($updated->required); @@ -81,14 +81,14 @@ public function testApplyChangesOnlyTheGivenFields(): void public function testZeroSizeClearsTheSize(): void { - $this->assertNull(Attribute::text('body', 500)->apply(new AttributeUpdate(size: 0))->size); + $this->assertNull(Attribute::text('body', 500)->apply(new Update(size: 0))->size); } public function testApplyLeavesTheOriginalUntouched(): void { $original = Attribute::string('name', 64); - $original->apply(new AttributeUpdate(size: 128, key: 'title', default: 'y')); + $original->apply(new Update(size: 128, key: 'title', default: 'y')); $this->assertSame(64, $original->size); $this->assertSame('name', $original->key); @@ -97,44 +97,44 @@ public function testApplyLeavesTheOriginalUntouched(): void public function testNullDefaultClearsTheDefault(): void { - $updated = Attribute::integer('count', default: 5)->apply(new AttributeUpdate(default: null)); + $updated = Attribute::integer('count', default: 5)->apply(new Update(default: null)); $this->assertNull($updated->default); } public function testDefaultCanBeSet(): void { - $updated = Attribute::integer('count')->apply(new AttributeUpdate(default: 7)); + $updated = Attribute::integer('count')->apply(new Update(default: 7)); $this->assertSame(7, $updated->default); } public function testKeyRenames(): void { - $this->assertSame('title', Attribute::string('name')->apply(new AttributeUpdate(key: 'title'))->key); + $this->assertSame('title', Attribute::string('name')->apply(new Update(key: 'title'))->key); } public function testFiltersAcceptEnumCasesAndStrings(): void { - $updated = Attribute::string('name')->apply(new AttributeUpdate(filters: [Filter::Json, 'encrypt'])); + $updated = Attribute::string('name')->apply(new Update(filters: [Filter::Json, 'encrypt'])); $this->assertSame(['json', 'encrypt'], $updated->filters); } public function testEmptyFiltersClearTheFilters(): void { - $this->assertSame([], Attribute::string('name', filters: ['lowercase'])->apply(new AttributeUpdate(filters: []))->filters); + $this->assertSame([], Attribute::string('name', filters: ['lowercase'])->apply(new Update(filters: []))->filters); } public function testClearingFiltersKeepsTheTypeFilter(): void { - $this->assertSame(['datetime'], Attribute::datetime('at')->apply(new AttributeUpdate(filters: []))->filters); - $this->assertSame(['point', 'encrypt'], Attribute::point('at')->apply(new AttributeUpdate(filters: ['encrypt']))->filters); + $this->assertSame(['datetime'], Attribute::datetime('at')->apply(new Update(filters: []))->filters); + $this->assertSame(['point', 'encrypt'], Attribute::point('at')->apply(new Update(filters: ['encrypt']))->filters); } public function testFormatReplacesTheFormat(): void { - $updated = Attribute::string('name', format: new Format('email'))->apply(new AttributeUpdate(format: new Format('url', ['scheme' => 'https']))); + $updated = Attribute::string('name', format: new Format('email'))->apply(new Update(format: new Format('url', ['scheme' => 'https']))); $this->assertSame('url', $updated->format?->name); $this->assertSame(['scheme' => 'https'], $updated->format->options); @@ -142,7 +142,7 @@ public function testFormatReplacesTheFormat(): void public function testNullFormatIsAChange(): void { - $update = new AttributeUpdate(format: null); + $update = new Update(format: null); $this->assertTrue($update->changesFormat()); $this->assertFalse($update->isEmpty()); @@ -150,7 +150,7 @@ public function testNullFormatIsAChange(): void public function testNullFormatRemovesTheFormat(): void { - $updated = Attribute::string('name', format: new Format('email', ['strict' => true]))->apply(new AttributeUpdate(format: null)); + $updated = Attribute::string('name', format: new Format('email', ['strict' => true]))->apply(new Update(format: null)); $this->assertNull($updated->format); $this->assertNull($updated->toDocument()->getAttribute('format')); @@ -161,14 +161,14 @@ public function testOmittedFormatKeepsTheFormat(): void { $format = new Format('email', ['strict' => true]); - $updated = Attribute::string('name', format: $format)->apply(new AttributeUpdate(size: 32)); + $updated = Attribute::string('name', format: $format)->apply(new Update(size: 32)); $this->assertSame($format, $updated->format); } public function testSignedAndArrayChange(): void { - $updated = Attribute::integer('count')->apply(new AttributeUpdate(signed: false, array: true)); + $updated = Attribute::integer('count')->apply(new Update(signed: false, array: true)); $this->assertFalse($updated->signed); $this->assertTrue($updated->array); @@ -176,7 +176,7 @@ public function testSignedAndArrayChange(): void public function testTypeChangesToAnotherAttributeType(): void { - $updated = Attribute::string('body', 100)->apply(new AttributeUpdate(type: ColumnType::Text)); + $updated = Attribute::string('body', 100)->apply(new Update(type: ColumnType::Text)); $this->assertSame(ColumnType::Text, $updated->type); $this->assertSame('text', $updated->toDocument()->getAttribute('type')); @@ -228,7 +228,7 @@ public static function typeChanges(): array #[DataProvider('typeChanges')] public function testTypeChangeProducesWhatTheTargetFactoryProduces(Attribute $source, ColumnType $type, Attribute $expected): void { - $updated = $source->apply(new AttributeUpdate(type: $type)); + $updated = $source->apply(new Update(type: $type)); $this->assertSame($expected->toDocument()->getArrayCopy(), $updated->toDocument()->getArrayCopy()); } @@ -236,46 +236,46 @@ public function testTypeChangeProducesWhatTheTargetFactoryProduces(Attribute $so public function testTypeChangeKeepsUserFiltersAndSwapsTheTypeFilter(): void { $updated = Attribute::string('field', filters: ['encrypt']) - ->apply(new AttributeUpdate(type: ColumnType::Datetime)) - ->apply(new AttributeUpdate(type: ColumnType::Polygon)); + ->apply(new Update(type: ColumnType::Datetime)) + ->apply(new Update(type: ColumnType::Polygon)); $this->assertSame(['polygon', 'encrypt'], $updated->filters); } public function testExplicitFiltersOnATypeChangeAreKept(): void { - $updated = Attribute::datetime('field')->apply(new AttributeUpdate(type: ColumnType::String, filters: ['datetime'])); + $updated = Attribute::datetime('field')->apply(new Update(type: ColumnType::String, filters: ['datetime'])); $this->assertSame(['datetime'], $updated->filters); } public function testUpdatesCannotBreakTheTypeInvariants(): void { - $this->assertTrue(Attribute::string('name')->apply(new AttributeUpdate(signed: false))->signed); - $this->assertFalse(Attribute::datetime('at')->apply(new AttributeUpdate(signed: true))->signed); - $this->assertFalse(Attribute::point('at')->apply(new AttributeUpdate(array: true))->array); - $this->assertNull(Attribute::boolean('flag')->apply(new AttributeUpdate(size: 4))->size); - $this->assertSame(8, Attribute::integer('count')->apply(new AttributeUpdate(size: 20))->size); - $this->assertNull(Attribute::integer('count', width: IntegerWidth::Bits64)->apply(new AttributeUpdate(size: 4))->size); + $this->assertTrue(Attribute::string('name')->apply(new Update(signed: false))->signed); + $this->assertFalse(Attribute::datetime('at')->apply(new Update(signed: true))->signed); + $this->assertFalse(Attribute::point('at')->apply(new Update(array: true))->array); + $this->assertNull(Attribute::boolean('flag')->apply(new Update(size: 4))->size); + $this->assertSame(8, Attribute::integer('count')->apply(new Update(size: 20))->size); + $this->assertNull(Attribute::integer('count', width: IntegerWidth::Bits64)->apply(new Update(size: 4))->size); } public function testTypeOutsideTheAttributeTypesIsRejected(): void { $this->expectException(Structure::class); - Attribute::string('body')->apply(new AttributeUpdate(type: ColumnType::Tuple)); + Attribute::string('body')->apply(new Update(type: ColumnType::Tuple)); } public function testTypeCannotBecomeRelationship(): void { $this->expectException(Structure::class); - Attribute::string('author')->apply(new AttributeUpdate(type: ColumnType::Relationship)); + Attribute::string('author')->apply(new Update(type: ColumnType::Relationship)); } public function testSameTypeIsAccepted(): void { - $this->assertSame(ColumnType::Integer, Attribute::integer('count')->apply(new AttributeUpdate(type: ColumnType::Integer))->type); + $this->assertSame(ColumnType::Integer, Attribute::integer('count')->apply(new Update(type: ColumnType::Integer))->type); } public function testRelationshipAttributeCannotChangeType(): void @@ -284,14 +284,14 @@ public function testRelationshipAttributeCannotChangeType(): void $this->expectException(Structure::class); - $attribute->apply(new AttributeUpdate(type: ColumnType::String)); + $attribute->apply(new Update(type: ColumnType::String)); } public function testRenamingARelationshipAttributeRenamesItsRelationship(): void { $attribute = Attribute::relationship('author', Relationship::manyToOne('users'), RelationshipSide::Parent); - $renamed = $attribute->apply(new AttributeUpdate(key: 'writer')); + $renamed = $attribute->apply(new Update(key: 'writer')); $this->assertSame('writer', $renamed->key); $this->assertSame('writer', $renamed->relationship?->key); diff --git a/tests/unit/Model/CollectionUpdateTest.php b/tests/unit/Model/CollectionUpdateTest.php index dc13ab385..972a5955a 100644 --- a/tests/unit/Model/CollectionUpdateTest.php +++ b/tests/unit/Model/CollectionUpdateTest.php @@ -3,7 +3,7 @@ namespace Tests\Unit\Model; use PHPUnit\Framework\TestCase; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update; use Utopia\Database\Permission; use Utopia\Database\Role; @@ -11,7 +11,7 @@ final class CollectionUpdateTest extends TestCase { public function testDefaultsChangeNothing(): void { - $update = new CollectionUpdate(); + $update = new Update(); $this->assertNull($update->permissions); $this->assertNull($update->documentSecurity); @@ -19,8 +19,8 @@ public function testDefaultsChangeNothing(): void public function testCarriesOnlyTheGivenChanges(): void { - $permissions = new CollectionUpdate(permissions: [Permission::read(Role::any())]); - $security = new CollectionUpdate(documentSecurity: false); + $permissions = new Update(permissions: [Permission::read(Role::any())]); + $security = new Update(documentSecurity: false); $this->assertSame(['read("any")'], $permissions->permissions); $this->assertNull($permissions->documentSecurity); @@ -30,14 +30,14 @@ public function testCarriesOnlyTheGivenChanges(): void public function testAnEmptyPermissionListIsAChange(): void { - $update = new CollectionUpdate(permissions: []); + $update = new Update(permissions: []); $this->assertSame([], $update->permissions); } public function testPropertiesAreReadonly(): void { - $update = new CollectionUpdate(documentSecurity: true); + $update = new Update(documentSecurity: true); $this->expectException(\Error::class); diff --git a/tests/unit/Model/RelationshipUpdateTest.php b/tests/unit/Model/RelationshipUpdateTest.php index 45ba746fc..d7651d682 100644 --- a/tests/unit/Model/RelationshipUpdateTest.php +++ b/tests/unit/Model/RelationshipUpdateTest.php @@ -4,15 +4,15 @@ use PHPUnit\Framework\TestCase; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipDeleteAction; use Utopia\Database\RelationshipType; -use Utopia\Database\RelationshipUpdate; final class RelationshipUpdateTest extends TestCase { public function testDefaultsChangeNothing(): void { - $update = new RelationshipUpdate(); + $update = new Update(); $this->assertNull($update->key); $this->assertNull($update->twoWayKey); @@ -24,7 +24,7 @@ public function testEmptyUpdateKeepsEveryField(): void { $relationship = Relationship::oneToMany('comments', key: 'comments', twoWay: true, twoWayKey: 'post', onDelete: RelationshipDeleteAction::Cascade); - $updated = $relationship->apply(new RelationshipUpdate()); + $updated = $relationship->apply(new Update()); $this->assertSame('comments', $updated->relatedCollection); $this->assertSame(RelationshipType::OneToMany, $updated->type); @@ -38,7 +38,7 @@ public function testApplyChangesOnlyTheGivenFields(): void { $relationship = Relationship::manyToOne('users', key: 'author', twoWay: true, twoWayKey: 'posts'); - $updated = $relationship->apply(new RelationshipUpdate(key: 'writer', onDelete: RelationshipDeleteAction::SetNull)); + $updated = $relationship->apply(new Update(key: 'writer', onDelete: RelationshipDeleteAction::SetNull)); $this->assertSame('writer', $updated->key); $this->assertSame(RelationshipDeleteAction::SetNull, $updated->onDelete); @@ -52,7 +52,7 @@ public function testApplyChangesEveryField(): void { $relationship = Relationship::oneToOne('profiles', key: 'profile', twoWay: true, twoWayKey: 'user', onDelete: RelationshipDeleteAction::Cascade); - $updated = $relationship->apply(new RelationshipUpdate( + $updated = $relationship->apply(new Update( key: 'avatar', twoWayKey: 'owner', twoWay: false, @@ -69,14 +69,14 @@ public function testApplyChangesEveryField(): void public function testFalseTwoWayIsAChange(): void { - $updated = Relationship::manyToMany('tags', twoWay: true)->apply(new RelationshipUpdate(twoWay: false)); + $updated = Relationship::manyToMany('tags', twoWay: true)->apply(new Update(twoWay: false)); $this->assertFalse($updated->twoWay); } public function testApplyResolvesAnUnresolvedKey(): void { - $updated = Relationship::manyToOne('users')->apply(new RelationshipUpdate(key: 'author')); + $updated = Relationship::manyToOne('users')->apply(new Update(key: 'author')); $this->assertSame('author', $updated->key); $this->assertNull($updated->twoWayKey); @@ -86,7 +86,7 @@ public function testApplyLeavesTheOriginalUntouched(): void { $relationship = Relationship::oneToMany('comments', key: 'comments', twoWayKey: 'post'); - $relationship->apply(new RelationshipUpdate(key: 'replies', twoWayKey: 'thread', twoWay: true, onDelete: RelationshipDeleteAction::Cascade)); + $relationship->apply(new Update(key: 'replies', twoWayKey: 'thread', twoWay: true, onDelete: RelationshipDeleteAction::Cascade)); $this->assertSame('comments', $relationship->key); $this->assertSame('post', $relationship->twoWayKey); diff --git a/tests/unit/MongoAdapterPathsTest.php b/tests/unit/MongoAdapterPathsTest.php index 0123661e6..2a0701970 100644 --- a/tests/unit/MongoAdapterPathsTest.php +++ b/tests/unit/MongoAdapterPathsTest.php @@ -18,8 +18,8 @@ use Utopia\Database\Operator; use Utopia\Database\OperatorType; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipSide; -use Utopia\Database\RelationshipUpdate; use Utopia\Database\Storage; use Utopia\Database\Validator\Authorization; use Utopia\Mongo\Client; @@ -49,8 +49,8 @@ public function testManyToManyKeysAreRenamedInTheJunctionOfEitherSide(): void }); $adapter = $this->adapter(); - $adapter->updateRelationship('books', Relationship::manyToMany(relatedCollection: 'authors', twoWay: true, key: 'authors', twoWayKey: 'books'), RelationshipSide::Parent, new RelationshipUpdate(key: 'writers')); - $adapter->updateRelationship('authors', Relationship::manyToMany(relatedCollection: 'books', twoWay: true, key: 'books', twoWayKey: 'authors'), RelationshipSide::Child, new RelationshipUpdate(key: 'titles')); + $adapter->updateRelationship('books', Relationship::manyToMany(relatedCollection: 'authors', twoWay: true, key: 'authors', twoWayKey: 'books'), RelationshipSide::Parent, new Update(key: 'writers')); + $adapter->updateRelationship('authors', Relationship::manyToMany(relatedCollection: 'books', twoWay: true, key: 'books', twoWayKey: 'authors'), RelationshipSide::Child, new Update(key: 'titles')); $this->assertSame([ [self::NAMESPACE.'__11_22', ['$rename' => ['authors' => 'writers']]], diff --git a/tests/unit/RefusedSchemaChangeTest.php b/tests/unit/RefusedSchemaChangeTest.php index a7a2d2377..9fa893e1e 100644 --- a/tests/unit/RefusedSchemaChangeTest.php +++ b/tests/unit/RefusedSchemaChangeTest.php @@ -12,7 +12,7 @@ use Utopia\Cache\Adapter\None; use Utopia\Cache\Cache; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update as AttributeUpdate; use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Exception as DatabaseException; @@ -22,7 +22,7 @@ use Utopia\Database\Index; use Utopia\Database\Permission; use Utopia\Database\Relationship; -use Utopia\Database\RelationshipUpdate; +use Utopia\Database\Relationship\Update as RelationshipUpdate; use Utopia\Database\Role; use Utopia\Database\Validator\Authorization; diff --git a/tests/unit/Relationships/OneWayChildSideTest.php b/tests/unit/Relationships/OneWayChildSideTest.php index cfef27dd3..776c7feb6 100644 --- a/tests/unit/Relationships/OneWayChildSideTest.php +++ b/tests/unit/Relationships/OneWayChildSideTest.php @@ -18,8 +18,8 @@ use Utopia\Database\Index; use Utopia\Database\Permission; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipSide; -use Utopia\Database\RelationshipUpdate; use Utopia\Database\Role; use Utopia\Database\Validator\Authorization; @@ -44,7 +44,7 @@ public function testAChildSideUpdateRenamesTheParentKey(Closure $factory): void { $database = $factory(); - $updated = $database->updateRelationship('passports', 'holder', new RelationshipUpdate(key: 'owner', twoWayKey: 'document')); + $updated = $database->updateRelationship('passports', 'holder', new Update(key: 'owner', twoWayKey: 'document')); $this->assertSame('owner', $updated->key); $this->assertSame('document', $updated->twoWayKey); diff --git a/tests/unit/Relationships/PreparedCreateTest.php b/tests/unit/Relationships/PreparedCreateTest.php index 1614450a7..9919bbc61 100644 --- a/tests/unit/Relationships/PreparedCreateTest.php +++ b/tests/unit/Relationships/PreparedCreateTest.php @@ -15,7 +15,7 @@ use Utopia\Cache\Cache; use Utopia\Database\Attribute; use Utopia\Database\Collection; -use Utopia\Database\CollectionUpdate; +use Utopia\Database\Collection\Update; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Event; @@ -680,13 +680,13 @@ private static function writes(): array }, 'related collection the caller may not create in' => static function (Database $database) use ($create): array { self::chain($database, RelationshipType::OneToMany, 2); - $database->updateCollection('level2', new CollectionUpdate(permissions: [Permission::read(Role::any())], documentSecurity: true)); + $database->updateCollection('level2', new Update(permissions: [Permission::read(Role::any())], documentSecurity: true)); return [self::tree(RelationshipType::OneToMany, 'root', 0, 2), $create($database, 'level0')]; }, 'many to many link the caller may not update' => static function (Database $database) use ($create): array { self::chain($database, RelationshipType::ManyToMany, 2); - $database->updateCollection('level2', new CollectionUpdate(permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: true)); + $database->updateCollection('level2', new Update(permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: true)); $database->createDocument('level2', new Document(['$id' => 'locked', 'name' => 'locked', '$permissions' => [Permission::read(Role::any())]])); return [new Document([ diff --git a/tests/unit/Relationships/RelationshipHookTest.php b/tests/unit/Relationships/RelationshipHookTest.php index 9cb657781..25f2c1d92 100644 --- a/tests/unit/Relationships/RelationshipHookTest.php +++ b/tests/unit/Relationships/RelationshipHookTest.php @@ -30,9 +30,9 @@ use Utopia\Database\PermissionType; use Utopia\Database\Query; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipDeleteAction; use Utopia\Database\RelationshipType; -use Utopia\Database\RelationshipUpdate; use Utopia\Database\Role; use Utopia\Database\Validator\Authorization; use Utopia\Query\CursorDirection; @@ -527,7 +527,7 @@ public function handle(Domain $event): void }; $database->addHook($updates); - $updated = $database->updateRelationship('parent', 'children', new RelationshipUpdate(key: 'kids', onDelete: RelationshipDeleteAction::SetNull)); + $updated = $database->updateRelationship('parent', 'children', new Update(key: 'kids', onDelete: RelationshipDeleteAction::SetNull)); $this->assertSame('kids', $updated->key); $this->assertSame(RelationshipDeleteAction::SetNull, $updated->onDelete); diff --git a/tests/unit/Relationships/RelationshipRenameRecoveryTest.php b/tests/unit/Relationships/RelationshipRenameRecoveryTest.php index 287bd2e25..79c672d13 100644 --- a/tests/unit/Relationships/RelationshipRenameRecoveryTest.php +++ b/tests/unit/Relationships/RelationshipRenameRecoveryTest.php @@ -15,7 +15,7 @@ use Utopia\Database\Hook\Relationships; use Utopia\Database\Permission; use Utopia\Database\Relationship; -use Utopia\Database\RelationshipUpdate; +use Utopia\Database\Relationship\Update; use Utopia\Database\Role; use Utopia\Database\Validator\Authorization; @@ -59,7 +59,7 @@ public function testAnOrphanColumnUnderTheNewKeyIsNotTakenForAnEarlierRename(): $this->database->createDocument('books', new Document(['$id' => 'dune', 'name' => 'Dune', 'author' => 'herbert'])); $this->addColumn('books', 'writer'); - $this->assertRenameFails('books', 'author', new RelationshipUpdate(key: 'writer')); + $this->assertRenameFails('books', 'author', new Update(key: 'writer')); $this->assertSame(['author', 'name'], $this->attributeKeys('books')); $author = $this->database->getDocument('books', 'dune')->getAttribute('author'); @@ -72,7 +72,7 @@ public function testATwoWayKeyRenameTheRelatedTableRejectsIsNotTakenForAnEarlier $this->database->createRelationship('books', Relationship::oneToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'book')); $this->addColumn('authors', 'novel'); - $this->assertRenameFails('books', 'author', new RelationshipUpdate(twoWayKey: 'novel')); + $this->assertRenameFails('books', 'author', new Update(twoWayKey: 'novel')); $this->assertSame(['book', 'name'], $this->attributeKeys('authors')); } @@ -83,7 +83,7 @@ public function testARetryCompletesARenameThatMovedOnlyOneOfItsColumns(): void $this->database->createDocument('authors', new Document(['$id' => 'herbert', 'name' => 'Herbert'])); $this->database->createDocument('books', new Document(['$id' => 'dune', 'name' => 'Dune', 'author' => 'herbert'])); $this->addColumn('authors', 'novel'); - $update = new RelationshipUpdate(key: 'writer', twoWayKey: 'novel'); + $update = new Update(key: 'writer', twoWayKey: 'novel'); $this->assertRenameFails('books', 'author', $update); $this->assertSame(['author', 'name'], $this->attributeKeys('books')); @@ -106,7 +106,7 @@ private function addColumn(string $collection, string $column): void $this->pdo->exec('ALTER TABLE `'.self::NAMESPACE."_{$collection}` ADD COLUMN `{$column}` VARCHAR(255)"); } - private function assertRenameFails(string $collection, string $key, RelationshipUpdate $update): void + private function assertRenameFails(string $collection, string $key, Update $update): void { try { $this->database->updateRelationship($collection, $key, $update); diff --git a/tests/unit/Relationships/RelationshipSchemaTest.php b/tests/unit/Relationships/RelationshipSchemaTest.php index 326a9a81d..5d31bc50a 100644 --- a/tests/unit/Relationships/RelationshipSchemaTest.php +++ b/tests/unit/Relationships/RelationshipSchemaTest.php @@ -28,8 +28,8 @@ use Utopia\Database\Permission; use Utopia\Database\Query; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipSide; -use Utopia\Database\RelationshipUpdate; use Utopia\Database\Role; use Utopia\Database\Schema\Index as SchemaIndex; use Utopia\Database\Validator\Authorization; @@ -58,7 +58,7 @@ public function testRenamingARelationshipWhoseIndexIsGoneKeepsTheOldKey(Closure $database->deleteIndex('books', '_index_author'); try { - $database->updateRelationship('books', 'author', new RelationshipUpdate(key: 'writer')); + $database->updateRelationship('books', 'author', new Update(key: 'writer')); $this->fail('a relationship whose index is gone cannot be renamed'); } catch (DatabaseException $error) { $this->assertSame("Failed to update relationship indexes for 'author': Index not found", $error->getMessage()); @@ -112,7 +112,7 @@ public function testAFailedDefinitionUpdateReversesTheSchemaRename(): void $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); try { - $database->updateRelationship('books', 'author', new RelationshipUpdate(key: 'writer')); + $database->updateRelationship('books', 'author', new Update(key: 'writer')); $this->fail('a failed definition update must fail the rename'); } catch (DatabaseException $error) { $this->assertSame($failure, $error->getPrevious()); @@ -136,7 +136,7 @@ public function testAFailedJunctionDefinitionUpdateRestoresBothSides(): void $database->createRelationship('books', Relationship::manyToMany(relatedCollection: 'authors', twoWay: true, key: 'writers', twoWayKey: 'works')); try { - $database->updateRelationship('books', 'writers', new RelationshipUpdate(key: 'authors_of', twoWayKey: 'written')); + $database->updateRelationship('books', 'writers', new Update(key: 'authors_of', twoWayKey: 'written')); $this->fail('a failed junction definition update must fail the rename'); } catch (DatabaseException $error) { $this->assertSame($failure, $error->getPrevious()); @@ -162,7 +162,7 @@ public function testAFailedSecondIndexRenameReversesTheFirstAndTheDefinitions(Cl $physical = $this->schemaIndexIds($database, 'books'); try { - $database->updateRelationship('books', 'library', new RelationshipUpdate(key: 'shelf', twoWayKey: 'keeper')); + $database->updateRelationship('books', 'library', new Update(key: 'shelf', twoWayKey: 'keeper')); $this->fail('a rename whose second index is gone must fail'); } catch (DatabaseException $error) { $this->assertSame("Failed to update relationship indexes for 'library': Index not found", $error->getMessage()); @@ -183,7 +183,7 @@ public function testRelationshipSchemaChangesNeedTheRelationshipsFeature(): void foreach ([ static fn (): Relationship => $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors')), - static fn (): Relationship => $database->updateRelationship('books', 'author', new RelationshipUpdate(key: 'writer')), + static fn (): Relationship => $database->updateRelationship('books', 'author', new Update(key: 'writer')), static fn () => $database->deleteRelationship('books', 'author'), ] as $change) { try { @@ -327,11 +327,11 @@ public function testAnUpdateWithoutChangesIsAcceptedAndAnUnknownRelationshipIsNo $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); $before = $this->relationship($database, 'books', 'author'); - $this->assertSame($before->toDocument()->getArrayCopy(), $database->updateRelationship('books', 'author', new RelationshipUpdate())->toDocument()->getArrayCopy()); + $this->assertSame($before->toDocument()->getArrayCopy(), $database->updateRelationship('books', 'author', new Update())->toDocument()->getArrayCopy()); $this->assertSame($before->toDocument()->getArrayCopy(), $this->relationship($database, 'books', 'author')->toDocument()->getArrayCopy()); try { - $database->updateRelationship('books', 'missing', new RelationshipUpdate(key: 'other')); + $database->updateRelationship('books', 'missing', new Update(key: 'other')); $this->fail('an unknown relationship cannot be updated'); } catch (NotFoundException $error) { $this->assertSame('Relationship not found', $error->getMessage()); @@ -349,7 +349,7 @@ public function testRenamingFromTheChildSideOfAOneToManyRenamesItsIndex(Closure $database->createDocument('authors', new Document([Document::ID => 'ada', 'name' => 'Ada'])); $database->createDocument('books', new Document([Document::ID => 'notes', 'title' => 'Notes', 'author' => 'ada'])); - $this->assertSame('writer', $database->updateRelationship('books', 'author', new RelationshipUpdate(key: 'writer'))->key); + $this->assertSame('writer', $database->updateRelationship('books', 'author', new Update(key: 'writer'))->key); $this->assertSame(['writer'], $this->indexAttributes($database, 'books', '_index_writer')); $this->assertNull($this->index($database, 'books', '_index_author')); @@ -368,7 +368,7 @@ public function testRenamingTheParentKeyFromTheChildSideOfAManyToOneRenamesItsIn $database = $this->database($adapter()); $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); - $this->assertSame('writer', $database->updateRelationship('authors', 'books', new RelationshipUpdate(twoWayKey: 'writer'))->twoWayKey); + $this->assertSame('writer', $database->updateRelationship('authors', 'books', new Update(twoWayKey: 'writer'))->twoWayKey); $this->assertSame(['writer'], $this->indexAttributes($database, 'books', '_index_writer')); $this->assertNull($this->index($database, 'books', '_index_author')); @@ -385,7 +385,7 @@ public function testAnAdapterThatDoesNotUpdateTheRelationshipRefusesTheUpdate(Cl $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); try { - $database->updateRelationship('books', 'author', new RelationshipUpdate(key: 'writer')); + $database->updateRelationship('books', 'author', new Update(key: 'writer')); $this->fail('an adapter that does not update the relationship must refuse the update'); } catch (RefusedException $error) { $this->assertSame("Failed to update relationship 'author'", $error->getMessage()); @@ -403,7 +403,7 @@ public function testAnUpdateTheAdapterFailsIsWrappedWithItsCause(): void $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); try { - $database->updateRelationship('books', 'author', new RelationshipUpdate(key: 'writer')); + $database->updateRelationship('books', 'author', new Update(key: 'writer')); $this->fail('an update the adapter fails must be reported'); } catch (DatabaseException $error) { $this->assertNotInstanceOf(RefusedException::class, $error); @@ -433,7 +433,7 @@ public function testATypedUpdateFailureIsTheAdaptersOwn(\Throwable $failure): vo $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); try { - $database->updateRelationship('books', 'author', new RelationshipUpdate(key: 'writer')); + $database->updateRelationship('books', 'author', new Update(key: 'writer')); $this->fail('an update the adapter fails must be reported'); } catch (\Throwable $error) { $this->assertSame($failure, $error); @@ -447,7 +447,7 @@ public function testARenameTheSchemaAlreadyAppliedIsCompleted(): void { $adapter = new class (new PDO('sqlite::memory:')) extends SQLite { #[\Override] - public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool + public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, Update $update): bool { parent::updateRelationship($collection, $relationship, $side, $update); @@ -457,7 +457,7 @@ public function updateRelationship(string $collection, Relationship $relationshi $database = $this->database($adapter); $database->createRelationship('books', Relationship::manyToOne(relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); - $this->assertSame('writer', $database->updateRelationship('books', 'author', new RelationshipUpdate(key: 'writer'))->key); + $this->assertSame('writer', $database->updateRelationship('books', 'author', new Update(key: 'writer'))->key); $this->assertContains('writer', $this->attributeKeys($database, 'books')); $this->assertSame('writer', $this->relationship($database, 'authors', 'books')->twoWayKey); @@ -475,7 +475,7 @@ public function testAFailedJunctionIndexRenameRestoresTheJunctionDefinitions(Clo $database->deleteIndex($junction, '_index_writers'); try { - $database->updateRelationship('books', 'writers', new RelationshipUpdate(key: 'authors_of', twoWayKey: 'written')); + $database->updateRelationship('books', 'writers', new Update(key: 'authors_of', twoWayKey: 'written')); $this->fail('a rename whose junction index is gone must fail'); } catch (DatabaseException $error) { $this->assertSame("Failed to update relationship indexes for 'writers': Index not found", $error->getMessage()); @@ -710,7 +710,7 @@ public function updateDocument(string $collection, string $id, Document $documen $database->armed = true; try { - $database->updateRelationship('books', 'writers', new RelationshipUpdate(key: 'authors_of', twoWayKey: 'written')); + $database->updateRelationship('books', 'writers', new Update(key: 'authors_of', twoWayKey: 'written')); $this->fail('a failed junction purge must fail the rename'); } catch (RuntimeException $error) { $this->assertSame($failure, $error); @@ -774,7 +774,7 @@ public function updateDocument(string $collection, string $id, Document $documen $database->armed = true; try { - $database->updateRelationship('books', 'writers', new RelationshipUpdate(key: 'authors_of', twoWayKey: 'written')); + $database->updateRelationship('books', 'writers', new Update(key: 'authors_of', twoWayKey: 'written')); $this->fail('a rename whose second junction index is gone must fail'); } catch (DatabaseException $error) { $this->assertSame("Failed to update relationship indexes for 'writers': Index not found", $error->getMessage()); @@ -911,7 +911,7 @@ public function createRelationship(string $collection, Relationship $relationshi } #[\Override] - public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool + public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, Update $update): bool { return $this->intercept(__FUNCTION__, [$relationship, $update->key, $update->twoWayKey]) ?? parent::updateRelationship($collection, $relationship, $side, $update); } @@ -1087,7 +1087,7 @@ private function refusingUpdates(Adapter $adapter): Adapter if ($adapter instanceof SQLite) { return new class (new PDO('sqlite::memory:')) extends SQLite { #[\Override] - public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool + public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, Update $update): bool { return false; } diff --git a/tests/unit/Relationships/RelationshipValidationTest.php b/tests/unit/Relationships/RelationshipValidationTest.php index b78cec905..7087f3ca1 100644 --- a/tests/unit/Relationships/RelationshipValidationTest.php +++ b/tests/unit/Relationships/RelationshipValidationTest.php @@ -22,8 +22,8 @@ use Utopia\Database\Operator; use Utopia\Database\Permission; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipType; -use Utopia\Database\RelationshipUpdate; use Utopia\Database\Role; use Utopia\Query\Schema\ColumnType; @@ -587,7 +587,7 @@ public function testUpdateRelationshipToExistingKey(): void $this->expectException(DuplicateException::class); $this->expectExceptionMessage('Relationship already exists'); - $db->updateRelationship('ovens', 'cakes', new RelationshipUpdate(key: 'owner')); + $db->updateRelationship('ovens', 'cakes', new Update(key: 'owner')); } public function testOneToOneRelationshipRejectsArrayOperators(): void diff --git a/tests/unit/Relationships/TypedOptionsTest.php b/tests/unit/Relationships/TypedOptionsTest.php index f53ced277..f7fa37168 100644 --- a/tests/unit/Relationships/TypedOptionsTest.php +++ b/tests/unit/Relationships/TypedOptionsTest.php @@ -19,9 +19,9 @@ use Utopia\Database\Hook\Relationships; use Utopia\Database\Permission; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipDeleteAction; use Utopia\Database\RelationshipSide; -use Utopia\Database\RelationshipUpdate; use Utopia\Database\Role; use Utopia\Database\Validator\Authorization; @@ -61,7 +61,7 @@ public function testAnUpdateFromTheChildSideStoresTheOptionsOnBothSides(Closure $database = $this->database($adapter()); $database->createRelationship('artists', Relationship::oneToMany('albums', key: 'albums', twoWay: true, twoWayKey: 'artist')); - $updated = $database->updateRelationship('albums', 'artist', new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull)); + $updated = $database->updateRelationship('albums', 'artist', new Update(onDelete: RelationshipDeleteAction::SetNull)); $this->assertSame('artists', $updated->relatedCollection); $this->assertSame('artist', $updated->key); @@ -89,7 +89,7 @@ public function testDeletingFollowsTheStoredDeleteAction(Closure $adapter): void $this->assertFalse($database->getDocument('artists', 'a1')->isEmpty()); } - $database->updateRelationship('albums', 'artist', new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull)); + $database->updateRelationship('albums', 'artist', new Update(onDelete: RelationshipDeleteAction::SetNull)); $database->deleteDocument('artists', 'a1'); $album = $database->getDocument('albums', 'b1'); diff --git a/tests/unit/Support/VerdictMemory.php b/tests/unit/Support/VerdictMemory.php index eeaf3cd99..afef0bc98 100644 --- a/tests/unit/Support/VerdictMemory.php +++ b/tests/unit/Support/VerdictMemory.php @@ -7,8 +7,8 @@ use Utopia\Database\Attribute; use Utopia\Database\Index; use Utopia\Database\Relationship; +use Utopia\Database\Relationship\Update; use Utopia\Database\RelationshipSide; -use Utopia\Database\RelationshipUpdate; /** * A Memory adapter whose schema methods answer with the verdict set for them, or apply the change when there is @@ -119,7 +119,7 @@ public function createRelationship(string $collection, Relationship $relationshi } #[\Override] - public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool + public function updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, Update $update): bool { return $this->verdict(__FUNCTION__) ?? parent::updateRelationship($collection, $relationship, $side, $update); } diff --git a/tests/unit/Validator/IndexRevalidationTest.php b/tests/unit/Validator/IndexRevalidationTest.php index e2bdb65fb..e5d346efd 100644 --- a/tests/unit/Validator/IndexRevalidationTest.php +++ b/tests/unit/Validator/IndexRevalidationTest.php @@ -7,7 +7,7 @@ use Utopia\Cache\Cache; use Utopia\Database\Adapter\Memory; use Utopia\Database\Attribute; -use Utopia\Database\AttributeUpdate; +use Utopia\Database\Attribute\Update; use Utopia\Database\Capability; use Utopia\Database\Collection; use Utopia\Database\Database; @@ -34,7 +34,7 @@ public function testUpdatingAnIndexedAttributeDoesNotConflictWithItsOwnIndex(): indexes: [Index::key(key: 'by_name', attributes: ['name'])], )); - $updated = $database->updateAttribute(self::COLLECTION, 'name', new AttributeUpdate(size: 128)); + $updated = $database->updateAttribute(self::COLLECTION, 'name', new Update(size: 128)); $this->assertSame(128, $updated->size); $this->assertSame(128, $database->getCollection(self::COLLECTION)->attributes()[0]->size); From 8f7b5f9a56f85e651b436d9dbd27eca46d641e72 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 14 Oct 2026 18:56:46 +1300 Subject: [PATCH 09/18] fix(pool): forget the cached write-pool hostname when a hostname is set ReadWritePool caches the write pool's hostname on first use to avoid a borrow per cache key. The cache outlived setHostname(), so a hostname set after the first answer was ignored and cache keys kept naming the pooled adapter's host, the very bug 8.1.2 fixes for Pool. Co-Authored-By: Claude Opus 5.5 --- src/Database/Adapter/ReadWritePool.php | 8 ++++++++ tests/unit/Adapter/PoolHostnameTest.php | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/Database/Adapter/ReadWritePool.php b/src/Database/Adapter/ReadWritePool.php index 18016ea19..28871425f 100644 --- a/src/Database/Adapter/ReadWritePool.php +++ b/src/Database/Adapter/ReadWritePool.php @@ -122,6 +122,14 @@ public function hostname(): string return $this->writePoolHostname ??= parent::hostname(); } + #[\Override] + public function setHostname(string $hostname): static + { + $this->writePoolHostname = null; + + return parent::setHostname($hostname); + } + /** * Whether the calling coroutine's latest read was served by a replica, which may lag the * primary: what it returned must not be cached for other readers. diff --git a/tests/unit/Adapter/PoolHostnameTest.php b/tests/unit/Adapter/PoolHostnameTest.php index 65c5ac682..7de671182 100644 --- a/tests/unit/Adapter/PoolHostnameTest.php +++ b/tests/unit/Adapter/PoolHostnameTest.php @@ -8,6 +8,7 @@ use Utopia\Cache\Cache; use Utopia\Database\Adapter; use Utopia\Database\Adapter\Pool; +use Utopia\Database\Adapter\ReadWritePool; use Utopia\Database\Database; use Utopia\Database\Validator\Authorization; use Utopia\Pools\Pool as UtopiaPool; @@ -50,6 +51,23 @@ public function testADatabaseOverAPoolNamesThePoolsHostname(): void $this->assertSame('edge-1', $database->getHostname()); } + public function testAReadWritePoolFollowsTheHostnameSetAfterItAnswered(): void + { + $pool = new ReadWritePool( + $this->connections(new HostnameSQLite('db-1')), + $this->connections(new HostnameSQLite('replica-1')), + ); + $pool->setAuthorization(new Authorization()); + + $this->assertSame('db-1', $pool->hostname()); + + $pool->setHostname('edge-1'); + $this->assertSame('edge-1', $pool->hostname()); + + $pool->setHostname(''); + $this->assertSame('db-1', $pool->hostname()); + } + public function testThePoolHandsBackTheConnectionsItWasBuiltWith(): void { $connections = $this->connections(new HostnameSQLite('db-1')); From cc9bf5698859aab859f3ebbe5d4db8c6d786d289 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 14 Oct 2026 18:57:12 +1300 Subject: [PATCH 10/18] test(hook): cover the column check of the AllowNull constructor Deleting AllowNullUidTest with the wrapper dropped the only test that the AllowNull constructor refuses an injected column name; wrap() was covered but the constructor validates separately. Co-Authored-By: Claude Opus 5.5 --- tests/unit/Hook/ColumnNameTest.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/unit/Hook/ColumnNameTest.php b/tests/unit/Hook/ColumnNameTest.php index 3ddf73d90..d475beb30 100644 --- a/tests/unit/Hook/ColumnNameTest.php +++ b/tests/unit/Hook/ColumnNameTest.php @@ -41,6 +41,23 @@ public static function invalidColumns(): iterable yield 'an empty name' => ['']; } + #[DataProvider('invalidColumns')] + public function testAllowNullRejectsAColumnOutsideTheIdentifierPattern(string $column): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid column name: '.$column); + + new AllowNull(new Filter(['any'], static fn (string $table): string => $table.'_perms'), $column); + } + + public function testAllowNullRejectsAnInjectedStatement(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid column name: table_main._uid; DROP TABLE'); + + new AllowNull(new Filter(['any'], static fn (string $table): string => $table.'_perms'), 'table_main._uid; DROP TABLE'); + } + /** * @param Closure(string): Filter $construct */ From e9496533d228bdf1a34ac35e7c7709d141eaf22c Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 14 Oct 2026 19:07:09 +1300 Subject: [PATCH 11/18] refactor(documents): name the integer rounding modes with an enum wholeNumber() took a closure over three anonymous booleans to say when to move away from zero, and three wrappers named the modes. An internal Rounding enum names them and a match picks the direction, so a reader sees the rule in one place instead of decoding closure arguments. Co-Authored-By: Claude Opus 5.5 --- src/Database/Rounding.php | 55 +++++++++++++++++++ src/Database/Trait/Documents.php | 94 ++++---------------------------- 2 files changed, 66 insertions(+), 83 deletions(-) create mode 100644 src/Database/Rounding.php diff --git a/src/Database/Rounding.php b/src/Database/Rounding.php new file mode 100644 index 000000000..76c8b32e3 --- /dev/null +++ b/src/Database/Rounding.php @@ -0,0 +1,55 @@ += 0.5; + $fractional = $fraction > 0; + } elseif (\is_string($value) && \preg_match(self::DECIMAL, $value, $parts) === 1) { + $negative = $parts[1] === '-'; + $whole = BigInt::toNative($parts[1].$parts[2]); + $half = $parts[3][0] >= '5'; + $fractional = \trim($parts[3], '0') !== ''; + } else { + return $value; + } + + if ($whole === null) { + return $value; + } + + $awayFromZero = match ($this) { + self::HalfAwayFromZero => $half, + self::Floor => $negative && $fractional, + self::Ceil => ! $negative && $fractional, + }; + + return $awayFromZero ? BigInt::add($whole, $negative ? -1 : 1) : $whole; + } +} diff --git a/src/Database/Trait/Documents.php b/src/Database/Trait/Documents.php index 1cfefbb04..ba0dadb0f 100644 --- a/src/Database/Trait/Documents.php +++ b/src/Database/Trait/Documents.php @@ -46,6 +46,7 @@ use Utopia\Database\Query; use Utopia\Database\RelationshipSide; use Utopia\Database\RelationshipType; +use Utopia\Database\Rounding; use Utopia\Database\Validator\Authorization\Input; use Utopia\Database\Validator\BigInt; use Utopia\Database\Validator\PartialStructure; @@ -278,10 +279,6 @@ private function integerBound(int|float|string $bound, string $name): int|string return BigInt::integralValue($bound) ?? throw new TypeException($name.' must be an integer.'); } - /** - * An integer attribute takes a fractional increment or decrement amount rounded half away from zero, a - * fractional maximum floored and a fractional minimum ceiled. - */ private function roundOperators(Collection $collection, Document $document): void { foreach ($collection->attributes() as $attribute) { @@ -290,95 +287,26 @@ private function roundOperators(Collection $collection, Document $document): voi continue; } - $roundLimit = match ($operator->getMethod()) { - OperatorType::Increment => self::roundMaximum(...), - OperatorType::Decrement => self::roundMinimum(...), + $limitRounding = match ($operator->getMethod()) { + OperatorType::Increment => Rounding::Floor, + OperatorType::Decrement => Rounding::Ceil, default => null, }; - if ($roundLimit === null) { + if ($limitRounding === null) { continue; } $values = $operator->getValues(); if (\array_key_exists(0, $values)) { - $values[0] = self::roundAmount($values[0]); + $values[0] = Rounding::HalfAwayFromZero->round($values[0]); } if (isset($values[1])) { - $values[1] = $roundLimit($values[1]); + $values[1] = $limitRounding->round($values[1]); } $document->setAttribute($attribute->key, (clone $operator)->setValues($values)); } } - /** - * @template T - * - * @param T $value - * @return T|int|string - */ - private static function roundAmount(mixed $value): mixed - { - return self::wholeNumber($value, static fn (bool $negative, bool $half, bool $fractional): bool => $half); - } - - /** - * @template T - * - * @param T $value - * @return T|int|string - */ - private static function roundMaximum(mixed $value): mixed - { - return self::wholeNumber($value, static fn (bool $negative, bool $half, bool $fractional): bool => $negative && $fractional); - } - - /** - * @template T - * - * @param T $value - * @return T|int|string - */ - private static function roundMinimum(mixed $value): mixed - { - return self::wholeNumber($value, static fn (bool $negative, bool $half, bool $fractional): bool => ! $negative && $fractional); - } - - /** - * $value cut toward zero, then moved one further from zero where $away says so, exactly at any size. Anything - * but a finite float or a decimal string is returned as given, for the caller to accept or refuse. - * - * @template T - * - * @param T $value - * @param Closure(bool, bool, bool): bool $away Given whether $value is negative, whether its fraction is at - * least a half and whether it has one - * @return T|int|string - */ - private static function wholeNumber(mixed $value, Closure $away): mixed - { - if (\is_float($value) && \is_finite($value)) { - $negative = $value < 0; - $magnitude = \floor(\abs($value)); - $fraction = \abs($value) - $magnitude; - $whole = BigInt::integralValue($negative ? -$magnitude : $magnitude); - $half = $fraction >= 0.5; - $fractional = $fraction > 0; - } elseif (\is_string($value) && \preg_match('/^(-?)(\d+)\.(\d+)$/', $value, $parts) === 1) { - $negative = $parts[1] === '-'; - $whole = BigInt::toNative($parts[1].$parts[2]); - $half = $parts[3][0] >= '5'; - $fractional = \trim($parts[3], '0') !== ''; - } else { - return $value; - } - - if ($whole === null || ! $away($negative, $half, $fractional)) { - return $whole ?? $value; - } - - return BigInt::add($whole, $negative ? -1 : 1); - } - /** * Cached validator instances keyed by context and a * stable schema/authorization fingerprint. @@ -2758,10 +2686,10 @@ public function increaseDocumentAttribute( $numericAttribute = $this->numericAttribute($collection, $attribute); if ($this->isDeclaredInteger($numericAttribute ?? $this->declaredAttribute($collection, $attribute))) { - $value = self::roundAmount($value); + $value = Rounding::HalfAwayFromZero->round($value); $this->assertIntegerChange($value); if ($max !== null) { - $max = $this->integerBound(self::roundMaximum($max), 'Max'); + $max = $this->integerBound(Rounding::Floor->round($max), 'Max'); } } @@ -2889,10 +2817,10 @@ public function decreaseDocumentAttribute( $numericAttribute = $this->numericAttribute($collection, $attribute); if ($this->isDeclaredInteger($numericAttribute ?? $this->declaredAttribute($collection, $attribute))) { - $value = self::roundAmount($value); + $value = Rounding::HalfAwayFromZero->round($value); $this->assertIntegerChange($value); if ($min !== null) { - $min = $this->integerBound(self::roundMinimum($min), 'Min'); + $min = $this->integerBound(Rounding::Ceil->round($min), 'Min'); } } From 9b0ccc41fbf32a912de1e5d906615cba304dfdf3 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 14 Oct 2026 19:07:21 +1300 Subject: [PATCH 12/18] fix(documents): round every plain decimal text that is_numeric accepts The decimal pattern wanted digits on both sides of the point and no sign or whitespace, so '.5', '1.', '+1.5' and ' 1.5' passed the numeric check and were then refused as "must be an integer", which named the wrong problem. Text with an exponent stays refused: it is not plain decimal and rounding it exactly would need a parser for scientific notation. Co-Authored-By: Claude Opus 5.5 --- src/Database/Rounding.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Database/Rounding.php b/src/Database/Rounding.php index 76c8b32e3..ca01c0de8 100644 --- a/src/Database/Rounding.php +++ b/src/Database/Rounding.php @@ -13,13 +13,13 @@ enum Rounding case Floor; case Ceil; - private const string DECIMAL = '/^(-?)(\d+)\.(\d+)$/'; + private const string DECIMAL = '/^\s*([+-]?)(?=\.?\d)(\d*)\.(\d*)\s*$/D'; /** * @template T * * @param T $value - * @return T|int|string $value as a whole number, exact at any size; anything but a finite float or decimal + * @return T|int|string $value as a whole number, exact at any size; anything but a finite float or plain decimal * text is returned as given, for the caller to accept or refuse */ public function round(mixed $value): mixed @@ -33,8 +33,8 @@ public function round(mixed $value): mixed $fractional = $fraction > 0; } elseif (\is_string($value) && \preg_match(self::DECIMAL, $value, $parts) === 1) { $negative = $parts[1] === '-'; - $whole = BigInt::toNative($parts[1].$parts[2]); - $half = $parts[3][0] >= '5'; + $whole = BigInt::toNative(($negative ? '-' : '').($parts[2] ?: '0')); + $half = $parts[3] !== '' && $parts[3][0] >= '5'; $fractional = \trim($parts[3], '0') !== ''; } else { return $value; From dfd3382c4d101fd4b541e9a4bf8849ea26eb638f Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 14 Oct 2026 19:07:21 +1300 Subject: [PATCH 13/18] fix(documents): drop an operator change that rounds to nothing increaseDocumentAttribute(0.4) leaves the document alone, but increment(0.4) given to updateDocument() still counted as a change and moved $updatedAt, and updateDocuments() and upserts wrote every row. roundOperators() now reports the attributes whose fractional amount rounds to 0 and the caller keeps their stored value, so the existing no-change handling of each write applies: updateDocument() keeps $updatedAt, updateDocuments() with nothing else returns 0, and an upsert of a stored document skips it. An upsert that creates the document keeps the rounded operator, so it stores what increment(0) stores on insert. An amount given as the integer 0 is not a rounding case and behaves as before. Co-Authored-By: Claude Opus 5.5 --- src/Database/Trait/Documents.php | 45 +++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/src/Database/Trait/Documents.php b/src/Database/Trait/Documents.php index ba0dadb0f..feb9d8d0f 100644 --- a/src/Database/Trait/Documents.php +++ b/src/Database/Trait/Documents.php @@ -279,8 +279,12 @@ private function integerBound(int|float|string $bound, string $name): int|string return BigInt::integralValue($bound) ?? throw new TypeException($name.' must be an integer.'); } - private function roundOperators(Collection $collection, Document $document): void + /** + * @return list The attributes whose fractional change rounds to nothing + */ + private function roundOperators(Collection $collection, Document $document): array { + $unchanged = []; foreach ($collection->attributes() as $attribute) { $operator = $document->getAttribute($attribute->key); if (! $operator instanceof Operator || ! $this->isDeclaredInteger($attribute)) { @@ -298,13 +302,33 @@ private function roundOperators(Collection $collection, Document $document): voi $values = $operator->getValues(); if (\array_key_exists(0, $values)) { - $values[0] = Rounding::HalfAwayFromZero->round($values[0]); + $amount = Rounding::HalfAwayFromZero->round($values[0]); + if ($amount === 0 && $amount !== $values[0]) { + $unchanged[] = $attribute->key; + } + $values[0] = $amount; } if (isset($values[1])) { $values[1] = $limitRounding->round($values[1]); } $document->setAttribute($attribute->key, (clone $operator)->setValues($values)); } + + return $unchanged; + } + + /** + * @param list $keys + */ + private static function keepStored(Document $document, Document $stored, array $keys): void + { + foreach ($keys as $key) { + if ($stored->offsetExists($key)) { + $document->setAttribute($key, $stored->getAttribute($key)); + } else { + $document->removeAttribute($key); + } + } } /** @@ -1678,7 +1702,7 @@ public function updateDocument(string $collection, string $id, Document $documen // for a write that leaves the stored document identical. $document = $this->removeUnknownAttributes($collection, $document); - $this->roundOperators($collection, $document); + self::keepStored($document, $old, $this->roundOperators($collection, $document)); $shouldUpdate = false; @@ -1960,6 +1984,14 @@ public function updateDocuments( $collection = $this->silent(fn () => $this->getCollection($collection)); + $unchanged = $this->roundOperators($collection, $updates); + if (\count($unchanged) === \count($updates)) { + return 0; + } + foreach ($unchanged as $key) { + $updates->removeAttribute($key); + } + if ($this->checksGrants() && $this->relationshipHook?->isEnabled() && self::holdsRelatedDocument($collection, $updates)) { throw new RelationshipException('Invalid relationship value. A bulk update takes related documents by ID while grants are checked.'); } @@ -2005,8 +2037,6 @@ public function updateDocuments( $updatedAt = $updates->getUpdatedAt(); $updates[Document::UPDATED_AT] = ($updatedAt === null || ! $this->datePreservation()->get()) ? DateTime::now() : $updatedAt; - $this->roundOperators($collection, $updates); - $decodedUpdates = clone $updates; $updates = $this->encode( $collection, @@ -2322,7 +2352,10 @@ private function upsert( $old = $existing[$this->upsertKey($document)] ?? new Document(); $document = $this->removeUnknownAttributes($collection, $document); - $this->roundOperators($collection, $document); + $unchanged = $this->roundOperators($collection, $document); + if (! $old->isEmpty()) { + self::keepStored($document, $old, $unchanged); + } // Extract operators early to avoid comparison issues $documentArray = $document->getArrayCopy(); From 3a28e0d0d60d111c92542c45cef9f408370dc14b Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 14 Oct 2026 19:07:21 +1300 Subject: [PATCH 14/18] test(documents): cover zero rounding, decimal text and integer arrays Adds the cases the rounding review found missing: changes that round to nothing on every write path with their events and $updatedAt, plain decimal text with signs, whitespace and a bare point, exponent text, text operator amounts and fractional integer-array items. The adapters move to an Engine enum and the e2e closure to a typed helper, which clears the PHPStan match.unhandled and argument.type errors. Co-Authored-By: Claude Opus 5.5 --- .../Adapter/Scopes/IntegerRoundingTests.php | 32 +- tests/unit/Documents/IntegerRoundingTest.php | 273 +++++++++++++----- tests/unit/Support/Engine.php | 34 +++ 3 files changed, 256 insertions(+), 83 deletions(-) create mode 100644 tests/unit/Support/Engine.php diff --git a/tests/e2e/Adapter/Scopes/IntegerRoundingTests.php b/tests/e2e/Adapter/Scopes/IntegerRoundingTests.php index 5beac52e7..62c2208e9 100644 --- a/tests/e2e/Adapter/Scopes/IntegerRoundingTests.php +++ b/tests/e2e/Adapter/Scopes/IntegerRoundingTests.php @@ -72,19 +72,21 @@ public function testIntegerRoundingOfOperatorsInUpdates(): void $collection = $this->roundingCollection($database, 'rounding_operators'); try { - $update = fn (array $changes): Document => $database->updateDocument($collection, 'counter', new Document($changes)); - - $this->assertSame(102, $update(['count' => Operator::increment(1.5)])->getAttribute('count')); - $this->assertSame(99, $update(['count' => Operator::decrement('2.5')])->getAttribute('count')); - $this->assertSame(96, $update(['count' => Operator::increment(-2.5)])->getAttribute('count')); - $this->assertSame(98, $update(['count' => Operator::increment(2, 98.7)])->getAttribute('count')); - $this->assertSame(98, $update(['count' => Operator::increment(1, 98.7)])->getAttribute('count')); - $this->assertSame(98, $update(['count' => Operator::decrement(4, '94.2')])->getAttribute('count')); - $this->assertSame(95, $update(['count' => Operator::decrement(3, '94.2')])->getAttribute('count')); - $this->assertSame(9007199254740994, $update(['big' => Operator::increment('9007199254740993.5')])->getAttribute('big')); - $this->assertSame(2.0, $update(['ratio' => Operator::increment(0.5)])->getAttribute('ratio')); + $this->assertSame(102, $this->updateRoundingCounter($database, $collection, ['count' => Operator::increment(1.5)])->getAttribute('count')); + $this->assertSame(99, $this->updateRoundingCounter($database, $collection, ['count' => Operator::decrement('2.5')])->getAttribute('count')); + $this->assertSame(96, $this->updateRoundingCounter($database, $collection, ['count' => Operator::increment(-2.5)])->getAttribute('count')); + $updatedAt = $database->getDocument($collection, 'counter')->getUpdatedAt(); + $this->assertSame(96, $this->updateRoundingCounter($database, $collection, ['count' => Operator::increment(0.4)])->getAttribute('count')); + $this->assertSame($updatedAt, $database->getDocument($collection, 'counter')->getUpdatedAt()); + $this->assertSame(98, $this->updateRoundingCounter($database, $collection, ['count' => Operator::increment(2, 98.7)])->getAttribute('count')); + $this->assertSame(98, $this->updateRoundingCounter($database, $collection, ['count' => Operator::increment(1, 98.7)])->getAttribute('count')); + $this->assertSame(98, $this->updateRoundingCounter($database, $collection, ['count' => Operator::decrement(4, '94.2')])->getAttribute('count')); + $this->assertSame(95, $this->updateRoundingCounter($database, $collection, ['count' => Operator::decrement(3, '94.2')])->getAttribute('count')); + $this->assertSame(9007199254740994, $this->updateRoundingCounter($database, $collection, ['big' => Operator::increment('9007199254740993.5')])->getAttribute('big')); + $this->assertSame(2.0, $this->updateRoundingCounter($database, $collection, ['ratio' => Operator::increment(0.5)])->getAttribute('ratio')); $database->createDocument($collection, new Document([Document::ID => 'second', 'count' => 10, 'big' => 0, 'ratio' => 0.0])); + $this->assertSame(0, $database->updateDocuments($collection, new Document(['count' => Operator::increment(0.4)]))); $this->assertSame(2, $database->updateDocuments($collection, new Document(['count' => Operator::increment(2.5)]))); $this->assertSame(98, $database->getDocument($collection, 'counter')->getAttribute('count')); $this->assertSame(13, $database->getDocument($collection, 'second')->getAttribute('count')); @@ -180,6 +182,14 @@ private function roundingCollection(Database $database, string $id): string return $id; } + /** + * @param array $changes + */ + private function updateRoundingCounter(Database $database, string $collection, array $changes): Document + { + return $database->updateDocument($collection, 'counter', new Document($changes)); + } + private function assertRoundingLimited(string $message, callable $change): void { try { diff --git a/tests/unit/Documents/IntegerRoundingTest.php b/tests/unit/Documents/IntegerRoundingTest.php index efcea197e..9eff6670a 100644 --- a/tests/unit/Documents/IntegerRoundingTest.php +++ b/tests/unit/Documents/IntegerRoundingTest.php @@ -2,19 +2,17 @@ namespace Tests\Unit\Documents; -use PDO; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Tests\Unit\Event\RecordingLifecycle; +use Tests\Unit\Support\Engine; use Utopia\Cache\Adapter\None; use Utopia\Cache\Cache; -use Utopia\Database\Adapter; -use Utopia\Database\Adapter\Memory; -use Utopia\Database\Adapter\SQLite; use Utopia\Database\Attribute; -use Utopia\Database\Capability; use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Event; use Utopia\Database\Exception\Limit as LimitException; use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Exception\Type as TypeException; @@ -29,26 +27,20 @@ final class IntegerRoundingTest extends TestCase private const string DOCUMENT = 'counter'; - private const string MEMORY = 'memory'; - - private const string SCHEMALESS = 'schemaless memory'; - - private const string SQLITE = 'sqlite'; - /** - * @return iterable + * @return iterable */ - public static function adapters(): iterable + public static function engines(): iterable { - yield self::MEMORY => [self::MEMORY]; - yield self::SCHEMALESS => [self::SCHEMALESS]; - yield self::SQLITE => [self::SQLITE]; + foreach (Engine::cases() as $engine) { + yield $engine->value => [$engine]; + } } - #[DataProvider('adapters')] - public function testAnAmountIsRoundedHalfAwayFromZero(string $adapter): void + #[DataProvider('engines')] + public function testAnAmountIsRoundedHalfAwayFromZero(Engine $engine): void { - $database = $this->database($adapter); + $database = $this->database($engine); $this->assertSame(102, $this->increase($database, 'count', 1.5)->getAttribute('count')); $this->assertSame(105, $this->increase($database, 'count', 2.5)->getAttribute('count')); @@ -61,10 +53,10 @@ public function testAnAmountIsRoundedHalfAwayFromZero(string $adapter): void $this->assertSame(103, $this->stored($database, 'count')); } - #[DataProvider('adapters')] - public function testAnAmountBelowAHalfLeavesTheDocumentUnchanged(string $adapter): void + #[DataProvider('engines')] + public function testAnAmountBelowAHalfLeavesTheDocumentUnchanged(Engine $engine): void { - $database = $this->database($adapter); + $database = $this->database($engine); $updatedAt = $database->getDocument(self::COLLECTION, self::DOCUMENT)->getUpdatedAt(); $this->assertSame(100, $this->increase($database, 'count', 0.4)->getAttribute('count')); @@ -76,10 +68,10 @@ public function testAnAmountBelowAHalfLeavesTheDocumentUnchanged(string $adapter $this->assertSame($updatedAt, $stored->getUpdatedAt()); } - #[DataProvider('adapters')] - public function testBigIntegerTextIsRoundedExactly(string $adapter): void + #[DataProvider('engines')] + public function testBigIntegerTextIsRoundedExactly(Engine $engine): void { - $database = $this->database($adapter); + $database = $this->database($engine); $this->assertSame(9007199254740994, $this->increase($database, 'big', '9007199254740993.5')->getAttribute('big')); $this->assertSame(9007199254740995, $this->increase($database, 'big', 1, '9007199254740995.9')->getAttribute('big')); @@ -92,10 +84,10 @@ public function testBigIntegerTextIsRoundedExactly(string $adapter): void $this->assertSame(\PHP_INT_MAX, $this->stored($database, 'big')); } - #[DataProvider('adapters')] - public function testAMaximumIsFlooredAndAMinimumCeiled(string $adapter): void + #[DataProvider('engines')] + public function testAMaximumIsFlooredAndAMinimumCeiled(Engine $engine): void { - $database = $this->database($adapter); + $database = $this->database($engine); $this->assertSame(101, $this->increase($database, 'count', 1, 101.9)->getAttribute('count')); $this->assertLimited('Attribute value exceeds maximum limit: 101', fn () => $this->increase($database, 'count', 1, 101.9)); @@ -111,10 +103,10 @@ public function testAMaximumIsFlooredAndAMinimumCeiled(string $adapter): void $this->assertSame(0, $this->stored($database, 'count')); } - #[DataProvider('adapters')] - public function testWholeBoundsAreComparedExactly(string $adapter): void + #[DataProvider('engines')] + public function testWholeBoundsAreComparedExactly(Engine $engine): void { - $database = $this->database($adapter); + $database = $this->database($engine); $this->assertSame(101, $this->increase($database, 'count', 1, 101.0)->getAttribute('count')); $this->assertLimited('Attribute value exceeds maximum limit: 101', fn () => $this->increase($database, 'count', 1, '101.00')); @@ -125,10 +117,10 @@ public function testWholeBoundsAreComparedExactly(string $adapter): void $this->assertSame(101, $this->stored($database, 'count')); } - #[DataProvider('adapters')] - public function testWhatIsNotAFiniteNumberIsStillRefused(string $adapter): void + #[DataProvider('engines')] + public function testWhatIsNotAFiniteNumberIsStillRefused(Engine $engine): void { - $database = $this->database($adapter); + $database = $this->database($engine); $this->assertRefused('Max must be an integer.', fn () => $this->increase($database, 'count', 1, 'ten')); $this->assertRefused('Min must be an integer.', fn () => $this->decrease($database, 'count', 1, \NAN)); @@ -136,10 +128,153 @@ public function testWhatIsNotAFiniteNumberIsStillRefused(string $adapter): void $this->assertSame(100, $this->stored($database, 'count')); } - #[DataProvider('adapters')] - public function testUpdateDocumentRoundsIncrementAndDecrementOperators(string $adapter): void + #[DataProvider('engines')] + public function testPlainDecimalTextIsRounded(Engine $engine): void + { + $database = $this->database($engine); + + $this->assertSame(102, $this->increase($database, 'count', ' 1.5')->getAttribute('count')); + $this->assertSame(104, $this->increase($database, 'count', '1.5 ')->getAttribute('count')); + $this->assertSame(106, $this->increase($database, 'count', '+1.5')->getAttribute('count')); + $this->assertSame(107, $this->increase($database, 'count', '.5')->getAttribute('count')); + $this->assertSame(108, $this->increase($database, 'count', '1.')->getAttribute('count')); + $this->assertSame(107, $this->decrease($database, 'count', "\t.5\n")->getAttribute('count')); + $this->assertLimited('Attribute value exceeds maximum limit: 107', fn () => $this->increase($database, 'count', 1, ' 107.9 ')); + $this->assertSame(106, $this->decrease($database, 'count', 1, '-.5')->getAttribute('count')); + $this->assertSame(107, $this->update($database, ['count' => Operator::increment('+.5', ' 107.9')])->getAttribute('count')); + $this->assertSame(107, $this->stored($database, 'count')); + } + + #[DataProvider('engines')] + public function testTextWithAnExponentIsRefused(Engine $engine): void + { + $database = $this->database($engine); + + $this->assertRefused('Change value must be an integer.', fn () => $this->increase($database, 'count', '1.5e2')); + $this->assertRefused('Change value must be an integer.', fn () => $this->decrease($database, 'count', '15e-1')); + $this->assertRefused('Max must be an integer.', fn () => $this->increase($database, 'count', 1, '1.5e2')); + $this->assertSame(100, $this->stored($database, 'count')); + } + + #[DataProvider('engines')] + public function testAnOperatorAmountGivenAsTextIsRounded(Engine $engine): void + { + $database = $this->database($engine); + + $this->assertSame(99, $this->update($database, ['count' => Operator::increment('-0.5')])->getAttribute('count')); + $this->assertSame(100, $this->update($database, ['count' => Operator::decrement('-0.5')])->getAttribute('count')); + $this->assertSame(99, $this->update($database, ['count' => Operator::decrement(' .5')])->getAttribute('count')); + $this->assertSame(99, $this->stored($database, 'count')); + } + + #[DataProvider('engines')] + public function testACounterChangeThatRoundsToNothingFiresNoEvent(Engine $engine): void + { + $database = $this->database($engine); + $updatedAt = $database->getDocument(self::COLLECTION, self::DOCUMENT)->getUpdatedAt(); + $recorder = new RecordingLifecycle(); + $database->addHook($recorder); + + $this->increase($database, 'count', 0.4); + $this->decrease($database, 'count', '0.49'); + + $this->assertSame([], $recorder->received(Event::DocumentIncrease)); + $this->assertSame([], $recorder->received(Event::DocumentDecrease)); + $this->assertSame([], $recorder->received(Event::DocumentUpdate)); + $this->assertSame($updatedAt, $database->getDocument(self::COLLECTION, self::DOCUMENT)->getUpdatedAt()); + } + + #[DataProvider('engines')] + public function testAnOperatorThatRoundsToNothingIsNoChange(Engine $engine): void + { + $database = $this->database($engine); + $updatedAt = $database->getDocument(self::COLLECTION, self::DOCUMENT)->getUpdatedAt(); + + $this->assertSame(100, $this->update($database, ['count' => Operator::increment(0.4)])->getAttribute('count')); + $this->assertSame(100, $this->update($database, ['count' => Operator::decrement('-0.4', 100.5)])->getAttribute('count')); + $this->assertSame($updatedAt, $database->getDocument(self::COLLECTION, self::DOCUMENT)->getUpdatedAt()); + + $sameValue = new RecordingLifecycle(); + $database->addHook($sameValue); + $this->update($database, ['count' => 100]); + + $roundedToNothing = new RecordingLifecycle(); + $database->addHook($roundedToNothing); + $this->update($database, ['count' => Operator::increment(0.4)]); + + $this->assertSame(\array_slice($sameValue->getEvents(), 0, \count($roundedToNothing->getEvents())), $roundedToNothing->getEvents()); + $this->assertSame($updatedAt, $database->getDocument(self::COLLECTION, self::DOCUMENT)->getUpdatedAt()); + } + + #[DataProvider('engines')] + public function testUpdateDocumentsWithOnlyAnOperatorThatRoundsToNothingUpdatesNothing(Engine $engine): void + { + $database = $this->database($engine); + $recorder = new RecordingLifecycle(); + $database->addHook($recorder); + + $this->assertSame(0, $database->updateDocuments(self::COLLECTION, new Document(['count' => Operator::increment(0.4)]))); + $this->assertSame(1, $database->updateDocuments(self::COLLECTION, new Document(['count' => Operator::decrement(0.4), 'big' => 7]))); + + $this->assertCount(1, $recorder->received(Event::DocumentsUpdate)); + $this->assertSame(100, $this->stored($database, 'count')); + $this->assertSame(7, $this->stored($database, 'big')); + } + + public function testAnUpsertWithAnOperatorThatRoundsToNothingIsNoChange(): void + { + $database = $this->database(Engine::SQLite); + $updatedAt = $database->getDocument(self::COLLECTION, self::DOCUMENT)->getUpdatedAt(); + $recorder = new RecordingLifecycle(); + $database->addHook($recorder); + + $upserted = $database->upsertDocument(self::COLLECTION, new Document([ + Document::ID => self::DOCUMENT, + 'count' => Operator::increment(0.4), + 'big' => 0, + 'ratio' => 1.5, + 'counts' => [1], + ])); + $count = $database->upsertDocuments(self::COLLECTION, [new Document([ + Document::ID => self::DOCUMENT, + 'count' => Operator::decrement(0.4, 100.5), + 'big' => 0, + 'ratio' => 1.5, + 'counts' => [1], + ])]); + + $this->assertSame(100, $upserted->getAttribute('count')); + $this->assertSame(0, $count); + $this->assertSame([], $recorder->received(Event::DocumentUpsert)); + $this->assertSame($updatedAt, $database->getDocument(self::COLLECTION, self::DOCUMENT)->getUpdatedAt()); + } + + public function testAnUpsertThatCreatesADocumentTakesAnAmountThatRoundsToNothingAsZero(): void { - $database = $this->database($adapter); + $database = $this->database(Engine::SQLite); + + $database->upsertDocuments(self::COLLECTION, [ + new Document([Document::ID => 'rounded', 'count' => Operator::increment(0.4)]), + new Document([Document::ID => 'zero', 'count' => Operator::increment(0)]), + ]); + + $this->assertSame($this->stored($database, 'count', 'zero'), $this->stored($database, 'count', 'rounded')); + } + + #[DataProvider('engines')] + public function testFractionalItemsAddedToAnIntegerArrayAreRefused(Engine $engine): void + { + $database = $this->database($engine); + + $this->assertStructureRefused(fn () => $this->update($database, ['counts' => [1, 2.5]])); + $this->assertStructureRefused(fn () => $this->update($database, ['counts' => Operator::arrayAppend([2.5])])); + $this->assertSame([1], $this->stored($database, 'counts')); + } + + #[DataProvider('engines')] + public function testUpdateDocumentRoundsIncrementAndDecrementOperators(Engine $engine): void + { + $database = $this->database($engine); $this->assertSame(102, $this->update($database, ['count' => Operator::increment(1.5)])->getAttribute('count')); $this->assertSame(99, $this->update($database, ['count' => Operator::decrement('2.5')])->getAttribute('count')); @@ -154,10 +289,10 @@ public function testUpdateDocumentRoundsIncrementAndDecrementOperators(string $a $this->assertSame(9007199254740994, $this->stored($database, 'big')); } - #[DataProvider('adapters')] - public function testUpdateDocumentsRoundsIncrementAndDecrementOperators(string $adapter): void + #[DataProvider('engines')] + public function testUpdateDocumentsRoundsIncrementAndDecrementOperators(Engine $engine): void { - $database = $this->database($adapter); + $database = $this->database($engine); $database->createDocument(self::COLLECTION, new Document([Document::ID => 'second', 'count' => 10, 'big' => 0, 'ratio' => 0.0])); $this->assertSame(2, $database->updateDocuments(self::COLLECTION, new Document(['count' => Operator::increment(2.5)]))); @@ -175,7 +310,7 @@ public function testUpdateDocumentsRoundsIncrementAndDecrementOperators(string $ public function testUpsertsRoundIncrementAndDecrementOperators(): void { - $database = $this->database(self::SQLITE); + $database = $this->database(Engine::SQLite); $database->createDocument(self::COLLECTION, new Document([Document::ID => 'second', 'count' => 10, 'big' => 0, 'ratio' => 0.0])); $upserted = $database->upsertDocument(self::COLLECTION, new Document([Document::ID => self::DOCUMENT, 'count' => Operator::increment(1.5)])); @@ -191,22 +326,22 @@ public function testUpsertsRoundIncrementAndDecrementOperators(): void } /** - * @return iterable + * @return iterable */ public static function refusedOperators(): iterable { - foreach ([self::MEMORY, self::SQLITE] as $adapter) { - yield "{$adapter} multiply" => [$adapter, Operator::multiply(1.5), 'multiply']; - yield "{$adapter} divide" => [$adapter, Operator::divide(2.5), 'divide']; - yield "{$adapter} modulo" => [$adapter, Operator::modulo(1.5), 'modulo']; - yield "{$adapter} power" => [$adapter, Operator::power(0.5), 'power']; + foreach ([Engine::Memory, Engine::SQLite] as $engine) { + yield "{$engine->value} multiply" => [$engine, Operator::multiply(1.5), 'multiply']; + yield "{$engine->value} divide" => [$engine, Operator::divide(2.5), 'divide']; + yield "{$engine->value} modulo" => [$engine, Operator::modulo(1.5), 'modulo']; + yield "{$engine->value} power" => [$engine, Operator::power(0.5), 'power']; } } #[DataProvider('refusedOperators')] - public function testMultiplyDivideModuloOrPowerByAFractionOnAnIntegerIsRefused(string $adapter, Operator $operator, string $method): void + public function testMultiplyDivideModuloOrPowerByAFractionOnAnIntegerIsRefused(Engine $engine, Operator $operator, string $method): void { - $database = $this->database($adapter); + $database = $this->database($engine); try { $this->update($database, ['count' => $operator]); @@ -218,10 +353,10 @@ public function testMultiplyDivideModuloOrPowerByAFractionOnAnIntegerIsRefused(s $this->assertSame(100, $this->stored($database, 'count')); } - #[DataProvider('adapters')] - public function testFloatAttributesAreNotRounded(string $adapter): void + #[DataProvider('engines')] + public function testFloatAttributesAreNotRounded(Engine $engine): void { - $database = $this->database($adapter); + $database = $this->database($engine); $this->assertSame(2.75, $this->increase($database, 'ratio', 1.25)->getAttribute('ratio')); $this->assertSame(2.25, $this->decrease($database, 'ratio', 0.5, 2.25)->getAttribute('ratio')); @@ -233,12 +368,12 @@ public function testFloatAttributesAreNotRounded(string $adapter): void $this->assertSame(1.5, $this->stored($database, 'ratio')); } - private function database(string $adapter): Database + private function database(Engine $engine): Database { $authorization = new Authorization(); $authorization->addRole(Role::any()->toString()); - $database = (new Database(self::adapter($adapter), new Cache(new None()))) + $database = (new Database($engine->adapter(), new Cache(new None()))) ->setAuthorization($authorization) ->setDatabase('integer_rounding') ->setNamespace('integer_rounding_'.\uniqid()); @@ -249,6 +384,7 @@ private function database(string $adapter): Database Attribute::integer(key: 'count'), Attribute::bigInteger(key: 'big'), Attribute::double(key: 'ratio'), + Attribute::integer(key: 'counts', array: true), ], permissions: [ Permission::create(Role::any()), @@ -262,29 +398,12 @@ private function database(string $adapter): Database 'count' => 100, 'big' => 0, 'ratio' => 1.5, + 'counts' => [1], ])); return $database; } - private static function adapter(string $adapter): Adapter - { - return match ($adapter) { - self::MEMORY => new Memory(), - self::SCHEMALESS => new class () extends Memory { - #[\Override] - public function capabilities(): array - { - return \array_values(\array_filter( - parent::capabilities(), - static fn (Capability $capability): bool => $capability !== Capability::DefinedAttributes, - )); - } - }, - self::SQLITE => new SQLite(new PDO('sqlite::memory:')), - }; - } - private function increase(Database $database, string $attribute, int|float|string $value, int|float|string|null $max = null): Document { return $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, $attribute, $value, $max); @@ -318,6 +437,16 @@ private function assertLimited(string $message, callable $change): void } } + private function assertStructureRefused(callable $change): void + { + try { + $change(); + $this->fail('The change was not refused'); + } catch (StructureException $error) { + $this->assertStringStartsWith('Invalid document structure: ', $error->getMessage()); + } + } + private function assertRefused(string $message, callable $change): void { try { diff --git a/tests/unit/Support/Engine.php b/tests/unit/Support/Engine.php new file mode 100644 index 000000000..2d48cf221 --- /dev/null +++ b/tests/unit/Support/Engine.php @@ -0,0 +1,34 @@ + new Memory(), + self::SchemalessMemory => new class () extends Memory { + #[\Override] + public function capabilities(): array + { + return \array_values(\array_filter( + parent::capabilities(), + static fn (Capability $capability): bool => $capability !== Capability::DefinedAttributes, + )); + } + }, + self::SQLite => new SQLite(new PDO('sqlite::memory:')), + }; + } +} From 2970af0a3fb086ca22ea8ef46da094294dd86c15 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 14 Oct 2026 19:07:21 +1300 Subject: [PATCH 15/18] docs: describe zero rounding, decimal text and the rounding opt-in losses States that a change rounding to 0 skips the max/min check and what each write path does with it, widens the decimal text description to match the parser, qualifies "still refused" as true only without setRoundFractions(true), lists the two behaviours that opt-in users lose, and notes the ReadWritePool hostname fix. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 17 ++++++++++------- UPGRADE.md | 29 ++++++++++++++++++++--------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a4a231f9..5dbe6d0f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,12 +50,15 @@ namespace of the model they update. Every entry under Removed and Changed (break fractional `min`; `Operator::increment()` and `Operator::decrement()` given to `updateDocument()`, `updateDocuments()`, `upsertDocument()` and `upsertDocuments()` round their amount and limit the same way, so `increment(-2.5)` subtracts 3. Rounding happens once, in PHP, before validation and on every adapter alike. Decimal - text such as `'9007199254740993.5'` is rounded exactly at any size; text with an exponent is still refused. A - counter change that rounds to 0, such as 0.4, leaves the document unchanged: it writes nothing and fires no event. - Before, these were refused, unless `setRoundFractions(true)` stored what the adapter's engine stored on 7.4.1. -- Still refused with `Exception\Structure`, as in 8.0.3: a fractional amount or limit of `multiply()`, `divide()`, - `modulo()` or `power()` on an integer attribute, and a fractional item added to an integer array. Float and double - attributes are unchanged. + text, which is what `is_numeric()` accepts without an exponent, such as `'9007199254740993.5'`, `' +.5'` or `'1.'`, + is rounded exactly at any size; text with an exponent is refused. A change that rounds to 0, such as 0.4, changes + nothing and skips the `max` or `min` check: a counter writes nothing and fires no event, and an operator is dropped + from the update, so `updateDocuments()` with nothing else to change returns 0. Before, these were refused, unless + `setRoundFractions(true)` stored what the adapter's engine stored on 7.4.1. +- Refused with `Exception\Structure`, as in 8.0.3 and in 8.1.1 without `setRoundFractions(true)`: a fractional amount + or limit of `multiply()`, `divide()`, `modulo()` or `power()` on an integer attribute, and a fractional item added to + an integer array. `setRoundFractions(true)` resolved the operators and cut the array items toward zero. Float and + double attributes are unchanged. ### Added @@ -65,7 +68,7 @@ namespace of the model they update. Every entry under Removed and Changed (break - `Adapter\Pool::hostname()` returns the hostname given to `setHostname()` when it is not empty, and asks the pooled adapter only otherwise. 8.1.1 ignored it, so `Database::getHostname()` and the cache keys built from it named the - pooled adapter's host instead. + pooled adapter's host instead. `ReadWritePool` also follows a hostname set after its first answer. ## 8.1.1 diff --git a/UPGRADE.md b/UPGRADE.md index 0bf8cc235..4ce9516ee 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -76,16 +76,23 @@ On a single-valued integer or big integer attribute: | A `min`, or the limit of `decrement()` | Ceiled | 99.1 is 100 | The operators are rounded in `updateDocument()`, `updateDocuments()`, `upsertDocument()` and `upsertDocuments()`. -Rounding happens once, in PHP, before validation, so every adapter stores the same result. Decimal text is rounded -exactly at any size: `'9007199254740993.5'` adds 9007199254740994. Text with an exponent, such as `'1.5e2'`, and -values that are not finite are still refused as in 8.1.1. +Rounding happens once, in PHP, before validation, so every adapter stores the same result. Decimal text is any text +`is_numeric()` accepts without an exponent: an optional `+` or `-`, digits before or after the point or both, and +surrounding whitespace, such as `' +.5'` or `'1.'`. It is rounded exactly at any size: `'9007199254740993.5'` adds +9007199254740994. Text with an exponent, such as `'1.5e2'`, and values that are not finite are refused. -A counter change that rounds to 0, such as 0.4, leaves the document unchanged: it writes nothing, keeps `$updatedAt` -and fires no event. An operator amount that rounds to 0 changes nothing either. +A change that rounds to 0, such as 0.4, changes nothing, and its `max` or `min` is not checked: -Still refused with `Exception\Structure`, as by default in 8.1.1: a fractional amount or limit of `multiply()`, -`divide()`, `modulo()` or `power()` on an integer attribute, and a fractional item added to an integer array. Float and -double attributes are unchanged. +- `increaseDocumentAttribute()` and `decreaseDocumentAttribute()` write nothing, keep `$updatedAt` and fire no event. +- An `increment()` or `decrement()` is dropped from the update. `updateDocument()` treats the attribute as given its + stored value, so with nothing else changed it keeps `$updatedAt`, as for any update that changes nothing. + `updateDocuments()` given nothing else returns 0 and fires no event. An upsert of a stored document counts it as no + change; an upsert that creates the document stores what `increment(0)` or `decrement(0)` would. +- An amount given as the integer 0 is not rounded and behaves as in 8.1.1. + +Refused with `Exception\Structure`, as in 8.1.1 without `setRoundFractions(true)`: a fractional amount or limit of +`multiply()`, `divide()`, `modulo()` or `power()` on an integer attribute, and a fractional item added to an integer +array. Float and double attributes are unchanged. If you relied on fractions being refused, check the value before you call. If you used `setRoundFractions(true)`: @@ -95,11 +102,15 @@ If you relied on fractions being refused, check the value before you call. If yo - A fractional `max` or `min` is floored or ceiled before the comparison, instead of being compared as given. - The stored value is no longer read locked to resolve an operator, and `updateDocuments()` sends one update for all rows again. +- A fractional item added to an integer array is refused with `Exception\Structure` instead of being cut toward zero. +- A fractional amount or limit of `multiply()`, `divide()`, `modulo()` or `power()` on an integer attribute is refused + with `Exception\Structure` instead of being resolved against the stored value. ## Hostname of a pool - `Pool::hostname()` returns the hostname given to `Pool::setHostname()` when it is not empty. Otherwise it asks the - pooled adapter, as 8.1.1 always did. `Database::getHostname()` and the document and query cache keys follow it, so a + pooled adapter, as 8.1.1 always did. A `ReadWritePool` follows a later `setHostname()` too, instead of keeping the + hostname it answered first. `Database::getHostname()` and the document and query cache keys follow it, so a pool given a hostname reads and writes cache entries under that hostname from 8.1.2 on. - `Pool::getPool(): Utopia\Pools\Pool` returns the pool of adapters the handle borrows from. A subclass that exposed the protected `$pool` property, or overrode the hostname, for either purpose can be removed. From b3279a1430b2cd44bf881d0c2397e0a863fdf59e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 14 Oct 2026 19:29:58 +1300 Subject: [PATCH 16/18] fix(documents): drop zero-rounded operators from the caller's updates updateDocuments returned 0 for updates whose only change rounded to nothing but left the rounded increment(0) in the caller's document. The same updates given again then counted as a change and bumped $updatedAt, and Mirror, which replicates a clone of those updates, wrote that change to the destination while the source wrote nothing. The rounded keys are now removed before the empty check, so the caller's document carries no change and replication has nothing to apply. Co-Authored-By: Claude Opus 5.5 --- src/Database/Trait/Documents.php | 9 ++++----- tests/unit/Documents/IntegerRoundingTest.php | 14 ++++++++++++++ tests/unit/MirrorTest.php | 18 ++++++++++++++++++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/Database/Trait/Documents.php b/src/Database/Trait/Documents.php index feb9d8d0f..22284eb8b 100644 --- a/src/Database/Trait/Documents.php +++ b/src/Database/Trait/Documents.php @@ -1984,13 +1984,12 @@ public function updateDocuments( $collection = $this->silent(fn () => $this->getCollection($collection)); - $unchanged = $this->roundOperators($collection, $updates); - if (\count($unchanged) === \count($updates)) { - return 0; - } - foreach ($unchanged as $key) { + foreach ($this->roundOperators($collection, $updates) as $key) { $updates->removeAttribute($key); } + if ($updates->isEmpty()) { + return 0; + } if ($this->checksGrants() && $this->relationshipHook?->isEnabled() && self::holdsRelatedDocument($collection, $updates)) { throw new RelationshipException('Invalid relationship value. A bulk update takes related documents by ID while grants are checked.'); diff --git a/tests/unit/Documents/IntegerRoundingTest.php b/tests/unit/Documents/IntegerRoundingTest.php index 9eff6670a..c427f57bc 100644 --- a/tests/unit/Documents/IntegerRoundingTest.php +++ b/tests/unit/Documents/IntegerRoundingTest.php @@ -221,6 +221,20 @@ public function testUpdateDocumentsWithOnlyAnOperatorThatRoundsToNothingUpdatesN $this->assertSame(7, $this->stored($database, 'big')); } + #[DataProvider('engines')] + public function testUpdatesWithAnOperatorThatRoundsToNothingUpdateNothingWhenGivenAgain(Engine $engine): void + { + $database = $this->database($engine); + $updatedAt = $database->getDocument(self::COLLECTION, self::DOCUMENT)->getUpdatedAt(); + $updates = new Document(['count' => Operator::increment(0.4)]); + \usleep(2000); + + $this->assertSame(0, $database->updateDocuments(self::COLLECTION, $updates)); + $this->assertSame(0, $database->updateDocuments(self::COLLECTION, $updates)); + + $this->assertSame($updatedAt, $database->getDocument(self::COLLECTION, self::DOCUMENT)->getUpdatedAt()); + } + public function testAnUpsertWithAnOperatorThatRoundsToNothingIsNoChange(): void { $database = $this->database(Engine::SQLite); diff --git a/tests/unit/MirrorTest.php b/tests/unit/MirrorTest.php index bd67e6a51..0206e3479 100644 --- a/tests/unit/MirrorTest.php +++ b/tests/unit/MirrorTest.php @@ -40,6 +40,7 @@ use Utopia\Database\Mirror; use Utopia\Database\Mirror\Failure; use Utopia\Database\Mirror\Filter; +use Utopia\Database\Operator; use Utopia\Database\Permission; use Utopia\Database\Query; use Utopia\Database\Role; @@ -248,6 +249,23 @@ public function testUpdateThroughMirrorInvalidatesItsQueryCache(): void $this->assertSame('updated', $this->title($mirror)); } + public function testUpdatesWithAnOperatorThatRoundsToNothingAreNotReplicated(): void + { + [$mirror, $source, $destination] = $this->pair(); + $this->seed($mirror); + $recorder = new RecordingLifecycle(); + $destination->addHook($recorder); + \usleep(2000); + + $this->assertSame(0, $mirror->updateDocuments(self::COLLECTION, new Document(['views' => Operator::increment(0.4)]))); + + $this->assertSame([], $recorder->received(Event::DocumentsUpdate)); + $this->assertSame( + $source->getDocument(self::COLLECTION, 'first')->getUpdatedAt(), + $destination->getDocument(self::COLLECTION, 'first')->getUpdatedAt(), + ); + } + public function testAnEventThroughTheMirrorInvalidatesTheMirrorQueryCacheAndIsDispatchedOnce(): void { $source = new Database(new Memory(), new Cache(new None())); From 07260288245a377d3bf4b250ef64cfa1570206ec Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 14 Oct 2026 19:33:55 +1300 Subject: [PATCH 17/18] perf(big-int): add and subtract long integer strings in linear time Rounding decimal text adds one to its whole part with BigInt::add, whose string helpers built the result by prepending a digit at a time, so the work grew with the square of the length: a million-digit amount took about 14 seconds before the range check refused it. The helpers now pad both numbers to whole 18-digit chunks, work chunk by chunk in native integers and join the reversed chunks once, which keeps results exact without capping the length; the same amount is refused in milliseconds. Co-Authored-By: Claude Opus 5.5 --- src/Database/Validator/BigInt.php | 60 ++++++++++++-------- tests/unit/Documents/IntegerRoundingTest.php | 12 ++++ tests/unit/Validator/BigIntTest.php | 18 ++++++ 3 files changed, 66 insertions(+), 24 deletions(-) diff --git a/src/Database/Validator/BigInt.php b/src/Database/Validator/BigInt.php index c6b33719f..d7f31535f 100644 --- a/src/Database/Validator/BigInt.php +++ b/src/Database/Validator/BigInt.php @@ -12,6 +12,10 @@ class BigInt extends Validator public const string SIGNED_MAX = '9223372036854775807'; public const string UNSIGNED_MAX = '18446744073709551615'; + private const int CHUNK_DIGITS = 18; + + private const int CHUNK_BASE = 10 ** self::CHUNK_DIGITS; + public function __construct( private readonly bool $signed, private readonly bool $supportUnsigned64Bit = true @@ -393,41 +397,49 @@ public static function isIntegerValue(mixed $value): bool private static function addUnsignedStrings(string $a, string $b): string { - $aIndex = \strlen($a) - 1; - $bIndex = \strlen($b) - 1; + [$a, $b] = self::alignToChunks($a, $b); + $chunks = []; $carry = 0; - $result = ''; - while ($aIndex >= 0 || $bIndex >= 0 || $carry > 0) { - $sum = ($aIndex >= 0 ? (int) $a[$aIndex--] : 0) - + ($bIndex >= 0 ? (int) $b[$bIndex--] : 0) - + $carry; - $result = (string) ($sum % 10).$result; - $carry = \intdiv($sum, 10); + for ($offset = \strlen($a) - self::CHUNK_DIGITS; $offset >= 0; $offset -= self::CHUNK_DIGITS) { + $sum = (int) \substr($a, $offset, self::CHUNK_DIGITS) + (int) \substr($b, $offset, self::CHUNK_DIGITS) + $carry; + $carry = $sum >= self::CHUNK_BASE ? 1 : 0; + $chunks[] = \str_pad((string) ($sum - $carry * self::CHUNK_BASE), self::CHUNK_DIGITS, '0', \STR_PAD_LEFT); + } + if ($carry === 1) { + $chunks[] = '1'; } - return self::normalizeUnsignedString($result); + return self::normalizeUnsignedString(\implode('', \array_reverse($chunks))); } private static function subtractUnsignedStrings(string $a, string $b): string { - $aIndex = \strlen($a) - 1; - $bIndex = \strlen($b) - 1; + [$a, $b] = self::alignToChunks($a, $b); + $chunks = []; $borrow = 0; - $result = ''; - - while ($aIndex >= 0) { - $digit = (int) $a[$aIndex--] - $borrow - ($bIndex >= 0 ? (int) $b[$bIndex--] : 0); - if ($digit < 0) { - $digit += 10; - $borrow = 1; - } else { - $borrow = 0; - } - $result = (string) $digit.$result; + + for ($offset = \strlen($a) - self::CHUNK_DIGITS; $offset >= 0; $offset -= self::CHUNK_DIGITS) { + $difference = (int) \substr($a, $offset, self::CHUNK_DIGITS) - (int) \substr($b, $offset, self::CHUNK_DIGITS) - $borrow; + $borrow = $difference < 0 ? 1 : 0; + $chunks[] = \str_pad((string) ($difference + $borrow * self::CHUNK_BASE), self::CHUNK_DIGITS, '0', \STR_PAD_LEFT); } - return self::normalizeUnsignedString($result); + return self::normalizeUnsignedString(\implode('', \array_reverse($chunks))); + } + + /** + * @return array{string, string} Both numbers left-padded with zeros to the same whole number of chunks + */ + private static function alignToChunks(string $a, string $b): array + { + $length = \max(\strlen($a), \strlen($b)); + $length += (self::CHUNK_DIGITS - $length % self::CHUNK_DIGITS) % self::CHUNK_DIGITS; + + return [ + \str_pad($a, $length, '0', \STR_PAD_LEFT), + \str_pad($b, $length, '0', \STR_PAD_LEFT), + ]; } private static function multiplyUnsignedStrings(string $a, string $b): string diff --git a/tests/unit/Documents/IntegerRoundingTest.php b/tests/unit/Documents/IntegerRoundingTest.php index c427f57bc..85931d56d 100644 --- a/tests/unit/Documents/IntegerRoundingTest.php +++ b/tests/unit/Documents/IntegerRoundingTest.php @@ -145,6 +145,18 @@ public function testPlainDecimalTextIsRounded(Engine $engine): void $this->assertSame(107, $this->stored($database, 'count')); } + public function testAMillionDigitAmountIsRefusedWithoutDelay(): void + { + $database = $this->database(Engine::SQLite); + $amount = \str_repeat('1', 1000000).'.5'; + $start = \hrtime(true); + + $this->assertLimited('Attribute value exceeds maximum limit: 2147483647', fn () => $this->increase($database, 'count', $amount)); + + $this->assertLessThan(2.0, (\hrtime(true) - $start) / 1e9, 'Rounding a long amount must take time linear in its length'); + $this->assertSame(100, $this->stored($database, 'count')); + } + #[DataProvider('engines')] public function testTextWithAnExponentIsRefused(Engine $engine): void { diff --git a/tests/unit/Validator/BigIntTest.php b/tests/unit/Validator/BigIntTest.php index 35e817291..2cb168d52 100644 --- a/tests/unit/Validator/BigIntTest.php +++ b/tests/unit/Validator/BigIntTest.php @@ -124,6 +124,24 @@ public function testNativeIntegerArithmeticAtTheSignedLimits(): void $this->assertSame(0, BigInt::add(5, -5)); } + public function testStringArithmeticCarriesAndBorrowsAcrossLongNumbers(): void + { + $nines = \str_repeat('9', 36); + $power = '1'.\str_repeat('0', 36); + + $this->assertSame($power, BigInt::add($nines, 1)); + $this->assertSame('1'.\str_repeat('9', 39).'8', BigInt::add(\str_repeat('9', 40), \str_repeat('9', 40))); + $this->assertSame('1111111110111111111011111111100', BigInt::add('123456789012345678901234567890', '987654321098765432109876543210')); + $this->assertSame($nines, BigInt::subtract($power, 1)); + $this->assertSame(1, BigInt::subtract('1'.\str_repeat('0', 40), \str_repeat('9', 40))); + $this->assertSame(1, BigInt::add($power, '-'.$nines)); + $this->assertSame('-'.$nines, BigInt::add('-'.$power, 1)); + $this->assertSame('-'.$power, BigInt::subtract('-'.$nines, 1)); + $this->assertSame('100000000000000000000000000000000000000000000000000000', BigInt::add('99999999999999999999999999999999999999', '99999999999999900000000000000000000000000000000000001')); + $this->assertSame('1'.\str_repeat('0', 1000000), BigInt::add(\str_repeat('9', 1000000), 1)); + $this->assertSame(\str_repeat('9', 1000000), BigInt::subtract('1'.\str_repeat('0', 1000000), 1)); + } + public function testComparingNativeAndStringIntegersAgrees(): void { $this->assertSame(-1, BigInt::compare(-2, 1)); From f9f6f514fce9bcd9363f24d0754f53b06e43b7b3 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 14 Oct 2026 19:41:55 +1300 Subject: [PATCH 18/18] fix(document): mark removeAttribute impure so analysis forgets isEmpty removeAttribute changes the document but returns static, so PHPStan took it for a possibly pure call and kept remembering an earlier isEmpty() result across it. updateDocuments checks isEmpty() again after removing the operators that round to nothing, which PHPStan then reported as always false. Declaring the mutation lets analysis see the change. Co-Authored-By: Claude Opus 5.5 --- src/Database/Document.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Database/Document.php b/src/Database/Document.php index 798511983..c8e479327 100644 --- a/src/Database/Document.php +++ b/src/Database/Document.php @@ -515,6 +515,8 @@ public function setAttributes(array $attributes): static * Remove Attribute. * * Method for removing a specific field attribute + * + * @phpstan-impure */ public function removeAttribute(string $key): static {