diff --git a/src/Util/Json.php b/src/Util/Json.php index 752c1fd600b..0a9ee4ccb9d 100644 --- a/src/Util/Json.php +++ b/src/Util/Json.php @@ -12,9 +12,9 @@ use const JSON_PRETTY_PRINT; use const JSON_UNESCAPED_SLASHES; use const JSON_UNESCAPED_UNICODE; -use function count; -use function is_array; +use const SORT_STRING; use function is_object; +use function is_scalar; use function json_decode; use function json_encode; use function json_last_error; @@ -75,24 +75,31 @@ public static function canonicalize(string $json): array */ private static function recursiveSort(&$json): void { - if (!is_array($json)) { - // If the object is not empty, change it to an associative array - // so we can sort the keys (and we will still re-encode it - // correctly, since PHP encodes associative arrays as JSON objects.) - // But EMPTY objects MUST remain empty objects. (Otherwise we will - // re-encode it as a JSON array rather than a JSON object.) - // See #2919. - if (is_object($json) && count((array) $json) > 0) { - $json = (array) $json; - } else { - return; - } + // Nulls, empty arrays, and scalars need no further handling. + if (!$json || is_scalar($json)) { + return; } - ksort($json); + $isObject = is_object($json); + + if ($isObject) { + // Objects need to be sorted during canonicalization to ensure + // correct comparsion since JSON objects are unordered. It must be + // kept as an object so that the value correctly stays as a JSON + // object instead of potentially being converted to an array. This + // approach ensures that numeric string JSON keys are preserved and + // don't risk being flattened due to PHP's array semantics. + // See #2919, #4584, #4674 + $json = (array) $json; + ksort($json, SORT_STRING); + } foreach ($json as $key => &$value) { self::recursiveSort($value); } + + if ($isObject) { + $json = (object) $json; + } } } diff --git a/tests/unit/Framework/Constraint/JsonMatchesTest.php b/tests/unit/Framework/Constraint/JsonMatchesTest.php index a8ce6d1704f..57f44a111b3 100644 --- a/tests/unit/Framework/Constraint/JsonMatchesTest.php +++ b/tests/unit/Framework/Constraint/JsonMatchesTest.php @@ -23,33 +23,41 @@ final class JsonMatchesTest extends ConstraintTestCase public static function evaluateDataprovider(): array { return [ - 'valid JSON' => [true, json_encode(['Mascott' => 'Tux']), json_encode(['Mascott' => 'Tux'])], - 'error syntax' => [false, '{"Mascott"::}', json_encode(['Mascott' => 'Tux'])], - 'error UTF-8' => [false, json_encode('\xB1\x31'), json_encode(['Mascott' => 'Tux'])], - 'invalid JSON in class instantiation' => [false, json_encode(['Mascott' => 'Tux']), '{"Mascott"::}'], - 'string type not equals number' => [false, '{"age": "5"}', '{"age": 5}'], - 'string type not equals boolean' => [false, '{"age": "true"}', '{"age": true}'], - 'string type not equals null' => [false, '{"age": "null"}', '{"age": null}'], - 'object fields are unordered' => [true, '{"first":1, "second":"2"}', '{"second":"2", "first":1}'], - 'child object fields are unordered' => [true, '{"Mascott": {"name":"Tux", "age":5}}', '{"Mascott": {"age":5, "name":"Tux"}}'], - 'null field different from missing field' => [false, '{"present": true, "missing": null}', '{"present": true}'], - 'array elements are ordered' => [false, '["first", "second"]', '["second", "first"]'], - 'single boolean valid json' => [true, 'true', 'true'], - 'single number valid json' => [true, '5.3', '5.3'], - 'single null valid json' => [true, 'null', 'null'], - 'objects are not arrays' => [false, '{}', '[]'], + 'valid JSON' => [true, json_encode(['Mascott' => 'Tux']), json_encode(['Mascott' => 'Tux'])], + 'error syntax' => [false, '{"Mascott"::}', json_encode(['Mascott' => 'Tux'])], + 'error UTF-8' => [false, json_encode('\xB1\x31'), json_encode(['Mascott' => 'Tux'])], + 'invalid JSON in class instantiation' => [false, json_encode(['Mascott' => 'Tux']), '{"Mascott"::}'], + 'string type not equals number' => [false, '{"age": "5"}', '{"age": 5}'], + 'string type not equals boolean' => [false, '{"age": "true"}', '{"age": true}'], + 'string type not equals null' => [false, '{"age": "null"}', '{"age": null}'], + 'object fields are unordered' => [true, '{"first":1, "second":"2"}', '{"second":"2", "first":1}'], + 'object fields with numeric keys are unordered' => [true, '{"0":null,"a":{},"b":[],"c":"1","d":1,"e":-1,"f":[1,2],"g":[2,1],"h":{"0":"0","1":"1","2":"2"}}', '{"a":{},"d":1,"b":[],"e":-1,"0":null,"c":"1","f":[1,2],"h":{"2":"2","1":"1","0":"0"},"g":[2,1]}'], + 'child object fields are unordered' => [true, '{"Mascott": {"name":"Tux", "age":5}}', '{"Mascott": {"age":5, "name":"Tux"}}'], + 'null field different from missing field' => [false, '{"present": true, "missing": null}', '{"present": true}'], + 'array elements are ordered' => [false, '["first", "second"]', '["second", "first"]'], + 'single boolean valid json' => [true, 'true', 'true'], + 'single number valid json' => [true, '5.3', '5.3'], + 'single null valid json' => [true, 'null', 'null'], + 'objects are not arrays' => [false, '{}', '[]'], + 'arrays are not objects' => [false, '[]', '{}'], + 'objects in arrays are unordered' => [true, '[{"0":"0","1":"1"},{"2":"2","3":"3"}]', '[{"1":"1","0":"0"},{"2":"2","3":"3"}]'], ]; } public static function evaluateThrowsExpectationFailedExceptionWhenJsonIsValidButDoesNotMatchDataprovider(): array { return [ - 'error UTF-8' => [json_encode('\xB1\x31'), json_encode(['Mascott' => 'Tux'])], - 'string type not equals number' => ['{"age": "5"}', '{"age": 5}'], - 'string type not equals boolean' => ['{"age": "true"}', '{"age": true}'], - 'string type not equals null' => ['{"age": "null"}', '{"age": null}'], - 'null field different from missing field' => ['{"missing": null, "present": true}', '{"present": true}'], - 'array elements are ordered' => ['["first", "second"]', '["second", "first"]'], + 'error UTF-8' => [json_encode('\xB1\x31'), json_encode(['Mascott' => 'Tux'])], + 'string type not equals number' => ['{"age": "5"}', '{"age": 5}'], + 'string type not equals boolean' => ['{"age": "true"}', '{"age": true}'], + 'string type not equals null' => ['{"age": "null"}', '{"age": null}'], + 'null field different from missing field' => ['{"missing": null, "present": true}', '{"present": true}'], + 'array elements are ordered' => ['["first", "second"]', '["second", "first"]'], + 'objects with numeric keys are not arrays' => ['{"0":{}}', '[{}]'], + 'child array elements are ordered' => ['{"0":null,"a":{},"b":[],"c":"1","d":1,"e":-1,"f":[1,2],"g":[2,1],"h":{"0":"0","1":"1","2":"2"}}', '{"a":{},"d":1,"b":[],"e":-1,"0":null,"c":"1","f":[2,1],"h":{"2":"2","1":"1","0":"0"},"g":[2,1]}'], + 'child object with numeric fields stay as object' => ['{"0":null,"a":{},"b":[],"c":"1","d":1,"e":-1,"f":[1,2],"g":[2,1],"h":{"0":"0","1":"1","2":"2"}}', '{"a":{},"d":1,"b":[],"e":-1,"0":null,"c":"1","f":[1,2],"h":["0","1","2"],"g":[2,1]}'], + 'nested arrays are ordered' => ['[[1,0],[2,3]]', '[{"1":"1","0":"0"},{"2":"2","3":"3"}]'], + 'child objects in arrays stay in order' => ['[{"0":"0","1":"1"},{"2":"2","3":"3"}]', '[{"2":"2","3":"3"},{"1":"1","0":"0"}]'], ]; }