Passed
Push — master ( 980804...08b834 )
by Mihail
09:48
created

XmlSerializer::parseXmlValue()   C

Complexity

Conditions 12
Paths 8

Size

Total Lines 32
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 156

Importance

Changes 4
Bugs 0 Features 0
Metric Value
eloc 21
c 4
b 0
f 0
dl 0
loc 32
ccs 0
cts 5
cp 0
rs 6.9666
cc 12
nc 8
nop 1
crap 156

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
/*
4
 * This file is part of the Koded package.
5
 *
6
 * (c) Mihail Binev <[email protected]>
7
 *
8
 * Please view the LICENSE distributed with this source code
9
 * for the full copyright and license information.
10
 */
11
12
namespace Koded\Stdlib\Serializer;
13
14
use DateTimeImmutable;
15
use DateTimeInterface;
16
use DOMDocument;
17
use DOMNode;
18
use Koded\Stdlib\Serializer;
19
use Throwable;
20
use function Koded\Stdlib\{json_serialize, json_unserialize};
0 ignored issues
show
Bug introduced by
The type Koded\Stdlib\json_serialize was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
Bug introduced by
The type Koded\Stdlib\json_unserialize was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
21
22
/**
23
 * Class XmlSerializer is heavily modified Symfony encoder (XmlEncoder).
24
 *
25
 * @see https://www.w3.org/TR/xmlschema-2/#built-in-datatypes
26
 */
