diff --git a/.travis.yml b/.travis.yml index 33b9328..fe757d0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,8 +19,14 @@ cache: # execute any number of scripts before the test run, custom env's are available as variables before_script: + - ls -la $HOME/.composer/cache + - test -f $HOME/.composer/cache/composer.lock.$(phpenv version-name) && cp $HOME/.composer/cache/composer.lock.$(phpenv version-name) ./composer.lock || echo "No composer.lock cached" - composer install --dev --no-interaction --prefer-dist -# - cat composer.lock + - test -f $HOME/.composer/cache/composer.lock.$(phpenv version-name) || cp ./composer.lock $HOME/.composer/cache/composer.lock.$(phpenv version-name) + - if [[ $(phpenv version-name) =~ 7.2 ]] ; then test -f $HOME/.composer/cache/phpstan.phar || wget https://github.com/phpstan/phpstan/releases/download/0.9.1/phpstan.phar -O $HOME/.composer/cache/phpstan.phar; fi + - if [[ $(phpenv version-name) =~ 7.2 ]] ; then test -f $HOME/.composer/cache/ocular.phar || wget https://scrutinizer-ci.com/ocular.phar -O $HOME/.composer/cache/ocular.phar; fi + - if [[ $(phpenv version-name) =~ 7.2 ]] ; then test -f $HOME/.composer/cache/cctr || wget https://codeclimate.com/downloads/test-reporter/test-reporter-0.1.4-linux-amd64 -O $HOME/.composer/cache/cctr && chmod +x $HOME/.composer/cache/cctr; fi + - if [[ $(phpenv version-name) =~ 7.2 ]] ; then $HOME/.composer/cache/cctr before-build; fi matrix: allow_failures: @@ -29,11 +35,9 @@ matrix: fast_finish: true script: - - mkdir -p build/logs - - ./vendor/bin/phpunit -v --configuration phpunit.xml --coverage-clover build/logs/clover.xml + - ./vendor/bin/phpunit -v --configuration phpunit.xml --coverage-text --coverage-clover clover.xml + - if [[ $(phpenv version-name) =~ 7.2 ]] ; then php $HOME/.composer/cache/phpstan.phar analyze -l 7 ./src; fi after_script: - - wget https://scrutinizer-ci.com/ocular.phar - - php ocular.phar code-coverage:upload --format=php-clover build/logs/coverage.clover - - if [[ $(phpenv version-name) =~ 7.1 ]] ; then php vendor/bin/coveralls -v; fi - - if [[ $(phpenv version-name) =~ 7.1 ]] ; then php vendor/bin/test-reporter; fi + - if [[ $(phpenv version-name) =~ 7.2 ]] ; then php $HOME/.composer/cache/ocular.phar code-coverage:upload --format=php-clover clover.xml; fi + - if [[ $(phpenv version-name) =~ 7.2 ]] ; then $HOME/.composer/cache/cctr after-build --exit-code $TRAVIS_TEST_RESULT; fi diff --git a/src/Cli/Apply.php b/src/Cli/Apply.php index 6015ece..f664bb0 100644 --- a/src/Cli/Apply.php +++ b/src/Cli/Apply.php @@ -49,10 +49,10 @@ public function performAction() $patch = JsonPatch::import(json_decode($patchJson)); $base = json_decode($baseJson); $patch->apply($base); + $this->out = $base; } catch (Exception $e) { $this->response->error($e->getMessage()); } - $this->out = $base; $this->postPerform(); } diff --git a/src/Cli/Base.php b/src/Cli/Base.php index 3c264fb..c130bd9 100644 --- a/src/Cli/Base.php +++ b/src/Cli/Base.php @@ -3,6 +3,7 @@ namespace Swaggest\JsonDiff\Cli; +use Swaggest\JsonDiff\Exception; use Swaggest\JsonDiff\JsonDiff; use Yaoi\Command; @@ -48,7 +49,12 @@ protected function prePerform() if ($this->rearrangeArrays) { $options += JsonDiff::REARRANGE_ARRAYS; } - $this->diff = new JsonDiff(json_decode($originalJson), json_decode($newJson), $options); + try { + $this->diff = new JsonDiff(json_decode($originalJson), json_decode($newJson), $options); + } catch (Exception $e) { + $this->response->error($e->getMessage()); + return; + } $this->out = ''; } diff --git a/src/Exception.php b/src/Exception.php index b93c6ae..1af1431 100644 --- a/src/Exception.php +++ b/src/Exception.php @@ -5,5 +5,5 @@ class Exception extends \Exception { - + const EMPTY_PROPERTY_NAME_UNSUPPORTED = 1; } \ No newline at end of file diff --git a/src/JsonDiff.php b/src/JsonDiff.php index b80c4d8..1c4e9e3 100644 --- a/src/JsonDiff.php +++ b/src/JsonDiff.php @@ -45,9 +45,10 @@ class JsonDiff /** * Processor constructor. - * @param $original - * @param $new + * @param mixed $original + * @param mixed $new * @param int $options + * @throws Exception */ public function __construct($original, $new, $options = 0) { @@ -181,11 +182,21 @@ public function getPatch() return $this->jsonPatch; } + /** + * @return array|null|object|\stdClass + * @throws Exception + */ private function rearrange() { return $this->process($this->original, $this->new); } + /** + * @param mixed $original + * @param mixed $new + * @return array|null|object|\stdClass + * @throws Exception + */ private function process($original, $new) { if ( @@ -194,6 +205,9 @@ private function process($original, $new) ) { if ($original !== $new) { $this->modifiedCnt++; + if ($this->options & self::STOP_ON_DIFF) { + return null; + } $this->modifiedPaths [] = $this->path; $this->jsonPatch->op(new Test($this->path, $original)); @@ -202,9 +216,6 @@ private function process($original, $new) JsonPointer::add($this->modifiedOriginal, $this->pathItems, $original); JsonPointer::add($this->modifiedNew, $this->pathItems, $new); - if ($this->options & self::STOP_ON_DIFF) { - return null; - } } return $new; } @@ -222,6 +233,12 @@ private function process($original, $new) $originalKeys = $original instanceof \stdClass ? get_object_vars($original) : $original; foreach ($originalKeys as $key => $originalValue) { + if ($this->options & self::STOP_ON_DIFF) { + if ($this->modifiedCnt || $this->addedCnt || $this->removedCnt) { + return null; + } + } + $path = $this->path; $pathItems = $this->pathItems; $this->path .= '/' . JsonPointer::escapeSegment($key, $this->options & self::JSON_URI_FRAGMENT_ID); @@ -232,14 +249,14 @@ private function process($original, $new) unset($newArray[$key]); } else { $this->removedCnt++; + if ($this->options & self::STOP_ON_DIFF) { + return null; + } $this->removedPaths [] = $this->path; $this->jsonPatch->op(new Remove($this->path)); JsonPointer::add($this->removed, $this->pathItems, $originalValue); - if ($this->options & self::STOP_ON_DIFF) { - return null; - } } $this->path = $path; $this->pathItems = $pathItems; @@ -247,19 +264,19 @@ private function process($original, $new) // additions foreach ($newArray as $key => $value) { + $this->addedCnt++; + if ($this->options & self::STOP_ON_DIFF) { + return null; + } $newOrdered[$key] = $value; $path = $this->path . '/' . JsonPointer::escapeSegment($key, $this->options & self::JSON_URI_FRAGMENT_ID); $pathItems = $this->pathItems; $pathItems[] = $key; JsonPointer::add($this->added, $pathItems, $value); - $this->addedCnt++; $this->addedPaths [] = $path; $this->jsonPatch->op(new Add($path, $value)); - if ($this->options & self::STOP_ON_DIFF) { - return null; - } } return is_array($new) ? $newOrdered : (object)$newOrdered; diff --git a/src/JsonPatch.php b/src/JsonPatch.php index 9df34ba..f32d3f2 100644 --- a/src/JsonPatch.php +++ b/src/JsonPatch.php @@ -108,7 +108,7 @@ public function jsonSerialize() } /** - * @param $original + * @param mixed $original * @throws Exception */ public function apply(&$original) @@ -117,32 +117,34 @@ public function apply(&$original) $pathItems = JsonPointer::splitPath($operation->path); switch (true) { case $operation instanceof Add: - JsonPointer::add($original, $pathItems, $operation->value); + JsonPointer::add($original, $pathItems, $operation->value, false); break; case $operation instanceof Copy: $fromItems = JsonPointer::splitPath($operation->from); $value = JsonPointer::get($original, $fromItems); - JsonPointer::add($original, $pathItems, $value); + JsonPointer::add($original, $pathItems, $value, false); break; case $operation instanceof Move: $fromItems = JsonPointer::splitPath($operation->from); $value = JsonPointer::get($original, $fromItems); - JsonPointer::add($original, $pathItems, $value); JsonPointer::remove($original, $fromItems); + JsonPointer::add($original, $pathItems, $value, false); break; case $operation instanceof Remove: JsonPointer::remove($original, $pathItems); break; case $operation instanceof Replace: JsonPointer::get($original, $pathItems); - JsonPointer::add($original, $pathItems, $operation->value); + JsonPointer::remove($original, $pathItems); + JsonPointer::add($original, $pathItems, $operation->value, false); break; case $operation instanceof Test: $value = JsonPointer::get($original, $pathItems); $diff = new JsonDiff($operation->value, $value, JsonDiff::STOP_ON_DIFF); if ($diff->getDiffCnt() !== 0) { - throw new Exception('Test operation ' . json_encode($operation) . ' failed: ' . json_encode($value)); + throw new Exception('Test operation ' . json_encode($operation, JSON_UNESCAPED_SLASHES) + . ' failed: ' . json_encode($value)); } break; } diff --git a/src/JsonPointer.php b/src/JsonPointer.php index 25686e9..9f179f5 100644 --- a/src/JsonPointer.php +++ b/src/JsonPointer.php @@ -31,7 +31,7 @@ public static function splitPath($path) $result = array(); if ($first === '#') { foreach ($pathItems as $key) { - $key = str_replace(array('~0', '~1'), array('~', '/'), urldecode($key)); + $key = str_replace(array('~1', '~0'), array('/', '~'), urldecode($key)); $result[] = $key; } } else { @@ -39,7 +39,7 @@ public static function splitPath($path) throw new Exception('Path must start with "/": ' . $path); } foreach ($pathItems as $key) { - $key = str_replace(array('~0', '~1'), array('~', '/'), $key); + $key = str_replace(array('~1', '~0'), array('/', '~'), $key); $result[] = $key; } } @@ -50,22 +50,50 @@ public static function splitPath($path) * @param mixed $holder * @param string[] $pathItems * @param mixed $value + * @param bool $recursively + * @throws Exception */ - public static function add(&$holder, $pathItems, $value) + public static function add(&$holder, $pathItems, $value, $recursively = true) { $ref = &$holder; while (null !== $key = array_shift($pathItems)) { if ($ref instanceof \stdClass) { + if (PHP_VERSION_ID < 71000 && '' === $key) { + throw new Exception('Empty property name is not supported by PHP <7.1', + Exception::EMPTY_PROPERTY_NAME_UNSUPPORTED); + } + $ref = &$ref->$key; - } elseif ($ref === null - && !is_int($key) - && false === filter_var($key, FILTER_VALIDATE_INT) - ) { - $key = (string)$key; - $ref = new \stdClass(); - $ref = &$ref->{$key}; - } else { - $ref = &$ref[$key]; + } else { // null or array + $intKey = filter_var($key, FILTER_VALIDATE_INT); + if ($ref === null && (false === $intKey || $intKey !== 0)) { + $key = (string)$key; + if ($recursively) { + $ref = new \stdClass(); + $ref = &$ref->{$key}; + } else { + throw new Exception('Non-existent path'); + } + } else { + if ($recursively && $ref === null) $ref = array(); + if ('-' === $key) { + $ref = &$ref[]; + } else { + if (is_array($ref) && array_key_exists($key, $ref) && empty($pathItems)) { + array_splice($ref, $key, 0, array($value)); + } + if (false === $intKey) { + throw new Exception('Invalid key for array operation'); + } + if ($intKey > count($ref) && !$recursively) { + throw new Exception('Index is greater than number of items in array'); + } elseif ($intKey < 0) { + throw new Exception('Negative index'); + } + + $ref = &$ref[$intKey]; + } + } } } $ref = $value; @@ -108,6 +136,11 @@ public static function get($holder, $pathItems) $ref = $holder; while (null !== $key = array_shift($pathItems)) { if ($ref instanceof \stdClass) { + if (PHP_VERSION_ID < 71000 && '' === $key) { + throw new Exception('Empty property name is not supported by PHP <7.1', + Exception::EMPTY_PROPERTY_NAME_UNSUPPORTED); + } + $vars = (array)$ref; if (self::arrayKeyExists($key, $vars)) { $ref = self::arrayGet($key, $vars); @@ -159,6 +192,7 @@ public static function remove(&$holder, $pathItems) unset($parent->$refKey); } else { unset($parent[$refKey]); + $parent = array_values($parent); } } return $ref; diff --git a/tests/assets/spec-tests.json b/tests/assets/spec-tests.json new file mode 100644 index 0000000..79a2705 --- /dev/null +++ b/tests/assets/spec-tests.json @@ -0,0 +1,233 @@ +[ + { + "comment": "4.1. add with missing object", + "doc": { "q": { "bar": 2 } }, + "patch": [ {"op": "add", "path": "/a/b", "value": 1} ], + "error": + "path /a does not exist -- missing objects are not created recursively" + }, + + { + "comment": "A.1. Adding an Object Member", + "doc": { + "foo": "bar" + }, + "patch": [ + { "op": "add", "path": "/baz", "value": "qux" } + ], + "expected": { + "baz": "qux", + "foo": "bar" + } + }, + + { + "comment": "A.2. Adding an Array Element", + "doc": { + "foo": [ "bar", "baz" ] + }, + "patch": [ + { "op": "add", "path": "/foo/1", "value": "qux" } + ], + "expected": { + "foo": [ "bar", "qux", "baz" ] + } + }, + + { + "comment": "A.3. Removing an Object Member", + "doc": { + "baz": "qux", + "foo": "bar" + }, + "patch": [ + { "op": "remove", "path": "/baz" } + ], + "expected": { + "foo": "bar" + } + }, + + { + "comment": "A.4. Removing an Array Element", + "doc": { + "foo": [ "bar", "qux", "baz" ] + }, + "patch": [ + { "op": "remove", "path": "/foo/1" } + ], + "expected": { + "foo": [ "bar", "baz" ] + } + }, + + { + "comment": "A.5. Replacing a Value", + "doc": { + "baz": "qux", + "foo": "bar" + }, + "patch": [ + { "op": "replace", "path": "/baz", "value": "boo" } + ], + "expected": { + "baz": "boo", + "foo": "bar" + } + }, + + { + "comment": "A.6. Moving a Value", + "doc": { + "foo": { + "bar": "baz", + "waldo": "fred" + }, + "qux": { + "corge": "grault" + } + }, + "patch": [ + { "op": "move", "from": "/foo/waldo", "path": "/qux/thud" } + ], + "expected": { + "foo": { + "bar": "baz" + }, + "qux": { + "corge": "grault", + "thud": "fred" + } + } + }, + + { + "comment": "A.7. Moving an Array Element", + "doc": { + "foo": [ "all", "grass", "cows", "eat" ] + }, + "patch": [ + { "op": "move", "from": "/foo/1", "path": "/foo/3" } + ], + "expected": { + "foo": [ "all", "cows", "eat", "grass" ] + } + + }, + + { + "comment": "A.8. Testing a Value: Success", + "doc": { + "baz": "qux", + "foo": [ "a", 2, "c" ] + }, + "patch": [ + { "op": "test", "path": "/baz", "value": "qux" }, + { "op": "test", "path": "/foo/1", "value": 2 } + ], + "expected": { + "baz": "qux", + "foo": [ "a", 2, "c" ] + } + }, + + { + "comment": "A.9. Testing a Value: Error", + "doc": { + "baz": "qux" + }, + "patch": [ + { "op": "test", "path": "/baz", "value": "bar" } + ], + "error": "string not equivalent" + }, + + { + "comment": "A.10. Adding a nested Member Object", + "doc": { + "foo": "bar" + }, + "patch": [ + { "op": "add", "path": "/child", "value": { "grandchild": { } } } + ], + "expected": { + "foo": "bar", + "child": { + "grandchild": { + } + } + } + }, + + { + "comment": "A.11. Ignoring Unrecognized Elements", + "doc": { + "foo":"bar" + }, + "patch": [ + { "op": "add", "path": "/baz", "value": "qux", "xyz": 123 } + ], + "expected": { + "foo":"bar", + "baz":"qux" + } + }, + + { + "comment": "A.12. Adding to a Non-existent Target", + "doc": { + "foo": "bar" + }, + "patch": [ + { "op": "add", "path": "/baz/bat", "value": "qux" } + ], + "error": "add to a non-existent target" + }, + + { + "comment": "A.13 Invalid JSON Patch Document", + "doc": { + "foo": "bar" + }, + "patch": [ + { "op": "add", "path": "/baz", "value": "qux", "op": "remove" } + ], + "error": "operation has two 'op' members", + "disabled": true + }, + + { + "comment": "A.14. ~ Escape Ordering", + "doc": { + "/": 9, + "~1": 10 + }, + "patch": [{"op": "test", "path": "/~01", "value": 10}], + "expected": { + "/": 9, + "~1": 10 + } + }, + + { + "comment": "A.15. Comparing Strings and Numbers", + "doc": { + "/": 9, + "~1": 10 + }, + "patch": [{"op": "test", "path": "/~01", "value": "10"}], + "error": "number is not equal to string" + }, + + { + "comment": "A.16. Adding an Array Value", + "doc": { + "foo": ["bar"] + }, + "patch": [{ "op": "add", "path": "/foo/-", "value": ["abc", "def"] }], + "expected": { + "foo": ["bar", ["abc", "def"]] + } + } + +] diff --git a/tests/assets/tests.json b/tests/assets/tests.json new file mode 100644 index 0000000..c4a9d1e --- /dev/null +++ b/tests/assets/tests.json @@ -0,0 +1,1576 @@ +[ + { + "comment": "empty list, empty docs", + "doc": {}, + "patch": [], + "expected": {} + }, + { + "comment": "empty patch list", + "doc": { + "foo": 1 + }, + "patch": [], + "expected": { + "foo": 1 + } + }, + { + "comment": "rearrangements OK?", + "doc": { + "foo": 1, + "bar": 2 + }, + "patch": [], + "expected": { + "bar": 2, + "foo": 1 + } + }, + { + "comment": "rearrangements OK? How about one level down ... array", + "doc": [ + { + "foo": 1, + "bar": 2 + } + ], + "patch": [], + "expected": [ + { + "bar": 2, + "foo": 1 + } + ] + }, + { + "comment": "rearrangements OK? How about one level down...", + "doc": { + "foo": { + "foo": 1, + "bar": 2 + } + }, + "patch": [], + "expected": { + "foo": { + "bar": 2, + "foo": 1 + } + } + }, + { + "comment": "add replaces any existing field", + "doc": { + "foo": null + }, + "patch": [ + { + "op": "add", + "path": "/foo", + "value": 1 + } + ], + "expected": { + "foo": 1 + } + }, + { + "comment": "toplevel array", + "doc": [], + "patch": [ + { + "op": "add", + "path": "/0", + "value": "foo" + } + ], + "expected": [ + "foo" + ] + }, + { + "comment": "toplevel array, no change", + "doc": [ + "foo" + ], + "patch": [], + "expected": [ + "foo" + ] + }, + { + "comment": "toplevel object, numeric string", + "doc": {}, + "patch": [ + { + "op": "add", + "path": "/foo", + "value": "1" + } + ], + "expected": { + "foo": "1" + } + }, + { + "comment": "toplevel object, integer", + "doc": {}, + "patch": [ + { + "op": "add", + "path": "/foo", + "value": 1 + } + ], + "expected": { + "foo": 1 + } + }, + { + "comment": "Toplevel scalar values OK?", + "doc": "foo", + "patch": [ + { + "op": "replace", + "path": "", + "value": "bar" + } + ], + "expected": "bar", + "disabled": true + }, + { + "comment": "replace object document with array document?", + "doc": {}, + "patch": [ + { + "op": "add", + "path": "", + "value": [] + } + ], + "expected": [] + }, + { + "comment": "replace array document with object document?", + "doc": [], + "patch": [ + { + "op": "add", + "path": "", + "value": {} + } + ], + "expected": {} + }, + { + "comment": "append to root array document?", + "doc": [], + "patch": [ + { + "op": "add", + "path": "/-", + "value": "hi" + } + ], + "expected": [ + "hi" + ] + }, + { + "comment": "Add, / target", + "doc": {}, + "patch": [ + { + "op": "add", + "path": "/", + "value": 1 + } + ], + "expected": { + "": 1 + } + }, + { + "comment": "Add, /foo/ deep target (trailing slash)", + "doc": { + "foo": {} + }, + "patch": [ + { + "op": "add", + "path": "/foo/", + "value": 1 + } + ], + "expected": { + "foo": { + "": 1 + } + } + }, + { + "comment": "Add composite value at top level", + "doc": { + "foo": 1 + }, + "patch": [ + { + "op": "add", + "path": "/bar", + "value": [ + 1, + 2 + ] + } + ], + "expected": { + "foo": 1, + "bar": [ + 1, + 2 + ] + } + }, + { + "comment": "Add into composite value", + "doc": { + "foo": 1, + "baz": [ + { + "qux": "hello" + } + ] + }, + "patch": [ + { + "op": "add", + "path": "/baz/0/foo", + "value": "world" + } + ], + "expected": { + "foo": 1, + "baz": [ + { + "qux": "hello", + "foo": "world" + } + ] + } + }, + { + "doc": { + "bar": [ + 1, + 2 + ] + }, + "patch": [ + { + "op": "add", + "path": "/bar/8", + "value": "5" + } + ], + "error": "Out of bounds (upper)" + }, + { + "doc": { + "bar": [ + 1, + 2 + ] + }, + "patch": [ + { + "op": "add", + "path": "/bar/-1", + "value": "5" + } + ], + "error": "Out of bounds (lower)" + }, + { + "doc": { + "foo": 1 + }, + "patch": [ + { + "op": "add", + "path": "/bar", + "value": true + } + ], + "expected": { + "foo": 1, + "bar": true + } + }, + { + "doc": { + "foo": 1 + }, + "patch": [ + { + "op": "add", + "path": "/bar", + "value": false + } + ], + "expected": { + "foo": 1, + "bar": false + } + }, + { + "doc": { + "foo": 1 + }, + "patch": [ + { + "op": "add", + "path": "/bar", + "value": null + } + ], + "expected": { + "foo": 1, + "bar": null + } + }, + { + "comment": "0 can be an array index or object element name", + "doc": { + "foo": 1 + }, + "patch": [ + { + "op": "add", + "path": "/0", + "value": "bar" + } + ], + "expected": { + "foo": 1, + "0": "bar" + } + }, + { + "doc": [ + "foo" + ], + "patch": [ + { + "op": "add", + "path": "/1", + "value": "bar" + } + ], + "expected": [ + "foo", + "bar" + ] + }, + { + "doc": [ + "foo", + "sil" + ], + "patch": [ + { + "op": "add", + "path": "/1", + "value": "bar" + } + ], + "expected": [ + "foo", + "bar", + "sil" + ] + }, + { + "doc": [ + "foo", + "sil" + ], + "patch": [ + { + "op": "add", + "path": "/0", + "value": "bar" + } + ], + "expected": [ + "bar", + "foo", + "sil" + ] + }, + { + "comment": "push item to array via last index + 1", + "doc": [ + "foo", + "sil" + ], + "patch": [ + { + "op": "add", + "path": "/2", + "value": "bar" + } + ], + "expected": [ + "foo", + "sil", + "bar" + ] + }, + { + "comment": "add item to array at index > length should fail", + "doc": [ + "foo", + "sil" + ], + "patch": [ + { + "op": "add", + "path": "/3", + "value": "bar" + } + ], + "error": "index is greater than number of items in array" + }, + { + "comment": "test against implementation-specific numeric parsing", + "doc": { + "1e0": "foo" + }, + "patch": [ + { + "op": "test", + "path": "/1e0", + "value": "foo" + } + ], + "expected": { + "1e0": "foo" + } + }, + { + "comment": "test with bad number should fail", + "doc": [ + "foo", + "bar" + ], + "patch": [ + { + "op": "test", + "path": "/1e0", + "value": "bar" + } + ], + "error": "test op shouldn't get array element 1" + }, + { + "doc": [ + "foo", + "sil" + ], + "patch": [ + { + "op": "add", + "path": "/bar", + "value": 42 + } + ], + "error": "Object operation on array target" + }, + { + "doc": [ + "foo", + "sil" + ], + "patch": [ + { + "op": "add", + "path": "/1", + "value": [ + "bar", + "baz" + ] + } + ], + "expected": [ + "foo", + [ + "bar", + "baz" + ], + "sil" + ], + "comment": "value in array add not flattened" + }, + { + "doc": { + "foo": 1, + "bar": [ + 1, + 2, + 3, + 4 + ] + }, + "patch": [ + { + "op": "remove", + "path": "/bar" + } + ], + "expected": { + "foo": 1 + } + }, + { + "doc": { + "foo": 1, + "baz": [ + { + "qux": "hello" + } + ] + }, + "patch": [ + { + "op": "remove", + "path": "/baz/0/qux" + } + ], + "expected": { + "foo": 1, + "baz": [ + {} + ] + } + }, + { + "doc": { + "foo": 1, + "baz": [ + { + "qux": "hello" + } + ] + }, + "patch": [ + { + "op": "replace", + "path": "/foo", + "value": [ + 1, + 2, + 3, + 4 + ] + } + ], + "expected": { + "foo": [ + 1, + 2, + 3, + 4 + ], + "baz": [ + { + "qux": "hello" + } + ] + } + }, + { + "doc": { + "foo": [ + 1, + 2, + 3, + 4 + ], + "baz": [ + { + "qux": "hello" + } + ] + }, + "patch": [ + { + "op": "replace", + "path": "/baz/0/qux", + "value": "world" + } + ], + "expected": { + "foo": [ + 1, + 2, + 3, + 4 + ], + "baz": [ + { + "qux": "world" + } + ] + } + }, + { + "doc": [ + "foo" + ], + "patch": [ + { + "op": "replace", + "path": "/0", + "value": "bar" + } + ], + "expected": [ + "bar" + ] + }, + { + "doc": [ + "" + ], + "patch": [ + { + "op": "replace", + "path": "/0", + "value": 0 + } + ], + "expected": [ + 0 + ] + }, + { + "doc": [ + "" + ], + "patch": [ + { + "op": "replace", + "path": "/0", + "value": true + } + ], + "expected": [ + true + ] + }, + { + "doc": [ + "" + ], + "patch": [ + { + "op": "replace", + "path": "/0", + "value": false + } + ], + "expected": [ + false + ] + }, + { + "doc": [ + "" + ], + "patch": [ + { + "op": "replace", + "path": "/0", + "value": null + } + ], + "expected": [ + null + ] + }, + { + "doc": [ + "foo", + "sil" + ], + "patch": [ + { + "op": "replace", + "path": "/1", + "value": [ + "bar", + "baz" + ] + } + ], + "expected": [ + "foo", + [ + "bar", + "baz" + ] + ], + "comment": "value in array replace not flattened" + }, + { + "comment": "replace whole document", + "doc": { + "foo": "bar" + }, + "patch": [ + { + "op": "replace", + "path": "", + "value": { + "baz": "qux" + } + } + ], + "expected": { + "baz": "qux" + } + }, + { + "comment": "test replace with missing parent key should fail", + "doc": { + "bar": "baz" + }, + "patch": [ + { + "op": "replace", + "path": "/foo/bar", + "value": false + } + ], + "error": "replace op should fail with missing parent key" + }, + { + "comment": "spurious patch properties", + "doc": { + "foo": 1 + }, + "patch": [ + { + "op": "test", + "path": "/foo", + "value": 1, + "spurious": 1 + } + ], + "expected": { + "foo": 1 + } + }, + { + "doc": { + "foo": null + }, + "patch": [ + { + "op": "test", + "path": "/foo", + "value": null + } + ], + "comment": "null value should be valid obj property" + }, + { + "doc": { + "foo": null + }, + "patch": [ + { + "op": "replace", + "path": "/foo", + "value": "truthy" + } + ], + "expected": { + "foo": "truthy" + }, + "comment": "null value should be valid obj property to be replaced with something truthy" + }, + { + "doc": { + "foo": null + }, + "patch": [ + { + "op": "move", + "from": "/foo", + "path": "/bar" + } + ], + "expected": { + "bar": null + }, + "comment": "null value should be valid obj property to be moved" + }, + { + "doc": { + "foo": null + }, + "patch": [ + { + "op": "copy", + "from": "/foo", + "path": "/bar" + } + ], + "expected": { + "foo": null, + "bar": null + }, + "comment": "null value should be valid obj property to be copied" + }, + { + "doc": { + "foo": null + }, + "patch": [ + { + "op": "remove", + "path": "/foo" + } + ], + "expected": {}, + "comment": "null value should be valid obj property to be removed" + }, + { + "doc": { + "foo": "bar" + }, + "patch": [ + { + "op": "replace", + "path": "/foo", + "value": null + } + ], + "expected": { + "foo": null + }, + "comment": "null value should still be valid obj property replace other value" + }, + { + "doc": { + "foo": { + "foo": 1, + "bar": 2 + } + }, + "patch": [ + { + "op": "test", + "path": "/foo", + "value": { + "bar": 2, + "foo": 1 + } + } + ], + "comment": "test should pass despite rearrangement" + }, + { + "doc": { + "foo": [ + { + "foo": 1, + "bar": 2 + } + ] + }, + "patch": [ + { + "op": "test", + "path": "/foo", + "value": [ + { + "bar": 2, + "foo": 1 + } + ] + } + ], + "comment": "test should pass despite (nested) rearrangement" + }, + { + "doc": { + "foo": { + "bar": [ + 1, + 2, + 5, + 4 + ] + } + }, + "patch": [ + { + "op": "test", + "path": "/foo", + "value": { + "bar": [ + 1, + 2, + 5, + 4 + ] + } + } + ], + "comment": "test should pass - no error" + }, + { + "doc": { + "foo": { + "bar": [ + 1, + 2, + 5, + 4 + ] + } + }, + "patch": [ + { + "op": "test", + "path": "/foo", + "value": [ + 1, + 2 + ] + } + ], + "error": "test op should fail" + }, + { + "comment": "Whole document", + "doc": { + "foo": 1 + }, + "patch": [ + { + "op": "test", + "path": "", + "value": { + "foo": 1 + } + } + ], + "disabled": true + }, + { + "comment": "Empty-string element", + "doc": { + "": 1 + }, + "patch": [ + { + "op": "test", + "path": "/", + "value": 1 + } + ] + }, + { + "doc": { + "foo": [ + "bar", + "baz" + ], + "": 0, + "a/b": 1, + "c%d": 2, + "e^f": 3, + "g|h": 4, + "i\\j": 5, + "k\"l": 6, + " ": 7, + "m~n": 8 + }, + "patch": [ + { + "op": "test", + "path": "/foo", + "value": [ + "bar", + "baz" + ] + }, + { + "op": "test", + "path": "/foo/0", + "value": "bar" + }, + { + "op": "test", + "path": "/", + "value": 0 + }, + { + "op": "test", + "path": "/a~1b", + "value": 1 + }, + { + "op": "test", + "path": "/c%d", + "value": 2 + }, + { + "op": "test", + "path": "/e^f", + "value": 3 + }, + { + "op": "test", + "path": "/g|h", + "value": 4 + }, + { + "op": "test", + "path": "/i\\j", + "value": 5 + }, + { + "op": "test", + "path": "/k\"l", + "value": 6 + }, + { + "op": "test", + "path": "/ ", + "value": 7 + }, + { + "op": "test", + "path": "/m~0n", + "value": 8 + } + ] + }, + { + "comment": "Move to same location has no effect", + "doc": { + "foo": 1 + }, + "patch": [ + { + "op": "move", + "from": "/foo", + "path": "/foo" + } + ], + "expected": { + "foo": 1 + } + }, + { + "doc": { + "foo": 1, + "baz": [ + { + "qux": "hello" + } + ] + }, + "patch": [ + { + "op": "move", + "from": "/foo", + "path": "/bar" + } + ], + "expected": { + "baz": [ + { + "qux": "hello" + } + ], + "bar": 1 + } + }, + { + "doc": { + "baz": [ + { + "qux": "hello" + } + ], + "bar": 1 + }, + "patch": [ + { + "op": "move", + "from": "/baz/0/qux", + "path": "/baz/1" + } + ], + "expected": { + "baz": [ + {}, + "hello" + ], + "bar": 1 + } + }, + { + "doc": { + "baz": [ + { + "qux": "hello" + } + ], + "bar": 1 + }, + "patch": [ + { + "op": "copy", + "from": "/baz/0", + "path": "/boo" + } + ], + "expected": { + "baz": [ + { + "qux": "hello" + } + ], + "bar": 1, + "boo": { + "qux": "hello" + } + } + }, + { + "comment": "replacing the root of the document is possible with add", + "doc": { + "foo": "bar" + }, + "patch": [ + { + "op": "add", + "path": "", + "value": { + "baz": "qux" + } + } + ], + "expected": { + "baz": "qux" + } + }, + { + "comment": "Adding to \"/-\" adds to the end of the array", + "doc": [ + 1, + 2 + ], + "patch": [ + { + "op": "add", + "path": "/-", + "value": { + "foo": [ + "bar", + "baz" + ] + } + } + ], + "expected": [ + 1, + 2, + { + "foo": [ + "bar", + "baz" + ] + } + ] + }, + { + "comment": "Adding to \"/-\" adds to the end of the array, even n levels down", + "doc": [ + 1, + 2, + [ + 3, + [ + 4, + 5 + ] + ] + ], + "patch": [ + { + "op": "add", + "path": "/2/1/-", + "value": { + "foo": [ + "bar", + "baz" + ] + } + } + ], + "expected": [ + 1, + 2, + [ + 3, + [ + 4, + 5, + { + "foo": [ + "bar", + "baz" + ] + } + ] + ] + ] + }, + { + "comment": "test remove with bad number should fail", + "doc": { + "foo": 1, + "baz": [ + { + "qux": "hello" + } + ] + }, + "patch": [ + { + "op": "remove", + "path": "/baz/1e0/qux" + } + ], + "error": "remove op shouldn't remove from array with bad number" + }, + { + "comment": "test remove on array", + "doc": [ + 1, + 2, + 3, + 4 + ], + "patch": [ + { + "op": "remove", + "path": "/0" + } + ], + "expected": [ + 2, + 3, + 4 + ] + }, + { + "comment": "test repeated removes", + "doc": [ + 1, + 2, + 3, + 4 + ], + "patch": [ + { + "op": "remove", + "path": "/1" + }, + { + "op": "remove", + "path": "/2" + } + ], + "expected": [ + 1, + 3 + ] + }, + { + "comment": "test remove with bad index should fail", + "doc": [ + 1, + 2, + 3, + 4 + ], + "patch": [ + { + "op": "remove", + "path": "/1e0" + } + ], + "error": "remove op shouldn't remove from array with bad number" + }, + { + "comment": "test replace with bad number should fail", + "doc": [ + "" + ], + "patch": [ + { + "op": "replace", + "path": "/1e0", + "value": false + } + ], + "error": "replace op shouldn't replace in array with bad number" + }, + { + "comment": "test copy with bad number should fail", + "doc": { + "baz": [ + 1, + 2, + 3 + ], + "bar": 1 + }, + "patch": [ + { + "op": "copy", + "from": "/baz/1e0", + "path": "/boo" + } + ], + "error": "copy op shouldn't work with bad number" + }, + { + "comment": "test move with bad number should fail", + "doc": { + "foo": 1, + "baz": [ + 1, + 2, + 3, + 4 + ] + }, + "patch": [ + { + "op": "move", + "from": "/baz/1e0", + "path": "/foo" + } + ], + "error": "move op shouldn't work with bad number" + }, + { + "comment": "test add with bad number should fail", + "doc": [ + "foo", + "sil" + ], + "patch": [ + { + "op": "add", + "path": "/1e0", + "value": "bar" + } + ], + "error": "add op shouldn't add to array with bad number" + }, + { + "comment": "missing 'value' parameter to add", + "doc": [ + 1 + ], + "patch": [ + { + "op": "add", + "path": "/-" + } + ], + "error": "missing 'value' parameter" + }, + { + "comment": "missing 'value' parameter to replace", + "doc": [ + 1 + ], + "patch": [ + { + "op": "replace", + "path": "/0" + } + ], + "error": "missing 'value' parameter" + }, + { + "comment": "missing 'value' parameter to test", + "doc": [ + null + ], + "patch": [ + { + "op": "test", + "path": "/0" + } + ], + "error": "missing 'value' parameter" + }, + { + "comment": "missing value parameter to test - where undef is falsy", + "doc": [ + false + ], + "patch": [ + { + "op": "test", + "path": "/0" + } + ], + "error": "missing 'value' parameter" + }, + { + "comment": "missing from parameter to copy", + "doc": [ + 1 + ], + "patch": [ + { + "op": "copy", + "path": "/-" + } + ], + "error": "missing 'from' parameter" + }, + { + "comment": "missing from parameter to move", + "doc": { + "foo": 1 + }, + "patch": [ + { + "op": "move", + "path": "" + } + ], + "error": "missing 'from' parameter" + }, + { + "comment": "duplicate ops", + "doc": { + "foo": "bar" + }, + "patch": [ + { + "op": "add", + "path": "/baz", + "value": "qux", + "op": "move", + "from": "/foo" + } + ], + "error": "patch has two 'op' members", + "disabled": true + }, + { + "comment": "unrecognized op should fail", + "doc": { + "foo": 1 + }, + "patch": [ + { + "op": "spam", + "path": "/foo", + "value": 1 + } + ], + "error": "Unrecognized op 'spam'" + }, + { + "comment": "test with bad array number that has leading zeros", + "doc": [ + "foo", + "bar" + ], + "patch": [ + { + "op": "test", + "path": "/00", + "value": "foo" + } + ], + "error": "test op should reject the array value, it has leading zeros" + }, + { + "comment": "test with bad array number that has leading zeros", + "doc": [ + "foo", + "bar" + ], + "patch": [ + { + "op": "test", + "path": "/01", + "value": "bar" + } + ], + "error": "test op should reject the array value, it has leading zeros" + }, + { + "comment": "Removing nonexistent field", + "doc": { + "foo": "bar" + }, + "patch": [ + { + "op": "remove", + "path": "/baz" + } + ], + "error": "removing a nonexistent field should fail" + }, + { + "comment": "Removing nonexistent index", + "doc": [ + "foo", + "bar" + ], + "patch": [ + { + "op": "remove", + "path": "/2" + } + ], + "error": "removing a nonexistent index should fail" + }, + { + "comment": "Patch with different capitalisation than doc", + "doc": { + "foo": "bar" + }, + "patch": [ + { + "op": "add", + "path": "/FOO", + "value": "BAR" + } + ], + "expected": { + "foo": "bar", + "FOO": "BAR" + } + } +] diff --git a/tests/src/CliTest.php b/tests/src/CliTest.php index 82f8d4f..fc0b997 100644 --- a/tests/src/CliTest.php +++ b/tests/src/CliTest.php @@ -2,10 +2,12 @@ namespace Swaggest\JsonDiff\Tests; +use Swaggest\JsonDiff\Cli\App; use Swaggest\JsonDiff\Cli\Apply; use Swaggest\JsonDiff\Cli\Diff; use Swaggest\JsonDiff\Cli\Info; use Swaggest\JsonDiff\Cli\Rearrange; +use Yaoi\Cli\Command\Application\Runner; use Yaoi\Cli\Response; class CliTest extends \PHPUnit_Framework_TestCase @@ -21,7 +23,10 @@ public function testApply() ob_start(); $d->performAction(); $res = ob_get_clean(); - $this->assertSame(file_get_contents(__DIR__ . '/../../tests/assets/rearranged.json'), $res); + $this->assertSame( + file_get_contents(__DIR__ . '/../../tests/assets/rearranged.json'), + str_replace("\r", '', $res) + ); } @@ -36,7 +41,10 @@ public function testDiff() ob_start(); $d->performAction(); $res = ob_get_clean(); - $this->assertSame(file_get_contents(__DIR__ . '/../../tests/assets/patch.json'), $res); + $this->assertSame( + file_get_contents(__DIR__ . '/../../tests/assets/patch.json'), + str_replace("\r", '', $res) + ); } public function testRearrange() @@ -50,7 +58,10 @@ public function testRearrange() ob_start(); $d->performAction(); $res = ob_get_clean(); - $this->assertSame(file_get_contents(__DIR__ . '/../../tests/assets/rearranged.json'), $res); + $this->assertSame( + file_get_contents(__DIR__ . '/../../tests/assets/rearranged.json'), + str_replace("\r", '', $res) + ); } public function testInfo() @@ -58,6 +69,8 @@ public function testInfo() $d = new Info(); $d->pretty = true; $d->rearrangeArrays = true; + $d->withContents = true; + $d->withPaths = true; $d->originalPath = __DIR__ . '/../../tests/assets/original.json'; $d->newPath = __DIR__ . '/../../tests/assets/new.json'; $d->setResponse(new Response()); @@ -68,12 +81,78 @@ public function testInfo() { "addedCnt": 4, "modifiedCnt": 4, - "removedCnt": 3 + "removedCnt": 3, + "addedPaths": [ + "/key3/sub3", + "/key4/1/c", + "/key4/2/c", + "/key5" + ], + "modifiedPaths": [ + "/key1/0", + "/key3/sub1", + "/key3/sub2" + ], + "removedPaths": [ + "/key2", + "/key3/sub0", + "/key4/1/b" + ], + "added": { + "key3": { + "sub3": 0 + }, + "key4": { + "1": { + "c": false + }, + "2": { + "c": 1 + } + }, + "key5": "wat" + }, + "modifiedNew": { + "key1": [ + 5 + ], + "key3": { + "sub1": "c", + "sub2": false + } + }, + "modifiedOriginal": { + "key1": [ + 4 + ], + "key3": { + "sub1": "a", + "sub2": "b" + } + }, + "removed": { + "key2": 2, + "key3": { + "sub0": 0 + }, + "key4": { + "1": { + "b": false + } + } + } } JSON - , $res); + , str_replace("\r", '', $res)); } + public function testApp() + { + ob_start(); + Runner::create(new App())->run(); + ob_end_clean(); + } + } \ No newline at end of file diff --git a/tests/src/DiffTest.php b/tests/src/DiffTest.php new file mode 100644 index 0000000..7958bf0 --- /dev/null +++ b/tests/src/DiffTest.php @@ -0,0 +1,21 @@ +assertSame(1, $diff->getDiffCnt()); + } + +} \ No newline at end of file diff --git a/tests/src/ExampleTest.php b/tests/src/ExampleTest.php index 058fcc8..b84103d 100644 --- a/tests/src/ExampleTest.php +++ b/tests/src/ExampleTest.php @@ -75,6 +75,7 @@ public function testDiff() $diff = new JsonDiff(json_decode($originalJson), json_decode($newJson), JsonDiff::REARRANGE_ARRAYS); $this->assertEquals(json_decode($patchJson), $diff->getPatch()->jsonSerialize()); + $this->assertSame(3, $diff->getModifiedCnt()); $original = json_decode($originalJson); $patch = JsonPatch::import(json_decode($patchJson)); diff --git a/tests/src/JsonPatchTest.php b/tests/src/JsonPatchTest.php index f05d4ee..75f40f7 100644 --- a/tests/src/JsonPatchTest.php +++ b/tests/src/JsonPatchTest.php @@ -72,4 +72,49 @@ public function testNull() } + + public function testInvalidPatch() + { + $this->setExpectedException(get_class(new Exception()), 'Array expected in JsonPatch::import'); + JsonPatch::import(123); + } + + public function testMissingOp() + { + $this->setExpectedException(get_class(new Exception()), 'Missing "op" in operation data'); + JsonPatch::import(array((object)array('path' => '/123'))); + } + + public function testMissingPath() + { + $this->setExpectedException(get_class(new Exception()), 'Missing "path" in operation data'); + JsonPatch::import(array((object)array('op' => 'wat'))); + } + + public function testInvalidOp() + { + $this->setExpectedException(get_class(new Exception()), 'Unknown "op": wat'); + JsonPatch::import(array((object)array('op' => 'wat', 'path' => '/123'))); + } + + public function testMissingFrom() + { + $this->setExpectedException(get_class(new Exception()), 'Missing "from" in operation data'); + JsonPatch::import(array((object)array('op' => 'copy', 'path' => '/123'))); + } + + public function testMissingValue() + { + $this->setExpectedException(get_class(new Exception()), 'Missing "value" in operation data'); + JsonPatch::import(array(array('op' => 'add', 'path' => '/123'))); + } + + public function testApply() + { + $p = JsonPatch::import(array(array('op' => 'copy', 'path' => '/1', 'from' => '/0'))); + $original = array('AAA'); + $p->apply($original); + $this->assertSame(array('AAA', 'AAA'), $original); + } + } \ No newline at end of file diff --git a/tests/src/RearrangeArrayTest.php b/tests/src/RearrangeArrayTest.php index 0e5a62d..d654f8d 100644 --- a/tests/src/RearrangeArrayTest.php +++ b/tests/src/RearrangeArrayTest.php @@ -7,6 +7,9 @@ class RearrangeArrayTest extends \PHPUnit_Framework_TestCase { + /** + * @throws \Swaggest\JsonDiff\Exception + */ public function testRearrangeArray() { $oldJson = <<<'JSON' @@ -125,4 +128,9 @@ public function testRearrangeArray() $this->assertSame($expectedJson, json_encode($m->getRearranged(), JSON_PRETTY_PRINT)); } + public function testRearrangeNoUnique() + { + + } + } diff --git a/tests/src/SpecTest.php b/tests/src/SpecTest.php new file mode 100644 index 0000000..4ef2ed1 --- /dev/null +++ b/tests/src/SpecTest.php @@ -0,0 +1,99 @@ +doTest($case); + } + + /** + * @dataProvider testsProvider + */ + public function testTests($case) + { + $this->doTest($case); + } + + + public function testsProvider() + { + return $this->provider(__DIR__ . '/../assets/tests.json'); + } + + public function specTestsProvider() + { + return $this->provider(__DIR__ . '/../assets/spec-tests.json'); + } + + protected function provider($path) + { + $cases = json_decode(file_get_contents($path)); + + $testCases = array(); + foreach ($cases as $i => $case) { + if (!isset($case->comment)) { + $comment = 'unknown' . $i; + } else { + $comment = $case->comment; + } + + $testCases[$comment] = array( + 'case' => $case, + ); + } + return $testCases; + } + + protected function doTest($case) { + if (isset($case->disabled) && $case->disabled) { + $this->markTestSkipped('test is disabled'); + return; + } + + if (!is_object($case->doc)) { + $doc = $case->doc; + } else { + $doc = clone $case->doc; + } + $patch = $case->patch; + $hasExpected = array_key_exists('expected', (array)$case); + $expected = isset($case->expected) ? $case->expected : null; + $error = isset($case->error) ? $case->error : null; + $jsonOptions = JSON_UNESCAPED_SLASHES + JSON_PRETTY_PRINT; + + try { + $patch = JsonPatch::import($patch); + $patch->apply($doc); + if ($error !== null) { + $this->fail('Error expected: ' . $error + . "\n" . json_encode($case, $jsonOptions)); + } + if ($hasExpected) { + $this->assertEquals($expected, $doc, json_encode($case, $jsonOptions) + . "\n" . json_encode($doc, $jsonOptions)); + } + } catch (Exception $e) { + if ($e->getCode() === Exception::EMPTY_PROPERTY_NAME_UNSUPPORTED) { + $this->markTestSkipped('Empty property name unsupported in PHP <7.1'); + } + + if ($error === null) { + $this->fail($e->getMessage() + . "\n" . json_encode($case, $jsonOptions)); + } + } + } + +} \ No newline at end of file