diff --git a/composer.json b/composer.json index 1ccda769..b9d23f01 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,8 @@ }, "extra": { "branch-alias": { - "dev-master": "v2.1.x-dev" + "dev-master": "v2.1.x-dev", + "dev-differential-update": "v2.2.x-dev" } } } diff --git a/src/Mongolid/DataMapper/DataMapper.php b/src/Mongolid/DataMapper/DataMapper.php index 68f22262..7d3ca5a3 100644 --- a/src/Mongolid/DataMapper/DataMapper.php +++ b/src/Mongolid/DataMapper/DataMapper.php @@ -175,10 +175,11 @@ public function update($entity, array $options = []): bool } $data = $this->parseToDocument($entity); + $updateData = $this->getUpdateData($entity, $data); $queryResult = $this->getCollection()->updateOne( ['_id' => $data['_id']], - ['$set' => $data], + $updateData, $this->mergeOptions($options) ); @@ -570,4 +571,52 @@ public function setSchema(Schema $schema) { $this->schema = $schema; } + + private function getUpdateData($model, array $data): array + { + $changes = []; + $oldData = []; + + if ($model instanceof AttributesAccessInterface) { + $oldData = $model->originalAttributes(); + } + + $data = array_filter($data, function ($value) { + return !is_null($value); + }); + + $this->calculateChanges($changes, $data, $oldData); + + return $changes; + } + + /** + * Based on the work of "bjori/mongo-php-transistor". + * Calculate `$set` and `$unset` arrays for update operation and store them on $changes. + * + * @see https://github.com/bjori/mongo-php-transistor/blob/70f5af00795d67f4d5a8c397e831435814df9937/src/Transistor.php#L108 + */ + private function calculateChanges(array &$changes, array $newData, array $oldData, string $keyfix = '') + { + foreach ($newData as $k => $v) { + if (!isset($oldData[$k])) { // new field + $changes['$set']["{$keyfix}{$k}"] = $v; + } elseif ($oldData[$k] != $v) { // changed value + if (is_array($v) && is_array($oldData[$k]) && $v) { // check array recursively for changes + $this->calculateChanges($changes, $v, $oldData[$k], "{$keyfix}{$k}."); + } else { + // overwrite normal changes in keys + // this applies to previously empty arrays/documents too + $changes['$set']["{$keyfix}{$k}"] = $v; + } + } + } + + foreach ($oldData as $k => $v) { // data that used to exist, but now doesn't + if (!isset($newData[$k])) { // removed field + $changes['$unset']["{$keyfix}{$k}"] = ''; + continue; + } + } + } } diff --git a/src/Mongolid/Model/Attributes.php b/src/Mongolid/Model/Attributes.php index 2a492cef..785eda39 100644 --- a/src/Mongolid/Model/Attributes.php +++ b/src/Mongolid/Model/Attributes.php @@ -122,6 +122,14 @@ public function setAttribute(string $key, $value) $this->attributes[$key] = $value; } + /** + * Get original attributes. + */ + public function originalAttributes() + { + return $this->original; + } + /** * Stores original attributes from actual data from attributes * to be used in future comparisons about changes. diff --git a/tests/Mongolid/Cursor/CursorTest.php b/tests/Mongolid/Cursor/CursorTest.php index 6ff26933..205caa06 100644 --- a/tests/Mongolid/Cursor/CursorTest.php +++ b/tests/Mongolid/Cursor/CursorTest.php @@ -191,7 +191,8 @@ public function testShouldGetCurrentUsingActiveRecordClasses() { // Arrange $collection = m::mock(Collection::class); - $entity = m::mock(ActiveRecord::class.'[]'); + $entity = new class() extends ActiveRecord { + }; $entity->name = 'John Doe'; $driverCursor = new ArrayIterator([$entity]); $cursor = $this->getCursor(null, $collection, 'find', [[]], $driverCursor); diff --git a/tests/Mongolid/DataMapper/DataMapperTest.php b/tests/Mongolid/DataMapper/DataMapperTest.php index e157e2c8..7fb5e7a9 100644 --- a/tests/Mongolid/DataMapper/DataMapperTest.php +++ b/tests/Mongolid/DataMapper/DataMapperTest.php @@ -259,6 +259,11 @@ public function testShouldUpdate($entity, $writeConcern, $shouldFireEventAfter, ->andReturn(1); if ($entity instanceof AttributesAccessInterface) { + $entity->shouldReceive('originalAttributes') + ->once() + ->with() + ->andReturn([]); + $entity->shouldReceive('syncOriginalAttributes') ->once() ->with(); @@ -276,6 +281,65 @@ public function testShouldUpdate($entity, $writeConcern, $shouldFireEventAfter, $this->assertEquals($expected, $mapper->update($entity, $options)); } + public function testDifferentialUpdateShouldWork() + { + // Arrange + $entity = m::mock(AttributesAccessInterface::class); + $connPool = m::mock(Pool::class); + $mapper = m::mock(DataMapper::class.'[parseToDocument,getCollection]', [$connPool]); + + $collection = m::mock(Collection::class); + $parsedObject = ['_id' => 123, 'name' => 'Original Name', 'age' => 32, 'hobbies' => ['bike', 'skate'], 'address' => null, 'other' => null]; + $originalAttributes = ['_id' => 123, 'name' => 'Original Name', 'hobbies' => ['bike', 'motorcycle', 'gardening'], 'address' => '1 Blue street', 'gender' => 'm', 'nullField' => null]; + $operationResult = m::mock(); + $options = ['writeConcern' => new WriteConcern(1)]; + + $entity->_id = 123; + $updateData = ['$set' => ['age' => 32, 'hobbies.1' => 'skate'], '$unset' => ['hobbies.2' => '', 'address' => '', 'gender' => '', 'nullField' => '']]; + + // Act + $mapper->shouldAllowMockingProtectedMethods(); + + $mapper->shouldReceive('parseToDocument') + ->once() + ->with($entity) + ->andReturn($parsedObject); + + $mapper->shouldReceive('getCollection') + ->once() + ->andReturn($collection); + + $collection->shouldReceive('updateOne') + ->once() + ->with( + ['_id' => 123], + $updateData, + $options + )->andReturn($operationResult); + + $operationResult->shouldReceive('isAcknowledged') + ->once() + ->andReturn(true); + + $operationResult->shouldReceive('getModifiedCount') + ->andReturn(1); + + $entity->shouldReceive('originalAttributes') + ->once() + ->with() + ->andReturn($originalAttributes); + + $entity->shouldReceive('syncOriginalAttributes') + ->once() + ->with(); + + $this->expectEventToBeFired('updating', $entity, true); + $this->expectEventToBeFired('updated', $entity, false); + + // Assert + $this->assertTrue($mapper->update($entity, $options)); + } + /** * @dataProvider getWriteConcernVariations */ diff --git a/tests/Mongolid/DynamicSchemaTest.php b/tests/Mongolid/DynamicSchemaTest.php index bd3cc9ac..38e34a76 100644 --- a/tests/Mongolid/DynamicSchemaTest.php +++ b/tests/Mongolid/DynamicSchemaTest.php @@ -18,7 +18,7 @@ public function tearDown() public function testShouldExtendSchema() { // Arrange - $schema = m::mock(DynamicSchema::class.'[]'); + $schema = new DynamicSchema(); // Assert $this->assertInstanceOf(Schema::class, $schema); @@ -27,7 +27,7 @@ public function testShouldExtendSchema() public function testShouldBeDynamic() { // Arrange - $schema = m::mock(DynamicSchema::class.'[]'); + $schema = new DynamicSchema(); // Assert $this->assertAttributeEquals(true, 'dynamic', $schema); diff --git a/tests/Mongolid/Model/RelationsTest.php b/tests/Mongolid/Model/RelationsTest.php index 4e81c798..13039f6c 100644 --- a/tests/Mongolid/Model/RelationsTest.php +++ b/tests/Mongolid/Model/RelationsTest.php @@ -28,7 +28,8 @@ public function testShouldReferenceOne($entity, $field, $fieldValue, $useCache, { // Set $expectedQuery = $expectedQuery['referencesOne']; - $model = m::mock(ActiveRecord::class.'[]'); + $model = new class() extends ActiveRecord { + }; $dataMapper = m::mock(DataMapper::class)->makePartial(); $result = m::mock(); @@ -61,7 +62,8 @@ public function testShouldReferenceMany($entity, $field, $fieldValue, $useCache, { // Set $expectedQuery = $expectedQuery['referencesMany']; - $model = m::mock(ActiveRecord::class.'[]'); + $model = new class() extends ActiveRecord { + }; $dataMapper = m::mock(DataMapper::class)->makePartial(); $result = m::mock(Cursor::class); @@ -93,7 +95,8 @@ public function testShouldReferenceMany($entity, $field, $fieldValue, $useCache, public function testShouldEmbedsOne($entity, $field, $fieldValue, $expectedItems) { // Set - $model = m::mock(ActiveRecord::class.'[]'); + $model = new class() extends ActiveRecord { + }; $cursorFactory = m::mock(CursorFactory::class); $cursor = m::mock(EmbeddedCursor::class); $document = $fieldValue; @@ -124,7 +127,8 @@ public function testShouldEmbedsOne($entity, $field, $fieldValue, $expectedItems public function testShouldEmbedsMany($entity, $field, $fieldValue, $expectedItems) { // Set - $model = m::mock(ActiveRecord::class.'[]'); + $model = new class() extends ActiveRecord { + }; $cursorFactory = m::mock(CursorFactory::class); $cursor = m::mock(EmbeddedCursor::class); $document = $fieldValue; diff --git a/tests/Mongolid/SchemaTest.php b/tests/Mongolid/SchemaTest.php index addbdfb0..d46056c9 100644 --- a/tests/Mongolid/SchemaTest.php +++ b/tests/Mongolid/SchemaTest.php @@ -39,7 +39,8 @@ public function testMustHaveAnEntityClass() public function testShouldCastNullIntoObjectId() { // Arrange - $schema = m::mock(Schema::class.'[]'); + $schema = new class extends Schema { + }; $value = null; // Assert @@ -52,7 +53,8 @@ public function testShouldCastNullIntoObjectId() public function testShouldNotCastRandomStringIntoObjectId() { // Arrange - $schema = m::mock(Schema::class.'[]'); + $schema = new class extends Schema { + }; $value = 'A random string'; // Assert @@ -65,7 +67,8 @@ public function testShouldNotCastRandomStringIntoObjectId() public function testShouldCastObjectIdStringIntoObjectId() { // Arrange - $schema = m::mock(Schema::class.'[]'); + $schema = new class extends Schema { + }; $value = '507f1f77bcf86cd799439011'; // Assert @@ -83,7 +86,8 @@ public function testShouldCastObjectIdStringIntoObjectId() public function testShouldCastNullIntoAutoIncrementSequence() { // Arrange - $schema = m::mock(Schema::class.'[]'); + $schema = new class extends Schema { + }; $sequenceService = m::mock(SequenceService::class); $value = null; @@ -103,7 +107,8 @@ public function testShouldCastNullIntoAutoIncrementSequence() public function testShouldNotAutoIncrementSequenceIfValueIsNotNull() { - $schema = m::mock(Schema::class.'[]'); + $schema = new class extends Schema { + }; $sequenceService = m::mock(SequenceService::class); $value = 3; @@ -124,7 +129,8 @@ public function testShouldNotAutoIncrementSequenceIfValueIsNotNull() public function testShouldCastDocumentTimestamps() { // Arrange - $schema = m::mock(Schema::class.'[]'); + $schema = new class extends Schema { + }; $value = null; // Assertion @@ -137,7 +143,8 @@ public function testShouldCastDocumentTimestamps() public function testShouldRefreshUpdatedAtTimestamps() { // Arrange - $schema = m::mock(Schema::class.'[]'); + $schema = new class extends Schema { + }; $value = (new UTCDateTime(25)); // Assertion @@ -155,7 +162,8 @@ public function testShouldNotRefreshCreatedAtTimestamps( $compareTimestamp = true ) { // Arrange - $schema = m::mock(Schema::class.'[]'); + $schema = new class extends Schema { + }; // Assertion $result = $schema->createdAtTimestamp($value);