Completed
Push — master ( 415fde...48f3df )
by
unknown
11s
created

SolrIndex::applySearchVariants()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 8
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 4
nc 2
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
    /**
128
     * Helper for returning the indexer class name from an index name, encoded via {@link getIndexName()}
129
     *
130
     * @param string $indexName
131
     * @return string
132
     */
133
    public static function getClassNameFromIndex($indexName)
134
    {
135 View Code Duplication
        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...
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...
136
            && (substr($indexName, 0, strlen($indexPrefix)) === $indexPrefix)
137
        ) {
138
            $indexName = substr($indexName, strlen($indexPrefix));
139
        }
140
141 View Code Duplication
        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...
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...
142
            && (substr($indexName, -strlen($indexSuffix)) === $indexSuffix)
143
        ) {
144
            $indexName = substr($indexName, 0, -strlen($indexSuffix));
145
        }
146
147
        return str_replace('-', '\\', $indexName);
148
    }
149
150
    public function getTypes()
151
    {
152
        return $this->renderWith($this->getTemplatesPath() . '/types.ss');
153
    }
154
155
    /**
156
     * Index-time analyzer which is applied to a specific field.
157
     * Can be used to remove HTML tags, apply stemming, etc.
158
     *
159
     * @see http://wiki.apache.org/solr/AnalyzersTokenizersTokenFilters#solr.WhitespaceTokenizerFactory
160
     *
161
     * @param string $field
162
     * @param string $type
163
     * @param Array $params Parameters for the analyzer, usually at least a "class"
164
     */
165
    public function addAnalyzer($field, $type, $params)
166
    {
167
        $fullFields = $this->fieldData($field);
168
        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...
169
            foreach ($fullFields as $fullField => $spec) {
170
                if (!isset($this->analyzerFields[$fullField])) {
171
                    $this->analyzerFields[$fullField] = array();
172
                }
173
                $this->analyzerFields[$fullField][$type] = $params;
174
            }
175
        }
176
    }
177
178
    /**
179
     * Get the default text field, normally '_text'
180
     *
181
     * @return string
182
     */
183
    public function getDefaultField()
184
    {
185
        return $this->config()->default_field;
186
    }
187
188
    /**
189
     * Get list of fields each text field should be copied into.
190
     * This will fallback to the default field if omitted.
191
     *
192
     * @return array
193
     */
194
    protected function getCopyDestinations()
195
    {
196
        $copyFields = $this->config()->copy_fields;
197
        if ($copyFields) {
198
            return $copyFields;
199
        }
200
        // Fallback to default field
201
        $df = $this->getDefaultField();
202
        return array($df);
203
    }
204
205
    public function getFieldDefinitions()
206
    {
207
        $xml = array();
208
        $stored = $this->getStoredDefault();
209
210
        $xml[] = "";
211
212
        // Add the hardcoded field definitions
213
214
        $xml[] = "<field name='_documentid' type='string' indexed='true' stored='true' required='true' />";
215
216
        $xml[] = "<field name='ID' type='tint' indexed='true' stored='true' required='true' />";
217
        $xml[] = "<field name='ClassName' type='string' indexed='true' stored='true' required='true' />";
218
        $xml[] = "<field name='ClassHierarchy' type='string' indexed='true' stored='true' required='true' multiValued='true' />";
219
220
        // Add the fulltext collation field
221
222
        $df = $this->getDefaultField();
223
        $xml[] = "<field name='{$df}' type='htmltext' indexed='true' stored='{$stored}' multiValued='true' />" ;
224
225
        // Add the user-specified fields
226
227
        foreach ($this->fulltextFields as $name => $field) {
228
            $xml[] = $this->getFieldDefinition($name, $field, self::$fulltextTypeMap);
229
        }
230
231 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...
232
            if ($field['fullfield'] == 'ID' || $field['fullfield'] == 'ClassName') {
233
                continue;
234
            }
235
            $xml[] = $this->getFieldDefinition($name, $field);
236
        }
