Completed
Push — master ( 2f51b1...b997a2 )
by Viacheslav
24s
created

JsonDiff::getModifiedDiff()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

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