XmlSerializer   F
last analyzed

Complexity

Total Complexity 64

Size/Duplication

Total Lines 284
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 15
Bugs 0 Features 3
Metric Value
wmc 64
eloc 133
c 15
b 0
f 3
dl 0
loc 284
ccs 140
cts 140
cp 1
rs 3.28

13 Methods

Rating   Name   Duplication   Size   Complexity  
B appendNode() 0 36 10
A __construct() 0 8 3
A serialize() 0 13 2
A unserialize() 0 16 4
A type() 0 3 1
A val() 0 3 1
A hasValidName() 0 5 3
A parseXmlAttributes() 0 11 3
B parseXmlValue() 0 20 8
A extractValuesFromChildNodes() 0 13 5
A parseXml() 0 18 6
B buildXml() 0 37 11
B getValueByType() 0 28 7

How to fix   Complexity   

Complex Class

Complex classes like XmlSerializer often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use XmlSerializer, and based on these observations, apply Extract Interface, too.

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 InvalidArgumentException;
19
use Koded\Stdlib\Serializer;
20
use Throwable;
21
use function array_is_list;
0 ignored issues
show
introduced by
The function array_is_list was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
22
use function count;
23
use function current;
24
use function error_log;
25
use function filter_var;
26
use function is_array;
27
use function is_bool;
28
use function is_float;
29
use function is_int;
30
use function is_iterable;
31
use function is_numeric;
32
use function is_object;
33
use function Koded\Stdlib\json_serialize;
34
use function Koded\Stdlib\json_unserialize;
35
use function key;
36
use function preg_match;
37
use function str_contains;
38
use function str_starts_with;
39
use function substr;
40
use function trim;
41
42
/**
43
 * Class XmlSerializer is heavily modified Symfony encoder (XmlEncoder).
44
 *
45
 * @see https://www.w3.org/TR/xmlschema-2/#built-in-datatypes
46
 */