237
238 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...
239
            if ($field['fullfield'] == 'ID' || $field['fullfield'] == 'ClassName') {
240
                continue;
241
            }
242
            $xml[] = $this->getFieldDefinition($name, $field);
243
        }
244
245
        return implode("\n\t\t", $xml);
246
    }
247
248
    /**
249
     * Extract first suggestion text from collated values
250
     *
251
     * @param mixed $collation
252
     * @return string
253
     */
254
    protected function getCollatedSuggestion($collation = '')
255
    {
256
        if (is_string($collation)) {
257
            return $collation;
258
        }
259
        if (is_object($collation)) {
260
            if (isset($collation->misspellingsAndCorrections)) {
261
                foreach ($collation->misspellingsAndCorrections as $key => $value) {
262
                    return $value;
263
                }
264
            }
265
        }
266
        return '';
267
    }
268
269
    /**
270
     * Extract a human friendly spelling suggestion from a Solr spellcheck collation string.
271
     * @param string $collation
272
     * @return String
273
     */
274
    protected function getNiceSuggestion($collation = '')
275
    {
276
        $collationParts = explode(' ', $collation);
277
278
        // Remove advanced query params from the beginning of each collation part.
279
        foreach ($collationParts as $key => &$part) {
280
            $part = ltrim($part, '+');
281
        }
282
283
        return implode(' ', $collationParts);
284
    }
285
286
    /**
287
     * Extract a query string from a Solr spellcheck collation string.
288
     * Useful for constructing 'Did you mean?' links, for example:
289
     * <a href="http://example.com/search?q=$SuggestionQueryString">$SuggestionNice</a>
290
     * @param string $collation
291
     * @return String
292
     */
293
    protected function getSuggestionQueryString($collation = '')
294
    {
295
        return str_replace(' ', '+', $this->getNiceSuggestion($collation));
296
    }
297
298
    /**
299
     * Add a field that should be stored
300
     *
301
     * @param string $field The field to add
302
     * @param string $forceType The type to force this field as (required in some cases, when not
303
     * detectable from metadata)
304
     * @param array $extraOptions Dependent on search implementation
305
     */
306
    public function addStoredField($field, $forceType = null, $extraOptions = array())
307
    {
308
        $options = array_merge($extraOptions, array('stored' => 'true'));
309
        $this->addFulltextField($field, $forceType, $options);
310
    }
311
312
    /**
313
     * Add a fulltext field with a boosted value
314
     *
315
     * @param string $field The field to add
316
     * @param string $forceType The type to force this field as (required in some cases, when not
317
     * detectable from metadata)
318
     * @param array $extraOptions Dependent on search implementation
319
     * @param float $boost Numeric boosting value (defaults to 2)
320
     */
321
    public function addBoostedField($field, $forceType = null, $extraOptions = array(), $boost = 2)
322
    {
323
        $options = array_merge($extraOptions, array('boost' => $boost));
324
        $this->addFulltextField($field, $forceType, $options);
325
    }
326
327
328
    public function fieldData($field, $forceType = null, $extraOptions = array())
329
    {
330
        // Ensure that 'boost' is recorded here without being captured by solr
331
        $boost = null;
332
        if (array_key_exists('boost', $extraOptions)) {
333
            $boost = $extraOptions['boost'];
334
            unset($extraOptions['boost']);
335
        }
336
        $data = parent::fieldData($field, $forceType, $extraOptions);
337
338
        // Boost all fields with this name
339
        if (isset($boost)) {
340
            foreach ($data as $fieldName => $fieldInfo) {
341
                $this->boostedFields[$fieldName] = $boost;
342
            }
343
        }
344
        return $data;
345
    }
346
347
    /**
348
     * Set the default boosting level for a specific field.
349
     * Will control the default value for qf param (Query Fields), but will not
350
     * override a query-specific value.
351
     *
352
     * Fields must be added before having a field boosting specified
353
     *
354
     * @param string $field Full field key (Model_Field)
355
     * @param float|null $level Numeric boosting value. Set to null to clear boost
356
     */
