From ea6861c6c4162adc3a011995cf0322afd06d4a3e Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 19 Jan 2026 09:55:50 +0000 Subject: [PATCH 1/4] feat: Add preserveSequence flag for upserts - Add preserveSequence property to Database class - Add getPreserveSequence(), setPreserveSequence(), withPreserveSequence() methods - Conditionally remove $sequence in upsertDocuments based on flag - Add testPreserveSequenceUpsert test in GeneralTests.php --- src/Database/Database.php | 33 +++++- tests/e2e/Adapter/Scopes/GeneralTests.php | 118 ++++++++++++++++++++++ 2 files changed, 149 insertions(+), 2 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index b9b52a8c5..c78b1ae13 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -408,6 +408,8 @@ class Database protected bool $preserveDates = false; + protected bool $preserveSequence = false; + protected int $maxQueryValues = 5000; protected bool $migrating = false; @@ -1400,6 +1402,30 @@ public function withPreserveDates(callable $callback): mixed } } + public function getPreserveSequence(): bool + { + return $this->preserveSequence; + } + + public function setPreserveSequence(bool $preserve): static + { + $this->preserveSequence = $preserve; + + return $this; + } + + public function withPreserveSequence(callable $callback): mixed + { + $previous = $this->preserveSequence; + $this->preserveSequence = true; + + try { + return $callback(); + } finally { + $this->preserveSequence = $previous; + } + } + public function setMaxQueryValues(int $max): self { $this->maxQueryValues = $max; @@ -6594,8 +6620,11 @@ public function upsertDocumentsWithIncrease( $document ->setAttribute('$id', empty($document->getId()) ? ID::unique() : $document->getId()) ->setAttribute('$collection', $collection->getId()) - ->setAttribute('$updatedAt', ($updatedAt === null || !$this->preserveDates) ? $time : $updatedAt) - ->removeAttribute('$sequence'); + ->setAttribute('$updatedAt', ($updatedAt === null || !$this->preserveDates) ? $time : $updatedAt); + + if (!$this->preserveSequence) { + $document->removeAttribute('$sequence'); + } $createdAt = $document->getCreatedAt(); if ($createdAt === null || !$this->preserveDates) { diff --git a/tests/e2e/Adapter/Scopes/GeneralTests.php b/tests/e2e/Adapter/Scopes/GeneralTests.php index bf4280163..71e4f3c05 100644 --- a/tests/e2e/Adapter/Scopes/GeneralTests.php +++ b/tests/e2e/Adapter/Scopes/GeneralTests.php @@ -295,6 +295,124 @@ public function testPreserveDatesCreate(): void $this->getDatabase()->getAuthorization()->reset(); } + public function testPreserveSequenceUpsert(): void + { + $this->getDatabase()->getAuthorization()->disable(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForUpserts()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionName = 'preserve_sequence_upsert'; + + $database->createCollection($collectionName); + $database->createAttribute($collectionName, 'name', Database::VAR_STRING, 128, true); + + // Create initial documents + $doc1 = $database->createDocument($collectionName, new Document([ + '$id' => 'doc1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Alice', + ])); + + $doc2 = $database->createDocument($collectionName, new Document([ + '$id' => 'doc2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Bob', + ])); + + $originalSeq1 = $doc1->getSequence(); + $originalSeq2 = $doc2->getSequence(); + + $this->assertNotEmpty($originalSeq1); + $this->assertNotEmpty($originalSeq2); + + // Test Without preserveSequence (default), $sequence should be ignored + $database->setPreserveSequence(false); + + $results = []; + $database->upsertDocuments($collectionName, [ + new Document([ + '$id' => 'doc1', + '$sequence' => 999, // Try to set a different sequence + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Alice Updated', + ]), + ], onNext: function ($doc) use (&$results) { + $results[] = $doc; + }); + + $doc1Updated = $database->getDocument($collectionName, 'doc1'); + $this->assertEquals('Alice Updated', $doc1Updated->getAttribute('name')); + $this->assertEquals($originalSeq1, $doc1Updated->getSequence()); // Sequence unchanged + + // Test With preserveSequence, $sequence should be preserved + $database->setPreserveSequence(true); + + $results = []; + $database->upsertDocuments($collectionName, [ + new Document([ + '$id' => 'doc2', + '$sequence' => $originalSeq2, // Explicitly set the same sequence + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Bob Updated', + ]), + ], onNext: function ($doc) use (&$results) { + $results[] = $doc; + }); + + $doc2Updated = $database->getDocument($collectionName, 'doc2'); + $this->assertEquals('Bob Updated', $doc2Updated->getAttribute('name')); + $this->assertEquals($originalSeq2, $doc2Updated->getSequence()); // Sequence preserved + + // Test withPreserveSequence helper + $database->setPreserveSequence(false); + + $doc1 = $database->getDocument($collectionName, 'doc1'); + $currentSeq1 = $doc1->getSequence(); + + $database->withPreserveSequence(function () use ($database, $collectionName, $currentSeq1) { + $database->upsertDocuments($collectionName, [ + new Document([ + '$id' => 'doc1', + '$sequence' => $currentSeq1, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Alice Final', + ]), + ]); + }); + + $doc1Final = $database->getDocument($collectionName, 'doc1'); + $this->assertEquals('Alice Final', $doc1Final->getAttribute('name')); + $this->assertEquals($currentSeq1, $doc1Final->getSequence()); + + // Verify flag was reset after withPreserveSequence + $this->assertFalse($database->getPreserveSequence()); + + $database->deleteCollection($collectionName); + + $this->getDatabase()->getAuthorization()->reset(); + } + public function testGetAttributeLimit(): void { $this->assertIsInt($this->getDatabase()->getLimitForAttributes()); From f418802d3a44e883c2a57e3a3932d95298d21c75 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 19 Jan 2026 10:18:54 +0000 Subject: [PATCH 2/4] fix: Add getSupportForAttributes guard to test --- tests/e2e/Adapter/Scopes/GeneralTests.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/e2e/Adapter/Scopes/GeneralTests.php b/tests/e2e/Adapter/Scopes/GeneralTests.php index 71e4f3c05..c9b667d39 100644 --- a/tests/e2e/Adapter/Scopes/GeneralTests.php +++ b/tests/e2e/Adapter/Scopes/GeneralTests.php @@ -307,6 +307,11 @@ public function testPreserveSequenceUpsert(): void return; } + if (!$database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + $collectionName = 'preserve_sequence_upsert'; $database->createCollection($collectionName); From b0586bdd40c265290ebde26e13595828ee79041e Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 19 Jan 2026 11:12:09 +0000 Subject: [PATCH 3/4] refactor: Move test to DocumentTests, fix schemaless support --- tests/e2e/Adapter/Scopes/DocumentTests.php | 112 +++++++++++++++++++ tests/e2e/Adapter/Scopes/GeneralTests.php | 123 --------------------- 2 files changed, 112 insertions(+), 123 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 34b48ff9b..09fec8f84 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -1178,6 +1178,118 @@ public function testUpsertMixedPermissionDelta(): void ], $db->getDocument(__FUNCTION__, 'b')->getPermissions()); } + public function testPreserveSequenceUpsert(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForUpserts()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionName = 'preserve_sequence_upsert'; + + $database->createCollection($collectionName); + + if ($database->getAdapter()->getSupportForAttributes()) { + $database->createAttribute($collectionName, 'name', Database::VAR_STRING, 128, true); + } + + // Create initial documents + $doc1 = $database->createDocument($collectionName, new Document([ + '$id' => 'doc1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Alice', + ])); + + $doc2 = $database->createDocument($collectionName, new Document([ + '$id' => 'doc2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Bob', + ])); + + $originalSeq1 = $doc1->getSequence(); + $originalSeq2 = $doc2->getSequence(); + + $this->assertNotEmpty($originalSeq1); + $this->assertNotEmpty($originalSeq2); + + // Test: Without preserveSequence (default), $sequence should be ignored + $database->setPreserveSequence(false); + + $database->upsertDocuments($collectionName, [ + new Document([ + '$id' => 'doc1', + '$sequence' => 999, // Try to set a different sequence + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Alice Updated', + ]), + ]); + + $doc1Updated = $database->getDocument($collectionName, 'doc1'); + $this->assertEquals('Alice Updated', $doc1Updated->getAttribute('name')); + $this->assertEquals($originalSeq1, $doc1Updated->getSequence()); // Sequence unchanged + + // Test: With preserveSequence=true, $sequence from document should be used + $database->setPreserveSequence(true); + + $database->upsertDocuments($collectionName, [ + new Document([ + '$id' => 'doc2', + '$sequence' => $originalSeq2, // Keep original sequence + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Bob Updated', + ]), + ]); + + $doc2Updated = $database->getDocument($collectionName, 'doc2'); + $this->assertEquals('Bob Updated', $doc2Updated->getAttribute('name')); + $this->assertEquals($originalSeq2, $doc2Updated->getSequence()); // Sequence preserved + + // Test: withPreserveSequence helper + $database->setPreserveSequence(false); + + $doc1 = $database->getDocument($collectionName, 'doc1'); + $currentSeq1 = $doc1->getSequence(); + + $database->withPreserveSequence(function () use ($database, $collectionName, $currentSeq1) { + $database->upsertDocuments($collectionName, [ + new Document([ + '$id' => 'doc1', + '$sequence' => $currentSeq1, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Alice Final', + ]), + ]); + }); + + $doc1Final = $database->getDocument($collectionName, 'doc1'); + $this->assertEquals('Alice Final', $doc1Final->getAttribute('name')); + $this->assertEquals($currentSeq1, $doc1Final->getSequence()); + + // Verify flag was reset after withPreserveSequence + $this->assertFalse($database->getPreserveSequence()); + + $database->setPreserveSequence(false); + $database->deleteCollection($collectionName); + } + public function testRespectNulls(): Document { /** @var Database $database */ diff --git a/tests/e2e/Adapter/Scopes/GeneralTests.php b/tests/e2e/Adapter/Scopes/GeneralTests.php index c9b667d39..bf4280163 100644 --- a/tests/e2e/Adapter/Scopes/GeneralTests.php +++ b/tests/e2e/Adapter/Scopes/GeneralTests.php @@ -295,129 +295,6 @@ public function testPreserveDatesCreate(): void $this->getDatabase()->getAuthorization()->reset(); } - public function testPreserveSequenceUpsert(): void - { - $this->getDatabase()->getAuthorization()->disable(); - - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForUpserts()) { - $this->expectNotToPerformAssertions(); - return; - } - - if (!$database->getAdapter()->getSupportForAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $collectionName = 'preserve_sequence_upsert'; - - $database->createCollection($collectionName); - $database->createAttribute($collectionName, 'name', Database::VAR_STRING, 128, true); - - // Create initial documents - $doc1 = $database->createDocument($collectionName, new Document([ - '$id' => 'doc1', - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'name' => 'Alice', - ])); - - $doc2 = $database->createDocument($collectionName, new Document([ - '$id' => 'doc2', - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'name' => 'Bob', - ])); - - $originalSeq1 = $doc1->getSequence(); - $originalSeq2 = $doc2->getSequence(); - - $this->assertNotEmpty($originalSeq1); - $this->assertNotEmpty($originalSeq2); - - // Test Without preserveSequence (default), $sequence should be ignored - $database->setPreserveSequence(false); - - $results = []; - $database->upsertDocuments($collectionName, [ - new Document([ - '$id' => 'doc1', - '$sequence' => 999, // Try to set a different sequence - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'name' => 'Alice Updated', - ]), - ], onNext: function ($doc) use (&$results) { - $results[] = $doc; - }); - - $doc1Updated = $database->getDocument($collectionName, 'doc1'); - $this->assertEquals('Alice Updated', $doc1Updated->getAttribute('name')); - $this->assertEquals($originalSeq1, $doc1Updated->getSequence()); // Sequence unchanged - - // Test With preserveSequence, $sequence should be preserved - $database->setPreserveSequence(true); - - $results = []; - $database->upsertDocuments($collectionName, [ - new Document([ - '$id' => 'doc2', - '$sequence' => $originalSeq2, // Explicitly set the same sequence - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'name' => 'Bob Updated', - ]), - ], onNext: function ($doc) use (&$results) { - $results[] = $doc; - }); - - $doc2Updated = $database->getDocument($collectionName, 'doc2'); - $this->assertEquals('Bob Updated', $doc2Updated->getAttribute('name')); - $this->assertEquals($originalSeq2, $doc2Updated->getSequence()); // Sequence preserved - - // Test withPreserveSequence helper - $database->setPreserveSequence(false); - - $doc1 = $database->getDocument($collectionName, 'doc1'); - $currentSeq1 = $doc1->getSequence(); - - $database->withPreserveSequence(function () use ($database, $collectionName, $currentSeq1) { - $database->upsertDocuments($collectionName, [ - new Document([ - '$id' => 'doc1', - '$sequence' => $currentSeq1, - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'name' => 'Alice Final', - ]), - ]); - }); - - $doc1Final = $database->getDocument($collectionName, 'doc1'); - $this->assertEquals('Alice Final', $doc1Final->getAttribute('name')); - $this->assertEquals($currentSeq1, $doc1Final->getSequence()); - - // Verify flag was reset after withPreserveSequence - $this->assertFalse($database->getPreserveSequence()); - - $database->deleteCollection($collectionName); - - $this->getDatabase()->getAuthorization()->reset(); - } - public function testGetAttributeLimit(): void { $this->assertIsInt($this->getDatabase()->getLimitForAttributes()); From cf118098afcd8019592eb1e55977aad8a797ca49 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 19 Jan 2026 12:42:03 +0000 Subject: [PATCH 4/4] Add setPreserveSequence to Mirror and test invalid sequence validation - Add setPreserveSequence delegation to Mirror.php - Add test case for invalid $sequence value throwing StructureException --- src/Database/Mirror.php | 9 ++++++++ tests/e2e/Adapter/Scopes/DocumentTests.php | 26 ++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/Database/Mirror.php b/src/Database/Mirror.php index c4cf23e30..cdb26a6ad 100644 --- a/src/Database/Mirror.php +++ b/src/Database/Mirror.php @@ -139,6 +139,15 @@ public function setPreserveDates(bool $preserve): static return $this; } + public function setPreserveSequence(bool $preserve): static + { + $this->delegate(__FUNCTION__, \func_get_args()); + + $this->preserveSequence = $preserve; + + return $this; + } + public function enableValidation(): static { $this->delegate(__FUNCTION__); diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 09fec8f84..868ffc84c 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -1286,6 +1286,32 @@ public function testPreserveSequenceUpsert(): void // Verify flag was reset after withPreserveSequence $this->assertFalse($database->getPreserveSequence()); + // Test: With preserveSequence=true, invalid $sequence should throw error (SQL adapters only) + $database->setPreserveSequence(true); + + try { + $database->upsertDocuments($collectionName, [ + new Document([ + '$id' => 'doc1', + '$sequence' => 'abc', // Invalid sequence value + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Alice Invalid', + ]), + ]); + // Schemaless adapters may not validate sequence type, so only fail for schemaful + if ($database->getAdapter()->getSupportForAttributes()) { + $this->fail('Expected StructureException for invalid sequence'); + } + } catch (Throwable $e) { + if ($database->getAdapter()->getSupportForAttributes()) { + $this->assertInstanceOf(StructureException::class, $e); + $this->assertStringContainsString('sequence', $e->getMessage()); + } + } + $database->setPreserveSequence(false); $database->deleteCollection($collectionName); }