From c3630414e792dfbc1c01238eba73fa3d18a215ab Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 3 May 2025 11:25:30 +0200 Subject: [PATCH 1/7] Return JSON objects in a wrapper object --- src/main/php/text/json/Format.class.php | 95 ++++++++++++------- src/main/php/text/json/Input.class.php | 4 +- src/main/php/text/json/Output.class.php | 26 +---- .../text/json/unittest/FormatTest.class.php | 19 +++- .../json/unittest/JsonInputTest.class.php | 23 +++-- .../json/unittest/JsonOutputTest.class.php | 11 ++- .../json/unittest/WrappedFormatTest.class.php | 3 +- 7 files changed, 103 insertions(+), 78 deletions(-) diff --git a/src/main/php/text/json/Format.class.php b/src/main/php/text/json/Format.class.php index d1ceee0..11afaba 100755 --- a/src/main/php/text/json/Format.class.php +++ b/src/main/php/text/json/Format.class.php @@ -1,6 +1,6 @@ options); - } else if ('integer' === $t) { - return (string)$value; - } else if ('double' === $t) { + $r= ''; + foreach ($this->tokensOf($value) as $bytes) { + $r.= $bytes; + } + return $r; + } + + /** + * Yields tokens for a given value + * + * @param var $value + * @return iterable + */ + public function tokensOf($value) { + if (is_string($value)) { + yield json_encode($value, $this->options); + } else if (is_int($value)) { + yield (string)$value; + } else if (is_float($value)) { $cast= (string)$value; - return strpos($cast, '.') ? $cast : $cast.'.0'; - } else if ('array' === $t) { + yield strpos($cast, '.') ? $cast : $cast.'.0'; + } else if (is_array($value)) { if (empty($value)) { - return '[]'; + yield '[]'; } else if (0 === key($value)) { - $r= $this->open('['); - $next= false; + yield $this->open('['); + $i= 0; foreach ($value as $element) { - if ($next) { - $r.= $this->comma; - } else { - $next= true; - } - $r.= $this->representationOf($element); + if ($i++) yield $this->comma; + yield from $this->tokensOf($element); } - return $r.$this->close(']'); - } else { map: - $r= $this->open('{'); - $next= false; - foreach ($value as $key => $mapped) { - if ($next) { - $r.= $this->comma; - } else { - $next= true; - } - $r.= $this->representationOf($key).$this->colon.$this->representationOf($mapped); + yield $this->close(']'); + } else { + map: yield $this->open('{'); + $i= 0; + foreach ($value as $key => $element) { + if ($i++) yield $this->comma; + yield from $this->tokensOf((string)$key); + yield $this->colon; + yield from $this->tokensOf($element); } - return $r.$this->close('}'); + yield $this->close('}'); } } else if (null === $value) { - return 'null'; + yield 'null'; } else if (true === $value) { - return 'true'; + yield 'true'; } else if (false === $value) { - return 'false'; - } else if ($value instanceof StdClass) { - $value= (array)$value; - if (empty($value)) return '{}'; + yield 'false'; + } else if ($value instanceof JsonObject || $value instanceof StdClass) { goto map; + } else if ($value instanceof Traversable) { + $i= 0; + $map= null; + foreach ($value as $key => $element) { + if (0 === $i++) { + $map= 0 !== $key; + yield $this->open($map ? '{' : '['); + } else { + yield $this->comma; + } + + if ($map) { + yield from $this->tokensOf((string)$key); + yield $this->colon; + } + yield from $this->tokensOf($element); + } + yield null === $map ? '[]' : $this->close($map ? '}' : ']'); } else { throw new IllegalArgumentException('Cannot represent instances of '.typeof($value)); } diff --git a/src/main/php/text/json/Input.class.php b/src/main/php/text/json/Input.class.php index 763bf0d..d85e638 100755 --- a/src/main/php/text/json/Input.class.php +++ b/src/main/php/text/json/Input.class.php @@ -92,7 +92,7 @@ protected function escaped($pos, $len, &$offset) { protected function readObject($nesting) { $token= $this->nextToken(); if ('}' === $token) { - return []; + return new JsonObject(); } else if (null !== $token) { $result= []; if (++$nesting > $this->maximumNesting) { @@ -113,7 +113,7 @@ protected function readObject($nesting) { if (',' === $delim) { continue; } else if ('}' === $delim) { - return $result; + return new JsonObject($result); } else { throw new FormatException('Unexpected '.Objects::stringOf($delim).', expecting "," or "}"'); } diff --git a/src/main/php/text/json/Output.class.php b/src/main/php/text/json/Output.class.php index 4b0e411..c328ab2 100755 --- a/src/main/php/text/json/Output.class.php +++ b/src/main/php/text/json/Output.class.php @@ -22,30 +22,8 @@ public function __construct($format= null) { * @return self */ public function write($value) { - $f= $this->format; - if ($value instanceof Traversable || is_array($value)) { - $i= 0; - $map= null; - foreach ($value as $key => $element) { - if (0 === $i++) { - $map= 0 !== $key; - $this->appendToken($f->open($map ? '{' : '[')); - } else { - $this->appendToken($f->comma); - } - - if ($map) { - $this->appendToken($f->representationOf((string)$key).$f->colon); - } - $this->write($element); - } - if (null === $map) { - $this->appendToken('[]'); - } else { - $this->appendToken($f->close($map ? '}' : ']')); - } - } else { - $this->appendToken($f->representationOf($value)); + foreach ($this->format->tokensOf($value) as $token) { + $this->appendToken($token); } return $this; } diff --git a/src/test/php/text/json/unittest/FormatTest.class.php b/src/test/php/text/json/unittest/FormatTest.class.php index 0a30fd6..7fadcc6 100755 --- a/src/test/php/text/json/unittest/FormatTest.class.php +++ b/src/test/php/text/json/unittest/FormatTest.class.php @@ -1,10 +1,21 @@ format()->tokensOf($value))); + } } \ No newline at end of file diff --git a/src/test/php/text/json/unittest/JsonInputTest.class.php b/src/test/php/text/json/unittest/JsonInputTest.class.php index 1a82743..6231f06 100755 --- a/src/test/php/text/json/unittest/JsonInputTest.class.php +++ b/src/test/php/text/json/unittest/JsonInputTest.class.php @@ -3,7 +3,7 @@ use io\streams\MemoryInputStream; use lang\FormatException; use test\{Assert, Expect, Test, Values}; -use text\json\Types; +use text\json\{Types, JsonObject}; use util\collections\Pair; /** @@ -118,32 +118,37 @@ public function read_keyword($expected, $source) { #[Test, Values(['{}', '{ }'])] public function read_empty_object($source) { - Assert::equals([], $this->read($source)); + Assert::equals(new JsonObject([]), $this->read($source)); } #[Test, Values(['{"key": "value"}', '{"key" : "value"}', '{ "key" : "value" }'])] public function read_key_value_pair($source) { - Assert::equals(['key' => 'value'], $this->read($source)); + Assert::equals(new JsonObject(['key' => 'value']), $this->read($source)); } #[Test, Values(['{"a": "v1", "b": "v2"}', '{"a" : "v1", "b" : "v2"}', '{ "a" : "v1" , "b" : "v2" }'])] public function read_key_value_pairs($source) { - Assert::equals(['a' => 'v1', 'b' => 'v2'], $this->read($source)); + Assert::equals(new JsonObject(['a' => 'v1', 'b' => 'v2']), $this->read($source)); } #[Test, Values(['{"": "value"}', '{"" : "value"}', '{ "" : "value" }'])] public function empty_key($source) { - Assert::equals(['' => 'value'], $this->read($source)); + Assert::equals(new JsonObject(['' => 'value']), $this->read($source)); } #[Test] public function keys_overwrite_each_other() { - Assert::equals(['key' => 'v2'], $this->read('{"key": "v1", "key": "v2"}')); + Assert::equals(new JsonObject(['key' => 'v2']), $this->read('{"key": "v1", "key": "v2"}')); } #[Test] public function object_ending_with_zero() { - Assert::equals(['key' => 0], $this->read('{"key": 0}')); + Assert::equals(new JsonObject(['key' => 0]), $this->read('{"key": 0}')); + } + + #[Test] + public function array_access() { + Assert::equals('value', $this->read('{"key" : "value"}')['key']); } #[Test, Expect(FormatException::class), Values(['{', '{{', '{{}', '}', '}}'])] @@ -279,11 +284,11 @@ public function files_typically_end_with_trailing_newline() { #[Test] public function indented_json() { Assert::equals( - [ + new JsonObject([ 'color' => 'green', 'sizes' => ['S', 'M', 'L', 'XL'], 'price' => 12.99 - ], + ]), $this->read('{ "color" : "green", "sizes" : [ "S", "M", "L", "XL" ], diff --git a/src/test/php/text/json/unittest/JsonOutputTest.class.php b/src/test/php/text/json/unittest/JsonOutputTest.class.php index f1997cb..e1e2cde 100755 --- a/src/test/php/text/json/unittest/JsonOutputTest.class.php +++ b/src/test/php/text/json/unittest/JsonOutputTest.class.php @@ -2,7 +2,7 @@ use lang\IllegalArgumentException; use test\{Assert, After, Before, Expect, Test, Values}; -use text\json\Types; +use text\json\{Types, JsonObject}; abstract class JsonOutputTest { private static $precision; @@ -116,14 +116,19 @@ public function write_empty_object() { Assert::equals('{}', $this->write((object)[])); } + #[Test] + public function write_json_object() { + Assert::equals('{}', $this->write(new JsonObject())); + } + #[Test] public function write_array_as_object() { - Assert::equals('{0:1,1:2,2:3}', $this->write((object)[1, 2, 3])); + Assert::equals('{"0":1,"1":2,"2":3}', $this->write((object)[1, 2, 3])); } #[Test] public function write_nested_array_as_object() { - Assert::equals('{"values":{0:1,1:2,2:3}}', $this->write(['values' => (object)[1, 2, 3]])); + Assert::equals('{"values":{"0":1,"1":2,"2":3}}', $this->write(['values' => (object)[1, 2, 3]])); } #[Test] diff --git a/src/test/php/text/json/unittest/WrappedFormatTest.class.php b/src/test/php/text/json/unittest/WrappedFormatTest.class.php index 3f2a02d..381500a 100755 --- a/src/test/php/text/json/unittest/WrappedFormatTest.class.php +++ b/src/test/php/text/json/unittest/WrappedFormatTest.class.php @@ -1,8 +1,7 @@ Date: Sat, 3 May 2025 11:33:43 +0200 Subject: [PATCH 2/7] Add test for object roundtrips --- src/test/php/text/json/unittest/JsonTest.class.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/test/php/text/json/unittest/JsonTest.class.php b/src/test/php/text/json/unittest/JsonTest.class.php index 62b6fc1..d8752ab 100755 --- a/src/test/php/text/json/unittest/JsonTest.class.php +++ b/src/test/php/text/json/unittest/JsonTest.class.php @@ -1,9 +1,9 @@ Date: Sat, 3 May 2025 11:34:01 +0200 Subject: [PATCH 3/7] Add JsonObject implementation and tests --- src/main/php/text/json/JsonObject.class.php | 53 +++++++++++++ .../json/unittest/JsonObjectTest.class.php | 75 +++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100755 src/main/php/text/json/JsonObject.class.php create mode 100755 src/test/php/text/json/unittest/JsonObjectTest.class.php diff --git a/src/main/php/text/json/JsonObject.class.php b/src/main/php/text/json/JsonObject.class.php new file mode 100755 index 0000000..6383186 --- /dev/null +++ b/src/main/php/text/json/JsonObject.class.php @@ -0,0 +1,53 @@ +backing= $backing; + } + + #[ReturnTypeWillChange] + public function count() { return sizeof($this->backing); } + + #[ReturnTypeWillChange] + public function offsetGet($key) { return $this->backing[$key]; } + + #[ReturnTypeWillChange] + public function offsetSet($key, $value) { $this->backing[$key]= $value; } + + #[ReturnTypeWillChange] + public function offsetExists($key) { return array_key_exists($key, $this->backing); } + + #[ReturnTypeWillChange] + public function offsetUnset($key) { unset($this->backing[$key]); } + + #[ReturnTypeWillChange] + public function getIterator() { return new ArrayIterator($this->backing); } + + /** @return string */ + public function toString() { + return '(object)'.Objects::stringOf($this->backing); + } + + /** @return string */ + public function hashCode() { + return 'J'.Objects::hashOf($this->backing); + } + + /** + * Comparison + * + * @param var $value + * @return int + */ + public function compareTo($value) { + return $value instanceof self ? Objects::compare($this->backing, $value->backing) : 1; + } +} \ No newline at end of file diff --git a/src/test/php/text/json/unittest/JsonObjectTest.class.php b/src/test/php/text/json/unittest/JsonObjectTest.class.php new file mode 100755 index 0000000..8dae95c --- /dev/null +++ b/src/test/php/text/json/unittest/JsonObjectTest.class.php @@ -0,0 +1,75 @@ + 'value']]])] + public function can_create_with($backing) { + new JsonObject($backing); + } + + #[Test] + public function get() { + Assert::equals('value', (new JsonObject(['key' => 'value']))['key']); + } + + #[Test] + public function set() { + $fixture= new JsonObject(['key' => 'value']); + $fixture['key']= 'changed'; + + Assert::equals('changed', $fixture['key']); + } + + #[Test] + public function isset() { + $fixture= new JsonObject(['key' => 'value']); + + Assert::true(isset($fixture['key'])); + Assert::false(isset($fixture['color'])); + } + + #[Test] + public function unset() { + $fixture= new JsonObject(['key' => 'value']); + unset($fixture['key']); + + Assert::throws(IndexOutOfBoundsException::class, function() use($fixture) { + $fixture['key']; + }); + } + + #[Test, Values([[[]], [['key' => 'value']], [['a' => 0, 'b' => 1]]])] + public function iteration($backing) { + Assert::equals($backing, iterator_to_array(new JsonObject($backing))); + } + + #[Test, Values([[[], 0], [['key' => 'value'], 1], [['a' => 0, 'b' => 1], 2]])] + public function count($backing, $expected) { + Assert::equals($expected, sizeof(new JsonObject($backing))); + } + + #[Test] + public function compare() { + $a= new JsonObject(['key' => 'value']); + $b= new JsonObject(['key' => 'value']); + $c= new JsonObject(['key' => 'VALUE']); + + Assert::equals(0, $a->compareTo($b)); + Assert::equals(1, $a->compareTo($c)); + Assert::equals(-1, $c->compareTo($a)); + } + + #[Test, Values([[[], '(object)[]'], [['key' => 'value'], '(object)[key => "value"]']])] + public function string_representation($backing, $expected) { + Assert::equals($expected, (new JsonObject($backing))->toString()); + } +} \ No newline at end of file From 42cc75b97e028e6e33fa31eeddf1807fae682d67 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 3 May 2025 11:45:22 +0200 Subject: [PATCH 4/7] Test null-colaescing values to prevent exceptions --- src/main/php/text/json/JsonObject.class.php | 2 +- src/test/php/text/json/unittest/JsonObjectTest.class.php | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/php/text/json/JsonObject.class.php b/src/main/php/text/json/JsonObject.class.php index 6383186..bf73b39 100755 --- a/src/main/php/text/json/JsonObject.class.php +++ b/src/main/php/text/json/JsonObject.class.php @@ -23,7 +23,7 @@ public function offsetGet($key) { return $this->backing[$key]; } public function offsetSet($key, $value) { $this->backing[$key]= $value; } #[ReturnTypeWillChange] - public function offsetExists($key) { return array_key_exists($key, $this->backing); } + public function offsetExists($key) { return isset($this->backing[$key]); } #[ReturnTypeWillChange] public function offsetUnset($key) { unset($this->backing[$key]); } diff --git a/src/test/php/text/json/unittest/JsonObjectTest.class.php b/src/test/php/text/json/unittest/JsonObjectTest.class.php index 8dae95c..cf4efc6 100755 --- a/src/test/php/text/json/unittest/JsonObjectTest.class.php +++ b/src/test/php/text/json/unittest/JsonObjectTest.class.php @@ -21,6 +21,11 @@ public function get() { Assert::equals('value', (new JsonObject(['key' => 'value']))['key']); } + #[Test] + public function null_coalesce() { + Assert::equals('default', (new JsonObject())['key'] ?? 'default'); + } + #[Test] public function set() { $fixture= new JsonObject(['key' => 'value']); @@ -31,9 +36,10 @@ public function set() { #[Test] public function isset() { - $fixture= new JsonObject(['key' => 'value']); + $fixture= new JsonObject(['key' => 'value', 'price' => null]); Assert::true(isset($fixture['key'])); + Assert::false(isset($fixture['price'])); Assert::false(isset($fixture['color'])); } From d6ac07f29158c9e084c906bd91d94a2970b1de58 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 3 May 2025 11:47:19 +0200 Subject: [PATCH 5/7] Fix PHP 7 parser vagary --- src/test/php/text/json/unittest/JsonObjectTest.class.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/php/text/json/unittest/JsonObjectTest.class.php b/src/test/php/text/json/unittest/JsonObjectTest.class.php index cf4efc6..296a951 100755 --- a/src/test/php/text/json/unittest/JsonObjectTest.class.php +++ b/src/test/php/text/json/unittest/JsonObjectTest.class.php @@ -23,7 +23,8 @@ public function get() { #[Test] public function null_coalesce() { - Assert::equals('default', (new JsonObject())['key'] ?? 'default'); + $fixture= new JsonObject(); + Assert::equals('default', $fixture['key'] ?? 'default'); } #[Test] From 1a0491ceb62c8e5cd633cd17f9566ed0327a4659 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 3 May 2025 12:34:06 +0200 Subject: [PATCH 6/7] Add write_json_object() test --- src/test/php/text/json/unittest/JsonOutputTest.class.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/test/php/text/json/unittest/JsonOutputTest.class.php b/src/test/php/text/json/unittest/JsonOutputTest.class.php index e1e2cde..70d49c1 100755 --- a/src/test/php/text/json/unittest/JsonOutputTest.class.php +++ b/src/test/php/text/json/unittest/JsonOutputTest.class.php @@ -117,10 +117,15 @@ public function write_empty_object() { } #[Test] - public function write_json_object() { + public function write_empty_json_object() { Assert::equals('{}', $this->write(new JsonObject())); } + #[Test] + public function write_json_object() { + Assert::equals('{"key":"value"}', $this->write(new JsonObject(['key' => 'value']))); + } + #[Test] public function write_array_as_object() { Assert::equals('{"0":1,"1":2,"2":3}', $this->write((object)[1, 2, 3])); From 16213b3df53c72f51a05e8e374bcf82db7f412fd Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 3 May 2025 12:41:09 +0200 Subject: [PATCH 7/7] QA: WS --- src/main/php/text/json/Input.class.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/php/text/json/Input.class.php b/src/main/php/text/json/Input.class.php index d85e638..d5df7d8 100755 --- a/src/main/php/text/json/Input.class.php +++ b/src/main/php/text/json/Input.class.php @@ -94,10 +94,11 @@ protected function readObject($nesting) { if ('}' === $token) { return new JsonObject(); } else if (null !== $token) { - $result= []; if (++$nesting > $this->maximumNesting) { throw new FormatException('Nesting level too deep'); } + + $result= []; do { $key= $this->valueOf($token, $nesting); if (!is_string($key)) {