XPathQuery::getValues()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 13
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 5
nc 2
nop 3
dl 0
loc 13
ccs 6
cts 6
cp 1
crap 2
rs 10
c 1
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * Derafu: Biblioteca PHP (Núcleo).
7
 * Copyright (C) Derafu <https://www.derafu.org>
8
 *
9
 * Este programa es software libre: usted puede redistribuirlo y/o modificarlo
10
 * bajo los términos de la Licencia Pública General Affero de GNU publicada por
11
 * la Fundación para el Software Libre, ya sea la versión 3 de la Licencia, o
12
 * (a su elección) cualquier versión posterior de la misma.
13
 *
14
 * Este programa se distribuye con la esperanza de que sea útil, pero SIN
15
 * GARANTÍA ALGUNA; ni siquiera la garantía implícita MERCANTIL o de APTITUD
16
 * PARA UN PROPÓSITO DETERMINADO. Consulte los detalles de la Licencia Pública
17
 * General Affero de GNU para obtener una información más detallada.
18
 *
19
 * Debería haber recibido una copia de la Licencia Pública General Affero de GNU
20
 * junto a este programa.
21
 *
22
 * En caso contrario, consulte <http://www.gnu.org/licenses/agpl.html>.
23
 */
24
25
namespace Derafu\Lib\Core\Support\Xml;
26
27
use DOMDocument;
28
use DOMNode;
29
use DOMNodeList;
30
use DOMXPath;
31
use InvalidArgumentException;
32
use LogicException;
33
34
/**
35
 * Clase para facilitar el manejo de XML usando XPath.
36
 */
37
class XPathQuery
38
{
39
    /**
40
     * Instancia del documento XML.
41
     *
42
     * @var DOMDocument
43
     */
44
    private readonly DOMDocument $dom;
45
46
    /**
47
     * Instancia que representa el buscador con XPath.
48
     *
49
     * @var DOMXPath
50
     */
51
    private readonly DOMXPath $xpath;
52
53
    /**
54
     * Si se deben usar los prefijos de namespaces o si deben ser ignorados.
55
     *
56
     * @var boolean
57
     */
58
    private readonly bool $registerNodeNS;
59
60
    /**
61
     * Constructor que recibe el documento XML y prepara XPath.
62
     *
63
     * @param string|DOMDocument $xml Documento XML.
64
     * @param array $namespaces Arreglo asociativo con prefijo y URI.
65
     */
66 37
    public function __construct(
67
        string|DOMDocument $xml,
68
        array $namespaces = []
69
    ) {
70
        // Asignar instancia del documento DOM.
71 37
        if ($xml instanceof DOMDocument) {
72 8
            $this->dom = $xml;
0 ignored issues
show
Bug introduced by
The property dom is declared read-only in Derafu\Lib\Core\Support\Xml\XPathQuery.
Loading history...
73
        } else {
74 29
            $this->dom = new DOMDocument();
75 29
            $this->loadXml($xml);
76
        }
77
78
        // Crear instancia de consultas XPath sobre el documento DOM.
79 35
        $this->xpath = new DOMXPath($this->dom);
0 ignored issues
show
Bug introduced by
The property xpath is declared read-only in Derafu\Lib\Core\Support\Xml\XPathQuery.
Loading history...
80
81
        // Asignar o desactivar uso de namespaces.
82 35
        if ($namespaces) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $namespaces of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
83 12
            foreach ($namespaces as $prefix => $namespace) {
84 12
                $this->xpath->registerNamespace($prefix, $namespace);
85
            }
86 12
            $this->registerNodeNS = true;
0 ignored issues
show
Bug introduced by
The property registerNodeNS is declared read-only in Derafu\Lib\Core\Support\Xml\XPathQuery.
Loading history...
87
        } else {
88 23
            $this->registerNodeNS = false;
89
        }
90
    }
91
92
    /**
93
     * Devuelve el DOMDocument usado internamente.
94
     *
95
     * @return DOMDocument
96
     */
97 2
    public function getDomDocument(): DOMDocument
98
    {
99 2
        return $this->dom;
100
    }
101
102
    /**
103
     * Ejecuta una consulta XPath y devuelve el resultado procesado.
104
     *
105
     * El resultado dependerá de o que se encuentre:
106
     *
107
     *   - `null`: si no hubo coincidencias.
108
     *   - string: si hubo una coincidencia.
109
     *   - string[]: si hubo más de una coincidencia.
110
     *
111
     * Si el nodo tiene hijos, devuelve un arreglo recursivo representando
112
     * toda la estructura de los nodos.
113
     *
114
     * @param string $query Consulta XPath.
115
     * @param array $params Arreglo de parámetros.
116
     * @param DOMNode|null $contextNode Desde qué nodo evaluar la expresión.
117
     * @return string|string[]|null El valor procesado: string, arreglo o null.
118
     */
