Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.04% covered (warning)
87.04%
141 / 162
80.65% covered (warning)
80.65%
25 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
UrlQueryHelper
87.04% covered (warning)
87.04%
141 / 162
80.65% covered (warning)
80.65%
25 / 31
95.29
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getBasicSearchParam
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clearSearchQueryParams
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 regenerateSearchQueryParams
73.53% covered (warning)
73.53%
25 / 34
0.00% covered (danger)
0.00%
0 / 1
19.17
 getDefault
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setDefaultParameter
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getParamsWithConfiguredDefaults
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSuppressQuery
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 isQuerySuppressed
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getParamArray
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __toString
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 replaceTerm
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 addFacet
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 addFilter
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 removeAllFilters
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 resetDefaultFilters
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getParams
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseFilter
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getAliasesForFacetField
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 removeFacet
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
9.09
 removeFilter
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSort
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 setHandler
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setViewParam
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLimit
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 setSearchTerms
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 asHiddenFields
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
8.05
 buildQueryString
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 filtered
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 updateQueryString
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
1<?php
2
3/**
4 * Class to help build URLs and forms in the view based on search settings.
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2010.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Search
25 * @author   Demian Katz <demian.katz@villanova.edu>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org Main Site
28 */
29
30namespace VuFind\Search;
31
32use VuFindSearch\Query\AbstractQuery;
33use VuFindSearch\Query\Query;
34use VuFindSearch\Query\QueryGroup;
35use VuFindSearch\Query\WorkKeysQuery;
36
37use function call_user_func;
38use function count;
39use function in_array;
40use function is_array;
41use function is_callable;
42
43/**
44 * Class to help build URLs and forms in the view based on search settings.
45 *
46 * @category VuFind
47 * @package  Search
48 * @author   Demian Katz <demian.katz@villanova.edu>
49 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
50 * @link     https://vufind.org Main Site
51 */
52class UrlQueryHelper
53{
54    /**
55     * Configuration for this helper.
56     *
57     * @var array
58     */
59    protected $config;
60
61    /**
62     * URL query parameters
63     *
64     * @var array
65     */
66    protected $urlParams = [];
67
68    /**
69     * Current query object
70     *
71     * @var AbstractQuery
72     */
73    protected $queryObject;
74
75    /**
76     * Constructor
77     *
78     * Note that the constructor is final here, because this class relies on
79     * "new static()" to build instances, and we must ensure that child classes
80     * have consistent constructor signatures.
81     *
82     * @param array         $urlParams             Array of URL query parameters.
83     * @param AbstractQuery $query                 Query object to use to update
84     * URL query.
85     * @param array         $options               Configuration options for the
86     * object.
87     * @param bool          $regenerateQueryParams Should we add parameters based
88     * on the contents of $query to $urlParams (true) or are they already there
89     * (false)?
90     */
91    final public function __construct(
92        array $urlParams,
93        AbstractQuery $query,
94        array $options = [],
95        $regenerateQueryParams = true
96    ) {
97        $this->config = $options;
98        $this->urlParams = $urlParams;
99        $this->queryObject = $query;
100        if ($regenerateQueryParams) {
101            $this->regenerateSearchQueryParams();
102        }
103    }
104
105    /**
106     * Get the name of the basic search param.
107     *
108     * @return string
109     */
110    protected function getBasicSearchParam()
111    {
112        return $this->config['basicSearchParam'] ?? 'lookfor';
113    }
114
115    /**
116     * Reset search-related parameters in the internal array.
117     *
118     * @return void
119     */
120    protected function clearSearchQueryParams()
121    {
122        unset($this->urlParams[$this->getBasicSearchParam()]);
123        unset($this->urlParams['join']);
124        unset($this->urlParams['type']);
125        $searchParams = ['bool', 'lookfor', 'type', 'op'];
126        foreach (array_keys($this->urlParams) as $key) {
127            if (preg_match('/(' . implode('|', $searchParams) . ')[0-9]+/', $key)) {
128                unset($this->urlParams[$key]);
129            }
130        }
131    }
132
133    /**
134     * Adjust the internal query array based on the query object.
135     *
136     * @return void
137     */
138    protected function regenerateSearchQueryParams()
139    {
140        $this->clearSearchQueryParams();
141        if ($this->isQuerySuppressed()) {
142            return;
143        }
144        if ($this->queryObject instanceof QueryGroup) {
145            $this->urlParams['join'] = $this->queryObject->getOperator();
146            foreach ($this->queryObject->getQueries() as $i => $current) {
147                if ($current instanceof QueryGroup) {
148                    $operator = $current->isNegated()
149                        ? 'NOT' : $current->getOperator();
150                    $this->urlParams['bool' . $i] = [$operator];
151                    foreach ($current->getQueries() as $inner) {
152                        if (!isset($this->urlParams['lookfor' . $i])) {
153                            $this->urlParams['lookfor' . $i] = [];
154                        }
155                        if (!isset($this->urlParams['type' . $i])) {
156                            $this->urlParams['type' . $i] = [];
157                        }
158                        $this->urlParams['lookfor' . $i][] = $inner->getString();
159                        $this->urlParams['type' . $i][] = $inner->getHandler();
160                        if (null !== ($op = $inner->getOperator())) {
161                            // We want the op and lookfor parameters to align
162                            // with each other; let's backfill empty op values
163                            // if there aren't enough in place already.
164                            $expectedOps
165                                = count($this->urlParams['lookfor' . $i]) - 1;
166                            while (
167                                count($this->urlParams['op' . $i] ?? [])
168                                < $expectedOps
169                            ) {
170                                $this->urlParams['op' . $i][] = '';
171                            }
172                            $this->urlParams['op' . $i][] = $op;
173                        }
174                    }
175                }
176            }
177        } elseif ($this->queryObject instanceof Query) {
178            $search = $this->queryObject->getString();
179            if (!empty($search)) {
180                $this->urlParams[$this->getBasicSearchParam()] = $search;
181            }
182            $type = $this->queryObject->getHandler();
183            if (!empty($type)) {
184                $this->urlParams['type'] = $type;
185            }
186        } elseif ($this->queryObject instanceof WorkKeysQuery) {
187            $this->urlParams['id'] = $this->queryObject->getId();
188            $this->urlParams['search'] = 'versions';
189        }
190    }
191
192    /**
193     * Look up a default value in the internal configuration array.
194     *
195     * @param string $key Name of default to load
196     *
197     * @return mixed
198     */
199    protected function getDefault($key)
200    {
201        return $this->config['defaults'][$key] ?? null;
202    }
203
204    /**
205     * Set the default value of a parameter, and add that parameter to the object
206     * if it is not already defined.
207     *
208     * @param string $name          Name of parameter
209     * @param string $value         Value of parameter
210     * @param bool   $forceOverride Force an override of the existing value, even if
211     * it was set in the incoming $urlParams in the constructor (defaults to false)
212     *
213     * @return UrlQueryHelper
214     */
215    public function setDefaultParameter($name, $value, $forceOverride = false)
216    {
217        // Add the new default to the configuration, and apply it to the query
218        // if no existing value has already been set in this position (or if an
219        // override has been forced).
220        $this->config['defaults'][$name] = $value;
221        if (!isset($this->urlParams[$name]) || $forceOverride) {
222            $this->urlParams[$name] = $value;
223        }
224        return $this;
225    }
226
227    /**
228     * Get an array of field names with configured defaults; this is a useful way
229     * to identify custom query parameters added through setDefaultParameter().
230     *
231     * @return array
232     */
233    public function getParamsWithConfiguredDefaults()
234    {
235        return array_keys($this->config['defaults'] ?? []);
236    }
237
238    /**
239     * Control query suppression
240     *
241     * @param bool $suppress Should we suppress queries?
242     *
243     * @return UrlQueryHelper
244     */
245    public function setSuppressQuery($suppress)
246    {
247        $this->config['suppressQuery'] = $suppress;
248        $this->regenerateSearchQueryParams();
249        return $this;
250    }
251
252    /**
253     * Is query suppressed?
254     *
255     * @return bool
256     */
257    public function isQuerySuppressed()
258    {
259        return isset($this->config['suppressQuery'])
260            ? (bool)$this->config['suppressQuery'] : false;
261    }
262
263    /**
264     * Get an array of URL parameters.
265     *
266     * @return array
267     */
268    public function getParamArray()
269    {
270        return $this->urlParams;
271    }
272
273    /**
274     * Magic method: behavior when this object is treated as a string.
275     *
276     * @return string
277     */
278    public function __toString()
279    {
280        $escape = $this->config['escape'] ?? true;
281        return $this->getParams($escape);
282    }
283
284    /**
285     * Replace a term in the search query (used for spelling replacement)
286     *
287     * @param string   $from       Search term to find
288     * @param string   $to         Search term to insert
289     * @param callable $normalizer Function to normalize text strings (null for
290     * no normalization)
291     *
292     * @return UrlQueryHelper
293     */
294    public function replaceTerm($from, $to, $normalizer = null)
295    {
296        $query = clone $this->queryObject;
297        $query->replaceTerm($from, $to, $normalizer);
298        return new static($this->urlParams, $query, $this->config);
299    }
300
301    /**
302     * Add a facet to the parameters.
303     *
304     * @param string $field    Facet field
305     * @param string $value    Facet value
306     * @param string $operator Facet type to add (AND, OR, NOT)
307     *
308     * @return UrlQueryHelper
309     */
310    public function addFacet($field, $value, $operator = 'AND')
311    {
312        // Facets are just a special case of filters:
313        $prefix = ($operator == 'NOT') ? '-' : ($operator == 'OR' ? '~' : '');
314        return $this->addFilter($prefix . $field . ':"' . $value . '"');
315    }
316
317    /**
318     * Add a filter to the parameters.
319     *
320     * @param string $filter Filter to add
321     *
322     * @return UrlQueryHelper
323     */
324    public function addFilter($filter)
325    {
326        $params = $this->urlParams;
327
328        // Add the filter:
329        if (!isset($params['filter'])) {
330            $params['filter'] = [];
331        }
332        $params['filter'][] = $filter;
333
334        // Clear page:
335        unset($params['page']);
336
337        return new static($params, $this->queryObject, $this->config, false);
338    }
339
340    /**
341     * Remove all filters.
342     *
343     * @return string
344     */
345    public function removeAllFilters()
346    {
347        $params = $this->urlParams;
348        // Clear page:
349        unset($params['filter']);
350
351        return new static($params, $this->queryObject, $this->config, false);
352    }
353
354    /**
355     * Reset default filter state.
356     *
357     * @return string
358     */
359    public function resetDefaultFilters()
360    {
361        $params = $this->urlParams;
362        // Clear page:
363        unset($params['dfApplied']);
364
365        return new static($params, $this->queryObject, $this->config, false);
366    }
367
368    /**
369     * Get the current search parameters as a GET query.
370     *
371     * @param bool $escape Should we escape the string for use in the view?
372     *
373     * @return string
374     */
375    public function getParams($escape = true)
376    {
377        return '?' . static::buildQueryString($this->urlParams, $escape);
378    }
379
380    /**
381     * Parse apart the field and value from a URL filter string.
382     *
383     * @param string $filter A filter string from url : "field:value"
384     *
385     * @return array         Array with elements 0 = field, 1 = value.
386     */
387    protected function parseFilter($filter)
388    {
389        // Simplistic explode/trim behavior if no callback is provided:
390        if (
391            !isset($this->config['parseFilterCallback'])
392            || !is_callable($this->config['parseFilterCallback'])
393        ) {
394            $parts = explode(':', $filter, 2);
395            $parts[1] = trim($parts[1], '"');
396            return $parts;
397        }
398        return call_user_func($this->config['parseFilterCallback'], $filter);
399    }
400
401    /**
402     * Given a facet field, return an array containing all aliases of that
403     * field.
404     *
405     * @param string $field Field to look up
406     *
407     * @return array
408     */
409    protected function getAliasesForFacetField($field)
410    {
411        // If no callback is provided, aliases are unsupported:
412        if (
413            !isset($this->config['getAliasesForFacetFieldCallback'])
414            || !is_callable($this->config['getAliasesForFacetFieldCallback'])
415        ) {
416            return [$field];
417        }
418        return call_user_func(
419            $this->config['getAliasesForFacetFieldCallback'],
420            $field
421        );
422    }
423
424    /**
425     * Remove a facet from the parameters.
426     *
427     * @param string $field    Facet field
428     * @param string $value    Facet value
429     * @param string $operator Facet type to add (AND, OR, NOT)
430     *
431     * @return UrlQueryHelper
432     */
433    public function removeFacet($field, $value, $operator = 'AND')
434    {
435        $params = $this->urlParams;
436
437        // Account for operators:
438        if ($operator == 'NOT') {
439            $field = '-' . $field;
440        } elseif ($operator == 'OR') {
441            $field = '~' . $field;
442        }
443
444        $fieldAliases = $this->getAliasesForFacetField($field);
445
446        // Remove the filter:
447        $newFilter = [];
448        if (isset($params['filter']) && is_array($params['filter'])) {
449            foreach ($params['filter'] as $current) {
450                [$currentField, $currentValue]
451                    = $this->parseFilter($current);
452                if (
453                    !in_array($currentField, $fieldAliases)
454                    || $currentValue != $value
455                ) {
456                    $newFilter[] = $current;
457                }
458            }
459        }
460        if (empty($newFilter)) {
461            unset($params['filter']);
462        } else {
463            $params['filter'] = $newFilter;
464        }
465
466        // Clear page:
467        unset($params['page']);
468
469        return new static($params, $this->queryObject, $this->config, false);
470    }
471
472    /**
473     * Remove a filter from the parameters.
474     *
475     * @param string $filter Filter to add
476     *
477     * @return string
478     */
479    public function removeFilter($filter)
480    {
481        // Treat this as a special case of removeFacet:
482        [$field, $value] = $this->parseFilter($filter);
483        return $this->removeFacet($field, $value);
484    }
485
486    /**
487     * Return HTTP parameters to render a different page of results.
488     *
489     * @param string $p New page parameter (null for NO page parameter)
490     *
491     * @return string
492     */
493    public function setPage($p)
494    {
495        return $this->updateQueryString('page', $p, 1);
496    }
497
498    /**
499     * Return HTTP parameters to render the current page with a different sort
500     * parameter.
501     *
502     * @param string $s New sort parameter (null for NO sort parameter)
503     *
504     * @return string
505     */
506    public function setSort($s)
507    {
508        return $this->updateQueryString(
509            'sort',
510            $s,
511            $this->getDefault('sort'),
512            true
513        );
514    }
515
516    /**
517     * Return HTTP parameters to render the current page with a different search
518     * handler.
519     *
520     * @param string $handler new Handler.
521     *
522     * @return string
523     */
524    public function setHandler($handler)
525    {
526        $query = clone $this->queryObject;
527        // We can only set the handler on basic queries:
528        if ($query instanceof Query) {
529            $query->setHandler($handler);
530        }
531        return new static($this->urlParams, $query, $this->config);
532    }
533
534    /**
535     * Return HTTP parameters to render the current page with a different view
536     * parameter.
537     *
538     * Note: This is called setViewParam rather than setView to avoid confusion
539     * with the \Laminas\View\Helper\AbstractHelper interface.
540     *
541     * @param string $v New sort parameter (null for NO view parameter)
542     *
543     * @return string
544     */
545    public function setViewParam($v)
546    {
547        // Because of the way view settings are stored in the session, we always
548        // want an explicit value here (hence null rather than default view in
549        // third parameter below):
550        return $this->updateQueryString('view', $v, null);
551    }
552
553    /**
554     * Return HTTP parameters to render the current page with a different limit
555     * parameter.
556     *
557     * @param string $l New limit parameter (null for NO limit parameter)
558     *
559     * @return string
560     */
561    public function setLimit($l)
562    {
563        return $this->updateQueryString(
564            'limit',
565            $l,
566            $this->getDefault('limit'),
567            true
568        );
569    }
570
571    /**
572     * Return HTTP parameters to render the current page with a different set
573     * of search terms.
574     *
575     * @param string $lookfor New search terms
576     *
577     * @return string
578     */
579    public function setSearchTerms($lookfor)
580    {
581        $query = new Query($lookfor);
582        return new static($this->urlParams, $query, $this->config);
583    }
584
585    /**
586     * Turn the current GET parameters into a set of hidden form fields.
587     *
588     * @param array $filter Array of parameters to exclude -- key = field name,
589     * value = regular expression to exclude.
590     *
591     * @return string
592     */
593    public function asHiddenFields($filter = [])
594    {
595        $retVal = '';
596        foreach ($this->urlParams as $paramName => $paramValue) {
597            if (is_array($paramValue)) {
598                foreach ($paramValue as $paramValue2) {
599                    if (!$this->filtered($paramName, $paramValue2, $filter)) {
600                        $retVal .= '<input type="hidden" name="' .
601                            htmlspecialchars($paramName) . '[]" value="' .
602                            htmlspecialchars($paramValue2 ?? '') . '">';
603                    }
604                }
605            } else {
606                if (!$this->filtered($paramName, $paramValue, $filter)) {
607                    $retVal .= '<input type="hidden" name="' .
608                        htmlspecialchars($paramName) . '" value="' .
609                        htmlspecialchars($paramValue ?? '') . '">';
610                }
611            }
612        }
613        return $retVal;
614    }
615
616    /**
617     * Turn an array into a properly URL-encoded query string. This is
618     * equivalent to the built-in PHP http_build_query function, but it handles
619     * arrays in a more compact way and ensures that ampersands don't get
620     * messed up based on server-specific settings.
621     *
622     * @param array $a      Array of parameters to turn into a GET string
623     * @param bool  $escape Should we escape the string for use in the view?
624     *
625     * @return string
626     */
627    public static function buildQueryString($a, $escape = true)
628    {
629        $parts = [];
630        foreach ($a as $key => $value) {
631            if (is_array($value)) {
632                foreach ($value as $current) {
633                    $parts[] = urlencode($key . '[]') . '=' . urlencode($current ?? '');
634                }
635            } else {
636                $parts[] = urlencode($key) . '=' . urlencode($value ?? '');
637            }
638        }
639        $retVal = implode('&', $parts);
640        return $escape ? htmlspecialchars($retVal) : $retVal;
641    }
642
643    /**
644     * Support method for asHiddenFields -- are the provided field and value
645     * excluded by the provided filter?
646     *
647     * @param string $field  Field to check
648     * @param string $value  Regular expression to check
649     * @param array  $filter Filter provided to asHiddenFields() above
650     *
651     * @return bool
652     */
653    protected function filtered($field, $value, $filter)
654    {
655        return isset($filter[$field]) && preg_match($filter[$field], $value);
656    }
657
658    /**
659     * Generic case of parameter rebuilding.
660     *
661     * @param string $field     Field to update
662     * @param string $value     Value to use (null to skip field entirely)
663     * @param string $default   Default value (skip field if $value matches; null
664     *                          for no default).
665     * @param bool   $clearPage Should we clear the page number, if any?
666     *
667     * @return string
668     */
669    protected function updateQueryString(
670        $field,
671        $value,
672        $default = null,
673        $clearPage = false
674    ) {
675        $params = $this->urlParams;
676        if (null === $value || $value == $default) {
677            unset($params[$field]);
678        } else {
679            $params[$field] = $value;
680        }
681        if ($clearPage && isset($params['page'])) {
682            unset($params['page']);
683        }
684        return new static($params, $this->queryObject, $this->config, false);
685    }
686}