From 48b0cf13a8f7e258515f63a3fb2657648a32b2d9 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Sun, 18 Jan 2026 15:01:44 +0530 Subject: [PATCH 1/2] added unset for schemaless upsert --- src/Database/Adapter/Mongo.php | 52 ++++ tests/e2e/Adapter/Scopes/SchemalessTests.php | 286 +++++++++++++++++++ 2 files changed, 338 insertions(+) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index fdcdd80c2..4c14531eb 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1562,6 +1562,7 @@ public function upsertDocuments(Document $collection, string $attribute, array $ $operations = []; foreach ($changes as $change) { $document = $change->getNew(); + $oldDocument = $change->getOld(); $attributes = $document->getAttributes(); $attributes['_uid'] = $document->getId(); $attributes['_createdAt'] = $document['$createdAt']; @@ -1587,6 +1588,9 @@ public function upsertDocuments(Document $collection, string $attribute, array $ unset($record['_id']); // Don't update _id + // Get fields to unset for schemaless mode + $unsetFields = $this->getUnsetForSchemalessUpsert($oldDocument, $document, $record); + if (!empty($attribute)) { // Get the attribute value before removing it from $set $attributeValue = $record[$attribute] ?? 0; @@ -1595,17 +1599,28 @@ public function upsertDocuments(Document $collection, string $attribute, array $ // it is requierd to mimic the behaver of SQL on duplicate key update unset($record[$attribute]); + // Also remove from unset if it was there + unset($unsetFields[$attribute]); + // Increment the specific attribute and update all other fields $update = [ '$inc' => [$attribute => $attributeValue], '$set' => $record ]; + + if (!empty($unsetFields)) { + $update['$unset'] = $unsetFields; + } } else { // Update all fields $update = [ '$set' => $record ]; + if (!empty($unsetFields)) { + $update['$unset'] = $unsetFields; + } + // Add UUID7 _id for new documents in upsert operations if (empty($document->getSequence())) { $update['$setOnInsert'] = [ @@ -3395,4 +3410,41 @@ public function getSupportForTrigramIndex(): bool { return false; } + + /** + * Get fields to unset for schemaless upsert operations + * + * @param Document $oldDocument + * @param Document $newDocument + * @param array $record + * @return array + */ + private function getUnsetForSchemalessUpsert(Document $oldDocument, Document $newDocument, array $record): array + { + $unsetFields = []; + + if ($this->getSupportForAttributes() || $oldDocument->isEmpty()) { + return $unsetFields; + } + + $oldUserAttributes = $oldDocument->getAttributes(); + $newUserAttributes = $newDocument->getAttributes(); + + $protectedFields = ['_uid', '_id', '_createdAt', '_updatedAt', '_permissions', '_tenant']; + + foreach ($oldUserAttributes as $originalKey => $originalValue) { + if (in_array($originalKey, $protectedFields) || array_key_exists($originalKey, $newUserAttributes)) { + continue; + } + + $transformed = $this->replaceChars('$', '_', [$originalKey => $originalValue]); + $dbKey = array_key_first($transformed); + + if ($dbKey && !array_key_exists($dbKey, $record) && !in_array($dbKey, $protectedFields)) { + $unsetFields[$dbKey] = ''; + } + } + + return $unsetFields; + } } diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index 856d08263..c9ea0e186 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -1865,4 +1865,290 @@ public function testSchemalessNestedObjectAttributeQueries(): void $database->deleteCollection($col); } + + public function testUpsertFieldRemoval(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->markTestSkipped('Adapter supports attributes (schemaful mode). Field removal in upsert is tested in schemaful tests.'); + return; + } + + $collectionName = ID::unique(); + $database->createCollection($collectionName, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]); + + $permissions = [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + // Test 1: Basic field removal with upsertDocument + // Create a document with multiple fields + $doc1 = $database->createDocument($collectionName, new Document([ + '$id' => 'doc1', + '$permissions' => $permissions, + 'title' => 'Original Title', + 'description' => 'Original Description', + 'category' => 'tech', + 'tags' => ['php', 'mongodb'], + 'metadata' => [ + 'author' => 'John Doe', + 'version' => 1 + ] + ])); + + $this->assertEquals('Original Title', $doc1->getAttribute('title')); + $this->assertEquals('Original Description', $doc1->getAttribute('description')); + $this->assertEquals('tech', $doc1->getAttribute('category')); + $this->assertArrayHasKey('tags', $doc1->getArrayCopy()); + $this->assertArrayHasKey('metadata', $doc1->getArrayCopy()); + + // Upsert with fewer fields - removed fields should be deleted + $upserted = $database->upsertDocument($collectionName, new Document([ + '$id' => 'doc1', + '$permissions' => $permissions, + 'title' => 'Updated Title', + 'category' => 'science', + // description, tags, and metadata are removed + ])); + + $this->assertEquals('Updated Title', $upserted->getAttribute('title')); + $this->assertEquals('science', $upserted->getAttribute('category')); + + // Verify removed fields are actually deleted + $retrieved = $database->getDocument($collectionName, 'doc1'); + $this->assertEquals('Updated Title', $retrieved->getAttribute('title')); + $this->assertEquals('science', $retrieved->getAttribute('category')); + $this->assertArrayNotHasKey('description', $retrieved->getArrayCopy()); + $this->assertArrayNotHasKey('tags', $retrieved->getArrayCopy()); + $this->assertArrayNotHasKey('metadata', $retrieved->getArrayCopy()); + + // Test 2: Remove all custom fields except one + $doc2 = $database->createDocument($collectionName, new Document([ + '$id' => 'doc2', + '$permissions' => $permissions, + 'field1' => 'value1', + 'field2' => 'value2', + 'field3' => 'value3', + 'field4' => 'value4', + ])); + + // Upsert keeping only field1 + $database->upsertDocument($collectionName, new Document([ + '$id' => 'doc2', + '$permissions' => $permissions, + 'field1' => 'updated_value1', + ])); + + $retrieved2 = $database->getDocument($collectionName, 'doc2'); + $this->assertEquals('updated_value1', $retrieved2->getAttribute('field1')); + $this->assertArrayNotHasKey('field2', $retrieved2->getArrayCopy()); + $this->assertArrayNotHasKey('field3', $retrieved2->getArrayCopy()); + $this->assertArrayNotHasKey('field4', $retrieved2->getArrayCopy()); + + // Test 3: Remove nested object fields + $doc3 = $database->createDocument($collectionName, new Document([ + '$id' => 'doc3', + '$permissions' => $permissions, + 'name' => 'Product', + 'details' => [ + 'color' => 'red', + 'size' => 'large', + 'weight' => 10 + ], + 'specs' => [ + 'cpu' => 'Intel', + 'ram' => '8GB' + ] + ])); + + // Upsert removing details but keeping specs + $database->upsertDocument($collectionName, new Document([ + '$id' => 'doc3', + '$permissions' => $permissions, + 'name' => 'Updated Product', + 'specs' => [ + 'cpu' => 'AMD', + 'ram' => '16GB' + ], + // details is removed + ])); + + $retrieved3 = $database->getDocument($collectionName, 'doc3'); + $this->assertEquals('Updated Product', $retrieved3->getAttribute('name')); + $this->assertArrayHasKey('specs', $retrieved3->getArrayCopy()); + $this->assertEquals('AMD', $retrieved3->getAttribute('specs')['cpu']); + $this->assertArrayNotHasKey('details', $retrieved3->getArrayCopy()); + + // Test 4: Remove array fields + $doc4 = $database->createDocument($collectionName, new Document([ + '$id' => 'doc4', + '$permissions' => $permissions, + 'title' => 'Article', + 'tags' => ['tag1', 'tag2', 'tag3'], + 'categories' => ['cat1', 'cat2'], + 'comments' => ['comment1', 'comment2'] + ])); + + // Upsert removing tags and comments but keeping categories + $database->upsertDocument($collectionName, new Document([ + '$id' => 'doc4', + '$permissions' => $permissions, + 'title' => 'Updated Article', + 'categories' => ['cat3'], + ])); + + $retrieved4 = $database->getDocument($collectionName, 'doc4'); + $this->assertEquals('Updated Article', $retrieved4->getAttribute('title')); + $this->assertArrayHasKey('categories', $retrieved4->getArrayCopy()); + $this->assertEquals(['cat3'], $retrieved4->getAttribute('categories')); + $this->assertArrayNotHasKey('tags', $retrieved4->getArrayCopy()); + $this->assertArrayNotHasKey('comments', $retrieved4->getArrayCopy()); + + // Test 5: upsertDocuments with field removal (bulk upsert) + $docs5 = [ + new Document([ + '$id' => 'bulk1', + '$permissions' => $permissions, + 'fieldA' => 'valueA', + 'fieldB' => 'valueB', + 'fieldC' => 'valueC', + ]), + new Document([ + '$id' => 'bulk2', + '$permissions' => $permissions, + 'fieldX' => 'valueX', + 'fieldY' => 'valueY', + 'fieldZ' => 'valueZ', + ]), + ]; + $database->createDocuments($collectionName, $docs5); + + // Upsert removing some fields from each + $upsertDocs5 = [ + new Document([ + '$id' => 'bulk1', + '$permissions' => $permissions, + 'fieldA' => 'updatedA', + // fieldB and fieldC removed + ]), + new Document([ + '$id' => 'bulk2', + '$permissions' => $permissions, + 'fieldX' => 'updatedX', + 'fieldZ' => 'updatedZ', + // fieldY removed + ]), + ]; + $database->upsertDocuments($collectionName, $upsertDocs5); + + $retrievedBulk1 = $database->getDocument($collectionName, 'bulk1'); + $this->assertEquals('updatedA', $retrievedBulk1->getAttribute('fieldA')); + $this->assertArrayNotHasKey('fieldB', $retrievedBulk1->getArrayCopy()); + $this->assertArrayNotHasKey('fieldC', $retrievedBulk1->getArrayCopy()); + + $retrievedBulk2 = $database->getDocument($collectionName, 'bulk2'); + $this->assertEquals('updatedX', $retrievedBulk2->getAttribute('fieldX')); + $this->assertEquals('updatedZ', $retrievedBulk2->getAttribute('fieldZ')); + $this->assertArrayNotHasKey('fieldY', $retrievedBulk2->getArrayCopy()); + + // Test 6: Upsert creating new document (should not unset anything) + $newDoc = $database->upsertDocument($collectionName, new Document([ + '$id' => 'newDoc', + '$permissions' => $permissions, + 'newField' => 'newValue', + ])); + + $this->assertEquals('newValue', $newDoc->getAttribute('newField')); + $retrievedNew = $database->getDocument($collectionName, 'newDoc'); + $this->assertEquals('newValue', $retrievedNew->getAttribute('newField')); + $this->assertArrayHasKey('newField', $retrievedNew->getArrayCopy()); + + // Test 7: Remove all custom fields (keep only system fields) + $doc7 = $database->createDocument($collectionName, new Document([ + '$id' => 'doc7', + '$permissions' => $permissions, + 'custom1' => 'value1', + 'custom2' => 'value2', + 'custom3' => 'value3', + ])); + + // Upsert with only system fields (no custom fields) + $database->upsertDocument($collectionName, new Document([ + '$id' => 'doc7', + '$permissions' => $permissions, + // No custom fields + ])); + + $retrieved7 = $database->getDocument($collectionName, 'doc7'); + $this->assertArrayNotHasKey('custom1', $retrieved7->getArrayCopy()); + $this->assertArrayNotHasKey('custom2', $retrieved7->getArrayCopy()); + $this->assertArrayNotHasKey('custom3', $retrieved7->getArrayCopy()); + // System fields should still exist + $this->assertEquals('doc7', $retrieved7->getId()); + $this->assertNotNull($retrieved7->getCreatedAt()); + $this->assertNotNull($retrieved7->getUpdatedAt()); + + // Test 8: Mixed scenario - add new fields while removing others + $doc8 = $database->createDocument($collectionName, new Document([ + '$id' => 'doc8', + '$permissions' => $permissions, + 'oldField1' => 'old1', + 'oldField2' => 'old2', + 'keepField' => 'keep', + ])); + + // Upsert removing oldField1 and oldField2, keeping keepField, adding newField + $database->upsertDocument($collectionName, new Document([ + '$id' => 'doc8', + '$permissions' => $permissions, + 'keepField' => 'updatedKeep', + 'newField' => 'newValue', + ])); + + $retrieved8 = $database->getDocument($collectionName, 'doc8'); + $this->assertEquals('updatedKeep', $retrieved8->getAttribute('keepField')); + $this->assertEquals('newValue', $retrieved8->getAttribute('newField')); + $this->assertArrayNotHasKey('oldField1', $retrieved8->getArrayCopy()); + $this->assertArrayNotHasKey('oldField2', $retrieved8->getArrayCopy()); + + // Test 9: Verify internal/system fields are never removed + $doc9 = $database->createDocument($collectionName, new Document([ + '$id' => 'doc9', + '$permissions' => $permissions, + 'data' => 'test', + ])); + + $originalCreatedAt = $doc9->getCreatedAt(); + $originalUpdatedAt = $doc9->getUpdatedAt(); + + // Upsert - internal fields should be preserved + $database->upsertDocument($collectionName, new Document([ + '$id' => 'doc9', + '$permissions' => $permissions, + 'newData' => 'newTest', + ])); + + $retrieved9 = $database->getDocument($collectionName, 'doc9'); + // System fields should still exist + $this->assertEquals('doc9', $retrieved9->getId()); + $this->assertEquals($originalCreatedAt, $retrieved9->getCreatedAt()); + // UpdatedAt should be different (document was updated) + $this->assertNotEquals($originalUpdatedAt, $retrieved9->getUpdatedAt()); + $this->assertEquals('newTest', $retrieved9->getAttribute('newData')); + // Old field should be removed + $this->assertArrayNotHasKey('data', $retrieved9->getArrayCopy()); + + // Clean up + $database->deleteCollection($collectionName); + } } From 6f23b3d89e6d31704649537857c26667619e9e33 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Sun, 18 Jan 2026 15:04:58 +0530 Subject: [PATCH 2/2] linting --- tests/e2e/Adapter/Scopes/SchemalessTests.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/e2e/Adapter/Scopes/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index c9ea0e186..ad2dc7f98 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -1873,7 +1873,6 @@ public function testUpsertFieldRemoval(): void if ($database->getAdapter()->getSupportForAttributes()) { $this->markTestSkipped('Adapter supports attributes (schemaful mode). Field removal in upsert is tested in schemaful tests.'); - return; } $collectionName = ID::unique();