diff --git a/src/Database/Database.php b/src/Database/Database.php index c4eda9e11..83b7b6b66 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5205,6 +5205,11 @@ public function updateDocument(string $collection, string $id, Document $documen break; } + if (Operator::isOperator($value)) { + $shouldUpdate = true; + break; + } + if (!\is_array($value) || !\array_is_list($value)) { throw new RelationshipException('Invalid relationship value. Must be either an array of documents or document IDs, ' . \gettype($value) . ' given.'); } @@ -5610,6 +5615,24 @@ private function updateDocumentRelationships(Document $collection, Document $old $twoWayKey = (string)$relationship['options']['twoWayKey']; $side = (string)$relationship['options']['side']; + if (Operator::isOperator($value)) { + $operator = $value; + if ($operator->isArrayOperation()) { + $existingIds = []; + if (\is_array($oldValue)) { + $existingIds = \array_map(function ($item) { + if ($item instanceof Document) { + return $item->getId(); + } + return $item; + }, $oldValue); + } + + $value = $this->applyRelationshipOperator($operator, $existingIds); + $document->setAttribute($key, $value); + } + } + if ($oldValue == $value) { if ( ($relationType === Database::RELATION_ONE_TO_ONE @@ -5969,6 +5992,63 @@ private function getJunctionCollection(Document $collection, Document $relatedCo : '_' . $relatedCollection->getSequence() . '_' . $collection->getSequence(); } + /** + * Apply an operator to a relationship array of IDs + * + * @param Operator $operator + * @param array $existingIds + * @return array + */ + private function applyRelationshipOperator(Operator $operator, array $existingIds): array + { + $method = $operator->getMethod(); + $values = $operator->getValues(); + + // Extract IDs from operator values (could be strings or Documents) + $valueIds = \array_filter(\array_map(fn ($item) => $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : null), $values)); + + switch ($method) { + case Operator::TYPE_ARRAY_APPEND: + return \array_values(\array_merge($existingIds, $valueIds)); + + case Operator::TYPE_ARRAY_PREPEND: + return \array_values(\array_merge($valueIds, $existingIds)); + + case Operator::TYPE_ARRAY_INSERT: + $index = $values[0] ?? 0; + $item = $values[1] ?? null; + $itemId = $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : null); + if ($itemId !== null) { + \array_splice($existingIds, $index, 0, [$itemId]); + } + return \array_values($existingIds); + + case Operator::TYPE_ARRAY_REMOVE: + $toRemove = $values[0] ?? null; + if (\is_array($toRemove)) { + $toRemoveIds = \array_filter(\array_map(fn ($item) => $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : null), $toRemove)); + return \array_values(\array_diff($existingIds, $toRemoveIds)); + } + $toRemoveId = $toRemove instanceof Document ? $toRemove->getId() : (\is_string($toRemove) ? $toRemove : null); + if ($toRemoveId !== null) { + return \array_values(\array_diff($existingIds, [$toRemoveId])); + } + return $existingIds; + + case Operator::TYPE_ARRAY_UNIQUE: + return \array_values(\array_unique($existingIds)); + + case Operator::TYPE_ARRAY_INTERSECT: + return \array_values(\array_intersect($existingIds, $valueIds)); + + case Operator::TYPE_ARRAY_DIFF: + return \array_values(\array_diff($existingIds, $valueIds)); + + default: + return $existingIds; + } + } + /** * Create or update a document. * diff --git a/src/Database/Validator/Operator.php b/src/Database/Validator/Operator.php index e43ebf26c..842a4861e 100644 --- a/src/Database/Validator/Operator.php +++ b/src/Database/Validator/Operator.php @@ -36,6 +36,50 @@ public function __construct(Document $collection, ?Document $currentDocument = n } } + /** + * Check if a value is a valid relationship reference (string ID or Document) + * + * @param mixed $item + * @return bool + */ + private function isValidRelationshipValue(mixed $item): bool + { + return \is_string($item) || $item instanceof Document; + } + + /** + * Check if a relationship attribute represents a "many" side (returns array of documents) + * + * @param Document|array $attribute + * @return bool + */ + private function isRelationshipArray(Document|array $attribute): bool + { + $options = $attribute instanceof Document + ? $attribute->getAttribute('options', []) + : ($attribute['options'] ?? []); + + $relationType = $options['relationType'] ?? ''; + $side = $options['side'] ?? ''; + + // Many-to-many is always an array on both sides + if ($relationType === Database::RELATION_MANY_TO_MANY) { + return true; + } + + // One-to-many: array on parent side, single on child side + if ($relationType === Database::RELATION_ONE_TO_MANY && $side === Database::RELATION_SIDE_PARENT) { + return true; + } + + // Many-to-one: array on child side, single on parent side + if ($relationType === Database::RELATION_MANY_TO_ONE && $side === Database::RELATION_SIDE_CHILD) { + return true; + } + + return false; + } + /** * Get Description * @@ -165,7 +209,19 @@ private function validateOperatorForAttribute( break; case DatabaseOperator::TYPE_ARRAY_APPEND: case DatabaseOperator::TYPE_ARRAY_PREPEND: - if (!$isArray) { + // For relationships, check if it's a "many" side + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; + } + foreach ($values as $item) { + if (!$this->isValidRelationshipValue($item)) { + $this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects"; + return false; + } + } + } elseif (!$isArray) { $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; return false; } @@ -182,14 +238,24 @@ private function validateOperatorForAttribute( break; case DatabaseOperator::TYPE_ARRAY_UNIQUE: - if (!$isArray) { + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; + } + } elseif (!$isArray) { $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; return false; } break; case DatabaseOperator::TYPE_ARRAY_INSERT: - if (!$isArray) { + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; + } + } elseif (!$isArray) { $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; return false; } @@ -206,6 +272,14 @@ private function validateOperatorForAttribute( } $insertValue = $values[1]; + + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isValidRelationshipValue($insertValue)) { + $this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects"; + return false; + } + } + if ($type === Database::VAR_INTEGER && \is_numeric($insertValue)) { if ($insertValue > Database::MAX_INT || $insertValue < Database::MIN_INT) { $this->message = "Cannot apply {$method} operator: array items must be between " . Database::MIN_INT . " and " . Database::MAX_INT; @@ -228,7 +302,19 @@ private function validateOperatorForAttribute( break; case DatabaseOperator::TYPE_ARRAY_REMOVE: - if (!$isArray) { + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; + } + $toValidate = \is_array($values[0]) ? $values[0] : $values; + foreach ($toValidate as $item) { + if (!$this->isValidRelationshipValue($item)) { + $this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects"; + return false; + } + } + } elseif (!$isArray) { $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; return false; } @@ -240,7 +326,12 @@ private function validateOperatorForAttribute( break; case DatabaseOperator::TYPE_ARRAY_INTERSECT: - if (!$isArray) { + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; + } + } elseif (!$isArray) { $this->message = "Cannot use {$method} operator on non-array attribute '{$operator->getAttribute()}'"; return false; } @@ -250,16 +341,41 @@ private function validateOperatorForAttribute( return false; } + if ($type === Database::VAR_RELATIONSHIP) { + foreach ($values as $item) { + if (!$this->isValidRelationshipValue($item)) { + $this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects"; + return false; + } + } + } + break; case DatabaseOperator::TYPE_ARRAY_DIFF: - if (!$isArray) { + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; + } + foreach ($values as $item) { + if (!$this->isValidRelationshipValue($item)) { + $this->message = "Cannot apply {$method} operator: relationship values must be document IDs (strings) or Document objects"; + return false; + } + } + } elseif (!$isArray) { $this->message = "Cannot use {$method} operator on non-array attribute '{$operator->getAttribute()}'"; return false; } break; case DatabaseOperator::TYPE_ARRAY_FILTER: - if (!$isArray) { + if ($type === Database::VAR_RELATIONSHIP) { + if (!$this->isRelationshipArray($attribute)) { + $this->message = "Cannot apply {$method} operator to single-value relationship '{$operator->getAttribute()}'"; + return false; + } + } elseif (!$isArray) { $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; return false; } diff --git a/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php index f49b51de2..a3f4f5564 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php @@ -2093,4 +2093,161 @@ public function testPartialUpdateManyToManyWithStringIdsAndDocuments(): void $database->deleteCollection('tags'); $database->deleteCollection('articles'); } + + public function testManyToManyRelationshipWithArrayOperators(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Cleanup any leftover collections from previous runs + try { + $database->deleteCollection('library'); + } catch (\Throwable $e) { + } + try { + $database->deleteCollection('book'); + } catch (\Throwable $e) { + } + + $database->createCollection('library'); + $database->createCollection('book'); + + $database->createAttribute('library', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('book', 'title', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'library', + relatedCollection: 'book', + type: Database::RELATION_MANY_TO_MANY, + twoWay: true, + id: 'books', + twoWayKey: 'libraries' + ); + + // Create some books + $book1 = $database->createDocument('book', new Document([ + '$id' => 'book1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Book 1', + ])); + + $book2 = $database->createDocument('book', new Document([ + '$id' => 'book2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Book 2', + ])); + + $book3 = $database->createDocument('book', new Document([ + '$id' => 'book3', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Book 3', + ])); + + $book4 = $database->createDocument('book', new Document([ + '$id' => 'book4', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Book 4', + ])); + + // Create library with one book + $library = $database->createDocument('library', new Document([ + '$id' => 'library1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Library 1', + 'books' => ['book1'], + ])); + + $this->assertCount(1, $library->getAttribute('books')); + $this->assertEquals('book1', $library->getAttribute('books')[0]->getId()); + + // Test arrayAppend - add a single book + $library = $database->updateDocument('library', 'library1', new Document([ + 'books' => \Utopia\Database\Operator::arrayAppend(['book2']), + ])); + + $library = $database->getDocument('library', 'library1'); + $this->assertCount(2, $library->getAttribute('books')); + $bookIds = \array_map(fn ($book) => $book->getId(), $library->getAttribute('books')); + $this->assertContains('book1', $bookIds); + $this->assertContains('book2', $bookIds); + + // Test arrayAppend - add multiple books + $library = $database->updateDocument('library', 'library1', new Document([ + 'books' => \Utopia\Database\Operator::arrayAppend(['book3', 'book4']), + ])); + + $library = $database->getDocument('library', 'library1'); + $this->assertCount(4, $library->getAttribute('books')); + $bookIds = \array_map(fn ($book) => $book->getId(), $library->getAttribute('books')); + $this->assertContains('book1', $bookIds); + $this->assertContains('book2', $bookIds); + $this->assertContains('book3', $bookIds); + $this->assertContains('book4', $bookIds); + + // Test arrayRemove - remove a single book + $library = $database->updateDocument('library', 'library1', new Document([ + 'books' => \Utopia\Database\Operator::arrayRemove('book2'), + ])); + + $library = $database->getDocument('library', 'library1'); + $this->assertCount(3, $library->getAttribute('books')); + $bookIds = \array_map(fn ($book) => $book->getId(), $library->getAttribute('books')); + $this->assertContains('book1', $bookIds); + $this->assertNotContains('book2', $bookIds); + $this->assertContains('book3', $bookIds); + $this->assertContains('book4', $bookIds); + + // Test arrayRemove - remove multiple books at once + $library = $database->updateDocument('library', 'library1', new Document([ + 'books' => \Utopia\Database\Operator::arrayRemove(['book3', 'book4']), + ])); + + $library = $database->getDocument('library', 'library1'); + $this->assertCount(1, $library->getAttribute('books')); + $bookIds = \array_map(fn ($book) => $book->getId(), $library->getAttribute('books')); + $this->assertContains('book1', $bookIds); + $this->assertNotContains('book3', $bookIds); + $this->assertNotContains('book4', $bookIds); + + // Test arrayPrepend - add books + // Note: Order is not guaranteed for many-to-many relationships as they use junction tables + $library = $database->updateDocument('library', 'library1', new Document([ + 'books' => \Utopia\Database\Operator::arrayPrepend(['book2']), + ])); + + $library = $database->getDocument('library', 'library1'); + $this->assertCount(2, $library->getAttribute('books')); + $bookIds = \array_map(fn ($book) => $book->getId(), $library->getAttribute('books')); + $this->assertContains('book1', $bookIds); + $this->assertContains('book2', $bookIds); + + // Cleanup + $database->deleteCollection('library'); + $database->deleteCollection('book'); + } } diff --git a/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php b/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php index 5b4df0d6d..bb4aa7650 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php @@ -2676,4 +2676,191 @@ public function testPartialUpdateWithStringIdsVsDocuments(): void $database->deleteCollection('libraries'); $database->deleteCollection('books_lib'); } + + public function testOneToManyRelationshipWithArrayOperators(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Cleanup any leftover collections from previous runs + try { + $database->deleteCollection('author'); + } catch (\Throwable $e) { + } + try { + $database->deleteCollection('article'); + } catch (\Throwable $e) { + } + + $database->createCollection('author'); + $database->createCollection('article'); + + $database->createAttribute('author', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('article', 'title', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'author', + relatedCollection: 'article', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'articles', + twoWayKey: 'author' + ); + + // Create some articles + $article1 = $database->createDocument('article', new Document([ + '$id' => 'article1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Article 1', + ])); + + $article2 = $database->createDocument('article', new Document([ + '$id' => 'article2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Article 2', + ])); + + $article3 = $database->createDocument('article', new Document([ + '$id' => 'article3', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Article 3', + ])); + + // Create author with one article + $database->createDocument('author', new Document([ + '$id' => 'author1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Author 1', + 'articles' => ['article1'], + ])); + + // Fetch the document to get relationships (needed for Mirror which may not return relationships on create) + $author = $database->getDocument('author', 'author1'); + $this->assertCount(1, $author->getAttribute('articles')); + $this->assertEquals('article1', $author->getAttribute('articles')[0]->getId()); + + // Test arrayAppend - add articles + $author = $database->updateDocument('author', 'author1', new Document([ + 'articles' => \Utopia\Database\Operator::arrayAppend(['article2']), + ])); + + $author = $database->getDocument('author', 'author1'); + $this->assertCount(2, $author->getAttribute('articles')); + $articleIds = \array_map(fn ($article) => $article->getId(), $author->getAttribute('articles')); + $this->assertContains('article1', $articleIds); + $this->assertContains('article2', $articleIds); + + // Test arrayRemove - remove an article + $author = $database->updateDocument('author', 'author1', new Document([ + 'articles' => \Utopia\Database\Operator::arrayRemove('article1'), + ])); + + $author = $database->getDocument('author', 'author1'); + $this->assertCount(1, $author->getAttribute('articles')); + $articleIds = \array_map(fn ($article) => $article->getId(), $author->getAttribute('articles')); + $this->assertNotContains('article1', $articleIds); + $this->assertContains('article2', $articleIds); + + // Cleanup + $database->deleteCollection('author'); + $database->deleteCollection('article'); + } + + public function testOneToManyChildSideRejectsArrayOperators(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Cleanup any leftover collections from previous runs + try { + $database->deleteCollection('parent_o2m'); + } catch (\Throwable $e) { + } + try { + $database->deleteCollection('child_o2m'); + } catch (\Throwable $e) { + } + + $database->createCollection('parent_o2m'); + $database->createCollection('child_o2m'); + + $database->createAttribute('parent_o2m', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('child_o2m', 'title', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'parent_o2m', + relatedCollection: 'child_o2m', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'children', + twoWayKey: 'parent' + ); + + // Create a parent + $database->createDocument('parent_o2m', new Document([ + '$id' => 'parent1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Parent 1', + ])); + + // Create child with parent + $database->createDocument('child_o2m', new Document([ + '$id' => 'child1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'title' => 'Child 1', + 'parent' => 'parent1', + ])); + + // Array operators should fail on child side (single-value "parent" relationship) + try { + $database->updateDocument('child_o2m', 'child1', new Document([ + 'parent' => \Utopia\Database\Operator::arrayAppend(['parent2']), + ])); + $this->fail('Expected exception for array operator on child side of one-to-many relationship'); + } catch (\Utopia\Database\Exception\Structure $e) { + $this->assertStringContainsString('single-value relationship', $e->getMessage()); + } + + // Cleanup + $database->deleteCollection('parent_o2m'); + $database->deleteCollection('child_o2m'); + } } diff --git a/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php b/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php index 66da1c750..160fca576 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php @@ -2597,4 +2597,80 @@ public function testPartialUpdateOneToOneWithoutRelationshipField(): void $database->deleteCollection('cities_strict'); $database->deleteCollection('mayors_strict'); } + + public function testOneToOneRelationshipRejectsArrayOperators(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Cleanup any leftover collections from previous runs + try { + $database->deleteCollection('user_o2o'); + } catch (\Throwable $e) { + } + try { + $database->deleteCollection('profile_o2o'); + } catch (\Throwable $e) { + } + + $database->createCollection('user_o2o'); + $database->createCollection('profile_o2o'); + + $database->createAttribute('user_o2o', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('profile_o2o', 'bio', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'user_o2o', + relatedCollection: 'profile_o2o', + type: Database::RELATION_ONE_TO_ONE, + twoWay: true, + id: 'profile', + twoWayKey: 'user' + ); + + // Create a profile + $database->createDocument('profile_o2o', new Document([ + '$id' => 'profile1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'bio' => 'Test bio', + ])); + + // Create user with profile + $database->createDocument('user_o2o', new Document([ + '$id' => 'user1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'User 1', + 'profile' => 'profile1', + ])); + + // Array operators should fail on one-to-one relationships + try { + $database->updateDocument('user_o2o', 'user1', new Document([ + 'profile' => \Utopia\Database\Operator::arrayAppend(['profile2']), + ])); + $this->fail('Expected exception for array operator on one-to-one relationship'); + } catch (\Utopia\Database\Exception\Structure $e) { + $this->assertStringContainsString('single-value relationship', $e->getMessage()); + } + + // Cleanup + $database->deleteCollection('user_o2o'); + $database->deleteCollection('profile_o2o'); + } }