diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 7bb1628cb..a5cffd444 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -619,6 +619,16 @@ abstract public function updateDocuments(string $collection, Document $updates, */ abstract public function deleteDocument(string $collection, string $id): bool; + /** + * Delete Documents + * + * @param string $collection + * @param array $ids + * + * @return int + */ + abstract public function deleteDocuments(string $collection, array $ids): int; + /** * Find Documents * diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index f3359b5f8..e98ba76a1 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1677,6 +1677,75 @@ public function deleteDocument(string $collection, string $id): bool return $deleted; } + /** + * Delete Documents + * + * @param string $collection + * @param array $ids + * + * @return int + */ + public function deleteDocuments(string $collection, array $ids): int + { + try { + $name = $this->filter($collection); + $where = []; + + if ($this->sharedTables) { + $where[] = "_tenant = :_tenant"; + } + + $where[] = "_uid IN (" . \implode(', ', \array_map(fn ($index) => ":_id_{$index}", \array_keys($ids))) . ")"; + + $sql = "DELETE FROM {$this->getSQLTable($name)} WHERE " . \implode(' AND ', $where); + + $sql = $this->trigger(Database::EVENT_DOCUMENTS_DELETE, $sql); + + $stmt = $this->getPDO()->prepare($sql); + + foreach ($ids as $id => $value) { + $stmt->bindValue(":_id_{$id}", $value); + } + + if ($this->sharedTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + + $sql = " + DELETE FROM {$this->getSQLTable($name . '_perms')} + WHERE _document IN (" . \implode(', ', \array_map(fn ($index) => ":_id_{$index}", \array_keys($ids))) . ") + "; + + if ($this->sharedTables) { + $sql .= ' AND _tenant = :_tenant'; + } + + $sql = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $sql); + + $stmtPermissions = $this->getPDO()->prepare($sql); + + foreach ($ids as $id => $value) { + $stmtPermissions->bindValue(":_id_{$id}", $value); + } + + if ($this->sharedTables) { + $stmtPermissions->bindValue(':_tenant', $this->tenant); + } + + if (!$stmt->execute()) { + throw new DatabaseException('Failed to delete documents'); + } + + if (!$stmtPermissions->execute()) { + throw new DatabaseException('Failed to delete permissions'); + } + } catch (\Throwable $e) { + throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + } + + return $stmt->rowCount(); + } + /** * Find Documents * diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 69547bb97..b88d575cd 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -931,6 +931,43 @@ public function deleteDocument(string $collection, string $id): bool return (!!$result); } + /** + * Delete Documents + * + * @param string $collection + * @param array $ids + * + * @return int + */ + public function deleteDocuments(string $collection, array $ids): int + { + $name = $this->getNamespace() . '_' . $this->filter($collection); + + $filters = $this->buildFilters([new Query(Query::TYPE_EQUAL, '_uid', $ids)]); + + if ($this->sharedTables) { + $filters['_tenant'] = (string)$this->getTenant(); + } + + $filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators); + $filters = $this->timeFilter($filters); + + $options = []; + + try { + $count = $this->client->delete( + collection: $name, + filters: $filters, + options: $options, + limit: 0 + ); + } catch (MongoException $e) { + $this->processException($e); + } + + return $count ?? 0; + } + /** * Update Attribute. * @param string $collection diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index a2154bbfd..7d1746fa8 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1598,6 +1598,76 @@ public function deleteDocument(string $collection, string $id): bool return $deleted; } + + /** + * Delete Documents + * + * @param string $collection + * @param array $ids + * + * @return int + */ + public function deleteDocuments(string $collection, array $ids): int + { + try { + $name = $this->filter($collection); + $where = []; + + if ($this->sharedTables) { + $where[] = "_tenant = :_tenant"; + } + + $where[] = "_uid IN (" . \implode(', ', \array_map(fn ($index) => ":_id_{$index}", \array_keys($ids))) . ")"; + + $sql = "DELETE FROM {$this->getSQLTable($name)} WHERE " . \implode(' AND ', $where); + + $sql = $this->trigger(Database::EVENT_DOCUMENTS_DELETE, $sql); + + $stmt = $this->getPDO()->prepare($sql); + + foreach ($ids as $id => $value) { + $stmt->bindValue(":_id_{$id}", $value); + } + + if ($this->sharedTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + + $sql = " + DELETE FROM {$this->getSQLTable($name . '_perms')} + WHERE _document IN (" . \implode(', ', \array_map(fn ($id) => ":_id_{$id}", \array_keys($ids))) . ") + "; + + if ($this->sharedTables) { + $sql .= ' AND _tenant = :_tenant'; + } + + $sql = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $sql); + + $stmtPermissions = $this->getPDO()->prepare($sql); + + foreach ($ids as $id => $value) { + $stmtPermissions->bindValue(":_id_{$id}", $value); + } + + if ($this->sharedTables) { + $stmtPermissions->bindValue(':_tenant', $this->tenant); + } + + if (!$stmt->execute()) { + throw new DatabaseException('Failed to delete documents'); + } + + if (!$stmtPermissions->execute()) { + throw new DatabaseException('Failed to delete permissions'); + } + } catch (\Throwable $e) { + throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + } + + return $stmt->rowCount(); + } + /** * Find Documents * diff --git a/src/Database/Database.php b/src/Database/Database.php index 5b8093206..89170c484 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -115,6 +115,7 @@ class Database public const EVENT_DOCUMENT_FIND = 'document_find'; public const EVENT_DOCUMENT_CREATE = 'document_create'; public const EVENT_DOCUMENTS_CREATE = 'documents_create'; + public const EVENT_DOCUMENTS_DELETE = 'documents_delete'; public const EVENT_DOCUMENT_READ = 'document_read'; public const EVENT_DOCUMENT_UPDATE = 'document_update'; public const EVENT_DOCUMENTS_UPDATE = 'documents_update'; @@ -137,6 +138,7 @@ class Database public const EVENT_INDEX_DELETE = 'index_delete'; public const INSERT_BATCH_SIZE = 100; + public const DELETE_BATCH_SIZE = 100; protected Adapter $adapter; @@ -4956,7 +4958,7 @@ private function deleteCascade(Document $collection, Document $relatedCollection $this->deleteDocument( $relatedCollection->getId(), - $value->getId() + ($value instanceof Document) ? $value->getId() : $value ); \array_pop($this->relationshipDeleteStack); @@ -5031,6 +5033,88 @@ private function deleteCascade(Document $collection, Document $relatedCollection } } + /** + * Delete Documents + * + * Deletes all documents which match the given query, will respect the relationship's onDelete optin. + * + * @param string $collection + * @param array $queries + * @param int $batchSize + * + * @return int + * + * @throws AuthorizationException + * @throws DatabaseException + * @throws RestrictedException + */ + public function deleteDocuments(string $collection, array $queries = [], int $batchSize = self::DELETE_BATCH_SIZE): int + { + if ($this->adapter->getSharedTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + + $queries = Query::groupByType($queries)['filters']; + $collection = $this->silent(fn () => $this->getCollection($collection)); + $affectedDocumentIds = []; + + $deleted = $this->withTransaction(function () use ($collection, $queries, $batchSize, $affectedDocumentIds) { + $lastDocument = null; + + $documentSecurity = $collection->getAttribute('documentSecurity', false); + $skipAuth = $this->authorization->isValid(new Input(self::PERMISSION_DELETE, $collection->getDelete())); + + if (!$skipAuth && !$documentSecurity && $collection->getId() !== self::METADATA) { + throw new AuthorizationException($this->authorization->getDescription()); + } + + while (true) { + $affectedDocuments = $this->find($collection->getId(), array_merge( + empty($lastDocument) ? [ + Query::limit($batchSize), + ] : [ + Query::limit($batchSize), + Query::cursorAfter($lastDocument), + ], + $queries, + ), forPermission: Database::PERMISSION_DELETE); + + if (empty($affectedDocuments)) { + break; + } + + $affectedDocumentIds = array_merge($affectedDocumentIds, array_map(fn ($document) => $document->getId(), $affectedDocuments)); + + foreach ($affectedDocuments as $document) { + // Delete Relationships + if ($this->resolveRelationships) { + $document = $this->silent(fn () => $this->deleteDocumentRelationships($collection, $document)); + } + + $this->purgeRelatedDocuments($collection, $document->getId()); + $this->purgeCachedDocument($collection->getId(), $document->getId()); + } + + if (count($affectedDocuments) < $batchSize) { + break; + } else { + $lastDocument = end($affectedDocuments); + } + } + + if (empty($affectedDocumentIds)) { + return 0; + } + + $this->trigger(self::EVENT_DOCUMENTS_DELETE, $affectedDocumentIds); + + // Mass delete using adapter with query + return $this->adapter->deleteDocuments($collection->getId(), $affectedDocumentIds); + }); + + return $deleted; + } + /** * Cleans the all the collection's documents from the cache * And the all related cached documents. diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index bfdc2b344..f3c4b7b79 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -15567,6 +15567,649 @@ public function testTransformations(): void $this->assertTrue($result->isEmpty()); } + public function propegateBulkDocuments(bool $documentSecurity = false): void + { + for ($i = 0; $i < 10; $i++) { + static::getDatabase()->createDocument('bulk_delete', new Document( + array_merge([ + '$id' => 'doc' . $i, + 'text' => 'value' . $i, + 'integer' => $i + ], $documentSecurity ? [ + '$permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + ] : []) + )); + } + } + + public function testDeleteBulkDocuments(): void + { + static::getDatabase()->createCollection( + 'bulk_delete', + attributes: [ + new Document([ + '$id' => 'text', + 'type' => Database::VAR_STRING, + 'size' => 100, + 'required' => true, + ]), + new Document([ + '$id' => 'integer', + 'type' => Database::VAR_INTEGER, + 'size' => 10, + 'required' => true, + ]) + ], + documentSecurity: false, + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::delete(Role::any()) + ] + ); + + $this->propegateBulkDocuments(); + + $docs = static::getDatabase()->find('bulk_delete'); + $this->assertCount(10, $docs); + + // TEST: Bulk Delete All Documents + $deleted = static::getDatabase()->deleteDocuments('bulk_delete'); + $this->assertEquals(10, $deleted); + + $docs = static::getDatabase()->find('bulk_delete'); + $this->assertCount(0, $docs); + + // TEST: Bulk delete documents with queries. + $this->propegateBulkDocuments(); + + $deleted = static::getDatabase()->deleteDocuments('bulk_delete', [ + Query::greaterThanEqual('integer', 5) + ]); + $this->assertEquals(5, $deleted); + + $docs = static::getDatabase()->find('bulk_delete'); + $this->assertCount(5, $docs); + + // TEST (FAIL): Bulk delete all documents with invalid collection permission + static::getDatabase()->updateCollection('bulk_delete', [], false); + try { + static::getDatabase()->deleteDocuments('bulk_delete'); + $this->fail('Bulk deleted documents with invalid collection permission'); + } catch (\Utopia\Database\Exception\Authorization) { + } + + static::getDatabase()->updateCollection('bulk_delete', [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::delete(Role::any()) + ], false); + $deleted = static::getDatabase()->deleteDocuments('bulk_delete'); + + $this->assertEquals(5, $deleted); + $this->assertEquals(0, count($this->getDatabase()->find('bulk_delete'))); + + // TEST: Make sure we can't delete documents we don't have permissions for + static::getDatabase()->updateCollection('bulk_delete', [ + Permission::create(Role::any()), + ], true); + $this->propegateBulkDocuments(true); + + $deleted = static::getDatabase()->deleteDocuments('bulk_delete'); + $this->assertEquals(0, $deleted); + + $documents = static::$authorization->skip(function () { + return static::getDatabase()->find('bulk_delete'); + }); + + $this->assertCount(10, $documents); + + static::getDatabase()->updateCollection('bulk_delete', [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::delete(Role::any()) + ], false); + static::getDatabase()->deleteDocuments('bulk_delete'); + $this->assertEquals(0, count($this->getDatabase()->find('bulk_delete'))); + + // Teardown + static::getDatabase()->deleteCollection('bulk_delete'); + } + + public function testDeleteBulkDocumentsOneToOneRelationship(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->getDatabase()->createCollection('bulk_delete_person_o2o'); + $this->getDatabase()->createCollection('bulk_delete_library_o2o'); + + $this->getDatabase()->createAttribute('bulk_delete_person_o2o', 'name', Database::VAR_STRING, 255, true); + $this->getDatabase()->createAttribute('bulk_delete_library_o2o', 'name', Database::VAR_STRING, 255, true); + $this->getDatabase()->createAttribute('bulk_delete_library_o2o', 'area', Database::VAR_STRING, 255, true); + + // Restrict + $this->getDatabase()->createRelationship( + collection: 'bulk_delete_person_o2o', + relatedCollection: 'bulk_delete_library_o2o', + type: Database::RELATION_ONE_TO_ONE, + onDelete: Database::RELATION_MUTATE_RESTRICT + ); + + $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2o', new Document([ + '$id' => 'person1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Person 1', + 'bulk_delete_library_o2o' => [ + '$id' => 'library1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Library 1', + 'area' => 'Area 1', + ], + ])); + + $person1 = $this->getDatabase()->getDocument('bulk_delete_person_o2o', 'person1'); + $library = $person1->getAttribute('bulk_delete_library_o2o'); + $this->assertEquals('library1', $library['$id']); + $this->assertArrayNotHasKey('bulk_delete_person_o2o', $library); + + // Delete person + try { + $this->getDatabase()->deleteDocuments('bulk_delete_person_o2o'); + $this->fail('Failed to throw exception'); + } catch (RestrictedException $e) { + $this->assertEquals('Cannot delete document because it has at least one related document.', $e->getMessage()); + } + + $this->getDatabase()->updateDocument('bulk_delete_person_o2o', 'person1', new Document([ + '$id' => 'person1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Person 1', + 'bulk_delete_library_o2o' => null, + ])); + + $this->getDatabase()->deleteDocuments('bulk_delete_person_o2o'); + $this->assertCount(0, $this->getDatabase()->find('bulk_delete_person_o2o')); + $this->getDatabase()->deleteDocuments('bulk_delete_library_o2o'); + $this->assertCount(0, $this->getDatabase()->find('bulk_delete_library_o2o')); + + // NULL + $this->getDatabase()->updateRelationship( + collection: 'bulk_delete_person_o2o', + id: 'bulk_delete_library_o2o', + onDelete: Database::RELATION_MUTATE_SET_NULL + ); + + $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2o', new Document([ + '$id' => 'person1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Person 1', + 'bulk_delete_library_o2o' => [ + '$id' => 'library1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Library 1', + 'area' => 'Area 1', + ], + ])); + + $person1 = $this->getDatabase()->getDocument('bulk_delete_person_o2o', 'person1'); + $library = $person1->getAttribute('bulk_delete_library_o2o'); + $this->assertEquals('library1', $library['$id']); + $this->assertArrayNotHasKey('bulk_delete_person_o2o', $library); + + $person = $this->getDatabase()->getDocument('bulk_delete_person_o2o', 'person1'); + + $this->getDatabase()->deleteDocuments('bulk_delete_library_o2o'); + $this->assertCount(0, $this->getDatabase()->find('bulk_delete_library_o2o')); + $this->assertCount(1, $this->getDatabase()->find('bulk_delete_person_o2o')); + + $person = $this->getDatabase()->getDocument('bulk_delete_person_o2o', 'person1'); + $library = $person->getAttribute('bulk_delete_library_o2o'); + $this->assertNull($library); + + // NULL - Cleanup + $this->getDatabase()->deleteDocuments('bulk_delete_person_o2o'); + $this->assertCount(0, $this->getDatabase()->find('bulk_delete_person_o2o')); + $this->getDatabase()->deleteDocuments('bulk_delete_library_o2o'); + $this->assertCount(0, $this->getDatabase()->find('bulk_delete_library_o2o')); + + // Cascade + $this->getDatabase()->updateRelationship( + collection: 'bulk_delete_person_o2o', + id: 'bulk_delete_library_o2o', + onDelete: Database::RELATION_MUTATE_CASCADE + ); + + $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2o', new Document([ + '$id' => 'person1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Person 1', + 'bulk_delete_library_o2o' => [ + '$id' => 'library1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Library 1', + 'area' => 'Area 1', + ], + ])); + + $person1 = $this->getDatabase()->getDocument('bulk_delete_person_o2o', 'person1'); + $library = $person1->getAttribute('bulk_delete_library_o2o'); + $this->assertEquals('library1', $library['$id']); + $this->assertArrayNotHasKey('bulk_delete_person_o2o', $library); + + $person = $this->getDatabase()->getDocument('bulk_delete_person_o2o', 'person1'); + + $this->getDatabase()->deleteDocuments('bulk_delete_library_o2o'); + $this->assertCount(0, $this->getDatabase()->find('bulk_delete_library_o2o')); + $this->assertCount(1, $this->getDatabase()->find('bulk_delete_person_o2o')); + + $person = $this->getDatabase()->getDocument('bulk_delete_person_o2o', 'person1'); + $library = $person->getAttribute('bulk_delete_library_o2o'); + $this->assertEmpty($library); + $this->assertNotNull($library); + + // Test Bulk delete parent + $this->getDatabase()->deleteDocuments('bulk_delete_person_o2o'); + $this->assertCount(0, $this->getDatabase()->find('bulk_delete_person_o2o')); + + $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2o', new Document([ + '$id' => 'person1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Person 1', + 'bulk_delete_library_o2o' => [ + '$id' => 'library1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Library 1', + 'area' => 'Area 1', + ], + ])); + + $person1 = $this->getDatabase()->getDocument('bulk_delete_person_o2o', 'person1'); + $library = $person1->getAttribute('bulk_delete_library_o2o'); + $this->assertEquals('library1', $library['$id']); + $this->assertArrayNotHasKey('bulk_delete_person_o2o', $library); + + $this->getDatabase()->deleteDocuments('bulk_delete_person_o2o'); + $this->assertCount(0, $this->getDatabase()->find('bulk_delete_person_o2o')); + $this->assertCount(0, $this->getDatabase()->find('bulk_delete_library_o2o')); + } + + public function testDeleteBulkDocumentsOneToManyRelationship(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->getDatabase()->createCollection('bulk_delete_person_o2m'); + $this->getDatabase()->createCollection('bulk_delete_library_o2m'); + + $this->getDatabase()->createAttribute('bulk_delete_person_o2m', 'name', Database::VAR_STRING, 255, true); + $this->getDatabase()->createAttribute('bulk_delete_library_o2m', 'name', Database::VAR_STRING, 255, true); + $this->getDatabase()->createAttribute('bulk_delete_library_o2m', 'area', Database::VAR_STRING, 255, true); + + // Restrict + $this->getDatabase()->createRelationship( + collection: 'bulk_delete_person_o2m', + relatedCollection: 'bulk_delete_library_o2m', + type: Database::RELATION_ONE_TO_MANY, + onDelete: Database::RELATION_MUTATE_RESTRICT + ); + + $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2m', new Document([ + '$id' => 'person1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Person 1', + 'bulk_delete_library_o2m' => [ + [ + '$id' => 'library1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Library 1', + 'area' => 'Area 1', + ], + [ + '$id' => 'library2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Library 2', + 'area' => 'Area 2', + ], + ], + ])); + + $person1 = $this->getDatabase()->getDocument('bulk_delete_person_o2m', 'person1'); + $libraries = $person1->getAttribute('bulk_delete_library_o2m'); + $this->assertCount(2, $libraries); + + // Delete person + try { + $this->getDatabase()->deleteDocuments('bulk_delete_person_o2m'); + $this->fail('Failed to throw exception'); + } catch (RestrictedException $e) { + $this->assertEquals('Cannot delete document because it has at least one related document.', $e->getMessage()); + } + + // Restrict Cleanup + $this->getDatabase()->deleteDocuments('bulk_delete_library_o2m'); + $this->assertCount(0, $this->getDatabase()->find('bulk_delete_library_o2m')); + + $this->getDatabase()->deleteDocuments('bulk_delete_person_o2m'); + $this->assertCount(0, $this->getDatabase()->find('bulk_delete_person_o2m')); + + // NULL + $this->getDatabase()->updateRelationship( + collection: 'bulk_delete_person_o2m', + id: 'bulk_delete_library_o2m', + onDelete: Database::RELATION_MUTATE_SET_NULL + ); + + $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2m', new Document([ + '$id' => 'person1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Person 1', + 'bulk_delete_library_o2m' => [ + [ + '$id' => 'library1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Library 1', + 'area' => 'Area 1', + ], + [ + '$id' => 'library2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Library 2', + 'area' => 'Area 2', + ], + ], + ])); + + $person1 = $this->getDatabase()->getDocument('bulk_delete_person_o2m', 'person1'); + $libraries = $person1->getAttribute('bulk_delete_library_o2m'); + $this->assertCount(2, $libraries); + + $this->getDatabase()->deleteDocuments('bulk_delete_library_o2m'); + $this->assertCount(0, $this->getDatabase()->find('bulk_delete_library_o2m')); + + $person = $this->getDatabase()->getDocument('bulk_delete_person_o2m', 'person1'); + $libraries = $person->getAttribute('bulk_delete_library_o2m'); + $this->assertEmpty($libraries); + + // NULL - Cleanup + $this->getDatabase()->deleteDocuments('bulk_delete_person_o2m'); + $this->assertCount(0, $this->getDatabase()->find('bulk_delete_person_o2m')); + + + // Cascade + $this->getDatabase()->updateRelationship( + collection: 'bulk_delete_person_o2m', + id: 'bulk_delete_library_o2m', + onDelete: Database::RELATION_MUTATE_CASCADE + ); + + $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2m', new Document([ + '$id' => 'person1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Person 1', + 'bulk_delete_library_o2m' => [ + [ + '$id' => 'library1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Library 1', + 'area' => 'Area 1', + ], + [ + '$id' => 'library2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Library 2', + 'area' => 'Area 2', + ], + ], + ])); + + $person1 = $this->getDatabase()->getDocument('bulk_delete_person_o2m', 'person1'); + $libraries = $person1->getAttribute('bulk_delete_library_o2m'); + $this->assertCount(2, $libraries); + + $this->getDatabase()->deleteDocuments('bulk_delete_library_o2m'); + $this->assertCount(0, $this->getDatabase()->find('bulk_delete_library_o2m')); + + $person = $this->getDatabase()->getDocument('bulk_delete_person_o2m', 'person1'); + $libraries = $person->getAttribute('bulk_delete_library_o2m'); + $this->assertEmpty($libraries); + } + + public function testDeleteBulkDocumentsManyToManyRelationship(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->getDatabase()->createCollection('bulk_delete_person_m2m'); + $this->getDatabase()->createCollection('bulk_delete_library_m2m'); + + $this->getDatabase()->createAttribute('bulk_delete_person_m2m', 'name', Database::VAR_STRING, 255, true); + $this->getDatabase()->createAttribute('bulk_delete_library_m2m', 'name', Database::VAR_STRING, 255, true); + $this->getDatabase()->createAttribute('bulk_delete_library_m2m', 'area', Database::VAR_STRING, 255, true); + + // Many-to-Many Relationship + $this->getDatabase()->createRelationship( + collection: 'bulk_delete_person_m2m', + relatedCollection: 'bulk_delete_library_m2m', + type: Database::RELATION_MANY_TO_MANY, + onDelete: Database::RELATION_MUTATE_RESTRICT + ); + + $person1 = $this->getDatabase()->createDocument('bulk_delete_person_m2m', new Document([ + '$id' => 'person1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Person 1', + 'bulk_delete_library_m2m' => [ + [ + '$id' => 'library1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Library 1', + 'area' => 'Area 1', + ], + [ + '$id' => 'library2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Library 2', + 'area' => 'Area 2', + ], + ], + ])); + + $person1 = $this->getDatabase()->getDocument('bulk_delete_person_m2m', 'person1'); + $libraries = $person1->getAttribute('bulk_delete_library_m2m'); + $this->assertCount(2, $libraries); + + // Delete person + try { + $this->getDatabase()->deleteDocuments('bulk_delete_person_m2m'); + $this->fail('Failed to throw exception'); + } catch (RestrictedException $e) { + $this->assertEquals('Cannot delete document because it has at least one related document.', $e->getMessage()); + } + + // Restrict Cleanup + $this->getDatabase()->deleteRelationship('bulk_delete_person_m2m', 'bulk_delete_library_m2m'); + $this->getDatabase()->deleteDocuments('bulk_delete_library_m2m'); + $this->assertCount(0, $this->getDatabase()->find('bulk_delete_library_m2m')); + + $this->getDatabase()->deleteDocuments('bulk_delete_person_m2m'); + $this->assertCount(0, $this->getDatabase()->find('bulk_delete_person_m2m')); + } + + public function testDeleteBulkDocumentsManyToOneRelationship(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + $this->getDatabase()->createCollection('bulk_delete_person_m2o'); + $this->getDatabase()->createCollection('bulk_delete_library_m2o'); + + $this->getDatabase()->createAttribute('bulk_delete_person_m2o', 'name', Database::VAR_STRING, 255, true); + $this->getDatabase()->createAttribute('bulk_delete_library_m2o', 'name', Database::VAR_STRING, 255, true); + $this->getDatabase()->createAttribute('bulk_delete_library_m2o', 'area', Database::VAR_STRING, 255, true); + + // Many-to-One Relationship + $this->getDatabase()->createRelationship( + collection: 'bulk_delete_person_m2o', + relatedCollection: 'bulk_delete_library_m2o', + type: Database::RELATION_MANY_TO_ONE, + onDelete: Database::RELATION_MUTATE_RESTRICT + ); + + $person1 = $this->getDatabase()->createDocument('bulk_delete_person_m2o', new Document([ + '$id' => 'person1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Person 1', + 'bulk_delete_library_m2o' => [ + '$id' => 'library1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Library 1', + 'area' => 'Area 1', + ], + ])); + + $person2 = $this->getDatabase()->createDocument('bulk_delete_person_m2o', new Document([ + '$id' => 'person2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Person 2', + 'bulk_delete_library_m2o' => [ + '$id' => 'library1', + ] + ])); + + $person1 = $this->getDatabase()->getDocument('bulk_delete_person_m2o', 'person1'); + $library = $person1->getAttribute('bulk_delete_library_m2o'); + $this->assertEquals('library1', $library['$id']); + + // Delete library + try { + $this->getDatabase()->deleteDocuments('bulk_delete_library_m2o'); + $this->fail('Failed to throw exception'); + } catch (RestrictedException $e) { + $this->assertEquals('Cannot delete document because it has at least one related document.', $e->getMessage()); + } + + $this->assertEquals(2, count($this->getDatabase()->find('bulk_delete_person_m2o'))); + + // Test delete people + $this->getDatabase()->deleteDocuments('bulk_delete_person_m2o'); + $this->assertEquals(0, count($this->getDatabase()->find('bulk_delete_person_m2o'))); + + // Restrict Cleanup + $this->getDatabase()->deleteDocuments('bulk_delete_library_m2o'); + $this->assertCount(0, $this->getDatabase()->find('bulk_delete_library_m2o')); + + $this->getDatabase()->deleteDocuments('bulk_delete_person_m2o'); + $this->assertCount(0, $this->getDatabase()->find('bulk_delete_person_m2o')); + } + public function testUpdateDocuments(): void { if (!static::getDatabase()->getAdapter()->getSupportForBatchOperations()) {