Completed
Push — dev2 ( a70cfe...24a813 )
by Gordon
04:47
created

Searchable::showRecordInSearch()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 1 Features 0
Metric Value
c 2
b 1
f 0
dl 0
loc 3
rs 10
cc 2
eloc 2
nc 2
nop 0
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
	public function getElasticaType() {
97
		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
	public function getElasticaFields($storeMethodName = false, $recurse = true) {
129
		$db = $this->owner->db();
130
		$fields = $this->getAllSearchableFields();
131
		$result = array();
132
133
		foreach($fields as $name => $params) {
134
			$spec = array();
135
			$name = str_replace('()', '', $name);
136
137
			if(array_key_exists($name, $db)) {
138
				$class = $db[$name];
139
				SearchableHelper::assignSpecForStandardFieldType($name, $class, $spec, $this->html_fields, self::$mappings);
140
			} else {
141
				// field name is not in the db, it could be a method
142
				$has_lists = SearchableHelper::getListRelationshipMethods($this->owner);
143
				$has_ones = $this->owner->has_one();
144
145
				// check has_many and many_many relations
146
				if(isset($has_lists[$name])) {
147
					// the classes returned by the list method
148
					$resultType = $has_lists[$name];
149
					SearchableHelper::assignSpecForRelationship($name, $resultType, $spec, $storeMethodName, $recurse);
150
				} else if(isset($has_ones[$name])) {
151
					$resultType = $has_ones[$name];
152
					SearchableHelper::assignSpecForRelationship($name, $resultType, $spec, $storeMethodName, $recurse);
153
				}
154
				// otherwise fall back to string - Enum is one such category
155
				else {
156
					$spec["type"] = "string";
157
				}
158
			}
159
160
			SearchableHelper::addIndexedFields($name, $spec, $this->owner->ClassName);
161
			$result[$name] = $spec;
162
		}
163
164
		if($this->owner->hasMethod('updateElasticHTMLFields')) {
165
			$this->html_fields = $this->owner->updateElasticHTMLFields($this->html_fields);
166
		}
167
168
		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
	public function getElasticaMapping() {
179
		$mapping = new Mapping();
180
181
		$fields = $this->getElasticaFields(false);
182
183
		$localeMapping = array();
184
185
		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
		$fields['IsInSiteTree'] = array('type'=>'boolean');
195
		$fields['Link'] = array('type' => 'string', 'index' => 'not_analyzed');
196
197
		$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
		$mapping->enableAllField();
203
204
		if($this->owner->hasMethod('updateElasticsearchMapping')) {
205
			$mapping = $this->owner->updateElasticsearchMapping($mapping);
206
		}
207
		return $mapping;
208
	}
209
210
211
	/**
212
	 * Get an elasticsearch document
213
	 *
214
	 * @return \Elastica\Document
215
	 */
216
	public function getElasticaDocument() {
217
		self::$index_ctr++;
218
		$fields = $this->getFieldValuesAsArray();
219
		$progress = \Controller::curr()->request->getVar('progress');
220
		if(!empty($progress)) {
221
			self::$progressInterval = (int)$progress;
222
		}
223
224
		if(self::$progressInterval > 0) {
225
			if(self::$index_ctr % self::$progressInterval === 0) {
226
				ElasticaUtil::message("\t" . $this->owner->ClassName . " - Prepared " . self::$index_ctr . " for indexing...");
227
			}
228
		}
229
230
		// Optionally update the document
231
		$document = new Document($this->owner->ID, $fields);
232
		if($this->owner->hasMethod('updateElasticsearchDocument')) {
233
			$document = $this->owner->updateElasticsearchDocument($document);
234
		}
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
		$classname = $this->owner->ClassName;
239
		$inSiteTree = SearchableHelper::isInSiteTree($classname);
240
241
		$document->set('IsInSiteTree', $inSiteTree);
242
		if($inSiteTree) {
243
			$document->set('Link', $this->owner->AbsoluteLink());
244
		}
245
246
		if(isset($this->owner->Locale)) {
247
			$document->set('Locale', $this->owner->Locale);
248
		}
249
250
		return $document;
251
	}
252
253
254
255
256
257
	public function getFieldValuesAsArray($recurse = true) {
258
		$fields = array();
259
260
		foreach($this->getElasticaFields($recurse) as $field => $config) {
261
262
			//This is the case of calling a method to get a value, the field does not exist in the DB
263
			if(null === $this->owner->$field && is_callable(get_class($this->owner) . "::" . $field)) {
264
				// call a method to get a field value
265
				SearchableHelper::storeMethodTextValue($this->owner, $field, $fields, $this->html_fields);
266
			} else {
267
				if(in_array($field, $this->html_fields)) {
268
					SearchableHelper::storeFieldHTMLValue($this->owner, $field, $fields);
269
				} else {
270
					SearchableHelper::storeRelationshipValue($this->owner, $field, $fields, $config, $recurse);
271
272
				}
273
274
			}
275
		}
276
277
		return $fields;
278
	}
279
280
281
	/**
282
	 * Returns whether to include the document into the search index.
283
	 * All documents are added unless they have a field "ShowInSearch" which is set to false
284
	 *
285
	 * @return boolean
286
	 */
287
	public function showRecordInSearch() {
288
		return !($this->owner->hasField('ShowInSearch') && false == $this->owner->ShowInSearch);
289
	}
290
291
292
	/**
293
	 * Delete the record from the search index if ShowInSearch is deactivated (non-SiteTree).
294
	 */
295
	public function onBeforeWrite() {
296
		if(($this->owner instanceof \SiteTree)) {
297
			if($this->owner->hasField('ShowInSearch') &&
298
				$this->owner->isChanged('ShowInSearch', 2) && false == $this->owner->ShowInSearch) {
299
				$this->doDeleteDocument();
300
			}
301
		}
302
	}
303
304
305
	/**
306
	 * Delete the record from the search index if ShowInSearch is deactivated (SiteTree).
307
	 */
308
	public function onBeforePublish() {
309
		if(false == $this->owner->ShowInSearch) {
310
			if($this->owner->isPublished()) {
311
				$liveRecord = \Versioned::get_by_stage(get_class($this->owner), 'Live')->
312
					byID($this->owner->ID);
313
				if($liveRecord->ShowInSearch != $this->owner->ShowInSearch) {
314
					$this->doDeleteDocument();
315
				}
316
			}
317
		}
318
	}
319
320
321
	/**
322
	 * Updates the record in the search index (non-SiteTree).
323
	 */
324
	public function onAfterWrite() {
325
		$this->doIndexDocument();
326
	}
327
328
329
	/**
330
	 * Updates the record in the search index (SiteTree).
331
	 */
332
	public function onAfterPublish() {
333
		$this->doIndexDocument();
334
	}
335
336
337
	/**
338
	 * Updates the record in the search index.
339
	 */
340
	protected function doIndexDocument() {
341
		if($this->showRecordInSearch()) {
342
			if(!$this->owner->IndexingOff) {
343
				$this->service->index($this->owner);
344
			}
345
		}
346
	}
347
348
349
	/**
350
	 * Removes the record from the search index (non-SiteTree).
351
	 */
352
	public function onAfterDelete() {
353
		$this->doDeleteDocumentIfInSearch();
354
	}
355
356
357
	/**
358
	 * Removes the record from the search index (non-SiteTree).
359
	 */
360
	public function onAfterUnpublish() {
361
		$this->doDeleteDocumentIfInSearch();
362
	}
363
364
365
	/**
366
	 * Removes the record from the search index if the "ShowInSearch" attribute is set to true.
367
	 */
368
	protected function doDeleteDocumentIfInSearch() {
369
		if($this->showRecordInSearch()) {
370
			$this->doDeleteDocument();
371
		}
372
	}
373
374
375
	/**
376
	 * Removes the record from the search index.
377
	 */
378
	protected function doDeleteDocument() {
379
		try {
380
			if(!$this->owner->IndexingOff) {
381
				// this goes to elastica service
382
				$this->service->remove($this->owner);
383
			}
384
		} catch (\Elastica\Exception\NotFoundException $e) {
385
			trigger_error("Deleted document " . $this->owner->ClassName . " (" . $this->owner->ID .
386
				") not found in search index.", E_USER_NOTICE);
387
		}
388
389
	}
390
391
392
	/**
393
	 * Return all of the searchable fields defined in $this->owner::$searchable_fields and all the parent classes.
394
	 *
395
	 * @param  $recuse Whether or not to traverse relationships. First time round yes, subsequently no
396
	 * @return array searchable fields
397
	 */
398
	public function getAllSearchableFields($recurse = true) {
399
		$fields = \Config::inst()->get(get_class($this->owner), 'searchable_fields');
400
401
		// fallback to default method
402
		if(!$fields) {
403
			user_error('The field $searchable_fields must be set for the class ' . $this->owner->ClassName);
404
		}
405
406
		// get the values of these fields
407
		$elasticaMapping = SearchableHelper::fieldsToElasticaConfig($fields);
408
409
		if($recurse) {
410
			// now for the associated methods and their results
411
			$methodDescs = \Config::inst()->get(get_class($this->owner), 'searchable_relationships');
412
			$has_ones = $this->owner->has_one();
413
			$has_lists = SearchableHelper::getListRelationshipMethods($this->owner);
414
415
			if(isset($methodDescs) && is_array($methodDescs)) {
416
				foreach($methodDescs as $methodDesc) {
417
					// split before the brackets which can optionally list which fields to index
418
					$splits = explode('(', $methodDesc);
419
					$methodName = $splits[0];
420
421
					if(isset($has_lists[$methodName])) {
422
423
						$relClass = $has_lists[$methodName];
424
						$fields = \Config::inst()->get($relClass, 'searchable_fields');
425
						if(!$fields) {
426
							user_error('The field $searchable_fields must be set for the class ' . $relClass);
427
						}
428
						$rewrite = SearchableHelper::fieldsToElasticaConfig($fields);
429
430
						// mark as a method, the resultant fields are correct
431
						$elasticaMapping[$methodName . '()'] = $rewrite;
432
					} else if(isset($has_ones[$methodName])) {
433
						$relClass = $has_ones[$methodName];
434
						$fields = \Config::inst()->get($relClass, 'searchable_fields');
435
						if(!$fields) {
436
							user_error('The field $searchable_fields must be set for the class ' . $relClass);
437
						}
438
						$rewrite = SearchableHelper::fieldsToElasticaConfig($fields);
439
440
						// mark as a method, the resultant fields are correct
441
						$elasticaMapping[$methodName . '()'] = $rewrite;
442
					} else {
443
						user_error('The method ' . $methodName . ' not found in class ' . $this->owner->ClassName .
444
								', please check configuration');
445
					}
446
				}
447
			}
448
		}
449
450
		return $elasticaMapping;
451
	}
452
453
454
455
456
	public function requireDefaultRecords() {
457
		parent::requireDefaultRecords();
458
459
		$searchableFields = $this->getElasticaFields(true, true);
460
461
462
		$doSC = \SearchableClass::get()->filter(array('Name' => $this->owner->ClassName))->first();
463
		if(!$doSC) {
464
			$doSC = new \SearchableClass();
465
			$doSC->Name = $this->owner->ClassName;
466
467
			$inSiteTree = SearchableHelper::isInSiteTree($this->owner->ClassName);
468
			$doSC->InSiteTree = $inSiteTree;
469
470
			$doSC->write();
471
		}
472
473
		foreach($searchableFields as $name => $searchableField) {
474
			// check for existence of methods and if they exist use that as the name
475
			if(!isset($searchableField['type'])) {
476
				$name = $searchableField['properties']['__method'];
477
			}
478
479
			$filter = array('ClazzName' => $this->owner->ClassName, 'Name' => $name);
480
			$doSF = \SearchableField::get()->filter($filter)->first();
481
482
483
			if(!$doSF) {
484
				$doSF = new \SearchableField();
485
				$doSF->ClazzName = $this->owner->ClassName;
486
				$doSF->Name = $name;
487
488
				if(isset($searchableField['type'])) {
489
					$doSF->Type = $searchableField['type'];
490
				} else {
491
					$doSF->Name = $searchableField['properties']['__method'];
492
					$doSF->Type = 'relationship';
493
				}
494
				$doSF->SearchableClassID = $doSC->ID;
495
496
				if(isset($searchableField['fields']['autocomplete'])) {
497
					$doSF->Autocomplete = true;
498
				}
499
500
				$doSF->write();
501
				\DB::alteration_message("Created new searchable editable field " . $name, "changed");
502
			}
503
504
			// FIXME deal with deletions
505
		}
506
	}
507
508
509
	/*
510
	Allow the option of overriding the default template with one of <ClassName>ElasticSearchResult
511
	 */
512
	public function RenderResult($linkToContainer = '') {
513
		$vars = new \ArrayData(array('SearchResult' => $this->owner, 'ContainerLink' => $linkToContainer));
514
		$possibleTemplates = array($this->owner->ClassName . 'ElasticSearchResult', 'ElasticSearchResult');
515
		return $this->owner->customise($vars)->renderWith($possibleTemplates);
516
	}
517
518
519
520
	public function getTermVectors() {
521
		return $this->service->getTermVectors($this->owner);
522
	}
523
524
525
	public function updateCMSFields(\FieldList $fields) {
526
		$isIndexed = false;
527
		// SIteTree object must have a live record, ShowInSearch = true
528
		if(SearchableHelper::isInSiteTree($this->owner->ClassName)) {
529
			$liveRecord = \Versioned::get_by_stage(get_class($this->owner), 'Live')->
530
				byID($this->owner->ID);
531
			if($liveRecord->ShowInSearch) {
532
				$isIndexed = true;
533
			} else {
534
				$isIndexed = false;
535
			}
536
		} else {
537
			// In the case of a DataObject we use the ShowInSearchFlag
538
			$isIndexed = true;
539
		}
540
541
		if($isIndexed) {
542
			$termVectors = $this->getTermVectors();
543
			$termFields = array_keys($termVectors);
544
			sort($termFields);
545
546
			foreach($termFields as $field) {
547
				$terms = new \ArrayList();
548
549
				foreach(array_keys($termVectors[$field]['terms']) as $term) {
550
					$do = new \DataObject();
551
					$do->Term = $term;
552
					$stats = $termVectors[$field]['terms'][$term];
553
					if(isset($stats['ttf'])) {
554
						$do->TTF = $stats['ttf'];
555
					}
556
557
					if(isset($stats['doc_freq'])) {
558
						$do->DocFreq = $stats['doc_freq'];
559
					}
560
561
					if(isset($stats['term_freq'])) {
562
						$do->TermFreq = $stats['term_freq'];
563
					}
564
					$terms->push($do);
565
				}
566
567
				$config = \GridFieldConfig_RecordViewer::create(100);
568
				$config->getComponentByType('GridFieldDataColumns')->setDisplayFields(array(
569
					'Term' => 'Term',
570
					'TTF' => 'Total term frequency (how often a term occurs in all documents)',
571
					'DocFreq' => 'n documents with this term',
572
					'TermFreq'=> 'n times this term appears in this field'
573
				));
574
575
			   $underscored = str_replace('.', '_', $field);
576
577
				$gridField = new \GridField(
578
					'TermsFor' . $underscored, // Field name
579
					$field . 'TITLE' . $field, // Field title
580
					$terms,
581
					$config
582
				);
583
			   $fields->addFieldToTab('Root.ElasticaTerms.' . $underscored, $gridField);
584
			}
585
586
		}
587
588
		return $fields;
589
	}
590
591
592
}
593