From d01aa3da42293cf4ed988bdeded97883ba838c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20Delr=C3=A9?= Date: Sun, 10 May 2026 23:51:42 +0200 Subject: [PATCH] feat: add toArray() and toObject() with namespace support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FluidXml::toArray() returns ['rootTag' => value] for the document. FluidContext::toArray() returns an array of ['tag' => value] entries for each selected node. Conversion rules: - Text-only element -> plain string value - Empty element -> null - Attributes -> '@name' keys - Text with siblings -> '@' key - CDATA content -> '@:cdata' key - Comment -> '@:comment' key - Repeated child tags -> indexed sub-arrays - Namespace decls -> '@xmlns' / '@xmlns:prefix' keys (only those introduced on the element, not inherited) The output mirrors the array syntax accepted by addChild(), enabling round-trip import/export. FluidXml::toObject() and FluidContext::toObject() return the same structure as stdClass instead of arrays, which integrates more naturally with JSON workflows and property-access code. Associative arrays become stdClass objects; indexed arrays (repeated siblings) remain PHP arrays. Namespace declarations are detected via the XPath namespace::* axis, comparing in-scope namespaces with the parent element to emit only the declarations local to each node. Closes #28 Signed-off-by: Guillaume Delré --- source/FluidXml/FluidContext.php | 21 +++++ source/FluidXml/FluidHelper.php | 82 ++++++++++++++++++ source/FluidXml/FluidInterface.php | 2 + source/FluidXml/FluidXml.php | 16 ++++ specs/FluidXml.php | 129 +++++++++++++++++++++++++++++ 5 files changed, 250 insertions(+) diff --git a/source/FluidXml/FluidContext.php b/source/FluidXml/FluidContext.php index 6f76e41..d322440 100644 --- a/source/FluidXml/FluidContext.php +++ b/source/FluidXml/FluidContext.php @@ -402,6 +402,27 @@ public function xml($strip = false): string return FluidHelper::domnodesToString($this->nodes); } + public function toArray(): array + { + $result = []; + + foreach ($this->nodes as $node) { + if ($node instanceof \DOMElement) { + $result[] = [$node->nodeName => FluidHelper::domNodeToArray($node)]; + } + } + + return $result; + } + + public function toObject(): array + { + return \array_map( + fn($item) => FluidHelper::arrayToObject($item), + $this->toArray() + ); + } + public function html($strip = false): string { return FluidHelper::domnodesToString($this->nodes, true); diff --git a/source/FluidXml/FluidHelper.php b/source/FluidXml/FluidHelper.php index 25b0b71..81b614a 100644 --- a/source/FluidXml/FluidHelper.php +++ b/source/FluidXml/FluidHelper.php @@ -4,6 +4,88 @@ class FluidHelper { + public static function domNodeToArray(\DOMElement $node): mixed + { + $result = []; + + if ($node->hasAttributes()) { + foreach ($node->attributes as $attr) { + $result['@' . $attr->name] = $attr->value; + } + } + + // Capture namespace declarations introduced on this element (not inherited). + $xpath = new \DOMXPath($node->ownerDocument); + $parentNs = []; + + if ($node->parentNode instanceof \DOMElement) { + foreach ($xpath->query('namespace::*', $node->parentNode) as $ns) { + $parentNs[$ns->nodeName] = $ns->nodeValue; + } + } + + foreach ($xpath->query('namespace::*', $node) as $ns) { + if ($ns->localName === 'xml') { + continue; + } + if (! isset($parentNs[$ns->nodeName]) || $parentNs[$ns->nodeName] !== $ns->nodeValue) { + $result['@' . $ns->nodeName] = $ns->nodeValue; + } + } + + $childElements = []; + + foreach ($node->childNodes as $child) { + if ($child instanceof \DOMCDATASection) { + $result['@:cdata'] = $child->nodeValue; + } elseif ($child instanceof \DOMText) { + $text = \trim($child->nodeValue); + if ($text !== '') { + $result['@'] = ($result['@'] ?? '') . $text; + } + } elseif ($child instanceof \DOMComment) { + $result['@:comment'] = $child->nodeValue; + } elseif ($child instanceof \DOMElement) { + $childElements[] = $child; + } + } + + $names = \array_map(fn($el) => $el->nodeName, $childElements); + $hasRepeat = \count($names) !== \count(\array_unique($names)); + + if ($hasRepeat) { + foreach ($childElements as $child) { + $result[] = [$child->nodeName => static::domNodeToArray($child)]; + } + } else { + foreach ($childElements as $child) { + $result[$child->nodeName] = static::domNodeToArray($child); + } + } + + // Simplify text-only element to a plain string + if (\array_keys($result) === ['@']) { + return $result['@']; + } + + return empty($result) ? null : $result; + } + + public static function arrayToObject(array $array): \stdClass + { + $obj = new \stdClass(); + + foreach ($array as $k => $v) { + $obj->$k = \is_array($v) + ? (\array_is_list($v) + ? \array_map(fn($i) => \is_array($i) ? static::arrayToObject($i) : $i, $v) + : static::arrayToObject($v)) + : $v; + } + + return $obj; + } + public static function isAnXmlString($string): bool { // Removes any empty new line at the beginning, diff --git a/source/FluidXml/FluidInterface.php b/source/FluidXml/FluidInterface.php index 0bfc6e4..b959f19 100644 --- a/source/FluidXml/FluidInterface.php +++ b/source/FluidXml/FluidInterface.php @@ -12,6 +12,8 @@ public function __toString(); public function xml($strip = false); public function html($strip = false); public function save($file, $strip = false); + public function toArray(): array; + public function toObject(): \stdClass|array; public function query(...$query); public function __invoke(...$query); public function times($times, callable $fn = null); diff --git a/source/FluidXml/FluidXml.php b/source/FluidXml/FluidXml.php index 1f8b11d..a5170ec 100644 --- a/source/FluidXml/FluidXml.php +++ b/source/FluidXml/FluidXml.php @@ -161,6 +161,22 @@ public function xml($strip = false) return $this->document->dom->saveXML(); } + public function toArray(): array + { + $root = $this->document->dom->documentElement; + + if ($root === null) { + return []; + } + + return [$root->nodeName => FluidHelper::domNodeToArray($root)]; + } + + public function toObject(): \stdClass + { + return FluidHelper::arrayToObject($this->toArray()); + } + public function html($strip = false): string { $header = "\n"; diff --git a/specs/FluidXml.php b/specs/FluidXml.php index 6cac1ef..a288615 100644 --- a/specs/FluidXml.php +++ b/specs/FluidXml.php @@ -2249,6 +2249,135 @@ function addchild($parent, $i) }); }); + describe('.toArray()', function () { + it('should export a simple element with text content', function () { + $xml = new FluidXml(); + $xml->addChild(['type' => 'Herbivore']); + + $actual = $xml->toArray(); + $expected = ['doc' => ['type' => 'Herbivore']]; + \assert($actual === $expected, __($actual, $expected)); + }); + + it('should export attributes using the @ prefix', function () { + $xml = new FluidXml(); + $xml->addChild(['animal' => ['@operation' => 'create', '@' => 'data']]); + + $actual = $xml->toArray(); + $expected = ['doc' => ['animal' => ['@operation' => 'create', '@' => 'data']]]; + \assert($actual === $expected, __($actual, $expected)); + }); + + it('should export nested elements as nested arrays', function () { + $xml = new FluidXml(); + $xml->addChild(['animal' => [ + '@operation' => 'create', + 'type' => 'Herbivore', + 'attribute' => ['legs' => '4', 'head' => '1'], + ]]); + + $actual = $xml->toArray(); + $expected = ['doc' => [ + 'animal' => [ + '@operation' => 'create', + 'type' => 'Herbivore', + 'attribute' => ['legs' => '4', 'head' => '1'], + ], + ]]; + \assert($actual === $expected, __($actual, $expected)); + }); + + it('should export repeated sibling elements as indexed arrays', function () { + $xml = new FluidXml(); + $xml->addChild(['chapters' => [ + ['chapter' => 'One'], + ['chapter' => 'Two'], + ]]); + + $actual = $xml->toArray(); + $expected = ['doc' => [ + 'chapters' => [ + ['chapter' => 'One'], + ['chapter' => 'Two'], + ], + ]]; + \assert($actual === $expected, __($actual, $expected)); + }); + + it('should export an empty element as null', function () { + $xml = new FluidXml(); + $xml->addChild('empty'); + + $actual = $xml->toArray(); + $expected = ['doc' => ['empty' => null]]; + \assert($actual === $expected, __($actual, $expected)); + }); + + it('should export queried nodes via FluidContext', function () { + $xml = new FluidXml(); + $xml->addChild(['animal' => ['type' => 'Herbivore', 'legs' => '4']]); + + $actual = $xml->query('//type')->toArray(); + $expected = [['type' => 'Herbivore']]; + \assert($actual === $expected, __($actual, $expected)); + }); + + it('should export namespace declarations using the @xmlns prefix', function () { + $xml = new FluidXml('animal'); + $xml->namespace(new FluidNamespace('foo', 'http://foo.com')); + $xml->addChild('foo:legs', '4'); + + $actual = $xml->toArray(); + // The xmlns:foo declaration appears on foo:legs (where it is first used). + // Text content is under '@' when co-present with namespace metadata. + $expected = ['animal' => [ + 'foo:legs' => [ + '@xmlns:foo' => 'http://foo.com', + '@' => '4', + ], + ]]; + \assert($actual === $expected, __($actual, $expected)); + }); + }); + + describe('.toObject()', function () { + it('should return a stdClass mirroring toArray()', function () { + $xml = new FluidXml(); + $xml->addChild(['animal' => ['@operation' => 'create', 'type' => 'Herbivore']]); + + $obj = $xml->toObject(); + \assert($obj instanceof \stdClass, __($obj, 'stdClass')); + \assert($obj->doc instanceof \stdClass, __($obj->doc, 'stdClass')); + \assert($obj->doc->animal instanceof \stdClass, __($obj->doc->animal, 'stdClass')); + \assert($obj->doc->animal->{'@operation'} === 'create', __($obj->doc->animal->{'@operation'}, 'create')); + \assert($obj->doc->animal->type === 'Herbivore', __($obj->doc->animal->type, 'Herbivore')); + }); + + it('should preserve indexed arrays for repeated siblings', function () { + $xml = new FluidXml(); + $xml->addChild(['chapters' => [ + ['chapter' => 'One'], + ['chapter' => 'Two'], + ]]); + + $obj = $xml->toObject(); + \assert(\is_array($obj->doc->chapters), __($obj->doc->chapters, 'array')); + \assert($obj->doc->chapters[0] instanceof \stdClass, __($obj->doc->chapters[0], 'stdClass')); + \assert($obj->doc->chapters[0]->chapter === 'One', __($obj->doc->chapters[0]->chapter, 'One')); + }); + + it('should return an array of stdClass for FluidContext', function () { + $xml = new FluidXml(); + $xml->addChild(['animal' => ['type' => 'Herbivore', 'legs' => '4']]); + + $result = $xml->query('//animal')->toObject(); + \assert(\is_array($result), __($result, 'array')); + \assert($result[0] instanceof \stdClass, __($result[0], 'stdClass')); + \assert($result[0]->animal instanceof \stdClass, __($result[0]->animal, 'stdClass')); + \assert($result[0]->animal->type === 'Herbivore', __($result[0]->animal->type, 'Herbivore')); + }); + }); + describe('.__toString()', function () { it('should behave like .xml()', function () { $xml = new FluidXml();