diff --git a/README.md b/README.md index 81097e5..d37ac94 100755 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Address [![Supports PHP 8.0+](https://raw.githubusercontent.com/xp-framework/web/master/static/php-8_0plus.svg)](http://php.net/) [![Latest Stable Version](https://poser.pugx.org/xp-forge/address/version.png)](https://packagist.org/packages/xp-forge/address) -Creates objects from XML input streams while parsing them. Yes, this still happens today 😉 +Creates objects from JSON or XML input streams while parsing them. Yes, XML still happens today 😉 Example ------- diff --git a/src/main/php/util/address/Address.class.php b/src/main/php/util/address/Address.class.php index 242203c..9bf13db 100755 --- a/src/main/php/util/address/Address.class.php +++ b/src/main/php/util/address/Address.class.php @@ -1,7 +1,5 @@ stream; } * Creates an iterator. Default implementation is to return an * `XmlIterator` instance for BC reasons. */ - public function iterator(): Iterator { return new XmlIterator($this->stream()); } + public function iterator(): StreamIterator { return new XmlIterator($this->stream()); } } \ No newline at end of file diff --git a/src/main/php/util/address/ByAddresses.class.php b/src/main/php/util/address/ByAddresses.class.php index 02209e5..fe986ed 100755 --- a/src/main/php/util/address/ByAddresses.class.php +++ b/src/main/php/util/address/ByAddresses.class.php @@ -72,7 +72,7 @@ protected function next($iteration, $result) { // Select attributes and children while (null !== ($path= $iteration->path()) && 0 === strncmp($path, $base, $offset)) { - $relative= substr($iteration->path(), $offset); + $relative= strtr(substr($iteration->path(), $offset), "\x1D", '/'); if ('@' === $relative[0]) { $address= $this->addresses[$relative] ?? $this->addresses['@*'] ?? null; $relative= substr($relative, 1); diff --git a/src/main/php/util/address/Iteration.class.php b/src/main/php/util/address/Iteration.class.php index 2967e9b..80a6157 100755 --- a/src/main/php/util/address/Iteration.class.php +++ b/src/main/php/util/address/Iteration.class.php @@ -7,14 +7,14 @@ class Iteration { /** * Creates an iteration * - * @param util.address.XmlIterator $it + * @param util.address.StreamIterator $it * @param string $base */ public function __construct($it) { $this->it= $it; } - /** @return util.address.XmlIterator */ + /** @return util.address.StreamIterator */ public function iterator() { return $this->it; } /** @return bool */ diff --git a/src/main/php/util/address/JsonIterator.class.php b/src/main/php/util/address/JsonIterator.class.php new file mode 100755 index 0000000..3de585c --- /dev/null +++ b/src/main/php/util/address/JsonIterator.class.php @@ -0,0 +1,131 @@ +input->nextToken($delimiters ?? $this->input->delimiters); + } while (null !== $token && 0 === strcspn($token, "\r\n\t ")); + return $token; + } + + /** + * Reads a string, handling escape sequences and unclosed strings + * + * @return string + * @throws lang.FormatException + */ + protected function string() { + $s= '"'; + do { + $chunk= $this->input->nextToken('\\"'); + if (null === $chunk) { + throw new FormatException('Unclosed string literal'); + } else if ('\\' === $chunk) { + $s.= $chunk.$this->input->nextToken('\\"'); + } else { + $s.= $chunk; + } + } while ('"' !== $chunk); + + // Optimize empty string case + return '""' === $s ? '' : json_decode($s); + } + + /** + * Yields values based on a given token, destructuring lists and maps + * into their components. + * + * @param string $token + * @return iterable + */ + protected function iterate($token) { + if ('{' === $token) { + yield $this->path => null; + + $next= $this->token(); + while ('}' !== $next && $this->input->hasMoreTokens()) { + $this->path.= self::SEPARATOR.strtr($this->string(), self::SEPARATOR, "\x1D"); + $this->token(':'); + + foreach ($this->iterate($this->token()) as $value) { + yield $this->path => $value; + } + + $this->path= substr($this->path, 0, strrpos($this->path, self::SEPARATOR)); + if (',' === ($next= $this->token(',}'))) { + $next= $this->token(); + } + } + } else if ('[' === $token) { + yield $this->path => null; + + $next= $this->token(); + $i= 0; + while (']' !== $next && $this->input->hasMoreTokens()) { + $this->path.= self::SEPARATOR.'[]'; + + foreach ($this->iterate($next) as $value) { + yield $this->path => $value; + } + + $this->path= substr($this->path, 0, strrpos($this->path, self::SEPARATOR)); + if (',' === ($next= $this->token(',]'))) { + $next= $this->token(); + } + } + } else if ('"' === $token) { + yield $this->string(); + } else if ('null' === $token) { + yield null; + } else if ('false' === $token) { + yield false; + } else if ('true' === $token) { + yield true; + } else if (0 === strcspn($token, '.0123456789eE+-')) { + yield strlen($token) === strcspn($token, '.eE') ? (int)$token : (float)$token; + } else { + throw new FormatException('Unexpected token `'.$token.'`'); + } + } + + /** @return ?util.address.Token */ + protected function nextToken() { + if ($this->tokens) return array_shift($this->tokens); + + if (null === $this->it) { + $this->path= self::SEPARATOR; + $this->it= $this->iterate($this->token()); + } else { + $this->it->next(); + } + + return $this->it->valid() ? new Token($this->it->key(), $this->it->current()) : null; + } +} \ No newline at end of file diff --git a/src/main/php/util/address/JsonStreaming.class.php b/src/main/php/util/address/JsonStreaming.class.php new file mode 100755 index 0000000..eac3fb5 --- /dev/null +++ b/src/main/php/util/address/JsonStreaming.class.php @@ -0,0 +1,14 @@ +stream); } + +} \ No newline at end of file diff --git a/src/main/php/util/address/StreamIterator.class.php b/src/main/php/util/address/StreamIterator.class.php new file mode 100755 index 0000000..7e5aea9 --- /dev/null +++ b/src/main/php/util/address/StreamIterator.class.php @@ -0,0 +1,90 @@ +input= $input; + } + + /** @return ?util.address.Token */ + protected abstract function nextToken(); + + /** + * Creates value from definition. + * + * @param util.address.Definition $definition + * @param bool $source + * @return var + */ + public function value($definition, $source) { + if (null === $this->token->source) { + $token= $this->token; + + // Create value, storing tokens during the iteration + $iteration= new Iteration($this); + $value= $definition->create($iteration); + + // Unless we are at the end of the stream, push back last token. + $this->token && array_unshift($this->tokens, $this->token); + $this->token= $source ? $token->from($iteration->tokens) : $token; + return $value; + } else { + + // Restore tokens consumed by previous iteration + $this->tokens= array_merge($this->token->source, [$this->token], $this->tokens); + $this->token= array_shift($this->tokens); + return $definition->create(new Iteration($this)); + } + } + + /** @return void */ + #[ReturnTypeWillChange] + public function rewind() { + if (null !== $this->path) { + $this->input->reset(); + } + + $this->path= ''; + $this->token= $this->nextToken(); + } + + /** @return string */ + #[ReturnTypeWillChange] + public function current() { + return $this->token->content; + } + + /** @return string */ + #[ReturnTypeWillChange] + public function key() { + return $this->token->path; + } + + /** @return void */ + #[ReturnTypeWillChange] + public function next() { + $this->token= $this->nextToken(); + } + + /** @return bool */ + #[ReturnTypeWillChange] + public function valid() { + return null !== $this->token; + } +} \ No newline at end of file diff --git a/src/main/php/util/address/Streaming.class.php b/src/main/php/util/address/Streaming.class.php index dc59b89..f58fd4d 100755 --- a/src/main/php/util/address/Streaming.class.php +++ b/src/main/php/util/address/Streaming.class.php @@ -1,6 +1,6 @@ path().'/'; + $offset= strlen($base); + $value= $iteration->next(); + + while (null !== ($path= $iteration->path()) && 0 === strncmp($path, $base, $offset)) { + if (0 === substr_compare($path, '/[]', -3, 3)) { + $segments= substr($path, $offset, -3); + $array= true; + } else { + $segments= substr($path, $offset); + $array= false; + } + + $ptr= &$value; + if (strlen($segments) > 0) { + foreach (explode('/', $segments) as $segment) { + $ptr= &$ptr[strtr($segment, "\x1D", '/')]; + } + } + + if ($array) { + $ptr[]= $this->create($iteration); + } else { + $ptr= $iteration->next(); + } + } + return $value; + } +} \ No newline at end of file diff --git a/src/main/php/util/address/XmlIterator.class.php b/src/main/php/util/address/XmlIterator.class.php index fdc04b9..583eaa4 100755 --- a/src/main/php/util/address/XmlIterator.class.php +++ b/src/main/php/util/address/XmlIterator.class.php @@ -10,14 +10,10 @@ * * @test xp://util.address.unittest.XmlIteratorTest */ -class XmlIterator implements Iterator { - const SEPARATOR= '/'; - - private $input, $path, $valid, $node; +class XmlIterator extends StreamIterator { + private $valid, $node; private $encoding= 'utf-8'; - private $tokens= []; private $entities= ['amp' => '&', 'apos' => "'", 'quot' => '"', 'gt' => '>', 'lt' => '<']; - public $token; /** * Creates a new XML iterator on a given stream @@ -26,7 +22,6 @@ class XmlIterator implements Iterator { */ public function __construct(InputStream $input) { $this->input= new StreamTokenizer($input, '<>', true); - $this->path= null; } /** @@ -222,8 +217,8 @@ protected function attributesIn($string) { return $attributes; } - /** @return util.address.Token */ - protected function token() { + /** @return ?util.address.Token */ + protected function nextToken() { if (empty($this->tokens)) { $this->valid= false; while (null !== ($token= $this->input->nextToken())) { @@ -269,68 +264,4 @@ protected function token() { // echo "<<< ", $token ? "token<{$token->path}= {$token->content}>" : "(null)", "\n"; return $token; } - - /** - * Creates value from definition. - * - * @param util.address.Definition $definition - * @param bool $source - * @return var - */ - public function value($definition, $source) { - if (null === $this->token->source) { - $token= $this->token; - - // Create value, storing tokens during the iteration - $iteration= new Iteration($this); - $value= $definition->create($iteration); - - // Unless we are at the end of the stream, push back last token. - $this->valid= true; - $this->token && array_unshift($this->tokens, $this->token); - $this->token= $source ? $token->from($iteration->tokens) : $token; - return $value; - } else { - - // Restore tokens consumed by previous iteration - $this->tokens= array_merge($this->token->source, [$this->token], $this->tokens); - $this->token= array_shift($this->tokens); - return $definition->create(new Iteration($this)); - } - } - - /** @return void */ - #[ReturnTypeWillChange] - public function rewind() { - if (null !== $this->path) { - $this->input->reset(); - } - - $this->path= ''; - $this->token= $this->token(); - } - - /** @return string */ - #[ReturnTypeWillChange] - public function current() { - return $this->token->content; - } - - /** @return string */ - #[ReturnTypeWillChange] - public function key() { - return $this->token->path; - } - - /** @return void */ - #[ReturnTypeWillChange] - public function next() { - $this->token= $this->token(); - } - - /** @return bool */ - #[ReturnTypeWillChange] - public function valid() { - return $this->valid; - } } \ No newline at end of file diff --git a/src/main/php/util/address/XmlStreaming.class.php b/src/main/php/util/address/XmlStreaming.class.php index 21565d0..4f23496 100755 --- a/src/main/php/util/address/XmlStreaming.class.php +++ b/src/main/php/util/address/XmlStreaming.class.php @@ -1,7 +1,5 @@ stream); } + public function iterator(): StreamIterator { return new XmlIterator($this->stream); } } \ No newline at end of file diff --git a/src/test/php/util/address/unittest/Composer.class.php b/src/test/php/util/address/unittest/Composer.class.php new file mode 100755 index 0000000..abf3761 --- /dev/null +++ b/src/test/php/util/address/unittest/Composer.class.php @@ -0,0 +1,31 @@ +name= $name; + $this->type= $type; + $this->keywords= $keywords; + $this->requirements= $requirements; + } + + /** @return string */ + public function hashCode() { return 'C'.Objects::hashOf((array)$this); } + + /** @return string */ + public function toString() { return nameof($this).'@'.Objects::stringOf(get_object_vars($this)); } + + /** + * Compares this + * + * @param var $value + * @return int + */ + public function compareTo($value) { + return $value instanceof self ? Objects::compare((array)$this, (array)$value) : 1; + } +} \ No newline at end of file diff --git a/src/test/php/util/address/unittest/JsonInputTest.class.php b/src/test/php/util/address/unittest/JsonInputTest.class.php new file mode 100755 index 0000000..a09540b --- /dev/null +++ b/src/test/php/util/address/unittest/JsonInputTest.class.php @@ -0,0 +1,37 @@ +getPackage(); + return [ + [new JsonStreaming($package->getResource('composer.json'))], + [new JsonStreaming($package->getResourceAsStream('composer.json')->in())], + [new JsonStreaming($package->getResourceAsStream('composer.json'))] + ]; + } + + #[Test, Values(from: 'inputs')] + public function feed($input) { + $composer= $input->next(new ObjectOf(Composer::class, [ + 'name' => function($self) { $self->name= yield; }, + 'type' => function($self) { $self->type= yield; }, + 'keywords/[]' => function($self) { $self->keywords[]= yield; }, + 'require' => function($self) { $self->requirements= yield new StructureOf(); }, + ])); + + Assert::equals( + new Composer('xp-forge/address', 'library', ['module', 'xp'], [ + 'xp-framework/core' => '^11.0 | ^10.0', + 'xp-framework/reflection' => '^2.0 | ^1.9', + 'xp-framework/tokenize' => '^9.0 | ^8.1', + 'php' => '>=7.0.0', + ]), + $composer + ); + } +} \ No newline at end of file diff --git a/src/test/php/util/address/unittest/JsonIteratorTest.class.php b/src/test/php/util/address/unittest/JsonIteratorTest.class.php new file mode 100755 index 0000000..b1cf55a --- /dev/null +++ b/src/test/php/util/address/unittest/JsonIteratorTest.class.php @@ -0,0 +1,112 @@ +assertIterated([[$expected]], new JsonIterator(new MemoryInputStream($input))); + } + + #[Test] + public function empty_map() { + $this->assertIterated( + [['/' => null]], + new JsonIterator(new MemoryInputStream('{}')) + ); + } + + #[Test] + public function single_pair() { + $this->assertIterated( + [['/' => null], ['//test' => 'Test']], + new JsonIterator(new MemoryInputStream('{"test":"Test"}')) + ); + } + + #[Test, Values(['{"color":"Green","price":12.99}', '{"color": "Green", "price": 12.99}'])] + public function two_pairs($input) { + $this->assertIterated( + [['/' => null], ['//color' => 'Green'], ['//price' => 12.99]], + new JsonIterator(new MemoryInputStream($input)) + ); + } + + #[Test, Values(from: 'scalars')] + public function map_with_scalar($input, $expected) { + $this->assertIterated( + [['/' => null], ['//value' => $expected], ['//ok' => true]], + new JsonIterator(new MemoryInputStream('{"value":'.$input.',"ok":true}')) + ); + } + + #[Test] + public function empty_list() { + $this->assertIterated( + [['/' => null]], + new JsonIterator(new MemoryInputStream('[]')) + ); + } + + #[Test] + public function single_element() { + $this->assertIterated( + [['/' => null], ['//[]' => 'Test']], + new JsonIterator(new MemoryInputStream('["Test"]')) + ); + } + + #[Test, Values(['["Color","Price"]', '["Color", "Price"]'])] + public function two_elements($input) { + $this->assertIterated( + [['/' => null], ['//[]' => 'Color'], ['//[]' => 'Price']], + new JsonIterator(new MemoryInputStream($input)) + ); + } + + #[Test] + public function map_containing_list() { + $this->assertIterated( + [['/' => null], ['//items' => null], ['//items/[]' => 'One'], ['//items/[]' => 'Two']], + new JsonIterator(new MemoryInputStream('{"items":["One","Two"]}')) + ); + } +} \ No newline at end of file diff --git a/src/test/php/util/address/unittest/StreamIteratorTest.class.php b/src/test/php/util/address/unittest/StreamIteratorTest.class.php new file mode 100755 index 0000000..33778a9 --- /dev/null +++ b/src/test/php/util/address/unittest/StreamIteratorTest.class.php @@ -0,0 +1,21 @@ + $value) { + $actual[]= [$key => $value]; + } + Assert::equals($expected, $actual); + } +} \ No newline at end of file diff --git a/src/test/php/util/address/unittest/StructureOfTest.class.php b/src/test/php/util/address/unittest/StructureOfTest.class.php new file mode 100755 index 0000000..8f7bf01 --- /dev/null +++ b/src/test/php/util/address/unittest/StructureOfTest.class.php @@ -0,0 +1,52 @@ +next(new StructureOf())); + } + + #[Test] + public function list() { + $address= new JsonStreaming('["red","green","blue"]"'); + Assert::equals( + ['red', 'green', 'blue'], + $address->next(new StructureOf()) + ); + } + + #[Test] + public function object() { + $address= new JsonStreaming('{"name":"Test","ok":true,"undefined":null}"'); + Assert::equals( + ['name' => 'Test', 'ok' => true, 'undefined' => null], + $address->next(new StructureOf()) + ); + } + + #[Test] + public function object_containing_list() { + $address= new JsonStreaming('{"colors":["red","green","blue"]}'); + Assert::equals( + ['colors' => ['red', 'green', 'blue']], + $address->next(new StructureOf()) + ); + } + + #[Test] + public function containing_list_of_objects() { + $address= new JsonStreaming('{"colors":[{"id":"green","component":"G"},{"id":"red","component":"R"}]}'); + Assert::equals( + ['colors' => [ + ['id' => 'green', 'component' => 'G'], + ['id' => 'red', 'component' => 'R'], + ]], + $address->next(new StructureOf()) + ); + } +} \ No newline at end of file diff --git a/src/test/php/util/address/unittest/UsingNextTest.class.php b/src/test/php/util/address/unittest/UsingNextTest.class.php index 8f04cce..428019c 100755 --- a/src/test/php/util/address/unittest/UsingNextTest.class.php +++ b/src/test/php/util/address/unittest/UsingNextTest.class.php @@ -4,7 +4,7 @@ use test\{Action, Assert, Test}; use util\address\{ValueOf, XmlStreaming}; -/** @deprecated */ +/** @deprecated Use `fn($self[, $path]) => ... yield;` instead */ class UsingNextTest { const BOOK = 'Name1977-12-14'; diff --git a/src/test/php/util/address/unittest/XmlIteratorTest.class.php b/src/test/php/util/address/unittest/XmlIteratorTest.class.php index ef1fd24..1e413fd 100755 --- a/src/test/php/util/address/unittest/XmlIteratorTest.class.php +++ b/src/test/php/util/address/unittest/XmlIteratorTest.class.php @@ -5,21 +5,7 @@ use test\{Assert, Expect, Test, Values}; use util\address\XmlIterator; -class XmlIteratorTest { - - /** - * Assert iteration result - * - * @param [:var][] $expected - * @param util.data.XmlIterator $fixture - */ - protected function assertIterated($expected, XmlIterator $fixture) { - $actual= []; - foreach ($fixture as $key => $value) { - $actual[]= [$key => $value]; - } - Assert::equals($expected, $actual); - } +class XmlIteratorTest extends StreamIteratorTest { #[Test] public function can_create() { diff --git a/src/test/resources/util/address/unittest/composer.json b/src/test/resources/util/address/unittest/composer.json new file mode 100755 index 0000000..94209f0 --- /dev/null +++ b/src/test/resources/util/address/unittest/composer.json @@ -0,0 +1,20 @@ +{ + "name" : "xp-forge/address", + "type" : "library", + "homepage" : "http://xp-framework.net/", + "license" : "BSD-3-Clause", + "description" : "Creates objects from XML input streams while parsing them.", + "keywords": ["module", "xp"], + "require" : { + "xp-framework/core": "^11.0 | ^10.0", + "xp-framework/reflection": "^2.0 | ^1.9", + "xp-framework/tokenize": "^9.0 | ^8.1", + "php" : ">=7.0.0" + }, + "require-dev" : { + "xp-framework/test": "^1.0" + }, + "autoload" : { + "files" : ["src/main/php/autoload.php"] + } +}