Passed
Push — master ( 51f154...20806c )
by Mihail
02:01
created

XmlSerializer::val()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 1
Metric Value
eloc 1
c 1
b 0
f 1
dl 0
loc 3
ccs 2
cts 2
cp 1
rs 10
cc 1
nc 1
nop 0
crap 1
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, 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...
34
use function key;
35
use function preg_match;
36
use function str_contains;
37
use function str_starts_with;
38
use function substr;
39
use function trim;
40
41
/**
42
 * Class XmlSerializer is heavily modified Symfony encoder (XmlEncoder).
43
 *
44
 * @see https://www.w3.org/TR/xmlschema-2/#built-in-datatypes
45
 */
46
class XmlSerializer implements Serializer
47
{
48
    /** @var string The key name for the node value */
49
    private string $val = '#';
50
    private string|null $root;
51
52 28
    public function __construct(?string $root, string $nodeKey = '#')
53
    {
54 28
        $this->root = $root;
55 28
        $nodeKey = trim($nodeKey);
56 28
        if ('@' === $nodeKey || empty($nodeKey)) {
57 1
            throw new InvalidArgumentException('Invalid node key identifier', self::E_INVALID_SERIALIZER);
58
        }
59 28
        $this->val = $nodeKey;
60 28
    }
61
62 2
    public function type(): string
63
    {
64 2
        return Serializer::XML;
65
    }
66
67 1
    final public function val(): string
68
    {
69 1
        return $this->val;
70
    }
71
72
    /**
73
     * @param iterable $data
74
     *
75
     * @return string|null XML
76
     */
77 15
    public function serialize(mixed $data): string|null
78
    {
79 15
        $document = new DOMDocument('1.0', 'UTF-8');
80 15
        $document->formatOutput = false;
81 15
        if (is_iterable($data)) {
82 9
            $root = $document->createElement($this->root);
83 9
            $document->appendChild($root);
84 9
            $document->createAttributeNS('http://www.w3.org/2001/XMLSchema-instance', 'xsi:' . $this->root);
85 9
            $this->buildXml($document, $root, $data);
86
        } else {
87 6
            $this->appendNode($document, $document, $data, $this->root);
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

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

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