Skip to content

Commit f7a26a2

Browse files
authored
Merge pull request #622 from utopia-php/dat-569
Add error handling callbacks for batch document operations
2 parents 8e5038a + d38bec2 commit f7a26a2

7 files changed

Lines changed: 391 additions & 23 deletions

File tree

src/Database/Database.php

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5734,6 +5734,7 @@ private function deleteCascade(Document $collection, Document $relatedCollection
57345734
* @param array<Query> $queries
57355735
* @param int $batchSize
57365736
* @param callable|null $onNext
5737+
* @param callable|null $onError
57375738
* @return int
57385739
* @throws AuthorizationException
57395740
* @throws DatabaseException
@@ -5745,6 +5746,7 @@ public function deleteDocuments(
57455746
array $queries = [],
57465747
int $batchSize = self::DELETE_BATCH_SIZE,
57475748
?callable $onNext = null,
5749+
?callable $onError = null,
57485750
): int {
57495751
if ($this->adapter->getSharedTables() && empty($this->adapter->getTenant())) {
57505752
throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.');
@@ -5825,32 +5827,33 @@ public function deleteDocuments(
58255827

58265828
$sequences = [];
58275829
$permissionIds = [];
5828-
foreach ($batch as $document) {
5829-
$sequences[] = $document->getSequence();
5830-
if (!empty($document->getPermissions())) {
5831-
$permissionIds[] = $document->getId();
5832-
}
58335830

5834-
if ($this->resolveRelationships) {
5835-
$document = $this->silent(fn () => $this->deleteDocumentRelationships(
5836-
$collection,
5837-
$document
5838-
));
5839-
}
5831+
$this->withTransaction(function () use ($collection, $sequences, $permissionIds, $batch) {
5832+
foreach ($batch as $document) {
5833+
$sequences[] = $document->getSequence();
5834+
if (!empty($document->getPermissions())) {
5835+
$permissionIds[] = $document->getId();
5836+
}
58405837

5841-
// Check if document was updated after the request timestamp
5842-
try {
5843-
$oldUpdatedAt = new \DateTime($document->getUpdatedAt());
5844-
} catch (Exception $e) {
5845-
throw new DatabaseException($e->getMessage(), $e->getCode(), $e);
5846-
}
5838+
if ($this->resolveRelationships) {
5839+
$document = $this->silent(fn () => $this->deleteDocumentRelationships(
5840+
$collection,
5841+
$document
5842+
));
5843+
}
58475844

5848-
if (!\is_null($this->timestamp) && $oldUpdatedAt > $this->timestamp) {
5849-
throw new ConflictException('Document was updated after the request timestamp');
5845+
// Check if document was updated after the request timestamp
5846+
try {
5847+
$oldUpdatedAt = new \DateTime($document->getUpdatedAt());
5848+
} catch (Exception $e) {
5849+
throw new DatabaseException($e->getMessage(), $e->getCode(), $e);
5850+
}
5851+
5852+
if (!\is_null($this->timestamp) && $oldUpdatedAt > $this->timestamp) {
5853+
throw new ConflictException('Document was updated after the request timestamp');
5854+
}
58505855
}
5851-
}
58525856

5853-
$this->withTransaction(function () use ($collection, $sequences, $permissionIds) {
58545857
$this->adapter->deleteDocuments(
58555858
$collection->getId(),
58565859
$sequences,
@@ -5866,8 +5869,11 @@ public function deleteDocuments(
58665869
} else {
58675870
$this->purgeCachedDocument($collection->getId(), $document->getId());
58685871
}
5869-
5870-
$onNext && $onNext($document);
5872+
try {
5873+
$onNext && $onNext($document);
5874+
} catch (Throwable $th) {
5875+
$onError ? $onError($th) : throw $th;
5876+
}
58715877
$modified++;
58725878
}
58735879

src/Database/Mirror.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,7 @@ public function deleteDocuments(
829829
array $queries = [],
830830
int $batchSize = self::DELETE_BATCH_SIZE,
831831
?callable $onNext = null,
832+
?callable $onError = null,
832833
): int {
833834
$modified = 0;
834835

@@ -840,6 +841,7 @@ function ($doc) use (&$modified, $onNext) {
840841
$onNext && $onNext($doc);
841842
$modified++;
842843
},
844+
$onError
843845
);
844846

845847
if (

tests/e2e/Adapter/Scopes/DocumentTests.php

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4214,6 +4214,130 @@ public function testDeleteBulkDocumentsQueries(): void
42144214
$database->deleteCollection('bulk_delete_queries');
42154215
}
42164216

4217+
public function testDeleteBulkDocumentsWithCallbackSupport(): void
4218+
{
4219+
/** @var Database $database */
4220+
$database = static::getDatabase();
4221+
4222+
if (!$database->getAdapter()->getSupportForBatchOperations()) {
4223+
$this->expectNotToPerformAssertions();
4224+
return;
4225+
}
4226+
4227+
$database->createCollection(
4228+
'bulk_delete_with_callback',
4229+
attributes: [
4230+
new Document([
4231+
'$id' => 'text',
4232+
'type' => Database::VAR_STRING,
4233+
'size' => 100,
4234+
'required' => true,
4235+
]),
4236+
new Document([
4237+
'$id' => 'integer',
4238+
'type' => Database::VAR_INTEGER,
4239+
'size' => 10,
4240+
'required' => true,
4241+
])
4242+
],
4243+
permissions: [
4244+
Permission::create(Role::any()),
4245+
Permission::read(Role::any()),
4246+
Permission::delete(Role::any())
4247+
],
4248+
documentSecurity: false
4249+
);
4250+
4251+
$this->propagateBulkDocuments('bulk_delete_with_callback');
4252+
4253+
$docs = $database->find('bulk_delete_with_callback');
4254+
$this->assertCount(10, $docs);
4255+
4256+
/**
4257+
* Test Short select query, test pagination as well, Add order to select
4258+
*/
4259+
$selects = ['$sequence', '$id', '$collection', '$permissions', '$updatedAt'];
4260+
4261+
try {
4262+
// a non existent document to test the error thrown
4263+
$database->deleteDocuments(
4264+
collection: 'bulk_delete_with_callback',
4265+
queries: [
4266+
Query::select([...$selects, '$createdAt']),
4267+
Query::lessThan('$createdAt', '1800-01-01'),
4268+
Query::orderAsc('$createdAt'),
4269+
Query::orderAsc(),
4270+
Query::limit(1),
4271+
],
4272+
batchSize: 1,
4273+
onNext: function () {
4274+
throw new Exception("Error thrown to test that deletion doesn't stop and error is caught");
4275+
}
4276+
);
4277+
} catch (Exception $e) {
4278+
$this->assertInstanceOf(Exception::class, $e);
4279+
$this->assertEquals("Error thrown to test that deletion doesn't stop and error is caught", $e->getMessage());
4280+
}
4281+
4282+
$docs = $database->find('bulk_delete_with_callback');
4283+
$this->assertCount(10, $docs);
4284+
4285+
$count = $database->deleteDocuments(
4286+
collection: 'bulk_delete_with_callback',
4287+
queries: [
4288+
Query::select([...$selects, '$createdAt']),
4289+
Query::cursorAfter($docs[6]),
4290+
Query::greaterThan('$createdAt', '2000-01-01'),
4291+
Query::orderAsc('$createdAt'),
4292+
Query::orderAsc(),
4293+
Query::limit(2),
4294+
],
4295+
batchSize: 1,
4296+
onNext: function () {
4297+
// simulating error throwing but should not stop deletion
4298+
throw new Exception("Error thrown to test that deletion doesn't stop and error is caught");
4299+
},
4300+
onError:function ($e) {
4301+
$this->assertInstanceOf(Exception::class, $e);
4302+
$this->assertEquals("Error thrown to test that deletion doesn't stop and error is caught", $e->getMessage());
4303+
}
4304+
);
4305+
4306+
$this->assertEquals(2, $count);
4307+
4308+
// TEST: Bulk Delete All Documents without passing callbacks
4309+
$this->assertEquals(8, $database->deleteDocuments('bulk_delete_with_callback'));
4310+
4311+
$docs = $database->find('bulk_delete_with_callback');
4312+
$this->assertCount(0, $docs);
4313+
4314+
// TEST: Bulk delete documents with queries with callbacks
4315+
$this->propagateBulkDocuments('bulk_delete_with_callback');
4316+
4317+
$results = [];
4318+
$count = $database->deleteDocuments('bulk_delete_with_callback', [
4319+
Query::greaterThanEqual('integer', 5)
4320+
], onNext: function ($doc) use (&$results) {
4321+
$results[] = $doc;
4322+
throw new Exception("Error thrown to test that deletion doesn't stop and error is caught");
4323+
}, onError:function ($e) {
4324+
$this->assertInstanceOf(Exception::class, $e);
4325+
$this->assertEquals("Error thrown to test that deletion doesn't stop and error is caught", $e->getMessage());
4326+
});
4327+
4328+
$this->assertEquals(5, $count);
4329+
4330+
foreach ($results as $document) {
4331+
$this->assertGreaterThanOrEqual(5, $document->getAttribute('integer'));
4332+
}
4333+
4334+
$docs = $database->find('bulk_delete_with_callback');
4335+
$this->assertEquals(5, \count($docs));
4336+
4337+
// Teardown
4338+
$database->deleteCollection('bulk_delete_with_callback');
4339+
}
4340+
42174341
public function testUpdateDocumentsQueries(): void
42184342
{
42194343
/** @var Database $database */

tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1681,4 +1681,63 @@ public function testUpdateParentAndChild_ManyToMany(): void
16811681
}
16821682

16831683

1684+
public function testDeleteDocumentsRelationshipErrorDoesNotDeleteParent_ManyToMany(): void
1685+
{
1686+
/** @var Database $database */
1687+
$database = static::getDatabase();
1688+
1689+
if (!$database->getAdapter()->getSupportForRelationships() || !$database->getAdapter()->getSupportForBatchOperations()) {
1690+
$this->expectNotToPerformAssertions();
1691+
return;
1692+
}
1693+
1694+
$parentCollection = 'parent_relationship_many_to_many';
1695+
$childCollection = 'child_relationship_many_to_many';
1696+
1697+
$database->createCollection($parentCollection);
1698+
$database->createCollection($childCollection);
1699+
$database->createAttribute($parentCollection, 'name', Database::VAR_STRING, 255, true);
1700+
$database->createAttribute($childCollection, 'name', Database::VAR_STRING, 255, true);
1701+
1702+
$database->createRelationship(
1703+
collection: $parentCollection,
1704+
relatedCollection: $childCollection,
1705+
type: Database::RELATION_MANY_TO_MANY,
1706+
onDelete: Database::RELATION_MUTATE_RESTRICT
1707+
);
1708+
1709+
$parent = $database->createDocument($parentCollection, new Document([
1710+
'$id' => 'parent1',
1711+
'$permissions' => [
1712+
Permission::read(Role::any()),
1713+
Permission::update(Role::any()),
1714+
Permission::delete(Role::any()),
1715+
],
1716+
'name' => 'Parent 1',
1717+
$childCollection => [
1718+
[
1719+
'$id' => 'child1',
1720+
'$permissions' => [
1721+
Permission::read(Role::any()),
1722+
Permission::update(Role::any()),
1723+
Permission::delete(Role::any()),
1724+
],
1725+
'name' => 'Child 1',
1726+
]
1727+
]
1728+
]));
1729+
1730+
try {
1731+
$database->deleteDocuments($parentCollection, [Query::equal('$id', ['parent1'])]);
1732+
$this->fail('Expected exception was not thrown');
1733+
} catch (RestrictedException $e) {
1734+
$this->assertEquals('Cannot delete document because it has at least one related document.', $e->getMessage());
1735+
}
1736+
$parentDoc = $database->getDocument($parentCollection, 'parent1');
1737+
$childDoc = $database->getDocument($childCollection, 'child1');
1738+
$this->assertFalse($parentDoc->isEmpty(), 'Parent should not be deleted');
1739+
$this->assertFalse($childDoc->isEmpty(), 'Child should not be deleted');
1740+
$database->deleteCollection($parentCollection);
1741+
$database->deleteCollection($childCollection);
1742+
}
16841743
}

tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1759,4 +1759,64 @@ public function testUpdateParentAndChild_ManyToOne(): void
17591759
$database->deleteCollection($parentCollection);
17601760
$database->deleteCollection($childCollection);
17611761
}
1762+
1763+
public function testDeleteDocumentsRelationshipErrorDoesNotDeleteParent_ManyToOne(): void
1764+
{
1765+
/** @var Database $database */
1766+
$database = static::getDatabase();
1767+
1768+
if (!$database->getAdapter()->getSupportForRelationships() || !$database->getAdapter()->getSupportForBatchOperations()) {
1769+
$this->expectNotToPerformAssertions();
1770+
return;
1771+
}
1772+
1773+
$parentCollection = 'parent_relationship_error_many_to_one';
1774+
$childCollection = 'child_relationship_error_many_to_one';
1775+
1776+
$database->createCollection($parentCollection);
1777+
$database->createCollection($childCollection);
1778+
$database->createAttribute($parentCollection, 'name', Database::VAR_STRING, 255, true);
1779+
$database->createAttribute($childCollection, 'name', Database::VAR_STRING, 255, true);
1780+
1781+
$database->createRelationship(
1782+
collection: $childCollection,
1783+
relatedCollection: $parentCollection,
1784+
type: Database::RELATION_MANY_TO_ONE,
1785+
onDelete: Database::RELATION_MUTATE_RESTRICT
1786+
);
1787+
1788+
$parent = $database->createDocument($parentCollection, new Document([
1789+
'$id' => 'parent1',
1790+
'$permissions' => [
1791+
Permission::read(Role::any()),
1792+
Permission::update(Role::any()),
1793+
Permission::delete(Role::any()),
1794+
],
1795+
'name' => 'Parent 1',
1796+
]));
1797+
1798+
$child = $database->createDocument($childCollection, new Document([
1799+
'$id' => 'child1',
1800+
'$permissions' => [
1801+
Permission::read(Role::any()),
1802+
Permission::update(Role::any()),
1803+
Permission::delete(Role::any()),
1804+
],
1805+
'name' => 'Child 1',
1806+
$parentCollection => 'parent1'
1807+
]));
1808+
1809+
try {
1810+
$database->deleteDocuments($parentCollection, [Query::equal('$id', ['parent1'])]);
1811+
$this->fail('Expected exception was not thrown');
1812+
} catch (RestrictedException $e) {
1813+
$this->assertEquals('Cannot delete document because it has at least one related document.', $e->getMessage());
1814+
}
1815+
$parentDoc = $database->getDocument($parentCollection, 'parent1');
1816+
$childDoc = $database->getDocument($childCollection, 'child1');
1817+
$this->assertFalse($parentDoc->isEmpty(), 'Parent should not be deleted');
1818+
$this->assertFalse($childDoc->isEmpty(), 'Child should not be deleted');
1819+
$database->deleteCollection($parentCollection);
1820+
$database->deleteCollection($childCollection);
1821+
}
17621822
}

0 commit comments

Comments
 (0)