Completed
Push — dev2 ( 764ed3...9e10b9 )
by Gordon
26:05 queued 17:34
created

ElasticSearcher::setClasses()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1
Metric Value
dl 0
loc 3
ccs 3
cts 3
cp 1
rs 10
cc 1
eloc 2
nc 1
nop 1
crap 1
1
<?php
2
namespace SilverStripe\Elastica;
3
4
//use \SilverStripe\Elastica\ResultList;
5
use Elastica\Query;
6
7
use Elastica\Query\QueryString;
8
use Elastica\Aggregation\Filter;
9
use Elastica\Filter\Term;
10
use Elastica\Filter\BoolAnd;
11
use Elastica\Query\Filtered;
12
use Elastica\Query\MultiMatch;
13
use Elastica\Query\MoreLikeThis;
14
15
16
17
class ElasticSearcher {
18
	/**
19
	 * Comma separated list of SilverStripe ClassNames to search. Leave blank for all
20
	 * @var string
21
	 */
22
	private $classes = '';
23
24
	/**
25
	 * Array of aggregation selected mapped to the value selected, e.g. 'Aperture' => '11'
26
	 * @var array
27
	 */
28
	private $filters = array();
29
30
	/**
31
	 * The locale to search, is set to current locale or default locale by default
32
	 * but can be overriden.  This is the code in the form en_US, th_TH etc
33
	 */
34
	private $locale = null;
35
36
	/**
37
	 * Object just to manipulate the query and result, used for aggregations
38
	 * @var ElasticaSearchHelper
39
	 */
40
	private $manipulator;
41
42
	/**
43
	 * Offset from zero to return search results from
44
	 * @var integer
45
	 */
46
	private $start = 0;
47
48
	/**
49
	 * How many search results to return
50
	 * @var integer
51
	 */
52
	private $pageLength = 10;
53
54
	/**
55
	 * After a search is performed aggregrations are saved here
56
	 * @var array
57
	 */
58
	private $aggregations = null;
59
60
	/**
61
	 * Array of highlighted fields, e.g. Title, Title.standard.  If this is empty then the
62
	 * ShowHighlight field of SearchableField is used to determine which fields to highlight
63
	 * @var array
64
	 */
65
	private $highlightedFields = array();
66
67
68
	/*
69
	Allow an empty search to return either no results (default) or all results, useful for
70
	showing some results during aggregation
71
	 */
72
	private $showResultsForEmptySearch = false;
73
74
75
	private $SuggestedQuery = null;
76
77
78
	// ---- variables for more like this searching, defaults as per Elasticsearch ----
79
	private $minTermFreq = 2;
80
81
	private $maxTermFreq = 25;
82
83
	private $minDocFreq = 2;
84
85
	private $maxDocFreq = 0;
86
87
	private $minWordLength = 0;
88
89
	private $maxWordLength = 0;
90
91
	private $minShouldMatch = '30%';
92
93
	private $similarityStopWords = '';
94
95
96
	/*
97
	Show results for an empty search string
98
	 */
99
	public function showResultsForEmptySearch() {
100
		$this->showResultsForEmptySearch = true;
101
	}
102
103
104
	/*
105
	Hide results for an empty search
106
	 */
107
	public function hideResultsForEmptySearch() {
108
		$this->showResultsForEmptySearch = false;
109
	}
110
111
112
	/**
113
	 * Accessor the variable to determine whether or not to show results for an empty search
114
	 * @return boolean true to show results for empty search, otherwise false
115
	 */
116
	public function getShowResultsForEmptySearch() {
117
		return $this->showResultsForEmptySearch;
118
	}
119
120
	/**
121
	 * Update the list of Classes to search, use SilverStripe ClassName comma separated
122
	 * @param string $newClasses comma separated list of SilverStripe ClassNames
123
	 */
124 1
	public function setClasses($newClasses) {
125 1
		$this->classes = $newClasses;
126 1
	}
127
128
	/**
129
	 * Set the manipulator, mainly used for aggregation
130
	 * @param ElasticaSearchHelper $newManipulator manipulator used for aggregation
131
	 */
132
	public function setQueryResultManipulator($newManipulator) {
133
		$this->manipulator = $newManipulator;
134
	}
135
136
	/**
137
	 * Update the start variable
138
	 * @param int $newStart Offset for search
139
	 */
140
	public function setStart($newStart) {
141
		$this->start = $newStart;
142
	}
143
144
	/**
145
	 * Update the page length variable
146
	 * @param int $newPageLength the number of results to be returned
147
	 */
148
	public function setPageLength($newPageLength) {
149
		$this->pageLength = $newPageLength;
150
	}
151
152
	/**
153
	 * Set a new locale
154
	 * @param string $newLocale locale in short form, e.g. th_TH
155
	 */
156
	public function setLocale($newLocale) {
157
		$this->locale = $newLocale;
158
	}
159
160
	/**
161
	 * Add a filter to the current query in the form of a key/value pair
162
	 * @param string $field the name of the indexed field to filter on
163
	 * @param string|boolean|integer $value the value of the indexed field to filter on
164
	 */
165
	public function addFilter($field, $value) {
166
		$this->filters[$field] = $value;
167
	}
168
169
	/**
170
	 * Accessor to the aggregations, to be used after a search
171
	 * @return array Aggregations returned after a search
172
	 */
173
	public function getAggregations() {
174
		return $this->aggregations;
175
	}
176
177
	/**
178
	 * Set the minimum term frequency for term to be considered in input query
179
	 */
180
	public function setMinTermFreq($newMinTermFreq) {
181
		$this->minTermFreq = $newMinTermFreq;
182
	}
183
184
	/**
185
	 * Set the maximum term frequency for term to be considered in input query
186
	 */
187
	public function setMaxTermFreq($newMaxTermFreq) {
188
		$this->maxTermFreq = $newMaxTermFreq;
189
	}
190
191
	/**
192
	 * Set the minimum number of documents a term can reside in for consideration as
193
	 * part of the input query
194
	 */
195
	public function setMinDocFreq($newMinDocFreq) {
196
		$this->minDocFreq = $newMinDocFreq;
197
	}
198
199
	/**
200
	 * Set the maximum number of documents a term can reside in for consideration as
201
	 * part of the input query
202
	 */
203
	public function setMaxDocFreq($newMaxDocFreq) {
204
		$this->maxDocFreq = $newMaxDocFreq;
205
	}
206
207
	/**
208
	 * Set the minimum word length for a term to be considered part of the query
209
	 */
210
	public function setMinWordLength($newMinWordLength) {
211
		$this->minWordLength = $newMinWordLength;
212
	}
213
214
	/**
215
	 * Set the maximum word length for a term to be considered part of the query
216
	 */
217
	public function setMaxWordLength($newMaxWordLength) {
218
		$this->maxWordLength = $newMaxWordLength;
219
	}
220
221
	/*
222
	Number or percentage of chosen terms that match
223
	 */
224
	public function setMinShouldMatch($newMinShouldMatch) {
225
		$this->minShouldMatch = $newMinShouldMatch;
226
	}
227
228
	public function setSimilarityStopWords($newSimilarityStopWords) {
229
		$this->similarityStopWords = $newSimilarityStopWords;
230
	}
231
232
233
	/*
234
	Set the highlight fields for subsequent searches
235
	 */
236
237
	/**
238
	 * @param string[] $newHighlightedFields
239
	 */
240
	public function setHighlightedFields($newHighlightedFields) {
241
		$this->highlightedFields = $newHighlightedFields;
242
	}
243
244
245
246
	/**
247
	 * Search against elastica using the criteria already provided, such as page length, start,
248
	 * and of course the filters
249
	 * @param  string $queryText query string, e.g. 'New Zealand'
250
	 * @param array $fieldsToSearch Mapping of name to an array of mapping Weight and Elastic mapping,
251
	 *                              e.g. array('Title' => array('Weight' => 2, 'Type' => 'string'))
252
	 * @return \PaginatedList    SilverStripe DataObjects returned from the search against ElasticSearch
253
	 */
254
	public function search($queryText, $fieldsToSearch = null,  $testMode = false) {
255 View Code Duplication
		if ($this->locale == null) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
256
			if (class_exists('Translatable') && \SiteTree::has_extension('Translatable')) {
257
				$this->locale = \Translatable::get_current_locale();
258
			} else {
259
				// if no translatable we only have the default locale
260
				$this->locale = \i18n::default_locale();
261
			}
262
		}
263
264
		$qg = new QueryGenerator();
265
		$qg->setQueryText($queryText);
266
267
		$qg->setFields($fieldsToSearch);
268
		$qg->setSelectedFilters($this->filters);
269
		$qg->setClasses($this->classes);
270
271
		$qg->setPageLength($this->pageLength);
272
		$qg->setStart($this->start);
273
274
		$qg->setQueryResultManipulator($this->manipulator);
275
276
		$qg->setShowResultsForEmptyQuery($this->showResultsForEmptySearch);
277
278
		$query = $qg->generateElasticaQuery();
279
280
		$elasticService = \Injector::inst()->create('SilverStripe\Elastica\ElasticaService');
281
		$elasticService->setLocale($this->locale);
282
		$elasticService->setHighlightedFields($this->highlightedFields);
283
		if ($testMode) {
284
			$elasticService->setTestMode(true);
285
		}
286
		$resultList = new ResultList($elasticService, $query, $queryText, $this->filters);
287
288
		// restrict SilverStripe ClassNames returned
289
		// elasticsearch uses the notion of a 'type', and here this maps to a SilverStripe class
290
		$types = $this->classes;
291
292
		$resultList->setTypes($types);
293
294
		// set the optional aggregation manipulator
295
		$resultList->SearchHelper = $this->manipulator;
296
297
		// at this point ResultList object, not yet executed search query
298
		$paginated = new \PaginatedList(
299
			$resultList
300
		);
301
302
		$paginated->setPageStart($this->start);
303
		$paginated->setPageLength($this->pageLength);
304
		$paginated->setTotalItems($resultList->getTotalItems());
305
306
		$this->aggregations = $resultList->getAggregations();
0 ignored issues
show
Documentation Bug introduced by
It seems like $resultList->getAggregations() of type object<SilverStripe\Elastica\ArrayList> is incompatible with the declared type array of property $aggregations.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
307
308
		if ($resultList->SuggestedQuery) {
0 ignored issues
show
Documentation introduced by
The property SuggestedQuery does not exist on object<SilverStripe\Elastica\ResultList>. Since you implemented __get, maybe consider adding a @property annotation.

Since your code implements the magic getter _get, this function will be called for any read access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

If the property has read access only, you can use the @property-read annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
309
			$this->SuggestedQuery = $resultList->SuggestedQuery;
0 ignored issues
show
Documentation introduced by
The property SuggestedQuery does not exist on object<SilverStripe\Elastica\ResultList>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
310
			$this->SuggestedQueryHighlighted = $resultList->SuggestedQueryHighlighted;
311
		}
312
		return $paginated;
313
	}
