Completed
Pull Request — master (#206)
by Jake Dale
06:32
created

SolrIndex::add()   B

Complexity

Conditions 5
Paths 3

Size

Total Lines 14
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 14
rs 8.8571
cc 5
eloc 8
nc 3
nop 1
1
<?php
2
3
namespace SilverStripe\FullTextSearch\Solr;
4
5
use SilverStripe\Control\Director;
6
use SilverStripe\Core\Environment;
7
use SilverStripe\FulltextSearch\Search\Indexes\SearchIndex;
8
use SilverStripe\FullTextSearch\Search\Variants\SearchVariant_Caller;
9
use SilverStripe\FullTextSearch\Solr\Services\SolrService;
10
use SilverStripe\FulltextSearch\Search\Queries\SearchQuery;
11
use SilverStripe\FullTextSearch\Search\Queries\SearchQuery_Range;
12
use SilverStripe\FullTextSearch\Search\Variants\SearchVariant;
13
use SilverStripe\FulltextSearch\Search\SearchIntrospection;
14
use SilverStripe\ORM\ArrayList;
15
use SilverStripe\ORM\DataObject;
16
use SilverStripe\ORM\FieldType\DBField;
17
use SilverStripe\ORM\PaginatedList;
18
use SilverStripe\View\ArrayData;
19
20
abstract class SolrIndex extends SearchIndex
21
{
22
    public static $fulltextTypeMap = array(
23
        '*' => 'text',
24
        'HTMLVarchar' => 'htmltext',
25
        'HTMLText' => 'htmltext'
26
    );
27
28
    public static $filterTypeMap = array(
29
        '*' => 'string',
30
        'Boolean' => 'boolean',
31
        'Date' => 'tdate',
32
        'SSDatetime' => 'tdate',
33
        'SS_Datetime' => 'tdate',
34
        'ForeignKey' => 'tint',
35
        'Int' => 'tint',
36
        'Float' => 'tfloat',
37
        'Double' => 'tdouble'
38
    );
39
40
    public static $sortTypeMap = array();
41
42
    protected $analyzerFields = array();
43
44
    protected $copyFields = array();
45
46
    protected $extrasPath = null;
47
48
    protected $templatesPath = null;
49
50
    private static $casting = [
0 ignored issues
show
Comprehensibility introduced by
Consider using a different property name as you override a private property of the parent class.
Loading history...
Unused Code introduced by
The property $casting is not used and could be removed.

This check marks private properties in classes that are never used. Those properties can be removed.

Loading history...
51
        'FieldDefinitions' => 'HTMLText',
52
        'CopyFieldDefinitions' => 'HTMLText'
53
    ];
54
55
    /**
56
     * List of boosted fields
57
     *
58
     * @var array
59
     */
60
    protected $boostedFields = array();
61
62
    /**
63
     * Name of default field
64
     *
65
     * @var string
66
     * @config
67
     */
68
    private static $default_field = '_text';
0 ignored issues
show
Unused Code introduced by
The property $default_field is not used and could be removed.

This check marks private properties in classes that are never used. Those properties can be removed.

Loading history...
69
70
    /**
71
     * List of copy fields all fulltext fields should be copied into.
72
     * This will fallback to default_field if not specified
73
     *
74
     * @var array
75
     */
76
    private static $copy_fields = array();
0 ignored issues
show
Unused Code introduced by
The property $copy_fields is not used and could be removed.

This check marks private properties in classes that are never used. Those properties can be removed.

Loading history...
77
78
    /**
79
     * @return String Absolute path to the folder containing
80
     * templates which are used for generating the schema and field definitions.
81
     */
82
    public function getTemplatesPath()
83
    {
84
        $globalOptions = Solr::solr_options();
85
        $path = $this->templatesPath ? $this->templatesPath : $globalOptions['templatespath'];
86
        return rtrim($path, '/');
87
    }
88
89
    /**
90
     * @return String Absolute path to the configuration default files,
91
     * e.g. solrconfig.xml.
92
     */
93
    public function getExtrasPath()
94
    {
95
        $globalOptions = Solr::solr_options();
96
        return $this->extrasPath ? $this->extrasPath : $globalOptions['extraspath'];
97
    }
98
99
    public function generateSchema()
100
    {
101
        return $this->renderWith($this->getTemplatesPath() . '/schema.ss');
102
    }
103
104
    /**
105
     * Helper for returning the correct index name. Supports prefixing and
106
     * suffixing
107
     *
108
     * @return string
109
     */
110
    public function getIndexName()
111
    {
112
        $name = $this->sanitiseClassName(get_class($this), '-');
113
114
        $indexParts = [$name];
115
116
        if ($indexPrefix = Environment::getEnv('SS_SOLR_INDEX_PREFIX')) {
0 ignored issues
show
Bug introduced by
The method getEnv() does not seem to exist on object<SilverStripe\Core\Environment>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
117
            array_unshift($indexParts, $indexPrefix);
118
        }
119
120
        if ($indexSuffix = Environment::getEnv('SS_SOLR_INDEX_SUFFIX')) {
0 ignored issues
show
Bug introduced by
The method getEnv() does not seem to exist on object<SilverStripe\Core\Environment>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
121
            $indexParts[] = $indexSuffix;
122
        }
123
124
        return implode($indexParts);
125
    }
126
127
    public function getTypes()
128
    {
129
        return $this->renderWith($this->getTemplatesPath() . '/types.ss');
130
    }
131
132
    /**
133
     * Index-time analyzer which is applied to a specific field.
134
     * Can be used to remove HTML tags, apply stemming, etc.
135
     *
136
     * @see http://wiki.apache.org/solr/AnalyzersTokenizersTokenFilters#solr.WhitespaceTokenizerFactory
137
     *
138
     * @param string $field
139
     * @param string $type
140
     * @param Array $params Parameters for the analyzer, usually at least a "class"
141
     */
142
    public function addAnalyzer($field, $type, $params)
143
    {
144
        $fullFields = $this->fieldData($field);
145
        if ($fullFields) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $fullFields of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
146
            foreach ($fullFields as $fullField => $spec) {
147
                if (!isset($this->analyzerFields[$fullField])) {
148
                    $this->analyzerFields[$fullField] = array();
149
                }
150
                $this->analyzerFields[$fullField][$type] = $params;
151
            }
152
        }
153
    }
154
155
    /**
156
     * Get the default text field, normally '_text'
157
     *
158
     * @return string
159
     */
160
    public function getDefaultField()
161
    {
162
        return $this->config()->default_field;
163
    }
164
165
    /**
166
     * Get list of fields each text field should be copied into.
167
     * This will fallback to the default field if omitted.
168
     *
169
     * @return array
170
     */
171
    protected function getCopyDestinations()
172
    {
173
        $copyFields = $this->config()->copy_fields;
174
        if ($copyFields) {
175
            return $copyFields;
176
        }
177
        // Fallback to default field
178
        $df = $this->getDefaultField();
179
        return array($df);
180
    }
181
182
    public function getFieldDefinitions()
183
    {
184
        $xml = array();
185
        $stored = $this->getStoredDefault();
186
187
        $xml[] = "";
188
189
        // Add the hardcoded field definitions
190
191
        $xml[] = "<field name='_documentid' type='string' indexed='true' stored='true' required='true' />";
192
193
        $xml[] = "<field name='ID' type='tint' indexed='true' stored='true' required='true' />";
194
        $xml[] = "<field name='ClassName' type='string' indexed='true' stored='true' required='true' />";
195
        $xml[] = "<field name='ClassHierarchy' type='string' indexed='true' stored='true' required='true' multiValued='true' />";
196
197
        // Add the fulltext collation field
198
199
        $df = $this->getDefaultField();
200
        $xml[] = "<field name='{$df}' type='htmltext' indexed='true' stored='{$stored}' multiValued='true' />" ;
201
202
        // Add the user-specified fields
203
204
        foreach ($this->fulltextFields as $name => $field) {
205
            $xml[] = $this->getFieldDefinition($name, $field, self::$fulltextTypeMap);
206
        }
207
208 View Code Duplication
        foreach ($this->filterFields as $name => $field) {
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...
209
            if ($field['fullfield'] == 'ID' || $field['fullfield'] == 'ClassName') {
210
                continue;
211
            }
212
            $xml[] = $this->getFieldDefinition($name, $field);
213
        }
214
215 View Code Duplication
        foreach ($this->sortFields as $name => $field) {
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...
216
            if ($field['fullfield'] == 'ID' || $field['fullfield'] == 'ClassName') {
217
                continue;
218
            }
219
            $xml[] = $this->getFieldDefinition($name, $field);
220
        }
221
222
        return implode("\n\t\t", $xml);
223
    }
224
225
    /**
226
     * Extract first suggestion text from collated values
227
     *
228
     * @param mixed $collation
229
     * @return string
230
     */
231
    protected function getCollatedSuggestion($collation = '')
232
    {
233
        if (is_string($collation)) {
234
            return $collation;
235
        }
236
        if (is_object($collation)) {
237
            if (isset($collation->misspellingsAndCorrections)) {
238
                foreach ($collation->misspellingsAndCorrections as $key => $value) {
239
                    return $value;
240
                }
241
            }
242
        }
243
        return '';
244
    }
245
246
    /**
247
     * Extract a human friendly spelling suggestion from a Solr spellcheck collation string.
248
     * @param string $collation
249
     * @return String
250
     */
251
    protected function getNiceSuggestion($collation = '')
252
    {
253
        $collationParts = explode(' ', $collation);
254
255
        // Remove advanced query params from the beginning of each collation part.
256
        foreach ($collationParts as $key => &$part) {
257
            $part = ltrim($part, '+');
258
        }
259
260
        return implode(' ', $collationParts);
261
    }
262
263
    /**
264
     * Extract a query string from a Solr spellcheck collation string.
265
     * Useful for constructing 'Did you mean?' links, for example:
266
     * <a href="http://example.com/search?q=$SuggestionQueryString">$SuggestionNice</a>
267
     * @param string $collation
268
     * @return String
269
     */
270
    protected function getSuggestionQueryString($collation = '')
271
    {
272
        return str_replace(' ', '+', $this->getNiceSuggestion($collation));
273
    }
274
275
    /**
276
     * Add a field that should be stored
277
     *
278
     * @param string $field The field to add
279
     * @param string $forceType The type to force this field as (required in some cases, when not
280
     * detectable from metadata)
281
     * @param array $extraOptions Dependent on search implementation
282
     */
283
    public function addStoredField($field, $forceType = null, $extraOptions = array())
284
    {
285
        $options = array_merge($extraOptions, array('stored' => 'true'));
286
        $this->addFulltextField($field, $forceType, $options);
287
    }
288
289
    /**
290
     * Add a fulltext field with a boosted value
291
     *
292
     * @param string $field The field to add
293
     * @param string $forceType The type to force this field as (required in some cases, when not
294
     * detectable from metadata)
295
     * @param array $extraOptions Dependent on search implementation
296
     * @param float $boost Numeric boosting value (defaults to 2)
297
     */
298
    public function addBoostedField($field, $forceType = null, $extraOptions = array(), $boost = 2)
299
    {
300
        $options = array_merge($extraOptions, array('boost' => $boost));
301
        $this->addFulltextField($field, $forceType, $options);
302
    }
303
304
305
    public function fieldData($field, $forceType = null, $extraOptions = array())
306
    {
307
        // Ensure that 'boost' is recorded here without being captured by solr
308
        $boost = null;
309
        if (array_key_exists('boost', $extraOptions)) {
310
            $boost = $extraOptions['boost'];
311
            unset($extraOptions['boost']);
312
        }
313
        $data = parent::fieldData($field, $forceType, $extraOptions);
314
315
        // Boost all fields with this name
316
        if (isset($boost)) {
317
            foreach ($data as $fieldName => $fieldInfo) {
318
                $this->boostedFields[$fieldName] = $boost;
319
            }
320
        }
321
        return $data;
322
    }
323
324
    /**
325
     * Set the default boosting level for a specific field.
326
     * Will control the default value for qf param (Query Fields), but will not
327
     * override a query-specific value.
328
     *
329
     * Fields must be added before having a field boosting specified
330
     *
331
     * @param string $field Full field key (Model_Field)
332
     * @param float|null $level Numeric boosting value. Set to null to clear boost
333
     */
334
    public function setFieldBoosting($field, $level)
335
    {
336
        if (!isset($this->fulltextFields[$field])) {
337
            throw new \InvalidArgumentException("No fulltext field $field exists on ".$this->getIndexName());
338
        }
339
        if ($level === null) {
340
            unset($this->boostedFields[$field]);
341
        } else {
342
            $this->boostedFields[$field] = $level;
343
        }
344
    }
345
346
    /**
347
     * Get all boosted fields
348
     *
349
     * @return array
350
     */
351
    public function getBoostedFields()
352
    {
353
        return $this->boostedFields;
354
    }
355
356
    /**
357
     * Determine the best default value for the 'qf' parameter
358
     *
359
     * @return array|null List of query fields, or null if not specified
360
     */
361
    public function getQueryFields()
362
    {
363
        // Not necessary to specify this unless boosting
364
        if (empty($this->boostedFields)) {
365
            return null;
366
        }
367
        $queryFields = array();
368
        foreach ($this->boostedFields as $fieldName => $boost) {
369
            $queryFields[] = $fieldName . '^' . $boost;
370
        }
371
372
        // If any fields are queried, we must always include the default field, otherwise it will be excluded
373
        $df = $this->getDefaultField();
374
        if ($queryFields && !isset($this->boostedFields[$df])) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $queryFields of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
375
            $queryFields[] = $df;
376
        }
377
378
        return $queryFields;
379
    }