357
    public function setFieldBoosting($field, $level)
358
    {
359
        if (!isset($this->fulltextFields[$field])) {
360
            throw new \InvalidArgumentException("No fulltext field $field exists on ".$this->getIndexName());
361
        }
362
        if ($level === null) {
363
            unset($this->boostedFields[$field]);
364
        } else {
365
            $this->boostedFields[$field] = $level;
366
        }
367
    }
368
369
    /**
370
     * Get all boosted fields
371
     *
372
     * @return array
373
     */
374
    public function getBoostedFields()
375
    {
376
        return $this->boostedFields;
377
    }
378
379
    /**
380
     * Determine the best default value for the 'qf' parameter
381
     *
382
     * @return array|null List of query fields, or null if not specified
383
     */
384
    public function getQueryFields()
385
    {
386
        // Not necessary to specify this unless boosting
387
        if (empty($this->boostedFields)) {
388
            return null;
389
        }
390
        $queryFields = array();
391
        foreach ($this->boostedFields as $fieldName => $boost) {
392
            $queryFields[] = $fieldName . '^' . $boost;
393
        }
394
395
        // If any fields are queried, we must always include the default field, otherwise it will be excluded
396
        $df = $this->getDefaultField();
397
        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...
398
            $queryFields[] = $df;
399
        }
400
401
        return $queryFields;
402
    }
403
404
    /**
405
     * Gets the default 'stored' value for fields in this index
406
     *
407
     * @return string A default value for the 'stored' field option, either 'true' or 'false'
408
     */
409
    protected function getStoredDefault()
410
    {
411
        return Director::isDev() ? 'true' : 'false';
412
    }
413
414
    /**
415
     * @param string $name
416
     * @param Array $spec
417
     * @param Array $typeMap
418
     * @return String XML
419
     */
420
    protected function getFieldDefinition($name, $spec, $typeMap = null)
421
    {
422
        if (!$typeMap) {
423
            $typeMap = self::$filterTypeMap;
424
        }
425
        $multiValued = (isset($spec['multi_valued']) && $spec['multi_valued']) ? "true" : '';
426
        $type = isset($typeMap[$spec['type']]) ? $typeMap[$spec['type']] : $typeMap['*'];
427
428
        $analyzerXml = '';
429
        if (isset($this->analyzerFields[$name])) {
430
            foreach ($this->analyzerFields[$name] as $analyzerType => $analyzerParams) {
431
                $analyzerXml .= $this->toXmlTag($analyzerType, $analyzerParams);
432
            }
433
        }
434
435
        $fieldParams = array_merge(
436
            array(
437
                'name' => $name,
438
                'type' => $type,
439
                'indexed' => 'true',
440
                'stored' => $this->getStoredDefault(),
441
                'multiValued' => $multiValued
442
            ),
443
            isset($spec['extra_options']) ? $spec['extra_options'] : array()
444
        );
445
446
        return $this->toXmlTag(
447
            "field",
448
            $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...
449
            $analyzerXml ? "<analyzer>$analyzerXml</analyzer>" : null
450
        );
451
    }
452
453
    /**
454
     * Convert definition to XML tag
455
     *
456
     * @param string $tag
457
     * @param string $attrs Map of attributes
458
     * @param string $content Inner content
459
     * @return String XML tag
460
     */
461
    protected function toXmlTag($tag, $attrs, $content = null)
462
    {
463
        $xml = "<$tag ";
464
        if ($attrs) {
465
            $attrStrs = array();
466
            foreach ($attrs as $attrName => $attrVal) {
0 ignored issues
show
Bug introduced by
The expression $attrs of type string is not traversable.
Loading history...
467
                $attrStrs[] = "$attrName='$attrVal'";
468
            }
469
            $xml .= $attrStrs ? implode(' ', $attrStrs) : '';
470
        }
471
        $xml .= $content ? ">$content</$tag>" : '/>';
472
        return $xml;
473
    }
