Passed
Push — master ( 660904...4d7c06 )
by Esteban De La Fuente
03:42
created

XPathQuery::resolveQuery()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 20
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 3

Importance

Changes 0
Metric Value
cc 3
eloc 10
nc 4
nop 2
dl 0
loc 20
ccs 12
cts 12
cp 1
crap 3
rs 9.9332
c 0
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
33
/**
34
 * Clase para facilitar el manejo de XML usando XPath.
35
 */
36
class XPathQuery
37
{
38
    /**
39
     * Instancia del documento XML.
40
     *
41
     * @var DOMDocument
42
     */
43
    private readonly DOMDocument $dom;
44
45
    /**
46
     * Instancia que representa el buscador con XPath.
47
     *
48
     * @var DOMXPath
49
     */
50
    private readonly DOMXPath $xpath;
51
52
    /**
53
     * Si se deben usar los prefijos de namespaces o si deben ser ignorados.
54
     *
55
     * @var boolean
56
     */
57
    private readonly bool $registerNodeNS;
58
59
    /**
60
     * Constructor que recibe el documento XML y prepara XPath.
61
     *
62
     * @param string|DOMDocument $xml Documento XML.
63
     * @param array $namespaces Arreglo asociativo con prefijo y URI.
64
     */
65 29
    public function __construct(
66
        string|DOMDocument $xml,
67
        array $namespaces = []
68
    ) {
69
        // Asignar instancia del documento DOM.
70 29
        if ($xml instanceof DOMDocument) {
71
            $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...
72
        } else {
73 29
            $this->dom = new DOMDocument();
74 29
            $this->loadXml($xml);
75
        }
76
77
        // Crear instancia de consultas XPath sobre el documento DOM.
78 27
        $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...
79
80
        // Asignar o desactivar uso de namespaces.
81 27
        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...
82 12
            foreach ($namespaces as $prefix => $namespace) {
83 12
                $this->xpath->registerNamespace($prefix, $namespace);
84
            }
85 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...
86
        } else {
87 15
            $this->registerNodeNS = false;
88
        }
89
    }
90
91
    /**
92
     * Devuelve el DOMDocument usado internamente.
93
     *
94
     * @return DOMDocument
95
     */
96 2
    public function getDomDocument(): DOMDocument
97
    {
98 2
        return $this->dom;
99
    }
100
101
    /**
102
     * Ejecuta una consulta XPath y devuelve el resultado procesado.
103
     *
104
     * El resultado dependerá de o que se encuentre:
105
     *
106
     *   - `null`: si no hubo coincidencias.
107
     *   - string: si hubo una coincidencia.
108
     *   - string[]: si hubo más de una coincidencia.
109
     *
110
     * Si el nodo tiene hijos, devuelve un arreglo recursivo representando
111
     * toda la estructura de los nodos.
112
     *
113
     * @param string $query Consulta XPath.
114
     * @param array $params Arreglo de parámetros.
115
     * @param DOMNode|null $contextNode Desde qué nodo evaluar la expresión.
116
     * @return string|string[]|null El valor procesado: string, arreglo o null.
117
     */
118 20
    public function get(
119
        string $query,
120
        array $params = [],
121
        ?DOMNode $contextNode = null
122
    ): string|array|null {
123 20
        $nodes = $this->getNodes($query, $params, $contextNode);
124
125
        // Sin coincidencias.
126 18
        if ($nodes->length === 0) {
127 3
            return null;
128
        }
129
130
        // Un solo nodo.
131 15
        if ($nodes->length === 1) {
132 13
            return $this->processNode($nodes->item(0));
133
        }
134
135
        // Varios nodos.
136 2
        $results = [];
137 2
        foreach ($nodes as $node) {
138 2
            $results[] = $this->processNode($node);
139
        }
140
141 2
        return $results;
142
    }
143
144
    /**
145
     * Ejecuta una consulta XPath y devuelve un arreglo de valores.
146
     *
147
     * @param string $query Consulta XPath.
148
     * @param array $params Arreglo de parámetros.
149
     * @param DOMNode|null $contextNode Desde qué nodo evaluar la expresión.
150
     * @return string[] Arreglo de valores encontrados.
151
     */
152 2
    public function getValues(
153
        string $query,
154
        array $params = [],
155
        ?DOMNode $contextNode = null
156
    ): array {
157 2
        $nodes = $this->getNodes($query, $params, $contextNode);
158
159 2
        $results = [];
160 2
        foreach ($nodes as $node) {
161 2
            $results[] = $node->nodeValue;
162
        }
163
164 2
        return $results;
165
    }
166
167
    /**
168
     * Ejecuta una consulta XPath y devuelve el primer resultado como string.
169
     *
170
     * @param string $query Consulta XPath.
171
     * @param array $params Arreglo de parámetros.
172
     * @param DOMNode|null $contextNode Desde qué nodo evaluar la expresión.
173
     * @return string|null El valor del nodo, o `null` si no existe.
174
     */
175 1
    public function getValue(
176
        string $query,
177
        array $params = [],
178
        ?DOMNode $contextNode = null
179
    ): ?string {
180 1
        $nodes = $this->getNodes($query, $params, $contextNode);
181
182 1
        return $nodes->length > 0
183 1
            ? $nodes->item(0)->nodeValue
184 1
            : null
185 1
        ;
186
    }
