Passed
Push — master ( 9ea496...a3d463 )
by Mihail
10:40
created

XmlSerializer::val()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 1
Metric Value
eloc 1
c 1
b 0
f 1
dl 0
loc 3
ccs 0
cts 2
cp 0
rs 10
cc 1
nc 1
nop 0
crap 2
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 for the node value */
30
    private string $val = '#';
31
    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...
32
33 2
    public function __construct(?string $root, string $nodeKey = '#')
34
    {
35 2
        $this->root = $root;
36 2
        $nodeKey = \trim($nodeKey);
37
        if ('@' === $nodeKey || empty($nodeKey)) {
38
            throw new \InvalidArgumentException('Invalid node key identifier', self::E_INVALID_SERIALIZER);
39
        }
40
        $this->val = $nodeKey;
41
    }
42
43
    public function type(): string
44
    {
45
        return Serializer::XML;
46
    }
47
48
    final public function val(): string
49
    {
50
        return $this->val;
51
    }
52
53
    /**
54
     * @param iterable $data
55
     *
56
     * @return string|null XML
57
     */
58
    public function serialize(mixed $data): string|null
59
    {
60
        $document = new DOMDocument('1.0', 'UTF-8');
61 1
        $document->formatOutput = false;
62
        if (\is_iterable($data)) {
63 1
            $root = $document->createElement($this->root);
64
            $document->appendChild($root);
65
            $document->createAttributeNS('http://www.w3.org/2001/XMLSchema-instance', 'xsi:' . $this->root);
66 1
            $this->buildXml($document, $root, $data);
67 1
        } else {
68 1
            $this->appendNode($document, $document, $data, $this->root);
69
        }
70
        return $document->saveXML();
71
    }
72
73
    /**
74 1
     * Unserialize a proper XML document into array, scalar value or NULL.
75
     *
76 1
     * @param string $xml XML
77
     *
78
     * @return mixed scalar|array|null
79
     */
80
    public function unserialize(string $xml): mixed
81
    {
82
        try {
83
            $document = new DOMDocument('1.0', 'UTF-8');
84
            $document->preserveWhiteSpace = false;
85
            $document->loadXML($xml);
86
            if ($document->documentElement->hasChildNodes()) {
87
                return $this->parseXml($document->documentElement);
88
            }
89
            return !$document->documentElement->getAttributeNode('xmlns:xsi')
90
                ? $this->parseXml($document->documentElement)
91
                : [];
92
93
        } catch (Throwable $e) {
94
            \error_log(PHP_EOL . "[{$e->getLine()}]: " . $e->getMessage());
95
            return null;
96
        }
97
    }
98
99
    private function buildXml(
100
        DOMDocument $document,
101
        DOMNode $parent,
102
        iterable $data): void
103
    {
104
        foreach ($data as $key => $data) {
105
            $isKeyNumeric = \is_numeric($key);
106
            if (\str_starts_with($key, '@') && $name = \substr($key, 1)) {
107
                // node attribute
108
                $parent->setAttribute($name, $data);
109
            } elseif ($this->val === $key) {
110
                // node value
111
                $parent->nodeValue = $data;
112
            } elseif (false === $isKeyNumeric && \is_array($data)) {
113
                if (\ctype_digit(\join('', \array_keys($data)))) {
114
                    foreach ($data as $d) {
115
                        $this->appendNode($document, $parent, $d, $key);
116
                    }
117
                } else {
118
                    $this->appendNode($document, $parent, $data, $key);
119
                }
120
            } elseif ($isKeyNumeric) {
121
                $this->appendNode($document, $parent, $data, 'item', $key);
122
            } else {
123
                $this->appendNode($document, $parent, $data, $key);
124
            }
125
        }
126
    }
127
128
    private function parseXml(DOMNode $node): mixed
129
    {
130
        $attrs = $this->parseXmlAttributes($node);
131
        $value = $this->parseXmlValue($node);
132
        if (0 === \count($attrs)) {
133
            return $value;
134
        }
135
        if (false === \is_array($value)) {
136
            $attrs[$this->val] = $value;
137
            return $this->getValueByType($attrs);
138
        }
139
        if (1 === \count($value) && \key($value)) {
140
            $attrs[\key($value)] = \current($value);
141
        }
142
        foreach ($value as $k => $v) {
143
            $attrs[$k] = $v;
144
        }
145
        return $attrs;
146
    }
147
148
    private function parseXmlAttributes(DOMNode $node): array
149
    {
150
        if (!$node->hasAttributes()) {
151
            return [];
152
        }
153
        $attrs = [];
154
        foreach ($node->attributes as $attr) {
155
            /** @var \DOMAttr $attr */
156
            $attrs['@' . $attr->nodeName] = $attr->nodeValue;
157
        }
158
        return $attrs;
159
    }
160
161
    /**
162
     * @param DOMNode $node
163
     *
164
     * @return array|string|null
165
     * @throws \Exception
166
     */
