Passed
Push — hans/code-cleanup ( 74c548...547d18 )
by Simon
09:31 queued 07:32
created

BaseIndex::doSearch()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 26
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 3.0884

Importance

Changes 4
Bugs 0 Features 0
Metric Value
cc 3
eloc 14
c 4
b 0
f 0
nc 4
nop 1
dl 0
loc 26
ccs 11
cts 14
cp 0.7856
crap 3.0884
rs 9.7998
1
<?php
2
3
4
namespace Firesphere\SolrSearch\Indexes;
5
6
use Exception;
7
use Firesphere\SolrSearch\Factories\QueryComponentFactory;
8
use Firesphere\SolrSearch\Helpers\SolrLogger;
9
use Firesphere\SolrSearch\Helpers\Synonyms;
10
use Firesphere\SolrSearch\Interfaces\ConfigStore;
11
use Firesphere\SolrSearch\Models\SearchSynonym;
12
use Firesphere\SolrSearch\Queries\BaseQuery;
13
use Firesphere\SolrSearch\Results\SearchResult;
14
use Firesphere\SolrSearch\Services\SchemaService;
15
use Firesphere\SolrSearch\Services\SolrCoreService;
16
use Firesphere\SolrSearch\States\SiteState;
17
use Firesphere\SolrSearch\Traits\BaseIndexTrait;
18
use Firesphere\SolrSearch\Traits\GetterSetterTrait;
19
use GuzzleHttp\Exception\GuzzleException;
20
use LogicException;
21
use ReflectionException;
22
use SilverStripe\Control\Director;
23
use SilverStripe\Core\Config\Config;
24
use SilverStripe\Core\Config\Configurable;
25
use SilverStripe\Core\Extensible;
26
use SilverStripe\Core\Injector\Injectable;
27
use SilverStripe\Core\Injector\Injector;
28
use SilverStripe\Dev\Deprecation;
29
use SilverStripe\ORM\ValidationException;
30
use SilverStripe\View\ArrayData;
31
use Solarium\Core\Client\Adapter\Guzzle;
32
use Solarium\Core\Client\Client;
33
use Solarium\QueryType\Select\Query\Query;
34
use Solarium\QueryType\Select\Result\Result;
35
36
/**
37
 * Base for creating a new Solr core.
38
 *
39
 * Base index settings and methods. Should be extended with at least a name for the index.
40
 * This is an abstract class that can not be instantiated on it's own
41
 *
42
 * @package Firesphere\SolrSearch\Indexes
43
 */