27
class XmlSerializer implements Serializer
28
{
29
    /** @var string The key name of the node value */
30
    private string $val = '#';
31
32
    private string|null $root;
1 ignored issue
show
Bug introduced by
The type Koded\Stdlib\Serializer\null was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
33 2
    private DOMDocument $document;
34
35 2
    public function __construct(?string $root, string $nodeKey = '#')
36 2
    {
37
        $this->root = $root;
38
        $nodeKey = \trim($nodeKey);
39
        if ('@' === $nodeKey || empty($nodeKey)) {
40
            throw new \InvalidArgumentException('Invalid node key identifier', self::E_INVALID_SERIALIZER);
41
        }
42
        $this->val = $nodeKey;
43
    }
44
45
    public function type(): string
46
    {
47
        return Serializer::XML;
48
    }
49
50
    final public function val(): string
51
    {
52
        return $this->val;
53
    }
54
55
    /**
56
     * @param iterable $data
57
     *
58
     * @return string XML
59
     */
60
    public function serialize(mixed $data): ?string
61 1
    {
62
        $this->document = new DOMDocument('1.0', 'UTF-8');
63 1
        $this->document->formatOutput = false;
64
        if (\is_iterable($data)) {
65
            $root = $this->document->createElement($this->root);
66 1
            $this->document->appendChild($root);
67 1
            $this->document->createAttributeNS('http://www.w3.org/2001/XMLSchema-instance', 'xsi:' . $this->root);
68 1
            $this->buildXml($root, $data);
69
        } else {
70
            $this->appendNode($this->document, $data, $this->root);
71
        }
72
        return $this->document->saveXML();
73
    }
74 1
75
    /**
76 1
     * Unserialize a proper XML document into array, scalar value or NULL.
77
     *
78
     * @param string $xml XML
79
     *
80
     * @return mixed scalar|array|null
81
     */
82
    public function unserialize(string $xml): mixed
83
    {
84
        try {
85
            $document = new DOMDocument('1.0', 'UTF-8');
86
            $document->preserveWhiteSpace = false;
87
            $document->loadXML($xml);
88
            if ($document->documentElement->hasChildNodes()) {
89
                return $this->parseXml($document->documentElement);
90
            }
91
            return false === $document->documentElement->getAttributeNode('xmlns:xsi')
92
                ? $this->parseXml($document->documentElement)
93
                : [];
94
95
        } catch (Throwable $e) {
96
            \error_log(PHP_EOL . "[{$e->getLine()}]: " . $e->getMessage());
97
            return null;
98
        }
99
    }
100
101
    private function buildXml(DOMNode $parent, iterable $data): void
102
    {
103
        foreach ($data as $key => $data) {
104
            $isKeyNumeric = \is_numeric($key);
105
            if (0 === \strpos($key, '@') && $name = \substr($key, 1)) {
106
                // a node attribute
107
                $parent->setAttribute($name, $data);
108
            } elseif ($this->val === $key) {
109
                // the node value
110
                $parent->nodeValue = $data;
111
            } elseif (false === $isKeyNumeric && \is_array($data)) {
112
                if (\ctype_digit(\join('', \array_keys($data)))) {
113
                    foreach ($data as $d) {
114
                        $this->appendNode($parent, $d, $key);
115
                    }
116
                } else {
117
                    $this->appendNode($parent, $data, $key);
118
                }
119
            } elseif ($isKeyNumeric) {
120
                $this->appendNode($parent, $data, 'item', $key);
121
            } else {
122
                $this->appendNode($parent, $data, $key);
123
            }
124
        }
125
    }
126
127
    private function parseXml(DOMNode $node)
128
    {
129
        $attrs = $this->parseXmlAttributes($node);
130
        $value = $this->parseXmlValue($node);
131
        if (0 === \count($attrs)) {
132
            return $value;
133
        }
134
        if (false === \is_array($value)) {
135
            $attrs[$this->val] = $value;
136
            return $this->getValueByType($attrs);
137
        }
138
        if (1 === \count($value) && \key($value)) {
0 ignored issues
show
Bug introduced by
$value of type null|string is incompatible with the type array|object expected by parameter $array of key(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

138
        if (1 === \count($value) && \key(/** @scrutinizer ignore-type */ $value)) {
Loading history...
Bug introduced by
$value of type null|string is incompatible with the type Countable|array expected by parameter $value of count(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

138
        if (1 === \count(/** @scrutinizer ignore-type */ $value) && \key($value)) {
Loading history...
139
            $attrs[\key($value)] = \current($value);
0 ignored issues
show
Bug introduced by
$value of type null|string is incompatible with the type array|object expected by parameter $array of current(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

139
            $attrs[\key($value)] = \current(/** @scrutinizer ignore-type */ $value);
Loading history...
140
        }
141
        foreach ($value as $k => $v) {
0 ignored issues
show
Bug introduced by
The expression $value of type null|string is not traversable.
Loading history...
142
            $attrs[$k] = $v;
143
        }
144
        return $attrs;
145
    }
146
147
    private function parseXmlAttributes(DOMNode $node): array
148
    {
149
        if (!$node->hasAttributes()) {
150
            return [];
151
        }
152
        $attrs = [];
153
        foreach ($node->attributes as $attr) {
154
            /** @var \DOMAttr $attr */
155
            $attrs['@' . $attr->nodeName] = $attr->nodeValue;
156
        }
157
        return $attrs;
158
    }
159
160
    /**
161
     * @param DOMNode $node
162
     *
163
     * @return array|string|null
164
     * @throws \Exception
165
     */
166
    private function parseXmlValue(DOMNode $node)
167
    {
168
        $value = [];
169
        if ($node->hasChildNodes()) {
170
            /** @var DOMNode $child */
171
            $child = $node->firstChild;
172
            if ($child->nodeType === XML_TEXT_NODE) {
173
                return $child->nodeValue;
174
            }
175
            if ($child->nodeType === XML_CDATA_SECTION_NODE) {
176
                return $child->wholeText;
177
            }
178
            foreach ($node->childNodes as $child) {
179
                if ($child->nodeType === XML_COMMENT_NODE) {
180
                    continue;
181
                }
182
                $v = $this->parseXml($child);
183
                if ('item' === $child->nodeName && isset($v['@key'])) {
184
                    $k = $v['@key'];
185
                    $value[$k] = $this->getValueByType($v);
186
                    unset($value[$k]['@key']);
187
                } else {
188
                    $value[$child->nodeName][] = $this->getValueByType($v);
189
                }
190
            }
191
        }
192
        foreach ($value as $k => $v) {
193
            if (\is_array($v) && 1 === \count($v)) {
194
                $value[$k] = \current($v);
195
            }
196
        }
197
        return $value ?: '';
198
    }
199
200
    /**
201
     * Creates an XML node in the document from the provided value
202
     * according to the PHP type of the value.
203
     *
204
     * @param DOMNode     $parent
205
     * @param mixed       $data
206
     * @param string      $name
207
     * @param string|null $key
208
     */
209
    private function appendNode(DOMNode $parent, $data, string $name, string $key = null): void
210
    {
211
        $element = $this->document->createElement($name);
212
        if (null !== $key) {
213
            $element->setAttribute('key', $key);
214
        }
215
        if (\is_iterable($data)) {
216
            $this->buildXml($element, $data);
217
        } elseif (\is_bool($data)) {
218
            $element->setAttribute('type', 'xsd:boolean');
219
            $element->appendChild($this->document->createTextNode($data));
220
        } elseif (\is_float($data)) {
221
            $element->setAttribute('type', 'xsd:float');
222
            $element->appendChild($this->document->createTextNode($data));
223
        } elseif (\is_int($data)) {
224
            $element->setAttribute('type', 'xsd:integer');
225
            $element->appendChild($this->document->createTextNode($data));
226
        } elseif (null === $data) {
227
            $element->setAttribute('xsi:nil', 'true');
228
        } elseif ($data instanceof DateTimeInterface) {
229
            $element->setAttribute('type', 'xsd:dateTime');
230
            $element->appendChild($this->document->createTextNode($data->format(DateTimeImmutable::ISO8601)));
231
        } elseif (\is_object($data)) {
232
            $element->setAttribute('type', 'xsd:object');
233
            $element->appendChild($this->document->createCDATASection(json_serialize($data)));
234
        } elseif (\preg_match('/[<>&\'"]/', $data) > 0) {
235
            $element->appendChild($this->document->createCDATASection($data));
236
        } else {
237
            $element->appendChild($this->document->createTextNode($data));
238
        }
239
        $parent->appendChild($element);
240
    }
241
242
    /**
243
     * Deserialize the XML document elements into strict PHP values
244
     * in regard to the XSD type defined in the XML element (if any).
245
     *
246
     * IMPORTANT: When deserializing an XML document into values,
247
     * if the XmlSerializer encounters an XML element that specifies xsi:nil="true",
248
     * it assigns a NULL to the corresponding element and ignores any other attributes
249
     *
250
     * @param array|string $value
251
     * @return array|string|null
252
     * @throws \Exception
253
     */
254
    private function getValueByType($value)
255
    {
256
        if (false === \is_array($value)) {
257
            return $value;
258
        }
259
        /*
260
         * if "xsi:nil" is NOT 'true', ignore the xsi:nil and
261
         * process the rest of the attributes for this element
262
         */
263
        if (isset($value['@xsi:nil']) && $value['@xsi:nil'] == 'true') {
264
            unset($value['@xsi:nil']);
265
            return null;
266
        }
267
        if (!(isset($value['@type']) && 0 === \strpos($value['@type'] ?? '', 'xsd:', 0))) {
268
            return $value;
269
        }
270
        switch ($value['@type']) {
271
            case 'xsd:integer':
272
                $value[$this->val] = (int)$value[$this->val];
273
                break;
274
            case 'xsd:boolean':
275
                $value[$this->val] = \filter_var($value[$this->val], FILTER_VALIDATE_BOOLEAN);
276
                break;
277
            case 'xsd:float':
278
                $value[$this->val] = (float)$value[$this->val];
279
                break;
280
            case 'xsd:dateTime':
281
                if (\is_string($value[$this->val])) {
282
                    $value[$this->val] = new DateTimeImmutable($value[$this->val]);
283
                }
284
                break;
285
            case 'xsd:object':
286
                if (\is_string($value[$this->val])) {
287
                    $value[$this->val] = json_unserialize($value[$this->val]);
288
                }
289
        }
290
        unset($value['@type']);
291
        if (\count($value) > 1) {
292
            return $value;
293
        }
294
        return $value[$this->val];
295
    }
296
}
297