From 415937858f1cabfcb0f80e7f7cb76cf69e041acf Mon Sep 17 00:00:00 2001 From: Viacheslav Poturaev Date: Thu, 24 Sep 2020 23:59:41 +0200 Subject: [PATCH 1/2] Rearrange equal items for non-homogeneous arrays, fixes #33 --- CHANGELOG.md | 6 +++ src/JsonDiff.php | 56 ++++++++++++++++------- src/JsonHash.php | 76 ++++++++++++++++++++++++++++++++ tests/src/JsonHashTest.php | 38 ++++++++++++++++ tests/src/RearrangeArrayTest.php | 21 +++++++++ 5 files changed, 182 insertions(+), 15 deletions(-) create mode 100644 src/JsonHash.php create mode 100644 tests/src/JsonHashTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8093585..b7c1576 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.7.6] - 2020-09-25 + +### Added +- Rearrangement of equal items for non-homogeneous arrays with `JsonDiff::REARRANGE_ARRAYS` option. + ## [3.7.5] - 2020-05-26 ### Fixed @@ -45,6 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Compatibility option to `TOLERATE_ASSOCIATIVE_ARRAYS` that mimic JSON objects. +[3.7.6]: https://github.com/swaggest/json-diff/compare/v3.7.5...v3.7.6 [3.7.5]: https://github.com/swaggest/json-diff/compare/v3.7.4...v3.7.5 [3.7.4]: https://github.com/swaggest/json-diff/compare/v3.7.3...v3.7.4 [3.7.3]: https://github.com/swaggest/json-diff/compare/v3.7.2...v3.7.3 diff --git a/src/JsonDiff.php b/src/JsonDiff.php index 2302def..2f55305 100644 --- a/src/JsonDiff.php +++ b/src/JsonDiff.php @@ -47,8 +47,6 @@ class JsonDiff private $options = 0; - private $original; - private $new; /** * @var mixed Merge patch container @@ -80,6 +78,12 @@ class JsonDiff /** @var JsonPatch */ private $jsonPatch; + /** @var JsonHash */ + private $jsonHashOriginal; + + /** @var JsonHash */ + private $jsonHashNew; + /** * @param mixed $original * @param mixed $new @@ -92,15 +96,13 @@ public function __construct($original, $new, $options = 0) $this->jsonPatch = new JsonPatch(); } - $this->original = $original; - $this->new = $new; $this->options = $options; if ($options & self::JSON_URI_FRAGMENT_ID) { $this->path = '#'; } - $this->rearranged = $this->rearrange(); + $this->rearranged = $this->process($original, $new); if (($new !== null) && $this->merge === null) { $this->merge = new \stdClass(); } @@ -241,14 +243,6 @@ public function getMergePatch() } - /** - * @return array|null|object|\stdClass - * @throws Exception - */ - private function rearrange() - { - return $this->process($this->original, $this->new); - } /** * @param mixed $original @@ -406,7 +400,7 @@ private function rearrangeArray(array $original, array $new) { $first = reset($original); if (!$first instanceof \stdClass) { - return $new; + return $this->rearrangeEqualItems($original, $new); } $uniqueKey = false; @@ -450,7 +444,7 @@ private function rearrangeArray(array $original, array $new) } if (!$uniqueKey) { - return $new; + return $this->rearrangeEqualItems($original, $new); } $newRearranged = []; @@ -499,4 +493,36 @@ private function rearrangeArray(array $original, array $new) $newRearranged = array_values($newRearranged); return $newRearranged; } + + private function rearrangeEqualItems(array $original, array $new) + { + if ($this->jsonHashOriginal === null) { + $this->jsonHashOriginal = new JsonHash($this->options); + $this->jsonHashNew = new JsonHash($this->options); + } + + $origIdx = []; + foreach ($original as $i => $item) { + $origIdx[$i] = $this->jsonHashOriginal->xorHash($item); + } + + $newIdx = []; + foreach ($new as $i => $item) { + $hash = $this->jsonHashNew->xorHash($item); + $newIdx[$hash][] = $i; + } + + $rearranged = $new; + foreach ($origIdx as $i => $hash) { + if (empty($newIdx[$hash])) { + continue; + } + + $j = array_shift($newIdx[$hash]); + $rearranged[$i] = $new[$j]; + $rearranged[$j] = $new[$i]; + } + + return $rearranged; + } } \ No newline at end of file diff --git a/src/JsonHash.php b/src/JsonHash.php new file mode 100644 index 0000000..4b03e86 --- /dev/null +++ b/src/JsonHash.php @@ -0,0 +1,76 @@ +options = $options; + } + + /** + * @param mixed $data + * @param string $path + * @return string + */ + public function xorHash($data, $path = '') + { + $xorHash = ''; + + if (!$data instanceof \stdClass && !is_array($data)) { + $s = $path . (string)$data; + if (strlen($xorHash) < strlen($s)) { + $xorHash = str_pad($xorHash, strlen($s)); + } + $xorHash ^= $s; + + return $xorHash; + } + + if ($this->options & JsonDiff::TOLERATE_ASSOCIATIVE_ARRAYS) { + if (is_array($data) && !empty($data) && !array_key_exists(0, $data)) { + $data = (object)$data; + } + } + + if (is_array($data)) { + if ($this->options & JsonDiff::REARRANGE_ARRAYS) { + foreach ($data as $key => $item) { + $itemPath = $path . '/' . $key; + $itemHash = $path . $this->xorHash($item, $itemPath); + if (strlen($xorHash) < strlen($itemHash)) { + $xorHash = str_pad($xorHash, strlen($itemHash)); + } + $xorHash ^= $itemHash; + } + } else { + foreach ($data as $key => $item) { + $itemPath = $path . '/' . $key; + $itemHash = md5($itemPath . $this->xorHash($item, $itemPath), true); + if (strlen($xorHash) < strlen($itemHash)) { + $xorHash = str_pad($xorHash, strlen($itemHash)); + } + $xorHash ^= $itemHash; + } + } + + return $xorHash; + } + + $dataKeys = get_object_vars($data); + foreach ($dataKeys as $key => $value) { + $propertyPath = $path . '/' . + JsonPointer::escapeSegment($key, (bool)($this->options & JsonDiff::JSON_URI_FRAGMENT_ID)); + $propertyHash = $propertyPath . $this->xorHash($value, $propertyPath); + if (strlen($xorHash) < strlen($propertyHash)) { + $xorHash = str_pad($xorHash, strlen($propertyHash)); + } + $xorHash ^= $propertyHash; + } + + return $xorHash; + } +} \ No newline at end of file diff --git a/tests/src/JsonHashTest.php b/tests/src/JsonHashTest.php new file mode 100644 index 0000000..f47ced2 --- /dev/null +++ b/tests/src/JsonHashTest.php @@ -0,0 +1,38 @@ +xorHash(json_decode('{"data": [{"A": 1},{"B": 2}]}')); + $h2 = (new JsonHash())->xorHash(json_decode('{"data": [{"B": 2},{"A": 1}]}')); + $h3 = (new JsonHash())->xorHash(json_decode('{"data": [{"B": 3},{"A": 2}]}')); + + $this->assertNotEmpty($h1); + $this->assertNotEmpty($h2); + $this->assertNotEmpty($h3); + $this->assertNotEquals($h1, $h2); + $this->assertNotEquals($h1, $h3); + } + + public function testHashRearrange() + { + $h1 = (new JsonHash(JsonDiff::REARRANGE_ARRAYS)) + ->xorHash(json_decode('{"data": [{"A": 1},{"B": 2}]}')); + $h2 = (new JsonHash(JsonDiff::REARRANGE_ARRAYS)) + ->xorHash(json_decode('{"data": [{"B": 2},{"A": 1}]}')); + $h3 = (new JsonHash(JsonDiff::REARRANGE_ARRAYS)) + ->xorHash(json_decode('{"data": [{"B": 3},{"A": 2}]}')); + + $this->assertNotEmpty($h1); + $this->assertNotEmpty($h2); + $this->assertNotEmpty($h3); + $this->assertEquals($h1, $h2); + $this->assertNotEquals($h1, $h3); + } +} \ No newline at end of file diff --git a/tests/src/RearrangeArrayTest.php b/tests/src/RearrangeArrayTest.php index 7e3228b..36a2fdb 100644 --- a/tests/src/RearrangeArrayTest.php +++ b/tests/src/RearrangeArrayTest.php @@ -178,4 +178,25 @@ function testRearrangeKeepOriginal() json_encode($m->getRearranged(), JSON_PRETTY_PRINT) ); } + + public function testEqualItems() + { + $diff = new \Swaggest\JsonDiff\JsonDiff( + json_decode('{"data": [{"A": 1, "C": [1,2,3]},{"B": 2}]}'), + json_decode('{"data": [{"B": 2},{"A": 1, "C": [3,2,1]}]}'), + JsonDiff::REARRANGE_ARRAYS); + + $this->assertEmpty($diff->getDiffCnt()); + } + + public function testEqualItemsDiff() + { + $diff = new \Swaggest\JsonDiff\JsonDiff( + json_decode('{"data": [{"A": 1, "C": [1,2,3,4]},{"B": 2}]}'), + json_decode('{"data": [{"B": 2},{"A": 1, "C": [5,3,2,1]}]}'), + JsonDiff::REARRANGE_ARRAYS); + + $this->assertEquals('[{"value":4,"op":"test","path":"/data/0/C/3"},{"value":5,"op":"replace","path":"/data/0/C/3"}]', + json_encode($diff->getPatch(), JSON_UNESCAPED_SLASHES)); + } } From fa5b459cde6ec4c1f82d2455ae1239da2875c1a1 Mon Sep 17 00:00:00 2001 From: Viacheslav Poturaev Date: Fri, 25 Sep 2020 00:34:35 +0200 Subject: [PATCH 2/2] Update README.md --- README.md | 38 +++++++++++++++++++++++++++++--- tests/src/RearrangeArrayTest.php | 11 +++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1e89e6c..0d34836 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ $r = new JsonDiff( ``` Available options: - * `REARRANGE_ARRAYS` is an option to enable arrays rearrangement to minimize the difference. + * `REARRANGE_ARRAYS` is an option to enable [arrays rearrangement](#arraysrearrangement) to minimize the difference. * `STOP_ON_DIFF` is an option to improve performance by stopping comparison when a difference is found. * `JSON_URI_FRAGMENT_ID` is an option to use URI Fragment Identifier Representation (example: "#/c%25d"). If not set default JSON String Representation (example: "/c%d"). * `SKIP_JSON_PATCH` is an option to improve performance by not building JsonPatch for this diff. @@ -67,8 +67,6 @@ Available options: Options can be combined, e.g. `JsonDiff::REARRANGE_ARRAYS + JsonDiff::STOP_ON_DIFF`. -On created object you have several handy methods. - #### `getDiffCnt` Returns total number of differences @@ -248,6 +246,40 @@ Due to magical methods and other restrictions PHP classes can not be reliably ma There is support for objects of PHP classes in `JsonPointer` with limitations: * `null` is equal to non-existent +## Arrays Rearrangement + +When `JsonDiff::REARRANGE_ARRAYS` option is enabled, array items are ordered to match the original array. + +If arrays contain homogenous objects, and those objects have a common property with unique values, array is +ordered to match placement of items with same value of such property in the original array. + +Example: +original +```json +[{"name": "Alex", "height": 180},{"name": "Joe", "height": 179},{"name": "Jane", "height": 165}] +``` +vs new +```json +[{"name": "Joe", "height": 179},{"name": "Jane", "height": 168},{"name": "Alex", "height": 180}] +``` +would produce a patch: +```json +[{"value":165,"op":"test","path":"/2/height"},{"value":168,"op":"replace","path":"/2/height"}] +``` + +If qualifying indexing property is not found, rearrangement is done based on items equality. + +Example: +original +```json +{"data": [{"A": 1, "C": [1, 2, 3]}, {"B": 2}]} +``` +vs new +```json +{"data": [{"B": 2}, {"A": 1, "C": [3, 2, 1]}]} +``` +would produce no difference. + ## CLI tool Moved to [`swaggest/json-cli`](https://github.com/swaggest/json-cli) \ No newline at end of file diff --git a/tests/src/RearrangeArrayTest.php b/tests/src/RearrangeArrayTest.php index 36a2fdb..a19eb90 100644 --- a/tests/src/RearrangeArrayTest.php +++ b/tests/src/RearrangeArrayTest.php @@ -199,4 +199,15 @@ public function testEqualItemsDiff() $this->assertEquals('[{"value":4,"op":"test","path":"/data/0/C/3"},{"value":5,"op":"replace","path":"/data/0/C/3"}]', json_encode($diff->getPatch(), JSON_UNESCAPED_SLASHES)); } + + public function testExample() + { + $diff = new \Swaggest\JsonDiff\JsonDiff( + json_decode('[{"name": "Alex", "height": 180},{"name": "Joe", "height": 179},{"name": "Jane", "height": 165}]'), + json_decode('[{"name": "Joe", "height": 179},{"name": "Jane", "height": 168},{"name": "Alex", "height": 180}]'), + JsonDiff::REARRANGE_ARRAYS); + + $this->assertEquals('[{"value":165,"op":"test","path":"/2/height"},{"value":168,"op":"replace","path":"/2/height"}]', + json_encode($diff->getPatch(), JSON_UNESCAPED_SLASHES)); + } }