380
381
    /**
382
     * Gets the default 'stored' value for fields in this index
383
     *
384
     * @return string A default value for the 'stored' field option, either 'true' or 'false'
385
     */
386
    protected function getStoredDefault()
387
    {
388
        return Director::isDev() ? 'true' : 'false';
389
    }
390
391
    /**
392
     * @param string $name
393
     * @param Array $spec
394
     * @param Array $typeMap
395
     * @return String XML
396
     */
397
    protected function getFieldDefinition($name, $spec, $typeMap = null)
398
    {
399
        if (!$typeMap) {
400
            $typeMap = self::$filterTypeMap;
401
        }
402
        $multiValued = (isset($spec['multi_valued']) && $spec['multi_valued']) ? "true" : '';
403
        $type = isset($typeMap[$spec['type']]) ? $typeMap[$spec['type']] : $typeMap['*'];
404
405
        $analyzerXml = '';
406
        if (isset($this->analyzerFields[$name])) {
407
            foreach ($this->analyzerFields[$name] as $analyzerType => $analyzerParams) {
408
                $analyzerXml .= $this->toXmlTag($analyzerType, $analyzerParams);
409
            }
410
        }
411
412
        $fieldParams = array_merge(
413
            array(
414
                'name' => $name,
415
                'type' => $type,
416
                'indexed' => 'true',
417
                'stored' => $this->getStoredDefault(),
418
                'multiValued' => $multiValued
419
            ),
420
            isset($spec['extra_options']) ? $spec['extra_options'] : array()
421
        );
422
423
        return $this->toXmlTag(
424
            "field",
425
            $fieldParams,
0 ignored issues
show
Documentation introduced by
$fieldParams is of type array<string,?>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
426
            $analyzerXml ? "<analyzer>$analyzerXml</analyzer>" : null
427
        );
428
    }
