Completed
Push — dev2 ( 14c647...d5d1ea )
by Gordon
32:41 queued 29:48
created

Searchable::getElasticaMapping()   B

Complexity

Conditions 3
Paths 4

Size

Total Lines 31
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 3.1172

Importance

Changes 2
Bugs 2 Features 0
Metric Value
dl 0
loc 31
ccs 13
cts 17
cp 0.7647
rs 8.8571
c 2
b 2
f 0
cc 3
eloc 15
nc 4
nop 0
crap 3.1172
1
<?php
2
3
namespace SilverStripe\Elastica;
4
5
use Elastica\Document;
6
use Elastica\Type\Mapping;
7
use ShortcodeParser;
8
9
/**
10
 * Adds elastic search integration to a data object.
11
 */
12
class Searchable extends \DataExtension {
13
14
	/**
15
	 * Counter used to display progress of indexing
16
	 * @var integer
17
	 */
18
	public static $index_ctr = 0;
19
20
	/**
21
	 * Everytime progressInterval divides $index_ctr exactly display progress
22
	 * @var integer
23
	 */
24
	private static $progressInterval = 0;
25
26
	public static $mappings = array(
27
		'Boolean'     => 'boolean',
28
		'Decimal'     => 'double',
29
		'Currency'    => 'double',
30
		'Double'      => 'double',
31
		'Enum'        => 'string',
32
		'Float'       => 'float',
33
		'HTMLText'    => 'string',
34
		'HTMLVarchar' => 'string',
35
		'Int'         => 'integer',
36
		'Text'        => 'string',
37
		'VarChar'     => 'string',
38
		'Varchar'     => 'string',
39
		'Year'        => 'integer',
40
		'Percentage'  => 'double',
41
		'Time'  => 'date',
42
		// The 2 different date types will be stored with different formats
43
		'Date'        => 'date',
44
		'SS_Datetime' => 'date',
45
		'Datetime' => 'date',
46
		'DBLocale'    => 'string'
47
	);
48
49
50
	/**
51
	 * @var ElasticaService associated elastica search service
52
	 */
53
	protected $service;
54
55
56
	/**
57
	 * Array of fields that need HTML parsed
58
	 * @var array
59
	 */
60
	protected $html_fields = array();
61
62
	/**
63
	 * Store a mapping of relationship name to result type
64
	 */
65
	protected $relationship_methods = array();
66
67
68
	/**
69
	 * If importing a large number of items from a fixtures file, or indeed some other source, then
70
	 * it is quicker to set a flag of value IndexingOff => false.  This has the effect of ensuring
71
	 * no indexing happens, a request is normally made per fixture when loading.  One can then run
72
	 * the reindexing teask to bulk index in one HTTP POST request to Elasticsearch
73
	 *
74
	 * @var boolean
75
	 */
76
	private static $IndexingOff = false;
77
78
79
	/**
80
	 * @see getElasticaResult
81
	 * @var \Elastica\Result
82
	 */
83
	protected $elastica_result;
84
85
	public function __construct(ElasticaService $service) {
86
		$this->service = $service;
87
		parent::__construct();
88
	}
89
90
91
	/**
92
	 * Get the elasticsearch type name
93
	 *
94
	 * @return string
95
	 */
96 26
	public function getElasticaType() {
97 26
		return get_class($this->owner);
98
	}
99
100
101
	/**
102
	 * If the owner is part of a search result
103
	 * the raw Elastica search result is returned
104
	 * if set via setElasticaResult
105
	 *
106
	 * @return \Elastica\Result
107
	 */
108
	public function getElasticaResult() {
109
		return $this->elastica_result;
110
	}
111
112
113
	/**
114
	 * Set the raw Elastica search result
115
	 *
116
	 * @param \Elastica\Result
117
	 */
118
	public function setElasticaResult(\Elastica\Result $result) {
119
		$this->elastica_result = $result;
120
	}
121
122
123
	/**
124
	 * Gets an array of elastic field definitions.
125
	 *
126
	 * @return array
127
	 */
128 26
	public function getElasticaFields($storeMethodName = false, $recurse = true) {
129 26
		$db = $this->owner->db();
130 26
		$fields = $this->getAllSearchableFields();
131 26
		$result = array();
132
133 26
		foreach($fields as $name => $params) {
134 26
			$spec = array();
135 26
			$name = str_replace('()', '', $name);
136
137 26
			if(array_key_exists($name, $db)) {
138 26
				$class = $db[$name];
139 26
				SearchableHelper::assignSpecForStandardFieldType($name, $class, $spec, $this->html_fields, self::$mappings);
140 26
			} else {
141
				// field name is not in the db, it could be a method
142 26
				$has_lists = SearchableHelper::getListRelationshipMethods($this->owner);
143 26
				$has_ones = $this->owner->has_one();
144
145
				// check has_many and many_many relations
146 26
				if(isset($has_lists[$name])) {
147
					// the classes returned by the list method
148 26
					$resultType = $has_lists[$name];
149 26
					SearchableHelper::assignSpecForRelationship($name, $resultType, $spec, $storeMethodName, $recurse);
150 26
				} else if(isset($has_ones[$name])) {
151 26
					$resultType = $has_ones[$name];
152 26
					SearchableHelper::assignSpecForRelationship($name, $resultType, $spec, $storeMethodName, $recurse);
153 26
				}
154
				// otherwise fall back to string - Enum is one such category
155 26
				else {
156 26
					$spec["type"] = "string";
157 26
				}
158
			}
159
160 26
			SearchableHelper::addIndexedFields($name, $spec, $this->owner->ClassName);
161 26
			$result[$name] = $spec;
162 26
		}
163
164 26
		if($this->owner->hasMethod('updateElasticHTMLFields')) {
165 26
			$this->html_fields = $this->owner->updateElasticHTMLFields($this->html_fields);
166 26
		}
167
168 26
		return $result;
169
	}
170
171
172
173
	/**
174
	 * Get the elasticsearch mapping for the current document/type
175
	 *
176
	 * @return \Elastica\Type\Mapping
177
	 */
178 26
	public function getElasticaMapping() {
179 26
		$mapping = new Mapping();
180
181 26
		$fields = $this->getElasticaFields(false);
182
183 26
		$localeMapping = array();
184
185 26
		if($this->owner->hasField('Locale')) {
186
			$localeMapping['type'] = 'string';
187
			// we wish the locale to be stored as is
188
			$localeMapping['index'] = 'not_analyzed';
189
			$fields['Locale'] = $localeMapping;
190
		}
191
192
		// ADD CUSTOM FIELDS HERE THAT ARE INDEXED BY DEFAULT
193
		// add a mapping to flag whether or not class is in SiteTree
194 26
		$fields['IsInSiteTree'] = array('type'=>'boolean');
195 26
		$fields['Link'] = array('type' => 'string', 'index' => 'not_analyzed');
196
197 26
		$mapping->setProperties($fields);
198
199
		//This concatenates all the fields together into a single field.
200
		//Initially added for suggestions compatibility, in that searching
201
		//_all field picks up all possible suggestions
202 26
		$mapping->enableAllField();
203
204 26
		if($this->owner->hasMethod('updateElasticsearchMapping')) {
205 26
			$mapping = $this->owner->updateElasticsearchMapping($mapping);
206 26
		}
207 26
		return $mapping;
208
	}
209
210
211
	/**
212
	 * Get an elasticsearch document
213
	 *
214
	 * @return \Elastica\Document
215
	 */
216 26
	public function getElasticaDocument() {
217 26
		self::$index_ctr++;
218 26
		$fields = $this->getFieldValuesAsArray();
219 26
		$progress = \Controller::curr()->request->getVar('progress');
220 26
		if(!empty($progress)) {
221
			self::$progressInterval = (int)$progress;
222 21
		}
223
224 26
		if(self::$progressInterval > 0) {
225 21
			if(self::$index_ctr % self::$progressInterval === 0) {
226
				ElasticaUtil::message("\t" . $this->owner->ClassName . " - Prepared " . self::$index_ctr . " for indexing...");
227
			}
228 21
		}
229
230
		// Optionally update the document
231 26
		$document = new Document($this->owner->ID, $fields);
232 26
		if($this->owner->hasMethod('updateElasticsearchDocument')) {
233 26
			$document = $this->owner->updateElasticsearchDocument($document);
234 26
		}
235
236
		// Check if the current classname is part of the site tree or not
237
		// Results are cached to save reprocessing the same
238 26
		$classname = $this->owner->ClassName;
239 26
		$inSiteTree = SearchableHelper::isInSiteTree($classname);
240
241 26
		$document->set('IsInSiteTree', $inSiteTree);
242 26
		if($inSiteTree) {
243 26
			$document->set('Link', $this->owner->AbsoluteLink());
244 26
		}
245
246 26
		if(isset($this->owner->Locale)) {
247
			$document->set('Locale', $this->owner->Locale);
248 1
		}
249
250 26
		return $document;
251
	}
252
253
254 26
	public function getFieldValuesAsArray($recurse = true) {
255 26
		$fields = array();
256 26
		foreach($this->getElasticaFields($recurse) as $field => $config) {
257
			//This is the case of calling a method to get a value, the field does not exist in the DB
258 26
			if(null === $this->owner->$field && is_callable(get_class($this->owner) . "::" . $field)) {
259
				// call a method to get a field value
260 26
				SearchableHelper::storeMethodTextValue($this->owner, $field, $fields, $this->html_fields);
261 26
			} else {
262 26
				if(in_array($field, $this->html_fields)) {
263 26
					SearchableHelper::storeFieldHTMLValue($this->owner, $field, $fields);
264 26
				} else {
265 26
					SearchableHelper::storeRelationshipValue($this->owner, $field, $fields, $config, $recurse);
266
				}
267
			}
268 26
		}
269 26
		return $fields;
270
	}
271
272
273
	/**
274
	 * Returns whether to include the document into the search index.
275
	 * All documents are added unless they have a field "ShowInSearch" which is set to false
276
	 *
277
	 * @return boolean
278
	 */
279 26
	public function showRecordInSearch() {
280 26
		return !($this->owner->hasField('ShowInSearch') && false == $this->owner->ShowInSearch);
281
	}
282
283
284
	/**
285
	 * Delete the record from the search index if ShowInSearch is deactivated (non-SiteTree).
286
	 */
287 26
	public function onBeforeWrite() {
288
		if(
289 26
			$this->owner instanceof \SiteTree &&
290 26
			$this->owner->hasField('ShowInSearch') &&
291 26
			$this->owner->isChanged('ShowInSearch', 2) &&
292
			false == $this->owner->ShowInSearch
293 26
		) {
294
			$this->doDeleteDocument();
295
		}
296 26
	}
297
298
299
	/**
300
	 * Delete the record from the search index if ShowInSearch is deactivated (SiteTree).
301
	 */
302
	public function onBeforePublish() {
303
		if(false == $this->owner->ShowInSearch && $this->owner->isPublished()) {
304
			$liveRecord = \Versioned::get_by_stage(get_class($this->owner), 'Live')->
305
				byID($this->owner->ID);
306
			if($liveRecord->ShowInSearch != $this->owner->ShowInSearch) {
307
				$this->doDeleteDocument();
308
			}
309
		}
310
	}
311
312
313
	/**
314
	 * Updates the record in the search index (non-SiteTree).
315
	 */
316 26
	public function onAfterWrite() {
317 26
		$this->doIndexDocument();
318 26
	}
319
320
321
	/**
322
	 * Updates the record in the search index (SiteTree).
323
	 */
324
	public function onAfterPublish() {
325
		$this->doIndexDocument();
326
	}
327
328
329
	/**
330
	 * Updates the record in the search index.
331
	 */
332 26
	protected function doIndexDocument() {
333 26
		if($this->showRecordInSearch() && !$this->owner->IndexingOff) {
334 26
			$this->service->index($this->owner);
335 26
		}
336 26
	}
337
338
339
	/**
340
	 * Removes the record from the search index (non-SiteTree).
341
	 */
342
	public function onAfterDelete() {
343
		$this->doDeleteDocumentIfInSearch();
344
	}
345
346
347
	/**
348
	 * Removes the record from the search index (non-SiteTree).
349
	 */
350
	public function onAfterUnpublish() {
351
		$this->doDeleteDocumentIfInSearch();
352
	}
353
354
355
	/**
356
	 * Removes the record from the search index if the "ShowInSearch" attribute is set to true.
357
	 */
358
	protected function doDeleteDocumentIfInSearch() {
359
		if($this->showRecordInSearch()) {
360
			$this->doDeleteDocument();
361
		}
362
	}
363
364
365
	/**
366
	 * Removes the record from the search index.
367
	 */
368
	protected function doDeleteDocument() {
369
		try {
370
			if(!$this->owner->IndexingOff) {
371
				// this goes to elastica service
372
				$this->service->remove($this->owner);
373
			}
374
		} catch (\Elastica\Exception\NotFoundException $e) {
375
			trigger_error("Deleted document " . $this->owner->ClassName . " (" . $this->owner->ID .
376
				") not found in search index.", E_USER_NOTICE);
377
		}
378
	}
379
380
381
	/**
382
	 * Return all of the searchable fields defined in $this->owner::$searchable_fields and all the parent classes.
383
	 *
384
	 * @param  $recuse Whether or not to traverse relationships. First time round yes, subsequently no
385
	 * @return array searchable fields
386
	 */
387 26
	public function getAllSearchableFields($recurse = true) {
388 26
		$fields = \Config::inst()->get(get_class($this->owner), 'searchable_fields');
389
390
		// fallback to default method
391 26
		if(!$fields) {
392
			user_error('The field $searchable_fields must be set for the class ' . $this->owner->ClassName);
393
		}
394
395
		// get the values of these fields
396 26
		$elasticaMapping = SearchableHelper::fieldsToElasticaConfig($fields);
397
398 26
		if($recurse) {
399
			// now for the associated methods and their results
400 26
			$methodDescs = \Config::inst()->get(get_class($this->owner), 'searchable_relationships');
401 26
			$has_ones = $this->owner->has_one();
402 26
			$has_lists = SearchableHelper::getListRelationshipMethods($this->owner);
403
404 26
			if(isset($methodDescs) && is_array($methodDescs)) {
405 26
				foreach($methodDescs as $methodDesc) {
406
					// split before the brackets which can optionally list which fields to index
407 26
					$splits = explode('(', $methodDesc);
408 26
					$methodName = $splits[0];
409
410 26
					if(isset($has_lists[$methodName])) {
411
412 26
						$relClass = $has_lists[$methodName];
413 26
						$fields = \Config::inst()->get($relClass, 'searchable_fields');
414 26
						if(!$fields) {
415
							user_error('The field $searchable_fields must be set for the class ' . $relClass);
416
						}
417 26
						$rewrite = SearchableHelper::fieldsToElasticaConfig($fields);
418
419
						// mark as a method, the resultant fields are correct
420 26
						$elasticaMapping[$methodName . '()'] = $rewrite;
421 26
					} else if(isset($has_ones[$methodName])) {
422 26
						$relClass = $has_ones[$methodName];
423 26
						$fields = \Config::inst()->get($relClass, 'searchable_fields');
424 26
						if(!$fields) {
425
							user_error('The field $searchable_fields must be set for the class ' . $relClass);
426
						}
427 26
						$rewrite = SearchableHelper::fieldsToElasticaConfig($fields);
428
429
						// mark as a method, the resultant fields are correct
430 26
						$elasticaMapping[$methodName . '()'] = $rewrite;
431 26
					} else {
432
						user_error('The method ' . $methodName . ' not found in class ' . $this->owner->ClassName .
433
								', please check configuration');
434
					}
435 26
				}
436 26
			}
437 26
		}
438
439 26
		return $elasticaMapping;
440
	}
441
442
443
444
445 21
	public function requireDefaultRecords() {
446 21
		parent::requireDefaultRecords();
447 21
		$searchableFields = $this->getElasticaFields(true, true);
448 21
		$doSC = SearchableHelper::findOrCreateSearchableClass($this->owner->ClassName);
449
450 21
		foreach($searchableFields as $name => $searchableField) {
451
			// check for existence of methods and if they exist use that as the name
452 21
			if(!isset($searchableField['type'])) {
453 21
				$name = $searchableField['properties']['__method'];
454 21
			}
455
456 21
			SearchableHelper::findOrCreateSearchableField(
457 21
				$this->owner->ClassName,
458 21
				$name,
459 21
				$searchableField,
460
				$doSC
461 21
			);
462
463
			// FIXME deal with deletions
464 21
		}
465 21
	}
466
467
468
	/*
469
	Allow the option of overriding the default template with one of <ClassName>ElasticSearchResult
470
	 */
471
	public function RenderResult($linkToContainer = '') {
472
		$vars = new \ArrayData(array('SearchResult' => $this->owner, 'ContainerLink' => $linkToContainer));
473
		$possibleTemplates = array($this->owner->ClassName . 'ElasticSearchResult', 'ElasticSearchResult');
474
		return $this->owner->customise($vars)->renderWith($possibleTemplates);
475
	}
476
477
478
	public function getTermVectors() {
479
		return $this->service->getTermVectors($this->owner);
480
	}
481
482
483
	public function updateCMSFields(\FieldList $fields) {
484
		$isIndexed = false;
485
		// SIteTree object must have a live record, ShowInSearch = true
486
		if (\DB::getConn()->hasTable($this->owner->ClassName)) {
0 ignored issues
show
Deprecated Code introduced by
The method DB::getConn() has been deprecated with message: since version 4.0 Use DB::get_conn instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
Deprecated Code introduced by
The method SS_Database::hasTable() has been deprecated with message: since version 4.0 Use DB::get_schema()->hasTable() instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
487
			if(SearchableHelper::isInSiteTree($this->owner->ClassName)) {
488
				$liveRecord = \Versioned::get_by_stage(get_class($this->owner), 'Live')->
489
					byID($this->owner->ID);
490
				if(!empty($liveRecord) && $liveRecord->ShowInSearch) {
491
					$isIndexed = true;
492
				} else {
493
					$isIndexed = false;
494
				}
495
			} else {
496
				// In the case of a DataObject we use the ShowInSearchFlag
497
				$isIndexed = true;
498
			}
499
		}
500
501
502
		if($isIndexed) {
503
			$termVectors = $this->getTermVectors();
504
			$termFields = array_keys($termVectors);
505
			sort($termFields);
506
507
			foreach($termFields as $field) {
508
				$terms = new \ArrayList();
509
510
				foreach(array_keys($termVectors[$field]['terms']) as $term) {
511
					$do = new \DataObject();
512
					$do->Term = $term;
513
					$stats = $termVectors[$field]['terms'][$term];
514
					if(isset($stats['ttf'])) {
515
						$do->TTF = $stats['ttf'];
516
					}
517
518
					if(isset($stats['doc_freq'])) {
519
						$do->DocFreq = $stats['doc_freq'];
520
					}
521
522
					if(isset($stats['term_freq'])) {
523
						$do->TermFreq = $stats['term_freq'];
524
					}
525
					$terms->push($do);
526
				}
527
528
				$config = \GridFieldConfig_RecordViewer::create(100);
529
				$config->getComponentByType('GridFieldDataColumns')->setDisplayFields(array(
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface GridFieldComponent as the method setDisplayFields() does only exist in the following implementations of said interface: GridFieldDataColumns, GridFieldEditableColumns, GridFieldExternalLink.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
530
					'Term' => 'Term',
531
					'TTF' => 'Total term frequency (how often a term occurs in all documents)',
532
					'DocFreq' => 'n documents with this term',
533
					'TermFreq'=> 'n times this term appears in this field'
534
				));
535
536
			   $underscored = str_replace('.', '_', $field);
537
538
				$gridField = new \GridField(
539
					'TermsFor' . $underscored, // Field name
540
					$field . 'TITLE' . $field, // Field title
541
					$terms,
542
					$config
543
				);
544
			   $fields->addFieldToTab('Root.ElasticaTerms.' . $underscored, $gridField);
545
			}
546
547
		}
548
549
		return $fields;
550
	}
551
552
553
}
554