187
188
    /**
189
     * Ejecuta una consulta XPath y devuelve los nodos resultantes.
190
     *
191
     * @param string $query Consulta XPath.
192
     * @param array $params Arreglo de parámetros.
193
     * @param DOMNode|null $contextNode Desde qué nodo evaluar la expresión.
194
     * @return DOMNodeList Nodos resultantes de la consulta XPath.
195
     */
196 25
    public function getNodes(
197
        string $query,
198
        array $params = [],
199
        ?DOMNode $contextNode = null
200
    ): DOMNodeList {
201 25
        $use_errors = libxml_use_internal_errors(true);
202
203 25
        $query = $this->resolveQuery($query, $params);
204
205 25
        $nodes = $this->xpath->query(
206 25
            $query,
207 25
            $contextNode,
208 25
            $this->registerNodeNS
209 25
        );
210
211 25
        $error = libxml_get_last_error();
212 25
        if ($nodes === false || $error) {
0 ignored issues
show
introduced by
$error is of type LibXMLError, thus it always evaluated to true.
Loading history...
213 3
            throw new InvalidArgumentException(sprintf(
214 3
                'Ocurrió un error al ejecutar la expresión XPath: %s.',
215 3
                $query
216 3
            ));
217
        }
218
219 22
        libxml_clear_errors();
220 22
        libxml_use_internal_errors($use_errors);
221
222 22
        return $nodes;
223
    }
224
225
    /**
226
     * Carga un string XML en el atributo $dom.
227
     *
228
     * @param string $xml
229
     * @return static
230
     */
231 29
    private function loadXml(string $xml): static
232
    {
233 29
        $use_errors = libxml_use_internal_errors(true);
234
235 29
        $this->dom->loadXml($xml);
236
237 29
        if ($error = libxml_get_last_error()) {
238 2
            throw new InvalidArgumentException(sprintf(
239 2
                'El XML proporcionado no es válido: %s.',
240 2
                $error->message
241 2
            ));
242
        }
243
244 27
        libxml_clear_errors();
245 27
        libxml_use_internal_errors($use_errors);
246
247 27
        return $this;
248
    }
249
250
    /**
251
     * Procesa un nodo DOM y sus hijos recursivamente.
252
     *
253
     * @param DOMNode $node Nodo DOM a procesar.
254
     * @return string|array Valor del nodo o estructura de hijos como arreglo.
255
     */
256 15
    private function processNode(DOMNode $node): string|array
257
    {
258 15
        if ($node->hasChildNodes()) {
259 15
            $children = [];
260 15
            foreach ($node->childNodes as $child) {
261 15
                if ($child->nodeType === XML_ELEMENT_NODE) {
262 5
                    $children[$child->nodeName] = $this->processNode($child);
263
                }
264
            }
265
266
            // Si tiene hijos procesados, devolver la estructura.
267 15
            return count($children) > 0 ? $children : $node->nodeValue;
268
        }
269
270
        // Si no tiene hijos, devolver el valor.
271
        return $node->nodeValue;
272
    }
273
274
    /**
275
     * Resuelve los parámetros de una consulta XPath.
276
     *
277
     * Este método reemplaza los marcadores nombrados (como `:param`) en la
278
     * consulta con las comillas en los valores escapadas.
279
     *
280
     * @param string $query Consulta XPath con marcadores nombrados (ej.: ":param").
281
     * @param array $params Arreglo de parámetros en formato ['param' => 'value'].
282
     * @return string Consulta XPath con los valores reemplazados.
283
     */
284 25
    private function resolveQuery(string $query, array $params = []): string
285
    {
286
        // Si los namespaces están desactivados, se adapta la consulta XPath.
287 25
        if (!$this->registerNodeNS) {
288 14
            $query = preg_replace_callback(
289 14
                '/(?<=\/|^)(\w+)/',
290 14
                fn ($matches) => '*[local-name()="' . $matches[1] . '"]',
291 14
                $query
292 14
            );
293
        }
294
295
        // Reemplazar parámetros.
296 25
        foreach ($params as $key => $value) {
297 5
            $placeholder = ':' . ltrim($key, ':');
298 5
            $quotedValue = $this->quoteValue($value);
299 5
            $query = str_replace($placeholder, $quotedValue, $query);
300
        }
301
302
        // Entregar la consulta resuelta.
303 25
        return $query;
304
    }
305
306
    /**
307
     * Escapa un valor para usarlo en una consulta XPath.
308
     *
309
     * Si la versión es PHP 8.4 o superior se utiliza DOMXPath::quote().
310
     * De lo contrario, se usa una implementación manual.
311
     *
312
     * @param string $value Valor a escapar.
313
     * @return string Valor escapado como literal XPath.
314
     */
315 5
    private function quoteValue(string $value): string
316
    {
317
        // Disponible solo desde PHP 8.4.
318 5
        if (method_exists(DOMXPath::class, 'quote')) {
319
            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

319
            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...
320
        }
321
322
        // Implementación manual para versiones anteriores a PHP 8.4.
323 5
        if (!str_contains($value, "'")) {
324 3
            return "'" . $value . "'";
325
        }
326 2
        if (!str_contains($value, '"')) {
327 1
            return '"' . $value . '"';
328
        }
329
330
        // Si contiene comillas simples y dobles, combinarlas con concat().
331 1
        return "concat('" . str_replace("'", "',\"'\",'", $value) . "')";
332
    }
333
}
334