Passed
Push — master ( 90cea8...3d6c73 )
by Timo
01:26
created

Query::escapePhrase()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 7
ccs 1
cts 1
cp 1
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 4
nc 1
nop 1
crap 1
1
<?php
2
namespace ApacheSolrForTypo3\Solr;
3
4
/***************************************************************
5
 *  Copyright notice
6
 *
7
 *  (c) 2009-2015 Ingo Renner <[email protected]>
8
 *  All rights reserved
9
 *
10
 *  This script is part of the TYPO3 project. The TYPO3 project is
11
 *  free software; you can redistribute it and/or modify
12
 *  it under the terms of the GNU General Public License as published by
13
 *  the Free Software Foundation; either version 2 of the License, or
14
 *  (at your option) any later version.
15
 *
16
 *  The GNU General Public License can be found at
17
 *  http://www.gnu.org/copyleft/gpl.html.
18
 *
19
 *  This script is distributed in the hope that it will be useful,
20
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
21
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
22
 *  GNU General Public License for more details.
23
 *
24
 *  This copyright notice MUST APPEAR in all copies of the script!
25
 ***************************************************************/
26
27
use ApacheSolrForTypo3\Solr\Domain\Site\SiteHashService;
28
use ApacheSolrForTypo3\Solr\FieldProcessor\PageUidToHierarchy;
29
use ApacheSolrForTypo3\Solr\System\Configuration\TypoScriptConfiguration;
30
use ApacheSolrForTypo3\Solr\System\Logging\SolrLogManager;
31
use TYPO3\CMS\Core\Utility\GeneralUtility;
32
33
/**
34
 * A Solr search query
35
 *
36
 * @author Ingo Renner <[email protected]>
37
 */