474
475
    /**
476
     * @param string $source Composite field name (<class>_<fieldname>)
477
     * @param string $dest
478
     */
479
    public function addCopyField($source, $dest, $extraOptions = array())
480
    {
481
        if (!isset($this->copyFields[$source])) {
482
            $this->copyFields[$source] = array();
483
        }
484
        $this->copyFields[$source][] = array_merge(
485
            array('source' => $source, 'dest' => $dest),
486
            $extraOptions
487
        );
488
    }
489
490
    /**
491
     * Generate XML for copy field definitions
492
     *
493
     * @return string
494
     */
495
    public function getCopyFieldDefinitions()
496
    {
497
        $xml = array();
498
499
        // Default copy fields
500
        foreach ($this->getCopyDestinations() as $copyTo) {
501
            foreach ($this->fulltextFields as $name => $field) {
502
                $xml[] = "<copyField source='{$name}' dest='{$copyTo}' />";
503
            }
504
        }
505
506
        // Explicit copy fields
507
        foreach ($this->copyFields as $source => $fields) {
508
            foreach ($fields as $fieldAttrs) {
509
                $xml[] = $this->toXmlTag('copyField', $fieldAttrs);
510
            }
511
        }
512
513
        return implode("\n\t", $xml);
514
    }
515
516
    /**
517
     * Determine if the given object is one of the given type
518
     *
519
     * @param string $class
520
     * @param array|string $base Class or list of base classes
521
     * @return bool
522
     */
523
    protected function classIs($class, $base)
524
    {
525
        if (is_array($base)) {
526
            foreach ($base as $nextBase) {
527
                if ($this->classIs($class, $nextBase)) {
528
                    return true;
529
                }
530
            }
531
            return false;
532
        }
533
534
        // Check single origin
535
        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...
536
    }
537
538
    protected function _addField($doc, $object, $field)
539
    {
540
        $class = get_class($object);
541
        if (!$this->classIs($class, $field['origin'])) {
542
            return;
543
        }
544
545
        $value = $this->_getFieldValue($object, $field);
546
547
        $type = isset(self::$filterTypeMap[$field['type']]) ? self::$filterTypeMap[$field['type']] : self::$filterTypeMap['*'];
548
549
        if (is_array($value)) {
550
            foreach ($value as $sub) {
551
                /* Solr requires dates in the form 1995-12-31T23:59:59Z */
552 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...
553
                    if (!$sub) {
554
                        continue;
555
                    }
556
                    $sub = gmdate('Y-m-d\TH:i:s\Z', strtotime($sub));
557
                }
558
559
                /* Solr requires numbers to be valid if presented, not just empty */
560 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...
561
                    continue;
562
                }
563
564
                $doc->addField($field['name'], $sub);
565
            }
566
        } else {
567
            /* Solr requires dates in the form 1995-12-31T23:59:59Z */
568 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...
569
                if (!$value) {
570
                    return;
571
                }
572
                $value = gmdate('Y-m-d\TH:i:s\Z', strtotime($value));
573
            }
574
575
            /* Solr requires numbers to be valid if presented, not just empty */
576 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...
577
                return;
578
            }
579
580
            // Only index fields that are not null
581
            if ($value !== null) {
582
                $doc->setField($field['name'], $value);
583
            }
584
        }
585
    }
586
587
    protected function _addAs($object, $base, $options)
