Completed
Push — master ( 2dd7b5...d21843 )
by Viacheslav
20:04 queued 09:55
created

JsonDiff::rearrangeEqualItems()   B

Complexity

Conditions 8
Paths 72

Size

Total Lines 44
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 72

Importance

Changes 0
Metric Value
eloc 27
c 0
b 0
f 0
dl 0
loc 44
ccs 0
cts 0
cp 0
rs 8.4444
cc 8
nc 72
nop 2
crap 72
1
<?php
2
3
namespace Swaggest\JsonDiff;
4
5
use Swaggest\JsonDiff\JsonPatch\Add;
6
use Swaggest\JsonDiff\JsonPatch\Remove;
7
use Swaggest\JsonDiff\JsonPatch\Replace;
8
use Swaggest\JsonDiff\JsonPatch\Test;
9
10
class JsonDiff
11
{
12
    /**
13
     * REARRANGE_ARRAYS is an option to enable arrays rearrangement to minimize the difference.
14
     */
15
    const REARRANGE_ARRAYS = 1;
16
17
    /**
18
     * STOP_ON_DIFF is an option to improve performance by stopping comparison when a difference is found.
19
     */
20
    const STOP_ON_DIFF = 2;
21
22
    /**
23
     * JSON_URI_FRAGMENT_ID is an option to use URI Fragment Identifier Representation (example: "#/c%25d").
24
     * If not set default JSON String Representation (example: "/c%d").
25
     */
26
    const JSON_URI_FRAGMENT_ID = 4;
27
28
    /**
29
     * SKIP_JSON_PATCH is an option to improve performance by not building JsonPatch for this diff.
30
     */
31
    const SKIP_JSON_PATCH = 8;
32
33
    /**
34
     * SKIP_JSON_MERGE_PATCH is an option to improve performance by not building JSON Merge Patch value for this diff.
35
     */
36
    const SKIP_JSON_MERGE_PATCH = 16;
37
38
    /**
39
     * TOLERATE_ASSOCIATIVE_ARRAYS is an option to allow associative arrays to mimic JSON objects (not recommended)
40
     */
41
    const TOLERATE_ASSOCIATIVE_ARRAYS = 32;
42
43
    /**
44
     * COLLECT_MODIFIED_DIFF is an option to enable getModifiedDiff.
45
     */
46
    const COLLECT_MODIFIED_DIFF = 64;
47
48
49
    private $options = 0;
50
51
    /**
52
     * @var mixed Merge patch container
53
     */
54
    private $merge;
55
56
    private $added;
57
    private $addedCnt = 0;
58
    private $addedPaths = array();
59
60
    private $removed;
61
    private $removedCnt = 0;
62
    private $removedPaths = array();
63
64
    private $modifiedOriginal;
65
    private $modifiedNew;
66
    private $modifiedCnt = 0;
67
    private $modifiedPaths = array();
68
    /**
69
     * @var ModifiedPathDiff[]
70
     */
71
    private $modifiedDiff = array();
72
73
    private $path = '';
74
    private $pathItems = array();
75
76
    private $rearranged;
77
78
    /** @var JsonPatch */
79
    private $jsonPatch;
80
81
    /** @var JsonHash */
82
    private $jsonHashOriginal;
83
84
    /** @var JsonHash */
85
    private $jsonHashNew;
86
87
    /**
88
     * @param mixed $original
89 48
     * @param mixed $new
90
     * @param int $options
91 48
     * @throws Exception
92 48
     */
93
    public function __construct($original, $new, $options = 0)
94
    {
95 48
        if (!($options & self::SKIP_JSON_PATCH)) {
96 48
            $this->jsonPatch = new JsonPatch();
97 48
        }
98
99 48
        $this->options = $options;
100 1
101
        if ($options & self::JSON_URI_FRAGMENT_ID) {
102
            $this->path = '#';
103 48
        }
104 48
105 20
        $this->rearranged = $this->process($original, $new);
106
        if (($new !== null) && $this->merge === null) {
107 48
            $this->merge = new \stdClass();
108
        }
109
    }
110
111
    /**
112
     * Returns total number of differences
113 21
     * @return int
114
     */
115 21
    public function getDiffCnt()
116
    {
117
        return $this->addedCnt + $this->modifiedCnt + $this->removedCnt;
118
    }
119
120
    /**
121
     * Returns removals as partial value of original.
122 4
     * @return mixed
123
     */
124 4
    public function getRemoved()
125
    {
126
        return $this->removed;
127
    }
128
129
    /**
130
     * Returns list of `JSON` paths that were removed from original.
131 4
     * @return array
132
     */
133 4
    public function getRemovedPaths()
134
    {
135
        return $this->removedPaths;
136
    }
137
138
    /**
139
     * Returns number of removals.
140 4
     * @return int
141
     */
142 4
    public function getRemovedCnt()
143
    {
144
        return $this->removedCnt;
145
    }
146
147
    /**
148
     * Returns additions as partial value of new.
149 1
     * @return mixed
150
     */
151 1
    public function getAdded()
152
    {
153
        return $this->added;
154
    }
155
156
    /**
157
     * Returns number of additions.
158 1
     * @return int
159
     */
160 1
    public function getAddedCnt()
161
    {
162
        return $this->addedCnt;
163
    }
164
165
    /**
166
     * Returns list of `JSON` paths that were added to new.
167 1
     * @return array
168
     */
169 1
    public function getAddedPaths()
170
    {
171
        return $this->addedPaths;
172
    }
173
174
    /**
175
     * Returns changes as partial value of original.
176 1
     * @return mixed
177
     */
178 1
    public function getModifiedOriginal()
179
    {
180
        return $this->modifiedOriginal;
181
    }
182
183
    /**
184
     * Returns changes as partial value of new.
185 1
     * @return mixed
186
     */
187 1
    public function getModifiedNew()
188
    {
189
        return $this->modifiedNew;
190
    }
191
192
    /**
193
     * Returns number of changes.
194 2
     * @return int
195
     */
196 2
    public function getModifiedCnt()
197
    {
198
        return $this->modifiedCnt;
199
    }
200
201
    /**
202
     * Returns list of `JSON` paths that were changed from original to new.
203 1
     * @return array
204
     */
205 1
    public function getModifiedPaths()
206
    {
207
        return $this->modifiedPaths;
208
    }
209
210
    /**
211
     * Returns list of paths with original and new values.
212 1
     * @return ModifiedPathDiff[]
213
     */
214 1
    public function getModifiedDiff()
215
    {
216
        return $this->modifiedDiff;
217
    }
218
219
    /**
220
     * Returns new value, rearranged with original order.
221 3
     * @return array|object
222
     */
223 3
    public function getRearranged()
224
    {
225
        return $this->rearranged;
226
    }
227
228
    /**
229
     * Returns JsonPatch of difference
230 7
     * @return JsonPatch
231
     */
232 7
    public function getPatch()
233
    {
234
        return $this->jsonPatch;
235
    }
236
237
    /**
238 19
     * Returns JSON Merge Patch value of difference
239
     */
240 19
    public function getMergePatch()
241
    {
242
        return $this->merge;
243
244
    }
245
246
247
    /**
248 48
     * @param mixed $original
249
     * @param mixed $new
250 48
     * @return array|null|object|\stdClass
251
     * @throws Exception
252
     */
253
    private function process($original, $new)
254
    {
255
        $merge = !($this->options & self::SKIP_JSON_MERGE_PATCH);
256
257
        if ($this->options & self::TOLERATE_ASSOCIATIVE_ARRAYS) {
258
            if (is_array($original) && !empty($original) && !array_key_exists(0, $original)) {
259 48
                $original = (object)$original;
260
            }
261 48
262
            if (is_array($new) && !empty($new) && !array_key_exists(0, $new)) {
263 48
                $new = (object)$new;
264 1
            }
265 1
        }
266
267
        if (
268 1
            (!$original instanceof \stdClass && !is_array($original))
269 1
            || (!$new instanceof \stdClass && !is_array($new))
270
        ) {
271
            if ($original !== $new) {
272
                $this->modifiedCnt++;
273
                if ($this->options & self::STOP_ON_DIFF) {
274 48
                    return null;
275 48
                }
276
                $this->modifiedPaths [] = $this->path;
277 40
278 21
                if ($this->jsonPatch !== null) {
279 21
                    $this->jsonPatch->op(new Test($this->path, $original));
280 4
                    $this->jsonPatch->op(new Replace($this->path, $new));
281
                }
282 17
283
                JsonPointer::add($this->modifiedOriginal, $this->pathItems, $original);
284 17
                JsonPointer::add($this->modifiedNew, $this->pathItems, $new);
285 17
286 17
                if ($merge) {
287
                    JsonPointer::add($this->merge, $this->pathItems, $new, JsonPointer::RECURSIVE_KEY_CREATION);
288
                }
289 17
290 17
                if ($this->options & self::COLLECT_MODIFIED_DIFF) {
291
                    $this->modifiedDiff[] = new ModifiedPathDiff($this->path, $original, $new);
292 17
                }
293 17
            }
294
            return $new;
295
        }
296 17
297 1
        if (
298
            ($this->options & self::REARRANGE_ARRAYS)
299
            && is_array($original) && is_array($new)
300 36
        ) {
301
            $new = $this->rearrangeArray($original, $new);
302
        }
303
304 38
        $newArray = $new instanceof \stdClass ? get_object_vars($new) : $new;
305 38
        $newOrdered = array();
306
307 5
        $originalKeys = $original instanceof \stdClass ? get_object_vars($original) : $original;
308
        $isArray = is_array($original);
309
        $removedOffset = 0;
310 38
311 38
        if ($merge && is_array($new) && !is_array($original)) {
312
            $merge = false;
313 38
            JsonPointer::add($this->merge, $this->pathItems, $new);
314 38
        } elseif ($merge && $new instanceof \stdClass && !$original instanceof \stdClass) {
315 38
            $merge = false;
316
            JsonPointer::add($this->merge, $this->pathItems, $new);
317 38
        }
318 2
319 2
        $isUriFragment = (bool)($this->options & self::JSON_URI_FRAGMENT_ID);
320 36
        $diffCnt = $this->addedCnt + $this->modifiedCnt + $this->removedCnt;
321 4
        foreach ($originalKeys as $key => $originalValue) {
322 4
            if ($this->options & self::STOP_ON_DIFF) {
323
                if ($this->modifiedCnt || $this->addedCnt || $this->removedCnt) {
324
                    return null;
325 38
                }
326 38
            }
327 37
328 7
            $path = $this->path;
329 1
            $pathItems = $this->pathItems;
330
            $actualKey = $key;
331
            if ($isArray && is_int($actualKey)) {
332
                $actualKey -= $removedOffset;
333 37
            }
334 37
            $this->path .= '/' . JsonPointer::escapeSegment((string)$actualKey, $isUriFragment);
335 37
            $this->pathItems[] = $actualKey;
336 37
337 17
            if (array_key_exists($key, $newArray)) {
338
                $newOrdered[$key] = $this->process($originalValue, $newArray[$key]);
339 37
                unset($newArray[$key]);
340 37
            } else {
341
                $this->removedCnt++;
342 37
                if ($this->options & self::STOP_ON_DIFF) {
343 31
                    return null;
344 31
                }
345
                $this->removedPaths [] = $this->path;
346 19
                if ($isArray) {
347 19
                    $removedOffset++;
348 2
                }
349
350 17
                if ($this->jsonPatch !== null) {
351 17
                    $this->jsonPatch->op(new Remove($this->path));
352 4
                }
353
354
                JsonPointer::add($this->removed, $this->pathItems, $originalValue);
355 17
                if ($merge) {
356 17
                    JsonPointer::add($this->merge, $this->pathItems, null);
357
                }
358
359 17
            }
360 17
            $this->path = $path;
361 14
            $this->pathItems = $pathItems;
362
        }
363
364
        if ($merge && $isArray && $this->addedCnt + $this->modifiedCnt + $this->removedCnt > $diffCnt) {
365 36
            JsonPointer::add($this->merge, $this->pathItems, $new);
366 36
        }
367
368
        // additions
369
        foreach ($newArray as $key => $value) {
370 36
            $this->addedCnt++;
371 16
            if ($this->options & self::STOP_ON_DIFF) {
372 16
                return null;
373 2
            }
374
            $newOrdered[$key] = $value;
375 14
            $path = $this->path . '/' . JsonPointer::escapeSegment($key, $isUriFragment);
376 14
            $pathItems = $this->pathItems;
377 14
            $pathItems[] = $key;
378 14
            JsonPointer::add($this->added, $pathItems, $value);
379 14
            if ($merge) {
380 14
                JsonPointer::add($this->merge, $pathItems, $value);
381 10
            }
382
383
            $this->addedPaths [] = $path;
384 14
385
            if ($this->jsonPatch !== null) {
386 14
                $this->jsonPatch->op(new Add($path, $value));
387 14
            }
388
389
        }
390
391
        return is_array($new) ? $newOrdered : (object)$newOrdered;
392 36
    }
393
394
    /**
395 5
     * @param array $original
396
     * @param array $new
397 5
     * @return array
398 5
     */
399 3
    private function rearrangeArray(array $original, array $new)
400
    {
401
        $first = reset($original);
402 4
        if (!$first instanceof \stdClass) {
403 4
            return $this->rearrangeEqualItems($original, $new);
404
        }
405
406
        $uniqueKey = false;
407 4
        $uniqueIdx = array();
408 4
409 4
        // find unique key for all items
410
        /** @var mixed[string]  $f */
411
        $f = get_object_vars($first);
412
        foreach ($f as $key => $value) {
413 4
            if (is_array($value) || $value instanceof \stdClass) {
414 4
                continue;
415 4
            }
416 4
417
            $keyIsUnique = true;
418
            $uniqueIdx = array();
419 4
            foreach ($original as $idx => $item) {
420
                if (!$item instanceof \stdClass) {
421
                    return $new;
422
                }
423 4
                if (!isset($item->$key)) {
424 4
                    $keyIsUnique = false;
425
                    break;
426
                }
427
                $value = $item->$key;
428
                if ($value instanceof \stdClass || is_array($value)) {
429 4
                    $keyIsUnique = false;
430
                    break;
431
                }
432
433 4
                if (isset($uniqueIdx[$value])) {
434
                    $keyIsUnique = false;
435
                    break;
436 4
                }
437 4
                $uniqueIdx[$value] = $idx;
438 4
            }
439
440
            if ($keyIsUnique) {
441
                $uniqueKey = $key;
442 4
                break;
443 4
            }
444 4
        }
445 4
446
        if (!$uniqueKey) {
0 ignored issues
show
introduced by
$uniqueKey is of type mixed, thus it always evaluated to false.
Loading history...
447
            return $this->rearrangeEqualItems($original, $new);
448
        }
449 4
450
        $newRearranged = [];
451
        $changedItems = [];
452
453 4
        foreach ($new as $item) {
454
            if (!$item instanceof \stdClass) {
455 4
                return $new;
456
            }
457
458
            if (!property_exists($item, $uniqueKey)) {
459 4
                return $new;
460
            }
461
462
            $value = $item->$uniqueKey;
463 4
464
            if ($value instanceof \stdClass || is_array($value)) {
465
                return $new;
466 4
            }
467 4
468 4
469 4
            if (isset($uniqueIdx[$value])) {
470 4
                $idx = $uniqueIdx[$value];
471
                // Abandon rearrangement if key is not unique in new array.
472
                if (isset($newRearranged[$idx])) {
473 4
                    return $new;
474
                }
475
476 4
                $newRearranged[$idx] = $item;
477
            } else {
478
                $changedItems[] = $item;
479
            }
480
481
            $newIdx[$value] = $item;
482
        }
483
484
        $idx = 0;
485
        foreach ($changedItems as $item) {
486
            while (array_key_exists($idx, $newRearranged)) {
487
                $idx++;
488
            }
489
            $newRearranged[$idx] = $item;
490
        }
491
492
        ksort($newRearranged);
493
        $newRearranged = array_values($newRearranged);
494
        return $newRearranged;
495
    }
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
            $hash = $this->jsonHashOriginal->xorHash($item);
507
            $origIdx[$hash][] = $i;
508
        }
509
510
        $newIdx = [];
511
        foreach ($new as $i => $item) {
512
            $hash = $this->jsonHashNew->xorHash($item);
513
            $newIdx[$i] = $hash;
514
        }
515
516
        $newRearranged = [];
517
        $changedItems = [];
518
        foreach ($newIdx as $i => $hash) {
519
            if (!empty($origIdx[$hash])) {
520
                $j = array_shift($origIdx[$hash]);
521
522
                $newRearranged[$j] = $new[$i];
523
            } else {
524
                $changedItems []= $new[$i];
525
            }
526
527
        }
528
529
        $idx = 0;
530
        foreach ($changedItems as $item) {
531
            while (array_key_exists($idx, $newRearranged)) {
532
                $idx++;
533
            }
534
            $newRearranged[$idx] = $item;
535
        }
536
537
        ksort($newRearranged);
538
        $newRearranged = array_values($newRearranged);
539
540
        return $newRearranged;
541
    }
542
}