Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.93% covered (success)
95.93%
212 / 221
95.45% covered (success)
95.45%
21 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Params
95.93% covered (success)
95.93%
212 / 221
95.45% covered (success)
95.45%
21 / 22
92
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 initFromRequest
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 initSearch
57.14% covered (warning)
57.14%
12 / 21
0.00% covered (danger)
0.00%
0 / 1
5.26
 setBasicSearch
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 initSort
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 setSort
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 addFilter
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 addHiddenFilter
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 removeFilter
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
9
 removeAllFilters
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 addFacet
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 addCheckboxFacet
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 resetFacetConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getBackendParameters
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
5
 addDefaultFilters
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 proxyMethod
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 translateFacetName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isBlenderFilter
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 translateFilter
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
14
 addLowerLevelHierarchicalFilterValues
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
6
 translateSearchType
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 translateSort
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Blender Search Parameters
5 *
6 * PHP version 8
7 *
8 * Copyright (C) The National Library of Finland 2015-2022.
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_Blender
25 * @author   Ere Maijala <ere.maijala@helsinki.fi>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org Main Page
28 */
29
30namespace VuFind\Search\Blender;
31
32use VuFind\Search\Base\Params as BaseParams;
33use VuFind\Search\Solr\HierarchicalFacetHelper;
34use VuFindSearch\ParamBag;
35
36use function array_slice;
37use function call_user_func_array;
38use function count;
39use function func_get_args;
40use function in_array;
41use function is_callable;
42
43/**
44 * Blender Search Parameters
45 *
46 * @category VuFind
47 * @package  Search_Blender
48 * @author   Ere Maijala <ere.maijala@helsinki.fi>
49 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
50 * @link     https://vufind.org Main Page
51 */
52class Params extends \VuFind\Search\Solr\Params
53{
54    /**
55     * Search params for backends
56     *
57     * @var \VuFind\Search\Base\Params[]
58     */
59    protected $searchParams;
60
61    /**
62     * Blender configuration
63     *
64     * @var \Laminas\Config\Config
65     */
66    protected $blenderConfig;
67
68    /**
69     * Blender mappings
70     *
71     * @var array
72     */
73    protected $mappings;
74
75    /**
76     * Current filters not supported by a backend
77     *
78     * @var array
79     */
80    protected $unsupportedFilters = [];
81
82    /**
83     * Constructor
84     *
85     * @param \VuFind\Search\Base\Options  $options       Options to use
86     * @param \VuFind\Config\PluginManager $configLoader  Config loader
87     * @param HierarchicalFacetHelper      $facetHelper   Hierarchical facet helper
88     * @param array                        $searchParams  Search params for backends
89     * @param \Laminas\Config\Config       $blenderConfig Blender configuration
90     * @param array                        $mappings      Blender mappings
91     */
92    public function __construct(
93        \VuFind\Search\Base\Options $options,
94        \VuFind\Config\PluginManager $configLoader,
95        HierarchicalFacetHelper $facetHelper,
96        array $searchParams,
97        \Laminas\Config\Config $blenderConfig,
98        array $mappings
99    ) {
100        // Assign these first; they are needed during parent's construct:
101        $this->searchParams = $searchParams;
102        $this->blenderConfig = $blenderConfig;
103        $this->mappings = $mappings;
104
105        parent::__construct(
106            $options,
107            $configLoader,
108            $facetHelper
109        );
110    }
111
112    /**
113     * Pull the search parameters
114     *
115     * @param \Laminas\Stdlib\Parameters $request Parameter object representing user
116     * request.
117     *
118     * @return void
119     */
120    public function initFromRequest($request)
121    {
122        $this->unsupportedFilters = [];
123
124        // First do a basic init without filters, facets etc. that are processed via
125        // methods called by parent's initFromRequest:
126        $filteredParams = [
127            'lookfor',
128            'type',
129            'sort',
130            'filter',
131            'hiddenFilters',
132            'daterange',
133        ];
134        foreach ($this->searchParams as $params) {
135            $translatedRequest = clone $request;
136            foreach (array_keys($translatedRequest->getArrayCopy()) as $key) {
137                // Check for filtered param or advanced search types:
138                if (in_array($key, $filteredParams) || preg_match('/^type\d+$/', $key)) {
139                    $translatedRequest->offsetUnset($key);
140                }
141            }
142            $params->initFromRequest($translatedRequest);
143        }
144        parent::initFromRequest($request);
145    }
146
147    /**
148     * Initialize the object's search settings from a request object.
149     *
150     * @param \Laminas\Stdlib\Parameters $request Parameter object representing user
151     * request.
152     *
153     * @return void
154     */
155    protected function initSearch($request)
156    {
157        foreach ($this->searchParams as $params) {
158            $backendId = $params->getSearchClassId();
159            // Clone request to avoid tampering the original one:
160            $translatedRequest = clone $request;
161            // Map basic search type:
162            if ($type = $translatedRequest->get('type')) {
163                $translatedRequest->set(
164                    'type',
165                    $this->translateSearchType($type, $backendId)
166                );
167            }
168            // Map advanced search types:
169            $i = 0;
170            while ($types = $translatedRequest->get("type$i")) {
171                $translatedRequest->set(
172                    "type$i",
173                    array_map(
174                        function ($type) use ($backendId) {
175                            return $this->translateSearchType($type, $backendId);
176                        },
177                        (array)$types
178                    )
179                );
180                ++$i;
181            }
182            $params->initSearch($translatedRequest);
183        }
184        parent::initSearch($request);
185    }
186
187    /**
188     * Set a basic search query:
189     *
190     * @param string $lookfor The search query
191     * @param string $handler The search handler (null for default)
192     *
193     * @return void
194     */
195    public function setBasicSearch($lookfor, $handler = null)
196    {
197        foreach ($this->searchParams as $params) {
198            $backendId = $params->getSearchClassId();
199            $params->setBasicSearch(
200                $lookfor,
201                $handler
202                    ? $this->translateSearchType($handler, $backendId) : $handler
203            );
204        }
205        parent::setBasicSearch($lookfor, $handler);
206    }
207
208    /**
209     * Get the value for which type of sorting to use
210     *
211     * @param \Laminas\Stdlib\Parameters $request Parameter object representing user
212     * request.
213     *
214     * @return void
215     */
216    protected function initSort($request)
217    {
218        foreach ($this->searchParams as $params) {
219            $backendId = $params->getSearchClassId();
220            // Clone request to avoid tampering the original one:
221            $translatedRequest = clone $request;
222            // Map sort:
223            if ($sort = $translatedRequest->get('sort')) {
224                $translatedRequest->set(
225                    'sort',
226                    $this->translateSort($sort, $backendId)
227                );
228            }
229            $params->initSort($translatedRequest);
230        }
231        parent::initSort($request);
232    }
233
234    /**
235     * Set the sorting value (note: sort will be set to default if an illegal
236     * or empty value is passed in).
237     *
238     * @param string $sort  New sort value (null for default)
239     * @param bool   $force Set sort value without validating it?
240     *
241     * @return void
242     */
243    public function setSort($sort, $force = false)
244    {
245        foreach ($this->searchParams as $params) {
246            $backendId = $params->getSearchClassId();
247            $params->setSort(
248                $sort ? $this->translateSort($sort, $backendId) : $sort,
249                $force
250            );
251        }
252        parent::setSort($sort, $force);
253    }
254
255    /**
256     * Take a filter string and add it into the protected
257     *   array checking for duplicates.
258     *
259     * @param string $newFilter A filter string from url : "field:value"
260     *
261     * @return void
262     */
263    public function addFilter($newFilter)
264    {
265        parent::addFilter($newFilter);
266        if ($this->isBlenderFilter($newFilter)) {
267            return;
268        }
269        foreach ($this->searchParams as $params) {
270            $backendId = $params->getSearchClassId();
271            if ($translated = $this->translateFilter($newFilter, $backendId)) {
272                foreach ($translated as $current) {
273                    if (null !== $current) {
274                        $params->addFilter($current);
275                    }
276                }
277            } else {
278                // Add the filter to the list of unsupported filters:
279                $this->unsupportedFilters[$backendId][]
280                    = $this->parseFilter($newFilter);
281            }
282        }
283    }
284
285    /**
286     * Take a filter string and add it into the protected hidden filters
287     *   array checking for duplicates.
288     *
289     * @param string $newFilter A filter string from url : "field:value"
290     *
291     * @return void
292     */
293    public function addHiddenFilter($newFilter)
294    {
295        parent::addHiddenFilter($newFilter);
296        if ($this->isBlenderFilter($newFilter)) {
297            return;
298        }
299        foreach ($this->searchParams as $params) {
300            $backendId = $params->getSearchClassId();
301            if ($translated = $this->translateFilter($newFilter, $backendId)) {
302                foreach ($translated as $current) {
303                    if (null !== $current) {
304                        $params->addHiddenFilter($current);
305                    }
306                }
307            } else {
308                // Add the filter to the list of unsupported filters:
309                $this->unsupportedFilters[$backendId][]
310                    = $this->parseFilter($newFilter);
311            }
312        }
313    }
314
315    /**
316     * Remove a filter from the list.
317     *
318     * @param string $oldFilter A filter string from url : "field:value"
319     *
320     * @return void
321     */
322    public function removeFilter($oldFilter)
323    {
324        parent::removeFilter($oldFilter);
325        if ($this->isBlenderFilter($oldFilter)) {
326            return;
327        }
328
329        // Update list of unsupported filters:
330        if ($this->unsupportedFilters) {
331            $parsed = $this->parseFilter($oldFilter);
332            foreach ($this->unsupportedFilters as $backendId => $filters) {
333                $updatedFilters = $filters;
334                foreach ($filters as $key => $filter) {
335                    if ($parsed === $filter) {
336                        unset($updatedFilters[$key]);
337                    }
338                }
339                $this->unsupportedFilters[$backendId] = $updatedFilters;
340            }
341        }
342
343        foreach ($this->searchParams as $params) {
344            $backendId = $params->getSearchClassId();
345            if ($translated = $this->translateFilter($oldFilter, $backendId)) {
346                foreach ($translated as $current) {
347                    $params->removeFilter($current);
348                }
349            }
350        }
351    }
352
353    /**
354     * Remove all filters from the list.
355     *
356     * @param string $field Name of field to remove filters from (null to remove
357     * all filters from all fields)
358     *
359     * @return void
360     */
361    public function removeAllFilters($field = null)
362    {
363        $this->unsupportedFilters = [];
364        if (null === $field) {
365            $this->proxyMethod(__FUNCTION__, func_get_args());
366            return;
367        }
368
369        parent::removeAllFilters($field);
370        foreach ($this->searchParams as $params) {
371            $backendId = $params->getSearchClassId();
372            if ($translated = $this->translateFacetName($field, $backendId)) {
373                $params->removeAllFilters($translated);
374            }
375        }
376    }
377
378    /**
379     * Add a field to facet on.
380     *
381     * @param string $newField Field name
382     * @param string $newAlias Optional on-screen display label
383     * @param bool   $ored     Should we treat this as an ORed facet?
384     *
385     * @return void
386     */
387    public function addFacet($newField, $newAlias = null, $ored = false)
388    {
389        parent::addFacet($newField, $newAlias, $ored);
390        foreach ($this->searchParams as $params) {
391            $backendId = $params->getSearchClassId();
392            if ($translated = $this->translateFacetName($newField, $backendId)) {
393                $params->addFacet($translated, $newAlias, $ored);
394            }
395        }
396    }
397
398    /**
399     * Add a checkbox facet. When the checkbox is checked, the specified filter
400     * will be applied to the search. When the checkbox is not checked, no filter
401     * will be applied.
402     *
403     * @param string $filter  [field]:[value] pair to associate with checkbox
404     * @param string $desc    Description to associate with the checkbox
405     * @param bool   $dynamic Is this being added dynamically (true) or in response
406     * to a user configuration (false)?
407     *
408     * @return void
409     */
410    public function addCheckboxFacet($filter, $desc, $dynamic = false)
411    {
412        parent::addCheckboxFacet($filter, $desc, $dynamic);
413        if ($this->isBlenderFilter($filter)) {
414            return;
415        }
416        foreach ($this->searchParams as $params) {
417            $backendId = $params->getSearchClassId();
418            if ($translated = $this->translateFilter($filter, $backendId)) {
419                foreach ($translated as $current) {
420                    if (null !== $current) {
421                        $params->addCheckboxFacet($current, $desc, $dynamic);
422                    }
423                }
424            }
425        }
426    }
427
428    /**
429     * Reset the current facet configuration.
430     *
431     * @return void
432     */
433    public function resetFacetConfig()
434    {
435        $this->proxyMethod(__FUNCTION__, func_get_args());
436    }
437
438    /**
439     * Create search backend parameters for advanced features.
440     *
441     * @return ParamBag
442     */
443    public function getBackendParameters(): ParamBag
444    {
445        $result = parent::getBackendParameters();
446        foreach ($this->unsupportedFilters as $backendId => $filters) {
447            if ($filters) {
448                $result->add('fq', "-blender_backend:\"$backendId\"");
449            }
450        }
451        foreach ($this->searchParams as $params) {
452            $backendId = $params->getSearchClassId();
453            if (!is_callable([$params, 'getBackendParameters'])) {
454                throw new \Exception(
455                    "Backend $backendId missing support for getBackendParameters"
456                );
457            }
458
459            // Clone params so that adding any default filters does not affect the
460            // original instance:
461            $params = clone $params;
462            $this->addDefaultFilters($params, $backendId);
463
464            $result->set(
465                "query_$backendId",
466                $params->getQuery()
467            );
468            $result->set(
469                "params_$backendId",
470                $params->getBackendParameters()
471            );
472        }
473        return $result;
474    }
475
476    /**
477     * Add default filters to the given params
478     *
479     * @param BaseParams $params    Params
480     * @param string     $backendId Backend ID
481     *
482     * @return void
483     */
484    protected function addDefaultFilters(BaseParams $params, string $backendId): void
485    {
486        foreach ($this->mappings['Facets']['Fields'] ?? [] as $fieldConfig) {
487            $mappings = $fieldConfig['Mappings'][$backendId] ?? [];
488            $defaultValue = $mappings['DefaultValue'] ?? null;
489            if (null !== $defaultValue) {
490                $translatedField = $mappings['Field'];
491                $filterList = $params->getFilterList();
492                $found = false;
493                foreach ($filterList as $filters) {
494                    foreach ($filters as $filter) {
495                        if ($filter['field'] === $translatedField) {
496                            $found = true;
497                            break;
498                        }
499                    }
500                }
501                if (!$found) {
502                    $params->addFilter("$translatedField:$defaultValue");
503                }
504            }
505        }
506    }
507
508    /**
509     * Proxy a method call to parent class and all backend params classes
510     *
511     * @param string $method Method
512     * @param array  $params Method parameters
513     *
514     * @return mixed
515     */
516    protected function proxyMethod(string $method, array $params)
517    {
518        $result = call_user_func_array(parent::class . "::$method", $params);
519        foreach ($this->searchParams as $searchParams) {
520            $result = call_user_func_array([$searchParams, $method], $params);
521        }
522        return $result;
523    }
524
525    /**
526     * Translate a facet field name
527     *
528     * @param string $field     Facet field
529     * @param string $backendId Backend ID
530     *
531     * @return string
532     */
533    protected function translateFacetName(string $field, string $backendId): string
534    {
535        $fieldConfig = $this->mappings['Facets']['Fields'][$field] ?? [];
536        return $fieldConfig['Mappings'][$backendId]['Field'] ?? '';
537    }
538
539    /**
540     * Check if the filter is a special Blender filter
541     *
542     * @param string $filter Filter
543     *
544     * @return bool
545     */
546    protected function isBlenderFilter(string $filter): bool
547    {
548        [, $field] = $this->parseFilterAndPrefix($filter);
549        return 'blender_backend' === $field;
550    }
551
552    /**
553     * Translate a filter
554     *
555     * @param string $filter    Filter
556     * @param string $backendId Backend ID
557     *
558     * @return array
559     */
560    protected function translateFilter(string $filter, string $backendId): array
561    {
562        [$prefix, $field, $value] = $this->parseFilterAndPrefix($filter);
563
564        $fieldConfig = $this->mappings['Facets']['Fields'][$field] ?? [];
565        if ($ignore = $fieldConfig['Mappings'][$backendId]['Ignore'] ?? '') {
566            if (true === $ignore || in_array($value, (array)$ignore)) {
567                return [null];
568            }
569        }
570        $mappings = $fieldConfig['Mappings'][$backendId] ?? [];
571        $translatedField = $mappings['Field'] ?? '';
572        if (!$mappings || !$translatedField) {
573            // Facet not supported by the backend
574            return [];
575        }
576
577        // Map filter value
578        $facetType = $fieldConfig['Type'] ?? 'normal';
579        if ('boolean' === $facetType) {
580            $value = (bool)$value;
581        }
582        $resultValues = [];
583        foreach ($mappings['Values'] ?? [$value => $value] as $k => $v) {
584            if ('boolean' === $facetType) {
585                $v = (bool)$v;
586            }
587            if ($value === $v) {
588                $resultValues[] = $k;
589            }
590        }
591        if ($mappings['Hierarchical'] ?? false) {
592            $resultValues = $this->addLowerLevelHierarchicalFilterValues(
593                $value,
594                $resultValues,
595                $mappings['Values'] ?? []
596            );
597        }
598
599        // If the result is more than one value, convert an AND search to OR:
600        if ('' === $prefix && count($resultValues) > 1) {
601            $prefix = '~';
602        }
603
604        $result = [];
605        foreach ($resultValues as $value) {
606            $result[] = $prefix . $translatedField . ':' . $value;
607        }
608
609        return $result;
610    }
611
612    /**
613     * Handle any lower level mappings when translating hierarchical facets.
614     *
615     * This ensures that selecting a facet value higher in a hierarchy than the
616     * mapped value still adds the correct filter.
617     * Example:
618     * - Backend's value 'journal' is mapped to hierarchical value
619     * '1/Journal/eJournal/'.
620     * - When user selects the top level facet '0/Journal/', it needs to be
621     * reflected as 'journal' in the backend.
622     *
623     * @param mixed $value        Filter value
624     * @param array $resultValues Current resulting filter values
625     * @param array $mappings     Value mappings
626     *
627     * @return array Updated filter values
628     */
629    protected function addLowerLevelHierarchicalFilterValues(
630        $value,
631        array $resultValues,
632        array $mappings
633    ): array {
634        $levelOffset = -1;
635        do {
636            $levelGood = false;
637            foreach ($mappings as $k => $v) {
638                $parts = explode('/', $v);
639                $partCount = count($parts);
640                if ($parts[0] <= 0 || $partCount <= 2) {
641                    continue;
642                }
643                $level = $parts[0] + $levelOffset;
644                if ($level < 0) {
645                    continue;
646                }
647                $levelGood = true;
648                $levelValue = $level . '/'
649                    . implode(
650                        '/',
651                        array_slice($parts, 1, $level + 1)
652                    ) . '/';
653                if ($value === $levelValue) {
654                    $resultValues[] = $k;
655                }
656            }
657            --$levelOffset;
658        } while ($levelGood);
659
660        return $resultValues;
661    }
662
663    /**
664     * Translate a search type
665     *
666     * @param string $type      Search type
667     * @param string $backendId Backend ID
668     *
669     * @return string
670     */
671    protected function translateSearchType(string $type, string $backendId): string
672    {
673        $mappings = $this->mappings['Search']['Fields'][$type]['Mappings'] ?? [];
674        return $mappings[$backendId] ?? '';
675    }
676
677    /**
678     * Translate a sort option
679     *
680     * @param string $sort      Sort option
681     * @param string $backendId Backend ID
682     *
683     * @return string
684     */
685    protected function translateSort(string $sort, string $backendId): string
686    {
687        $mappings = $this->mappings['Sorting']['Fields'][$sort]['Mappings'] ?? [];
688        return $mappings[$backendId] ?? '';
689    }
690}