429
430
    /**
431
     * Convert definition to XML tag
432
     *
433
     * @param string $tag
434
     * @param string $attrs Map of attributes
435
     * @param string $content Inner content
436
     * @return String XML tag
437
     */
438
    protected function toXmlTag($tag, $attrs, $content = null)
439
    {
440
        $xml = "<$tag ";
441
        if ($attrs) {
442
            $attrStrs = array();
443
            foreach ($attrs as $attrName => $attrVal) {
0 ignored issues
show
Bug introduced by
The expression $attrs of type string is not traversable.
Loading history...
444
                $attrStrs[] = "$attrName='$attrVal'";
445
            }
446
            $xml .= $attrStrs ? implode(' ', $attrStrs) : '';
447
        }
448
        $xml .= $content ? ">$content</$tag>" : '/>';
449
        return $xml;
450
    }
451
452
    /**
453
     * @param string $source Composite field name (<class>_<fieldname>)
454
     * @param string $dest
455
     */
456
    public function addCopyField($source, $dest, $extraOptions = array())
457
    {
458
        if (!isset($this->copyFields[$source])) {
459
            $this->copyFields[$source] = array();
460
        }
461
        $this->copyFields[$source][] = array_merge(
462
            array('source' => $source, 'dest' => $dest),
463
            $extraOptions
464
        );
465
    }