314
315
316
	/* Perform an autocomplete search */
317
318
	/**
319
	 * @param string $queryText
320
	 */
321
	public function autocomplete_search($queryText, $field) {
322 View Code Duplication
		if ($this->locale == null) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
323
			if (class_exists('Translatable') && \SiteTree::has_extension('Translatable')) {
324
				$this->locale = \Translatable::get_current_locale();
325
			} else {
326
				// if no translatable we only have the default locale
327
				$this->locale = \i18n::default_locale();
328
			}
329
		}
330
331
		$qg = new QueryGenerator();
332
		$qg->setQueryText($queryText);
333
334
		//only one field but must be array
335
		$qg->setFields(array($field => 1));
336
		if ($this->classes) {
337
			$qg->setClasses($this->classes);
338
		}
339
340
		if (!empty($this->filters)) {
341
			$qg->setSelectedFilters($this->filters);
342
		}
343
344
		$qg->setPageLength($this->pageLength);
345
		$qg->setStart(0);
346
347
		$qg->setShowResultsForEmptyQuery(false);
348
		$query = $qg->generateElasticaAutocompleteQuery();
349
350
		$elasticService = \Injector::inst()->create('SilverStripe\Elastica\ElasticaService');
351
		$elasticService->setLocale($this->locale);
352
		$resultList = new ResultList($elasticService, $query, $queryText, $this->filters);
