Skip to content

Commit fbad15d

Browse files
authored
Rearrange equal items for non-homogeneous arrays, fixes #33 (#35)
1 parent ab1ce3a commit fbad15d

File tree

6 files changed

+228
-18
lines changed

6 files changed

+228
-18
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [3.7.6] - 2020-09-25
8+
9+
### Added
10+
- Rearrangement of equal items for non-homogeneous arrays with `JsonDiff::REARRANGE_ARRAYS` option.
11+
712
## [3.7.5] - 2020-05-26
813

914
### Fixed
@@ -45,6 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4550
### Added
4651
- Compatibility option to `TOLERATE_ASSOCIATIVE_ARRAYS` that mimic JSON objects.
4752

53+
[3.7.6]: https://github.com/swaggest/json-diff/compare/v3.7.5...v3.7.6
4854
[3.7.5]: https://github.com/swaggest/json-diff/compare/v3.7.4...v3.7.5
4955
[3.7.4]: https://github.com/swaggest/json-diff/compare/v3.7.3...v3.7.4
5056
[3.7.3]: https://github.com/swaggest/json-diff/compare/v3.7.2...v3.7.3

README.md

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ $r = new JsonDiff(
5757
```
5858

5959
Available options:
60-
* `REARRANGE_ARRAYS` is an option to enable arrays rearrangement to minimize the difference.
60+
* `REARRANGE_ARRAYS` is an option to enable [arrays rearrangement](#arraysrearrangement) to minimize the difference.
6161
* `STOP_ON_DIFF` is an option to improve performance by stopping comparison when a difference is found.
6262
* `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").
6363
* `SKIP_JSON_PATCH` is an option to improve performance by not building JsonPatch for this diff.
@@ -67,8 +67,6 @@ Available options:
6767

6868
Options can be combined, e.g. `JsonDiff::REARRANGE_ARRAYS + JsonDiff::STOP_ON_DIFF`.
6969

70-
On created object you have several handy methods.
71-
7270
#### `getDiffCnt`
7371
Returns total number of differences
7472

@@ -248,6 +246,40 @@ Due to magical methods and other restrictions PHP classes can not be reliably ma
248246
There is support for objects of PHP classes in `JsonPointer` with limitations:
249247
* `null` is equal to non-existent
250248

249+
## Arrays Rearrangement
250+
251+
When `JsonDiff::REARRANGE_ARRAYS` option is enabled, array items are ordered to match the original array.
252+
253+
If arrays contain homogenous objects, and those objects have a common property with unique values, array is
254+
ordered to match placement of items with same value of such property in the original array.
255+
256+
Example:
257+
original
258+
```json
259+
[{"name": "Alex", "height": 180},{"name": "Joe", "height": 179},{"name": "Jane", "height": 165}]
260+
```
261+
vs new
262+
```json
263+
[{"name": "Joe", "height": 179},{"name": "Jane", "height": 168},{"name": "Alex", "height": 180}]
264+
```
265+
would produce a patch:
266+
```json
267+
[{"value":165,"op":"test","path":"/2/height"},{"value":168,"op":"replace","path":"/2/height"}]
268+
```
269+
270+
If qualifying indexing property is not found, rearrangement is done based on items equality.
271+
272+
Example:
273+
original
274+
```json
275+
{"data": [{"A": 1, "C": [1, 2, 3]}, {"B": 2}]}
276+
```
277+
vs new
278+
```json
279+
{"data": [{"B": 2}, {"A": 1, "C": [3, 2, 1]}]}
280+
```
281+
would produce no difference.
282+
251283
## CLI tool
252284

253285
Moved to [`swaggest/json-cli`](https://github.com/swaggest/json-cli)

src/JsonDiff.php

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,6 @@ class JsonDiff
4747

4848

4949
private $options = 0;
50-
private $original;
51-
private $new;
5250

5351
/**
5452
* @var mixed Merge patch container
@@ -80,6 +78,12 @@ class JsonDiff
8078
/** @var JsonPatch */
8179
private $jsonPatch;
8280

81+
/** @var JsonHash */
82+
private $jsonHashOriginal;
83+
84+
/** @var JsonHash */
85+
private $jsonHashNew;
86+
8387
/**
8488
* @param mixed $original
8589
* @param mixed $new
@@ -92,15 +96,13 @@ public function __construct($original, $new, $options = 0)
9296
$this->jsonPatch = new JsonPatch();
9397
}
9498

95-
$this->original = $original;
96-
$this->new = $new;
9799
$this->options = $options;
98100

99101
if ($options & self::JSON_URI_FRAGMENT_ID) {
100102
$this->path = '#';
101103
}
102104

103-
$this->rearranged = $this->rearrange();
105+
$this->rearranged = $this->process($original, $new);
104106
if (($new !== null) && $this->merge === null) {
105107
$this->merge = new \stdClass();
106108
}
@@ -241,14 +243,6 @@ public function getMergePatch()
241243

242244
}
243245

244-
/**
245-
* @return array|null|object|\stdClass
246-
* @throws Exception
247-
*/
248-
private function rearrange()
249-
{
250-
return $this->process($this->original, $this->new);
251-
}
252246

253247
/**
254248
* @param mixed $original
@@ -406,7 +400,7 @@ private function rearrangeArray(array $original, array $new)
406400
{
407401
$first = reset($original);
408402
if (!$first instanceof \stdClass) {
409-
return $new;
403+
return $this->rearrangeEqualItems($original, $new);
410404
}
411405

412406
$uniqueKey = false;
@@ -450,7 +444,7 @@ private function rearrangeArray(array $original, array $new)
450444
}
451445

452446
if (!$uniqueKey) {
453-
return $new;
447+
return $this->rearrangeEqualItems($original, $new);
454448
}
455449

456450
$newRearranged = [];
@@ -499,4 +493,36 @@ private function rearrangeArray(array $original, array $new)
499493
$newRearranged = array_values($newRearranged);
500494
return $newRearranged;
501495
}
496+
497+
private function rearrangeEqualItems(array $original, array $new)
498+
{
499+
if ($this->jsonHashOriginal === null) {
500+
$this->jsonHashOriginal = new JsonHash($this->options);
501+
$this->jsonHashNew = new JsonHash($this->options);
502+
}
503+
504+
$origIdx = [];
505+
foreach ($original as $i => $item) {
506+
$origIdx[$i] = $this->jsonHashOriginal->xorHash($item);
507+
}
508+
509+
$newIdx = [];
510+
foreach ($new as $i => $item) {
511+
$hash = $this->jsonHashNew->xorHash($item);
512+
$newIdx[$hash][] = $i;
513+
}
514+
515+
$rearranged = $new;
516+
foreach ($origIdx as $i => $hash) {
517+
if (empty($newIdx[$hash])) {
518+
continue;
519+
}
520+
521+
$j = array_shift($newIdx[$hash]);
522+
$rearranged[$i] = $new[$j];
523+
$rearranged[$j] = $new[$i];
524+
}
525+
526+
return $rearranged;
527+
}
502528
}

src/JsonHash.php

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
namespace Swaggest\JsonDiff;
4+
5+
class JsonHash
6+
{
7+
private $options = 0;
8+
9+
public function __construct($options = 0)
10+
{
11+
$this->options = $options;
12+
}
13+
14+
/**
15+
* @param mixed $data
16+
* @param string $path
17+
* @return string
18+
*/
19+
public function xorHash($data, $path = '')
20+
{
21+
$xorHash = '';
22+
23+
if (!$data instanceof \stdClass && !is_array($data)) {
24+
$s = $path . (string)$data;
25+
if (strlen($xorHash) < strlen($s)) {
26+
$xorHash = str_pad($xorHash, strlen($s));
27+
}
28+
$xorHash ^= $s;
29+
30+
return $xorHash;
31+
}
32+
33+
if ($this->options & JsonDiff::TOLERATE_ASSOCIATIVE_ARRAYS) {
34+
if (is_array($data) && !empty($data) && !array_key_exists(0, $data)) {
35+
$data = (object)$data;
36+
}
37+
}
38+
39+
if (is_array($data)) {
40+
if ($this->options & JsonDiff::REARRANGE_ARRAYS) {
41+
foreach ($data as $key => $item) {
42+
$itemPath = $path . '/' . $key;
43+
$itemHash = $path . $this->xorHash($item, $itemPath);
44+
if (strlen($xorHash) < strlen($itemHash)) {
45+
$xorHash = str_pad($xorHash, strlen($itemHash));
46+
}
47+
$xorHash ^= $itemHash;
48+
}
49+
} else {
50+
foreach ($data as $key => $item) {
51+
$itemPath = $path . '/' . $key;
52+
$itemHash = md5($itemPath . $this->xorHash($item, $itemPath), true);
53+
if (strlen($xorHash) < strlen($itemHash)) {
54+
$xorHash = str_pad($xorHash, strlen($itemHash));
55+
}
56+
$xorHash ^= $itemHash;
57+
}
58+
}
59+
60+
return $xorHash;
61+
}
62+
63+
$dataKeys = get_object_vars($data);
64+
foreach ($dataKeys as $key => $value) {
65+
$propertyPath = $path . '/' .
66+
JsonPointer::escapeSegment($key, (bool)($this->options & JsonDiff::JSON_URI_FRAGMENT_ID));
67+
$propertyHash = $propertyPath . $this->xorHash($value, $propertyPath);
68+
if (strlen($xorHash) < strlen($propertyHash)) {
69+
$xorHash = str_pad($xorHash, strlen($propertyHash));
70+
}
71+
$xorHash ^= $propertyHash;
72+
}
73+
74+
return $xorHash;
75+
}
76+
}

tests/src/JsonHashTest.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace Swaggest\JsonDiff\Tests;
4+
5+
use Swaggest\JsonDiff\JsonDiff;
6+
use Swaggest\JsonDiff\JsonHash;
7+
8+
class JsonHashTest extends \PHPUnit_Framework_TestCase
9+
{
10+
public function testHash()
11+
{
12+
$h1 = (new JsonHash())->xorHash(json_decode('{"data": [{"A": 1},{"B": 2}]}'));
13+
$h2 = (new JsonHash())->xorHash(json_decode('{"data": [{"B": 2},{"A": 1}]}'));
14+
$h3 = (new JsonHash())->xorHash(json_decode('{"data": [{"B": 3},{"A": 2}]}'));
15+
16+
$this->assertNotEmpty($h1);
17+
$this->assertNotEmpty($h2);
18+
$this->assertNotEmpty($h3);
19+
$this->assertNotEquals($h1, $h2);
20+
$this->assertNotEquals($h1, $h3);
21+
}
22+
23+
public function testHashRearrange()
24+
{
25+
$h1 = (new JsonHash(JsonDiff::REARRANGE_ARRAYS))
26+
->xorHash(json_decode('{"data": [{"A": 1},{"B": 2}]}'));
27+
$h2 = (new JsonHash(JsonDiff::REARRANGE_ARRAYS))
28+
->xorHash(json_decode('{"data": [{"B": 2},{"A": 1}]}'));
29+
$h3 = (new JsonHash(JsonDiff::REARRANGE_ARRAYS))
30+
->xorHash(json_decode('{"data": [{"B": 3},{"A": 2}]}'));
31+
32+
$this->assertNotEmpty($h1);
33+
$this->assertNotEmpty($h2);
34+
$this->assertNotEmpty($h3);
35+
$this->assertEquals($h1, $h2);
36+
$this->assertNotEquals($h1, $h3);
37+
}
38+
}

tests/src/RearrangeArrayTest.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,4 +178,36 @@ function testRearrangeKeepOriginal()
178178
json_encode($m->getRearranged(), JSON_PRETTY_PRINT)
179179
);
180180
}
181+
182+
public function testEqualItems()
183+
{
184+
$diff = new \Swaggest\JsonDiff\JsonDiff(
185+
json_decode('{"data": [{"A": 1, "C": [1,2,3]},{"B": 2}]}'),
186+
json_decode('{"data": [{"B": 2},{"A": 1, "C": [3,2,1]}]}'),
187+
JsonDiff::REARRANGE_ARRAYS);
188+
189+
$this->assertEmpty($diff->getDiffCnt());
190+
}
191+
192+
public function testEqualItemsDiff()
193+
{
194+
$diff = new \Swaggest\JsonDiff\JsonDiff(
195+
json_decode('{"data": [{"A": 1, "C": [1,2,3,4]},{"B": 2}]}'),
196+
json_decode('{"data": [{"B": 2},{"A": 1, "C": [5,3,2,1]}]}'),
197+
JsonDiff::REARRANGE_ARRAYS);
198+
199+
$this->assertEquals('[{"value":4,"op":"test","path":"/data/0/C/3"},{"value":5,"op":"replace","path":"/data/0/C/3"}]',
200+
json_encode($diff->getPatch(), JSON_UNESCAPED_SLASHES));
201+
}
202+
203+
public function testExample()
204+
{
205+
$diff = new \Swaggest\JsonDiff\JsonDiff(
206+
json_decode('[{"name": "Alex", "height": 180},{"name": "Joe", "height": 179},{"name": "Jane", "height": 165}]'),
207+
json_decode('[{"name": "Joe", "height": 179},{"name": "Jane", "height": 168},{"name": "Alex", "height": 180}]'),
208+
JsonDiff::REARRANGE_ARRAYS);
209+
210+
$this->assertEquals('[{"value":165,"op":"test","path":"/2/height"},{"value":168,"op":"replace","path":"/2/height"}]',
211+
json_encode($diff->getPatch(), JSON_UNESCAPED_SLASHES));
212+
}
181213
}

0 commit comments

Comments
 (0)