Skip to content

Commit b08adce

Browse files
committed
Merge branch 'main' of github.com:utopia-php/database into collection-level-column-permissions
# Conflicts: # src/Database/Trait/Documents.php
2 parents 397034a + d4b2787 commit b08adce

38 files changed

Lines changed: 4068 additions & 148 deletions

‎CHANGELOG.md‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,46 @@
11
# Changelog
22

3+
## 8.1.4
4+
5+
8.1.4 fixes `deleteDocuments()` deleting documents that stopped matching its queries after they were found. It needs
6+
no code change; a custom adapter can opt in to the fix, see
7+
[Upgrading from 8.1.3 to 8.1.4](UPGRADE.md#upgrading-from-813-to-814).
8+
9+
### Added
10+
11+
- `Adapter::deleteDocumentsMatching(Document $collection, array $sequences, array $permissionIds, array $queries): int`
12+
deletes the rows of `$sequences` that match every filter query in `$queries`, as `find()` is handed them, removes
13+
the permission rows of `$permissionIds` but those of a row kept because it no longer matches, and returns the
14+
number of rows deleted. It calls `deleteDocuments()` with the queries readable through the new protected
15+
`Adapter::deletionFilters()`, so an override of `deleteDocuments()` still runs, on a `Pool` too. Memory, SQLite,
16+
MariaDB, MySQL, PostgreSQL, MongoDB and Redis delete only the rows that match them; `Pool` and `ReadWritePool` hand
17+
them to the pooled adapter. A custom adapter that ignores them deletes every row of `$sequences`, as before.
18+
19+
### Fixed
20+
21+
- `deleteDocuments()` deletes only the documents that still match its filters when each batch is deleted. It found a
22+
batch and then deleted it by internal id alone, so a document another write changed in between, so that it no
23+
longer matched (such as a presence refreshed before an expired-presence cleanup deleted it), was deleted anyway,
24+
cascaded to its related documents, lost its permission rows, and was counted and handed to `onNext`. Each batch
25+
with filters on anything but `$sequence` now reads its documents again, with the same queries, in the transaction
26+
that deletes them, and deletes them with `Adapter::deleteDocumentsMatching()`. A document that no longer matches is
27+
kept with its related documents and permission rows, and is neither counted, handed to `onNext` nor in
28+
`Event::DocumentsDelete`'s count. A limit counts the documents found, kept ones included, so a kept document is not
29+
replaced by a later one. When a document changes between that read and the delete, the batch is rolled back and
30+
tried again, and fails with `Exception\Contention` once the transaction's retries are spent; the batches before it
31+
stay deleted and `Event::DocumentsDelete` reports them. In a transaction begun with `startTransaction()` the retry
32+
runs in a savepoint, which on MySQL and MariaDB reads the transaction's snapshot again and fails the same way; a
33+
MongoDB replica set has no savepoints, so the batch is not retried and its partial delete stays in the transaction.
34+
Either way the caller rolls the whole transaction back and runs it again. Without transactions, as on a standalone
35+
MongoDB server, nothing is rolled back: the documents still stored are kept and left out of the count, `onNext`,
36+
the cache purges and the event, the remaining batches are deleted, a document another deleter removed meanwhile is
37+
counted by this call, and a cascade that already ran for a kept document stays, so cascades are best-effort there.
38+
The adapter's count is a hint: only a document of the batch still stored after the delete counts as changed, so a
39+
custom adapter that reports a wrong count does not fail the delete. A delete without filters, or with filters only
40+
on `$sequence`, runs as before.
41+
- A bulk delete on SQLite, MariaDB, MySQL and PostgreSQL binds the floats of its filters in fixed-point notation, as
42+
`find()` binds them, so it matches the rows the find matched.
43+
344
## 8.1.3
445

546
8.1.3 adds a hook that rewrites the queries of a read. It changes nothing for a database without one; see

‎UPGRADE.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,25 @@
1+
# Upgrading from 8.1.3 to 8.1.4
2+
3+
8.1.4 is a patch release and needs no code change. `Database::deleteDocuments()` keeps its signature and now deletes
4+
only the documents that still match its filters when they are deleted; see [CHANGELOG.md](CHANGELOG.md#814).
5+
6+
`Adapter::deleteDocuments()` keeps its signature. A batch with filters reaches the adapter through the new
7+
`Adapter::deleteDocumentsMatching()`, which calls `deleteDocuments()` with the filters readable through
8+
`$this->deletionFilters()`, so an override of `deleteDocuments()` in a subclass of a bundled adapter still runs and,
9+
when it calls its parent, deletes only the rows that match. A custom adapter that extends `Adapter` directly deletes
10+
every row it is handed, as in 8.1.3; to keep a document another write changed after it was found, delete only the
11+
rows that match every query of `deletionFilters()` (filter queries, as `find()` is handed them), keep the permission
12+
rows of a row kept, and return the number of rows deleted.
13+
14+
The count `deleteDocuments()` returns is a hint: a lower count than the rows handed makes `Database::deleteDocuments()`
15+
check which documents are still stored, and a higher one is ignored, so a custom adapter that counts differently still
16+
works.
17+
18+
`deleteDocuments()` can now throw `Exception\Contention` when documents keep changing between the check and the delete
19+
of a batch. Inside a transaction begun with `startTransaction()`, roll it back and run it again: on MySQL and MariaDB
20+
the retry in the savepoint rereads the same snapshot, and a MongoDB replica set has no savepoint, so the partial
21+
delete of the batch stays in the transaction until it is rolled back.
22+
123
# Upgrading from 8.1.2 to 8.1.3
224

325
8.1.3 is a patch release and needs no code change. It adds `Hook\Queries`, which rewrites the queries of a read

‎composer.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
"utopia-php/cache": "^4.0 || ^5.0",
5555
"utopia-php/pools": "^2.0",
5656
"utopia-php/mongo": "^1.5.4",
57-
"utopia-php/query": "^0.7",
57+
"utopia-php/query": "^0.7.1",
5858
"utopia-php/async": "^0.2"
5959
},
6060
"require-dev": {

‎composer.lock‎

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/Database/Adapter.php‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ abstract class Adapter
4242
/** @var Value<bool>|null */
4343
private ?Value $ignoringDuplicates = null;
4444

45+
/** @var Value<array<Query>>|null */
46+
private ?Value $deletionFilters = null;
47+
4548
protected bool $tenantPerDocument = false;
4649

4750
protected int $inTransaction = 0;
@@ -773,9 +776,57 @@ abstract public function deleteDocument(Document $collection, string $id): bool;
773776
/**
774777
* @param array<string> $sequences
775778
* @param array<string> $permissionIds
779+
* @return int The number of document rows deleted. Database::deleteDocuments() takes it as a hint: a count below
780+
* the sequences given makes it check which of the documents are still stored, and a higher one is
781+
* ignored.
776782
*/
777783
abstract public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int;
778784

785+
/**
786+
* Delete the rows of the sequences given that match every filter query, and the permission rows of the
787+
* permission ids given but those of a row kept because it no longer matches. Database::deleteDocuments() calls
788+
* it for a batch it deletes with filters, so that a document another write changed after it was found is kept.
789+
*
790+
* This implementation calls deleteDocuments() with the queries as its deletionFilters(), so that an override of
791+
* deleteDocuments() still runs. The bundled adapters delete only the rows that match them; an adapter that
792+
* ignores them deletes every row of the sequences given, as before. An adapter that extends Adapter directly
793+
* honours them by reading deletionFilters() in deleteDocuments(), or by overriding this method.
794+
*
795+
* @param array<string> $sequences
796+
* @param array<string> $permissionIds
797+
* @param array<Query> $queries Filter queries as find() is handed them, which every deleted row must match
798+
* @return int The number of rows deleted
799+
*/
800+
public function deleteDocumentsMatching(Document $collection, array $sequences, array $permissionIds, array $queries): int
801+
{
802+
return $this->deletionFiltersValue()->with(
803+
$queries,
804+
fn (): int => $this->deleteDocuments($collection, $sequences, $permissionIds),
805+
);
806+
}
807+
808+
/**
809+
* The filter queries every row deleteDocuments() deletes must match: those deleteDocumentsMatching() was handed
810+
* while it runs, else none.
811+
*
812+
* @return array<Query>
813+
*/
814+
protected function deletionFilters(): array
815+
{
816+
return $this->deletionFiltersValue()->get();
817+
}
818+
819+
/**
820+
* @return Value<array<Query>>
821+
*/
822+
private function deletionFiltersValue(): Value
823+
{
824+
/** @var array<Query> $none */
825+
$none = [];
826+
827+
return $this->deletionFilters ??= new Value($none);
828+
}
829+
779830
/**
780831
* Find Documents
781832
*

‎src/Database/Adapter/Memory.php‎

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1726,6 +1726,12 @@ public function deleteDocument(Document $collection, string $id): bool
17261726
return true;
17271727
}
17281728

1729+
/**
1730+
* Deletes only the rows that match every one of the deletionFilters().
1731+
*
1732+
* @param array<string> $sequences
1733+
* @param array<string> $permissionIds
1734+
*/
17291735
#[\Override]
17301736
public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int
17311737
{
@@ -1734,52 +1740,59 @@ public function deleteDocuments(Document $collection, array $sequences, array $p
17341740
throw new NotFoundException('Collection not found');
17351741
}
17361742

1737-
$seqSet = [];
1738-
foreach ($sequences as $seq) {
1739-
$seqSet[(string) $seq] = true;
1743+
$sequenceSet = [];
1744+
foreach ($sequences as $sequence) {
1745+
$sequenceSet[(string) $sequence] = true;
17401746
}
17411747

1748+
$filters = Query::groupByType($this->deletionFilters())->filters;
17421749
$count = 0;
17431750
$deletedIds = [];
1744-
foreach ($this->data[$key]['documents'] as $docKey => $row) {
1751+
$keptIds = [];
1752+
foreach ($this->data[$key]['documents'] as $storageKey => $row) {
17451753
// With sharedTables the row map is keyed by "tenant|uid" so sequence
17461754
// collisions across tenants are possible. Skip rows that don't belong
17471755
// to the current tenant so we never delete another tenant's data.
17481756
if ($this->sharedTables && ($row[Storage::TENANT] ?? null) !== $this->getTenant()) {
17491757
continue;
17501758
}
17511759
$rowId = $row[Storage::SEQUENCE] ?? '';
1752-
$rowUid = $row[Storage::UID] ?? $docKey;
1753-
if (isset($seqSet[\is_scalar($rowId) ? (string) $rowId : ''])) {
1754-
$deletedIds[\is_scalar($rowUid) ? (string) $rowUid : $docKey] = true;
1760+
$rowUid = $row[Storage::UID] ?? $storageKey;
1761+
if (isset($sequenceSet[\is_scalar($rowId) ? (string) $rowId : ''])) {
1762+
if (! $this->matchesAll($row, $filters)) {
1763+
$keptIds[\is_scalar($rowUid) ? (string) $rowUid : $storageKey] = true;
1764+
1765+
continue;
1766+
}
1767+
$deletedIds[\is_scalar($rowUid) ? (string) $rowUid : $storageKey] = true;
17551768
$oldSignatures = $this->rowUniqueSignatures($key, $row);
1756-
unset($this->data[$key]['documents'][$docKey]);
1757-
$this->journal(function () use ($key, $docKey, $row): void {
1758-
$this->data[$key]['documents'][$docKey] = $row;
1769+
unset($this->data[$key]['documents'][$storageKey]);
1770+
$this->journal(function () use ($key, $storageKey, $row): void {
1771+
$this->data[$key]['documents'][$storageKey] = $row;
17591772
});
17601773
foreach ($oldSignatures as $indexId => $hash) {
1761-
if (($this->uniqueIndexHashes[$key][$indexId][$hash] ?? null) === $docKey) {
1774+
if (($this->uniqueIndexHashes[$key][$indexId][$hash] ?? null) === $storageKey) {
17621775
unset($this->uniqueIndexHashes[$key][$indexId][$hash]);
1763-
$this->journal(function () use ($key, $indexId, $hash, $docKey): void {
1764-
$this->uniqueIndexHashes[$key][$indexId][$hash] = $docKey;
1776+
$this->journal(function () use ($key, $indexId, $hash, $storageKey): void {
1777+
$this->uniqueIndexHashes[$key][$indexId][$hash] = $storageKey;
17651778
});
17661779
}
17671780
}
17681781
$count++;
17691782
}
17701783
}
17711784

1772-
$permSet = ! empty($permissionIds)
1785+
$permissionSet = ! empty($permissionIds)
17731786
? \array_flip(\array_map('strval', $permissionIds))
17741787
: [];
17751788

1776-
if (! empty($deletedIds) || ! empty($permSet)) {
1789+
if (! empty($deletedIds) || ! empty($permissionSet)) {
17771790
$tenant = $this->getTenant();
17781791
foreach (\array_keys($deletedIds) as $documentId) {
17791792
$this->removePermissionsForDocument($key, (string) $documentId, $tenant, $this->sharedTables);
17801793
}
1781-
foreach (\array_keys($permSet) as $documentId) {
1782-
if (isset($deletedIds[$documentId])) {
1794+
foreach (\array_keys($permissionSet) as $documentId) {
1795+
if (isset($deletedIds[$documentId]) || isset($keptIds[$documentId])) {
17831796
continue;
17841797
}
17851798
$this->removePermissionsForDocument($key, (string) $documentId, $tenant, $this->sharedTables);
@@ -2432,6 +2445,21 @@ protected function fusedFilter(string $key, string $collectionId, array $queries
24322445
return $output;
24332446
}
24342447

2448+
/**
2449+
* @param array<string, mixed> $row
2450+
* @param array<Query> $queries
2451+
*/
2452+
private function matchesAll(array $row, array $queries): bool
2453+
{
2454+
foreach ($queries as $query) {
2455+
if (! $this->matches($row, $query)) {
2456+
return false;
2457+
}
2458+
}
2459+
2460+
return true;
2461+
}
2462+
24352463
/**
24362464
* @param array<string, mixed> $row
24372465
*/

‎src/Database/Adapter/Mongo.php‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2290,6 +2290,8 @@ public function deleteDocument(Document $collection, string $id): bool
22902290
}
22912291

22922292
/**
2293+
* Deletes only the rows that match every one of the deletionFilters().
2294+
*
22932295
* @param array<string> $sequences
22942296
* @param array<string> $permissionIds
22952297
*
@@ -2301,14 +2303,17 @@ public function deleteDocuments(Document $collection, array $sequences, array $p
23012303
$collectionId = $collection->getId();
23022304
$name = $this->getNamespace().'_'.$this->filter($collectionId);
23032305

2304-
foreach ($sequences as $index => $sequence) {
2305-
$sequences[$index] = $sequence;
2306-
}
2307-
23082306
/** @var array<string, mixed> $filters */
23092307
$filters = $this->buildFilters([new Query(Method::Equal, Storage::SEQUENCE, $sequences)]);
23102308
$filters = $this->applyTenantFilter($filters, $collectionId);
23112309

2310+
$queries = \array_map(static fn (Query $query): Query => clone $query, $this->deletionFilters());
2311+
$this->escapeQueryAttributes($collection, $queries);
2312+
$matching = $this->buildFilters($queries);
2313+
if ($matching !== []) {
2314+
$filters = ['$and' => [$filters, $matching]];
2315+
}
2316+
23122317
$filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators);
23132318

23142319
$options = $this->getTransactionOptions();

‎src/Database/Adapter/Pool.php‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -864,11 +864,19 @@ public function deleteDocument(Document $collection, string $id): bool
864864
return $result;
865865
}
866866

867+
/**
868+
* Hands the pooled adapter the deletionFilters() through its deleteDocumentsMatching(), as they are scoped to
869+
* this pool, so that an override of this method still runs for a filtered batch.
870+
*/
867871
#[\Override]
868872
public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int
869873
{
874+
$filters = $this->deletionFilters();
875+
870876
/** @var int $result */
871-
$result = $this->delegate(__FUNCTION__, \func_get_args());
877+
$result = $filters === []
878+
? $this->delegate(__FUNCTION__, [$collection, $sequences, $permissionIds])
879+
: $this->delegate('deleteDocumentsMatching', [$collection, $sequences, $permissionIds, $filters]);
872880
return $result;
873881
}
874882

0 commit comments

Comments
 (0)