From 53c0718e286c4a1086eb39d65ce1c4e51bbccb39 Mon Sep 17 00:00:00 2001 From: tomas-novotny Date: Tue, 17 Oct 2023 14:06:06 +0200 Subject: [PATCH] Add xpath method --- CHANGELOG.md | 12 ++-- src/Builder/BaseNode.php | 71 ++++++++++++++++++++-- src/Builder/Node.php | 19 ++++++ src/Formatter/BaseConfig.php | 2 +- tests/Builder/NodeToArrayTest.php | 40 ++++++------- tests/Reader/DefaultReaderTest.php | 96 ++++++++++++++++++++++-------- 6 files changed, 183 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9628d7..6978af4 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,17 +9,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased](https://github.com/inspirum/xml-php/compare/v3.0.0...master) -## [v3.0.0 (2023-10-16)](https://github.com/inspirum/xml-php/compare/v2.3.1...v3.0.0) +## [v3.0.0 (2023-10-17)](https://github.com/inspirum/xml-php/compare/v2.3.1...v3.0.0) ### Changed - Support only **PHP 8.2+** - Make [`Config`](./src/Formatter/Config.php) interface instead of readonly class ### Added -- [`Node`](./src/Builder/Node.php) implements `\Inspirum\Arrayable\Model` interface +- Added [`Node::xpath`](./src/Builder/Node.php) method using internally [`DOMXPath`](https://www.php.net/manual/en/class.domxpath.php) - Added option cast node to flatten (one-dimensional) array -- Added [`DefaultConfig`](./src/Formatter/DefaultConfig.php) config class -- Added [`FullResponseConfig`](./src/Formatter/FullResponseConfig.php) config class -- Added [`FlattenConfig`](./src/Formatter/FlattenConfig.php) config class + - Added [`DefaultConfig`](./src/Formatter/DefaultConfig.php) config class + - Added [`FullResponseConfig`](./src/Formatter/FullResponseConfig.php) config class + - Added [`FlattenConfig`](./src/Formatter/FlattenConfig.php) config class ## [v2.3.1 (2023-08-08)](https://github.com/inspirum/xml-php/compare/v2.3.0...v2.3.1) @@ -56,7 +56,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - [`Inspirum\XML\Builder\Node`](./src/Builder/Node.php) - [`Inspirum\XML\Reader\ReaderFactory`](./src/Reader/ReaderFactory.php) - [`Inspirum\XML\Reader\Reader`](./src/Reader/Reader.php) -- Factories for [XML builder](./src/Builder/Document.php) and [XML Reader](./src/Reader/Reader.php) +- Factories for [**XML builder**](./src/Builder/Document.php) and [**XML Reader**](./src/Reader/Reader.php) - Publicly available [`Formatter::nodeToArray`](./src/Formatter/Formatter.php) method diff --git a/src/Builder/BaseNode.php b/src/Builder/BaseNode.php index b1fab8f..93ddda8 100644 --- a/src/Builder/BaseNode.php +++ b/src/Builder/BaseNode.php @@ -9,6 +9,7 @@ use DOMElement; use DOMException; use DOMNode; +use DOMXpath; use Inspirum\XML\Exception\Handler; use Inspirum\XML\Formatter\Config; use Inspirum\XML\Formatter\DefaultConfig; @@ -63,6 +64,11 @@ public function addTextElement(string $name, mixed $value, array $attributes = [ return $this->createNode($element); } + public function addElementFromNode(DOMNode $node, bool $forcedEscape = false, bool $withNamespaces = true): Node + { + return $this->addTextElement($node->nodeName, $node->textContent, $this->getAttributesFromNode($node), $forcedEscape, $withNamespaces); + } + public function append(Node $element): void { if ($element->getNode() !== null) { @@ -88,6 +94,11 @@ public function createTextElement(string $name, mixed $value, array $attributes return $this->createNode($element); } + public function createElementFromNode(DOMNode $node, bool $forcedEscape = false, bool $withNamespaces = true): Node + { + return $this->createTextElement($node->nodeName, $node->textContent, $this->getAttributesFromNode($node), $forcedEscape, $withNamespaces); + } + public function addXMLData(string $content): ?Node { if ($content === '') { @@ -177,8 +188,6 @@ private function setDOMElementValue(DOMElement $element, mixed $value, bool $for /** * Create new DOM attribute with namespace if exists - * - * @return void */ private function setDOMAttributeNS(DOMElement $element, string $name, mixed $value, bool $withNamespaces): void { @@ -199,7 +208,7 @@ private function setDOMAttributeNS(DOMElement $element, string $name, mixed $val */ private function appendChild(DOMNode $element): void { - $node = $this->node ?? $this->document; + $node = $this->resolveNode(); $node->appendChild($element); } @@ -221,7 +230,7 @@ private function registerNamespaces(array $attributes): void public function getTextContent(): ?string { - $node = $this->node ?? $this->document; + $node = $this->resolveNode(); return $node->textContent; } @@ -231,7 +240,18 @@ public function getTextContent(): ?string */ public function getAttributes(bool $autoCast = false): array { - $node = $this->node ?? $this->document; + $node = $this->resolveNode(); + + return $this->getAttributesFromNode($node, $autoCast); + } + + /** + * Get attributes from \DOMNode + * + * @return ($autoCast is true ? array : array) + */ + private function getAttributesFromNode(DOMNode $node, bool $autoCast = false): array + { $attributes = []; if ($node->hasAttributes()) { @@ -245,6 +265,45 @@ public function getAttributes(bool $autoCast = false): array return $attributes; } + /** + * @inheritDoc + */ + public function xpath(string $expression): ?array + { + $xpath = new DOMXpath($this->toDOMDocument()); + + $nodes = $xpath->query($expression); + if ($nodes === false) { + return null; + } + + $results = []; + foreach ($nodes as $node) { + $results[] = $this->createElementFromNode($node); + } + + return $results; + } + + /** + * Copy current node to new \DOMDocument + */ + private function toDOMDocument(): DOMDocument + { + $doc = new DOMDocument($this->document->xmlVersion ?? '1.0', $this->document->encoding ?? 'UTF-8'); + $doc->loadXML($this->toString()); + + return $doc; + } + + /** + * Resolve current node + */ + private function resolveNode(): DOMNode + { + return $this->node ?? $this->document; + } + public function toString(bool $formatOutput = false): string { return Handler::withErrorHandlerForDOMDocument(function () use ($formatOutput): string { @@ -269,7 +328,7 @@ public function __toString(): string */ public function toArray(?Config $config = null): array { - $result = Formatter::nodeToArray($this->node ?? $this->document, $config ?? new DefaultConfig()); + $result = Formatter::nodeToArray($this->resolveNode(), $config ?? new DefaultConfig()); if (is_array($result) === false) { $result = [$result]; diff --git a/src/Builder/Node.php b/src/Builder/Node.php index 46f4a43..3e94ec7 100644 --- a/src/Builder/Node.php +++ b/src/Builder/Node.php @@ -32,6 +32,13 @@ public function addElement(string $name, array $attributes = [], bool $withNames */ public function addTextElement(string $name, mixed $value, array $attributes = [], bool $forcedEscape = false, bool $withNamespaces = true): Node; + /** + * Add element from \DOMNode + * + * @throws \DOMException + */ + public function addElementFromNode(DOMNode $node, bool $forcedEscape = false, bool $withNamespaces = true): Node; + /** * Append node to parent node. */ @@ -55,6 +62,13 @@ public function createElement(string $name, array $attributes = [], bool $withNa */ public function createTextElement(string $name, mixed $value, array $attributes = [], bool $forcedEscape = false, bool $withNamespaces = true): Node; + /** + * Create new (unconnected) element from \DOMNode + * + * @throws \DOMException + */ + public function createElementFromNode(DOMNode $node, bool $forcedEscape = false, bool $withNamespaces = true): Node; + /** * Add XML data */ @@ -72,6 +86,11 @@ public function getTextContent(): ?string; */ public function getAttributes(bool $autoCast = false): array; + /** + * @return list<\Inspirum\XML\Builder\Node>|null + */ + public function xpath(string $expression): ?array; + /** * Get connected \DOMDocument */ diff --git a/src/Formatter/BaseConfig.php b/src/Formatter/BaseConfig.php index ebfc1c9..0ec3762 100644 --- a/src/Formatter/BaseConfig.php +++ b/src/Formatter/BaseConfig.php @@ -10,7 +10,7 @@ private const NODES = '@nodes'; private const VALUE = '@value'; private const FLATTEN_NODES = '/'; - private const FLATTEN_ATTRIBUTES = '#'; + private const FLATTEN_ATTRIBUTES = '@'; /** * @param list|true $alwaysArray diff --git a/tests/Builder/NodeToArrayTest.php b/tests/Builder/NodeToArrayTest.php index 350b783..0a57480 100644 --- a/tests/Builder/NodeToArrayTest.php +++ b/tests/Builder/NodeToArrayTest.php @@ -516,13 +516,13 @@ public function testWithFlattenConfig(): void self::assertSame( [ - 'a/b/c1#test' => ['true', 'cc'], - 'a/b/c1#a' => '1.4', + 'a/b/c1@test' => ['true', 'cc'], + 'a/b/c1@a' => '1.4', 'a/b/c1' => ['1', '0'], 'a/b/c2' => 'false', 'a/b/c3' => 'test', - 'a/b/c1#b' => '2', - 'a#version' => '1.0', + 'a/b/c1@b' => '2', + 'a@version' => '1.0', ], $xml->toArray($config), ); @@ -546,13 +546,13 @@ public function testWithFlattenAutocastConfig(): void self::assertSame( [ - 'a/b/c1#test' => [true, 'cc'], - 'a/b/c1#a' => 1.4, + 'a/b/c1@test' => [true, 'cc'], + 'a/b/c1@a' => 1.4, 'a/b/c1' => [1, 0], 'a/b/c2' => false, 'a/b/c3' => 'test', - 'a/b/c1#b' => 2, - 'a#version' => 1.0, + 'a/b/c1@b' => 2, + 'a@version' => 1.0, ], $xml->toArray($config), ); @@ -576,13 +576,13 @@ public function testWithFlattenAlwaysArrayConfig(): void self::assertSame( [ - 'a/b/c1#test' => ['true', 'cc'], - 'a/b/c1#a' => ['1.4'], + 'a/b/c1@test' => ['true', 'cc'], + 'a/b/c1@a' => ['1.4'], 'a/b/c1' => ['1', '0'], 'a/b/c2' => ['false'], 'a/b/c3' => ['test'], - 'a/b/c1#b' => ['2'], - 'a#version' => ['1.0'], + 'a/b/c1@b' => ['2'], + 'a@version' => ['1.0'], ], $xml->toArray($config), ); @@ -602,17 +602,17 @@ public function testWithFlattenCustomConfig(): void $bE = $aE->addElement('b'); $bE->addTextElement('c1', 0, ['test' => 'cc', 'b' => 2]); - $config = new FlattenConfig(flattenNodes: '|', flattenAttributes: '@'); + $config = new FlattenConfig(flattenNodes: '.', flattenAttributes: '#'); self::assertSame( [ - 'a|b|c1@test' => ['true', 'cc'], - 'a|b|c1@a' => '1.4', - 'a|b|c1' => ['1', '0'], - 'a|b|c2' => 'false', - 'a|b|c3' => 'test', - 'a|b|c1@b' => '2', - 'a@version' => '1.0', + 'a.b.c1#test' => ['true', 'cc'], + 'a.b.c1#a' => '1.4', + 'a.b.c1' => ['1', '0'], + 'a.b.c2' => 'false', + 'a.b.c3' => 'test', + 'a.b.c1#b' => '2', + 'a#version' => '1.0', ], $xml->toArray($config), ); diff --git a/tests/Reader/DefaultReaderTest.php b/tests/Reader/DefaultReaderTest.php index 4374438..522b72f 100644 --- a/tests/Reader/DefaultReaderTest.php +++ b/tests/Reader/DefaultReaderTest.php @@ -7,6 +7,7 @@ use Exception; use Inspirum\XML\Builder\DefaultDOMDocumentFactory; use Inspirum\XML\Builder\DefaultDocumentFactory; +use Inspirum\XML\Builder\Node; use Inspirum\XML\Reader\DefaultReaderFactory; use Inspirum\XML\Reader\DefaultXMLReaderFactory; use Inspirum\XML\Reader\Reader; @@ -20,9 +21,7 @@ use function is_array; use function is_numeric; use function is_string; -use function preg_quote; use function simplexml_load_string; -use function sprintf; class DefaultReaderTest extends BaseTestCase { @@ -361,41 +360,77 @@ public function testIteratePathMultipleNamespaces(): void /** * @param array|string> $expected */ - #[DataProvider('provideIterateWihSimpleLoadString')] - public function testIterateWihSimpleLoadString(string $file, bool $withNamespaces, string $path, array $expected): void + #[DataProvider('provideIterateXpath')] + public function testIterateWithSimpleLoadString(string $file, bool $withNamespaces, string $path, array $expected): void { $reader = $this->newReader(self::getTestFilePath($file)); foreach ($reader->iterateNode('item', $withNamespaces) as $i => $item) { + $elements = []; + try { - self::withErrorHandler(static function () use ($i, $item, $path, $expected): void { + self::withErrorHandler(static function () use ($item, $path, &$elements): void { $xml = simplexml_load_string($item->toString()); if ($xml === false) { throw new Exception('simplexml_load_string: error'); } $elements = $xml->xpath($path); - if ($elements === false || $elements === null) { - throw new Exception('xpath: error'); - } + }); + + if ($elements === false || $elements === null) { + throw new Exception('xpath: error'); + } + } catch (Throwable $exception) { + $expectedMessage = $expected[$i]; + if (is_string($expectedMessage)) { + self::assertMatchesRegularExpression($expectedMessage, $exception->getMessage()); + continue; + } + + throw $exception; + } - self::assertSame($expected[$i], array_map(static fn(SimpleXMLElement $element): string => (string) $element, $elements)); + self::assertSame($expected[$i], array_map(static fn(SimpleXMLElement $element): string => (string) $element, $elements)); + } + } + + /** + * @param array|string> $expected + */ + #[DataProvider('provideIterateXpath')] + public function testIterateWithXpath(string $file, bool $withNamespaces, string $path, array $expected): void + { + $reader = $this->newReader(self::getTestFilePath($file)); + + foreach ($reader->iterateNode('item', $withNamespaces) as $i => $item) { + $elements = []; + try { + self::withErrorHandler(static function () use ($item, $path, &$elements): void { + $elements = $item->xpath($path); }); + + if ($elements === null) { + throw new Exception('xpath: error'); + } } catch (Throwable $exception) { $expectedMessage = $expected[$i]; if (is_string($expectedMessage)) { - self::assertMatchesRegularExpression(sprintf('/%s/', preg_quote($expectedMessage)), $exception->getMessage()); - } else { - self::fail(); + self::assertMatchesRegularExpression($expectedMessage, $exception->getMessage()); + continue; } + + throw $exception; } + + self::assertSame($expected[$i], array_map(static fn(Node $element): ?string => $element->getTextContent(), $elements)); } } /** * @return iterable> */ - public static function provideIterateWihSimpleLoadString(): iterable + public static function provideIterateXpath(): iterable { yield [ 'file' => 'sample_04.xml', @@ -411,24 +446,26 @@ public static function provideIterateWihSimpleLoadString(): iterable ]; yield [ - 'file' => 'sample_08.xml', + 'file' => 'sample_04.xml', 'withNamespaces' => false, - 'path' => '/item/id', + 'path' => '/item/name[@price>10]', 'expected' => [ - 'simplexml_load_string(): namespace error', - 'simplexml_load_string(): namespace error', - 'simplexml_load_string(): namespace error', + ['Test 1'], + [], + ['Test 3'], + [], + [], ], ]; yield [ 'file' => 'sample_08.xml', 'withNamespaces' => false, - 'path' => '/item/g:id', + 'path' => '/item/id', 'expected' => [ - 'SimpleXMLElement::xpath(): Undefined namespace prefix', - 'simplexml_load_string(): namespace error', - 'simplexml_load_string(): namespace error', + ['1/L1'], + '/(simplexml_load_string\(\): namespace error |DOMDocument::loadXML\(\)): Namespace prefix h for test on id is not defined( in Entity)?/', + '/(simplexml_load_string\(\): namespace error |DOMDocument::loadXML\(\)): Namespace prefix g on id is not defined/', ], ]; @@ -443,12 +480,23 @@ public static function provideIterateWihSimpleLoadString(): iterable ], ]; + yield [ + 'file' => 'sample_08.xml', + 'withNamespaces' => false, + 'path' => '/item/g:id', + 'expected' => [ + '/(SimpleXMLElement::xpath\(\)|DOMXPath::query\(\))\: Undefined namespace prefix/', + '/(simplexml_load_string\(\): namespace error |DOMDocument::loadXML\(\)): Namespace prefix h for test on id is not defined( in Entity)?/', + '/(simplexml_load_string\(\): namespace error |DOMDocument::loadXML\(\)): Namespace prefix g on id is not defined/', + ], + ]; + yield [ 'file' => 'sample_08.xml', 'withNamespaces' => true, 'path' => '/item/g:id', 'expected' => [ - 'SimpleXMLElement::xpath(): Undefined namespace prefix', + '/(SimpleXMLElement::xpath\(\)|DOMXPath::query\(\))\: Undefined namespace prefix/', [], ['1/L3'], ], @@ -459,7 +507,7 @@ public static function provideIterateWihSimpleLoadString(): iterable 'withNamespaces' => true, 'path' => '/item/data/g:title', 'expected' => [ - 'SimpleXMLElement::xpath(): Undefined namespace prefix', + '/(SimpleXMLElement::xpath\(\)|DOMXPath::query\(\))\: Undefined namespace prefix/', ['Title 2'], [], ],