├── .gitignore ├── .gitmodules ├── .travis.yml ├── composer.json ├── simplexml_tests.json ├── README.md ├── run_tests.php ├── local_tests.json └── src └── JsonPatch.php /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | .idea 3 | .fuse* 4 | vendor/ 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "json-patch-tests"] 2 | path = json-patch-tests 3 | url = https://github.com/json-patch/json-patch-tests.git 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | php: 3 | - 5.4 4 | - 5.5 5 | - 5.6 6 | - hhvm 7 | 8 | before_script: 9 | - composer install --prefer-source 10 | 11 | script: php run_tests.php 12 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mikemccabe/json-patch-php", 3 | "description": "Produce and apply json-patch objects", 4 | "type": "library", 5 | "license": "LGPL-3.0", 6 | "autoload": { 7 | "psr-4": { 8 | "mikemccabe\\JsonPatch\\": "src" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /simplexml_tests.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "comment": "simplexml promotion - add after scalar", 3 | "doc": { "foo":1 }, 4 | "patch": [ { "op":"add", "path":"/foo/1", "value":2 } ], 5 | "expected": { "foo":[1, 2] } }, 6 | 7 | { "comment": "simplexml promotion - add before scalar", 8 | "doc": { "foo":1 }, 9 | "patch": [ { "op":"add", "path":"/foo/0", "value":2 } ], 10 | "expected": { "foo":[2, 1] } }, 11 | 12 | { "comment": "simplexml promotion - append", 13 | "doc": { "foo":1 }, 14 | "patch": [ { "op":"add", "path":"/foo/-", "value":2 } ], 15 | "expected": { "foo":[1, 2] } }, 16 | 17 | { "comment": "append to array", 18 | "doc": { "foo":1 }, 19 | "patch": [ { "op":"add", "path":"/foo/-", "value":2 } ], 20 | "expected": { "foo":[1, 2] } }, 21 | 22 | { "comment": "mid-path 0-index with tail 0-index - append", 23 | "doc": { "foo": { "bar": 1 } }, 24 | "patch": [ { "op":"add", "path":"/foo/0/bar/-", "value":2 }], 25 | "expected": { "foo": { "bar": [1,2] }} }, 26 | 27 | { "comment": "Add 1-length array is equivalent to scalar add", 28 | "doc": { }, 29 | "patch": [ { "op":"add", "path":"/foo/0", "value":1 } ], 30 | "expected": { "foo":1 }, 31 | "disabled": true 32 | }, 33 | 34 | { "comment": "simple 0-index of scalar ok", 35 | "doc": { "foo": 1 }, 36 | "patch": [ { "op":"test", "path":"/foo/0", "value":1 }] }, 37 | 38 | { "comment": "nested 0-index of scalar ok", 39 | "doc": { "foo": { "bar": 1 } }, 40 | "patch": [ { "op":"test", "path":"/foo/bar/0", "value":1 }] }, 41 | 42 | { "comment": "0-index after actual 0-index ok", 43 | "doc": { "foo": [{ "bar": 1 }, 1] }, 44 | "patch": [ { "op":"test", "path":"/foo/0/bar/0", "value":1 }] }, 45 | 46 | { "comment": "mid-path 0-index", 47 | "doc": { "foo": { "bar": [1, 2] } }, 48 | "patch": [ { "op":"test", "path":"/foo/0/bar/0", "value":1 }] }, 49 | 50 | { "comment": "mid-path 0-index with tail 0-index", 51 | "doc": { "foo": { "bar": 1 } }, 52 | "patch": [ { "op":"test", "path":"/foo/0/bar/0", "value":1 }] }, 53 | 54 | { "comment": "replace as array", 55 | "doc": { "foo":1 }, 56 | "patch": [ { "op":"replace", "path":"/foo/0", "value":2 } ], 57 | "expected": { "foo":2 } }, 58 | 59 | { "comment": "remove last demotes to singleton", 60 | "doc": { "foo":[1, 2] }, 61 | "patch": [ { "op":"remove", "path":"/foo/1"} ], 62 | "expected": { "foo":1 } }, 63 | 64 | { "comment": "tests complete" } 65 | ] 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | json-patch-php 2 | ================ 3 | 4 | Produce and apply json-patch objects. 5 | 6 | Implements IETF JSON-patch (RFC 6902) and JSON-pointer (RFC 6901): 7 | 8 | http://tools.ietf.org/html/rfc6902 9 | http://tools.ietf.org/html/rfc6901 10 | 11 | Using with Composer 12 | ------------------- 13 | 14 | To use this library as a Composer dependency in your project, include the 15 | following sections in your project's `composer.json` file: 16 | 17 | ``` 18 | "repositories": [ 19 | { 20 | "type": "vcs", 21 | "url": "https://github.com/mikemccabe/json-patch-php" 22 | } 23 | ], 24 | "require": { 25 | "mikemccabe/json-patch-php": "dev-master" 26 | } 27 | ``` 28 | 29 | Then, in your project's code, use the `JsonPatch` class definition from 30 | the `mikemccabe\JsonPatch` namespace like so: 31 | 32 | ```php 33 | use mikemccabe\JsonPatch\JsonPatch; 34 | ``` 35 | 36 | Entry points 37 | ------------ 38 | 39 | - JsonPatch::get($doc, $pointer) - get a value from a json document 40 | - JsonPatch::patch($doc, $patches) - apply patches to $doc and return result 41 | - JsonPatch::diff($src, $dst) - return patches to create $dst from $src 42 | 43 | Arguments are PHP arrays, i.e. the output of 44 | json_decode($json_string, 1) 45 | 46 | (Note that you MUST pass 1 as the second argument to json_decode to 47 | get an array. This library does not work with stdClass objects.) 48 | 49 | All structures are implemented directly as PHP arrays. An array is 50 | considered to be 'associative' (e.g. like a JSON 'object') if it 51 | contains at least one non-numeric key. 52 | 53 | Because of this, empty arrays ([]) and empty objects ({}) compare the 54 | same, and (for instance) an 'add' of a string key to an empty array 55 | will succeed in this implementation where it might fail in others. 56 | 57 | $simplexml_mode is provided to help with working with arrays produced 58 | from XML in the style of simplexml - e.g. repeated XML elements are 59 | expressed as arrays. When $simplexml_mode is enabled, leaves with 60 | scalar values are implicitly treated as length-1 arrays, so this test 61 | will succeed: 62 | 63 | { "comment": "basic simplexml array promotion", 64 | "doc": { "foo":1 }, 65 | "patch": [ { "op":"add", "path":"/foo/1", "value":2 } ], 66 | "expected": { "foo":[1, 2] } }, 67 | 68 | Also, when $simplexml_mode is true, 1-length arrays are converted to 69 | scalars on return from patch(). 70 | 71 | Tests 72 | ----- 73 | 74 | Some tests are in a submodule 75 | (https://github.com/json-patch/json-patch-tests). Do 'git submodule 76 | init' to pull these, then 'php runtests.php' to run them. 77 | 78 | 79 | [![Build Status](https://secure.travis-ci.org/mikemccabe/json-patch-php.png)](http://travis-ci.org/mikemccabe/json-patch-php) 80 | -------------------------------------------------------------------------------- /run_tests.php: -------------------------------------------------------------------------------- 1 | getMessage() . "\n"); 77 | print_test($test); 78 | print("\n"); 79 | return false; 80 | } 81 | else 82 | { 83 | if ($verbose) 84 | { 85 | if (array_key_exists('comment', $test)) 86 | { 87 | print "OK: " . $test['comment'] . "\n"; 88 | } 89 | print("caught: " . $ex->getMessage() . "\n"); 90 | print("expected: " . $test['error'] . "\n\n"); 91 | } 92 | return true; 93 | } 94 | } 95 | } 96 | 97 | 98 | // Piggyback on patch tests to test diff as well - use 'doc' and 99 | // 'expected' from testcases. Generate a diff, apply it, and check 100 | // that it matches the target - in both directions. 101 | function diff_test($test) 102 | { 103 | // Skip comment-only or test op tests 104 | if (!(isset($test['doc']) && isset($test['expected']))) 105 | { 106 | return true; 107 | } 108 | 109 | $result = true; 110 | try 111 | { 112 | $doc1 = $test['doc']; // copy, in case sort/patch alters 113 | $doc2 = $test['expected']; 114 | $patch = JsonPatch::diff($doc1, $doc2); 115 | $patched = JsonPatch::patch($doc1, $patch); 116 | if (!JsonPatch::considered_equal($patched, $doc2)) 117 | { 118 | print("diff test failed:\n"); 119 | print_test($test); 120 | print("from: " . json_encode($doc1) . "\n"); 121 | print("diff: " . json_encode($patch) . "\n"); 122 | print("found: " . json_encode($patched) . "\n"); 123 | print("expected: " . json_encode($doc2) . "\n\n"); 124 | $result = false; 125 | } 126 | 127 | // reverse order 128 | $doc1 = $test['expected']; // copy, in case sort/patch alters 129 | $doc2 = $test['doc']; 130 | $patch = JsonPatch::diff($doc1, $doc2); 131 | $patched = JsonPatch::patch($doc1, $patch); 132 | if (!JsonPatch::considered_equal($patched, $doc2)) 133 | { 134 | print("reverse diff test failed:\n"); 135 | print_test($test); 136 | print("from: " . json_encode($doc1) . "\n"); 137 | print("diff: " . json_encode($patch) . "\n"); 138 | print("found: " . json_encode($patched) . "\n"); 139 | print("expected: " . json_encode($doc2) . "\n\n"); 140 | $result = false; 141 | } 142 | } 143 | catch (Exception $ex) 144 | { 145 | print("caught exception ".$ex->getMessage()."\n"); 146 | return false; 147 | } 148 | return $result; 149 | } 150 | 151 | 152 | function test_file($filename, $simplexml_mode=false) 153 | { 154 | $testfile = file_get_contents($filename); 155 | if (!$testfile) 156 | { 157 | throw new Exception("Couldn't find test file $filename"); 158 | return false; 159 | } 160 | 161 | $tests = json_decode($testfile, 1); 162 | if (is_null($tests)) 163 | { 164 | throw new Exception("Error json-decoding test file $filename"); 165 | } 166 | 167 | $success = true; 168 | foreach ($tests as $test) 169 | { 170 | if (isset($test['disabled']) && $test['disabled']) 171 | { 172 | continue; 173 | } 174 | if (!do_test($test, $simplexml_mode)) 175 | { 176 | $success = false; 177 | } 178 | if (!$simplexml_mode && !diff_test($test)) 179 | { 180 | $success = false; 181 | } 182 | } 183 | return $success; 184 | } 185 | 186 | 187 | function main() 188 | { 189 | $result = true; 190 | $testfiles = array( 191 | 'local_tests.json', 192 | 'json-patch-tests/tests.json', 193 | 'json-patch-tests/spec_tests.json' 194 | ); 195 | foreach ($testfiles as $testfile) 196 | { 197 | if (!test_file($testfile)) 198 | { 199 | $result = false; 200 | } 201 | } 202 | if (!test_file('simplexml_tests.json', true)) 203 | { 204 | $result = false; 205 | } 206 | return $result; 207 | } 208 | 209 | 210 | if (!main()) 211 | { 212 | exit(1); 213 | } 214 | else 215 | { 216 | exit(0); 217 | } -------------------------------------------------------------------------------- /local_tests.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "comment": "blur arrays and objects", 3 | "doc": { "foo": 1 }, 4 | "patch": [ { "op": "add", "path": "/1", "value": 2 } ], 5 | "expected": { "foo": 1, "1": 2 } }, 6 | 7 | { "comment": "Adding to \"/-\" adds to the end of the array", 8 | "doc": [ 1, 2 ], 9 | "patch": [ { "op": "add", "path": "/-", "value": 3 } ], 10 | "expected": [ 1, 2, 3 ] }, 11 | 12 | { "comment": "value in array append not flattened", 13 | "doc": [1, 2], 14 | "patch": [{"op": "add", "path": "/-", "value": [3]}], 15 | "expected": [1, 2, [3]] }, 16 | 17 | { "comment": "move target can use '-'", 18 | "doc": {"to":[ 1, 2 ], "from": 3}, 19 | "patch": [{"op": "move", "from":"/from", "path": "/to/-"}], 20 | "expected": {"to":[ 1, 2, 3 ]}}, 21 | 22 | { "comment": "copy target can use '-'", 23 | "doc": {"to":[1, 2], "from": 3}, 24 | "patch": [{"op": "copy", "from": "/from", "path": "/to/-"}], 25 | "expected": { "to":[ 1, 2, 3 ], "from": 3 } }, 26 | 27 | { "comment": "replace target must exist", 28 | "doc": {"foo": "bar"}, 29 | "patch": [{"op": "replace", "path": "/baz", "value": "sil"}], 30 | "error": "replace target '/baz' not set" }, 31 | 32 | { "comment": "- as remove target not allowed", 33 | "doc": [1, 2], 34 | "patch": [{"op": "remove", "path": "/-"}], 35 | "error": "Non-array key '-' used on array" }, 36 | 37 | { "comment": "remove of numeric index from obj doesn't convert to array", 38 | "doc": {"foo": 1, "0":2, "bar":3}, 39 | "patch": [{"op": "remove", "path":"/0"}], 40 | "expected": {"foo":1, "bar":3} }, 41 | 42 | { "comment": "- as remove target for obj isn't special", 43 | "doc": {"-": 1, "foo": 2}, 44 | "patch": [{"op": "remove", "path": "/-"}], 45 | "expected": {"foo": 2} }, 46 | 47 | { "comment": "toplevel as remove target", 48 | "doc": [1], 49 | "patch": [{"op": "remove", "path": ""}], 50 | "error": "Can't remove whole document" }, 51 | 52 | { "comment": "Ok to have doc as toplevel string?", 53 | "doc": 1, 54 | "patch": [{"op": "replace", "path": "", "value": "bar"}], 55 | "expected": "bar" }, 56 | 57 | { "comment": "Ok to have doc as toplevel number?", 58 | "doc": 1, 59 | "patch": [{"op": "replace", "path": "", "value": 1}], 60 | "expected": 1 }, 61 | 62 | { "comment": "Ok to have result doc as toplevel string?", 63 | "doc": [ 1 ], 64 | "patch": [{"op": "replace", "path": "", "value": "bar"}], 65 | "expected": "bar" }, 66 | 67 | { "comment": "'add' should replace existing member if it already exists", 68 | "doc": { "foo": 1 }, 69 | "patch": [{"op": "add", "path": "/foo", "value": 2}], 70 | "expected": { "foo": 2 } }, 71 | 72 | { "comment": "test op with string at toplevel", 73 | "doc": "foo", 74 | "patch": [{"op": "test", "path":"", "value": "foo"}] }, 75 | 76 | { "comment": "test op with number at toplevel", 77 | "doc": 1, 78 | "patch": [{"op": "test", "path":"", "value": 1}] }, 79 | 80 | { "comment": "test op with false at toplevel", 81 | "doc": false, 82 | "patch": [{"op": "test", "path":"", "value": false}] }, 83 | 84 | { "comment": "test op with true at toplevel", 85 | "doc": true, 86 | "patch": [{"op": "test", "path":"", "value": true}] }, 87 | 88 | { "comment": "test op with null at toplevel", 89 | "doc": null, 90 | "patch": [{"op": "test", "path":"", "value": null}] }, 91 | 92 | { "comment": "test null != false", 93 | "doc": null, 94 | "patch": [{"op": "test", "path":"", "value": false}], 95 | "error": "expected false value not found" }, 96 | 97 | { "comment": "test false != null", 98 | "doc": false, 99 | "patch": [{"op": "test", "path":"", "value": null}], 100 | "error": "test target value different - expected null, found false" }, 101 | 102 | { "comment": "test null != false", 103 | "doc": null, 104 | "patch": [{"op": "test", "path":"", "value": false}], 105 | "error": "test target value different - expected false, found null" }, 106 | 107 | { "comment": "test emptystr != false", 108 | "doc": "", 109 | "patch": [{"op": "test", "path":"", "value": false}], 110 | "error": "test target value different - expected false, found \"\"" }, 111 | 112 | { "comment": "test false != emptystr", 113 | "doc": false, 114 | "patch": [{"op": "test", "path":"", "value": ""}], 115 | "error": "test target value different - expected \"\", found false" }, 116 | 117 | { "comment": "null within string", 118 | "doc": [ "foo\u0000foo" ], 119 | "patch": [{"op":"test", "path":"/0", "value":"foo\u0000foo"}] }, 120 | 121 | { "comment": "null string", 122 | "doc": [ "\u0000" ], 123 | "patch": [{"op":"test", "path":"/0", "value":"\u0000"}] }, 124 | 125 | { "comment": "null in key", 126 | "doc": { "foo\u0000foo": 1 }, 127 | "patch": [{"op":"replace", "path":"/foo\u0000foo", "value":2}], 128 | "expected": { "foo\u0000foo": 2 } }, 129 | 130 | { "comment": "null in key - test against prefix", 131 | "doc": { "foo": 1, "foo\u0000foo": 2 }, 132 | "patch": [{"op":"test", "path":"/foo\u0000foo", "value":2}] }, 133 | 134 | { "comment": "null in key - trailing", 135 | "doc": { "foo": 1, "foo\u0000": 2 }, 136 | "patch": [{"op":"test", "path":"/foo\u0000", "value":2}] }, 137 | 138 | { "comment": "null as key", 139 | "doc": { "\u0000": 1 }, 140 | "patch": [{"op":"replace", "path":"/\u0000", "value":2}], 141 | "expected": { "\u0000": 2 } }, 142 | 143 | { "comment": "null as key prefix", 144 | "doc": { "\u0000foo": 1 }, 145 | "patch": [{"op":"replace", "path":"/\u0000foo", "value":2}], 146 | "expected": { "\u0000foo": 2 } }, 147 | 148 | { "comment": "copy doc onto child", 149 | "doc": { "foo": 1 }, 150 | "patch": [{"op":"copy", "from":"", "path":"/bar"}], 151 | "expected": { "foo": 1, "bar": { "foo": 1 }} }, 152 | 153 | { "comment": "move doc onto child ('from' must not be proper prefix)", 154 | "doc": { "foo": { "bar": 1 } }, 155 | "patch": [{"op":"move", "from":"/foo", "path":"/foo/bar"}], 156 | "error": "path '/foo/bar' not found (already removed)"}, 157 | 158 | { "comment": "need bounds check on intermediate path", 159 | "doc": [1, [2]], 160 | "patch": [{"op": "test", "path":"/2/0", "value": 2}], 161 | "error": "path '/2/0' not in target doc" }, 162 | 163 | { "comment": "'-' should be legit member for object", 164 | "doc": {"foo": 1}, 165 | "patch": [{"op": "add", "path":"/-", "value": 2}], 166 | "expected": {"foo": 1, "-": 2} }, 167 | 168 | { "comment": "remove of array-looking element of object", 169 | "doc": {"foo":1, "0":2}, 170 | "patch": [{"op":"remove", "path":"/0"}], 171 | "expected": {"foo": 1} }, 172 | 173 | { "comment": "replace of array-looking element of object", 174 | "doc": {"foo":1, "0":2}, 175 | "patch": [{"op":"replace", "path":"/0", "value":3}], 176 | "expected": {"foo": 1, "0":3} }, 177 | 178 | { "comment": "replace string with null (elicits diff error)", 179 | "doc": [""], 180 | "patch": [{"op": "replace", "path": "/0", "value": null}], 181 | "expected": [null] }, 182 | 183 | { "comment": "test object sorting for equality if numeric indices exist", 184 | "doc": {"foo":1,"bar":3,"0":2}, 185 | "patch": [{"op": "test", "path":"", "value": {"foo":1,"0":2,"bar":3}}] }, 186 | 187 | { "comment": "test php-style array element delete - disabled as reverse diff (gappy array from pure array) is impossible in json-patch without borrowing php array semantics", 188 | "doc": {"0":"a", "2":"c"}, 189 | "patch": {"op":"add", "path":"/1", "value":"b"}, 190 | "expected": {"0":"a", "1":"b", "2":"c"}, 191 | "disabled": true 192 | }, 193 | 194 | { "comment": "test php-style array element delete - assoc-ish indexes", 195 | "doc": {"0a":"a", "2c":"c"}, 196 | "patch": {"op":"add", "path":"/1b", "value":"b"}, 197 | "expected": {"0a":"a", "1b":"b", "2c": "c"} }, 198 | 199 | { "comment": "Numerically equal must test equal", 200 | "doc": [1.00], 201 | "patch": [{"op": "test", "path":"/0", "value":1}]}, 202 | 203 | { "comment": "Numerically equal must test equal", 204 | "doc": [1], 205 | "patch": [{"op": "test", "path":"/0", "value":1.00}]}, 206 | 207 | { "comment": "Numerically equal must test equal", 208 | "doc": [1e0], 209 | "patch": [{"op": "test", "path":"/0", "value":1.00}]}, 210 | 211 | { "comment": "append", 212 | "doc": [1, 2, 3, 4], 213 | "patch": [{"op": "append", "path": "/-", "value":[5, 6, 7, 8]}], 214 | "expected": [1, 2, 3, 4, 5, 6, 7, 8], 215 | "disabled": true 216 | }, 217 | 218 | { "comment": "last" } 219 | ] 220 | -------------------------------------------------------------------------------- /src/JsonPatch.php: -------------------------------------------------------------------------------- 1 | "replace", "path" => "$path", 283 | "value" => $other)); 284 | } 285 | } 286 | return array(); 287 | } 288 | 289 | 290 | // Walk associative arrays $src and $dst, returning a list of patches 291 | private static function diff_assoc($path, $src, $dst) 292 | { 293 | $result = array(); 294 | if (count($src) == 0 && count($dst) != 0) 295 | { 296 | $result[] = array("op" => "replace", "path" => "$path", "value" => $dst); 297 | } 298 | else 299 | { 300 | foreach (array_keys($src) as $key) 301 | { 302 | $ekey = self::escape_pointer_part($key); 303 | if (!array_key_exists($key, $dst)) 304 | { 305 | $result[] = array("op" => "remove", "path" => "$path/$ekey"); 306 | } 307 | else 308 | { 309 | $result = array_merge($result, 310 | self::diff_values("$path/$ekey", 311 | $src[$key], $dst[$key])); 312 | } 313 | } 314 | foreach (array_keys($dst) as $key) 315 | { 316 | if (!array_key_exists($key, $src)) 317 | { 318 | $ekey = self::escape_pointer_part($key); 319 | $result[] = array("op" => "add", "path" => "$path/$ekey", 320 | "value" => $dst[$key]); 321 | } 322 | } 323 | } 324 | return $result; 325 | } 326 | 327 | 328 | // Walk simple arrays $src and $dst, returning a list of patches 329 | private static function diff_array($path, $src, $dst) 330 | { 331 | $result = array(); 332 | $lsrc = count($src); 333 | $ldst = count($dst); 334 | $max = ($lsrc > $ldst) ? $lsrc : $ldst; 335 | 336 | // Walk backwards through arrays, starting with longest 337 | $i = $max - 1; 338 | while ($i >= 0) // equivalent for loop didn't work? 339 | { 340 | if ($i < $lsrc && $i < $ldst && 341 | array_key_exists($i, $src) && array_key_exists($i, $dst)) 342 | { 343 | $result = array_merge($result, 344 | self::diff_values("$path/$i", 345 | $src[$i], $dst[$i])); 346 | } 347 | else if ($i < $ldst && array_key_exists($i, $dst)) 348 | { 349 | $result[] = array("op" => "add", "path" => "$path/$i", 350 | "value" => $dst[$i]); 351 | } 352 | else if ($i < $lsrc && !array_key_exists($i, $dst)) 353 | { 354 | $result[] = array("op" => "remove", "path" => "$path/$i"); 355 | } 356 | $i--; 357 | } 358 | return $result; 359 | } 360 | 361 | 362 | // patch support functions 363 | 364 | 365 | // Implements the 'test' op 366 | private static function test($doc, $path, $parts, $value, $simplexml_mode) 367 | { 368 | $found = self::get_helper($doc, $path, $parts, $simplexml_mode); 369 | 370 | if (!self::considered_equal($found, $value)) 371 | { 372 | throw new JsonPatchException("test target value different - expected " 373 | . json_encode($value) . ", found " 374 | . json_encode($found)); 375 | } 376 | } 377 | 378 | 379 | // Helper for get() and 'copy', 'move', 'test' ops - get a value from a doc. 380 | private static function get_helper($doc, $path, $parts, $simplexml_mode) 381 | { 382 | if (count($parts) == 0) 383 | { 384 | return $doc; 385 | } 386 | 387 | $part = array_shift($parts); 388 | if (!is_array($doc) || !array_key_exists($part, $doc)) 389 | { 390 | throw new JsonPatchException("Path '$path' not found"); 391 | } 392 | if ($simplexml_mode 393 | && count($parts) > 0 394 | && $parts[0] == '0' 395 | && self::is_associative($doc) 396 | && !(is_array($doc[$part]) && !self::is_associative($doc[$part]))) 397 | { 398 | return self::get_helper(array($doc[$part]), $path, $parts, 399 | $simplexml_mode); 400 | } 401 | else 402 | { 403 | return self::get_helper($doc[$part], $path, $parts, 404 | $simplexml_mode); 405 | } 406 | } 407 | 408 | 409 | // Test whether a php array looks 'associative' - does it have 410 | // any non-numeric keys? 411 | // 412 | // note: is_associative(array()) === false 413 | private static function is_associative($a) 414 | { 415 | if (!is_array($a)) 416 | { 417 | return false; 418 | } 419 | foreach (array_keys($a) as $key) 420 | { 421 | if (is_string($key)) 422 | { 423 | return true; 424 | } 425 | } 426 | // Also treat php gappy arrays as associative. 427 | // (e.g. {"0":"a", "2":"c"}) 428 | $len = count($a); 429 | for ($i = 0; $i < $len; $i++) 430 | { 431 | if (!array_key_exists($i, $a)) 432 | { 433 | return true; 434 | } 435 | } 436 | return false; 437 | } 438 | 439 | // Recursively sort array keys 440 | private static function rksort($a) 441 | { 442 | if (!is_array($a)) 443 | { 444 | return $a; 445 | } 446 | foreach (array_keys($a) as $key) 447 | { 448 | $a[$key] = self::rksort($a[$key]); 449 | } 450 | // SORT_STRING seems required, as otherwise numeric indices 451 | // (e.g. "4") aren't sorted. 452 | ksort($a, SORT_STRING); 453 | return $a; 454 | } 455 | 456 | 457 | // Per http://tools.ietf.org/html/rfc6902#section-4.6 458 | public static function considered_equal($a1, $a2) 459 | { 460 | return json_encode(self::rksort($a1)) === json_encode(self::rksort($a2)); 461 | } 462 | 463 | 464 | // Apply a single op to modify the given document. 465 | // 466 | // As php arrays are not passed by reference, this function works 467 | // recursively, rebuilding complete subarrays that need changing; 468 | // the revised subarray is changed in the parent array before 469 | // returning it. 470 | private static function do_op($doc, $op, $path, $parts, $value, 471 | $simplexml_mode) 472 | { 473 | // Special-case toplevel 474 | if (count($parts) == 0) 475 | { 476 | if ($op == 'add' || $op == 'replace') 477 | { 478 | return $value; 479 | } 480 | else if ($op == 'remove') 481 | { 482 | throw new JsonPatchException("Can't remove whole document"); 483 | } 484 | else 485 | { 486 | throw new JsonPatchException("'$op' can't operate on whole document"); 487 | } 488 | } 489 | 490 | $part = array_shift($parts); 491 | 492 | // recur until we get to the target 493 | if (count($parts) > 0) 494 | { 495 | if (!array_key_exists($part, $doc)) 496 | { 497 | throw new JsonPatchException("Path '$path' not found"); 498 | } 499 | // recur, adding resulting sub-doc into doc returned to caller 500 | 501 | // special case for simplexml-style behavior - make singleton 502 | // scalar leaves look like 1-length arrays 503 | if ($simplexml_mode 504 | && count($parts) > 0 505 | && ($parts[0] == '0' || $parts[0] == '1' || $parts[0] == '-') 506 | && self::is_associative($doc) 507 | && !(is_array($doc[$part]) && !self::is_associative($doc[$part]))) 508 | { 509 | $doc[$part] = self::do_op(array($doc[$part]), $op, $path, $parts, 510 | $value, $simplexml_mode); 511 | } 512 | else 513 | { 514 | $doc[$part] = self::do_op($doc[$part], $op, $path, $parts, 515 | $value, $simplexml_mode); 516 | } 517 | return $doc; 518 | } 519 | 520 | // at target 521 | if (!is_array($doc)) 522 | { 523 | throw new JsonPatchException('Target must be array or associative array'); 524 | } 525 | 526 | if (!self::is_associative($doc)) // N.B. returns false for empty arrays 527 | { 528 | if (count($doc) && !self::is_index($part) 529 | && !($part == '-' && ($op == 'add' || $op == 'append'))) 530 | { 531 | throw new JsonPatchException("Non-array key '$part' used on array"); 532 | } 533 | else 534 | { 535 | // check range, if numeric 536 | if (self::is_index($part) && 537 | ($part < 0 || (($op == 'remove' && $part >= count($doc)) 538 | || ($op != 'remove' && $part > count($doc))))) 539 | { 540 | throw new JsonPatchException("Can't operate outside of array bounds"); 541 | } 542 | } 543 | } 544 | 545 | if ($op == 'add' || $op == 'append') 546 | { 547 | if (!self::is_associative($doc) 548 | && (self::is_index($part) || $part == '-')) 549 | { 550 | // If index is '-', use array length 551 | $index = ($part == '-') ? count($doc) : $part; 552 | if ($op == 'append') 553 | { 554 | array_splice($doc, $index, 0, $value); 555 | } 556 | else 557 | { 558 | array_splice($doc, $index, 0, Array($value)); 559 | } 560 | } 561 | else 562 | { 563 | $doc[$part] = $value; 564 | } 565 | } 566 | 567 | else if ($op == 'replace') 568 | { 569 | if (!self::is_associative($doc) && self::is_index($part)) 570 | { 571 | array_splice($doc, $part, 1, Array($value)); 572 | } 573 | else 574 | { 575 | if (!array_key_exists($part, $doc)) 576 | { 577 | throw new JsonPatchException("replace target '$path' not set"); 578 | } 579 | $doc[$part] = $value; 580 | } 581 | } 582 | 583 | else if ($op == 'remove') 584 | { 585 | if (!self::is_associative($doc) && self::is_index($part)) 586 | { 587 | array_splice($doc, $part, 1); 588 | } 589 | else 590 | { 591 | if (!array_key_exists($part, $doc)) 592 | { 593 | throw new JsonPatchException("remove target '$path' not set"); 594 | } 595 | unset($doc[$part]); 596 | } 597 | } 598 | return $doc; 599 | } 600 | } 601 | --------------------------------------------------------------------------------