44
abstract class BaseIndex
45
{
46
    use Extensible;
47
    use Configurable;
48
    use Injectable;
49
    use GetterSetterTrait;
50
    use BaseIndexTrait;
51
52
    /**
53
     * Field types that can be added
54
     * Used in init to call build methods from configuration yml
55
     *
56
     * @array
57
     */
58
    private static $fieldTypes = [
59
        'FulltextFields',
60
        'SortFields',
61
        'FilterFields',
62
        'BoostedFields',
63
        'CopyFields',
64
        'DefaultField',
65
        'FacetFields',
66
        'StoredFields',
67
    ];
68
    /**
69
     * {@link SchemaService}
70
     *
71
     * @var SchemaService
72
     */
73
    protected $schemaService;
74
    /**
75
     * {@link QueryComponentFactory}
76
     *
77
     * @var QueryComponentFactory
78
     */
79
    protected $queryFactory;
80
    /**
81
     * The query terms as an array
82
     *
83
     * @var array
84
     */
85
    protected $queryTerms = [];
86
    /**
87
     * Should a retry occur if nothing was found and there are suggestions to follow
88
     *
89
     * @var bool
90
     */
91
    private $retry = false;
92
93
    /**
94
     * BaseIndex constructor.
95
     */
96 76
    public function __construct()
97
    {
98
        // Set up the client
99 76
        $config = Config::inst()->get(SolrCoreService::class, 'config');
100 76
        $config['endpoint'] = $this->getConfig($config['endpoint']);
101 76
        $this->client = new Client($config);
102 76
        $this->client->setAdapter(new Guzzle());
103
104
        // Set up the schema service, only used in the generation of the schema
105 76
        $schemaService = Injector::inst()->get(SchemaService::class, false);
106 76
        $schemaService->setIndex($this);
107 76
        $schemaService->setStore(Director::isDev());
108 76
        $this->schemaService = $schemaService;
109 76
        $this->queryFactory = Injector::inst()->get(QueryComponentFactory::class, false);
110
111 76
        $this->extend('onBeforeInit');
112 76
        $this->init();
113 76
        $this->extend('onAfterInit');
114 76
    }
115
116
    /**
117
     * Build a full config for all given endpoints
118
     * This is to add the current index to e.g. an index or select
119
     *
120
     * @param array $endpoints
121
     * @return array
122
     */
123 76
    public function getConfig($endpoints): array
124
    {
125 76
        foreach ($endpoints as $host => $endpoint) {
126 76
            $endpoints[$host]['core'] = $this->getIndexName();
127
        }
128
129 76
        return $endpoints;
130
    }
131
132
    /**
133
     * Name of this index.
134
     *
135
     * @return string
136
     */
137
    abstract public function getIndexName();
138
139
    /**
140
     * Required to initialise the fields.
141
     * It's loaded in to the non-static properties for backward compatibility with FTS
142
     * Also, it's a tad easier to use this way, loading the other way around would be very
143
     * memory intensive, as updating the config for each item is not efficient
144
     */
145 75
    public function init()
146
    {
147 75
        $config = self::config()->get($this->getIndexName());
148 75
        if (!$config) {
149 75
            Deprecation::notice('5', 'Please set an index name and use a config yml');
150
        }
151
152 75
        if (!empty($this->getClasses())) {
153 75
            if (!$this->usedAllFields) {
154 75
                Deprecation::notice('5', 'It is advised to use a config YML for most cases');
155
            }
156
157 75
            return;
158
        }
159
160 74
        $this->initFromConfig($config);
161 74
    }
162
163
    /**
164
     * Generate the config from yml if possible
165
     * @param array|null $config
166
     */
167 74
    protected function initFromConfig($config): void
168
    {
169 74
        if (!$config || !array_key_exists('Classes', $config)) {
170 2
            throw new LogicException('No classes or config to index found!');
171
        }
172
173 74
        $this->setClasses($config['Classes']);
174
175
        // For backward compatibility, copy the config to the protected values
176
        // Saves doubling up further down the line
177 74
        foreach (self::$fieldTypes as $type) {
178 74
            if (array_key_exists($type, $config)) {
179 74
                $method = 'set' . $type;
180 74
                $this->$method($config[$type]);
181
            }
182
        }
183 74
    }
184
185
    /**
186
     * Default returns a SearchResult. It can return an ArrayData if FTS Compat is enabled
187
     *
188
     * @param BaseQuery $query
189
     * @return SearchResult|ArrayData|mixed
190
     * @throws GuzzleException
191
     * @throws ValidationException
192
     * @throws ReflectionException
193
     */
194 5
    public function doSearch(BaseQuery $query)
195
    {
196 5
        SiteState::alterQuery($query);
197
        // Build the actual query parameters
198 5
        $clientQuery = $this->buildSolrQuery($query);
199
200 5
        $this->extend('onBeforeSearch', $query, $clientQuery);
201
202
        try {
203 5
            $result = $this->client->select($clientQuery);
204
        } catch (Exception $e) {
205
            $logger = new SolrLogger();
206
            $logger->saveSolrLog('Query');
207
        }
208
209
        // Handle the after search first. This gets a raw search result
210 5
        $this->extend('onAfterSearch', $result);
211 5
        $searchResult = new SearchResult($result, $query, $this);
212 5
        if ($this->doRetry($query, $result, $searchResult)) {
213 2
            return $this->spellcheckRetry($query, $searchResult);
214
        }
215
216
        // And then handle the search results, which is a useable object for SilverStripe
217 5
        $this->extend('updateSearchResults', $searchResult);
218
219 5
        return $searchResult;
220
    }
221
222
    /**
223
     * From the given BaseQuery, generate a Solarium ClientQuery object
224
     *
225
     * @param BaseQuery $query
226
     * @return Query
227
     */
228 5
    public function buildSolrQuery(BaseQuery $query): Query
229
    {
230 5
        $clientQuery = $this->client->createSelect();
231 5
        $factory = $this->buildFactory($query, $clientQuery);
232
233 5
        $clientQuery = $factory->buildQuery();
234 5
        $this->queryTerms = $factory->getQueryArray();
235
236 5
        $queryData = implode(' ', $this->queryTerms);
237 5
        $clientQuery->setQuery($queryData);
238
239 5
        return $clientQuery;
240
    }
241
242
    /**
243
     * Build a factory to use in the SolrQuery building. {@link static::buildSolrQuery()}
244
     *
245
     * @param BaseQuery $query
246
     * @param Query $clientQuery
247
     * @return QueryComponentFactory|mixed
248
     */
249 5
    protected function buildFactory(BaseQuery $query, Query $clientQuery)
250
    {
251 5
        $factory = $this->queryFactory;
252
253 5
        $helper = $clientQuery->getHelper();
254
255 5
        $factory->setQuery($query);
256 5
        $factory->setClientQuery($clientQuery);
257 5
        $factory->setHelper($helper);
258 5
        $factory->setIndex($this);
259
260 5
        return $factory;
261
    }
262
263
    /**
264
     * Check if the query should be retried with spellchecking
265
     * Conditions are:
266
     * It is not already a retry with spellchecking
267
     * Spellchecking is enabled
268
     * If spellchecking is enabled and nothing is found OR it should follow spellchecking none the less
269
     * There is a spellcheck output
270
     *
271
     * @param BaseQuery $query
272
     * @param Result $result
273
     * @param SearchResult $searchResult
274
     * @return bool
275
     */
276 5
    protected function doRetry(BaseQuery $query, Result $result, SearchResult $searchResult): bool
277
    {
278 5
        return !$this->retry &&
279 5
            $query->hasSpellcheck() &&
280 5
            ($query->shouldFollowSpellcheck() || $result->getNumFound() === 0) &&
281 5
            $searchResult->getCollatedSpellcheck();
282
    }
283
284
    /**
285
     * Retry the query with the first collated spellcheck found.
286
     *
287
     * @param BaseQuery $query
288
     * @param SearchResult $searchResult
289
     * @return SearchResult|mixed|ArrayData
290
     * @throws GuzzleException
291
     * @throws ValidationException
292
     * @throws ReflectionException
293
     */
294 2
    protected function spellcheckRetry(BaseQuery $query, SearchResult $searchResult)
295
    {
296 2
        $terms = $query->getTerms();
297 2
        $spellChecked = $searchResult->getCollatedSpellcheck();
298
        // Remove the fuzzyness from the collated check
299 2
        $term = preg_replace('/~\d+/', '', $spellChecked);
300 2
        $terms[0]['text'] = $term;
301 2
        $query->setTerms($terms);
302 2
        $this->retry = true;
303
304 2
        return $this->doSearch($query);
305
    }
306
307
    /**
308
     * Get all fields that are required for indexing in a unique way
309
     *
310
     * @return array
311
     */
312 8
    public function getFieldsForIndexing(): array
313
    {
314 8
        $facets = [];
315 8
        foreach ($this->getFacetFields() as $field) {
316 7
            $facets[] = $field['Field'];
317
        }
318
        // Return values to make the key reset
319
        // Only return unique values
320
        // And make it all a single array
321 8
        $fields = array_values(
322 8
            array_unique(
323 8
                array_merge(
324 8
                    $this->getFulltextFields(),
325 8
                    $this->getSortFields(),
326 8
                    $facets,
327 8
                    $this->getFilterFields()
328
                )
329
            )
330
        );
331
332 8
        $this->extend('updateFieldsForIndexing', $fields);
333
334 8
        return $fields;
335
    }
336
337
    /**
338
     * Upload config for this index to the given store
339
     *
340
     * @param ConfigStore $store
341
     */
342 34
    public function uploadConfig(ConfigStore $store): void
343
    {
344
        // @todo use types/schema/elevate rendering
345
        // Upload the config files for this index
346
        // Create a default schema which we can manage later
347 34
        $schema = (string)$this->schemaService->generateSchema();
348 34
        $store->uploadString(
349 34
            $this->getIndexName(),
350 34
            'schema.xml',
351 34
            $schema
352
        );
353
354 34
        $this->getSynonyms($store);
355
356
        // Upload additional files
357 34
        foreach (glob($this->schemaService->getExtrasPath() . '/*') as $file) {
358 34
            if (is_file($file)) {
359 34
                $store->uploadFile($this->getIndexName(), $file);
360
            }
361
        }
362 34
    }
363
364
    /**
365
     * Add synonyms. Public to be extendable
366
     *
367
     * @param ConfigStore $store Store to use to write synonyms
368
     * @param bool $defaults Include UK to US synonyms
369
     * @return string
370
     */
371 34
    public function getSynonyms($store = null, $defaults = true)
372
    {
373 34
        $synonyms = Synonyms::getSynonymsAsString($defaults);
374 34
        $syn = SearchSynonym::get();
375 34
        foreach ($syn as $synonym) {
376 1
            $synonyms .= $synonym->Keyword . ',' . $synonym->Synonym . PHP_EOL;
377
        }
378
379
        // Upload synonyms
380 34
        if ($store) {
381 34
            $store->uploadString(
382 34
                $this->getIndexName(),
383 34
                'synonyms.txt',
384 34
                $synonyms
385
            );
386
        }
387
388 34
        return $synonyms;
389
    }
390
391
    /**
392
     * Get the final, generated terms
393
     *
394
     * @return array
395
     */
396 2
    public function getQueryTerms(): array
397
    {
398 2
        return $this->queryTerms;
399
    }
400
401
    /**
402
     * Get the QueryComponentFactory. {@link QueryComponentFactory}
403
     *
404
     * @return QueryComponentFactory
405
     */
406 1
    public function getQueryFactory(): QueryComponentFactory
407
    {
408 1
        return $this->queryFactory;
409
    }
410
}
411