Completed
Branch master (f25cb1)
by Tomáš
01:19
created

XMLNode::__toString()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 4
ccs 2
cts 2
cp 1
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 0
crap 1
1
<?php
2
3
namespace Inspirum\XML\Services;
4
5
use DOMDocument;
6
use DOMDocumentFragment;
7
use DOMElement;
8
use DOMException;
9
use DOMNode;
10
use Inspirum\XML\Model\Values\Config;
11
use Throwable;
12
13
class XMLNode
14
{
15
    /**
16
     * DOM Document
17
     *
18
     * @var \DOMDocument
19
     */
20
    protected $document;
21
22
    /**
23
     * DOM Node
24
     *
25
     * @var \DOMNode|null
26
     */
27
    private $node;
28
29
    /**
30
     * XMLNode constructor
31
     *
32
     * @param \DOMDocument  $document
33
     * @param \DOMNode|null $element
34
     */
35 64
    protected function __construct(DOMDocument $document, ?DOMNode $element)
36
    {
37 64
        $this->document = $document;
38 64
        $this->node     = $element;
39 64
    }
40
41
    /**
42
     * Add element to XML node
43
     *
44
     * @param string               $name
45
     * @param array<string,string> $attributes
46
     *
47
     * @return \Inspirum\XML\Services\XMLNode
48
     */
49 34
    public function addElement(string $name, array $attributes = []): XMLNode
50
    {
51 34
        return $this->addTextElement($name, null, $attributes, false);
52
    }
53
54
    /**
55
     * Add text element
56
     *
57
     * @param string               $name
58
     * @param mixed                $value
59
     * @param array<string,string> $attributes
60
     * @param bool                 $forcedEscape
61
     *
62
     * @return \Inspirum\XML\Services\XMLNode
63
     */
64 38
    public function addTextElement(string $name, $value, array $attributes = [], bool $forcedEscape = false): XMLNode
65
    {
66 38
        $element = $this->createFullDOMElement($name, $value, $attributes, $forcedEscape);
67
68 32
        $this->appendChild($element);
69
70 32
        return new self($this->document, $element);
71
    }
72
73
    /**
74
     * Add XML data
75
     *
76
     * @param string $content
77
     *
78
     * @return \Inspirum\XML\Services\XMLNode|null
79
     */
80 4
    public function addXMLData(string $content): ?XMLNode
81
    {
82 4
        if ($content === '') {
83 1
            return null;
84
        }
85
86 3
        $element = $this->createDOMFragment($content);
87
88 3
        $this->appendChild($element);
89
90 3
        return new self($this->document, $element);
91
    }
92
93
    /**
94
     * Create new (unconnected) element
95
     *
96
     * @param string               $name
97
     * @param array<string,string> $attributes
98
     *
99
     * @return \Inspirum\XML\Services\XMLNode
100
     */
101 2
    public function createElement(string $name, array $attributes = []): XMLNode
102
    {
103 2
        return $this->createTextElement($name, null, $attributes, false);
104
    }
105
106
    /**
107
     * Create new (unconnected) text element
108
     *
109
     * @param string               $name
110
     * @param mixed                $value
111
     * @param array<string,string> $attributes
112
     * @param bool                 $forcedEscape
113
     *
114
     * @return \Inspirum\XML\Services\XMLNode
115
     */
116 10
    public function createTextElement(string $name, $value, array $attributes = [], bool $forcedEscape = false): XMLNode
117
    {
118 10
        $element = $this->createFullDOMElement($name, $value, $attributes, $forcedEscape);
119
120 10
        return new self($this->document, $element);
121
    }
122
123
    /**
124
     * Append node to parent node.
125
     *
126
     * @param \Inspirum\XML\Services\XMLNode $element
127
     *
128
     * @return void
129
     */
130 6
    public function append(XMLNode $element): void
131
    {
132 6
        if ($element->node !== null) {
133 6
            $this->appendChild($element->node);
134
        }
135 6
    }
136
137
    /**
138
     * Create new DOM element.
139
     *
140
     * @param string                     $name
141
     * @param string|float|int|bool|null $value
142
     * @param array<string,string>       $attributes
143
     * @param bool                       $forcedEscape
144
     *
145
     * @return \DOMElement
146
     */
147 48
    private function createFullDOMElement(
148
        string $name,
149
        $value = null,
150
        array $attributes = [],
151
        bool $forcedEscape = false
152
    ): DOMElement {
153 48
        $this->registerNamespaces($attributes);
154
155 45
        $element = $this->createDOMElementNS($name);
156
157 42
        $this->setDOMElementValue($element, $value, $forcedEscape);
158
159 42
        foreach ($attributes as $attributeName => $attributeValue) {
160 25
            $this->setDOMAttributeNS($element, $attributeName, $attributeValue);
161
        }
162
163 42
        return $element;
164
    }
165
166
    /**
167
     * Create new DOM fragment element
168
     *
169
     * @param string $content
170
     *
171
     * @return \DOMDocumentFragment
172
     */
173 3
    private function createDOMFragment(string $content): DOMDocumentFragment
174
    {
175 3
        $element = $this->document->createDocumentFragment();
176 3
        $element->appendXML($content);
177
178 3
        return $element;
179
    }
180
181
    /**
182
     * Create new DOM element with namespace if exists
183
     *
184
     * @param string      $name
185
     * @param string|null $value
186
     *
187
     * @return \DOMElement
188
     */
189 45
    private function createDOMElementNS(string $name, $value = null): DOMElement
190
    {
191 45
        $prefix = Formatter::getNamespacePrefix($name);
192 42
        $value  = Formatter::encodeValue($value);
193
194 42
        if ($prefix !== null && XML::hasNamespace($prefix)) {
195 8
            return $this->document->createElementNS(XML::getNamespace($prefix), $name, $value);
196
        } else {
197 38
            return $this->document->createElement($name, $value);
198
        }
199
    }
200
201
    /**
202
     * Set node value to element
203
     *
204
     * @param \DOMElement $element
205
     * @param mixed       $value
206
     * @param bool        $forcedEscape
207
     *
208
     * @return void
209
     */
210 42
    private function setDOMElementValue(DOMElement $element, $value, bool $forcedEscape = false): void
211
    {
212 42
        $value = Formatter::encodeValue($value);
213
214 42
        if ($value === '' || $value === null) {
215 36
            return;
216
        }
217
218
        try {
219 31
            if (strpos($value, '&') !== false || $forcedEscape) {
220 1
                throw new DOMException('DOMDocument::createElement(): unterminated entity reference');
221
            }
222 31
            $element->nodeValue = $value;
223 1
        } catch (Throwable $exception) {
224 1
            $cdata = $this->document->createCDATASection($value);
225 1
            $element->appendChild($cdata);
226
        }
227 31
    }
228
229
    /**
230
     * Create new DOM attribute with namespace if exists
231
     *
232
     * @param \DOMElement      $element
233
     * @param string           $name
234
     * @param string|float|int $value
235
     *
236
     * @return void
237
     */
238 25
    private function setDOMAttributeNS(DOMElement $element, string $name, $value): void
239
    {
240 25
        $prefix = Formatter::getNamespacePrefix($name);
241 25
        $value  = Formatter::encodeValue($value);
242
243 25
        if ($prefix === 'xmlns') {
244 13
            $element->setAttributeNS('http://www.w3.org/2000/xmlns/', $name, $value);
245 23
        } elseif ($prefix !== null && XML::hasNamespace($prefix)) {
246 8
            $element->setAttributeNS(XML::getNamespace($prefix), $name, $value);
247
        } else {
248 17
            $element->setAttribute($name, $value);
249
        }
250 25
    }
251
252
    /**
253
     * Append child to parent node.
254
     *
255
     * @param \DOMNode $element
256
     *
257
     * @return void
258
     */
259 38
    private function appendChild(DOMNode $element): void
260
    {
261 38
        $parentNode = $this->node ?: $this->document;
262 38
        $parentNode->appendChild($element);
263 38
    }
264
265
    /**
266
     * Register xmlns namespace URLs
267
     *
268
     * @param array<string,string> $attributes
269
     *
270
     * @return void
271
     */
272 48
    private function registerNamespaces(array $attributes): void
273
    {
274 48
        foreach ($attributes as $attributeName => $attributeValue) {
275 28
            [$prefix, $namespaceLocalName] = Formatter::parseQualifiedName($attributeName);
276
277 28
            if ($prefix === 'xmlns') {
278 13
                XML::registerNamespace($namespaceLocalName, $attributeValue);
279
            }
280
        }
281 45
    }
282
283
    /**
284
     * Return valid XML string.
285
     *
286
     * @param bool $formatOutput
287
     *
288
     * @return string
289
     *
290
     * @throws \DOMException
291
     */
292 25
    public function toString(bool $formatOutput = false): string
293
    {
294 25
        return $this->withErrorHandler(function () use ($formatOutput) {
295 25
            $this->document->formatOutput = $formatOutput;
296
297 25
            $xml = $this->node !== null
298 7
                ? $this->document->saveXML($this->node)
299 25
                : $this->document->saveXML();
300
301 25
            if ($xml === false) {
302
                // @codeCoverageIgnoreStart
303
                throw new DOMException('\DOMDocument::saveXML() method failed');
304
                // @codeCoverageIgnoreEnd
305
            }
306
307 25
            return $xml;
308 25
        });
309
    }
310
311
    /**
312
     * Convert to array
313
     *
314
     * @param \Inspirum\XML\Model\Values\Config|null $options
315
     *
316
     * @return array<int|string,mixed>
317
     */
318 12
    public function toArray(Config $options = null): array
319
    {
320 12
        $result = $this->nodeToArray($this->node ?: $this->document, $options ?: new Config());
321
322 12
        if (is_array($result) === false) {
323 2
            $result = [$result];
324
        }
325
326 12
        return $result;
327
    }
328
329
    /**
330
     * Get node text content
331
     *
332
     * @return string|null
333
     */
334 7
    public function getTextContent(): ?string
335
    {
336 7
        if ($this->node === null) {
337 1
            return null;
338
        }
339
340 6
        return $this->node->textContent;
341
    }
342
343
    /**
344
     * Convert node to array
345
     *
346
     * @param \DOMNode                          $node
347
     * @param \Inspirum\XML\Model\Values\Config $options
348
     *
349
     * @return array<int|string,mixed>|string|null
350
     */
351 12
    private function nodeToArray(DOMNode $node, Config $options)
352
    {
353
        $result = [
354 12
            $options->getAttributesName() => [],
355 12
            $options->getValueName()      => null,
356 12
            $options->getNodesName()      => [],
357
        ];
358
359
        /** @var \DOMAttr $attribute */
360 12
        foreach ($node->attributes as $attribute) {
361 8
            $result[$options->getAttributesName()][$attribute->nodeName] = $options->isAutoCast()
362 1
                ? Formatter::decodeValue($attribute->nodeValue)
363 7
                : $attribute->nodeValue;
364
        }
365
366
        /** @var \DOMNode $child */
367 12
        foreach ($node->childNodes as $child) {
368 10
            if (in_array($child->nodeType, [XML_TEXT_NODE, XML_CDATA_SECTION_NODE])) {
369 10
                if (trim($child->nodeValue) !== '') {
370 10
                    $result[$options->getValueName()] = $options->isAutoCast()
371 1
                        ? Formatter::decodeValue($child->nodeValue)
372 9
                        : $child->nodeValue;
373
                }
374 10
                continue;
375
            }
376
377 10
            $result[$options->getNodesName()][$child->nodeName][] = $this->nodeToArray($child, $options);
378
        }
379
380 12
        if ($options->isFullResponse()) {
381 1
            return $result;
382
        }
383
384 11
        if (count($result[$options->getNodesName()]) === 0 && count($result[$options->getAttributesName()]) === 0) {
385 9
            return $result[$options->getValueName()];
386
        }
387
388 10
        return $this->simplifyArray($result, $options, $node);
389
    }
390
391
    /**
392
     * Remove unnecessary data
393
     *
394
     * @param array<int|string,mixed>           $result
395
     * @param \Inspirum\XML\Model\Values\Config $options
396
     * @param \DOMNode                          $node
397
     *
398
     * @return array<int|string,mixed>
399
     */
400 10
    private function simplifyArray(array $result, Config $options, DOMNode $node): array
401
    {
402 10
        $simpleResult = $result[$options->getNodesName()];
403 10
        foreach ($simpleResult as $nodeName => $values) {
404
            if (
405 9
                in_array($nodeName, $options->getAlwaysArray()) === false
406 9
                && in_array($node->nodeName . '.' . $nodeName, $options->getAlwaysArray()) === false
407 9
                && array_keys($values) === [0]
408
            ) {
409 8
                $simpleResult[$nodeName] = $values[0];
410
            }
411
        }
412
413 10
        if (count($result[$options->getAttributesName()]) > 0) {
414 7
            $simpleResult[$options->getAttributesName()] = $result[$options->getAttributesName()];
415
        }
416
417 10
        if ($result[$options->getValueName()] !== null) {
418 4
            $simpleResult[$options->getValueName()] = $result[$options->getValueName()];
419
        }
420
421 10
        return $simpleResult;
422
    }
423
424
    /**
425
     * Register custom error handler to throw Exception on warning message
426
     *
427
     * @param callable $callback
428
     *
429
     * @return mixed
430
     *
431
     * @throws \DOMException
432
     */
433 35
    protected function withErrorHandler(callable $callback)
434
    {
435 34
        set_error_handler(function (int $code, string $message) {
436 7
            if (strpos($message, 'DOMDocument::') !== false) {
437 6
                throw new DOMException($message, $code);
438
            }
439 35
        });
440
441 34
        $response = $callback();
442
443 28
        restore_error_handler();
444
445 28
        return $response;
446
    }
447
448
    /**
449
     * Convert to string
450
     *
451
     * @return string
452
     */
453 1
    public function __toString()
454
    {
455 1
        return $this->toString();
456
    }
457
}
458