466
467
    /**
468
     * Generate XML for copy field definitions
469
     *
470
     * @return string
471
     */
472
    public function getCopyFieldDefinitions()
473
    {
474
        $xml = array();
475
476
        // Default copy fields
477
        foreach ($this->getCopyDestinations() as $copyTo) {
478
            foreach ($this->fulltextFields as $name => $field) {
479
                $xml[] = "<copyField source='{$name}' dest='{$copyTo}' />";
480
            }
481
        }
482
483
        // Explicit copy fields
484
        foreach ($this->copyFields as $source => $fields) {
485
            foreach ($fields as $fieldAttrs) {
486
                $xml[] = $this->toXmlTag('copyField', $fieldAttrs);
487
            }
488
        }
489
490
        return implode("\n\t", $xml);
491
    }
492
493
    /**
494
     * Determine if the given object is one of the given type
495
     *
496
     * @param string $class
497
     * @param array|string $base Class or list of base classes
498
     * @return bool
499
     */
500
    protected function classIs($class, $base)
501
    {
502
        if (is_array($base)) {
503
            foreach ($base as $nextBase) {
504
                if ($this->classIs($class, $nextBase)) {
505
                    return true;
506
                }
507
            }
508
            return false;
509
        }
510
511
        // Check single origin
512
        return $class === $base || is_subclass_of($class, $base);
0 ignored issues
show
Bug introduced by
Due to PHP Bug #53727, is_subclass_of might return inconsistent results on some PHP versions if $base can be an interface. If so, you could instead use ReflectionClass::implementsInterface.
Loading history...
513
    }
514
515
    protected function _addField($doc, $object, $field)