119 20
    public function get(
120
        string $query,
121
        array $params = [],
122
        ?DOMNode $contextNode = null
123
    ): string|array|null {
124 20
        $nodes = $this->getNodes($query, $params, $contextNode);
125
126
        // Sin coincidencias.
127 18
        if ($nodes->length === 0) {
128 3
            return null;
129
        }
130
131
        // Un solo nodo.
132 15
        if ($nodes->length === 1) {
133 13
            return $this->processNode($nodes->item(0));
134
        }
135
136
        // Varios nodos.
137 2
        $results = [];
138 2
        foreach ($nodes as $node) {
139 2
            $results[] = $this->processNode($node);
140
        }
141
142 2
        return $results;
143
    }
144
145
    /**
146
     * Ejecuta una consulta XPath y devuelve un arreglo de valores.
147
     *
148
     * @param string $query Consulta XPath.
149
     * @param array $params Arreglo de parámetros.
150
     * @param DOMNode|null $contextNode Desde qué nodo evaluar la expresión.
151
     * @return string[] Arreglo de valores encontrados.
152
     */
153 2
    public function getValues(
154
        string $query,
155
        array $params = [],
156
        ?DOMNode $contextNode = null
157
    ): array {
158 2
        $nodes = $this->getNodes($query, $params, $contextNode);
159
160 2
        $results = [];
161 2
        foreach ($nodes as $node) {
162 2
            $results[] = $node->nodeValue;
163
        }
164
165 2
        return $results;
166
    }
167
168
    /**
169
     * Ejecuta una consulta XPath y devuelve el primer resultado como string.
170
     *
171
     * @param string $query Consulta XPath.
172
     * @param array $params Arreglo de parámetros.
173
     * @param DOMNode|null $contextNode Desde qué nodo evaluar la expresión.
174
     * @return string|null El valor del nodo, o `null` si no existe.
175
     */
176 1
    public function getValue(
177
        string $query,
178
        array $params = [],
179
        ?DOMNode $contextNode = null
180
    ): ?string {
181 1
        $nodes = $this->getNodes($query, $params, $contextNode);
182
183 1
        return $nodes->length > 0
184 1
            ? $nodes->item(0)->nodeValue
185 1
            : null
186 1
        ;
187
    }
188
189
    /**
190
     * Ejecuta una consulta XPath y devuelve los nodos resultantes.
191
     *
192
     * @param string $query Consulta XPath.
193
     * @param array $params Arreglo de parámetros.
194
     * @param DOMNode|null $contextNode Desde qué nodo evaluar la expresión.
195
     * @return DOMNodeList Nodos resultantes de la consulta XPath.
196
     */
197 33
    public function getNodes(
198
        string $query,
199
        array $params = [],
200
        ?DOMNode $contextNode = null
201
    ): DOMNodeList {
202
        try {
203 33
            $query = $this->resolveQuery($query, $params);
204 33
            $nodes = $this->execute(fn () => $this->xpath->query(
205 33
                $query,
206 33
                $contextNode,
207 33
                $this->registerNodeNS
208 33
            ));
209 3
        } catch (LogicException $e) {
210 3
            throw new InvalidArgumentException(sprintf(
211 3
                'Ocurrió un error al ejecutar la expresión XPath: %s. %s',
212 3
                $query,
213 3
                $e->getMessage()
214 3
            ));
215
        }
216
217 30
        return $nodes;
218
    }
219
220
    /**
221
     * Carga un string XML en el atributo $dom.
222
     *
223
     * @param string $xml
224
     * @return static
225
     */
226 29
    private function loadXml(string $xml): static
227
    {
228
        try {
229 29
            $this->execute(fn () => $this->dom->loadXml($xml));
230 2
        } catch (LogicException $e) {
231 2
            throw new InvalidArgumentException(sprintf(
232 2
                'El XML proporcionado no es válido: %s',
233 2
                $e->getMessage()
234 2
            ));
235
        }
236
237 27
        return $this;
238
    }
239
240
    /**
241
     * Procesa un nodo DOM y sus hijos recursivamente.
242
     *
243
     * @param DOMNode $node Nodo DOM a procesar.
244
     * @return string|array Valor del nodo o estructura de hijos como arreglo.
245
     */
246 15
    private function processNode(DOMNode $node): string|array