353
354
		// restrict SilverStripe ClassNames returned
355
		// elasticsearch uses the notion of a 'type', and here this maps to a SilverStripe class
356
		$types = $this->classes;
357
		$resultList->setTypes($types);
358
		// This works in that is breaks things $resultList->setTypes(array('SiteTree'));
359
360
		return $resultList;
361
	}
362
363
364
	/**
365
	 * Perform a 'More Like This' search, aka relevance feedback, using the provided indexed DataObject
366
	 * @param  \DataObject $indexedItem A DataObject that has been indexed in Elasticsearch
367
	 * @param  array $fieldsToSearch  array of fieldnames to search, mapped to weighting
368
	 * @param  $$testMode Use all shards, not just one, for consistent results during unit testing. See
369
	 *         https://www.elastic.co/guide/en/elasticsearch/guide/current/relevance-is-broken.html#relevance-is-broken
370
	 * @return \PaginatedList  List of results
371
	 */
372 1
	public function moreLikeThis($indexedItem, $fieldsToSearch, $testMode = false) {
373 1
		if ($indexedItem == null) {
374
			throw new \InvalidArgumentException('A searchable item cannot be null');
375
		}
376
377 1
		if (!$indexedItem->hasExtension('SilverStripe\Elastica\Searchable')) {
378
			throw new \InvalidArgumentException('Objects of class '.$indexedItem->ClassName.' are not searchable');
379
		}
380
381 1
		if ($fieldsToSearch == null) {
382
			throw new \InvalidArgumentException('Fields cannot be null');
383
		}
384
385 1 View Code Duplication
		if ($this->locale == null) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
386 1
			if (class_exists('Translatable') && \SiteTree::has_extension('Translatable')) {
387
				$this->locale = \Translatable::get_current_locale();
388
			} else {
389
				// if no translatable we only have the default locale
390 1
				$this->locale = \i18n::default_locale();
391
			}
392 1
		}