588
    {
589
        $includeSubs = $options['include_children'];
590
591
        $doc = new \Apache_Solr_Document();
592
593
        // Always present fields
594
595
        $doc->setField('_documentid', $this->getDocumentID($object, $base, $includeSubs));
596
        $doc->setField('ID', $object->ID);
597
        $doc->setField('ClassName', $object->ClassName);
598
599
        foreach (SearchIntrospection::hierarchy(get_class($object), false) as $class) {
600
            $doc->addField('ClassHierarchy', $class);
601
        }
602
603
        // Add the user-specified fields
604
605
        foreach ($this->getFieldsIterator() as $name => $field) {
606
            if ($field['base'] === $base || (is_array($field['base']) && in_array($base, $field['base']))) {
607
                $this->_addField($doc, $object, $field);
608
            }
609
        }
610
611
        try {
612
            $this->getService()->addDocument($doc);
613
        } 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...
614
            static::warn($e);
615
            return false;
616
        }
617
618
        return $doc;
619
    }
620
621
    public function add($object)
622
    {
623
        $class = get_class($object);
624
        $docs = array();
625
626
        foreach ($this->getClasses() as $searchclass => $options) {
627
            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...
628
                $base = DataObject::getSchema()->baseDataClass($searchclass);
629
                $docs[] = $this->_addAs($object, $base, $options);
630
            }
631
        }
632
633
        return $docs;
634
    }
635
636
    public function canAdd($class)
637
    {
638
        foreach ($this->classes as $searchclass => $options) {
639
            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...
640
                return true;
641
            }
642
        }
643
644
        return false;
645
    }
646
647
    public function delete($base, $id, $state)
648
    {
649
        $documentID = $this->getDocumentIDForState($base, $id, $state);
650
651
        try {
652
            $this->getService()->deleteById($documentID);
653
        } 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...
654
            static::warn($e);
655
            return false;
656
        }
657
    }
658
659
    /**
660
     * Clear all records which do not match the given classname whitelist.
661
     *
662
     * Can also be used to trim an index when reducing to a narrower set of classes.
663
     *
664
     * Ignores current state / variant.
665
     *
666
     * @param array $classes List of non-obsolete classes in the same format as SolrIndex::getClasses()
667
     * @return bool Flag if successful
668
     */
669
    public function clearObsoleteClasses($classes)
670
    {
671
        if (empty($classes)) {
672
            return false;
673
        }
674
675
        // Delete all records which do not match the necessary classname rules
676
        $conditions = array();
677
        foreach ($classes as $class => $options) {
678
            if ($options['include_children']) {
679
                $conditions[] = "ClassHierarchy:{$class}";
680
            } else {
681
                $conditions[] = "ClassName:{$class}";
682
            }
683
        }
684
685
        // Delete records which don't match any of these conditions in this index
686
        $deleteQuery = "-(" . implode(' ', $conditions) . ")";
687
        $this
688
            ->getService()
689
            ->deleteByQuery($deleteQuery);
690
        return true;
691
    }
692
693
    public function commit()
694
    {
695
        try {
696
            $this->getService()->commit(false, false, false);
697
        } 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...
698
            static::warn($e);
699
            return false;
700
        }
701
    }
702
703
    /**
704
     * @param SearchQuery $query
705
     * @param integer $offset
706
     * @param integer $limit
707
     * @param array $params Extra request parameters passed through to Solr
708
     * @return ArrayData Map with the following keys:
709
     *  - 'Matches': ArrayList of the matched object instances
710
     */
711
    public function search(SearchQuery $query, $offset = -1, $limit = -1, $params = array())
712
    {
713
        $service = $this->getService();
714
        $this->applySearchVariants($query);
715
716
        $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...
717
        $fq = array(); // Filter query
718
        $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...
719
        $hlq = array(); // Highlight query
720
721
        // Build the search itself
722
        $q = $this->getQueryComponent($query, $hlq);
723
724
        // If using boosting, set the clean term separately for highlighting.
725
        // See https://issues.apache.org/jira/browse/SOLR-2632
726
        if (array_key_exists('hl', $params) && !array_key_exists('hl.q', $params)) {
727
            $params['hl.q'] = implode(' ', $hlq);
728
        }
729
730
        // Filter by class if requested
731
        $classq = array();
732
        foreach ($query->classes as $class) {
733
            if (!empty($class['includeSubclasses'])) {
734
                $classq[] = 'ClassHierarchy:' . $this->sanitiseClassName($class['class']);
735
            } else {
736
                $classq[] = 'ClassName:' . $this->sanitiseClassName($class['class']);
737
            }
738
        }
739
        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...
740
            $fq[] = '+('.implode(' ', $classq).')';
741
        }
