diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d4b9e70 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +; top-most EditorConfig file +root = true + +; Unix-style newlines +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..09c43ea --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +src/Tests export-ignore +.editorconfig export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.scrutinizer.yml export-ignore +.travis.yml export-ignore +CONTRIBUTING.md export-ignore +CONDUCT.md export-ignore +phpunit.xml.ci export-ignore +phpunit.xml.dist export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e45d856 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +build/ +vendor/ +composer.lock +phpunit.xml diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..6eeb828 --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,15 @@ +filter: + paths: [src/*] + excluded_paths: [src/Tests/*] + +checks: + php: + code_rating: true + duplication: true + +tools: + external_code_coverage: + timeout: 600 + php_code_sniffer: + config: + standard: "PSR2" diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..5e6b3b0 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,21 @@ +language: php + +cache: + directories: + - $HOME/.composer/cache + +php: + - 7.0 + +before_install: + - travis_retry composer self-update + +install: + - travis_retry composer update --no-interaction + +script: + - composer test-ci + +after_success: + - wget https://scrutinizer-ci.com/ocular.phar + - php ocular.phar code-coverage:upload --format=php-clover build/logs/clover.xml diff --git a/CONDUCT.md b/CONDUCT.md new file mode 100644 index 0000000..cf33a53 --- /dev/null +++ b/CONDUCT.md @@ -0,0 +1,51 @@ +# Contributor Code of Conduct + +As contributors and maintainers of this project, and in the interest of +fostering an open and welcoming community, we pledge to respect all people who +contribute through reporting issues, posting feature requests, updating +documentation, submitting pull requests or patches, and other activities. + +We are committed to making participation in this project a harassment-free +experience for everyone, regardless of level of experience, gender, gender +identity and expression, sexual orientation, disability, personal appearance, +body size, race, ethnicity, age, religion, or nationality. + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery +* Personal attacks +* Trolling or insulting/derogatory comments +* Public or private harassment +* Publishing other's private information, such as physical or electronic + addresses, without explicit permission +* Other unethical or unprofessional conduct + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +By adopting this Code of Conduct, project maintainers commit themselves to +fairly and consistently applying these principles to every aspect of managing +this project. Project maintainers who do not follow or enforce the Code of +Conduct may be permanently removed from the project team. + +This code of conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting a project maintainer at [Aurimas Niekis][email]. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. Maintainers are +obligated to maintain confidentiality with regard to the reporter of an +incident. + + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 1.3.0, available at +[http://contributor-covenant.org/version/1/3/0/][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/3/0/ +[email]: mailto:aurimas@niekis.lt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3447245 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,93 @@ +# Contributing + +If you're here, you would like to contribute to this repository and you're really welcome! + + +## Bug reports + +If you find a bug or a documentation issue, please report it or even better: fix it :). If you report it, +please be as precise as possible. Here is a little list of required information: + + - Precise description of the bug + - Details of your environment (for example: OS, PHP version, installed extensions) + - Backtrace which might help identifing the bug + + +## Feature requests + +If you think a feature is missing, please report it or even better: implement it :). If you report it, describe the more +precisely what you would like to see implemented and we will discuss what is the best approach for it. If you can do +some research before submitting it and link the resources to your description, you're awesome! It will allow us to more +easily understood/implement it. + + +## Sending a Pull Request + +If you're here, you are going to fix a bug or implement a feature and you're the best! +To do it, first fork the repository, clone it and create a new branch with the following commands: + +``` bash +$ git clone git@github.com:your-name/xml-iterator.git +$ git checkout -b feature-or-bug-fix-description +``` + +Then install the dependencies through [Composer](https://getcomposer.org/): + +``` bash +$ composer install +``` + +Write code and tests. When you are ready, run the tests. +(This is usually [PHPUnit](http://phpunit.de/)) + +``` bash +$ composer test +``` + +When you are ready with the code, tested it and documented it, you can commit and push it with the following commands: + +``` bash +$ git commit -m "Feature or bug fix description" +$ git push origin feature-or-bug-fix-description +``` + +**Note:** Please write your commit messages in the imperative and follow the +[guidelines](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) for clear and concise messages. + +Then [create a pull request](https://help.github.com/articles/creating-a-pull-request/) on GitHub. + +Please make sure that each individual commit in your pull request is meaningful. +If you had to make multiple intermediate commits while developing, +please squash them before submitting with the following commands +(here, we assume you would like to squash 3 commits in a single one): + +``` bash +$ git rebase -i HEAD~3 +``` + +If your branch conflicts with the master branch, you will need to rebase and repush it with the following commands: + +``` bash +$ git remote add upstream git@github.com:ThrusterIO/xml-iterator.git +$ git pull --rebase upstream master +$ git push -f origin feature-or-bug-fix-description +``` + + +## Coding standard + +This repository follows the [PSR-2 standard](http://www.php-fig.org/psr/psr-2/) and so, if you want to contribute, +you must follow these rules. + + +## Semver + +We are trying to follow [semver](http://semver.org/). When you are making BC breaking changes, +please let us know why you think it is important. +In this case, your patch can only be included in the next major version. + + +## Code of Conduct + +This project is released with a [Contributor Code of Conduct](CONDUCT.md). +By participating in this project you agree to abide by its terms. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a925760 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2016 Aurimas Niekis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..34e3698 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# XMLIterator Component + +[![Latest Version](https://img.shields.io/github/release/ThrusterIO/xml-iterator.svg?style=flat-square)] +(https://github.com/ThrusterIO/xml-iterator/releases) +[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)] +(LICENSE) +[![Build Status](https://img.shields.io/travis/ThrusterIO/xml-iterator.svg?style=flat-square)] +(https://travis-ci.org/ThrusterIO/xml-iterator) +[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/ThrusterIO/xml-iterator.svg?style=flat-square)] +(https://scrutinizer-ci.com/g/ThrusterIO/xml-iterator) +[![Quality Score](https://img.shields.io/scrutinizer/g/ThrusterIO/xml-iterator.svg?style=flat-square)] +(https://scrutinizer-ci.com/g/ThrusterIO/xml-iterator) +[![Total Downloads](https://img.shields.io/packagist/dt/thruster/xml-iterator.svg?style=flat-square)] +(https://packagist.org/packages/thruster/xml-iterator) + +[![Email](https://img.shields.io/badge/email-team@thruster.io-blue.svg?style=flat-square)] +(mailto:team@thruster.io) + +The Thruster XMLIterator Component fork from original library [XMLReaderIterator](https://github.com/hakre/XMLReaderIterator) + + +## Install + +Via Composer + +```bash +$ composer require thruster/xml-iterator +``` + +## Testing + +```bash +$ composer test +``` + + +## Contributing + +Please see [CONTRIBUTING](CONTRIBUTING.md) and [CONDUCT](CONDUCT.md) for details. + + +## License + +Please see [License File](LICENSE) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..7643753 --- /dev/null +++ b/composer.json @@ -0,0 +1,33 @@ +{ + "name": "thruster/xml-iterator", + "type": "library", + "description": "Thruster XMLIterator Component", + "keywords": ["xml-iterator", "thruster", "xmlreader"], + "homepage": "https://thruster.io", + "license": "MIT", + "authors": [ + { + "name": "Aurimas Niekis", + "email": "aurimas@niekis.lt" + } + ], + "require": { + "php": ">=7.0", + "ext-xmlreader": "*" + }, + "require-dev": { + "phpunit/phpunit": "~5.1" + }, + "autoload": { + "psr-4": { "Thruster\\Component\\XMLIterator\\": "src" } + }, + "scripts": { + "test": "vendor/bin/phpunit", + "test-ci": "vendor/bin/phpunit -c phpunit.xml.ci" + }, + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + } +} diff --git a/phpunit.xml.ci b/phpunit.xml.ci new file mode 100644 index 0000000..4c5d9c4 --- /dev/null +++ b/phpunit.xml.ci @@ -0,0 +1,34 @@ + + + + + + + + + + ./src/Tests/ + + + + + + ./src + + ./src/Tests + + + + + + + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..838a69a --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,27 @@ + + + + + + + + + + ./src/Tests/ + + + + + + ./src + + ./src/Tests + + + + diff --git a/src/AttributeFilter.php b/src/AttributeFilter.php new file mode 100644 index 0000000..eb400c1 --- /dev/null +++ b/src/AttributeFilter.php @@ -0,0 +1,52 @@ + + */ +class AttributeFilter extends AttributeFilterBase +{ + private $compare; + + /** + * @var bool + */ + private $invert; + + /** + * @param ElementIterator $elements + * @param string $attr name of the attribute, '*' for every attribute + * @param string|array $compare value(s) to compare against + * @param bool $invert + */ + public function __construct(ElementIterator $elements, $attr, $compare, bool $invert = false) + { + parent::__construct($elements, $attr); + + $this->compare = (array) $compare; + $this->invert = $invert; + } + + public function accept() + { + $result = $this->search($this->getAttributeValues(), $this->compare); + + return $this->invert ? !$result : $result; + } + + private function search($values, $compares) + { + foreach ($compares as $compare) { + if (in_array($compare, $values)) { + return true; + } + } + + return false; + } +} + diff --git a/src/AttributeFilterBase.php b/src/AttributeFilterBase.php new file mode 100644 index 0000000..e605cf9 --- /dev/null +++ b/src/AttributeFilterBase.php @@ -0,0 +1,38 @@ + + */ +abstract class AttributeFilterBase extends FilterBase +{ + private $attr; + + /** + * @param ElementIterator $elements + * @param string $attr name of the attribute, '*' for every attribute + */ + public function __construct(ElementIterator $elements, $attr) + { + parent::__construct($elements); + + $this->attr = $attr; + } + + protected function getAttributeValues() + { + $node = parent::current(); + + if ('*' === $this->attr) { + $attributes = $node->getAttributes()->getArrayCopy(); + } else { + $attributes = (array) $node->getAttribute($this->attr); + } + + return $attributes; + } +} diff --git a/src/AttributeIterator.php b/src/AttributeIterator.php new file mode 100644 index 0000000..ba9b4c5 --- /dev/null +++ b/src/AttributeIterator.php @@ -0,0 +1,117 @@ + + */ +class AttributeIterator implements IteratorInterface, Countable, ArrayAccess +{ + /** + * @var XMLReader + */ + private $reader; + + /** + * @var bool + */ + private $valid; + + /** + * @var array + */ + private $array; + + public function __construct(XMLReader $reader) + { + $this->reader = $reader; + } + + public function count() + { + return $this->reader->attributeCount; + } + + public function current() + { + return $this->reader->value; + } + + public function key() + { + return $this->reader->name; + } + + public function next() + { + $this->valid = $this->reader->moveToNextAttribute(); + + if (!$this->valid) { + $this->reader->moveToElement(); + } + } + + public function rewind() + { + $this->valid = $this->reader->moveToFirstAttribute(); + } + + public function valid() + { + return $this->valid; + } + + public function getArrayCopy() + { + if ($this->array === null) { + $this->array = iterator_to_array($this); + } + + return $this->array; + } + + public function getAttributeNames() + { + return array_keys($this->getArrayCopy()); + } + + public function offsetExists($offset) + { + $attributes = $this->getArrayCopy(); + + return isset($attributes[$offset]); + } + + public function offsetGet($offset) + { + $attributes = $this->getArrayCopy(); + + return $attributes[$offset]; + } + + public function offsetSet($offset, $value) + { + throw new BadMethodCallException('XMLReader attributes are read-only'); + } + + public function offsetUnset($offset) + { + throw new BadMethodCallException('XMLReader attributes are read-only'); + } + + /** + * @return XMLReader + */ + public function getReader() + { + return $this->getReader(); + } +} diff --git a/src/AttributePreg.php b/src/AttributePreg.php new file mode 100644 index 0000000..5ae1dd9 --- /dev/null +++ b/src/AttributePreg.php @@ -0,0 +1,49 @@ + + */ +class AttributePreg extends AttributeFilterBase +{ + /** + * @var string + */ + private $pattern; + + /** + * @var bool + */ + private $invert; + + /** + * @param ElementIterator $elements + * @param string $attr name of the attribute, '*' for every attribute + * @param string $pattern pcre based regex pattern for the attribute value + * @param bool $invert + * + * @throws InvalidArgumentException + */ + public function __construct(ElementIterator $elements, string $attr, string $pattern, bool $invert = false) + { + parent::__construct($elements, $attr); + + if (false === preg_match("$pattern", '')) { + throw new InvalidArgumentException("Invalid pcre pattern '$pattern'."); + } + + $this->pattern = $pattern; + $this->invert = $invert; + } + + public function accept() + { + return (bool) preg_grep($this->pattern, $this->getAttributeValues(), $this->invert ? PREG_GREP_INVERT : 0); + } +} diff --git a/src/ChildElementIterator.php b/src/ChildElementIterator.php new file mode 100644 index 0000000..574a567 --- /dev/null +++ b/src/ChildElementIterator.php @@ -0,0 +1,109 @@ + + */ +class ChildElementIterator extends ElementIterator +{ + /** + * @var int + */ + private $stopDepth; + + /** + * @var bool + */ + private $descendTree; + + /** + * @var bool + */ + private $didRewind; + + /** + * @var int + */ + private $index; + + /** + * @inheritdoc + * + * @param bool $descendantAxis traverse children of children + */ + public function __construct(XMLReader $reader, $name = null, bool $descendantAxis = false) + { + parent::__construct($reader, $name); + + $this->descendTree = $descendantAxis; + } + + /** + * @throws UnexpectedValueException + * @return void + */ + public function rewind() + { + // this iterator can not really rewind. instead it places itself onto the + // first children. + if ($this->reader->nodeType === XMLReader::NONE) { + $this->moveToNextElement(); + } + + if ($this->stopDepth === null) { + $this->stopDepth = $this->reader->depth; + } + + // move to first child - if any + parent::next(); + parent::rewind(); + + $this->index = 0; + $this->didRewind = true; + } + + public function next() + { + if ($this->valid()) { + $this->index++; + } + + while ($this->valid()) { + parent::next(); + + if ($this->descendTree || $this->reader->depth === $this->stopDepth + 1) { + break; + } + }; + } + + public function valid() + { + if (!($valid = parent::valid())) { + return $valid; + } + + return $this->reader->depth > $this->stopDepth; + } + + /** + * @return Node + */ + public function current() + { + $this->didRewind || self::rewind(); + + return parent::current(); + } + + public function key() + { + return $this->index; + } +} diff --git a/src/ChildIterator.php b/src/ChildIterator.php new file mode 100644 index 0000000..d41d4cb --- /dev/null +++ b/src/ChildIterator.php @@ -0,0 +1,37 @@ + + */ +class ChildIterator extends XMLIterator +{ + /** + * @var int + */ + private $stopDepth; + + public function __construct(XMLReader $reader) + { + parent::__construct($reader); + + $this->stopDepth = $reader->depth; + } + + public function rewind() + { + parent::next(); + parent::rewind(); + } + + public function valid() + { + $parent = parent::valid(); + + return $parent && ($this->reader->depth > $this->stopDepth); + } +} diff --git a/src/Element.php b/src/Element.php new file mode 100644 index 0000000..aa18081 --- /dev/null +++ b/src/Element.php @@ -0,0 +1,59 @@ + + */ +class Element extends Node +{ + private $name_; + private $attributes_; + + public function __construct(XMLReader $reader) + { + parent::__construct($reader); + + $this->initializeFrom($reader); + } + + public function getXMLElementAround($innerXML = '') + { + return XMLBuild::wrapTag($this->name_, $this->attributes_, $innerXML); + } + + public function getAttributes() + { + return $this->attributes_; + } + + public function getAttribute(string $name, $default = null) + { + return $this->attributes_[$name] ?? $default; + } + + public function __toString() + { + return $this->name_; + } + + private function initializeFrom(XMLReader $reader) + { + if ($reader->nodeType !== XMLReader::ELEMENT) { + $node = new Node($reader); + + throw new RuntimeException(sprintf( + 'Reader must be at an XMLReader::ELEMENT, is XMLReader::%s given.', + $node->getNodeTypeName() + )); + } + + $this->name_ = $reader->name; + $this->attributes_ = parent::getAttributes()->getArrayCopy(); + } +} diff --git a/src/ElementIterator.php b/src/ElementIterator.php new file mode 100644 index 0000000..15a0891 --- /dev/null +++ b/src/ElementIterator.php @@ -0,0 +1,157 @@ + + */ +class ElementIterator extends XMLIterator +{ + /** + * @var int + */ + private $index; + + /** + * @var string + */ + private $name; + + /** + * @var bool + */ + private $didRewind; + + /** + * @param XMLReader $reader + * @param null|string $name element name, leave empty or use '*' for all elements + */ + public function __construct(XMLReader $reader, $name = null) + { + parent::__construct($reader); + + $this->setName($name); + } + + /** + * @return void + */ + public function rewind() + { + parent::rewind(); + + $this->ensureCurrentElementState(); + $this->didRewind = true; + $this->index = 0; + } + + /** + * @return Node|null + */ + public function current() + { + $this->didRewind || self::rewind(); + $this->ensureCurrentElementState(); + + return self::valid() ? new Node($this->reader) : null; + } + + public function key() + { + return $this->index; + } + + public function next() + { + if (parent::valid()) { + $this->index++; + } + parent::next(); + $this->ensureCurrentElementState(); + } + + /** + * @return array + */ + public function toArray() + { + $array = []; + $this->didRewind || $this->rewind(); + if (!$this->valid()) { + return []; + } + $this->ensureCurrentElementState(); + while ($this->valid()) { + $element = new Node($this->reader); + if ($this->reader->hasValue) { + $string = $this->reader->value; + } else { + $string = $element->readString(); + } + if ($this->name) { + $array[] = $string; + } else { + $array[$element->name] = $string; + } + $this->moveToNextElementByName($this->name); + } + + return $array; + } + + /** + * @return string + */ + public function __toString() + { + return $this->readString(); + } + + /** + * decorate method calls + * + * @param string $name + * @param array $args + * + * @return mixed + */ + public function __call($name, $args) + { + return call_user_func_array([$this->current(), $name], $args); + } + + /** + * decorate property get + * + * @param string $name + * + * @return string + */ + public function __get($name) + { + return $this->current()->$name; + } + + /** + * @param null|string $name + */ + public function setName($name = null) + { + $this->name = '*' === $name ? null : $name; + } + + /** + * take care the underlying XMLReader is at an element with a fitting name (if $this is looking for a name) + */ + private function ensureCurrentElementState() + { + if ($this->reader->nodeType !== XMLReader::ELEMENT) { + $this->moveToNextElementByName($this->name); + } elseif ($this->name && $this->name !== $this->reader->name) { + $this->moveToNextElementByName($this->name); + } + } +} diff --git a/src/ElementXpathFilter.php b/src/ElementXpathFilter.php new file mode 100644 index 0000000..ea0778e --- /dev/null +++ b/src/ElementXpathFilter.php @@ -0,0 +1,40 @@ + + */ +class ElementXpathFilter extends FilterBase +{ + /** + * @var string + */ + private $expression; + + /** + * {@inheritDoc} + */ + public function __construct(ElementIterator $iterator, string $expression) + { + parent::__construct($iterator); + + $this->expression = $expression; + } + + public function accept() + { + $buffer = $this->getInnerIterator()->getNodeTree(); + $result = simplexml_load_string($buffer)->xpath($this->expression); + $count = count($result); + + if ($count !== 1) { + return false; + } + + return !($result[0]->children()->count()); + } +} diff --git a/src/FilterBase.php b/src/FilterBase.php new file mode 100644 index 0000000..90d5f46 --- /dev/null +++ b/src/FilterBase.php @@ -0,0 +1,27 @@ + + */ +abstract class FilterBase extends FilterIterator +{ + public function __construct(XMLIterator $elements) + { + parent::__construct($elements); + } + + /** + * @return XMLReader + */ + public function getReader() + { + return $this->getInnerIterator()->getReader(); + } +} diff --git a/src/Iteration.php b/src/Iteration.php new file mode 100644 index 0000000..7339b08 --- /dev/null +++ b/src/Iteration.php @@ -0,0 +1,93 @@ + + */ +class Iteration implements Iterator +{ + /** + * @var XMLReader + */ + private $reader; + + /** + * @var bool + */ + private $valid; + + /** + * @var int + */ + private $index; + + /** + * @var bool + */ + private $skipNextRead; + + public function __construct(XMLReader $reader) + { + $this->reader = $reader; + } + + /** + * skip the next read on next next() + * + * this is useful of the reader has moved to the next node already inside a foreach iteration and the next + * next would move the reader one off. + * + * @see next + */ + public function skipNextRead() + { + $this->skipNextRead = true; + } + + /** + * @return XMLReader + */ + public function current() + { + return $this->reader; + } + + public function next() + { + $this->index++; + + if ($this->skipNextRead) { + $this->skipNextRead = false; + $this->valid = $this->reader->nodeType; + } else { + $this->valid = $this->reader->read(); + } + } + + public function key() + { + return $this->index; + } + + public function valid() + { + return $this->valid; + } + + public function rewind() + { + if ($this->reader->nodeType !== XMLReader::NONE) { + throw new BadMethodCallException('Reader can not be rewound'); + } + + $this->index = 0; + $this->valid = $this->reader->read(); + } +} diff --git a/src/NextIteration.php b/src/NextIteration.php new file mode 100644 index 0000000..f1959f7 --- /dev/null +++ b/src/NextIteration.php @@ -0,0 +1,85 @@ + + */ +class NextIteration implements Iterator +{ + /** + * @var XMLReader + */ + private $reader; + + /** + * @var int + */ + private $index; + + /** + * @var bool + */ + private $valid; + + /** + * @var string + */ + private $localName; + + public function __construct(XMLReader $reader, $localName = null) + { + $this->reader = $reader; + $this->localName = $localName; + } + + public function rewind() + { + $this->moveReaderToCurrent(); + $this->index = 0; + } + + public function valid() + { + return $this->valid; + } + + public function current() + { + return $this->reader; + } + + public function key() + { + return $this->index; + } + + public function next() + { + $this->valid && $this->index++; + + if ($this->localName) { + $this->valid = $this->reader->next($this->localName); + } else { + $this->valid = $this->reader->next(); + } + } + + /** + * move cursor to the next element but only if it's not yet there + */ + private function moveReaderToCurrent() + { + if (($this->reader->nodeType === XMLReader::NONE) || + ($this->reader->nodeType !== XMLReader::ELEMENT) || + ($this->localName && $this->localName !== $this->reader->localName) + ) { + self::next(); + } + } +} diff --git a/src/Node.php b/src/Node.php new file mode 100644 index 0000000..117cdef --- /dev/null +++ b/src/Node.php @@ -0,0 +1,324 @@ + + */ +class Node +{ + /** + * @var XMLReader + */ + protected $reader; + + /** + * @var int + */ + private $nodeType; + + /** + * @var string + */ + private $name; + + /** + * @var string + */ + private $localName; + + /** + * @var SimpleXMLElement + */ + private $simpleXML; + + /** + * @var AttributeIterator + */ + private $attributes; + + /** + * @var string + */ + private $string; + + public function __construct(XMLReader $reader) + { + $this->reader = $reader; + $this->nodeType = $reader->nodeType; + $this->name = $reader->name; + } + + /** + * @inheritdoc + */ + public function __toString() + { + if (null === $this->string) { + $this->string = $this->readString(); + } + + return $this->string; + } + + /** + * @return SimpleXMLElement + */ + public function getSimpleXMLElement() + { + if (null === $this->simpleXML) { + if ($this->reader->nodeType !== XMLReader::ELEMENT) { + return null; + } + + $node = $this->expand(); + $this->simpleXML = simplexml_import_dom($node); + } + + return $this->simpleXML; + } + + /** + * @return AttributeIterator|array + */ + public function getAttributes() + { + if (null === $this->attributes) { + $this->attributes = new AttributeIterator($this->reader); + } + + return $this->attributes; + } + + /** + * @param string $name + * @param null $default + * + * @return string + */ + public function getAttribute(string $name, $default = null) + { + return $this->reader->getAttribute($name) ?? $default; + } + + /** + * @param null $name + * @param bool $descendantAxis + * + * @return ChildElementIterator + */ + public function getChildElements($name = null, bool $descendantAxis = false) : ChildElementIterator + { + return new ChildElementIterator($this->reader, $name, $descendantAxis); + } + + /** + * @return ChildIterator|Node[] + */ + public function getChildren() : ChildIterator + { + return new ChildIterator($this->reader); + } + + /** + * @return string name + */ + public function getName() + { + return $this->name; + } + + /** + * @return string local name + */ + public function getLocalName() + { + return $this->localName; + } + + public function getReader() + { + return $this->reader; + } + + /** + * @return string + */ + public function readOuterXml() + { + return $this->reader->readOuterXml(); + } + + /** + * XMLReader expand node and import it into a DOMNode with a DOMDocument + * + * This is for example useful for DOMDocument::saveXML() @see readOuterXml + * or getting a SimpleXMLElement out of it @see getSimpleXMLElement + * + * @throws BadMethodCallException + * + * @param DOMNode $baseNode + * + * @return DOMNode + */ + public function expand(DOMNode $baseNode = null) + { + if (null === $baseNode) { + $baseNode = new DOMDocument(); + } + + if ($baseNode instanceof DOMDocument) { + $doc = $baseNode; + } else { + $doc = $baseNode->ownerDocument; + } + + if (false === $node = $this->reader->expand($baseNode)) { + throw new BadMethodCallException('Unable to expand node.'); + } + + if ($node->ownerDocument !== $doc) { + $node = $doc->importNode($node, true); + } + + return $node; + } + + /** + * Decorated method + * + * @throws BadMethodCallException + * @return string + */ + public function readString() + { + return $this->reader->readString(); + } + + /** + * Return node-type as human readable string (constant name) + * + * @param null $nodeType + * + * @return string + */ + public function getNodeTypeName($nodeType = null) + { + $strings = [ + XMLReader::NONE => 'NONE', + XMLReader::ELEMENT => 'ELEMENT', + XMLReader::ATTRIBUTE => 'ATTRIBUTE', + XMLREADER::TEXT => 'TEXT', + XMLREADER::CDATA => 'CDATA', + XMLReader::ENTITY_REF => 'ENTITY_REF', + XMLReader::ENTITY => 'ENTITY', + XMLReader::PI => 'PI', + XMLReader::COMMENT => 'COMMENT', + XMLReader::DOC => 'DOC', + XMLReader::DOC_TYPE => 'DOC_TYPE', + XMLReader::DOC_FRAGMENT => 'DOC_FRAGMENT', + XMLReader::NOTATION => 'NOTATION', + XMLReader::WHITESPACE => 'WHITESPACE', + XMLReader::SIGNIFICANT_WHITESPACE => 'SIGNIFICANT_WHITESPACE', + XMLReader::END_ELEMENT => 'END_ELEMENT', + XMLReader::END_ENTITY => 'END_ENTITY', + XMLReader::XML_DECLARATION => 'XML_DECLARATION', + ]; + + if (null === $nodeType) { + $nodeType = $this->nodeType; + } + + return $strings[$nodeType]; + } + + /** + * decorate method calls + * + * @param string $name + * @param array $args + * + * @return mixed + */ + public function __call($name, $args) + { + return call_user_func_array([$this->reader, $name], $args); + } + + /** + * decorate property get + * + * @param string $name + * + * @return string + */ + public function __get($name) + { + return $this->reader->$name; + } + + /** + * debug utility method + * + * @param XMLReader $reader + * @param bool $return (optional) prints by default but can return string + * + * @return string|null + */ + public static function dump(XMLReader $reader, bool $return = false) + { + $node = new self($reader); + $nodeType = $reader->nodeType; + $nodeName = $node->getNodeTypeName(); + $extra = ''; + + if ($reader->nodeType === XMLReader::ELEMENT) { + $extra = '<' . $reader->name . '> '; + $extra .= sprintf("(isEmptyElement: %s) ", $reader->isEmptyElement ? 'Yes' : 'No'); + } + + if ($reader->nodeType === XMLReader::END_ELEMENT) { + $extra = 'name . '> '; + } + + if ($reader->nodeType === XMLReader::ATTRIBUTE) { + $str = $reader->value; + $len = strlen($str); + if ($len > 20) { + $str = substr($str, 0, 17) . '...'; + } + $str = strtr($str, ["\n" => '\n']); + $extra = sprintf('%s = (%d) "%s" ', $reader->name, strlen($str), $str); + } + + if ($reader->nodeType === XMLReader::TEXT || $reader->nodeType === XMLReader::WHITESPACE || + $reader->nodeType === XMLReader::SIGNIFICANT_WHITESPACE + ) { + $str = $reader->readString(); + $len = strlen($str); + if ($len > 20) { + $str = substr($str, 0, 17) . '...'; + } + $str = strtr($str, ["\n" => '\n']); + $extra = sprintf('(%d) "%s" ', strlen($str), $str); + } + + $label = sprintf("(#%d) %s %s", $nodeType, $nodeName, $extra); + + if ($return) { + return $label; + } + + printf("%s%s\n", str_repeat(' ', $reader->depth), $label); + + return null; + } +} diff --git a/src/NodeTypeFilter.php b/src/NodeTypeFilter.php new file mode 100644 index 0000000..1807dc7 --- /dev/null +++ b/src/NodeTypeFilter.php @@ -0,0 +1,53 @@ + + */ +class NodeTypeFilter extends FilterBase +{ + /** + * @var array + */ + private $allowed; + + /** + * @var XMLReader + */ + private $reader; + + /** + * @var bool + */ + private $invert; + + /** + * @param XMLIterator $iterator + * @param int|int[] $nodeType one or more type constants + * XMLReader::NONE XMLReader::ELEMENT XMLReader::ATTRIBUTE XMLReader::TEXT + * XMLReader::CDATA XMLReader::ENTITY_REF XMLReader::ENTITY XMLReader::PI + * XMLReader::COMMENT XMLReader::DOC XMLReader::DOC_TYPE XMLReader::DOC_FRAGMENT + * XMLReader::NOTATION XMLReader::WHITESPACE XMLReader::SIGNIFICANT_WHITESPACE + * XMLReader::END_ELEMENT XMLReader::END_ENTITY XMLReader::XML_DECLARATION + * @param bool $invert + */ + public function __construct(XMLIterator $iterator, $nodeType, bool $invert = false) + { + parent::__construct($iterator); + + $this->allowed = (array) $nodeType; + $this->reader = $iterator->getReader(); + $this->invert = $invert; + } + + public function accept() + { + $result = in_array($this->reader->nodeType, $this->allowed); + + return $this->invert ? !$result : $result; + } +} diff --git a/src/Tests/ChildElementIteratorTest.php b/src/Tests/ChildElementIteratorTest.php new file mode 100644 index 0000000..80651fa --- /dev/null +++ b/src/Tests/ChildElementIteratorTest.php @@ -0,0 +1,83 @@ + + */ +class ChildElementIteratorTest extends \PHPUnit_Framework_TestCase +{ + public function testOteration() + { + $reader = new XMLReaderStub(''); + + $it = new ChildElementIterator($reader); + + $this->assertEquals(false, $it->valid()); + $this->assertSame(null, $it->valid()); + + $it->rewind(); + $this->assertEquals(true, $it->valid()); + $this->assertEquals('child', $it->current()->getName()); + + $it->next(); + $this->assertEquals(false, $it->valid()); + + $reader = new XMLReaderStub(''); + $base = new ElementIterator($reader); + $base->rewind(); + $root = $base->current(); + $this->assertEquals('root', $root->getName()); + $children = $root->getChildElements(); + $this->assertEquals('root', $reader->name); + $children->rewind(); + $this->assertEquals('none', $reader->name); + $children->next(); + $this->assertEquals('one', $reader->name); + $childChildren = new ChildElementIterator($reader); + $this->assertEquals('child', $childChildren->current()->getName()); + $childChildren->next(); + $this->assertEquals(false, $childChildren->valid()); + $this->assertEquals('none', $reader->name); + $childChildren->next(); + $this->assertEquals('none', $reader->name); + + $this->assertEquals(true, $children->valid()); + $children->next(); + $this->assertEquals(false, $children->valid()); + + + // children w/o descendants + $reader->rewind(); + $expected = ['none', 'one', 'none']; + $root = $base->current(); + $this->assertEquals('root', $root->getName()); + + $count = 0; + foreach ($root->getChildElements() as $index => $child) { + $this->assertSame($count++, $index); + $this->assertEquals($expected[$index], $reader->name); + } + $this->assertEquals(count($expected), $count); + + // children w/ descendants + $reader->rewind(); + $expected = ['none', 'one', 'child', 'none']; + $root = $base->current(); + $this->assertEquals('root', $root->getName()); + + $count = 0; + foreach ($root->getChildElements(null, true) as $index => $child) { + $this->assertSame($count++, $index); + $this->assertEquals($expected[$index], $reader->name); + } + $this->assertEquals(count($expected), $count); + + } +} diff --git a/src/Tests/ElementIteratorTest.php b/src/Tests/ElementIteratorTest.php new file mode 100644 index 0000000..0713614 --- /dev/null +++ b/src/Tests/ElementIteratorTest.php @@ -0,0 +1,137 @@ + + */ +class ElementIteratorTest extends \PHPUnit_Framework_TestCase +{ + public function testCreationAndCurrent() + { + $reader = $this->createReader(); + + $it = new ElementIterator($reader); + + $this->assertSame('xml', $it->current()->getName()); + $it->next(); + $this->assertSame('node1', $it->current()->getName()); + $it->next(); + $this->assertSame('info1', $it->current()->getName()); + } + + /** @test */ + public function string() + { + $reader = new XMLReaderStub('has'); + + /** @var ElementIterator|Node[]|XMLReader $it */ + $it = new ElementIterator($reader); + + $it->rewind(); + $this->assertEquals(true, $it->valid()); + $this->assertEquals("has", (string) $it); + $this->assertEquals("has", $it->readString()); + } + + /** @test */ + public function iteration() + { + $reader = new XMLReaderStub('has'); + + /** @var ElementIterator|Node[] $it */ + $it = new ElementIterator($reader); + + $this->assertEquals(false, $it->valid()); + $this->assertSame(null, $it->valid()); + + $it->rewind(); + $this->assertEquals(true, $it->valid()); + $this->assertEquals('root', $it->current()->getName()); + $this->assertEquals(0, $it->key()); + + $it->rewind(); + $this->assertEquals(true, $it->valid()); + $current = $it->current(); + $this->assertEquals('root', $current->getName()); + $this->assertEquals(0, $it->key()); + + $string = $current->readString(); + $this->assertEquals('has', $string); + + $it->next(); + $this->assertEquals(true, $it->valid()); + $current = $it->current(); + $this->assertEquals('b', $current->getName()); + $this->assertEquals(1, $it->key()); + + $it->next(); + $this->assertEquals(false, $it->valid()); + $current = $it->current(); + $this->assertEquals(null, $current); + + } + + /** @test */ + public function getChildren() + { + $reader = $this->createReader(); + + $it = new ElementIterator($reader); + + $xml = $it->current(); + $this->assertSame('xml', $xml->name); // ensure this is the root node + $it->next(); + + $array = $it->toArray(); + $this->assertSame(7, count($array)); + $this->assertSame("\n test\n ", $array['node4']); + } + + /** + * @test + */ + function iterateOverNamedElements() + { + $reader = new XMLReaderStub('12c3'); + $it = new ElementIterator($reader, 'a'); + + $this->assertEquals(null, $it->valid()); + $it->rewind(); + $this->assertEquals(true, $it->valid()); + $this->assertEquals('a', $it->current()->getName()); + $it->next(); + $this->assertEquals('a', $it->current()->getName()); + $it->next(); + $this->assertEquals('a', $it->current()->getName()); + $this->assertEquals('3', $it); + $it->next(); + $this->assertEquals(false, $it->valid()); + } + + private function createReader() + { + return new XMLReaderStub(' + + + + + + + + + + + + test + + '); + } +} diff --git a/src/Tests/ElementTest.php b/src/Tests/ElementTest.php new file mode 100644 index 0000000..ef7289a --- /dev/null +++ b/src/Tests/ElementTest.php @@ -0,0 +1,59 @@ + + */ +class ElementTest extends \PHPUnit_Framework_TestCase +{ + /** + * @var XMLReader + */ + protected $reader; + + protected function setUp() + { + $this->reader = new XMLReaderStub('node value'); + } + + public function testElementCreation() + { + $reader = $this->reader; + $reader->next(); + $element = new Element($reader); + $this->assertSame($element->getNodeTypeName(), $element->getNodeTypeName(XMLReader::ELEMENT)); + $this->assertSame($element->name, 'root'); + } + + public function testReaderAttributeHandling() + { + $reader = new XMLReaderStub("node value"); + $reader->next(); + $this->assertSame("first", $reader->getAttribute('pos')); + $this->assertSame("a\r\nb c \td", $reader->getAttribute('plue'), 'entity handling'); + $element = new Element($reader); + $xml = $element->getXMLElementAround(); + $this->assertSame("", $xml, 'XML generation'); + } + + public function testCheckNodeValue() + { + $reader = new XMLReaderStub('has'); + /** @var ElementIterator|Node[] $it */ + $it = new ElementIterator($reader); + $count = 0; + foreach ($it as $element) { + $this->assertEquals('has', $element->readString()); + $count++; + } + $this->assertEquals(2, $count); + } +} diff --git a/src/Tests/Fixtures/data/dobs-items.xml b/src/Tests/Fixtures/data/dobs-items.xml new file mode 100644 index 0000000..168b861 --- /dev/null +++ b/src/Tests/Fixtures/data/dobs-items.xml @@ -0,0 +1,11 @@ + + + item #1 + item #2 + item #3 + + + item #4 + item #5 + + diff --git a/src/Tests/Fixtures/data/features-basic.xml b/src/Tests/Fixtures/data/features-basic.xml new file mode 100644 index 0000000..4fdb75b --- /dev/null +++ b/src/Tests/Fixtures/data/features-basic.xml @@ -0,0 +1,10 @@ + + + + + value + some text + + + & + diff --git a/src/Tests/Fixtures/data/features-xmlns.xml b/src/Tests/Fixtures/data/features-xmlns.xml new file mode 100644 index 0000000..5c753bf --- /dev/null +++ b/src/Tests/Fixtures/data/features-xmlns.xml @@ -0,0 +1,9 @@ + + + + + + + + & + diff --git a/src/Tests/Fixtures/data/movies.xml b/src/Tests/Fixtures/data/movies.xml new file mode 100644 index 0000000..c8be2ca --- /dev/null +++ b/src/Tests/Fixtures/data/movies.xml @@ -0,0 +1,32 @@ + + + + + PHP: Behind the Parser + + + Ms. Coder + Onlivia Actora + + + Mr. Coder + El ActÓr + + + + So, this language. It's like, a programming language. Or is it a + scripting language? All is revealed in this thrilling horror spoof + of a documentary. + + + PHP solves all my web problems + + 7 + 5 + + diff --git a/src/Tests/Fixtures/data/posts.xml b/src/Tests/Fixtures/data/posts.xml new file mode 100644 index 0000000..8d0b397 --- /dev/null +++ b/src/Tests/Fixtures/data/posts.xml @@ -0,0 +1,119 @@ + + + + + message 1 + client + + + + + + message 2 + client + + + + + + message 3 + client + + + + + + message 4 + client + + + + + + message 5 + client + + + + + + message 6 + client + + + + + + message 7 + client + + + + + + message 8.1 + client + + + + message 8.2 + client + + + + message 8.3 + client + + + + + + message 9 + client + + + + message 9 A + client + + + + + + + + message 10 + client + + + + + + hello + client + + + + hello client how can I help? + operator + + + + + + good morning + client + + + + good morning how can I help? + operator + + + + diff --git a/src/Tests/Fixtures/data/rss-feed-media.xml b/src/Tests/Fixtures/data/rss-feed-media.xml new file mode 100644 index 0000000..d8ab65e --- /dev/null +++ b/src/Tests/Fixtures/data/rss-feed-media.xml @@ -0,0 +1,40 @@ + + + + flow-Media Catalog + http://catalog.flownetworks.com/catalogs/1/videos.mrss + Video Catalog + + http://images.flow-media.com/flow_media_current.png + Get to know flow-Media + http://www.flow-media.com + + flow-Media + + .. + .. + .. + Wed, 01 May 2013 07:01:08 GMT + 9809880 + + 52985890 + + + + US + + + + + + + + diff --git a/src/Tests/Fixtures/dav-significant-whitespace.xml b/src/Tests/Fixtures/dav-significant-whitespace.xml new file mode 100644 index 0000000..98d898a --- /dev/null +++ b/src/Tests/Fixtures/dav-significant-whitespace.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/Tests/IterationTest.php b/src/Tests/IterationTest.php new file mode 100644 index 0000000..53b8587 --- /dev/null +++ b/src/Tests/IterationTest.php @@ -0,0 +1,81 @@ + + */ +class IterationTest extends \PHPUnit_Framework_TestCase +{ + public function testCreation() + { + $iterator = new Iteration(new XMLReaderStub('')); + $this->assertInstanceOf('\Thruster\Component\XMLIterator\Iteration', $iterator); + $this->assertInstanceOf('\Traversable', $iterator); + $this->assertInstanceOf('\Iterator', $iterator); + } + + public function testIteration() + { + $reader = new XMLReaderStub(''); + $iterator = new Iteration($reader); + + $data = array( + array(XMLReader::ELEMENT, 0), + array(XMLReader::ELEMENT, 1), + array(XMLReader::END_ELEMENT, 1), + array(XMLReader::END_ELEMENT, 0), + ); + + $count = 0; + + /* @var $reader XMLReader */ + foreach ($iterator as $index => $reader) { + $this->assertSame($count, $index); + list($nodeType, $depth) = $data[$index]; + $this->assertSame($nodeType, $reader->nodeType); + $this->assertSame($depth, $reader->depth); + $count++; + } + + $this->assertSame(4, $count); + } + + public function testSkipNextRead() + { + $reader = new XMLReaderStub(''); + $iterator = new Iteration($reader); + + $key = null; + + foreach ($iterator as $key => $node) { + $this->assertEquals('r', $node->name); + if ($key >= 6) { + break; + } + $iterator->skipNextRead(); + } + + $this->assertEquals(6, $key); + + $reader = new XMLReaderStub(''); + $iterator = new Iteration($reader); + + foreach ($iterator as $node) { + if ($node->name === 'r') { + continue; + } + $this->assertEquals(XMLReader::ELEMENT, $node->nodeType); + $this->assertEquals('a', $node->name); + + $node->next(); + $iterator->skipNextRead(); + } + } +} diff --git a/src/Tests/NodeTest.php b/src/Tests/NodeTest.php new file mode 100644 index 0000000..c39c44b --- /dev/null +++ b/src/Tests/NodeTest.php @@ -0,0 +1,86 @@ + + */ +class NodeTest extends \PHPUnit_Framework_TestCase +{ + /** + * some XMLReaderNode can not be turned into a SimpleXMLElement, this tests how robust XMLReaderNode + * is for the job. + */ + public function testAsSimpleXMLforElementAndSignificantWhitespace() + { + $reader = new XMLReaderStub(' + + '); + + $reader->read(); // (#1) + + // test asSimpleXML() for XMLReader::ELEMENT + $this->assertSame(XMLReader::ELEMENT, $reader->nodeType); + $node = new Node($reader); + $sxml = $node->getSimpleXMLElement(); + $this->assertInstanceOf('SimpleXMLElement', $sxml); + + $reader->read(); // (#14) SIGNIFICANT_WHITESPACE + + // test asSimpleXML() for XMLReader::SIGNIFICANT_WHITESPACE + $this->assertSame(XMLReader::SIGNIFICANT_WHITESPACE, $reader->nodeType); + $node = new Node($reader); + $sxml = $node->getSimpleXMLElement(); + $this->assertNull($sxml); + } + + public function testXxpand() + { + $reader = new XMLReaderStub(' + + + Desktop 1 (d) + 499.99 + + + + Tablet 1 (t) + 1099.99 + + '); + + $products = new ElementIterator($reader, 'product'); + $doc = new \DOMDocument(); + $xpath = new \DOMXPath($doc); + foreach ($products as $product) { + $node = $product->expand($doc); + $this->assertInstanceOf('DOMNode', $node); + $this->assertSame($node->ownerDocument, $doc); + $this->assertEquals('product', $xpath->evaluate('local-name(.)', $node)); + $this->addToAssertionCount(1); + } + $this->assertGreaterThan(0, $previous = $this->getNumAssertions()); + + unset($doc); + $reader->rewind(); + foreach ($products as $product) { + $node = $product->expand(); + $this->assertInstanceOf('DOMNode', $node); + $this->assertInstanceOf('DOMDocument', $node->ownerDocument); + $doc = $node->ownerDocument; + $xpath = new \DOMXPath($doc); + $this->assertSame($node->ownerDocument, $doc); + $this->assertEquals('product', $xpath->evaluate('local-name(.)', $node)); + $this->addToAssertionCount(1); + } + + $this->assertGreaterThan($previous, $this->getNumAssertions()); + } +} diff --git a/src/Tests/XMLIteratorTest.php b/src/Tests/XMLIteratorTest.php new file mode 100644 index 0000000..ce7b1f0 --- /dev/null +++ b/src/Tests/XMLIteratorTest.php @@ -0,0 +1,49 @@ + + */ +class XMLIteratorTest extends \PHPUnit_Framework_TestCase +{ + public function testOteration() + { + $reader = new XMLReaderStub('12'); + + $it = new XMLIterator($reader); + $this->assertSame(null, $it->valid()); + + $it->rewind(); + $this->assertSame(true, $it->valid()); + + $node = $it->current(); + $this->assertEquals('r', $node->getName()); + $this->assertEquals('12', (string) $node); + + $it->moveToNextElementByName('a'); + $node = $it->current(); + $this->assertEquals('a', $node->getName()); + $this->assertEquals('1', (string) $node); + + $it->moveToNextElementByName('a'); + $node = $it->current(); + $this->assertEquals('a', $node->getName()); + $this->assertEquals('2', (string) $node); + + $it->next(); + $it->next(); + $this->assertEquals(XMLReader::END_ELEMENT, $reader->nodeType); + $this->assertEquals('a', $it->current()->getName()); + + $it->next(); + $it->next(); + $this->assertEquals(false, $it->valid()); + } +} diff --git a/src/Tests/XMLReaderStub.php b/src/Tests/XMLReaderStub.php new file mode 100644 index 0000000..89cb12c --- /dev/null +++ b/src/Tests/XMLReaderStub.php @@ -0,0 +1,33 @@ + + */ +class XMLReaderStub extends XMLReader +{ + private $xml; + + public function __construct($xml) + { + $this->xml = $xml; + $this->rewind(); + } + + public function rewind() + { + $xml = $this->xml; + + if ($xml[0] === '<') { + $xml = 'data://text/xml;base64,' . base64_encode($this->xml); + } + + $this->open($xml); + } +} diff --git a/src/Tests/XMLReaderTest.php b/src/Tests/XMLReaderTest.php new file mode 100644 index 0000000..896a7ef --- /dev/null +++ b/src/Tests/XMLReaderTest.php @@ -0,0 +1,81 @@ + + */ +class XMLReaderTest extends XMLReaderTestCase +{ + /** + * @dataProvider provideAllFiles + * + * @param string $xml + */ + public function testReadBehavior($xml) + { + $reader = new XMLReaderStub($xml); + + $it = new XMLIterator($reader); + $expected = array(); + while ($reader->read()) { + $expected[] = Node::dump($reader, true); + } + + $reader->rewind(); + $index = 0; + foreach ($it as $index => $node) { + $this->assertEquals($expected[$index], Node::dump($reader, true)); + } + + $this->assertCount($index + 1, $expected); + } + + /** + * @dataProvider provideAllFiles + * + * @param string $xml + */ + public function testNextBehavior($xml) + { + $reader = new XMLReaderStub($xml); + + $it = new NextIteration($reader); + $expected = array(); + while ($reader->next()) { + $expected[] = Node::dump($reader, true); + } + + $reader->rewind(); + $index = 0; + foreach ($it as $index => $node) { + $this->assertEquals($expected[$index], Node::dump($reader, true)); + } + + $this->assertCount($index + 1, $expected); + } + + /** + * @see readBahvior + * @see writeBehavior + */ + public function provideAllFiles() + { + $result = array(); + + $path = __DIR__ . '/Fixtures'; + $result = $this->addXmlFiles($result, $path); + + $path = __DIR__ . '/Fixtures/data'; + $result = $this->addXmlFiles($result, $path); + + return $result; + } +} diff --git a/src/Tests/XMLReaderTestCase.php b/src/Tests/XMLReaderTestCase.php new file mode 100644 index 0000000..fe86131 --- /dev/null +++ b/src/Tests/XMLReaderTestCase.php @@ -0,0 +1,63 @@ + + */ +class XMLReaderTestCase extends \PHPUnit_Framework_TestCase +{ + protected function setUp() + { + // remove any xmlseq stream-wrapper as it might be a left-over from a previous test + if (in_array('xmlseq', stream_get_wrappers())) { + stream_wrapper_unregister('xmlseq'); + } + + parent::setUp(); + } + + + /** + * helper method to create data-providers + * + * @param array $result + * @param $path + * + * @return array of arrays with one entry of each filename as string + */ + protected function addXmlFiles(array $result, $path) + { + return $this->addFiles($result, $path, '~\.xml$~'); + } + + /** + * helper method to create data-providers + * + * @param array $result + * @param string $path + * @param string $pattern PCRE pattern matched against basename + * + * @return array of arrays with one entry of each filename as string + */ + protected function addFiles(array $result, $path, $pattern) + { + /** @var \FilesystemIterator|\SplFileInfo[] $dir */ + $dir = new \FilesystemIterator($path); + foreach ($dir as $file) { + if (!$file->isFile()) { + continue; + } + if (!preg_match($pattern, $file->getBasename())) { + continue; + } + + $result[] = array((string) $file); + } + + return $result; + } +} diff --git a/src/XMLBuild.php b/src/XMLBuild.php new file mode 100644 index 0000000..e666e26 --- /dev/null +++ b/src/XMLBuild.php @@ -0,0 +1,147 @@ + + */ +abstract class XMLBuild +{ + /** + * indentLines() + * + * this will add a line-separator at the end of the last line because if it was + * empty it is not any longer and deserves one. + * + * @param string $lines + * @param string $indent (optional) + * + * @return string + */ + public static function indentLines($lines, $indent = ' ') + { + $lineSeparator = "\n"; + $buffer = ''; + $line = strtok($lines, $lineSeparator); + + while ($line) { + $buffer .= $indent . $line . $lineSeparator; + $line = strtok($lineSeparator); + } + + strtok(null, null); + + return $buffer; + } + + /** + * @param string $name + * @param array|Traversable $attributes attributeName => attributeValue string pairs + * @param bool $emptyTag create an empty element tag (commonly known as short tags) + * + * @return string + */ + public static function startTag($name, $attributes, $emptyTag = false) + { + $buffer = '<' . $name; + $buffer .= static::attributes($attributes); + $buffer .= $emptyTag ? '/>' : '>'; + + return $buffer; + } + + /** + * @param array|Traversable $attributes attributeName => attributeValue string pairs + * + * @return string + */ + public static function attributes($attributes) + { + $buffer = ''; + foreach ($attributes as $name => $value) { + $buffer .= ' ' . $name . '="' . static::attributeValue($value) . '"'; + } + + return $buffer; + } + + /** + * @param string $value + * + * @see XMLBuild::numericEntitiesSingleByte + * + * @return string + */ + public static function attributeValue($value) + { + $buffer = $value; + // REC-xml/#AVNormalize - preserve + // REC-xml/#sec-line-ends - preserve + $buffer = preg_replace_callback('~\r\n|\r(?!\n)|\t~', ['self', 'numericEntitiesSingleByte'], $buffer); + + return htmlspecialchars($buffer, ENT_QUOTES, 'UTF-8', false); + } + + /** + * @param string $name + * @param array|Traversable $attributes attributeName => attributeValue string pairs + * @param string $innerXML + * + * @return string + */ + public static function wrapTag($name, $attributes, $innerXML) + { + if (!strlen($innerXML)) { + return XMLBuild::startTag($name, $attributes, true); + } + + return + XMLBuild::startTag($name, $attributes) + . "\n" + . XMLBuild::indentLines($innerXML) + . ""; + } + + /** + * @param XMLReader $reader + * + * @return string + */ + public static function readerNode(XMLReader $reader) + { + switch ($reader->nodeType) { + case XMLREADER::NONE: + return '%(0)%'; + case XMLReader::ELEMENT: + return XMLBuild::startTag($reader->name, new AttributeIterator($reader)); + default: + $node = new Node($reader); + $nodeTypeName = $node->getNodeTypeName(); + $nodeType = $reader->nodeType; + + return sprintf('%%%s (%d)%%', $nodeTypeName, $nodeType); + } + } + + /** + * @param array $matches + * + * @return string + * @see attributeValue() + */ + private static function numericEntitiesSingleByte($matches) + { + $buffer = str_split($matches[0]); + + foreach ($buffer as &$char) { + $char = sprintf('&#%d;', ord($char)); + } + + return implode('', $buffer); + } +} diff --git a/src/XMLIterator.php b/src/XMLIterator.php new file mode 100644 index 0000000..5f9e3e3 --- /dev/null +++ b/src/XMLIterator.php @@ -0,0 +1,174 @@ + + */ +class XMLIterator implements IteratorInterface +{ + /** + * @var XMLReader + */ + protected $reader; + + /** + * @var int + */ + private $index; + + /** + * @var bool + */ + private $lastRead; + + /** + * @var array + */ + private $elementStack; + + public function __construct(XMLReader $reader) + { + $this->reader = $reader; + } + + /** + * @return XMLReader + */ + public function getReader() : XMLReader + { + return $this->reader; + } + + /** + * @param string $name + * + * @return bool|Node + */ + public function moveToNextElementByName($name = null) + { + while (self::moveToNextElement()) { + if (!$name || $name === $this->reader->name) { + break; + } + + self::next(); + }; + + return self::valid() ? self::current() : false; + } + + public function moveToNextElement() + { + return $this->moveToNextByNodeType(XMLReader::ELEMENT); + } + + /** + * @param int $nodeType + * + * @return bool|Node + */ + public function moveToNextByNodeType($nodeType) + { + if (null === self::valid()) { + self::rewind(); + } elseif (self::valid()) { + self::next(); + } + + while (self::valid()) { + if ($this->reader->nodeType === $nodeType) { + break; + } + + self::next(); + } + + return self::valid() ? self::current() : false; + } + + /** + * @inheritdoc + */ + public function rewind() + { + // this iterator can not really rewind + if ($this->reader->nodeType === XMLREADER::NONE) { + self::next(); + } elseif ($this->lastRead === null) { + $this->lastRead = true; + } + + $this->index = 0; + } + + /** + * @inheritdoc + */ + public function valid() + { + return $this->lastRead; + } + + /** + * @return Node + */ + public function current() + { + return new Node($this->reader); + } + + /** + * @inheritdoc + */ + public function key() + { + return $this->index; + } + + /** + * @inheritdoc + */ + public function next() + { + if ($this->lastRead = $this->reader->read() and $this->reader->nodeType === XMLReader::ELEMENT) { + $depth = $this->reader->depth; + $this->elementStack[$depth] = new Element($this->reader); + + if (count($this->elementStack) !== $depth + 1) { + $this->elementStack = array_slice($this->elementStack, 0, $depth + 1); + } + } + + $this->index++; + } + + /** + * @return string + */ + public function getNodePath() : string + { + return '/' . implode('/', $this->elementStack); + } + + /** + * @return string + */ + public function getNodeTree() : string + { + $stack = $this->elementStack; + $buffer = ''; + + /* @var $element Element */ + while ($element = array_pop($stack)) { + $buffer = $element->getXMLElementAround($buffer); + } + + return $buffer; + } +} diff --git a/src/XMLReader.php b/src/XMLReader.php new file mode 100644 index 0000000..fdc96cf --- /dev/null +++ b/src/XMLReader.php @@ -0,0 +1,16 @@ + + */ +class XMLReader extends BaseXMLReader +{ + +}