Completed
Push — master ( 787a24...f25cb1 )
by Tomáš
42:21
created

XMLNode::createFullDOMElement()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 18
c 0
b 0
f 0
rs 9.6666
cc 2
nc 2
nop 4
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
    protected function __construct(DOMDocument $document, ?DOMNode $element)
36
    {
37
        $this->document = $document;
38
        $this->node     = $element;
39
    }
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
    public function addElement(string $name, array $attributes = []): XMLNode
50
    {
51
        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
    public function addTextElement(string $name, $value, array $attributes = [], bool $forcedEscape = false): XMLNode
65
    {
66
        $element = $this->createFullDOMElement($name, $value, $attributes, $forcedEscape);
67
68
        $this->appendChild($element);
69
70
        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
    public function addXMLData(string $content): ?XMLNode
81
    {
82
        if ($content === '') {
83
            return null;
84
        }
85
86
        $element = $this->createDOMFragment($content);
87
88
        $this->appendChild($element);
89
90
        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
    public function createElement(string $name, array $attributes = []): XMLNode
102
    {
103
        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
    public function createTextElement(string $name, $value, array $attributes = [], bool $forcedEscape = false): XMLNode
117
    {
118
        $element = $this->createFullDOMElement($name, $value, $attributes, $forcedEscape);
119
120
        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
    public function append(XMLNode $element): void
131
    {
132
        if ($element->node !== null) {
133
            $this->appendChild($element->node);
134
        }
135
    }
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
    private function createFullDOMElement(
148
        string $name,
149
        $value = null,
150
        array $attributes = [],
151
        bool $forcedEscape = false
152
    ): DOMElement {
153
        $this->registerNamespaces($attributes);
154
155
        $element = $this->createDOMElementNS($name);
156
157
        $this->setDOMElementValue($element, $value, $forcedEscape);
158
159
        foreach ($attributes as $attributeName => $attributeValue) {
160
            $this->setDOMAttributeNS($element, $attributeName, $attributeValue);
161
        }
162
163
        return $element;
164
    }
165
166
    /**
167
     * Create new DOM fragment element
168
     *
169
     * @param string $content
170
     *
171
     * @return \DOMDocumentFragment
172
     */
173
    private function createDOMFragment(string $content): DOMDocumentFragment
174
    {
175
        $element = $this->document->createDocumentFragment();
176
        $element->appendXML($content);
177
178
        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
    private function createDOMElementNS(string $name, $value = null): DOMElement
190
    {
191
        $prefix = Formatter::getNamespacePrefix($name);
192
        $value  = Formatter::encodeValue($value);
193
194
        if ($prefix !== null && XML::hasNamespace($prefix)) {
195
            return $this->document->createElementNS(XML::getNamespace($prefix), $name, $value);
196
        } else {
197
            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
    private function setDOMElementValue(DOMElement $element, $value, bool $forcedEscape = false): void
211
    {
212
        $value = Formatter::encodeValue($value);
213
214
        if ($value === '' || $value === null) {
215
            return;
216
        }
217
218
        try {
219
            if (strpos($value, '&') !== false || $forcedEscape) {
220
                throw new DOMException('DOMDocument::createElement(): unterminated entity reference');
221
            }
222
            $element->nodeValue = $value;
223
        } catch (Throwable $exception) {
224
            $cdata = $this->document->createCDATASection($value);
225
            $element->appendChild($cdata);
226
        }
227
    }
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
    private function setDOMAttributeNS(DOMElement $element, string $name, $value): void
239
    {
240
        $prefix = Formatter::getNamespacePrefix($name);
241
        $value  = Formatter::encodeValue($value);
242
243
        if ($prefix === 'xmlns') {
244
            $element->setAttributeNS('http://www.w3.org/2000/xmlns/', $name, $value);
245
        } elseif ($prefix !== null && XML::hasNamespace($prefix)) {
246
            $element->setAttributeNS(XML::getNamespace($prefix), $name, $value);
247
        } else {
248
            $element->setAttribute($name, $value);
249
        }
250
    }
251
252
    /**
253
     * Append child to parent node.
254
     *
255
     * @param \DOMNode $element
256
     *
257
     * @return void
258
     */
259
    private function appendChild(DOMNode $element): void
260
    {
261
        $parentNode = $this->node ?: $this->document;
262
        $parentNode->appendChild($element);
263
    }
264
265
    /**
266
     * Register xmlns namespace URLs
267
     *
268
     * @param array<string,string> $attributes
269
     *
270
     * @return void
271
     */
272
    private function registerNamespaces(array $attributes): void
273
    {
274
        foreach ($attributes as $attributeName => $attributeValue) {
275
            [$prefix, $namespaceLocalName] = Formatter::parseQualifiedName($attributeName);
276
277
            if ($prefix === 'xmlns') {
278
                XML::registerNamespace($namespaceLocalName, $attributeValue);
279
            }
280
        }
281
    }
282
283
    /**
284
     * Return valid XML string.
285
     *
286
     * @param bool $formatOutput
287
     *
288
     * @return string
289
     *
290
     * @throws \DOMException
291
     */
292
    public function toString(bool $formatOutput = false): string
293
    {
294
        return $this->withErrorHandler(function () use ($formatOutput) {
295
            $this->document->formatOutput = $formatOutput;
296
297
            $xml = $this->node !== null
298
                ? $this->document->saveXML($this->node)
299
                : $this->document->saveXML();
300
301
            if ($xml === false) {
302
                // @codeCoverageIgnoreStart
303
                throw new DOMException('\DOMDocument::saveXML() method failed');
304
                // @codeCoverageIgnoreEnd
305
            }
306
307
            return $xml;
308
        });
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
    public function toArray(Config $options = null): array
319
    {
320
        $result = $this->nodeToArray($this->node ?: $this->document, $options ?: new Config());
321
322
        if (is_array($result) === false) {
323
            $result = [$result];
324
        }
325
326
        return $result;
327
    }
328
329
    /**
330
     * Get node text content
331
     *
332
     * @return string|null
333
     */
334
    public function getTextContent(): ?string
335
    {
336
        if ($this->node === null) {
337
            return null;
338
        }
339
340
        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
    private function nodeToArray(DOMNode $node, Config $options)
352
    {
353
        $result = [
354
            $options->getAttributesName() => [],
355
            $options->getValueName()      => null,
356
            $options->getNodesName()      => [],
357
        ];
358
359
        /** @var \DOMAttr $attribute */
360
        foreach ($node->attributes as $attribute) {
361
            $result[$options->getAttributesName()][$attribute->nodeName] = $options->isAutoCast()
362
                ? Formatter::decodeValue($attribute->nodeValue)
363
                : $attribute->nodeValue;
364
        }
365
366
        /** @var \DOMNode $child */
367
        foreach ($node->childNodes as $child) {
368
            if (in_array($child->nodeType, [XML_TEXT_NODE, XML_CDATA_SECTION_NODE])) {
369
                if (trim($child->nodeValue) !== '') {
370
                    $result[$options->getValueName()] = $options->isAutoCast()
371
                        ? Formatter::decodeValue($child->nodeValue)
372
                        : $child->nodeValue;
373
                }
374
                continue;
375
            }
376
377
            $result[$options->getNodesName()][$child->nodeName][] = $this->nodeToArray($child, $options);
378
        }
379
380
        if ($options->isFullResponse()) {
381
            return $result;
382
        }
383
384
        if (count($result[$options->getNodesName()]) === 0 && count($result[$options->getAttributesName()]) === 0) {
385
            return $result[$options->getValueName()];
386
        }
387
388
        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
    private function simplifyArray(array $result, Config $options, DOMNode $node): array
401
    {
402
        $simpleResult = $result[$options->getNodesName()];
403
        foreach ($simpleResult as $nodeName => $values) {
404
            if (
405
                in_array($nodeName, $options->getAlwaysArray()) === false
406
                && in_array($node->nodeName . '.' . $nodeName, $options->getAlwaysArray()) === false
407
                && array_keys($values) === [0]
408
            ) {
409
                $simpleResult[$nodeName] = $values[0];
410
            }
411
        }
412
413
        if (count($result[$options->getAttributesName()]) > 0) {
414
            $simpleResult[$options->getAttributesName()] = $result[$options->getAttributesName()];
415
        }
416
417
        if ($result[$options->getValueName()] !== null) {
418
            $simpleResult[$options->getValueName()] = $result[$options->getValueName()];
419
        }
420
421
        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
    protected function withErrorHandler(callable $callback)
434
    {
435
        set_error_handler(function (int $code, string $message) {
436
            if (strpos($message, 'DOMDocument::') !== false) {
437
                throw new DOMException($message, $code);
438
            }
439
        });
440
441
        $response = $callback();
442
443
        restore_error_handler();
444
445
        return $response;
446
    }
447
448
    /**
449
     * Convert to string
450
     *
451
     * @return string
452
     */
453
    public function __toString()
454
    {
455
        return $this->toString();
456
    }
457
}
458