Skip to content

Rearrange equal items for non-homogeneous arrays, fixes #33 #35

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Sep 24, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
38 changes: 35 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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)
56 changes: 41 additions & 15 deletions src/JsonDiff.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,6 @@ class JsonDiff


private $options = 0;
private $original;
private $new;

/**
* @var mixed Merge patch container
Expand Down Expand Up @@ -80,6 +78,12 @@ class JsonDiff
/** @var JsonPatch */
private $jsonPatch;

/** @var JsonHash */
private $jsonHashOriginal;

/** @var JsonHash */
private $jsonHashNew;

/**
* @param mixed $original
* @param mixed $new
Expand All @@ -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();
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -450,7 +444,7 @@ private function rearrangeArray(array $original, array $new)
}

if (!$uniqueKey) {
return $new;
return $this->rearrangeEqualItems($original, $new);
}

$newRearranged = [];
Expand Down Expand Up @@ -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;
}
}
76 changes: 76 additions & 0 deletions src/JsonHash.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

namespace Swaggest\JsonDiff;

class JsonHash
{
private $options = 0;

public function __construct($options = 0)
{
$this->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;
}
}
38 changes: 38 additions & 0 deletions tests/src/JsonHashTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace Swaggest\JsonDiff\Tests;

use Swaggest\JsonDiff\JsonDiff;
use Swaggest\JsonDiff\JsonHash;

class JsonHashTest extends \PHPUnit_Framework_TestCase
{
public function testHash()
{
$h1 = (new JsonHash())->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);
}
}
32 changes: 32 additions & 0 deletions tests/src/RearrangeArrayTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,36 @@ 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));
}

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));
}
}