516
    {
517
        $class = get_class($object);
518
        if (!$this->classIs($class, $field['origin'])) {
519
            return;
520
        }
521
522
        $value = $this->_getFieldValue($object, $field);
523
524
        $type = isset(self::$filterTypeMap[$field['type']]) ? self::$filterTypeMap[$field['type']] : self::$filterTypeMap['*'];
525
526
        if (is_array($value)) {
527
            foreach ($value as $sub) {
528
                /* Solr requires dates in the form 1995-12-31T23:59:59Z */
529 View Code Duplication
                if ($type == 'tdate') {
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...
530
                    if (!$sub) {
531
                        continue;
532
                    }
533
                    $sub = gmdate('Y-m-d\TH:i:s\Z', strtotime($sub));
534
                }
535
536
                /* Solr requires numbers to be valid if presented, not just empty */
537 View Code Duplication
                if (($type == 'tint' || $type == 'tfloat' || $type == 'tdouble') && !is_numeric($sub)) {
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...
538
                    continue;
539
                }
540
541
                $doc->addField($field['name'], $sub);
542
            }
543
        } else {
544
            /* Solr requires dates in the form 1995-12-31T23:59:59Z */
545 View Code Duplication
            if ($type == 'tdate') {
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...
546
                if (!$value) {
547
                    return;
548
                }
549
                $value = gmdate('Y-m-d\TH:i:s\Z', strtotime($value));
550
            }
551
552
            /* Solr requires numbers to be valid if presented, not just empty */
553 View Code Duplication
            if (($type == 'tint' || $type == 'tfloat' || $type == 'tdouble') && !is_numeric($value)) {
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...
554
                return;
555
            }
556
557
            // Only index fields that are not null
558
            if ($value !== null) {
559
                $doc->setField($field['name'], $value);
560
            }
561
        }
562
    }
563
564
    protected function _addAs($object, $base, $options)
565
    {
566
        $includeSubs = $options['include_children'];
567
568
        $doc = new \Apache_Solr_Document();
569
570
        // Always present fields
571
572
        $doc->setField('_documentid', $this->getDocumentID($object, $base, $includeSubs));
573
        $doc->setField('ID', $object->ID);
574
        $doc->setField('ClassName', $object->ClassName);
575
576
        foreach (SearchIntrospection::hierarchy(get_class($object), false) as $class) {
577
            $doc->addField('ClassHierarchy', $class);
578
        }
579
580
        // Add the user-specified fields
581
582
        foreach ($this->getFieldsIterator() as $name => $field) {
583
            if ($field['base'] === $base || (is_array($field['base']) && in_array($base, $field['base']))) {
584
                $this->_addField($doc, $object, $field);
585
            }
586
        }
587
588
        try {
589
            $this->getService()->addDocument($doc);
590
        } catch (Exception $e) {
0 ignored issues
show
Bug introduced by
The class SilverStripe\FullTextSearch\Solr\Exception does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
591
            static::warn($e);
592
            return false;
593
        }
594
595
        return $doc;
596
    }
597
598
    public function add($object)
599
    {
600
        $class = get_class($object);
601
        $docs = array();
602
603
        foreach ($this->getClasses() as $searchclass => $options) {
604
            if ($searchclass == $class || ($options['include_children'] && is_subclass_of($class, $searchclass))) {
0 ignored issues
show
Bug introduced by
Due to PHP Bug #53727, is_subclass_of might return inconsistent results on some PHP versions if $searchclass can be an interface. If so, you could instead use ReflectionClass::implementsInterface.
Loading history...
605
                $base = DataObject::getSchema()->baseDataClass($searchclass);
606
                $docs[] = $this->_addAs($object, $base, $options);
607
            }
608
        }
609
610
        return $docs;
611
    }
612
613
    public function canAdd($class)
614
    {
615
        foreach ($this->classes as $searchclass => $options) {
616
            if ($searchclass == $class || ($options['include_children'] && is_subclass_of($class, $searchclass))) {
0 ignored issues
show
Bug introduced by
Due to PHP Bug #53727, is_subclass_of might return inconsistent results on some PHP versions if $searchclass can be an interface. If so, you could instead use ReflectionClass::implementsInterface.
Loading history...
617
                return true;
618
            }
619
        }
620
621
        return false;
622
    }
623
624
    public function delete($base, $id, $state)
625
    {
626
        $documentID = $this->getDocumentIDForState($base, $id, $state);
627
628
        try {
629
            $this->getService()->deleteById($documentID);
630
        } catch (Exception $e) {
0 ignored issues
show
Bug introduced by
The class SilverStripe\FullTextSearch\Solr\Exception does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
631
            static::warn($e);
632
            return false;
633
        }
634
    }
635
636
    /**
637
     * Clear all records which do not match the given classname whitelist.
638
     *
639
     * Can also be used to trim an index when reducing to a narrower set of classes.
640
     *
641
     * Ignores current state / variant.
642
     *
643
     * @param array $classes List of non-obsolete classes in the same format as SolrIndex::getClasses()
644
     * @return bool Flag if successful
645
     */
646
    public function clearObsoleteClasses($classes)
647
    {
648
        if (empty($classes)) {
649
            return false;
650
        }
651
652
        // Delete all records which do not match the necessary classname rules
653
        $conditions = array();
654
        foreach ($classes as $class => $options) {
655
            if ($options['include_children']) {
656
                $conditions[] = "ClassHierarchy:{$class}";
657
            } else {
658
                $conditions[] = "ClassName:{$class}";
659
            }
660
        }
661
662
        // Delete records which don't match any of these conditions in this index
663
        $deleteQuery = "-(" . implode(' ', $conditions) . ")";
664
        $this
665
            ->getService()
666
            ->deleteByQuery($deleteQuery);
667
        return true;
668
    }
