Passed
Push — Cleanup-attempt ( 703893...15009b )
by Simon
05:33
created

QueryComponentFactory::buildQueryBoost()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 7
c 0
b 0
f 0
dl 0
loc 11
rs 10
cc 2
nc 2
nop 3
1
<?php
2
3
4
namespace Firesphere\SolrSearch\Factories;
5
6
use Firesphere\SolrSearch\Indexes\BaseIndex;
7
use Firesphere\SolrSearch\Queries\BaseQuery;
8
use Minimalcode\Search\Criteria;
9
use SilverStripe\Control\Controller;
10
use SilverStripe\Security\Security;
11
use Solarium\Core\Query\Helper;
12
use Solarium\QueryType\Select\Query\Query;
13
14
/**
15
 * Class QueryComponentFactory
16
 * @package Firesphere\SolrSearch\Factories
17
 */
18
class QueryComponentFactory
19
{
20
    /**
21
     * @var BaseQuery
22
     */
23
    protected $query;
24
25
    /**
26
     * @var Query
27
     */
28
    protected $clientQuery;
29
30
    /**
31
     * @var Helper
32
     */
33
    protected $helper;
34
35
    /**
36
     * @var array
37
     */
38
    protected $boostTerms;
39
40
    /**
41
     * @var array
42
     */
43
    protected $queryArray;
44
45
    /**
46
     * @var BaseIndex
47
     */
48
    protected $index;
49
50
    /**
51
     * Build the full query
52
     * @return Query
53
     */
54
    public function buildQuery()
55
    {
56
        $this->buildTerms();
57
        $this->buildViewFilter();
58
        // Build class filtering
59
        $this->buildClassFilter();
60
        // Add filters
61
        $this->buildFilters();
62
        // And excludes
63
        $this->buildExcludes();
64
        // Setup the facets
65
        $this->buildFacets();
66
        // Build the facet filters
67
        $this->buildFacetQuery();
68
        // Add spellchecking
69
        $this->buildSpellcheck();
70
        // Set the start
71
        $this->clientQuery->setStart($this->query->getStart());
72
        // Double the rows in case something has been deleted, but not from Solr
73
        $this->clientQuery->setRows($this->query->getRows() * 2);
74
        // Add highlighting before adding boosting
75
        $this->clientQuery->getHighlighting()->setFields($this->query->getHighlight());
76
        // Add boosting
77
        $this->buildBoosts();
78
79
        // Filter out the fields we want to see if they're set
80
        if (count($this->query->getFields())) {
81
            $this->clientQuery->setFields($this->query->getFields());
82
        }
83
84
        return $this->clientQuery;
85
    }
86
87
88
    /**
89
     * @return array
90
     */
91
    protected function buildTerms(): array
92
    {
93
        $terms = $this->query->getTerms();
94
95
        $boostTerms = $this->index->getBoostTerms();
0 ignored issues
show
Bug introduced by
The method getBoostTerms() does not exist on Firesphere\SolrSearch\Indexes\BaseIndex. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

95
        /** @scrutinizer ignore-call */ 
96
        $boostTerms = $this->index->getBoostTerms();
Loading history...
96
97
        foreach ($terms as $search) {
98
            $term = $search['text'];
99
            $term = $this->escapeSearch($term, $this->helper);
100
            $postfix = $this->isFuzzy($search);
101
            // We can add the same term multiple times with different boosts
102
            // Not ideal, but it might happen, so let's add the term itself only once
103
            if (!in_array($term, $this->queryArray, true)) {
104
                $this->queryArray[] = $term . $postfix;
105
            }
106
            // If boosting is set, add the fields to boost
107
            if ($search['boost'] > 1) {
108
                $boost = $this->buildQueryBoost($search, $term, $boostTerms);
109
                $this->boostTerms = array_merge($boostTerms, $boost);
110
            }
111
        }
0 ignored issues
show
Bug Best Practice introduced by
In this branch, the function will implicitly return null which is incompatible with the type-hinted return array. Consider adding a return statement or allowing null as return value.

For hinted functions/methods where all return statements with the correct type are only reachable via conditions, ?null? gets implicitly returned which may be incompatible with the hinted type. Let?s take a look at an example:

interface ReturnsInt {
    public function returnsIntHinted(): int;
}

class MyClass implements ReturnsInt {
    public function returnsIntHinted(): int
    {
        if (foo()) {
            return 123;
        }
        // here: null is implicitly returned
    }
}
Loading history...
112
    }
113
114
    /**
115
     * @param string $searchTerm
116
     * @param Helper $helper
117
     * @return string
118
     */
119
    public function escapeSearch($searchTerm, Helper $helper): string
120
    {
121
        $term = [];
122
        // Escape special characters where needed. Except for quoted parts, those should be phrased
123
        preg_match_all('/"[^"]*"|\S+/', $searchTerm, $parts);
124
        foreach ($parts[0] as $part) {
125
            // As we split the parts, everything with two quotes is a phrase
126
            if (substr_count($part, '"') === 2) {
127
                $term[] = $helper->escapePhrase($part);
128
            } else {
129
                $term[] = $helper->escapeTerm($part);
130
            }
131
        }
132
133
        return implode(' ', $term);
134
    }
135
136
    /**
137
     * @param $search
138
     * @return string
139
     */
140
    protected function isFuzzy($search): string
141
    {
142
        $postfix = ''; // When doing fuzzy search, postfix, otherwise, don't
143
        if ($search['fuzzy']) {
144
            $postfix = '~';
145
            if (is_numeric($search['fuzzy'])) {
146
                $postfix .= $search['fuzzy'];
147
            }
148
        }
149
150
        return $postfix;
151
    }
152
153
    /**
154
     * Set boosting at Query time
155
     *
156
     * @param array $search
157
     * @param string $term
158
     * @param array $searchQuery
159
     * @return array
160
     */
161
    protected function buildQueryBoost($search, string $term, array $searchQuery): array
162
    {
163
        foreach ($search['fields'] as $boostField) {
164
            $boostField = str_replace('.', '_', $boostField);
165
            $criteria = Criteria::where($boostField)
166
                ->is($term)
167
                ->boost($search['boost']);
168
            $searchQuery[] = $criteria->getQuery();
169
        }
170
171
        return $searchQuery;
172
    }
173
174
    /**
175
     *
176
     */
177
    protected function buildViewFilter(): void
178
    {
179
        // Filter by what the user is allowed to see
180
        $viewIDs = ['1-null']; // null is always an option as that means publicly visible
181
        $currentUser = Security::getCurrentUser();
182
        if ($currentUser && $currentUser->exists()) {
183
            $viewIDs[] = '1-' . $currentUser->ID;
184
        }
185
        /** Add canView criteria. These are based on {@link DataObjectExtension::ViewStatus()} */
186
        $query = Criteria::where('ViewStatus')->in($viewIDs);
187
188
        $this->clientQuery->createFilterQuery('ViewStatus')
189
            ->setQuery($query->getQuery());
190
    }
191
192
    /**
193
     * Add filtered queries based on class hierarchy
194
     * We only need the class itself, since the hierarchy will take care of the rest
195
     */
196
    protected function buildClassFilter(): void
197
    {
198
        if (count($this->query->getClasses())) {
199
            $classes = $this->query->getClasses();
200
            $criteria = Criteria::where('ClassHierarchy')->in($classes);
201
            $this->clientQuery->createFilterQuery('classes')
202
                ->setQuery($criteria->getQuery());
203
        }
204
    }
205
206
    /**
207
     *
208
     */
209
    protected function buildFilters(): void
210
    {
211
        $filters = $this->query->getFilter();
212
        foreach ($filters as $field => $value) {
213
            $value = is_array($value) ? $value : [$value];
214
            $criteria = Criteria::where($field)->in($value);
215
            $this->clientQuery->createFilterQuery('filter-' . $field)
216
                ->setQuery($criteria->getQuery());
217
        }
218
    }
219
220
    /**
221
     *
222
     */
223
    protected function buildExcludes(): void
224
    {
225
        $filters = $this->query->getExclude();
226
        foreach ($filters as $field => $value) {
227
            $value = is_array($value) ? $value : [$value];
228
            $criteria = Criteria::where($field)
229
                ->in($value)
230
                ->not();
231
            $this->clientQuery->createFilterQuery('exclude-' . $field)
232
                ->setQuery($criteria->getQuery());
233
        }
234
    }
235
236
    /**
237
     *
238
     */
239
    protected function buildFacets(): void
240
    {
241
        $facets = $this->clientQuery->getFacetSet();
242
        // Facets should be set from the index configuration
243
        foreach ($this->index->getFacetFields() as $class => $config) {
244
            $facets->createFacetField('facet-' . $config['Title'])->setField($config['Field']);
245
        }
246
        // Count however, comes from the query
247
        $facets->setMinCount($this->query->getFacetsMinCount());
248
    }
249
250
    /**
251
     *
252
     */
253
    protected function buildFacetQuery()
254
    {
255
        $filterFacets = [];
256
        if (Controller::has_curr()) {
257
            $filterFacets = Controller::curr()->getRequest()->requestVars();
258
        }
259
        foreach ($this->index->getFacetFields() as $class => $config) {
260
            if (array_key_exists($config['Title'], $filterFacets)) {
261
                $filter = array_filter($filterFacets[$config['Title']], 'strlen');
262
                if (count($filter)) {
263
                    $criteria = Criteria::where($config['Field'])->in($filter);
264
                    $this->clientQuery
265
                        ->createFilterQuery('facet-' . $config['Title'])
266
                        ->setQuery($criteria->getQuery());
267
                }
268
            }
269
        }
270
    }
271
272
    /**
273
     *
274
     */
275
    protected function buildSpellcheck(): void
276
    {
277
        // Assuming the first term is the term entered
278
        $queryString = implode(' ', $this->queryArray);
279
        // Arbitrarily limit to 5 if the config isn't set
280
        $count = BaseIndex::config()->get('spellcheckCount') ?: 5;
281
        $spellcheck = $this->clientQuery->getSpellcheck();
282
        $spellcheck->setQuery($queryString);
283
        $spellcheck->setCount($count);
284
        $spellcheck->setBuild(true);
285
        $spellcheck->setCollate(true);
286
        $spellcheck->setExtendedResults(true);
287
        $spellcheck->setCollateExtendedResults('true');
288
    }
289
290
    /**
291
     * Add the index-time boosting to the query
292
     */
293
    protected function buildBoosts(): void
294
    {
295
        $boosts = $this->query->getBoostedFields();
296
        $queries = $this->getQueryArray();
297
        foreach ($boosts as $field => $boost) {
298
            foreach ($queries as $term) {
299
                $booster = Criteria::where($field)
300
                    ->is($term)
301
                    ->boost($boost);
302
                $this->queryArray[] = $booster->getQuery();
303
            }
304
        }
305
    }
306
307
    /**
308
     * @return array
309
     */
310
    public function getQueryArray(): array
311
    {
312
        return array_merge($this->queryArray, $this->boostTerms);
313
    }
314
315
    /**
316
     * @param array $queryArray
317
     * @return QueryComponentFactory
318
     */
319
    public function setQueryArray(array $queryArray): QueryComponentFactory
320
    {
321
        $this->queryArray = $queryArray;
322
323
        return $this;
324
    }
325
326
    /**
327
     * @return BaseQuery
328
     */
329
    public function getQuery(): BaseQuery
330
    {
331
        return $this->query;
332
    }
333
334
    /**
335
     * @param BaseQuery $query
336
     * @return QueryComponentFactory
337
     */
338
    public function setQuery(BaseQuery $query): QueryComponentFactory
339
    {
340
        $this->query = $query;
341
342
        return $this;
343
    }
344
345
    /**
346
     * @return Query
347
     */
348
    public function getClientQuery(): Query
349
    {
350
        return $this->clientQuery;
351
    }
352
353
    /**
354
     * @param Query $clientQuery
355
     * @return QueryComponentFactory
356
     */
357
    public function setClientQuery(Query $clientQuery): QueryComponentFactory
358
    {
359
        $this->clientQuery = $clientQuery;
360
361
        return $this;
362
    }
363
364
    /**
365
     * @return Helper
366
     */
367
    public function getHelper(): Helper
368
    {
369
        return $this->helper;
370
    }
371
372
    /**
373
     * @param Helper $helper
374
     * @return QueryComponentFactory
375
     */
376
    public function setHelper(Helper $helper): QueryComponentFactory
377
    {
378
        $this->helper = $helper;
379
380
        return $this;
381
    }
382
383
    /**
384
     * @return BaseIndex
385
     */
386
    public function getIndex(): BaseIndex
387
    {
388
        return $this->index;
389
    }
390
391
    /**
392
     * @param BaseIndex $index
393
     * @return QueryComponentFactory
394
     */
395
    public function setIndex(BaseIndex $index): QueryComponentFactory
396
    {
397
        $this->index = $index;
398
399
        return $this;
400
    }
401
}
402