742
743
        // Filter by filters
744
        $fq = array_merge($fq, $this->getFiltersComponent($query));
745
746
        // Prepare query fields unless specified explicitly
747
        if (isset($params['qf'])) {
748
            $qf = $params['qf'];
749
        } else {
750
            $qf = $this->getQueryFields();
751
        }
752
        if (is_array($qf)) {
753
            $qf = implode(' ', $qf);
754
        }
755
        if ($qf) {
756
            $params['qf'] = $qf;
757
        }
758
759
        if (!headers_sent() && Director::isDev()) {
760
            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...
761
                header('X-Query: '.implode(' ', $q));
762
            }
763
            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...
764
                header('X-Filters: "'.implode('", "', $fq).'"');
765
            }
766
            if ($qf) {
767
                header('X-QueryFields: '.$qf);
768
            }
769
        }
770
771
        if ($offset == -1) {
772
            $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...
773
        }
774
        if ($limit == -1) {
775
            $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...
776
        }
777
        if ($limit == -1) {
778
            $limit = SearchQuery::$default_page_size;
779
        }
780
781
        $params = array_merge($params, array('fq' => implode(' ', $fq)));
782
783
        $res = $service->search(
784
            $q ? implode(' ', $q) : '*:*',
785
            $offset,
786
            $limit,
787
            $params,
788
            \Apache_Solr_Service::METHOD_POST
789
        );
790
791
        $results = new ArrayList();
792
        if ($res->getHttpStatus() >= 200 && $res->getHttpStatus() < 300) {
793
            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...
794
                $result = DataObject::get_by_id($doc->ClassName, $doc->ID);
795
                if ($result) {
796
                    $results->push($result);
797
798
                    // Add highlighting (optional)
799
                    $docId = $doc->_documentid;
800
                    if ($res->highlighting && $res->highlighting->$docId) {
801
                        // TODO Create decorator class for search results rather than adding arbitrary object properties
802
                        // TODO Allow specifying highlighted field, and lazy loading
803
                        // in case the search API needs another query (similar to SphinxSearchable->buildExcerpt()).
804
                        $combinedHighlights = array();
805
                        foreach ($res->highlighting->$docId as $field => $highlights) {
806
                            $combinedHighlights = array_merge($combinedHighlights, $highlights);
807
                        }
808
809
                        // Remove entity-encoded U+FFFD replacement character. It signifies non-displayable characters,
810
                        // and shows up as an encoding error in browsers.
811
                        $result->Excerpt = DBField::create_field(
812
                            'HTMLText',
813
                            str_replace(
814
                                '&#65533;',
815
                                '',
816
                                implode(' ... ', $combinedHighlights)
817
                            )
818
                        );
819
                    }
820
                }
821
            }
822
            $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...
823
        } else {
824
            $numFound = 0;
825
        }
826
827
        $ret = array();
828
        $ret['Matches'] = new PaginatedList($results);
829
        $ret['Matches']->setLimitItems(false);
830
        // Tell PaginatedList how many results there are
831
        $ret['Matches']->setTotalItems($numFound);
832
        // Results for current page start at $offset
833
        $ret['Matches']->setPageStart($offset);
834
        // Results per page
835
        $ret['Matches']->setPageLength($limit);
836
837
        // Include spellcheck and suggestion data. Requires spellcheck=true in $params