669
670
    public function commit()
671
    {
672
        try {
673
            $this->getService()->commit(false, false, false);
674
        } catch (Exception $e) {
0 ignored issues
show
Bug introduced by
The class SilverStripe\FullTextSearch\Solr\Exception does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
675
            static::warn($e);
676
            return false;
677
        }
678
    }
679
680
    /**
681
     * @param SearchQuery $query
682
     * @param integer $offset
683
     * @param integer $limit
684
     * @param array $params Extra request parameters passed through to Solr
685
     * @return ArrayData Map with the following keys:
686
     *  - 'Matches': ArrayList of the matched object instances
687
     */
688
    public function search(SearchQuery $query, $offset = -1, $limit = -1, $params = array())
689
    {
690
        $service = $this->getService();
691
        $this->applySearchVariants($query);
692
693
        $q = array(); // Query
0 ignored issues
show
Unused Code introduced by
$q is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
694
        $fq = array(); // Filter query
695
        $qf = array(); // Query fields
0 ignored issues
show
Unused Code introduced by
$qf is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
696
        $hlq = array(); // Highlight query
697
698
        // Build the search itself
699
        $q = $this->getQueryComponent($query, $hlq);
700
701
        // If using boosting, set the clean term separately for highlighting.
702
        // See https://issues.apache.org/jira/browse/SOLR-2632
703
        if (array_key_exists('hl', $params) && !array_key_exists('hl.q', $params)) {
704
            $params['hl.q'] = implode(' ', $hlq);
705
        }
706
707
        // Filter by class if requested
708
        $classq = array();
709
        foreach ($query->classes as $class) {
710
            if (!empty($class['includeSubclasses'])) {
711
                $classq[] = 'ClassHierarchy:' . $this->sanitiseClassName($class['class']);
712
            } else {
713
                $classq[] = 'ClassName:' . $this->sanitiseClassName($class['class']);
714
            }
715
        }
716
        if ($classq) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $classq of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
717
            $fq[] = '+('.implode(' ', $classq).')';
718
        }
719
720
        // Filter by filters
721
        $fq = array_merge($fq, $this->getFiltersComponent($query));
722
723
        // Prepare query fields unless specified explicitly
724
        if (isset($params['qf'])) {
725
            $qf = $params['qf'];
726
        } else {
727
            $qf = $this->getQueryFields();
728
        }
729
        if (is_array($qf)) {
730
            $qf = implode(' ', $qf);
731
        }
732
        if ($qf) {
733
            $params['qf'] = $qf;
734
        }
735
736
        if (!headers_sent() && Director::isDev()) {
737
            if ($q) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $q of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
738
                header('X-Query: '.implode(' ', $q));
739
            }
740
            if ($fq) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $fq of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
741
                header('X-Filters: "'.implode('", "', $fq).'"');
742
            }
743
            if ($qf) {
744
                header('X-QueryFields: '.$qf);
745
            }
746
        }
747
748
        if ($offset == -1) {
749
            $offset = $query->start;
0 ignored issues
show
Documentation introduced by
The property $start is declared protected in SilverStripe\FullTextSea...rch\Queries\SearchQuery. Since you implemented __get(), maybe consider adding a @property or @property-read annotation. This makes it easier for IDEs to provide auto-completion.

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...
750
        }
751
        if ($limit == -1) {
752
            $limit = $query->limit;
0 ignored issues
show
Documentation introduced by
The property $limit is declared protected in SilverStripe\FullTextSea...rch\Queries\SearchQuery. Since you implemented __get(), maybe consider adding a @property or @property-read annotation. This makes it easier for IDEs to provide auto-completion.

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...
753
        }
754
        if ($limit == -1) {
755
            $limit = SearchQuery::$default_page_size;
756
        }
757
758
        $params = array_merge($params, array('fq' => implode(' ', $fq)));
759
760
        $res = $service->search(
761
            $q ? implode(' ', $q) : '*:*',
762
            $offset,
763
            $limit,
764
            $params,
765
            \Apache_Solr_Service::METHOD_POST
766
        );
767
768
        $results = new ArrayList();