247
    {
248 15
        if ($node->hasChildNodes()) {
249 15
            $children = [];
250 15
            foreach ($node->childNodes as $child) {
251 15
                if ($child->nodeType === XML_ELEMENT_NODE) {
252 5
                    $children[$child->nodeName] = $this->processNode($child);
253
                }
254
            }
255
256
            // Si tiene hijos procesados, devolver la estructura.
257 15
            return count($children) > 0 ? $children : $node->nodeValue;
258
        }
259
260
        // Si no tiene hijos, devolver el valor.
261
        return $node->nodeValue;
262
    }
263
264
    /**
265
     * Resuelve los parámetros de una consulta XPath.
266
     *
267
     * Este método reemplaza los marcadores nombrados (como `:param`) en la
268
     * consulta con las comillas en los valores escapadas.
269
     *
270
     * @param string $query Consulta XPath con marcadores nombrados (ej.: ":param").
271
     * @param array $params Arreglo de parámetros en formato ['param' => 'value'].
272
     * @return string Consulta XPath con los valores reemplazados.
273
     */
274 33
    private function resolveQuery(string $query, array $params = []): string
275
    {
276
        // Si los namespaces están desactivados, se adapta la consulta XPath.
277 33
        if (!$this->registerNodeNS) {
278 22
            $query = preg_replace_callback(
279 22
                '/(?<=\/|^)(\w+)/',
280 22
                fn ($matches) => '*[local-name()="' . $matches[1] . '"]',
281 22
                $query
282 22
            );
283
        }
284
285
        // Reemplazar parámetros.
286 33
        foreach ($params as $key => $value) {
287 5
            $placeholder = ':' . ltrim($key, ':');
288 5
            $quotedValue = $this->quoteValue($value);
289 5
            $query = str_replace($placeholder, $quotedValue, $query);
290
        }
291
292
        // Entregar la consulta resuelta.
293 33
        return $query;
294
    }
295
296
    /**
297
     * Escapa un valor para usarlo en una consulta XPath.
298
     *
299
     * Si la versión es PHP 8.4 o superior se utiliza DOMXPath::quote().
300
     * De lo contrario, se usa una implementación manual.
301
     *
302
     * @param string $value Valor a escapar.
303
     * @return string Valor escapado como literal XPath.
304
     */
305 5
    private function quoteValue(string $value): string
306
    {
307
        // Disponible solo desde PHP 8.4.
308 5
        if (method_exists(DOMXPath::class, 'quote')) {
309
            return DOMXPath::quote($value);
0 ignored issues
show
Bug introduced by
The method quote() does not exist on DOMXPath. ( Ignorable by Annotation )

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

309
            return DOMXPath::/** @scrutinizer ignore-call */ quote($value);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
310
        }
311
312
        // Implementación manual para versiones anteriores a PHP 8.4.
313 5
        if (!str_contains($value, "'")) {
314 3
            return "'" . $value . "'";
315
        }
316 2
        if (!str_contains($value, '"')) {
317 1
            return '"' . $value . '"';
318
        }
319
320
        // Si contiene comillas simples y dobles, combinarlas con concat().
321 1
        return "concat('" . str_replace("'", "',\"'\",'", $value) . "')";
322
    }
323
324
    /**
325
     * Envoltura para ejecutar capturando los errores los métodos asociados a
326
     * la instnacia de XPath.
327
     *
328
     * @param callable $function
329
     * @return mixed
330
     */
331 37
    private function execute(callable $function): mixed
332
    {
333 37
        $use_errors = libxml_use_internal_errors(true);
334
335 37
        $result = $function();
336
337 37
        $error = $this->getLastError();
338 37
        if ($result === false || $error) {
339 5
            libxml_clear_errors();
340 5
            libxml_use_internal_errors($use_errors);
341
342 5
            $message = $error ?: 'Ocurrió un error en XPathQuery.';
343 5
            throw new LogicException($message);
344
        }
345
346 35
        libxml_clear_errors();
347 35
        libxml_use_internal_errors($use_errors);
348
349 35
        return $result;
350
    }
351
352
    /**
353
     * Entrega, si existe, el último error generado de XML.
354
     *
355
     * @return string|null
356
     */
357 37
    private function getLastError(): ?string
358
    {
359 37
        $error = libxml_get_last_error();
360
361 37
        if (!$error) {
0 ignored issues
show
introduced by
$error is of type LibXMLError, thus it always evaluated to true.
Loading history...
362 35
            return null;
363
        }
364
365 5
        return trim($error->message) . '.';
366
    }
367
}
368