47
class XmlSerializer implements Serializer
48
{
49
    /** @var string The key name for the node value */
50
    private string $val = '#';
51
    private string|null $root;
52
53 28
    public function __construct(?string $root, string $nodeKey = '#')
54
    {
55 28
        $this->root = $root;
56 28
        $nodeKey = trim($nodeKey);
57 28
        if ('@' === $nodeKey || empty($nodeKey)) {
58 1
            throw new InvalidArgumentException('Invalid node key identifier', self::E_INVALID_SERIALIZER);
59
        }
60 28
        $this->val = $nodeKey;
61
    }
62
63 2
    public function type(): string
64
    {
65 2
        return Serializer::XML;
66
    }
67
68 1
    final public function val(): string
69
    {
70 1
        return $this->val;
71
    }
72
73
    /**
74
     * @param iterable $data
75
     *
76
     * @return string|null XML
77
     */
78 15
    public function serialize(mixed $data): string|null
79
    {
80 15
        $document = new DOMDocument('1.0', 'UTF-8');
81 15
        $document->formatOutput = false;
82 15
        if (is_iterable($data)) {
83 9
            $root = $document->createElement($this->root);
84 9
            $document->appendChild($root);
85 9
            $document->createAttributeNS('http://www.w3.org/2001/XMLSchema-instance', 'xsi:' . $this->root);
86 9
            $this->buildXml($document, $root, $data);
87
        } else {
88 6
            $this->appendNode($document, $document, $data, $this->root, null);
0 ignored issues
show
Bug introduced by
It seems like $this->root can also be of type null; however, parameter $name of Koded\Stdlib\Serializer\...erializer::appendNode() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

88
            $this->appendNode($document, $document, $data, /** @scrutinizer ignore-type */ $this->root, null);
Loading history...
89
        }
90 15
        return trim($document->saveXML());
91
    }
92
93
    /**
94
     * Unserialize a proper XML document into array, scalar value or NULL.
95
     *
96
     * @param string $xml XML
97
     *
98
     * @return mixed scalar|array|null
99
     */
100 19
    public function unserialize(string $xml): mixed
101
    {
102
        try {
103 19
            $document = new DOMDocument('1.0', 'UTF-8');
104 19
            $document->preserveWhiteSpace = false;
105 19
            $document->loadXML($xml);
106 15
            if ($document->documentElement->hasChildNodes()) {
107 13
                return $this->parseXml($document->documentElement);
108
            }
109 2
            return !$document->documentElement->getAttributeNode('xmlns:xsi')
110 1
                ? $this->parseXml($document->documentElement)
111 2
                : [];
112
113 4
        } catch (Throwable $e) {
114 4
            error_log(PHP_EOL . "[{$e->getLine()}]: " . $e->getMessage());
115 4
            return null;
116
        }
117
    }
118
119 9
    private function buildXml(
120
        DOMDocument $document,
121
        DOMNode $parent,
122
        iterable $data): void
123
    {
124 9
        foreach ($data as $key => $val) {
125 8
            $isKeyNumeric = is_numeric($key);
126 8
            if (str_starts_with($key, '@') && $name = substr($key, 1)) {
127
                // node attribute
128 2
                $parent->setAttribute($name, $val);
129 8
            } elseif ($this->val === $key) {
130
                // node value
131 2
                $parent->nodeValue = $val;
132 8
            } elseif (false === $isKeyNumeric && is_array($val)) {
133
                /*
134
                 * If the data is an associative array (with numeric keys)
135
                 * the structure is transformed to "item" nodes:
136
                 *      <item key="0">$key0</item>
137
                 *      <item key="1">$key1</item>
138
                 * by appending it to the parent node (if any)
139
                 */
140 6
                if (array_is_list($val)) {
0 ignored issues
show
Bug introduced by
The function array_is_list was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

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

140
                if (/** @scrutinizer ignore-call */ array_is_list($val)) {
Loading history...
141 4
                    foreach ($val as $d) {
142 4
                        $this->appendNode($document, $parent, $d, $key, null);
143
                    }
144
                } else {
145 6
                    $this->appendNode($document, $parent, $val, $key, null);
146
                }
147 7
            } elseif ($isKeyNumeric || false === $this->hasValidName($key)) {
148
                /* If the key is not a valid XML tag name,
149
                 * transform the key to "item" node:
150
                 *      <item key="$key">$value</item>
151
                 * by appending it to the parent node (if any)
152
                 */
153 5
                $this->appendNode($document, $parent, $val, 'item', $key);
154
            } else {
155 5
                $this->appendNode($document, $parent, $val, $key, null);
156
            }
157
        }
158
    }
159
160 14
    private function parseXml(DOMNode $node): mixed
161
    {
162 14
        $attrs = $this->parseXmlAttributes($node);
163 14
        $value = $this->parseXmlValue($node);
164 14
        if (0 === count($attrs)) {
165 13
            return $value;
166
        }
167 9
        if (false === is_array($value)) {
168 9
            $attrs[$this->val] = $value;
169 9
            return $this->getValueByType($attrs);
170
        }
171 3
        if (1 === count($value) && key($value)) {
172 3
            $attrs[key($value)] = current($value);
173
        }
174 3
        foreach ($value as $k => $v) {
175 3
            $attrs[$k] = $v;
176
        }
177 3
        return $attrs;
178
    }
179
180 14
    private function parseXmlAttributes(DOMNode $node): array
181
    {
182 14
        if (!$node->hasAttributes()) {
183 13
            return [];
184
        }
185 9
        $attrs = [];
186 9
        foreach ($node->attributes as $attr) {
187
            /** @var \DOMAttr $attr */
188 9
            $attrs['@' . $attr->nodeName] = $attr->nodeValue;
189
        }
190 9
        return $attrs;
191
    }
192
193
    /**
194
     * @param DOMNode $node
195
     *
196
     * @return array|string|null
197
     * @throws \Exception
198
     */
199 14
    private function parseXmlValue(DOMNode $node): mixed
200
    {
201 14
        $value = [];
202 14
        if ($node->hasChildNodes()) {
203
            /** @var DOMNode $child */
204 13
            $child = $node->firstChild;
205 13
            if ($child->nodeType === XML_TEXT_NODE) {
206 12
                return $child->nodeValue;
207
            }
208 9
            if ($child->nodeType === XML_CDATA_SECTION_NODE) {
209 4
                return $child->wholeText;
210
            }
211 9
            $this->extractValuesFromChildNodes($node, $value);
212
        }
213 10
        foreach ($value as $k => $v) {
214 8
            if (is_array($v) && 1 === count($v)) {
215 8
                $value[$k] = current($v);
216
            }
217
        }
218 10
        return $value ?: '';
219
    }
220
221
    /**
222
     * Creates an XML node in the document from the provided value
223
     * according to the PHP type of the value.
224
     *
225
     * @param DOMDocument $document
226
     * @param DOMNode $parent
227
     * @param mixed $data
228
     * @param string $name
229
     * @param string|null $key
230
     */
231 14
    private function appendNode(
232
        DOMDocument $document,
233
        DOMNode $parent,
234
        mixed $data,
235
        string $name,
236
        ?string $key): void
237
    {
238 14
        $element = $document->createElement($name);
239 14
        if (null !== $key) {
240 5
            $element->setAttribute('key', $key);
241
        }
242 14
        if (is_iterable($data)) {
243 6
            $this->buildXml($document, $element, $data);
244 13
        } elseif (is_bool($data)) {
245 3
            $element->setAttribute('type', 'xsd:boolean');
246 3
            $element->appendChild($document->createTextNode($data));
247 13
        } elseif (is_float($data)) {
248 3
            $element->setAttribute('type', 'xsd:float');
249 3
            $element->appendChild($document->createTextNode($data));
250 13
        } elseif (is_int($data)) {
251 5
            $element->setAttribute('type', 'xsd:integer');
252 5
            $element->appendChild($document->createTextNode($data));
253 11
        } elseif (null === $data) {
254 4
            $element->setAttribute('xsi:nil', 'true');
255 10
        } elseif ($data instanceof DateTimeInterface) {
256 3
            $element->setAttribute('type', 'xsd:dateTime');
257 3
            $element->appendChild($document->createTextNode($data->format(DateTimeInterface::ISO8601)));
258 10
        } elseif (is_object($data)) {
259 3
            $element->setAttribute('type', 'xsd:object');
260 3
            $element->appendChild($document->createCDATASection(json_serialize($data)));
261 10
        } elseif (preg_match('/[<>&\'"]/', $data) > 0) {
262 4
            $element->appendChild($document->createCDATASection($data));
263
        } else {
264 10
            $element->appendChild($document->createTextNode($data));
265
        }
266 14
        $parent->appendChild($element);
267
    }
268
269
    /**
270
     * Deserialize the XML document elements into strict PHP values
271
     * in regard to the XSD type defined in the XML element (if any).
272
     *
273
     * [IMPORTANT]: When deserializing an XML document into values,
274
     * if the XmlSerializer encounters an XML element that specifies xsi:nil="true",
275
     * it assigns a NULL to the corresponding element and ignores any other attributes
276
     *
277
     * @param array|string $value
278
     * @return mixed array|string|null
279
     * @throws \Exception
280
     */
281 9
    private function getValueByType(mixed $value): mixed
282
    {
283 9
        if (false === is_array($value)) {
284 5
            return $value;
285
        }
286
        /*
287
         * [NOTE] if "xsi:nil" is NOT 'true', ignore the xsi:nil
288
         * and process the rest of the attributes for this element
289
         */
290 9
        if (isset($value['@xsi:nil']) && $value['@xsi:nil'] == 'true') {
291 2
            unset($value['@xsi:nil']);
292 2
            return null;
293
        }
294 9
        if (!(isset($value['@type']) && str_starts_with($value['@type'] ?? '', 'xsd:'))) {
295 8
            return $value;
296
        }
297 4
        $value[$this->val] = match ($value['@type']) {
298 4
            'xsd:integer' => (int)$value[$this->val],
299 4
            'xsd:boolean' => filter_var($value[$this->val], FILTER_VALIDATE_BOOL),
300 4
            'xsd:float' => (float)$value[$this->val],
301 4
            'xsd:dateTime' => new DateTimeImmutable($value[$this->val]),
302 4
            'xsd:object' => json_unserialize($value[$this->val]),
303 4
        };
304 4
        unset($value['@type']);
305 4
        if (count($value) > 1) {
306 1
            return $value;
307
        }
308 3
        return $value[$this->val];
309
    }
310
311 6
    private function hasValidName(int|string $key): bool
312
    {
313 6
        return $key &&
314 6
            !str_contains($key, ' ') &&
315 6
            preg_match('~^[\pL_][\pL0-9._:-]*$~ui', $key);
316
    }
317
318 9
    private function extractValuesFromChildNodes(DOMNode $node, array &$value): void
319
    {
320 9
        foreach ($node->childNodes as $child) {
321 9
            if ($child->nodeType === XML_COMMENT_NODE) {
322 4
                continue;
323
            }
324 8
            $v = $this->parseXml($child);
325 8
            if ('item' === $child->nodeName && isset($v['@key'])) {
326 4
                $k = $v['@key'];
327 4
                $value[$k] = $this->getValueByType($v);
328 4
                unset($value[$k]['@key']);
329
            } else {
330 7
                $value[$child->nodeName][] = $this->getValueByType($v);
331
            }
332
        }
333
    }
334
}
335