393
394 1
		$weightedFieldsArray = array();
395 1
		foreach ($fieldsToSearch as $field => $weighting) {
396 1
			if (!is_string($field)) {
397
				throw new \InvalidArgumentException('Fields must be of the form fieldname => weight');
398
			}
399 1
			if (!is_numeric($weighting)) {
400
				throw new \InvalidArgumentException('Fields must be of the form fieldname => weight');
401
			}
402 1
			$weightedField = $field.'^'.$weighting;
403 1
			$weightedField = str_replace('^1', '', $weightedField);
404 1
			array_push($weightedFieldsArray, $weightedField);
405 1
		}
406
407
		$mlt = array(
408 1
			'fields' => $weightedFieldsArray,
409
			'docs' => array(
410
				array(
411 1
				'_type' => $indexedItem->ClassName,
412 1
				'_id' => $indexedItem->ID
413 1
				)
414 1
			),
415
			// defaults - FIXME, make configurable
416
			// see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-mlt-query.html
417
			// ---- term selection params ----
418 1
			'min_term_freq' => $this->minTermFreq,
419 1
			'max_query_terms' => $this->maxTermFreq,
420 1
			'min_doc_freq' => $this->minDocFreq,
421 1
			'min_word_length' => $this->minWordLength,
422 1
			'max_word_length' => $this->maxWordLength,
423 1
			'max_word_length' => $this->minWordLength,
424
425
			// ---- query formation params ----
426
			// see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-minimum-should-match.html
427 1
			'minimum_should_match' => $this->minShouldMatch,
428
429
			#FIXME configuration
430 1
			'stop_words' => explode(',', $this->similarityStopWords)
431 1
		);
432
433 1
		if ($this->maxDocFreq > 0) {
434
			$mlt['max_doc_freq'] = $this->maxDocFreq;
435
		}
436
437
438
439 1
        $query = new Query();
440 1
        $query->setParams(array('query' => array('more_like_this' => $mlt)));
441
442
443 1
        $elasticService = \Injector::inst()->create('SilverStripe\Elastica\ElasticaService');
444 1
		$elasticService->setLocale($this->locale);
445 1
		if ($testMode) {
446 1
			$elasticService->setTestMode(true);
447 1
		}
448
449
450
		// pagination
451 1
		$query->setSize($this->pageLength);
452 1
		$query->setFrom($this->start);
453
454 1
		$resultList = new ResultList($elasticService, $query, null);
455
        // at this point ResultList object, not yet executed search query
456 1
		$paginated = new \PaginatedList(
457
			$resultList
458 1
		);
459
460 1
		$paginated->setPageStart($this->start);
461 1
		$paginated->setPageLength($this->pageLength);
462 1
		$paginated->setTotalItems($resultList->getTotalItems());
463 1
		$this->aggregations = $resultList->getAggregations();
0 ignored issues
show
Documentation Bug introduced by
It seems like $resultList->getAggregations() of type object<SilverStripe\Elastica\ArrayList> is incompatible with the declared type array of property $aggregations.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
464
465 1
		return $paginated;
466
	}
467
468
469
	public function hasSuggestedQuery() {
470
		$result = isset($this->SuggestedQuery) && $this->SuggestedQuery != null;
471
		return $result;
472
	}
473
474
	/**
475
	 * @return string
476
	 */
477
	public function getSuggestedQuery() {
478
		return $this->SuggestedQuery;
479
	}
480
481
	public function getSuggestedQueryHighlighted() {
482
		return $this->SuggestedQueryHighlighted;
483
	}
484
485
}
486