Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.87% covered (success)
94.87%
111 / 117
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
QueryAdapter
94.87% covered (success)
94.87%
111 / 117
20.00% covered (danger)
20.00%
1 / 5
39.21
0.00% covered (danger)
0.00%
0 / 1
 deminify
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
6
 display
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 displayAdvanced
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
7.02
 fromRequest
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
13.08
 minify
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
10
1<?php
2
3/**
4 * Search query adapter
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2011.
9 * Copyright (C) The National Library of Finland 2024.
10 *
11 * This program is free software; you can redistribute it and/or modify
12 * it under the terms of the GNU General Public License version 2,
13 * as published by the Free Software Foundation.
14 *
15 * This program is distributed in the hope that it will be useful,
16 * but WITHOUT ANY WARRANTY; without even the implied warranty of
17 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18 * GNU General Public License for more details.
19 *
20 * You should have received a copy of the GNU General Public License
21 * along with this program; if not, write to the Free Software
22 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
23 *
24 * @category VuFind
25 * @package  Search_Solr
26 * @author   Demian Katz <demian.katz@villanova.edu>
27 * @author   Ere Maijala <ere.maijala@helsinki.fi>
28 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
29 * @link     https://vufind.org Main Page
30 */
31
32namespace VuFind\Search;
33
34use Laminas\Stdlib\Parameters;
35use VuFindSearch\Query\AbstractQuery;
36use VuFindSearch\Query\Query;
37use VuFindSearch\Query\QueryGroup;
38use VuFindSearch\Query\WorkKeysQuery;
39
40use function array_key_exists;
41use function call_user_func;
42use function count;
43
44/**
45 * Search query adapter
46 *
47 * @category VuFind
48 * @package  Search_Solr
49 * @author   Demian Katz <demian.katz@villanova.edu>
50 * @author   Ere Maijala <ere.maijala@helsinki.fi>
51 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
52 * @link     https://vufind.org Main Page
53 */
54class QueryAdapter implements QueryAdapterInterface
55{
56    /**
57     * Return a Query or QueryGroup based on minified search arguments.
58     *
59     * @param array $search Minified search arguments
60     *
61     * @return Query|QueryGroup|WorkKeysQuery
62     */
63    public function deminify(array $search)
64    {
65        $type = $search['s'] ?? null;
66        if ('w' === $type) {
67            // WorkKeysQuery
68            return new WorkKeysQuery($search['l'], $search['i'], $search['k'] ?? []);
69        }
70        // Use array_key_exists since null is also valid
71        if ('b' === $type || array_key_exists('l', $search)) {
72            // Basic search
73            $handler = $search['i'] ?? $search['f'];
74            return new Query(
75                $search['l'],
76                $handler,
77                $search['o'] ?? null
78            );
79        } elseif (isset($search['g'])) {
80            $operator = $search['g'][0]['b'];
81            return new QueryGroup(
82                $operator,
83                array_map([$this, 'deminify'], $search['g'])
84            );
85        } else {
86            // Special case: The outer-most group-of-groups.
87            if (isset($search[0]['j'])) {
88                $operator = $search[0]['j'];
89                return new QueryGroup(
90                    $operator,
91                    array_map([$this, 'deminify'], $search)
92                );
93            } else {
94                // Simple query
95                return new Query($search[0]['l'], $search[0]['i']);
96            }
97        }
98    }
99
100    /**
101     * Convert a Query or QueryGroup into a human-readable display query.
102     *
103     * @param AbstractQuery $query     Query to convert
104     * @param callable      $translate Callback to translate strings
105     * @param callable      $showName  Callback to translate field names
106     *
107     * @return string
108     */
109    public function display(AbstractQuery $query, $translate, $showName)
110    {
111        // Simple case -- basic query:
112        if ($query instanceof Query) {
113            return $query->getString();
114        }
115
116        // Work keys query:
117        if ($query instanceof WorkKeysQuery) {
118            return $translate('Versions') . ' - ' . ($query->getId() ?? '');
119        }
120
121        // Complex case -- advanced query:
122        return $this->displayAdvanced($query, $translate, $showName);
123    }
124
125    /**
126     * Support method for display() -- process advanced queries.
127     *
128     * @param QueryGroup $query     Query to convert
129     * @param callable   $translate Callback to translate strings
130     * @param callable   $showName  Callback to translate field names
131     *
132     * @return string
133     */
134    protected function displayAdvanced(
135        QueryGroup $query,
136        callable $translate,
137        callable $showName
138    ) {
139        // Groups and exclusions.
140        $groups = $excludes = [];
141
142        foreach ($query->getQueries() as $search) {
143            if ($search instanceof QueryGroup) {
144                $thisGroup = [];
145                // Process each search group
146                foreach ($search->getQueries() as $group) {
147                    if ($group instanceof Query) {
148                        // Build this group individually as a basic search
149                        $thisGroup[]
150                            = call_user_func($showName, $group->getHandler()) . ':'
151                            . $group->getString();
152                    } else {
153                        throw new \Exception('Unexpected ' . $group::class);
154                    }
155                }
156                // Is this an exclusion (NOT) group or a normal group?
157                $str = implode(
158                    ' ' . call_user_func($translate, $search->getOperator())
159                    . ' ',
160                    $thisGroup
161                );
162                if ($search->isNegated()) {
163                    $excludes[] = $str;
164                } else {
165                    $groups[] = $str;
166                }
167            } else {
168                throw new \Exception('Unexpected ' . $search::class);
169            }
170        }
171
172        // Base 'advanced' query
173        $operator = call_user_func($translate, $query->getOperator());
174        $output = '(' . implode(') ' . $operator . ' (', $groups) . ')';
175
176        // Concatenate exclusion after that
177        if (count($excludes) > 0) {
178            $output .= ' ' . call_user_func($translate, 'NOT') . ' (('
179                . implode(') ' . call_user_func($translate, 'OR') . ' (', $excludes)
180                . '))';
181        }
182
183        return $output;
184    }
185
186    /**
187     * Convert user request parameters into a query (currently for advanced searches
188     * and work keys searches only).
189     *
190     * @param Parameters $request        User-submitted parameters
191     * @param string     $defaultHandler Default search handler
192     *
193     * @return Query|QueryGroup|WorkKeysQuery
194     */
195    public function fromRequest(Parameters $request, $defaultHandler)
196    {
197        // Check for a work keys query first (id and keys included for back-compatibility):
198        if (
199            $request->get('search') === 'versions'
200            || ($request->offsetExists('id') && $request->offsetExists('keys'))
201        ) {
202            if (null !== ($id = $request->offsetGet('id'))) {
203                return new WorkKeysQuery($id, true);
204            }
205        }
206
207        $groups = [];
208        // Loop through all parameters and look for 'lookforX'
209        foreach ($request as $key => $value) {
210            if (!preg_match('/^lookfor(\d+)$/', $key, $matches)) {
211                continue;
212            }
213            $groupId = $matches[1];
214            $group = [];
215            $lastBool = null;
216            $value = (array)$value;
217
218            // Loop through each term inside the group
219            for ($i = 0; $i < count($value); $i++) {
220                // Ignore advanced search fields with no lookup
221                if ($value[$i] != '') {
222                    // Use default fields if not set
223                    $typeArr = (array)$request->get("type$groupId");
224                    $handler = !empty($typeArr[$i]) ? $typeArr[$i] : $defaultHandler;
225
226                    $opArr = (array)$request->get("op$groupId");
227                    $operator = !empty($opArr[$i]) ? $opArr[$i] : null;
228
229                    // Add term to this group
230                    $boolArr = (array)$request->get("bool$groupId");
231                    $lastBool = $boolArr[0] ?? 'AND';
232                    $group[] = new Query($value[$i], $handler, $operator);
233                }
234            }
235
236            // Make sure we aren't adding groups that had no terms
237            if (count($group) > 0) {
238                // Add the completed group to the list
239                $groups[] = new QueryGroup($lastBool, $group);
240            }
241        }
242
243        return (count($groups) > 0)
244            ? new QueryGroup($request->get('join', 'AND'), $groups)
245            : new Query();
246    }
247
248    /**
249     * Convert a Query or QueryGroup into minified search arguments.
250     *
251     * @param AbstractQuery $query    Query to minify
252     * @param bool          $topLevel Is this a top-level query? (Used for recursion)
253     *
254     * @return array
255     */
256    public function minify(AbstractQuery $query, $topLevel = true)
257    {
258        // Simple query:
259        if ($query instanceof Query) {
260            return [
261                [
262                    'l' => $query->getString(),
263                    'i' => $query->getHandler(),
264                    's' => 'b',
265                ],
266            ];
267        }
268
269        // WorkKeys query:
270        if ($query instanceof WorkKeysQuery) {
271            return [
272                'l' => $query->getId(),
273                'i' => $query->getIncludeSelf(),
274                'k' => $query->getWorkKeys(),
275                's' => 'w',
276            ];
277        }
278
279        // Advanced query:
280        $retVal = [];
281        $operator = $query->isNegated() ? 'NOT' : $query->getOperator();
282        foreach ($query->getQueries() as $current) {
283            if ($topLevel) {
284                $retVal[] = [
285                    'g' => $this->minify($current, false),
286                    'j' => $operator,
287                    's' => 'a',
288                ];
289            } elseif ($current instanceof QueryGroup) {
290                throw new \Exception('Not sure how to minify this query!');
291            } else {
292                $currentArr = [
293                    'f' => $current->getHandler(),
294                    'l' => $current->getString(),
295                    'b' => $operator,
296                ];
297                if (null !== ($op = $current->getOperator())) {
298                    // Some search forms omit the operator for the first element;
299                    // if we have an operator in a subsequent element, we should
300                    // backfill a blank here for consistency; otherwise, VuFind
301                    // may not construct correct search URLs.
302                    if (isset($retVal[0]['f']) && !isset($retVal[0]['o'])) {
303                        $retVal[0]['o'] = '';
304                    }
305                    $currentArr['o'] = $op;
306                }
307                $retVal[] = $currentArr;
308            }
309        }
310        return $retVal;
311    }
312}