769
        if ($res->getHttpStatus() >= 200 && $res->getHttpStatus() < 300) {
770
            foreach ($res->response->docs as $doc) {
0 ignored issues
show
Bug introduced by
The property response does not seem to exist. Did you mean _response?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
771
                $result = DataObject::get_by_id($doc->ClassName, $doc->ID);
772
                if ($result) {
773
                    $results->push($result);
774
775
                    // Add highlighting (optional)
776
                    $docId = $doc->_documentid;
777
                    if ($res->highlighting && $res->highlighting->$docId) {
778
                        // TODO Create decorator class for search results rather than adding arbitrary object properties
779
                        // TODO Allow specifying highlighted field, and lazy loading
780
                        // in case the search API needs another query (similar to SphinxSearchable->buildExcerpt()).
781
                        $combinedHighlights = array();
782
                        foreach ($res->highlighting->$docId as $field => $highlights) {
783
                            $combinedHighlights = array_merge($combinedHighlights, $highlights);
784
                        }
785
786
                        // Remove entity-encoded U+FFFD replacement character. It signifies non-displayable characters,
787
                        // and shows up as an encoding error in browsers.
788
                        $result->Excerpt = DBField::create_field(
789
                            'HTMLText',
790
                            str_replace(
791
                                '&#65533;',
792
                                '',
793
                                implode(' ... ', $combinedHighlights)
794
                            )
795
                        );
796
                    }
797
                }
798
            }
799
            $numFound = $res->response->numFound;
0 ignored issues
show
Bug introduced by
The property response does not seem to exist. Did you mean _response?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
800
        } else {
801
            $numFound = 0;
802
        }
803
804
        $ret = array();
805
        $ret['Matches'] = new PaginatedList($results);
806
        $ret['Matches']->setLimitItems(false);
807
        // Tell PaginatedList how many results there are
808
        $ret['Matches']->setTotalItems($numFound);
809
        // Results for current page start at $offset
810
        $ret['Matches']->setPageStart($offset);
811
        // Results per page
812
        $ret['Matches']->setPageLength($limit);
813
814
        // Include spellcheck and suggestion data. Requires spellcheck=true in $params
815
        if (isset($res->spellcheck)) {
816
            // Expose all spellcheck data, for custom handling.
817
            $ret['Spellcheck'] = $res->spellcheck;
0 ignored issues
show
Bug introduced by
The property spellcheck does not seem to exist in Apache_Solr_Response.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
818
819
            // Suggestions. Requires spellcheck.collate=true in $params
820
            if (isset($res->spellcheck->suggestions->collation)) {
821
                // Extract string suggestion
822
                $suggestion = $this->getCollatedSuggestion($res->spellcheck->suggestions->collation);
823
824
                // The collation, including advanced query params (e.g. +), suitable for making another query
825
                // programmatically.
826
                $ret['Suggestion'] = $suggestion;
827
828
                // A human friendly version of the suggestion, suitable for 'Did you mean $SuggestionNice?' display.
829
                $ret['SuggestionNice'] = $this->getNiceSuggestion($suggestion);
830
831
                // A string suitable for appending to an href as a query string.
832
                // For example <a href="http://example.com/search?q=$SuggestionQueryString">$SuggestionNice</a>
833
                $ret['SuggestionQueryString'] = $this->getSuggestionQueryString($suggestion);
834
            }
835
        }
836
837
        $ret = new ArrayData($ret);
838
839
        // Enable extensions to add extra data from the response into
840
        // the returned results set.
841
        $this->extend('updateSearchResults', $ret, $res);
842
843
        return $ret;
844
    }
845
846
    /**
847
     * With a common set of variants that are relevant to at least one class in the list (from either the query or
848
     * the current index), allow them to alter the query to add their variant column conditions.
849
     *
850
     * @param SearchQuery $query
851
     */
852
    protected function applySearchVariants(SearchQuery $query)
853
    {
854
        $classes = count($query->classes) ? $query->classes : $this->getClasses();
855
856
        /** @var SearchVariant_Caller $variantCaller */
857
        $variantCaller = SearchVariant::withCommon($classes);
858
        $variantCaller->call('alterQuery', $query, $this);
859
    }
860
861
    /**
862
     * Solr requires namespaced classes to have double escaped backslashes
863
     *
864
     * @param  string $className   E.g. My\Object\Here
865
     * @param  string $replaceWith The replacement character(s) to use
866
     * @return string              E.g. My\\Object\\Here
867
     */
868
    public function sanitiseClassName($className, $replaceWith = '\\\\')
869
    {
870
        return str_replace('\\', $replaceWith, $className);
871
    }
872
873
    /**
874
     * Get the query (q) component for this search
875
     *
876
     * @param SearchQuery $searchQuery
877
     * @param array &$hlq Highlight query returned by reference
878
     * @return array
879
     */
880
    protected function getQueryComponent(SearchQuery $searchQuery, &$hlq = array())
