Skip to content

Commit de44f6a

Browse files
committed
Merge branch 'main' of github.com:utopia-php/database into joins9
2 parents b1c88dd + 8795a7f commit de44f6a

5 files changed

Lines changed: 515 additions & 2 deletions

File tree

src/Database/Adapter/Mongo.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1572,6 +1572,7 @@ public function upsertDocuments(Document $collection, string $attribute, array $
15721572
$operations = [];
15731573
foreach ($changes as $change) {
15741574
$document = $change->getNew();
1575+
$oldDocument = $change->getOld();
15751576
$attributes = $document->getAttributes();
15761577
$attributes['_uid'] = $document->getId();
15771578
$attributes['_createdAt'] = $document['$createdAt'];
@@ -1597,6 +1598,9 @@ public function upsertDocuments(Document $collection, string $attribute, array $
15971598

15981599
unset($record['_id']); // Don't update _id
15991600

1601+
// Get fields to unset for schemaless mode
1602+
$unsetFields = $this->getUpsertAttributeRemovals($oldDocument, $document, $record);
1603+
16001604
if (!empty($attribute)) {
16011605
// Get the attribute value before removing it from $set
16021606
$attributeValue = $record[$attribute] ?? 0;
@@ -1605,17 +1609,28 @@ public function upsertDocuments(Document $collection, string $attribute, array $
16051609
// it is requierd to mimic the behaver of SQL on duplicate key update
16061610
unset($record[$attribute]);
16071611

1612+
// Also remove from unset if it was there
1613+
unset($unsetFields[$attribute]);
1614+
16081615
// Increment the specific attribute and update all other fields
16091616
$update = [
16101617
'$inc' => [$attribute => $attributeValue],
16111618
'$set' => $record
16121619
];
1620+
1621+
if (!empty($unsetFields)) {
1622+
$update['$unset'] = $unsetFields;
1623+
}
16131624
} else {
16141625
// Update all fields
16151626
$update = [
16161627
'$set' => $record
16171628
];
16181629

1630+
if (!empty($unsetFields)) {
1631+
$update['$unset'] = $unsetFields;
1632+
}
1633+
16191634
// Add UUID7 _id for new documents in upsert operations
16201635
if (empty($document->getSequence())) {
16211636
$update['$setOnInsert'] = [
@@ -1644,6 +1659,43 @@ public function upsertDocuments(Document $collection, string $attribute, array $
16441659
return \array_map(fn ($change) => $change->getNew(), $changes);
16451660
}
16461661

1662+
/**
1663+
* Get fields to unset for schemaless upsert operations
1664+
*
1665+
* @param Document $oldDocument
1666+
* @param Document $newDocument
1667+
* @param array<string, mixed> $record
1668+
* @return array<string, string>
1669+
*/
1670+
private function getUpsertAttributeRemovals(Document $oldDocument, Document $newDocument, array $record): array
1671+
{
1672+
$unsetFields = [];
1673+
1674+
if ($this->getSupportForAttributes() || $oldDocument->isEmpty()) {
1675+
return $unsetFields;
1676+
}
1677+
1678+
$oldUserAttributes = $oldDocument->getAttributes();
1679+
$newUserAttributes = $newDocument->getAttributes();
1680+
1681+
$protectedFields = ['_uid', '_id', '_createdAt', '_updatedAt', '_permissions', '_tenant'];
1682+
1683+
foreach ($oldUserAttributes as $originalKey => $originalValue) {
1684+
if (in_array($originalKey, $protectedFields) || array_key_exists($originalKey, $newUserAttributes)) {
1685+
continue;
1686+
}
1687+
1688+
$transformed = $this->replaceChars('$', '_', [$originalKey => $originalValue]);
1689+
$dbKey = array_key_first($transformed);
1690+
1691+
if ($dbKey && !array_key_exists($dbKey, $record) && !in_array($dbKey, $protectedFields)) {
1692+
$unsetFields[$dbKey] = '';
1693+
}
1694+
}
1695+
1696+
return $unsetFields;
1697+
}
1698+
16471699
/**
16481700
* Get sequences for documents that were created
16491701
*

src/Database/Database.php

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,8 @@ class Database
407407

408408
protected bool $preserveDates = false;
409409

410+
protected bool $preserveSequence = false;
411+
410412
protected int $maxQueryValues = 5000;
411413

412414
protected bool $migrating = false;
@@ -1399,6 +1401,30 @@ public function withPreserveDates(callable $callback): mixed
13991401
}
14001402
}
14011403

1404+
public function getPreserveSequence(): bool
1405+
{
1406+
return $this->preserveSequence;
1407+
}
1408+
1409+
public function setPreserveSequence(bool $preserve): static
1410+
{
1411+
$this->preserveSequence = $preserve;
1412+
1413+
return $this;
1414+
}
1415+
1416+
public function withPreserveSequence(callable $callback): mixed
1417+
{
1418+
$previous = $this->preserveSequence;
1419+
$this->preserveSequence = true;
1420+
1421+
try {
1422+
return $callback();
1423+
} finally {
1424+
$this->preserveSequence = $previous;
1425+
}
1426+
}
1427+
14021428
public function setMaxQueryValues(int $max): self
14031429
{
14041430
$this->maxQueryValues = $max;
@@ -6627,8 +6653,11 @@ public function upsertDocumentsWithIncrease(
66276653
$document
66286654
->setAttribute('$id', empty($document->getId()) ? ID::unique() : $document->getId())
66296655
->setAttribute('$collection', $collection->getId())
6630-
->setAttribute('$updatedAt', ($updatedAt === null || !$this->preserveDates) ? $time : $updatedAt)
6631-
->removeAttribute('$sequence');
6656+
->setAttribute('$updatedAt', ($updatedAt === null || !$this->preserveDates) ? $time : $updatedAt);
6657+
6658+
if (!$this->preserveSequence) {
6659+
$document->removeAttribute('$sequence');
6660+
}
66326661

66336662
$createdAt = $document->getCreatedAt();
66346663
if ($createdAt === null || !$this->preserveDates) {

src/Database/Mirror.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,15 @@ public function setPreserveDates(bool $preserve): static
139139
return $this;
140140
}
141141

142+
public function setPreserveSequence(bool $preserve): static
143+
{
144+
$this->delegate(__FUNCTION__, \func_get_args());
145+
146+
$this->preserveSequence = $preserve;
147+
148+
return $this;
149+
}
150+
142151
public function enableValidation(): static
143152
{
144153
$this->delegate(__FUNCTION__);

tests/e2e/Adapter/Scopes/DocumentTests.php

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,6 +1179,144 @@ public function testUpsertMixedPermissionDelta(): void
11791179
], $db->getDocument(__FUNCTION__, 'b')->getPermissions());
11801180
}
11811181

1182+
public function testPreserveSequenceUpsert(): void
1183+
{
1184+
/** @var Database $database */
1185+
$database = $this->getDatabase();
1186+
1187+
if (!$database->getAdapter()->getSupportForUpserts()) {
1188+
$this->expectNotToPerformAssertions();
1189+
return;
1190+
}
1191+
1192+
$collectionName = 'preserve_sequence_upsert';
1193+
1194+
$database->createCollection($collectionName);
1195+
1196+
if ($database->getAdapter()->getSupportForAttributes()) {
1197+
$database->createAttribute($collectionName, 'name', Database::VAR_STRING, 128, true);
1198+
}
1199+
1200+
// Create initial documents
1201+
$doc1 = $database->createDocument($collectionName, new Document([
1202+
'$id' => 'doc1',
1203+
'$permissions' => [
1204+
Permission::read(Role::any()),
1205+
Permission::update(Role::any()),
1206+
],
1207+
'name' => 'Alice',
1208+
]));
1209+
1210+
$doc2 = $database->createDocument($collectionName, new Document([
1211+
'$id' => 'doc2',
1212+
'$permissions' => [
1213+
Permission::read(Role::any()),
1214+
Permission::update(Role::any()),
1215+
],
1216+
'name' => 'Bob',
1217+
]));
1218+
1219+
$originalSeq1 = $doc1->getSequence();
1220+
$originalSeq2 = $doc2->getSequence();
1221+
1222+
$this->assertNotEmpty($originalSeq1);
1223+
$this->assertNotEmpty($originalSeq2);
1224+
1225+
// Test: Without preserveSequence (default), $sequence should be ignored
1226+
$database->setPreserveSequence(false);
1227+
1228+
$database->upsertDocuments($collectionName, [
1229+
new Document([
1230+
'$id' => 'doc1',
1231+
'$sequence' => 999, // Try to set a different sequence
1232+
'$permissions' => [
1233+
Permission::read(Role::any()),
1234+
Permission::update(Role::any()),
1235+
],
1236+
'name' => 'Alice Updated',
1237+
]),
1238+
]);
1239+
1240+
$doc1Updated = $database->getDocument($collectionName, 'doc1');
1241+
$this->assertEquals('Alice Updated', $doc1Updated->getAttribute('name'));
1242+
$this->assertEquals($originalSeq1, $doc1Updated->getSequence()); // Sequence unchanged
1243+
1244+
// Test: With preserveSequence=true, $sequence from document should be used
1245+
$database->setPreserveSequence(true);
1246+
1247+
$database->upsertDocuments($collectionName, [
1248+
new Document([
1249+
'$id' => 'doc2',
1250+
'$sequence' => $originalSeq2, // Keep original sequence
1251+
'$permissions' => [
1252+
Permission::read(Role::any()),
1253+
Permission::update(Role::any()),
1254+
],
1255+
'name' => 'Bob Updated',
1256+
]),
1257+
]);
1258+
1259+
$doc2Updated = $database->getDocument($collectionName, 'doc2');
1260+
$this->assertEquals('Bob Updated', $doc2Updated->getAttribute('name'));
1261+
$this->assertEquals($originalSeq2, $doc2Updated->getSequence()); // Sequence preserved
1262+
1263+
// Test: withPreserveSequence helper
1264+
$database->setPreserveSequence(false);
1265+
1266+
$doc1 = $database->getDocument($collectionName, 'doc1');
1267+
$currentSeq1 = $doc1->getSequence();
1268+
1269+
$database->withPreserveSequence(function () use ($database, $collectionName, $currentSeq1) {
1270+
$database->upsertDocuments($collectionName, [
1271+
new Document([
1272+
'$id' => 'doc1',
1273+
'$sequence' => $currentSeq1,
1274+
'$permissions' => [
1275+
Permission::read(Role::any()),
1276+
Permission::update(Role::any()),
1277+
],
1278+
'name' => 'Alice Final',
1279+
]),
1280+
]);
1281+
});
1282+
1283+
$doc1Final = $database->getDocument($collectionName, 'doc1');
1284+
$this->assertEquals('Alice Final', $doc1Final->getAttribute('name'));
1285+
$this->assertEquals($currentSeq1, $doc1Final->getSequence());
1286+
1287+
// Verify flag was reset after withPreserveSequence
1288+
$this->assertFalse($database->getPreserveSequence());
1289+
1290+
// Test: With preserveSequence=true, invalid $sequence should throw error (SQL adapters only)
1291+
$database->setPreserveSequence(true);
1292+
1293+
try {
1294+
$database->upsertDocuments($collectionName, [
1295+
new Document([
1296+
'$id' => 'doc1',
1297+
'$sequence' => 'abc', // Invalid sequence value
1298+
'$permissions' => [
1299+
Permission::read(Role::any()),
1300+
Permission::update(Role::any()),
1301+
],
1302+
'name' => 'Alice Invalid',
1303+
]),
1304+
]);
1305+
// Schemaless adapters may not validate sequence type, so only fail for schemaful
1306+
if ($database->getAdapter()->getSupportForAttributes()) {
1307+
$this->fail('Expected StructureException for invalid sequence');
1308+
}
1309+
} catch (Throwable $e) {
1310+
if ($database->getAdapter()->getSupportForAttributes()) {
1311+
$this->assertInstanceOf(StructureException::class, $e);
1312+
$this->assertStringContainsString('sequence', $e->getMessage());
1313+
}
1314+
}
1315+
1316+
$database->setPreserveSequence(false);
1317+
$database->deleteCollection($collectionName);
1318+
}
1319+
11821320
public function testRespectNulls(): Document
11831321
{
11841322
/** @var Database $database */

0 commit comments

Comments
 (0)