diff --git a/composer.lock b/composer.lock index 5933f4fc9..7b93ba9a1 100644 --- a/composer.lock +++ b/composer.lock @@ -8,25 +8,25 @@ "packages": [ { "name": "brick/math", - "version": "0.13.1", + "version": "0.14.0", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04" + "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/fc7ed316430118cc7836bf45faff18d5dfc8de04", - "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04", + "url": "https://api.github.com/repos/brick/math/zipball/113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", + "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^8.2" }, "require-dev": { "php-coveralls/php-coveralls": "^2.2", - "phpunit/phpunit": "^10.1", - "vimeo/psalm": "6.8.8" + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" }, "type": "library", "autoload": { @@ -56,7 +56,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.13.1" + "source": "https://github.com/brick/math/tree/0.14.0" }, "funding": [ { @@ -64,7 +64,7 @@ "type": "github" } ], - "time": "2025-03-29T13:50:30+00:00" + "time": "2025-08-29T12:40:03+00:00" }, { "name": "composer/semver", @@ -145,24 +145,21 @@ }, { "name": "google/protobuf", - "version": "v4.32.0", + "version": "v4.33.0", "source": { "type": "git", "url": "https://github.com/protocolbuffers/protobuf-php.git", - "reference": "9a9a92ecbe9c671dc1863f6d4a91ea3ea12c8646" + "reference": "b50269e23204e5ae859a326ec3d90f09efe3047d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/9a9a92ecbe9c671dc1863f6d4a91ea3ea12c8646", - "reference": "9a9a92ecbe9c671dc1863f6d4a91ea3ea12c8646", + "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/b50269e23204e5ae859a326ec3d90f09efe3047d", + "reference": "b50269e23204e5ae859a326ec3d90f09efe3047d", "shasum": "" }, "require": { "php": ">=8.1.0" }, - "provide": { - "ext-protobuf": "*" - }, "require-dev": { "phpunit/phpunit": ">=5.0.0 <8.5.27" }, @@ -186,9 +183,9 @@ "proto" ], "support": { - "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.32.0" + "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.0" }, - "time": "2025-08-14T20:00:33+00:00" + "time": "2025-10-15T20:10:28+00:00" }, { "name": "nyholm/psr7", @@ -336,20 +333,20 @@ }, { "name": "open-telemetry/api", - "version": "1.4.0", + "version": "1.7.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "b3a9286f9c1c8247c83493c5b1fa475cd0cec7f7" + "reference": "610b79ad9d6d97e8368bcb6c4d42394fbb87b522" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/b3a9286f9c1c8247c83493c5b1fa475cd0cec7f7", - "reference": "b3a9286f9c1c8247c83493c5b1fa475cd0cec7f7", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/610b79ad9d6d97e8368bcb6c4d42394fbb87b522", + "reference": "610b79ad9d6d97e8368bcb6c4d42394fbb87b522", "shasum": "" }, "require": { - "open-telemetry/context": "^1.0", + "open-telemetry/context": "^1.4", "php": "^8.1", "psr/log": "^1.1|^2.0|^3.0", "symfony/polyfill-php82": "^1.26" @@ -365,7 +362,7 @@ ] }, "branch-alias": { - "dev-main": "1.4.x-dev" + "dev-main": "1.7.x-dev" } }, "autoload": { @@ -402,20 +399,20 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-06-19T23:36:51+00:00" + "time": "2025-10-02T23:44:28+00:00" }, { "name": "open-telemetry/context", - "version": "1.3.1", + "version": "1.4.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/context.git", - "reference": "438f71812242db3f196fb4c717c6f92cbc819be6" + "reference": "d4c4470b541ce72000d18c339cfee633e4c8e0cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/438f71812242db3f196fb4c717c6f92cbc819be6", - "reference": "438f71812242db3f196fb4c717c6f92cbc819be6", + "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/d4c4470b541ce72000d18c339cfee633e4c8e0cf", + "reference": "d4c4470b541ce72000d18c339cfee633e4c8e0cf", "shasum": "" }, "require": { @@ -461,7 +458,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-08-13T01:12:00+00:00" + "time": "2025-09-19T00:05:49+00:00" }, { "name": "open-telemetry/exporter-otlp", @@ -529,16 +526,16 @@ }, { "name": "open-telemetry/gen-otlp-protobuf", - "version": "1.5.0", + "version": "1.8.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/gen-otlp-protobuf.git", - "reference": "585bafddd4ae6565de154610b10a787a455c9ba0" + "reference": "673af5b06545b513466081884b47ef15a536edde" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/585bafddd4ae6565de154610b10a787a455c9ba0", - "reference": "585bafddd4ae6565de154610b10a787a455c9ba0", + "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/673af5b06545b513466081884b47ef15a536edde", + "reference": "673af5b06545b513466081884b47ef15a536edde", "shasum": "" }, "require": { @@ -588,27 +585,27 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-01-15T23:07:07+00:00" + "time": "2025-09-17T23:10:12+00:00" }, { "name": "open-telemetry/sdk", - "version": "1.7.0", + "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sdk.git", - "reference": "86287cf30fd6549444d7b8f7d8758d92e24086ac" + "reference": "8986bcbcbea79cb1ba9e91c1d621541ad63d6b3e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/86287cf30fd6549444d7b8f7d8758d92e24086ac", - "reference": "86287cf30fd6549444d7b8f7d8758d92e24086ac", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/8986bcbcbea79cb1ba9e91c1d621541ad63d6b3e", + "reference": "8986bcbcbea79cb1ba9e91c1d621541ad63d6b3e", "shasum": "" }, "require": { "ext-json": "*", "nyholm/psr7-server": "^1.1", - "open-telemetry/api": "~1.4.0", - "open-telemetry/context": "^1.0", + "open-telemetry/api": "^1.7", + "open-telemetry/context": "^1.4", "open-telemetry/sem-conv": "^1.0", "php": "^8.1", "php-http/discovery": "^1.14", @@ -642,7 +639,7 @@ ] }, "branch-alias": { - "dev-main": "1.0.x-dev" + "dev-main": "1.9.x-dev" } }, "autoload": { @@ -685,7 +682,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-08-06T03:07:06+00:00" + "time": "2025-10-02T23:44:28+00:00" }, { "name": "open-telemetry/sem-conv", @@ -1164,20 +1161,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.0", + "version": "4.9.1", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0" + "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/4e0e23cc785f0724a0e838279a9eb03f28b092a0", - "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440", + "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13", + "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -1236,9 +1233,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.0" + "source": "https://github.com/ramsey/uuid/tree/4.9.1" }, - "time": "2025-06-25T14:20:11+00:00" + "time": "2025-09-04T20:59:21+00:00" }, { "name": "symfony/deprecation-contracts", @@ -1309,16 +1306,16 @@ }, { "name": "symfony/http-client", - "version": "v7.3.3", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "333b9bd7639cbdaecd25a3a48a9d2dcfaa86e019" + "reference": "4b62871a01c49457cf2a8e560af7ee8a94b87a62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/333b9bd7639cbdaecd25a3a48a9d2dcfaa86e019", - "reference": "333b9bd7639cbdaecd25a3a48a9d2dcfaa86e019", + "url": "https://api.github.com/repos/symfony/http-client/zipball/4b62871a01c49457cf2a8e560af7ee8a94b87a62", + "reference": "4b62871a01c49457cf2a8e560af7ee8a94b87a62", "shasum": "" }, "require": { @@ -1385,7 +1382,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.3.3" + "source": "https://github.com/symfony/http-client/tree/v7.3.4" }, "funding": [ { @@ -1405,7 +1402,7 @@ "type": "tidelift" } ], - "time": "2025-08-27T07:45:05+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/http-client-contracts", @@ -1965,16 +1962,16 @@ }, { "name": "utopia-php/framework", - "version": "0.33.24", + "version": "0.33.28", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "5112b1023342163e3fbedec99f38fc32c8700aa0" + "reference": "5aaa94d406577b0059ad28c78022606890dc6de0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/5112b1023342163e3fbedec99f38fc32c8700aa0", - "reference": "5112b1023342163e3fbedec99f38fc32c8700aa0", + "url": "https://api.github.com/repos/utopia-php/http/zipball/5aaa94d406577b0059ad28c78022606890dc6de0", + "reference": "5aaa94d406577b0059ad28c78022606890dc6de0", "shasum": "" }, "require": { @@ -2006,9 +2003,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.33.24" + "source": "https://github.com/utopia-php/http/tree/0.33.28" }, - "time": "2025-09-04T04:18:39+00:00" + "time": "2025-09-25T10:44:24+00:00" }, { "name": "utopia-php/pools", @@ -2249,16 +2246,16 @@ }, { "name": "laravel/pint", - "version": "v1.24.0", + "version": "v1.25.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "0345f3b05f136801af8c339f9d16ef29e6b4df8a" + "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/0345f3b05f136801af8c339f9d16ef29e6b4df8a", - "reference": "0345f3b05f136801af8c339f9d16ef29e6b4df8a", + "url": "https://api.github.com/repos/laravel/pint/zipball/5016e263f95d97670d71b9a987bd8996ade6d8d9", + "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9", "shasum": "" }, "require": { @@ -2269,9 +2266,9 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.82.2", - "illuminate/view": "^11.45.1", - "larastan/larastan": "^3.5.0", + "friendsofphp/php-cs-fixer": "^3.87.2", + "illuminate/view": "^11.46.0", + "larastan/larastan": "^3.7.1", "laravel-zero/framework": "^11.45.0", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.3.1", @@ -2282,9 +2279,6 @@ ], "type": "project", "autoload": { - "files": [ - "overrides/Runner/Parallel/ProcessFactory.php" - ], "psr-4": { "App\\": "app/", "Database\\Seeders\\": "database/seeders/", @@ -2314,7 +2308,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-07-10T18:09:32+00:00" + "time": "2025-09-19T02:57:12+00:00" }, { "name": "myclabs/deep-copy", @@ -2586,16 +2580,11 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.28", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpstan.git", - "reference": "fcf8b71aeab4e1a1131d1783cef97b23a51b87a9" - }, + "version": "1.12.32", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/fcf8b71aeab4e1a1131d1783cef97b23a51b87a9", - "reference": "fcf8b71aeab4e1a1131d1783cef97b23a51b87a9", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/2770dcdf5078d0b0d53f94317e06affe88419aa8", + "reference": "2770dcdf5078d0b0d53f94317e06affe88419aa8", "shasum": "" }, "require": { @@ -2640,7 +2629,7 @@ "type": "github" } ], - "time": "2025-07-17T17:15:39+00:00" + "time": "2025-09-30T10:16:31+00:00" }, { "name": "phpunit/php-code-coverage", @@ -2963,16 +2952,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.25", + "version": "9.6.29", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "049c011e01be805202d8eebedef49f769a8ec7b7" + "reference": "9ecfec57835a5581bc888ea7e13b51eb55ab9dd3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/049c011e01be805202d8eebedef49f769a8ec7b7", - "reference": "049c011e01be805202d8eebedef49f769a8ec7b7", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9ecfec57835a5581bc888ea7e13b51eb55ab9dd3", + "reference": "9ecfec57835a5581bc888ea7e13b51eb55ab9dd3", "shasum": "" }, "require": { @@ -2997,7 +2986,7 @@ "sebastian/comparator": "^4.0.9", "sebastian/diff": "^4.0.6", "sebastian/environment": "^5.1.5", - "sebastian/exporter": "^4.0.6", + "sebastian/exporter": "^4.0.8", "sebastian/global-state": "^5.0.8", "sebastian/object-enumerator": "^4.0.4", "sebastian/resource-operations": "^3.0.4", @@ -3046,7 +3035,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.25" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.29" }, "funding": [ { @@ -3070,7 +3059,7 @@ "type": "tidelift" } ], - "time": "2025-08-20T14:38:31+00:00" + "time": "2025-09-24T06:29:11+00:00" }, { "name": "rregeer/phpunit-coverage-check", @@ -3559,16 +3548,16 @@ }, { "name": "sebastian/exporter", - "version": "4.0.6", + "version": "4.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c", "shasum": "" }, "require": { @@ -3624,15 +3613,27 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T06:33:00+00:00" + "time": "2025-09-24T06:03:27+00:00" }, { "name": "sebastian/global-state", diff --git a/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php index 90d5dcad0..f49b51de2 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php @@ -1939,4 +1939,158 @@ public function testDeleteDocumentsRelationshipErrorDoesNotDeleteParent_ManyToMa $database->deleteCollection($parentCollection); $database->deleteCollection($childCollection); } + + public function testPartialUpdateManyToManyBothSides(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('partial_students'); + $database->createCollection('partial_courses'); + + $database->createAttribute('partial_students', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('partial_students', 'grade', Database::VAR_STRING, 10, false); + $database->createAttribute('partial_courses', 'title', Database::VAR_STRING, 255, true); + $database->createAttribute('partial_courses', 'credits', Database::VAR_INTEGER, 0, false); + + $database->createRelationship( + collection: 'partial_students', + relatedCollection: 'partial_courses', + type: Database::RELATION_MANY_TO_MANY, + twoWay: true, + id: 'partial_courses', + twoWayKey: 'partial_students' + ); + + // Create student with courses + $database->createDocument('partial_students', new Document([ + '$id' => 'student1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'David', + 'grade' => 'A', + 'partial_courses' => [ + ['$id' => 'course1', '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], 'title' => 'Math', 'credits' => 3], + ['$id' => 'course2', '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], 'title' => 'Science', 'credits' => 4], + ], + ])); + + // Partial update from student side - update grade only, preserve courses + $database->updateDocument('partial_students', 'student1', new Document([ + '$id' => 'student1', + '$collection' => 'partial_students', + 'grade' => 'A+', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + ])); + + $student = $database->getDocument('partial_students', 'student1'); + $this->assertEquals('David', $student->getAttribute('name'), 'Name should be preserved'); + $this->assertEquals('A+', $student->getAttribute('grade'), 'Grade should be updated'); + $this->assertCount(2, $student->getAttribute('partial_courses'), 'Courses should be preserved'); + + // Partial update from course side - update credits only, preserve students + $database->updateDocument('partial_courses', 'course1', new Document([ + '$id' => 'course1', + '$collection' => 'partial_courses', + 'credits' => 5, + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + ])); + + $course = $database->getDocument('partial_courses', 'course1'); + $this->assertEquals('Math', $course->getAttribute('title'), 'Title should be preserved'); + $this->assertEquals(5, $course->getAttribute('credits'), 'Credits should be updated'); + $this->assertCount(1, $course->getAttribute('partial_students'), 'Students should be preserved'); + + $database->deleteCollection('partial_students'); + $database->deleteCollection('partial_courses'); + } + + public function testPartialUpdateManyToManyWithStringIdsAndDocuments(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('tags'); + $database->createCollection('articles'); + + $database->createAttribute('tags', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('tags', 'color', Database::VAR_STRING, 50, false); + $database->createAttribute('articles', 'title', Database::VAR_STRING, 255, true); + $database->createAttribute('articles', 'published', Database::VAR_BOOLEAN, 0, false); + + $database->createRelationship( + collection: 'articles', + relatedCollection: 'tags', + type: Database::RELATION_MANY_TO_MANY, + twoWay: true, + id: 'tags', + twoWayKey: 'articles' + ); + + // Create article with tags + $database->createDocument('articles', new Document([ + '$id' => 'article1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'title' => 'Great Article', + 'published' => false, + 'tags' => [ + ['$id' => 'tag1', '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], 'name' => 'Tech', 'color' => 'blue'], + ], + ])); + + $database->createDocument('tags', new Document([ + '$id' => 'tag2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'News', + 'color' => 'red', + ])); + + // Update using STRING IDs + $database->updateDocument('articles', 'article1', new Document([ + '$id' => 'article1', + '$collection' => 'articles', + 'tags' => ['tag1', 'tag2'], // String IDs + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + ])); + + $article = $database->getDocument('articles', 'article1'); + $this->assertEquals('Great Article', $article->getAttribute('title')); + $this->assertFalse($article->getAttribute('published')); + $this->assertCount(2, $article->getAttribute('tags')); + + // Update from tag side using DOCUMENT objects + $database->createDocument('articles', new Document([ + '$id' => 'article2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'title' => 'Another Article', + 'published' => true, + ])); + + $database->updateDocument('tags', 'tag1', new Document([ + '$id' => 'tag1', + '$collection' => 'tags', + 'articles' => [ // Document objects + new Document(['$id' => 'article1']), + new Document(['$id' => 'article2']), + ], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + ])); + + $tag = $database->getDocument('tags', 'tag1'); + $this->assertEquals('Tech', $tag->getAttribute('name')); + $this->assertEquals('blue', $tag->getAttribute('color')); + $this->assertCount(2, $tag->getAttribute('articles')); + + $database->deleteCollection('tags'); + $database->deleteCollection('articles'); + } } diff --git a/tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php b/tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php index 73a1c7a6f..4e383bfe7 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php @@ -1819,4 +1819,137 @@ public function testDeleteDocumentsRelationshipErrorDoesNotDeleteParent_ManyToOn $database->deleteCollection($parentCollection); $database->deleteCollection($childCollection); } + + public function testPartialUpdateManyToOneParentSide(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('companies'); + $database->createCollection('employees'); + + $database->createAttribute('companies', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('employees', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('employees', 'salary', Database::VAR_INTEGER, 0, false); + + $database->createRelationship( + collection: 'employees', + relatedCollection: 'companies', + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'company', + twoWayKey: 'employees' + ); + + // Create company + $database->createDocument('companies', new Document([ + '$id' => 'company1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Tech Corp', + ])); + + $database->createDocument('companies', new Document([ + '$id' => 'company2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Design Inc', + ])); + + // Create employee with company (MANY_TO_ONE from employee side) + $database->createDocument('employees', new Document([ + '$id' => 'emp1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Alice', + 'salary' => 100000, + 'company' => 'company1', + ])); + + // Partial update from child (employee) side - update only salary, preserve company + $database->updateDocument('employees', 'emp1', new Document([ + '$id' => 'emp1', + '$collection' => 'employees', + 'salary' => 120000, + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + ])); + + $emp = $database->getDocument('employees', 'emp1'); + $this->assertEquals('Alice', $emp->getAttribute('name'), 'Name should be preserved'); + $this->assertEquals(120000, $emp->getAttribute('salary'), 'Salary should be updated'); + $this->assertEquals('company1', $emp->getAttribute('company')->getId(), 'Company relationship should be preserved'); + + // Partial update - change only company relationship + $database->updateDocument('employees', 'emp1', new Document([ + '$id' => 'emp1', + '$collection' => 'employees', + 'company' => 'company2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + ])); + + $emp = $database->getDocument('employees', 'emp1'); + $this->assertEquals('Alice', $emp->getAttribute('name'), 'Name should be preserved'); + $this->assertEquals(120000, $emp->getAttribute('salary'), 'Salary should be preserved'); + $this->assertEquals('company2', $emp->getAttribute('company')->getId(), 'Company should be updated'); + + $database->deleteCollection('companies'); + $database->deleteCollection('employees'); + } + + public function testPartialUpdateManyToOneChildSide(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('departments'); + $database->createCollection('staff'); + + $database->createAttribute('departments', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('departments', 'budget', Database::VAR_INTEGER, 0, false); + $database->createAttribute('staff', 'name', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'staff', + relatedCollection: 'departments', + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'department', + twoWayKey: 'staff' + ); + + // Create department with staff + $database->createDocument('departments', new Document([ + '$id' => 'dept1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Engineering', + 'budget' => 1000000, + 'staff' => [ + ['$id' => 'staff1', '$permissions' => [Permission::read(Role::any())], 'name' => 'Bob'], + ['$id' => 'staff2', '$permissions' => [Permission::read(Role::any())], 'name' => 'Carol'], + ], + ])); + + // Partial update from parent (department) side - update budget only, preserve staff + $database->updateDocument('departments', 'dept1', new Document([ + '$id' => 'dept1', + '$collection' => 'departments', + 'budget' => 1200000, + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + ])); + + $dept = $database->getDocument('departments', 'dept1'); + $this->assertEquals('Engineering', $dept->getAttribute('name'), 'Name should be preserved'); + $this->assertEquals(1200000, $dept->getAttribute('budget'), 'Budget should be updated'); + $this->assertCount(2, $dept->getAttribute('staff'), 'Staff should be preserved'); + + $database->deleteCollection('departments'); + $database->deleteCollection('staff'); + } } diff --git a/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php b/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php index b29bf2087..5b4df0d6d 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php @@ -948,7 +948,7 @@ public function testNestedOneToMany_OneToOneRelationship(): void '$id' => 'city1', '$collection' => 'cities', 'name' => 'City 1 updated', - 'mayor' => 'mayor1', // we don't support partial updates at the moment + 'mayor' => 'mayor1', '$permissions' => [ Permission::read(Role::any()), Permission::update(Role::any()), @@ -2214,4 +2214,466 @@ public function testDeleteDocumentsRelationshipErrorDoesNotDeleteParent_OneToMan $database->deleteCollection($parentCollection); $database->deleteCollection($childCollection); } + + public function testPartialBatchUpdateWithRelationships(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships() || !$database->getAdapter()->getSupportForBatchOperations()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Setup collections with relationships + $database->createCollection('products'); + $database->createCollection('categories'); + + $database->createAttribute('products', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('products', 'price', Database::VAR_FLOAT, 0, true); + $database->createAttribute('categories', 'name', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'categories', + relatedCollection: 'products', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'products', + twoWayKey: 'category' + ); + + // Create category with products + $database->createDocument('categories', new Document([ + '$id' => 'electronics', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Electronics', + 'products' => [ + [ + '$id' => 'product1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Laptop', + 'price' => 999.99, + ], + [ + '$id' => 'product2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Mouse', + 'price' => 25.50, + ], + ], + ])); + + // Verify initial state + $product1 = $database->getDocument('products', 'product1'); + $this->assertEquals('Laptop', $product1->getAttribute('name')); + $this->assertEquals(999.99, $product1->getAttribute('price')); + $this->assertEquals('electronics', $product1->getAttribute('category')->getId()); + + $product2 = $database->getDocument('products', 'product2'); + $this->assertEquals('Mouse', $product2->getAttribute('name')); + $this->assertEquals(25.50, $product2->getAttribute('price')); + $this->assertEquals('electronics', $product2->getAttribute('category')->getId()); + + // Perform a BATCH partial update - ONLY update price, NOT the category relationship + // This is the critical test case - batch updates with relationships + $database->updateDocuments( + 'products', + new Document([ + 'price' => 50.00, // Update price for all matching products + // NOTE: We deliberately do NOT include the 'category' field here - this is a partial update + ]), + [Query::equal('$id', ['product1', 'product2'])] + ); + + // Verify that prices were updated but category relationships were preserved + $product1After = $database->getDocument('products', 'product1'); + $this->assertEquals('Laptop', $product1After->getAttribute('name'), 'Product name should be preserved'); + $this->assertEquals(50.00, $product1After->getAttribute('price'), 'Price should be updated'); + + // This is the critical assertion - the category relationship should still exist after batch partial update + $categoryAfter = $product1After->getAttribute('category'); + $this->assertNotNull($categoryAfter, 'Category relationship should be preserved after batch partial update'); + $this->assertEquals('electronics', $categoryAfter->getId(), 'Category should still be electronics'); + + $product2After = $database->getDocument('products', 'product2'); + $this->assertEquals('Mouse', $product2After->getAttribute('name'), 'Product name should be preserved'); + $this->assertEquals(50.00, $product2After->getAttribute('price'), 'Price should be updated'); + $this->assertEquals('electronics', $product2After->getAttribute('category')->getId(), 'Category should still be electronics'); + + // Verify the reverse relationship is still intact + $category = $database->getDocument('categories', 'electronics'); + $products = $category->getAttribute('products'); + $this->assertCount(2, $products, 'Category should still have 2 products'); + $this->assertEquals('product1', $products[0]->getId()); + $this->assertEquals('product2', $products[1]->getId()); + + $database->deleteCollection('products'); + $database->deleteCollection('categories'); + } + + public function testPartialUpdateOnlyRelationship(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Setup collections + $database->createCollection('authors'); + $database->createCollection('books'); + + $database->createAttribute('authors', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('authors', 'bio', Database::VAR_STRING, 1000, false); + $database->createAttribute('books', 'title', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'authors', + relatedCollection: 'books', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'books', + twoWayKey: 'author' + ); + + // Create author with one book + $database->createDocument('authors', new Document([ + '$id' => 'author1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'John Doe', + 'bio' => 'A great author', + 'books' => [ + [ + '$id' => 'book1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'First Book', + ], + ], + ])); + + // Create a second book independently + $database->createDocument('books', new Document([ + '$id' => 'book2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Second Book', + ])); + + // Verify initial state + $author = $database->getDocument('authors', 'author1'); + $this->assertEquals('John Doe', $author->getAttribute('name')); + $this->assertEquals('A great author', $author->getAttribute('bio')); + $this->assertCount(1, $author->getAttribute('books')); + $this->assertEquals('book1', $author->getAttribute('books')[0]->getId()); + + // Partial update that ONLY changes the relationship (adds book2 to the author) + // Do NOT update name or bio + $database->updateDocument('authors', 'author1', new Document([ + '$id' => 'author1', + '$collection' => 'authors', + 'books' => ['book1', 'book2'], // Update relationship + // NOTE: We deliberately do NOT include 'name' or 'bio' + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ])); + + // Verify that the relationship was updated but other fields preserved + $authorAfter = $database->getDocument('authors', 'author1'); + $this->assertEquals('John Doe', $authorAfter->getAttribute('name'), 'Name should be preserved'); + $this->assertEquals('A great author', $authorAfter->getAttribute('bio'), 'Bio should be preserved'); + $this->assertCount(2, $authorAfter->getAttribute('books'), 'Should now have 2 books'); + + $bookIds = array_map(fn ($book) => $book->getId(), $authorAfter->getAttribute('books')); + $this->assertContains('book1', $bookIds); + $this->assertContains('book2', $bookIds); + + // Verify reverse relationships + $book1 = $database->getDocument('books', 'book1'); + $this->assertEquals('author1', $book1->getAttribute('author')->getId()); + + $book2 = $database->getDocument('books', 'book2'); + $this->assertEquals('author1', $book2->getAttribute('author')->getId()); + + $database->deleteCollection('authors'); + $database->deleteCollection('books'); + } + + public function testPartialUpdateBothDataAndRelationship(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Setup collections + $database->createCollection('teams'); + $database->createCollection('players'); + + $database->createAttribute('teams', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('teams', 'city', Database::VAR_STRING, 255, true); + $database->createAttribute('teams', 'founded', Database::VAR_INTEGER, 0, false); + $database->createAttribute('players', 'name', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'teams', + relatedCollection: 'players', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'players', + twoWayKey: 'team' + ); + + // Create team with players + $database->createDocument('teams', new Document([ + '$id' => 'team1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'The Warriors', + 'city' => 'San Francisco', + 'founded' => 1946, + 'players' => [ + [ + '$id' => 'player1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Player One', + ], + [ + '$id' => 'player2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Player Two', + ], + ], + ])); + + // Create an additional player + $database->createDocument('players', new Document([ + '$id' => 'player3', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Player Three', + ])); + + // Verify initial state + $team = $database->getDocument('teams', 'team1'); + $this->assertEquals('The Warriors', $team->getAttribute('name')); + $this->assertEquals('San Francisco', $team->getAttribute('city')); + $this->assertEquals(1946, $team->getAttribute('founded')); + $this->assertCount(2, $team->getAttribute('players')); + + // Partial update that changes BOTH flat data (city) AND relationship (players) + // Do NOT update name or founded + $database->updateDocument('teams', 'team1', new Document([ + '$id' => 'team1', + '$collection' => 'teams', + 'city' => 'Oakland', // Update flat data + 'players' => ['player1', 'player3'], // Update relationship (replace player2 with player3) + // NOTE: We deliberately do NOT include 'name' or 'founded' + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ])); + + // Verify that both updates worked and other fields preserved + $teamAfter = $database->getDocument('teams', 'team1'); + $this->assertEquals('The Warriors', $teamAfter->getAttribute('name'), 'Name should be preserved'); + $this->assertEquals('Oakland', $teamAfter->getAttribute('city'), 'City should be updated'); + $this->assertEquals(1946, $teamAfter->getAttribute('founded'), 'Founded should be preserved'); + $this->assertCount(2, $teamAfter->getAttribute('players'), 'Should still have 2 players'); + + $playerIds = array_map(fn ($player) => $player->getId(), $teamAfter->getAttribute('players')); + $this->assertContains('player1', $playerIds, 'Should still have player1'); + $this->assertContains('player3', $playerIds, 'Should now have player3'); + $this->assertNotContains('player2', $playerIds, 'Should no longer have player2'); + + // Verify reverse relationships + $player1 = $database->getDocument('players', 'player1'); + $this->assertEquals('team1', $player1->getAttribute('team')->getId()); + + $player2 = $database->getDocument('players', 'player2'); + $this->assertNull($player2->getAttribute('team'), 'Player2 should no longer have a team'); + + $player3 = $database->getDocument('players', 'player3'); + $this->assertEquals('team1', $player3->getAttribute('team')->getId()); + + $database->deleteCollection('teams'); + $database->deleteCollection('players'); + } + + public function testPartialUpdateOneToManyChildSide(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('blogs'); + $database->createCollection('posts'); + + $database->createAttribute('blogs', 'title', Database::VAR_STRING, 255, true); + $database->createAttribute('blogs', 'description', Database::VAR_STRING, 1000, false); + $database->createAttribute('posts', 'title', Database::VAR_STRING, 255, true); + $database->createAttribute('posts', 'views', Database::VAR_INTEGER, 0, false); + + $database->createRelationship( + collection: 'blogs', + relatedCollection: 'posts', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'posts', + twoWayKey: 'blog' + ); + + // Create blog with posts + $database->createDocument('blogs', new Document([ + '$id' => 'blog1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'title' => 'Tech Blog', + 'description' => 'A blog about technology', + 'posts' => [ + ['$id' => 'post1', '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], 'title' => 'Post 1', 'views' => 100], + ], + ])); + + // Partial update from child (post) side - update views only, preserve blog relationship + $database->updateDocument('posts', 'post1', new Document([ + '$id' => 'post1', + '$collection' => 'posts', + 'views' => 200, + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + ])); + + $post = $database->getDocument('posts', 'post1'); + $this->assertEquals('Post 1', $post->getAttribute('title'), 'Title should be preserved'); + $this->assertEquals(200, $post->getAttribute('views'), 'Views should be updated'); + $this->assertEquals('blog1', $post->getAttribute('blog')->getId(), 'Blog relationship should be preserved'); + + $database->deleteCollection('blogs'); + $database->deleteCollection('posts'); + } + + public function testPartialUpdateWithStringIdsVsDocuments(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection('libraries'); + $database->createCollection('books_lib'); + + $database->createAttribute('libraries', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('libraries', 'location', Database::VAR_STRING, 255, false); + $database->createAttribute('books_lib', 'title', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'libraries', + relatedCollection: 'books_lib', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'books', + twoWayKey: 'library' + ); + + // Create library with books + $database->createDocument('libraries', new Document([ + '$id' => 'lib1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Central Library', + 'location' => 'Downtown', + 'books' => [ + ['$id' => 'book1', '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], 'title' => 'Book One'], + ], + ])); + + // Create standalone book + $database->createDocument('books_lib', new Document([ + '$id' => 'book2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'title' => 'Book Two', + ])); + + // Partial update using STRING IDs for relationship + $database->updateDocument('libraries', 'lib1', new Document([ + '$id' => 'lib1', + '$collection' => 'libraries', + 'books' => ['book1', 'book2'], // Using string IDs + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + ])); + + $lib = $database->getDocument('libraries', 'lib1'); + $this->assertEquals('Central Library', $lib->getAttribute('name'), 'Name should be preserved'); + $this->assertEquals('Downtown', $lib->getAttribute('location'), 'Location should be preserved'); + $this->assertCount(2, $lib->getAttribute('books'), 'Should have 2 books'); + + // Create another standalone book + $database->createDocument('books_lib', new Document([ + '$id' => 'book3', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'title' => 'Book Three', + ])); + + // Partial update using DOCUMENT OBJECTS for relationship + $database->updateDocument('libraries', 'lib1', new Document([ + '$id' => 'lib1', + '$collection' => 'libraries', + 'books' => [ // Using Document objects + new Document(['$id' => 'book1']), + new Document(['$id' => 'book3']), + ], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + ])); + + $lib = $database->getDocument('libraries', 'lib1'); + $this->assertEquals('Central Library', $lib->getAttribute('name'), 'Name should be preserved'); + $this->assertEquals('Downtown', $lib->getAttribute('location'), 'Location should be preserved'); + $this->assertCount(2, $lib->getAttribute('books'), 'Should have 2 books'); + + $bookIds = array_map(fn ($book) => $book->getId(), $lib->getAttribute('books')); + $this->assertContains('book1', $bookIds); + $this->assertContains('book3', $bookIds); + + $database->deleteCollection('libraries'); + $database->deleteCollection('books_lib'); + } } diff --git a/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php b/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php index 52881707b..66da1c750 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php @@ -2429,4 +2429,172 @@ public function testDeleteDocumentsRelationshipErrorDoesNotDeleteParent_OneToOne $database->deleteCollection($parentCollection); $database->deleteCollection($childCollection); } + + public function testPartialUpdateOneToOneWithRelationships(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Setup collections with relationships + $database->createCollection('cities_partial'); + $database->createCollection('mayors_partial'); + + $database->createAttribute('cities_partial', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('cities_partial', 'population', Database::VAR_INTEGER, 0, false); + $database->createAttribute('mayors_partial', 'name', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'cities_partial', + relatedCollection: 'mayors_partial', + type: Database::RELATION_ONE_TO_ONE, + twoWay: true, + id: 'mayor', + twoWayKey: 'city' + ); + + // Create a city with a mayor + $database->createDocument('cities_partial', new Document([ + '$id' => 'city1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Test City', + 'population' => 100000, + 'mayor' => [ + '$id' => 'mayor1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Test Mayor', + ], + ])); + + // Verify initial state + $city = $database->getDocument('cities_partial', 'city1'); + $this->assertEquals('Test City', $city->getAttribute('name')); + $this->assertEquals(100000, $city->getAttribute('population')); + $this->assertEquals('mayor1', $city->getAttribute('mayor')->getId()); + + $mayor = $database->getDocument('mayors_partial', 'mayor1'); + $this->assertEquals('Test Mayor', $mayor->getAttribute('name')); + $this->assertEquals('city1', $mayor->getAttribute('city')->getId()); + + // Perform a partial update - ONLY update the city name, NOT the mayor relationship + $database->updateDocument('cities_partial', 'city1', new Document([ + '$id' => 'city1', + '$collection' => 'cities_partial', + 'name' => 'Updated City Name', + // NOTE: We deliberately do NOT include the 'mayor' field here - this is a partial update + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ])); + + // Verify that the city name was updated but the mayor relationship was preserved + $cityAfterUpdate = $database->getDocument('cities_partial', 'city1'); + $this->assertEquals('Updated City Name', $cityAfterUpdate->getAttribute('name'), 'City name should be updated'); + $this->assertEquals(100000, $cityAfterUpdate->getAttribute('population'), 'Population should be preserved'); + + // This is the critical test - the mayor relationship should still exist + $mayorAfterUpdate = $cityAfterUpdate->getAttribute('mayor'); + $this->assertNotNull($mayorAfterUpdate, 'Mayor relationship should be preserved after partial update'); + $this->assertEquals('mayor1', $mayorAfterUpdate->getId(), 'Mayor ID should still be mayor1'); + + // Verify the bidirectional relationship is still intact + $mayor = $database->getDocument('mayors_partial', 'mayor1'); + $this->assertEquals('city1', $mayor->getAttribute('city')->getId(), 'Reverse relationship should be preserved'); + $this->assertEquals('Updated City Name', $mayor->getAttribute('city')->getAttribute('name'), 'Reverse relationship should reflect updated city name'); + + $database->deleteCollection('cities_partial'); + $database->deleteCollection('mayors_partial'); + } + + public function testPartialUpdateOneToOneWithoutRelationshipField(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Recreate the exact scenario from testNestedOneToMany_OneToOneRelationship + $database->createCollection('cities_strict'); + $database->createCollection('mayors_strict'); + + $database->createAttribute('cities_strict', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('mayors_strict', 'name', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'cities_strict', + relatedCollection: 'mayors_strict', + type: Database::RELATION_ONE_TO_ONE, + twoWay: true, + id: 'mayor', + twoWayKey: 'city' + ); + + // Create city with mayor + $database->createDocument('cities_strict', new Document([ + '$id' => 'city1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'City 1', + 'mayor' => [ + '$id' => 'mayor1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Mayor 1', + ], + ])); + + // Get the current state to verify + $cityBefore = $database->getDocument('cities_strict', 'city1'); + $this->assertEquals('City 1', $cityBefore->getAttribute('name')); + $this->assertEquals('mayor1', $cityBefore->getAttribute('mayor')->getId()); + + // Now do what the comment says we "don't support" - update WITHOUT including mayor field + // Creating a fresh Document object with only the fields we want to update + $partialUpdate = new Document([ + '$id' => 'city1', + '$collection' => 'cities_strict', + 'name' => 'City 1 updated', // Update only name + // Deliberately NOT including 'mayor' at all + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ]); + + $database->updateDocument('cities_strict', 'city1', $partialUpdate); + + // Now check if the mayor relationship was preserved + $cityAfter = $database->getDocument('cities_strict', 'city1'); + $this->assertEquals('City 1 updated', $cityAfter->getAttribute('name')); + + // The relationship should still exist + $mayorAttr = $cityAfter->getAttribute('mayor'); + $this->assertNotNull($mayorAttr, 'Mayor should still be set after partial update without mayor field'); + $this->assertEquals('mayor1', $mayorAttr->getId()); + + // Also verify the reverse relationship + $mayor = $database->getDocument('mayors_strict', 'mayor1'); + $this->assertEquals('city1', $mayor->getAttribute('city')->getId()); + + $database->deleteCollection('cities_strict'); + $database->deleteCollection('mayors_strict'); + } }