Passed
Push — master ( 66e368...96dffb )
by Tomáš
02:06
created

BaseNode::addTextElement()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 3
c 1
b 0
f 0
dl 0
loc 7
ccs 4
cts 4
cp 1
rs 10
cc 1
nc 1
nop 5
crap 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Inspirum\XML\Builder;
6
7
use DOMDocument;
8
use DOMDocumentFragment;
9
use DOMElement;
10
use DOMException;
11
use DOMNode;
12
use DOMXpath;
13
use Inspirum\XML\Exception\Handler;
14
use Inspirum\XML\Formatter\Config;
15
use Inspirum\XML\Formatter\DefaultConfig;
16
use Inspirum\XML\Formatter\Formatter;
17
use Inspirum\XML\Parser\Parser;
18
use Throwable;
19
use function is_array;
20
use function is_string;
21
use function str_contains;
22
23
abstract class BaseNode implements Node
24
{
25 98
    protected function __construct(
26
        private readonly DOMDocument $document,
27
        private readonly ?DOMNode $node,
28
        private readonly NamespaceRegistry $namespaceRegistry,
29
    ) {
30 98
    }
31
32 73
    protected function createNode(DOMNode $element): Node
33
    {
34 73
        return new DefaultNode($this->document, $element, $this->namespaceRegistry);
35
    }
36
37 11
    public function getDocument(): DOMDocument
38
    {
39 11
        return $this->document;
40
    }
41
42 26
    public function getNode(): ?DOMNode
43
    {
44 26
        return $this->node;
45
    }
46
47
    /**
48
     * @inheritDoc
49
     */
50 42
    public function addElement(string $name, array $attributes = [], bool $withNamespaces = true): Node
51
    {
52 42
        return $this->addTextElement($name, null, $attributes, withNamespaces: $withNamespaces);
53
    }
54
55
    /**
56
     * @inheritDoc
57
     */
58 48
    public function addTextElement(string $name, mixed $value, array $attributes = [], bool $forcedEscape = false, bool $withNamespaces = true): Node
59
    {
60 48
        $element = $this->createFullDOMElement($name, $value, $attributes, $forcedEscape, $withNamespaces);
61
62 42
        $this->appendChild($element);
63
64 42
        return $this->createNode($element);
65
    }
66
67
    public function addElementFromNode(DOMNode $node, bool $forcedEscape = false, bool $withNamespaces = true): Node
68
    {
69
        return $this->addTextElement($node->nodeName, $node->textContent, $this->getAttributesFromNode($node), $forcedEscape, $withNamespaces);
70
    }
71
72 26
    public function append(Node $element): void
73
    {
74 26
        if ($element->getNode() !== null) {
75 26
            $this->appendChild($element->getNode());
76
        }
77
    }
78
79
    /**
80
     * @inheritDoc
81
     */
82 1
    public function createElement(string $name, array $attributes = [], bool $withNamespaces = true): Node
83
    {
84 1
        return $this->createTextElement($name, null, $attributes, withNamespaces: $withNamespaces);
85
    }
86
87
    /**
88
     * @inheritDoc
89
     */
90 32
    public function createTextElement(string $name, mixed $value, array $attributes = [], bool $forcedEscape = false, bool $withNamespaces = true): Node
91
    {
92 32
        $element = $this->createFullDOMElement($name, $value, $attributes, $forcedEscape, $withNamespaces);
93
94 32
        return $this->createNode($element);
95
    }
96
97 6
    public function createElementFromNode(DOMNode $node, bool $forcedEscape = false, bool $withNamespaces = true): Node
98
    {
99 6
        return $this->createTextElement($node->nodeName, $node->textContent, $this->getAttributesFromNode($node), $forcedEscape, $withNamespaces);
100
    }
101
102 4
    public function addXMLData(string $content): ?Node
103
    {
104 4
        if ($content === '') {
105 1
            return null;
106
        }
107
108 3
        $element = $this->createDOMFragment($content);
109
110 3
        $this->appendChild($element);
111
112 3
        return $this->createNode($element);
113
    }
114
115
    /**
116
     * Create new DOM element.
117
     *
118
     * @param array<string,mixed> $attributes
119
     *
120
     * @throws \DOMException
121
     */
122 79
    private function createFullDOMElement(string $name, mixed $value, array $attributes, bool $forcedEscape, bool $withNamespaces): DOMElement
123
    {
124 79
        $this->registerNamespaces($attributes);
125
126 76
        $element = $this->createDOMElementNS($name, null, $withNamespaces);
127
128 73
        $this->setDOMElementValue($element, $value, $forcedEscape);
129
130 73
        foreach ($attributes as $attributeName => $attributeValue) {
131 50
            $this->setDOMAttributeNS($element, $attributeName, $attributeValue, $withNamespaces);
132
        }
133
134 73
        return $element;
135
    }
136
137
    /**
138
     * Create new DOM fragment element
139
     */
140 3
    private function createDOMFragment(string $content): DOMDocumentFragment
141
    {
142 3
        $element = $this->document->createDocumentFragment();
143
144 3
        $element->appendXML($content);
145
146 3
        return $element;
147
    }
148
149
    /**
150
     * Create new DOM element with namespace if exists
151
     *
152
     * @throws \DOMException
153
     */
154 76
    private function createDOMElementNS(string $name, ?string $value, bool $withNamespaces): DOMElement
155
    {
156 76
        $prefix = Parser::getNamespacePrefix($name);
157 73
        $value  = Formatter::encodeValue($value);
158
159 73
        if ($withNamespaces && $prefix !== null && $this->namespaceRegistry->hasNamespace($prefix)) {
160 10
            return $this->document->createElementNS($this->namespaceRegistry->getNamespace($prefix), $name, (string) $value);
161
        }
162
163 69
        return $this->document->createElement($name, (string) $value);
164
    }
165
166
    /**
167
     * Set node value to element
168
     */
169 73
    private function setDOMElementValue(DOMElement $element, mixed $value, bool $forcedEscape): void
170
    {
171 73
        $value = Formatter::encodeValue($value);
172
173 73
        if ($value === '' || $value === null) {
174 65
            return;
175
        }
176
177
        try {
178 60
            if (str_contains($value, '&') || $forcedEscape) {
179 1
                throw new DOMException('DOMDocument::createElement(): unterminated entity reference');
180
            }
181
182 60
            $element->nodeValue = $value;
183 1
        } catch (Throwable) {
184 1
            $cdata = $this->document->createCDATASection($value);
185 1
            $element->appendChild($cdata);
186
        }
187
    }
188
189
    /**
190
     * Create new DOM attribute with namespace if exists
191
     */
192 50
    private function setDOMAttributeNS(DOMElement $element, string $name, mixed $value, bool $withNamespaces): void
193
    {
194 50
        $prefix = Parser::getNamespacePrefix($name);
195 50
        $value  = Formatter::encodeValue($value);
196
197 50
        if ($withNamespaces && $prefix === 'xmlns') {
198 23
            $element->setAttributeNS('http://www.w3.org/2000/xmlns/', $name, (string) $value);
199 47
        } elseif ($withNamespaces && $prefix !== null && $this->namespaceRegistry->hasNamespace($prefix)) {
200 8
            $element->setAttributeNS($this->namespaceRegistry->getNamespace($prefix), $name, (string) $value);
201 41
        } elseif ($prefix !== 'xmlns') {
202 41
            $element->setAttribute($name, (string) $value);
203
        }
204
    }
205
206
    /**
207
     * Append child to parent node.
208
     */
209 68
    private function appendChild(DOMNode $element): void
210
    {
211 68
        $node = $this->resolveNode();
212 68
        $node->appendChild($element);
213
    }
214
215
    /**
216
     * Register xmlns namespace URLs
217
     *
218
     * @param array<string,mixed> $attributes
219
     */
220 79
    private function registerNamespaces(array $attributes): void
221
    {
222 79
        foreach ($attributes as $attributeName => $attributeValue) {
223 53
            [$prefix, $namespaceLocalName] = Parser::parseQualifiedName($attributeName);
224
225 53
            if ($prefix === 'xmlns' && is_string($attributeValue)) {
226 27
                $this->namespaceRegistry->registerNamespace($namespaceLocalName, $attributeValue);
227
            }
228
        }
229
    }
230
231 14
    public function getTextContent(): ?string
232
    {
233 14
        $node = $this->resolveNode();
234
235 14
        return $node->textContent;
236
    }
237
238
    /**
239
     * @inheritDoc
240
     */
241 1
    public function getAttributes(bool $autoCast = false): array
242
    {
243 1
        $node = $this->resolveNode();
244
245 1
        return $this->getAttributesFromNode($node, $autoCast);
246
    }
247
248
    /**
249
     * Get attributes from \DOMNode
250
     *
251
     * @return ($autoCast is true ? array<string,mixed> : array<string,string>)
0 ignored issues
show
Documentation Bug introduced by
The doc comment ($autoCast at position 1 could not be parsed: Unknown type name '$autoCast' at position 1 in ($autoCast.
Loading history...
252
     */
253 7
    private function getAttributesFromNode(DOMNode $node, bool $autoCast = false): array
254
    {
255 7
        $attributes = [];
256
257 7
        if ($node->hasAttributes()) {
258
            /** @var \DOMAttr $attribute */
259 4
            foreach ($node->attributes ?? [] as $attribute) {
260 4
                $value                            = $attribute->nodeValue;
261 4
                $attributes[$attribute->nodeName] = $autoCast ? Formatter::decodeValue($value) : $value;
262
            }
263
        }
264
265 7
        return $attributes;
266
    }
267
268
    /**
269
     * @inheritDoc
270
     */
271 7
    public function xpath(string $expression): ?array
272
    {
273 7
        $xpath = new DOMXpath($this->toDOMDocument());
274
275 7
        $nodes = $xpath->query($expression);
276 6
        if ($nodes === false) {
277
            return null;
278
        }
279
280 6
        $results = [];
281 6
        foreach ($nodes as $node) {
282 6
            $results[] = $this->createElementFromNode($node);
283
        }
284
285 6
        return $results;
286
    }
287
288
    /**
289
     * Copy current node to new \DOMDocument
290
     */
291 7
    private function toDOMDocument(): DOMDocument
292
    {
293 7
        $doc = new DOMDocument($this->document->xmlVersion ?? '1.0', $this->document->encoding ?? 'UTF-8');
294 7
        $doc->loadXML($this->toString());
295
296 7
        return $doc;
297
    }
298
299
    /**
300
     * Resolve current node
301
     */
302 72
    private function resolveNode(): DOMNode
303
    {
304 72
        return $this->node ?? $this->document;
305
    }
306
307 48
    public function toString(bool $formatOutput = false): string
308
    {
309 48
        return Handler::withErrorHandlerForDOMDocument(function () use ($formatOutput): string {
310 48
            $this->document->formatOutput = $formatOutput;
311
312 48
            $xml = $this->document->saveXML($this->node);
313 48
            if ($xml === false) {
314 1
                throw new DOMException('\DOMDocument::saveXML() method failed');
315
            }
316
317 47
            return $xml;
318 48
        });
319
    }
320
321 1
    public function __toString(): string
322
    {
323 1
        return $this->toString();
324
    }
325
326
    /**
327
     * @inheritDoc
328
     */
329 19
    public function toArray(?Config $config = null): array
330
    {
331 19
        $result = Formatter::nodeToArray($this->resolveNode(), $config ?? new DefaultConfig());
332
333 19
        if (is_array($result) === false) {
334 2
            $result = [$result];
335
        }
336
337 19
        return $result;
338
    }
339
340
    /**
341
     * @inheritDoc
342
     */
343 1
    public function __toArray(): array
344
    {
345 1
        return $this->toArray();
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->toArray() returns the type array<integer,array> which is incompatible with the return type mandated by Inspirum\Arrayable\Arrayable::__toArray() of Inspirum\Arrayable\TValue[].

In the issue above, the returned value is violating the contract defined by the mentioned interface.

Let's take a look at an example:

interface HasName {
    /** @return string */
    public function getName();
}

class Name {
    public $name;
}

class User implements HasName {
    /** @return string|Name */
    public function getName() {
        return new Name('foo'); // This is a violation of the ``HasName`` interface
                                // which only allows a string value to be returned.
    }
}
Loading history...
346
    }
347
348
    /**
349
     * Convert to array
350
     *
351
     * @return array<int|string,mixed>
352
     */
353 1
    public function jsonSerialize(): array
354
    {
355 1
        return $this->toArray();
356
    }
357
}
358