838
        if (isset($res->spellcheck)) {
839
            // Expose all spellcheck data, for custom handling.
840
            $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...
841
842
            // Suggestions. Requires spellcheck.collate=true in $params
843
            if (isset($res->spellcheck->suggestions->collation)) {
844
                // Extract string suggestion
845
                $suggestion = $this->getCollatedSuggestion($res->spellcheck->suggestions->collation);
846
847
                // The collation, including advanced query params (e.g. +), suitable for making another query
848
                // programmatically.
849
                $ret['Suggestion'] = $suggestion;
850
851
                // A human friendly version of the suggestion, suitable for 'Did you mean $SuggestionNice?' display.
852
                $ret['SuggestionNice'] = $this->getNiceSuggestion($suggestion);
853
854
                // A string suitable for appending to an href as a query string.
855
                // For example <a href="http://example.com/search?q=$SuggestionQueryString">$SuggestionNice</a>
856
                $ret['SuggestionQueryString'] = $this->getSuggestionQueryString($suggestion);
857
            }
858
        }
859
860
        $ret = new ArrayData($ret);
861
862
        // Enable extensions to add extra data from the response into
863
        // the returned results set.
864
        $this->extend('updateSearchResults', $ret, $res);
865
866
        return $ret;
867
    }
868
869
    /**
870
     * With a common set of variants that are relevant to at least one class in the list (from either the query or
871
     * the current index), allow them to alter the query to add their variant column conditions.
872
     *
873
     * @param SearchQuery $query
874
     */
875
    protected function applySearchVariants(SearchQuery $query)
876
    {
877
        $classes = count($query->classes) ? $query->classes : $this->getClasses();
878
879
        /** @var SearchVariant_Caller $variantCaller */
880
        $variantCaller = SearchVariant::withCommon($classes);
881
        $variantCaller->call('alterQuery', $query, $this);
882
    }
883
884
    /**
885
     * Solr requires namespaced classes to have double escaped backslashes
886
     *
887
     * @param  string $className   E.g. My\Object\Here
888
     * @param  string $replaceWith The replacement character(s) to use
889
     * @return string              E.g. My\\Object\\Here
890
     */
891
    public function sanitiseClassName($className, $replaceWith = '\\\\')
892
    {
893
        return str_replace('\\', $replaceWith, $className);
894
    }
895
896
    /**
897
     * Get the query (q) component for this search
898
     *
899
     * @param SearchQuery $searchQuery
900
     * @param array &$hlq Highlight query returned by reference
901
     * @return array
902
     */
903
    protected function getQueryComponent(SearchQuery $searchQuery, &$hlq = array())
904
    {
905
        $q = array();
906
        foreach ($searchQuery->search as $search) {
907
            $text = $search['text'];
908
            preg_match_all('/"[^"]*"|\S+/', $text, $parts);
909
910
            $fuzzy = $search['fuzzy'] ? '~' : '';
911
912
            foreach ($parts[0] as $part) {
913
                $fields = (isset($search['fields'])) ? $search['fields'] : array();
914
                if (isset($search['boost'])) {
915
                    $fields = array_merge($fields, array_keys($search['boost']));
916
                }
917
                if ($fields) {
918
                    $searchq = array();
919
                    foreach ($fields as $field) {
920
                        // Escape namespace separators in class names
921
                        $field = $this->sanitiseClassName($field);
922
923
                        $boost = (isset($search['boost'][$field])) ? '^' . $search['boost'][$field] : '';
924
                        $searchq[] = "{$field}:".$part.$fuzzy.$boost;
925
                    }
926
                    $q[] = '+('.implode(' OR ', $searchq).')';
927
                } else {
928
                    $q[] = '+'.$part.$fuzzy;
929
                }
930
                $hlq[] = $part;
931
            }
932
        }
933
        return $q;
934
    }
935
936
    /**
937
     * Parse all require constraints for inclusion in a filter query
938
     *
939
     * @param SearchQuery $searchQuery
940
     * @return array List of parsed string values for each require
941
     */
942
    protected function getRequireFiltersComponent(SearchQuery $searchQuery)