167
    private function parseXmlValue(DOMNode $node): mixed
168
    {
169
        $value = [];
170
        if ($node->hasChildNodes()) {
171
            /** @var DOMNode $child */
172
            $child = $node->firstChild;
173
            if ($child->nodeType === XML_TEXT_NODE) {
174
                return $child->nodeValue;
175
            }
176
            if ($child->nodeType === XML_CDATA_SECTION_NODE) {
177
                return $child->wholeText;
178
            }
179
            foreach ($node->childNodes as $child) {
180
                if ($child->nodeType === XML_COMMENT_NODE) {
181
                    continue;
182
                }
183
                $v = $this->parseXml($child);
184
                if ('item' === $child->nodeName && isset($v['@key'])) {
185
                    $k = $v['@key'];
186
                    $value[$k] = $this->getValueByType($v);
187
                    unset($value[$k]['@key']);
188
                } else {
189
                    $value[$child->nodeName][] = $this->getValueByType($v);
190
                }
191
            }
192
        }
193
        foreach ($value as $k => $v) {
194
            if (\is_array($v) && 1 === \count($v)) {
195
                $value[$k] = \current($v);
196
            }
197
        }
198
        return $value ?: '';
199
    }
200
201
    /**
202
     * Creates an XML node in the document from the provided value
203
     * according to the PHP type of the value.
204
     *
205
     * @param DOMNode     $parent
206
     * @param mixed       $data
207
     * @param string      $name
208
     * @param string|null $key
209
     */
210
    private function appendNode(
211
        DOMDocument $document,
212
        DOMNode $parent,
213
        mixed $data,
214
        string $name,
215
        string $key = null): void
216
    {
217
        $element = $document->createElement($name);
218
        if (null !== $key) {
219
            $element->setAttribute('key', $key);
220
        }
221
        if (\is_iterable($data)) {
222
            $this->buildXml($document, $element, $data);
223
        } elseif (\is_bool($data)) {
224
            $element->setAttribute('type', 'xsd:boolean');
225
            $element->appendChild($document->createTextNode($data));
226
        } elseif (\is_float($data)) {
227
            $element->setAttribute('type', 'xsd:float');
228
            $element->appendChild($document->createTextNode($data));
229
        } elseif (\is_int($data)) {
230
            $element->setAttribute('type', 'xsd:integer');
231
            $element->appendChild($document->createTextNode($data));
232
        } elseif (null === $data) {
233
            $element->setAttribute('xsi:nil', 'true');
234
        } elseif ($data instanceof DateTimeInterface) {
235
            $element->setAttribute('type', 'xsd:dateTime');
236
            $element->appendChild($document->createTextNode($data->format(DateTimeImmutable::ISO8601)));
237
        } elseif (\is_object($data)) {
238
            $element->setAttribute('type', 'xsd:object');
239
            $element->appendChild($document->createCDATASection(json_serialize($data)));
240
        } elseif (\preg_match('/[<>&\'"]/', $data) > 0) {
241
            $element->appendChild($document->createCDATASection($data));
242
        } else {
243
            $element->appendChild($document->createTextNode($data));
244
        }
245
        $parent->appendChild($element);
246
    }
247
248
    /**
249
     * Deserialize the XML document elements into strict PHP values
250
     * in regard to the XSD type defined in the XML element (if any).
251
     *
252
     * [IMPORTANT]: When deserializing an XML document into values,
253
     * if the XmlSerializer encounters an XML element that specifies xsi:nil="true",
254
     * it assigns a NULL to the corresponding element and ignores any other attributes
255
     *
256
     * @param array|string $value
257
     * @return mixed array|string|null
258
     * @throws \Exception
259
     */
260
    private function getValueByType(mixed $value): mixed
261
    {
262
        if (false === \is_array($value)) {
263
            return $value;
264
        }
265
        /*
266
         * [NOTE] if "xsi:nil" is NOT 'true', ignore the xsi:nil
267
         * and process the rest of the attributes for this element
268
         */
269
        if (isset($value['@xsi:nil']) && $value['@xsi:nil'] == 'true') {
270
            unset($value['@xsi:nil']);
271
            return null;
272
        }
273
        if (!(isset($value['@type']) && \str_starts_with($value['@type'] ?? '', 'xsd:'))) {
274
            return $value;
275
        }
276
        $value[$this->val] = match ($value['@type']) {
277
            'xsd:integer' => (int)$value[$this->val],
278
            'xsd:boolean' => \filter_var($value[$this->val], FILTER_VALIDATE_BOOL),
279
            'xsd:float' => (float)$value[$this->val],
280
            'xsd:dateTime' => new DateTimeImmutable($value[$this->val]),
281
            'xsd:object' => json_unserialize($value[$this->val]),
282
        };
283
        unset($value['@type']);
284
        if (\count($value) > 1) {
285
            return $value;
286
        }
287
        return $value[$this->val];
288
    }
289
}
290