881
    {
882
        $q = array();
883
        foreach ($searchQuery->search as $search) {
884
            $text = $search['text'];
885
            preg_match_all('/"[^"]*"|\S+/', $text, $parts);
886
887
            $fuzzy = $search['fuzzy'] ? '~' : '';
888
889
            foreach ($parts[0] as $part) {
890
                $fields = (isset($search['fields'])) ? $search['fields'] : array();
891
                if (isset($search['boost'])) {
892
                    $fields = array_merge($fields, array_keys($search['boost']));
893
                }
894
                if ($fields) {
895
                    $searchq = array();
896
                    foreach ($fields as $field) {
897
                        // Escape namespace separators in class names
898
                        $field = $this->sanitiseClassName($field);
899
900
                        $boost = (isset($search['boost'][$field])) ? '^' . $search['boost'][$field] : '';
901
                        $searchq[] = "{$field}:".$part.$fuzzy.$boost;
902
                    }
903
                    $q[] = '+('.implode(' OR ', $searchq).')';
904
                } else {
905
                    $q[] = '+'.$part.$fuzzy;
906
                }
907
                $hlq[] = $part;
908
            }
909
        }
910
        return $q;
911
    }
912
913
    /**
914
     * Parse all require constraints for inclusion in a filter query
915
     *
916
     * @param SearchQuery $searchQuery
917
     * @return array List of parsed string values for each require
918
     */
919
    protected function getRequireFiltersComponent(SearchQuery $searchQuery)
920
    {
921
        $fq = array();
922
        foreach ($searchQuery->require as $field => $values) {
923
            $requireq = array();
924
925 View Code Duplication
            foreach ($values as $value) {
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...
926
                if ($value === SearchQuery::$missing) {
927
                    $requireq[] = "(*:* -{$field}:[* TO *])";
928
                } elseif ($value === SearchQuery::$present) {
929
                    $requireq[] = "{$field}:[* TO *]";
930
                } elseif ($value instanceof SearchQuery_Range) {
931
                    $start = $value->start;
932
                    if ($start === null) {
933
                        $start = '*';
934
                    }
935
                    $end = $value->end;
936
                    if ($end === null) {
937
                        $end = '*';
938
                    }
939
                    $requireq[] = "$field:[$start TO $end]";
940
                } else {
941
                    $requireq[] = $field.':"'.$value.'"';
942
                }
943
            }
944
945
            $fq[] = '+('.implode(' ', $requireq).')';
946
        }
947
        return $fq;
948
    }
949
950
    /**
951
     * Parse all exclude constraints for inclusion in a filter query
952
     *
953
     * @param SearchQuery $searchQuery
954
     * @return array List of parsed string values for each exclusion
955
     */
956
    protected function getExcludeFiltersComponent(SearchQuery $searchQuery)
957
    {
958
        $fq = array();
959
        foreach ($searchQuery->exclude as $field => $values) {
960
            // Handle namespaced class names
961
            $field = $this->sanitiseClassName($field);
962
963
            $excludeq = [];
964
            $missing = false;
965
966 View Code Duplication
            foreach ($values as $value) {
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...
967
                if ($value === SearchQuery::$missing) {
968
                    $missing = true;
969
                } elseif ($value === SearchQuery::$present) {
970
                    $excludeq[] = "{$field}:[* TO *]";
971
                } elseif ($value instanceof SearchQuery_Range) {
972
                    $start = $value->start;
973
                    if ($start === null) {
974
                        $start = '*';
975
                    }
976
                    $end = $value->end;
977
                    if ($end === null) {
978
                        $end = '*';
979
                    }
980
                    $excludeq[] = "$field:[$start TO $end]";
981
                } else {
982
                    $excludeq[] = $field.':"'.$value.'"';
983
                }
984
            }
985
986
            $fq[] = ($missing ? "+{$field}:[* TO *] " : '') . '-('.implode(' ', $excludeq).')';
987
        }
988
        return $fq;
989
    }
990
991
    /**
992
     * Get all filter conditions for this search
993
     *
994
     * @param SearchQuery $searchQuery
995
     * @return array
996
     */
997
    public function getFiltersComponent(SearchQuery $searchQuery)
998
    {
999
        return array_merge(
1000
            $this->getRequireFiltersComponent($searchQuery),
1001
            $this->getExcludeFiltersComponent($searchQuery)
1002
        );
1003
    }
1004
1005
    protected $service;
1006
1007
    /**
1008
     * @return SolrService
1009
     */
1010
    public function getService()
1011
    {
1012
        if (!$this->service) {
1013
            $this->service = Solr::service(get_class($this));
1014
        }
1015
        return $this->service;
1016
    }
1017
1018
    public function setService(SolrService $service)
1019
    {
1020
        $this->service = $service;
1021
        return $this;
1022
    }
1023
1024
    /**
1025
     * Upload config for this index to the given store
1026
     *
1027
     * @param SolrConfigStore $store
1028
     */
1029
    public function uploadConfig($store)
1030
    {
1031
        // Upload the config files for this index
1032
        $store->uploadString(
1033
            $this->getIndexName(),
1034
            'schema.xml',
1035
            (string)$this->generateSchema()
1036
        );
1037
1038
        // Upload additional files
1039
        foreach (glob($this->getExtrasPath().'/*') as $file) {
1040
            if (is_file($file)) {
1041
                $store->uploadFile($this->getIndexName(), $file);
1042
            }
1043
        }
1044
    }
1045
}
1046