943
    {
944
        $fq = array();
945
        foreach ($searchQuery->require as $field => $values) {
946
            $requireq = array();
947
948 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...
949
                if ($value === SearchQuery::$missing) {
950
                    $requireq[] = "(*:* -{$field}:[* TO *])";
951
                } elseif ($value === SearchQuery::$present) {
952
                    $requireq[] = "{$field}:[* TO *]";
953
                } elseif ($value instanceof SearchQuery_Range) {
954
                    $start = $value->start;
955
                    if ($start === null) {
956
                        $start = '*';
957
                    }
958
                    $end = $value->end;
959
                    if ($end === null) {
960
                        $end = '*';
961
                    }
962
                    $requireq[] = "$field:[$start TO $end]";
963
                } else {
964
                    $requireq[] = $field.':"'.$value.'"';
965
                }
966
            }
967
968
            $fq[] = '+('.implode(' ', $requireq).')';
969
        }
970
        return $fq;
971
    }
972
973
    /**
974
     * Parse all exclude constraints for inclusion in a filter query
975
     *
976
     * @param SearchQuery $searchQuery
977
     * @return array List of parsed string values for each exclusion
978
     */
979
    protected function getExcludeFiltersComponent(SearchQuery $searchQuery)
980
    {
981
        $fq = array();
982
        foreach ($searchQuery->exclude as $field => $values) {
983
            // Handle namespaced class names
984
            $field = $this->sanitiseClassName($field);
985
986
            $excludeq = [];
987
            $missing = false;
988
989 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...
990
                if ($value === SearchQuery::$missing) {
991
                    $missing = true;
992
                } elseif ($value === SearchQuery::$present) {
993
                    $excludeq[] = "{$field}:[* TO *]";
994
                } elseif ($value instanceof SearchQuery_Range) {
995
                    $start = $value->start;
996
                    if ($start === null) {
997
                        $start = '*';
998
                    }
999
                    $end = $value->end;
1000
                    if ($end === null) {
1001
                        $end = '*';
1002
                    }
1003
                    $excludeq[] = "$field:[$start TO $end]";
1004
                } else {
1005
                    $excludeq[] = $field.':"'.$value.'"';
1006
                }
1007
            }
1008
1009
            $fq[] = ($missing ? "+{$field}:[* TO *] " : '') . '-('.implode(' ', $excludeq).')';
1010
        }
1011
        return $fq;
1012
    }
1013
1014
    /**
1015
     * Get all filter conditions for this search
1016
     *
1017
     * @param SearchQuery $searchQuery
1018
     * @return array
1019
     */
1020
    public function getFiltersComponent(SearchQuery $searchQuery)
1021
    {
1022
        return array_merge(
1023
            $this->getRequireFiltersComponent($searchQuery),
1024
            $this->getExcludeFiltersComponent($searchQuery)
1025
        );
1026
    }
1027
1028
    protected $service;
1029
1030
    /**
1031
     * @return SolrService
1032
     */
1033
    public function getService()
1034
    {
1035
        if (!$this->service) {
1036
            $this->service = Solr::service(get_class($this));
1037
        }
1038
        return $this->service;
1039
    }
1040
1041
    public function setService(SolrService $service)
1042
    {
1043
        $this->service = $service;
1044
        return $this;
1045
    }
1046
1047
    /**
1048
     * Upload config for this index to the given store
1049
     *
1050
     * @param SolrConfigStore $store
1051
     */
1052
    public function uploadConfig($store)
1053
    {
1054
        // Upload the config files for this index
1055
        $store->uploadString(
1056
            $this->getIndexName(),
1057
            'schema.xml',
1058
            (string)$this->generateSchema()
1059
        );
1060
1061
        // Upload additional files
1062
        foreach (glob($this->getExtrasPath().'/*') as $file) {
1063
            if (is_file($file)) {
1064
                $store->uploadFile($this->getIndexName(), $file);
1065
            }
1066
        }
1067
    }
1068
}
1069