38
class Query
39
{
40
41
    // FIXME extract link building from the query, it's not the query's domain
42
43
    const SORT_ASC = 'ASC';
44
    const SORT_DESC = 'DESC';
45
46
    const OPERATOR_AND = 'AND';
47
    const OPERATOR_OR = 'OR';
48
49
    /**
50
     * Used to identify the queries.
51
     *
52
     * @var int
53
     */
54
    protected static $idCount = 0;
55
56
    /**
57
     * @var int
58
     */
59
    protected $id;
60
61
    /**
62
     * @var TypoScriptConfiguration
63
     */
64
    protected $solrConfiguration;
65
66
    /**
67
     * @var string
68
     */
69
    protected $keywords;
70
71
    /**
72
     * @var string
73
     */
74
    protected $keywordsRaw;
75
76
    /**
77
     * @var array
78
     */
79
    protected $filters = [];
80
81
    /**
82
     * @var string
83
     */
84
    protected $sorting;
85
86
    // TODO check usage of these two variants, especially the check for $rawQueryString in getQueryString()
87
    /**
88
     * @var
89
     */
90
    protected $queryString;
91
92
    /**
93
     * @var array
94
     */
95
    protected $queryParameters = [];
96
97
    /**
98
     * @var int
99
     */
100
    protected $resultsPerPage;
101
102
    /**
103
     * @var int
104
     */
105
    protected $page;
106
107
    /**
108
     * @var int
109
     */
110
    protected $linkTargetPageId;
111
112
    /**
113
     * Holds the query fields with their associated boosts. The key represents
114
     * the field name, value represents the field's boost. These are the fields
115
     * that will actually be searched.
116
     *
117
     * Used in Solr's qf parameter
118
     *
119
     * @var array
120
     * @see http://wiki.apache.org/solr/DisMaxQParserPlugin#qf_.28Query_Fields.29
121
     */
122
    protected $queryFields = [];
123
124
    /**
125
     * List of fields that will be returned in the result documents.
126
     *
127
     * used in Solr's fl parameter
128
     *
129
     * @var array
130
     * @see http://wiki.apache.org/solr/CommonQueryParameters#fl
131
     */
132
    protected $fieldList = [];
133
134
    /**
135
     * @var array
136
     */
137
    protected $filterFields;
138
139
    /**
140
     * @var bool
141
     */
142
    private $rawQueryString = false;
143
144
    /**
145
     * The field by which the result will be collapsed
146
     * @var string
147
     */
148
    protected $variantField = 'variantId';
149
150
    /**
151
     * @var SiteHashService
152
     */
153
    protected $siteHashService = null;
154
155
    /**
156
     * @var \ApacheSolrForTypo3\Solr\System\Logging\SolrLogManager
157
     */
158
    protected $logger = null;
159
160
    /**
161
     * Query constructor.
162
     * @param string $keywords
163
     * @param TypoScriptConfiguration $solrConfiguration
164
     * @param SiteHashService|null $siteHashService
165
     */
166
    public function __construct($keywords, $solrConfiguration = null, SiteHashService $siteHashService = null)
167
    {
168
        $keywords = (string)$keywords;
169
170
        $this->logger = GeneralUtility::makeInstance(SolrLogManager::class, __CLASS__);
171
        $this->solrConfiguration = is_null($solrConfiguration) ? Util::getSolrConfiguration() : $solrConfiguration;
172 132
        $this->siteHashService = is_null($siteHashService) ? GeneralUtility::makeInstance(SiteHashService::class) : $siteHashService;
173
174 132
        $this->setKeywords($keywords);
175
        $this->sorting = '';
176 132
177 132
        // What fields to search
178 132
        $queryFields = $this->solrConfiguration->getSearchQueryQueryFields();
179
        if ($queryFields != '') {
180 132
            $this->setQueryFieldsFromString($queryFields);
181 132
        }
182
183
        // What fields to return from Solr
184 132
        $this->fieldList = $this->solrConfiguration->getSearchQueryReturnFieldsAsArray(['*', 'score']);
185 132
        $this->linkTargetPageId = $this->solrConfiguration->getSearchTargetPage();
186 27
187 27
        $this->initializeQuery();
188
189
        $this->id = ++self::$idCount;
190 132
    }
191 132
192
    /**
193 132
     * @return void
194
     */
195 132
    protected function initializeQuery()
196 132
    {
197
        $this->initializeCollapsingFromConfiguration();
198
    }
199
200
    /**
201 131
     * Takes a string of comma separated query fields and _overwrites_ the
202
     * currently set query fields. Boost can also be specified in through the
203 131
     * given string.
204 131
     *
205
     * Example: "title^5, subtitle^2, content, author^0.5"
206
     * This sets the query fields to title with  a boost of 5.0, subtitle with
207
     * a boost of 2.0, content with a default boost of 1.0 and the author field
208
     * with a boost of 0.5
209
     *
210
     * @param string $queryFields A string defining which fields to query and their associated boosts
211
     * @return void
212
     */
213
    public function setQueryFieldsFromString($queryFields)
214
    {
215
        $fields = GeneralUtility::trimExplode(',', $queryFields, true);
216
217
        foreach ($fields as $field) {
218
            $fieldNameAndBoost = explode('^', $field);
219 28
220
            $boost = 1.0;
221 28
            if (isset($fieldNameAndBoost[1])) {
222
                $boost = floatval($fieldNameAndBoost[1]);
223 28
            }
224 28
225
            $this->setQueryField($fieldNameAndBoost[0], $boost);
226 28
        }
227 28
    }
228 28
229 28
    /**
230
     * Sets a query field and its boost. If the field does not exist yet, it
231 28
     * gets added. Boost is optional, if left out a default boost of 1.0 is
232 28
     * applied.
233 28
     *
234
     * @param string $fieldName The field's name
235
     * @param float $boost Optional field boost, defaults to 1.0
236
     * @return void
237
     */
238
    public function setQueryField($fieldName, $boost = 1.0)
239
    {
240
        $this->queryFields[$fieldName] = (float)$boost;
241
    }
242
243
    /**
244 28
     * magic implementation for clone(), makes sure that the id counter is
245
     * incremented
246 28
     *
247 28
     * @return void
248
     */
249
    public function __clone()
250
    {
251
        $this->id = ++self::$idCount;
252
    }
253
254
    /**
255 5
     * returns a string representation of the query
256
     *
257 5
     * @return string the string representation of the query
258 5
     */
259
    public function __toString()
260
    {
261
        return $this->getQueryString();
262
    }
263
264
    /**
265 8
     * Builds the query string which is then used for Solr's q parameters
266
     *
267 8
     * @return string Solr query string
268
     */
269
    public function getQueryString()
270
    {
271
        if (!$this->rawQueryString) {
272
            $this->buildQueryString();
273
        }
274
275 38
        return $this->queryString;
276
    }
277 38
278 35
    /**
279 35
     * Sets the query string without any escaping.
280
     *
281 38
     * Be cautious with this function!
282
     * TODO remove this method as it basically just sets the q parameter / keywords
283
     *
284
     * @param string $queryString The raw query string.
285
     */
286
    public function setQueryString($queryString)
287
    {
288
        $this->queryString = $queryString;
289
    }
290
291
    /**
292 4
     * Creates the string that is later used as the q parameter in the solr query
293
     *
294 4
     * @return void
295 4
     */
296
    protected function buildQueryString()
297
    {
298
        // very simple for now
299
        $this->queryString = $this->keywords;
300
    }
301
302 35
    /**
303
     * Sets whether a raw query sting should be used, that is, whether the query
304
     * string should be escaped or not.
305 35
     *
306 35
     * @param bool $useRawQueryString TRUE to use raw queries (like Lucene Query Language) or FALSE for regular, escaped queries
307
     */
308
    public function useRawQueryString($useRawQueryString)
309
    {
310
        $this->rawQueryString = (boolean)$useRawQueryString;
311
    }
312
313
    /**
314 4
     * Returns the query's ID.
315
     *
316 4
     * @return int The query's ID.
317 4
     */
318
    public function getId()
319
    {
320
        return $this->id;
321
    }
322
323
    /**
324
     * Quote and escape search strings
325
     *
326
     * @param string $string String to escape
327
     * @return string The escaped/quoted string
328
     */
329
    public function escape($string)
330
    {
331
        // when we have a numeric string only, nothing needs to be done
332
        if (is_numeric($string)) {
333
            return $string;
334
        }
335 132
336
        // when no whitespaces are in the query we can also just escape the special characters
337
        if (preg_match('/\W/', $string) != 1) {
338 132
            return $this->escapeSpecialCharacters($string);
339 1
        }
340
341
        // when there are no quotes inside the query string we can also just escape the whole string
342
        $hasQuotes = strrpos($string, '"') !== false;
343 132
        if (!$hasQuotes) {
344 111
            return $this->escapeSpecialCharacters($string);
345
        }
346
347
        $result = $this->tokenizeByQuotesAndEscapeDependingOnContext($string);
348 37
349 37
        return $result;
350 30
    }
351
352
    /**
353 7
     * This method is used to escape the content in the query string surrounded by quotes
354
     * different then when it is not in a quoted context.
355 7
     *
356
     * @param string $string
357
     * @return string
358
     */
359
    protected function tokenizeByQuotesAndEscapeDependingOnContext($string)
360
    {
361
        $result = '';
362
        $quotesCount = substr_count($string, '"');
363
        $isEvenAmountOfQuotes = $quotesCount % 2 === 0;
364
365 7
        // go over all quote segments and apply escapePhrase inside a quoted
366
        // context and escapeSpecialCharacters outside the quoted context.
367 7
        $segments = explode('"', $string);
368 7
        $segmentsIndex = 0;
369 7
        foreach ($segments as $segment) {
370
            $isInQuote = $segmentsIndex % 2 !== 0;
371
            $isLastQuote = $segmentsIndex === $quotesCount;
372
373 7
            if ($isLastQuote && !$isEvenAmountOfQuotes) {
374 7
                $result .= '\"';
375 7
            }
376 7
377 7
            if ($isInQuote && !$isLastQuote) {
378
                $result .= $this->escapePhrase($segment);
379 7
            } else {
380 1
                $result .= $this->escapeSpecialCharacters($segment);
381 1
            }
382
383 7
            $segmentsIndex++;
384 6
        }
385 6
386 7
        return $result;
387
    }
388
389 7
    // pagination
390 7
391
    /**
392 7
     * Escapes a value meant to be contained in a phrase with characters with
393
     * special meanings in Lucene query syntax.
394
     *
395
     * @param string $value Unescaped - "dirty" - string
396
     * @return string Escaped - "clean" - string
397
     */
398
    protected function escapePhrase($value)
399
    {
400
        $pattern = '/("|\\\)/';
401
        $replace = '\\\$1';
402
403
        return '"' . preg_replace($pattern, $replace, $value) . '"';
404 6
    }
405
406 6
    /**
407 6
     * Escapes characters with special meanings in Lucene query syntax.
408
     *
409 6
     * @param string $value Unescaped - "dirty" - string
410
     * @return string Escaped - "clean" - string
411
     */
412
    protected function escapeSpecialCharacters($value)
413
    {
414
        // list taken from http://lucene.apache.org/core/4_4_0/queryparser/org/apache/lucene/queryparser/classic/package-summary.html#package_description
415
        // which mentions: + - && || ! ( ) { } [ ] ^ " ~ * ? : \ /
416
        // of which we escape: ( ) { } [ ] ^ " ~ : \ /
417
        // and explicitly don't escape: + - && || ! * ?
418 132
        $pattern = '/(\\(|\\)|\\{|\\}|\\[|\\]|\\^|"|~|\:|\\\\|\\/)/';
419
        $replace = '\\\$1';
420
421
        return preg_replace($pattern, $replace, $value);
422
    }
423
424 132
    /**
425 132
     * Gets the currently showing page's number
426
     *
427 132
     * @return int page number currently showing
428
     */
429
    public function getPage()
430
    {
431
        return $this->page;
432
    }
433
434
    /**
435 1
     * Sets the page that should be shown
436
     *
437 1
     * @param int $page page number to show
438
     * @return void
439
     */
440
    public function setPage($page)
441
    {
442
        $this->page = max(intval($page), 0);
443
    }
444
445
    /**
446 1
     * Gets the index of the first result document we're showing
447
     *
448 1
     * @return int index of the currently first document showing
449 1
     */
450
    public function getStartIndex()
451
    {
452
        return ($this->page - 1) * $this->resultsPerPage;
453
    }
454
455
    /**
456
     * Gets the index of the last result document we're showing
457
     *
458
     * @return int index of the currently last document showing
459
     */
460
    public function getEndIndex()
461
    {
462
        return $this->page * $this->resultsPerPage;
463
    }
464
465
    // query elevation
466
467
    /**
468
     * Activates and deactivates query elevation for the current query.
469
     *
470
     * @param bool $elevation True to enable query elevation (default), FALSE to disable query elevation.
471
     * @param bool $forceElevation Optionally force elevation so that the elevated documents are always on top regardless of sorting, default to TRUE.
472
     * @param bool $markElevatedResults Mark elevated results
473
     * @return void
474
     */
475
    public function setQueryElevation(
476
        $elevation = true,
477
        $forceElevation = true,
478
        $markElevatedResults = true
479
    ) {
480
        if ($elevation) {
481 26
            $this->queryParameters['enableElevation'] = 'true';
482
            $this->setForceElevation($forceElevation);
483
            if ($markElevatedResults) {
484
                $this->addReturnField('isElevated:[elevated]');
485
            }
486 26
        } else {
487 22
            $this->queryParameters['enableElevation'] = 'false';
488 22
            unset($this->queryParameters['forceElevation']);
489 22
            $this->removeReturnField('isElevated:[elevated]');
490 22
            $this->removeReturnField('[elevated]'); // fallback
491 22
        }
492 22
    }
493 5
494 5
    /**
495 5
     * Enables or disables the forceElevation query parameter.
496 5
     *
497
     * @param bool $forceElevation
498 26
     */
499
    protected function setForceElevation($forceElevation)
500
    {
501
        if ($forceElevation) {
502
            $this->queryParameters['forceElevation'] = 'true';
503
        } else {
504
            $this->queryParameters['forceElevation'] = 'false';
505 22
        }
506
    }
507 22
508 21
    // collapsing
509 21
510 1
    /**
511
     * Check whether collapsing is active
512 22
     *
513
     * @return bool
514
     */
515
    public function getIsCollapsing()
516
    {
517
        return array_key_exists('collapsing', $this->filters);
518
    }
519
520
    /**
521 3
     * @param string $fieldName
522
     */
523 3
    public function setVariantField($fieldName)
524
    {
525
        $this->variantField = $fieldName;
526
    }
527
528
    /**
529 3
     * @return string
530
     */
531 3
    public function getVariantField()
532 3
    {
533
        return $this->variantField;
534
    }
535
536
    /**
537 1
     * @param bool $collapsing
538
     */
539 1
    public function setCollapsing($collapsing = true)
540
    {
541
        if ($collapsing) {
542
            $this->filters['collapsing'] = '{!collapse field=' . $this->variantField . '}';
543
            if ($this->solrConfiguration->getSearchVariantsExpand()) {
544
                $this->queryParameters['expand'] = 'true';
545 4
                $this->queryParameters['expand.rows'] = $this->solrConfiguration->getSearchVariantsLimit();
546
            }
547 4
        } else {
548 4
            unset($this->filters['collapsing']);
549 4
            unset($this->queryParameters['expand']);
550 2
            unset($this->queryParameters['expand.rows']);
551 2
        }
552 2
    }
553 4
554 1
    // grouping
555 1
556 1
    /**
557
     * Adds a field to the list of fields to return. Also checks whether * is
558 4
     * set for the fields, if so it's removed from the field list.
559
     *
560
     * @param string $fieldName Name of a field to return in the result documents
561
     */
562
    public function addReturnField($fieldName)
563
    {
564
        if (strpos($fieldName, '[') === false
565
            && strpos($fieldName, ']') === false
566
            && in_array('*', $this->fieldList)
567
        ) {
568 23
            $this->fieldList = array_diff($this->fieldList, ['*']);
569
        }
570 23
571 23
        $this->fieldList[] = $fieldName;
572 23
    }
573 23
574 1
    /**
575 1
     * Removes a field from the list of fields to return (fl parameter).
576
     *
577 23
     * @param string $fieldName Field to remove from the list of fields to return
578 23
     */
579
    public function removeReturnField($fieldName)
580
    {
581
        $key = array_search($fieldName, $this->fieldList);
582
583
        if ($key !== false) {
584
            unset($this->fieldList[$key]);
585 6
        }
586
    }
587 6
588
    /**
589 6
     * Activates and deactivates grouping for the current query.
590 2
     *
591 2
     * @param bool $grouping TRUE to enable grouping, FALSE to disable grouping
592 6
     * @return void
593
     */
594
    public function setGrouping($grouping = true)
595
    {
596
        if ($grouping) {
597
            $this->queryParameters['group'] = 'true';
598
            $this->queryParameters['group.format'] = 'grouped';
599
            $this->queryParameters['group.ngroups'] = 'true';
600 2
        } else {
601
            foreach ($this->queryParameters as $key => $value) {
602 2
                // remove all group.* settings
603 1
                if (GeneralUtility::isFirstPartOfStr($key, 'group')) {
604 1
                    unset($this->queryParameters[$key]);
605 1
                }
606 1
            }
607 1
        }
608
    }
609 1
610 1
    /**
611 1
     * Sets the number of groups to return per group field or group query
612 1
     *
613
     * Internally uses the rows parameter.
614 2
     *
615
     * @param int $numberOfGroups Number of groups per group.field or group.query
616
     */
617
    public function setNumberOfGroups($numberOfGroups)
618
    {
619
        $this->setResultsPerPage($numberOfGroups);
620
    }
621
622
    /**
623 1
     * Gets the number of groups to return per group field or group query
624
     *
625 1
     * Internally uses the rows parameter.
626 1
     *
627
     * @return int Number of groups per group.field or group.query
628
     */
629
    public function getNumberOfGroups()
630
    {
631
        return $this->getResultsPerPage();
632
    }
633
634
    /**
635 1
     * Returns the number of results that should be shown per page
636
     *
637 1
     * @return int number of results to show per page
638
     */
639
    public function getResultsPerPage()
640
    {
641
        return $this->resultsPerPage;
642
    }
643
644
    /**
645 25
     * Sets the number of results that should be shown per page
646
     *
647 25
     * @param int $resultsPerPage Number of results to show per page
648
     * @return void
649
     */
650
    public function setResultsPerPage($resultsPerPage)
651
    {
652
        $this->resultsPerPage = max(intval($resultsPerPage), 0);
653
    }
654
655
    /**
656 32
     * Adds a field that should be used for grouping.
657
     *
658 32
     * @param string $fieldName Name of a field for grouping
659 32
     */
660
    public function addGroupField($fieldName)
661
    {
662
        if (!isset($this->queryParameters['group.field'])) {
663
            $this->queryParameters['group.field'] = [];
664
        }
665
666 1
        $this->queryParameters['group.field'][] = $fieldName;
667
    }
668 1
669 1
    /**
670 1
     * Gets the fields set for grouping.
671
     *
672 1
     * @return array An array of fields set for grouping.
673 1
     */
674
    public function getGroupFields()
675
    {
676
        return (array)$this->getQueryParameter('group.field', []);
677
    }
678
679
    /**
680 1
     * Adds sorting configuration for grouping.
681
     *
682 1
     * @param string $sorting value of sorting configuration
683
     */
684
    public function addGroupSorting($sorting)
685
    {
686
        if (!isset($this->queryParameters['group.sort'])) {
687
            $this->queryParameters['group.sort'] = [];
688
        }
689
        $this->queryParameters['group.sort'][] = $sorting;
690 1
    }
691
692 1
    /**
693 1
     * Gets the sorting set for grouping.
694 1
     *
695 1
     * @return array An array of sorting configurations for grouping.
696 1
     */
697
    public function getGroupSortings()
698
    {
699
        return (array)$this->getQueryParameter('group.sort', []);
700
    }
701
702
    // faceting
703 1
704
    /**
705 1
     * Adds a query that should be used for grouping.
706
     *
707
     * @param string $query Lucene query for grouping
708
     */
709
    public function addGroupQuery($query)
710
    {
711
        if (!isset($this->queryParameters['group.query'])) {
712
            $this->queryParameters['group.query'] = [];
713
        }
714
715 1
        $this->queryParameters['group.query'][] = $query;
716
    }
717 1
718 1
    /**
719 1
     * Gets the queries set for grouping.
720
     *
721 1
     * @return array An array of queries set for grouping.
722 1
     */
723
    public function getGroupQueries()
724
    {
725
        return (array)$this->getQueryParameter('group.query', []);
726
    }
727
728
    /**
729 1
     * Sets the maximum number of results to be returned per group.
730
     *
731 1
     * @param int $numberOfResults Maximum number of results per group to return
732
     */
733
    public function setNumberOfResultsPerGroup($numberOfResults)
734
    {
735
        $numberOfResults = max(intval($numberOfResults), 0);
736
737
        $this->queryParameters['group.limit'] = $numberOfResults;
738
    }
739 1
740
    // filter
741 1
742
    /**
743 1
     * Gets the maximum number of results to be returned per group.
744 1
     *
745
     * @return int Maximum number of results per group to return
746
     */
747
    public function getNumberOfResultsPerGroup()
748
    {
749
        // default if nothing else set is 1, @see http://wiki.apache.org/solr/FieldCollapsing
750
        $numberOfResultsPerGroup = 1;
751
752
        if (!empty($this->queryParameters['group.limit'])) {
753 1
            $numberOfResultsPerGroup = $this->queryParameters['group.limit'];
754
        }
755
756 1
        return $numberOfResultsPerGroup;
757
    }
758 1
759 1
    /**
760 1
     * Activates and deactivates faceting for the current query.
761
     *
762 1
     * @param bool $faceting TRUE to enable faceting, FALSE to disable faceting
763
     * @return void
764
     */
765
    public function setFaceting($faceting = true)
766
    {
767
        if ($faceting) {
768
            $this->queryParameters['facet'] = 'true';
769
            $this->queryParameters['facet.mincount'] = $this->solrConfiguration->getSearchFacetingMinimumCount();
770
            $this->queryParameters['facet.limit'] = $this->solrConfiguration->getSearchFacetingFacetLimit();
771 31
772
            $this->applyConfiguredFacetSorting();
773 31
        } else {
774 31
            $this->removeFacetingParametersFromQuery();
775 31
        }
776 31
    }
777
778 31
    /**
779 31
     * Removes all facet.* or f.*.facet.* parameters from the query.
780 1
     *
781
     * @return void
782 31
     */
783
    protected function removeFacetingParametersFromQuery()
784
    {
785
        foreach ($this->queryParameters as $key => $value) {
786
            // remove all facet.* settings
787
            if (GeneralUtility::isFirstPartOfStr($key, 'facet')) {
788
                unset($this->queryParameters[$key]);
789 1
            }
790
791 1
            // remove all f.*.facet.* settings (overrides for individual fields)
792
            if (GeneralUtility::isFirstPartOfStr($key, 'f.') && strpos($key, '.facet.') !== false) {
793 1
                unset($this->queryParameters[$key]);
794 1
            }
795 1
        }
796
    }
797
798 1
    /**
799 1
     * Reads the facet sorting configuration and applies it to the queryParameters.
800 1
     *
801 1
     * @return void
802 1
     */
803
    protected function applyConfiguredFacetSorting()
804
    {
805
        $sorting = $this->solrConfiguration->getSearchFacetingSortBy();
806
        if (!GeneralUtility::inList('count,index,alpha,lex,1,0,true,false', $sorting)) {
807
            // when the sorting is not in the list of valid values we do not apply it.
808
            return;
809 31
        }
810
811 31
        // alpha and lex alias for index
812 31
        if ($sorting == 'alpha' || $sorting == 'lex') {
813
            $sorting = 'index';
814 10
        }
815
816
        $this->queryParameters['facet.sort'] = $sorting;
817
    }
818 21
819 1
    /**
820 1
     * Sets facet fields for a query.
821
     *
822 21
     * @param array $facetFields Array of field names
823 21
     */
824
    public function setFacetFields(array $facetFields)
825
    {
826
        $this->queryParameters['facet.field'] = [];
827
828
        foreach ($facetFields as $facetField) {
829
            $this->addFacetField($facetField);
830 1
        }
831
    }
832 1
833
    /**
834 1
     * Adds a single facet field.
835 1
     *
836 1
     * @param string $facetField field name
837 1
     */
838
    public function addFacetField($facetField)
839
    {
840
        $this->queryParameters['facet.field'][] = $facetField;
841
    }
842
843
    /**
844 2
     * Removes a filter on a field
845
     *
846 2
     * @param string $filterFieldName The field name the filter should be removed for
847 2
     * @return void
848
     */
849
    public function removeFilter($filterFieldName)
850
    {
851
        foreach ($this->filters as $key => $filterString) {
852
            if (GeneralUtility::isFirstPartOfStr($filterString,
853
                $filterFieldName . ':')
854
            ) {
855 1
                unset($this->filters[$key]);
856
            }
857 1
        }
858 1
    }
859 1
860 1
    /**
861 1
     * Removes a filter based on key of filter array
862 1
     *
863 1
     * @param string $key array key
864 1
     */
865
    public function removeFilterByKey($key)
866
    {
867
        unset($this->filters[$key]);
868
    }
869
870
    /**
871 1
     * Removes a filter by the filter value. The value has the following format:
872
     *
873 1
     * "fieldname:value"
874 1
     *
875
     * @param string $filterString The filter to remove, in the form of field:value
876
     */
877
    public function removeFilterByValue($filterString)
878
    {
879
        $key = array_search($filterString, $this->filters);
880
        if ($key === false) {
881
            // value not found, nothing to do
882
            return;
883 1
        }
884
        unset($this->filters[$key]);
885 1
    }
886 1
887
    /**
888
     * Gets all currently applied filters.
889
     *
890 1
     * @return array Array of filters
891 1
     */
892
    public function getFilters()
893
    {
894
        return $this->filters;
895
    }
896
897
    // sorting
898 30
899
    /**
900 30
     * Sets access restrictions for a frontend user.
901
     *
902
     * @param array $groups Array of groups a user has been assigned to
903
     */
904
    public function setUserAccessGroups(array $groups)
905
    {
906
        $groups = array_map('intval', $groups);
907
        $groups[] = 0; // always grant access to public documents
908
        $groups = array_unique($groups);
909
        sort($groups, SORT_NUMERIC);
910 29
911
        $accessFilter = '{!typo3access}' . implode(',', $groups);
912 29
913 29
        foreach ($this->filters as $key => $filter) {
914 29
            if (GeneralUtility::isFirstPartOfStr($filter, '{!typo3access}')) {
915 29
                unset($this->filters[$key]);
916
            }
917 29
        }
918
919 29
        $this->addFilter($accessFilter);
920 25
    }
921 1
922 1
    /**
923 29
     * Adds a filter parameter.
924
     *
925 29
     * @param string $filterString The filter to add, in the form of field:value
926 29
     * @return void
927
     */
928
    public function addFilter($filterString)
929
    {
930
        // TODO refactor to split filter field and filter value, @see Drupal
931
        if ($this->solrConfiguration->getLoggingQueryFilters()) {
932
            $this->logger->log(
933
                SolrLogManager::INFO,
934 37
                'Adding filter',
935
                [
936
                    $filterString
937 37
                ]
938
            );
939
        }
940
941
        $this->filters[] = $filterString;
942
    }
943
944
945
    // query parameters
946
947 37
    /**
948 37
     * Limits the query to certain sites
949
     *
950
     * @param string $allowedSites Comma-separated list of domains
951
     */
952
    public function setSiteHashFilter($allowedSites)
953
    {
954
        if (trim($allowedSites) === '*') {
955
            return;
956
        }
957
958 26
        $allowedSites = GeneralUtility::trimExplode(',', $allowedSites);
959
        $filters = [];
960 26
961 1
        foreach ($allowedSites as $site) {
962
            $siteHash = $this->siteHashService->getSiteHashForDomain($site);
963
            $filters[] = 'siteHash:"' . $siteHash . '"';
964 25
        }
965 25
966
        $this->addFilter(implode(' OR ', $filters));
967 25
    }
968 25
969 25
    /**
970 25
     * Limits the query to certain page tree branches
971
     *
972 25
     * @param string $pageIds Comma-separated list of page IDs
973 25
     */
974
    public function setRootlineFilter($pageIds)
975
    {
976
        $pageIds = GeneralUtility::trimExplode(',', $pageIds);
977
        $filters = [];
978
979
            /** @var $processor PageUidToHierarchy */
980
        $processor = GeneralUtility::makeInstance(PageUidToHierarchy::class);
981
        $hierarchies = $processor->process($pageIds);
982
983
        foreach ($hierarchies as $hierarchy) {
984
            $lastLevel = array_pop($hierarchy);
985
            $filters[] = 'rootline:"' . $lastLevel . '"';
986
        }
987
988
        $this->addFilter(implode(' OR ', $filters));
989
    }
990
991
    /**
992
     * Gets the list of fields a query will return.
993
     *
994
     * @return array Array of field names the query will return
995
     */
996
    public function getFieldList()
997
    {
998
        return $this->fieldList;
999
    }
1000
1001
    /**
1002
     * Sets the fields to return by a query.
1003
     *
1004
     * @param array|string $fieldList an array or comma-separated list of field names
1005
     * @throws \UnexpectedValueException on parameters other than comma-separated lists and arrays
1006
     */
1007
    public function setFieldList($fieldList = ['*', 'score'])
1008
    {
1009
        if (is_string($fieldList)) {
1010
            $fieldList = GeneralUtility::trimExplode(',', $fieldList);
1011
        }
1012
1013
        if (!is_array($fieldList) || empty($fieldList)) {
1014
            throw new \UnexpectedValueException(
1015
                'Field list must be a comma-separated list or array and must not be empty.',
1016
                1310740308
1017
            );
1018
        }
1019
1020
        $this->fieldList = $fieldList;
1021
    }
1022
1023
    /**
1024
     * Gets the query type, Solr's qt parameter.
1025
     *
1026
     * @return string Query type, qt parameter.
1027
     */
1028
    public function getQueryType()
1029
    {
1030
        return $this->queryParameters['qt'];
1031
    }
1032
1033
    /**
1034
     * Sets the query type, Solr's qt parameter.
1035
     *
1036 6
     * @param string|bool $queryType String query type or boolean FALSE to disable / reset the qt parameter.
1037
     * @see http://wiki.apache.org/solr/CoreQueryParameters#qt
1038 6
     */
1039
    public function setQueryType($queryType)
1040
    {
1041
        if ($queryType) {
1042
            $this->queryParameters['qt'] = $queryType;
1043
        } else {
1044
            unset($this->queryParameters['qt']);
1045
        }
1046
    }
1047 3
1048
    /**
1049 3
     * Sets the query operator to AND or OR. Unsets the query operator (actually
1050 2
     * sets it back to default) for FALSE.
1051 2
     *
1052
     * @param string|bool $operator AND or OR, FALSE to unset
1053 3
     */
1054 1
    public function setOperator($operator)
1055 1
    {
1056
        if (in_array($operator, [self::OPERATOR_AND, self::OPERATOR_OR])) {
1057 1
            $this->queryParameters['q.op'] = $operator;
1058
        }
1059
1060 3
        if ($operator === false) {
1061 3
            unset($this->queryParameters['q.op']);
1062
        }
1063
    }
1064
1065
    /**
1066
     * Gets the alternative query, Solr's q.alt parameter.
1067
     *
1068 1
     * @return string Alternative query, q.alt parameter.
1069
     */
1070 1
    public function getAlternativeQuery()
1071
    {
1072
        return $this->queryParameters['q.alt'];
1073
    }
1074
1075
    /**
1076
     * Sets an alternative query, Solr's q.alt parameter.
1077
     *
1078
     * This query supports the complete Lucene Query Language.
1079 2
     *
1080
     * @param mixed $alternativeQuery String alternative query or boolean FALSE to disable / reset the q.alt parameter.
1081 2
     * @see http://wiki.apache.org/solr/DisMaxQParserPlugin#q.alt
1082 2
     */
1083 2
    public function setAlternativeQuery($alternativeQuery)
1084 1
    {
1085
        if ($alternativeQuery) {
1086 2
            $this->queryParameters['q.alt'] = $alternativeQuery;
1087
        } else {
1088
            unset($this->queryParameters['q.alt']);
1089
        }
1090
    }
1091
1092
    // keywords
1093
1094 1
    /**
1095
     * Set the query to omit the response header
1096 1
     *
1097 1
     * @param bool $omitHeader TRUE (default) to omit response headers, FALSE to re-enable
1098 1
     */
1099
    public function setOmitHeader($omitHeader = true)
1100 1
    {
1101 1
        if ($omitHeader) {
1102 1
            $this->queryParameters['omitHeader'] = 'true';
1103 1
        } else {
1104
            unset($this->queryParameters['omitHeader']);
1105
        }
1106
    }
1107
1108
    /**
1109
     * Get the query keywords, keywords are escaped.
1110 1
     *
1111
     * @return string query keywords
1112 1
     */
1113
    public function getKeywords()
1114
    {
1115
        return $this->keywords;
1116
    }
1117
1118
    /**
1119
     * Sets the query keywords, escapes them as needed for Solr/Lucene.
1120
     *
1121
     * @param string $keywords user search terms/keywords
1122
     */
1123 26
    public function setKeywords($keywords)
1124
    {
1125 26
        $this->keywords = $this->escape($keywords);
1126 26
        $this->keywordsRaw = $keywords;
1127 26
    }
1128 1
1129
    /**
1130 26
     * Gets the cleaned keywords so that it can be used in templates f.e.
1131
     *
1132
     * @return string The cleaned keywords.
1133
     */
1134
    public function getKeywordsCleaned()
1135
    {
1136
        return $this->cleanKeywords($this->keywordsRaw);
1137
    }
1138
1139 1
    /**
1140
     * Helper method to escape/encode keywords for use in HTML
1141 1
     *
1142 1
     * @param string $keywords Keywords to prepare for use in HTML
1143 1
     * @return string Encoded keywords
1144 1
     */
1145
    public static function cleanKeywords($keywords)
1146 1
    {
1147
        $keywords = trim($keywords);
1148
        $keywords = GeneralUtility::removeXSS($keywords);
1149
        $keywords = htmlentities($keywords, ENT_QUOTES,
1150
            $GLOBALS['TSFE']->metaCharset);
1151
1152
        // escape triple hashes as they are used in the template engine
1153 20
        // TODO remove after switching to fluid templates
1154
        $keywords = Template::escapeMarkers($keywords);
1155 20
1156
        return $keywords;
1157
    }
1158
1159
    // relevance, matching
1160
1161
    /**
1162
     * Gets the raw, unescaped, unencoded keywords.
1163 132
     *
1164
     * USE WITH CAUTION!
1165 132
     *
1166 132
     * @return string raw keywords
1167 132
     */
1168
    public function getKeywordsRaw()
1169
    {
1170
        return $this->keywordsRaw;
1171
    }
1172
1173
    /**
1174 18
     * Sets the minimum match (mm) parameter
1175
     *
1176 18
     * @param mixed $minimumMatch Minimum match parameter as string or boolean FALSE to disable / reset the mm parameter
1177
     * @see http://wiki.apache.org/solr/DisMaxRequestHandler#mm_.28Minimum_.27Should.27_Match.29
1178
     */
1179
    public function setMinimumMatch($minimumMatch)
1180
    {
1181
        if (is_string($minimumMatch) && !empty($minimumMatch)) {
1182
            $this->queryParameters['mm'] = $minimumMatch;
1183
        } else {
1184
            unset($this->queryParameters['mm']);
1185 22
        }
1186
    }
1187 22
1188 22
    /**
1189 22
     * Sets the boost function (bf) parameter
1190 22
     *
1191
     * @param mixed $boostFunction boost function parameter as string or boolean FALSE to disable / reset the bf parameter
1192
     * @see http://wiki.apache.org/solr/DisMaxRequestHandler#bf_.28Boost_Functions.29
1193
     */
1194 22
    public function setBoostFunction($boostFunction)
1195
    {
1196 22
        if (is_string($boostFunction) && !empty($boostFunction)) {
1197
            $this->queryParameters['bf'] = $boostFunction;
1198
        } else {
1199
            unset($this->queryParameters['bf']);
1200
        }
1201
    }
1202
1203
    // query fields
1204
    // TODO move up to field list methods
1205
1206
    /**
1207
     * Sets the boost query (bq) parameter
1208 19
     *
1209
     * @param mixed $boostQuery boost query parameter as string or array to set a boost query or boolean FALSE to disable / reset the bq parameter
1210 19
     * @see http://wiki.apache.org/solr/DisMaxQParserPlugin#bq_.28Boost_Query.29
1211
     */
1212
    public function setBoostQuery($boostQuery)
1213
    {
1214
        if ((is_string($boostQuery) || is_array($boostQuery)) && !empty($boostQuery)) {
1215
            $this->queryParameters['bq'] = $boostQuery;
1216
        } else {
1217
            unset($this->queryParameters['bq']);
1218
        }
1219 1
    }
1220
1221 1
    /**
1222 1
     * Gets a specific query parameter by its name.
1223 1
     *
1224 1
     * @param string $parameterName The parameter to return
1225
     * @param mixed $defaultIfEmpty
1226 1
     * @return mixed The parameter's value or $defaultIfEmpty if not set
1227
     */
1228
    public function getQueryParameter($parameterName, $defaultIfEmpty = null)
1229
    {
1230
        $parameters = $this->getQueryParameters();
1231
        return isset($parameters[$parameterName]) ? $parameters[$parameterName] : $defaultIfEmpty;
1232
    }
1233
1234 1
    /**
1235
     * Builds an array of query parameters to use for the search query.
1236 1
     *
1237 1
     * @return array An array ready to use with query parameters
1238 1
     */
1239 1
    public function getQueryParameters()
1240
    {
1241 1
        $queryParameters = array_merge(
1242
            [
1243
                'fl' => implode(',', $this->fieldList),
1244
                'fq' => array_values($this->filters)
1245
            ],
1246
            $this->queryParameters
1247
        );
1248
1249
        $queryFieldString = $this->getQueryFieldsAsString();
1250
        if (!empty($queryFieldString)) {
1251
            $queryParameters['qf'] = $queryFieldString;
1252 1
        }
1253
1254 1
        return $queryParameters;
1255 1
    }
1256 1
1257 1
    // general query parameters
1258
1259 1
    /**
1260
     * Compiles the query fields into a string to be used in Solr's qf parameter.
1261
     *
1262
     * @return string A string of query fields with their associated boosts
1263
     */
1264
    public function getQueryFieldsAsString()
1265
    {
1266
        $queryFieldString = '';
1267
1268 10
        foreach ($this->queryFields as $fieldName => $fieldBoost) {
1269
            $queryFieldString .= $fieldName;
1270 10
1271 10
            if ($fieldBoost != 1.0) {
1272
                $queryFieldString .= '^' . number_format($fieldBoost, 1, '.', '');
1273
            }
1274
1275
            $queryFieldString .= ' ';
1276
        }
1277
1278
        return trim($queryFieldString);
1279 64
    }
1280
1281 64
    /**
1282
     * Enables or disables highlighting of search terms in result teasers.
1283 64
     *
1284 64
     * @param bool $highlighting Enables highlighting when set to TRUE, deactivates highlighting when set to FALSE, defaults to TRUE.
1285 64
     * @param int $fragmentSize Size, in characters, of fragments to consider for highlighting.
1286 64
     * @see http://wiki.apache.org/solr/HighlightingParameters
1287 64
     * @return void
1288
     */
1289 64
    public function setHighlighting($highlighting = true, $fragmentSize = 200)
1290 64
    {
1291 27
        if ($highlighting) {
1292 27
            $this->queryParameters['hl'] = 'true';
1293
            $this->queryParameters['hl.fragsize'] = (int)$fragmentSize;
1294 64
1295
            $highlightingFields = $this->solrConfiguration->getSearchResultsHighlightingFields();
1296
            if ($highlightingFields != '') {
1297
                $this->queryParameters['hl.fl'] = $highlightingFields;
1298
            }
1299
1300
            // the fast vector highlighter can only be used, when the fragmentSize is
1301
            // higher then 17 otherwise solr throws an exception
1302
            $useFastVectorHighlighter = ($fragmentSize >= 18);
1303
            $wrap = explode('|', $this->solrConfiguration->getSearchResultsHighlightingWrap());
1304 66
1305
            if ($useFastVectorHighlighter) {
1306 66
                $this->queryParameters['hl.useFastVectorHighlighter'] = 'true';
1307
                $this->queryParameters['hl.tag.pre'] = $wrap[0];
1308 66
                $this->queryParameters['hl.tag.post'] = $wrap[1];
1309 28
            }
1310
1311 28
            if (isset($wrap[0]) && isset($wrap[1])) {
1312 28
                $this->queryParameters['hl.simple.pre'] = $wrap[0];
1313 28
                $this->queryParameters['hl.simple.post'] = $wrap[1];
1314
            }
1315 28
        } else {
1316 66
            // remove all hl.* settings
1317
            foreach ($this->queryParameters as $key => $value) {
1318 66
                if (GeneralUtility::isFirstPartOfStr($key, 'hl')) {
1319
                    unset($this->queryParameters[$key]);
1320
                }
1321
            }
1322
        }
1323
    }
1324
1325
    // misc
1326
1327
    /**
1328
     * Enables or disables spellchecking for the query.
1329 31
     *
1330
     * @param bool $spellchecking Enables spellchecking when set to TRUE, deactivates spellchecking when set to FALSE, defaults to TRUE.
1331 31
     */
1332 31
    public function setSpellchecking($spellchecking = true)
1333 31
    {
1334
        if ($spellchecking) {
1335 31
            $this->queryParameters['spellcheck'] = 'true';
1336 31
            $this->queryParameters['spellcheck.collate'] = 'true';
1337 25
            $maxCollationTries = $this->solrConfiguration->getSearchSpellcheckingNumberOfSuggestionsToTry();
1338 25
            $this->addQueryParameter('spellcheck.maxCollationTries', $maxCollationTries);
1339
        } else {
1340
            unset($this->queryParameters['spellcheck']);
1341
            unset($this->queryParameters['spellcheck.collate']);
1342 31
            unset($this->queryParameters['spellcheck.maxCollationTries']);
1343 31
        }
1344
    }
1345 31
1346 29
    /**
1347 29
     * Adds any generic query parameter.
1348 29
     *
1349 29
     * @param string $parameterName Query parameter name
1350
     * @param string $parameterValue Parameter value
1351 31
     */
1352 26
    public function addQueryParameter($parameterName, $parameterValue)
1353 26
    {
1354 26
        $this->queryParameters[$parameterName] = $parameterValue;
1355 31
    }
1356
1357 1
    /**
1358 1
     * Sets the sort parameter.
1359 1
     *
1360 1
     * $sorting must include a field name (or the pseudo-field score),
1361 1
     * followed by a space,
1362
     * followed by a sort direction (asc or desc).
1363 31
     *
1364
     * Multiple fallback sortings can be separated by comma,
1365
     * ie: <field name> <direction>[,<field name> <direction>]...
1366
     *
1367
     * @param string|bool $sorting Either a comma-separated list of sort fields and directions or FALSE to reset sorting to the default behavior (sort by score / relevance)
1368
     * @see http://wiki.apache.org/solr/CommonQueryParameters#sort
1369
     */
1370
    public function setSorting($sorting)
1371
    {
1372 22
        if ($sorting) {
1373
            if (!is_string($sorting)) {
1374 22
                throw new \InvalidArgumentException('Sorting needs to be a string!');
1375 22
            }
1376 22
            $sortParameter = $this->removeRelevanceSortField($sorting);
1377 22
            $this->queryParameters['sort'] = $sortParameter;
1378 22
        } else {
1379 22
            unset($this->queryParameters['sort']);
1380 1
        }
1381 1
    }
1382 1
1383
    /**
1384 22
     * Removes the relevance sort field if present in the sorting field definition.
1385
     *
1386
     * @param string $sorting
1387
     * @return string
1388
     */
1389
    protected function removeRelevanceSortField($sorting)
1390
    {
1391
        $sortParameter = $sorting;
1392 30
        list($sortField) = explode(' ', $sorting);
1393
        if ($sortField == 'relevance') {
1394 30
            $sortParameter = '';
1395 30
            return $sortParameter;
1396
        }
1397
1398
        return $sortParameter;
1399
    }
1400
1401
    /**
1402
     * Enables or disables the debug parameter for the query.
1403
     *
1404
     * @param bool $debugMode Enables debugging when set to TRUE, deactivates debugging when set to FALSE, defaults to TRUE.
1405
     */
1406
    public function setDebugMode($debugMode = true)
1407
    {
1408
        if ($debugMode) {
1409
            $this->queryParameters['debugQuery'] = 'true';
1410 3
            $this->queryParameters['echoParams'] = 'all';
1411
        } else {
1412 3
            unset($this->queryParameters['debugQuery']);
1413 3
            unset($this->queryParameters['echoParams']);
1414
        }
1415
    }
1416 3
1417 3
    /**
1418 3
     * Returns the link target page id.
1419 1
     *
1420
     * @return int
1421 3
     */
1422
    public function getLinkTargetPageId()
1423
    {
1424
        return $this->linkTargetPageId;
1425
    }
1426
1427
    /**
1428
     * Activates the collapsing on the configured field, if collapsing was enabled.
1429 3
     *
1430
     * @return bool
1431 3
     */
1432 3
    protected function initializeCollapsingFromConfiguration()
1433 3
    {
1434 1
        // check collapsing
1435 1
        if ($this->solrConfiguration->getSearchVariants()) {
1436
            $collapseField = $this->solrConfiguration->getSearchVariantsField();
1437
            $this->setVariantField($collapseField);
1438 3
            $this->setCollapsing(true);
1439
1440
            return true;
1441
        }
1442
1443
        return false;
1444
    }
1445
}
1446