Completed
Pull Request — master (#150)
by
unknown
03:45
created

SolrIndex::getCopyFieldDefinitions()   A

Complexity

Conditions 5
Paths 9

Size

Total Lines 19
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 8
nc 9
nop 0
dl 0
loc 19
rs 9.6111
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\FullTextSearch\Solr;
4
5
use Exception;
6
use SilverStripe\Control\Director;
7
use SilverStripe\Core\Environment;
8
use SilverStripe\FullTextSearch\Search\Indexes\SearchIndex;
9
use SilverStripe\FullTextSearch\Search\Variants\SearchVariant_Caller;
10
use SilverStripe\FullTextSearch\Solr\Services\SolrService;
11
use SilverStripe\FullTextSearch\Search\Queries\SearchQuery;
12
use SilverStripe\FullTextSearch\Search\Queries\SearchQuery_Range;
13
use SilverStripe\FullTextSearch\Search\Variants\SearchVariant;
14
use SilverStripe\FullTextSearch\Search\SearchIntrospection;
15
use SilverStripe\ORM\ArrayList;
16
use SilverStripe\ORM\DataObject;
17
use SilverStripe\ORM\FieldType\DBField;
18
use SilverStripe\ORM\PaginatedList;
19
use SilverStripe\View\ArrayData;
20
21
abstract class SolrIndex extends SearchIndex
22
{
23
    public static $fulltextTypeMap = array(
24
        '*' => 'text',
25
        'HTMLVarchar' => 'htmltext',
26
        'HTMLText' => 'htmltext'
27
    );
28
29
    public static $filterTypeMap = array(
30
        '*' => 'string',
31
        'Boolean' => 'boolean',
32
        'Date' => 'tdate',
33
        'SSDatetime' => 'tdate',
34
        'SS_Datetime' => 'tdate',
35
        'ForeignKey' => 'tint',
36
        'Int' => 'tint',
37
        'Float' => 'tfloat',
38
        'Double' => 'tdouble'
39
    );
40
41
    public static $sortTypeMap = array();
42
43
    protected $analyzerFields = array();
44
45
    protected $copyFields = array();
46
47
    protected $extrasPath = null;
48
49
    protected $templatesPath = null;
50
51
    private static $casting = [
52
        'FieldDefinitions' => 'HTMLText',
53
        'CopyFieldDefinitions' => 'HTMLText'
54
    ];
55
56
    /**
57
     * List of boosted fields
58
     *
59
     * @var array
60
     */
61
    protected $boostedFields = array();
62
63
    /**
64
     * Name of default field
65
     *
66
     * @var string
67
     * @config
68
     */
69
    private static $default_field = '_text';
70
71
    /**
72
     * List of copy fields all fulltext fields should be copied into.
73
     * This will fallback to default_field if not specified
74
     *
75
     * @var array
76
     */
77
    private static $copy_fields = array();
78
79
    /**
80
     * @return String Absolute path to the folder containing
81
     * templates which are used for generating the schema and field definitions.
82
     */
83
    public function getTemplatesPath()
84
    {
85
        $globalOptions = Solr::solr_options();
86
        $path = $this->templatesPath ? $this->templatesPath : $globalOptions['templatespath'];
87
        return rtrim($path, '/');
88
    }
89
90
    /**
91
     * @return String Absolute path to the configuration default files,
92
     * e.g. solrconfig.xml.
93
     */
94
    public function getExtrasPath()
95
    {
96
        $globalOptions = Solr::solr_options();
97
        return $this->extrasPath ? $this->extrasPath : $globalOptions['extraspath'];
98
    }
99
100
    public function generateSchema()
101
    {
102
        return $this->renderWith($this->getTemplatesPath() . '/schema.ss');
103
    }
104
105
    /**
106
     * Helper for returning the correct index name. Supports prefixing and
107
     * suffixing
108
     *
109
     * @return string
110
     */
111
    public function getIndexName()
112
    {
113
        $name = $this->sanitiseClassName(get_class($this), '-');
114
115
        $indexParts = [$name];
116
117
        if ($indexPrefix = Environment::getEnv('SS_SOLR_INDEX_PREFIX')) {
118
            array_unshift($indexParts, $indexPrefix);
119
        }
120
121
        if ($indexSuffix = Environment::getEnv('SS_SOLR_INDEX_SUFFIX')) {
122
            $indexParts[] = $indexSuffix;
123
        }
124
125
        return implode($indexParts);
126
    }
127
128
    public function getTypes()
129
    {
130
        return $this->renderWith($this->getTemplatesPath() . '/types.ss');
131
    }
132
133
    /**
134
     * Index-time analyzer which is applied to a specific field.
135
     * Can be used to remove HTML tags, apply stemming, etc.
136
     *
137
     * @see http://wiki.apache.org/solr/AnalyzersTokenizersTokenFilters#solr.WhitespaceTokenizerFactory
138
     *
139
     * @param string $field
140
     * @param string $type
141
     * @param array $params parameters for the analyzer, usually at least a "class"
142
     */
143
    public function addAnalyzer($field, $type, $params)
144
    {
145
        $fullFields = $this->fieldData($field);
146
        if ($fullFields) {
147
            foreach ($fullFields as $fullField => $spec) {
148
                if (!isset($this->analyzerFields[$fullField])) {
149
                    $this->analyzerFields[$fullField] = array();
150
                }
151
                $this->analyzerFields[$fullField][$type] = $params;
152
            }
153
        }
154
    }
155
156
    /**
157
     * Get the default text field, normally '_text'
158
     *
159
     * @return string
160
     */
161
    public function getDefaultField()
162
    {
163
        return $this->config()->default_field;
164
    }
165
166
    /**
167
     * Get list of fields each text field should be copied into.
168
     * This will fallback to the default field if omitted.
169
     *
170
     * @return array
171
     */
172
    protected function getCopyDestinations()
173
    {
174
        $copyFields = $this->config()->copy_fields;
175
        if ($copyFields) {
176
            return $copyFields;
177
        }
178
        // Fallback to default field
179
        $df = $this->getDefaultField();
180
        return array($df);
181
    }
182
183
    public function getFieldDefinitions()
184
    {
185
        $xml = array();
186
        $stored = $this->getStoredDefault();
187
188
        $xml[] = "";
189
190
        // Add the hardcoded field definitions
191
192
        $xml[] = "<field name='_documentid' type='string' indexed='true' stored='true' required='true' />";
193
194
        $xml[] = "<field name='ID' type='tint' indexed='true' stored='true' required='true' />";
195
        $xml[] = "<field name='ClassName' type='string' indexed='true' stored='true' required='true' />";
196
        $xml[] = "<field name='ClassHierarchy' type='string' indexed='true' stored='true' required='true' multiValued='true' />";
197
198
        // Add the fulltext collation field
199
200
        $df = $this->getDefaultField();
201
        $xml[] = "<field name='{$df}' type='htmltext' indexed='true' stored='{$stored}' multiValued='true' />" ;
202
203
        // Add the user-specified fields
204
205
        foreach ($this->fulltextFields as $name => $field) {
206
            $xml[] = $this->getFieldDefinition($name, $field, self::$fulltextTypeMap);
207
        }
208
209
        foreach ($this->filterFields as $name => $field) {
210
            if ($field['fullfield'] == 'ID' || $field['fullfield'] == 'ClassName') {
211
                continue;
212
            }
213
            $xml[] = $this->getFieldDefinition($name, $field);
214
        }
215
216
        foreach ($this->sortFields as $name => $field) {
217
            if ($field['fullfield'] == 'ID' || $field['fullfield'] == 'ClassName') {
218
                continue;
219
            }
220
            $xml[] = $this->getFieldDefinition($name, $field);
221
        }
222
223
        return implode("\n\t\t", $xml);
224
    }
225
226
    /**
227
     * Extract first suggestion text from collated values
228
     *
229
     * @param mixed $collation
230
     * @return string
231
     */
232
    protected function getCollatedSuggestion($collation = '')
233
    {
234
        if (is_string($collation)) {
235
            return $collation;
236
        }
237
        if (is_object($collation)) {
238
            if (isset($collation->misspellingsAndCorrections)) {
239
                foreach ($collation->misspellingsAndCorrections as $key => $value) {
240
                    return $value;
241
                }
242
            }
243
        }
244
        return '';
245
    }
246
247
    /**
248
     * Extract a human friendly spelling suggestion from a Solr spellcheck collation string.
249
     * @param string $collation
250
     * @return String
251
     */
252
    protected function getNiceSuggestion($collation = '')
253
    {
254
        $collationParts = explode(' ', $collation);
255
256
        // Remove advanced query params from the beginning of each collation part.
257
        foreach ($collationParts as $key => &$part) {
258
            $part = ltrim($part, '+');
259
        }
260
261
        return implode(' ', $collationParts);
262
    }
263
264
    /**
265
     * Extract a query string from a Solr spellcheck collation string.
266
     * Useful for constructing 'Did you mean?' links, for example:
267
     * <a href="http://example.com/search?q=$SuggestionQueryString">$SuggestionNice</a>
268
     * @param string $collation
269
     * @return String
270
     */
271
    protected function getSuggestionQueryString($collation = '')
272
    {
273
        return str_replace(' ', '+', $this->getNiceSuggestion($collation));
274
    }
275
276
    /**
277
     * Add a field that should be stored
278
     *
279
     * @param string $field The field to add
280
     * @param string $forceType The type to force this field as (required in some cases, when not
281
     * detectable from metadata)
282
     * @param array $extraOptions Dependent on search implementation
283
     */
284
    public function addStoredField($field, $forceType = null, $extraOptions = array())
285
    {
286
        $options = array_merge($extraOptions, array('stored' => 'true'));
287
        $this->addFulltextField($field, $forceType, $options);
0 ignored issues
show
Bug introduced by
$options of type array is incompatible with the type string expected by parameter $extraOptions of SilverStripe\FullTextSea...dex::addFulltextField(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

287
        $this->addFulltextField($field, $forceType, /** @scrutinizer ignore-type */ $options);
Loading history...
288
    }
289
290
    /**
291
     * Add a fulltext field with a boosted value
292
     *
293
     * @param string $field The field to add
294
     * @param string $forceType The type to force this field as (required in some cases, when not
295
     * detectable from metadata)
296
     * @param array $extraOptions Dependent on search implementation
297
     * @param float $boost Numeric boosting value (defaults to 2)
298
     */
299
    public function addBoostedField($field, $forceType = null, $extraOptions = array(), $boost = 2)
300
    {
301
        $options = array_merge($extraOptions, array('boost' => $boost));
302
        $this->addFulltextField($field, $forceType, $options);
0 ignored issues
show
Bug introduced by
$options of type array is incompatible with the type string expected by parameter $extraOptions of SilverStripe\FullTextSea...dex::addFulltextField(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

302
        $this->addFulltextField($field, $forceType, /** @scrutinizer ignore-type */ $options);
Loading history...
303
    }
304
305
306
    public function fieldData($field, $forceType = null, $extraOptions = array())
307
    {
308
        // Ensure that 'boost' is recorded here without being captured by solr
309
        $boost = null;
310
        if (array_key_exists('boost', $extraOptions)) {
311
            $boost = $extraOptions['boost'];
312
            unset($extraOptions['boost']);
313
        }
314
        $data = parent::fieldData($field, $forceType, $extraOptions);
315
316
        // Boost all fields with this name
317
        if (isset($boost)) {
318
            foreach ($data as $fieldName => $fieldInfo) {
319
                $this->boostedFields[$fieldName] = $boost;
320
            }
321
        }
322
        return $data;
323
    }
324
325
    /**
326
     * Set the default boosting level for a specific field.
327
     * Will control the default value for qf param (Query Fields), but will not
328
     * override a query-specific value.
329
     *
330
     * Fields must be added before having a field boosting specified
331
     *
332
     * @param string $field Full field key (Model_Field)
333
     * @param float|null $level Numeric boosting value. Set to null to clear boost
334
     */
335
    public function setFieldBoosting($field, $level)
336
    {
337
        if (!isset($this->fulltextFields[$field])) {
338
            throw new \InvalidArgumentException("No fulltext field $field exists on " . $this->getIndexName());
339
        }
340
        if ($level === null) {
341
            unset($this->boostedFields[$field]);
342
        } else {
343
            $this->boostedFields[$field] = $level;
344
        }
345
    }
346
347
    /**
348
     * Get all boosted fields
349
     *
350
     * @return array
351
     */
352
    public function getBoostedFields()
353
    {
354
        return $this->boostedFields;
355
    }
356
357
    /**
358
     * Determine the best default value for the 'qf' parameter
359
     *
360
     * @return array|null List of query fields, or null if not specified
361
     */
362
    public function getQueryFields()
363
    {
364
        // Not necessary to specify this unless boosting
365
        if (empty($this->boostedFields)) {
366
            return null;
367
        }
368
        $queryFields = array();
369
        foreach ($this->boostedFields as $fieldName => $boost) {
370
            $queryFields[] = $fieldName . '^' . $boost;
371
        }
372
373
        // If any fields are queried, we must always include the default field, otherwise it will be excluded
374
        $df = $this->getDefaultField();
375
        if ($queryFields && !isset($this->boostedFields[$df])) {
376
            $queryFields[] = $df;
377
        }
378
379
        return $queryFields;
380
    }
381
382
    /**
383
     * Gets the default 'stored' value for fields in this index
384
     *
385
     * @return string A default value for the 'stored' field option, either 'true' or 'false'
386
     */
387
    protected function getStoredDefault()
388
    {
389
        return Director::isDev() ? 'true' : 'false';
390
    }
391
392
    /**
393
     * @param string $name
394
     * @param Array $spec
395
     * @param Array $typeMap
396
     * @return String XML
397
     */
398
    protected function getFieldDefinition($name, $spec, $typeMap = null)
399
    {
400
        if (!$typeMap) {
401
            $typeMap = self::$filterTypeMap;
402
        }
403
        $multiValued = (isset($spec['multi_valued']) && $spec['multi_valued']) ? "true" : '';
404
        $type = isset($typeMap[$spec['type']]) ? $typeMap[$spec['type']] : $typeMap['*'];
405
406
        $analyzerXml = '';
407
        if (isset($this->analyzerFields[$name])) {
408
            foreach ($this->analyzerFields[$name] as $analyzerType => $analyzerParams) {
409
                $analyzerXml .= $this->toXmlTag($analyzerType, $analyzerParams);
410
            }
411
        }
412
413
        $fieldParams = array_merge(
414
            array(
415
                'name' => $name,
416
                'type' => $type,
417
                'indexed' => 'true',
418
                'stored' => $this->getStoredDefault(),
419
                'multiValued' => $multiValued
420
            ),
421
            isset($spec['extra_options']) ? $spec['extra_options'] : array()
422
        );
423
424
        return $this->toXmlTag(
425
            "field",
426
            $fieldParams,
0 ignored issues
show
Bug introduced by
$fieldParams of type array is incompatible with the type string expected by parameter $attrs of SilverStripe\FullTextSea...r\SolrIndex::toXmlTag(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

426
            /** @scrutinizer ignore-type */ $fieldParams,
Loading history...
427
            $analyzerXml ? "<analyzer>$analyzerXml</analyzer>" : null
428
        );
429
    }
430
431
    /**
432
     * Convert definition to XML tag
433
     *
434
     * @param string $tag
435
     * @param string $attrs Map of attributes
436
     * @param string $content Inner content
437
     * @return String XML tag
438
     */
439
    protected function toXmlTag($tag, $attrs, $content = null)
440
    {
441
        $xml = "<$tag ";
442
        if ($attrs) {
443
            $attrStrs = array();
444
            foreach ($attrs as $attrName => $attrVal) {
0 ignored issues
show
Bug introduced by
The expression $attrs of type string is not traversable.
Loading history...
445
                $attrStrs[] = "$attrName='$attrVal'";
446
            }
447
            $xml .= $attrStrs ? implode(' ', $attrStrs) : '';
448
        }
449
        $xml .= $content ? ">$content</$tag>" : '/>';
450
        return $xml;
451
    }
452
453
    /**
454
     * @param string $source Composite field name (<class>_<fieldname>)
455
     * @param string $dest
456
     */
457
    public function addCopyField($source, $dest, $extraOptions = array())
458
    {
459
        if (!isset($this->copyFields[$source])) {
460
            $this->copyFields[$source] = array();
461
        }
462
        $this->copyFields[$source][] = array_merge(
463
            array('source' => $source, 'dest' => $dest),
464
            $extraOptions
465
        );
466
    }
467
468
    /**
469
     * Generate XML for copy field definitions
470
     *
471
     * @return string
472
     */
473
    public function getCopyFieldDefinitions()
474
    {
475
        $xml = array();
476
477
        // Default copy fields
478
        foreach ($this->getCopyDestinations() as $copyTo) {
479
            foreach ($this->fulltextFields as $name => $field) {
480
                $xml[] = "<copyField source='{$name}' dest='{$copyTo}' />";
481
            }
482
        }
483
484
        // Explicit copy fields
485
        foreach ($this->copyFields as $source => $fields) {
486
            foreach ($fields as $fieldAttrs) {
487
                $xml[] = $this->toXmlTag('copyField', $fieldAttrs);
488
            }
489
        }
490
491
        return implode("\n\t", $xml);
492
    }
493
494
    /**
495
     * Determine if the given object is one of the given type
496
     *
497
     * @param string $class
498
     * @param array|string $base Class or list of base classes
499
     * @return bool
500
     */
501
    protected function classIs($class, $base)
502
    {
503
        if (is_array($base)) {
504
            foreach ($base as $nextBase) {
505
                if ($this->classIs($class, $nextBase)) {
506
                    return true;
507
                }
508
            }
509
            return false;
510
        }
511
512
        // Check single origin
513
        return $class === $base || is_subclass_of($class, $base);
514
    }
515
516
    protected function _addField($doc, $object, $field)
517
    {
518
        $class = get_class($object);
519
        if (!$this->classIs($class, $field['origin'])) {
520
            return;
521
        }
522
523
        $value = $this->_getFieldValue($object, $field);
524
525
        $type = isset(self::$filterTypeMap[$field['type']]) ? self::$filterTypeMap[$field['type']] : self::$filterTypeMap['*'];
526
527
        if (is_array($value)) {
528
            foreach ($value as $sub) {
529
                /* Solr requires dates in the form 1995-12-31T23:59:59Z */
530
                if ($type == 'tdate') {
531
                    if (!$sub) {
532
                        continue;
533
                    }
534
                    $sub = gmdate('Y-m-d\TH:i:s\Z', strtotime($sub));
535
                }
536
537
                /* Solr requires numbers to be valid if presented, not just empty */
538
                if (($type == 'tint' || $type == 'tfloat' || $type == 'tdouble') && !is_numeric($sub)) {
539
                    continue;
540
                }
541
542
                $doc->addField($field['name'], $sub);
543
            }
544
        } else {
545
            /* Solr requires dates in the form 1995-12-31T23:59:59Z */
546
            if ($type == 'tdate') {
547
                if (!$value) {
548
                    return;
549
                }
550
                $value = gmdate('Y-m-d\TH:i:s\Z', strtotime($value));
551
            }
552
553
            /* Solr requires numbers to be valid if presented, not just empty */
554
            if (($type == 'tint' || $type == 'tfloat' || $type == 'tdouble') && !is_numeric($value)) {
555
                return;
556
            }
557
558
            // Only index fields that are not null
559
            if ($value !== null) {
560
                $doc->setField($field['name'], $value);
561
            }
562
        }
563
    }
564
565
    protected function _addAs($object, $base, $options)
566
    {
567
        $includeSubs = $options['include_children'];
568
569
        $doc = new \Apache_Solr_Document();
570
571
        // Always present fields
572
573
        $doc->setField('_documentid', $this->getDocumentID($object, $base, $includeSubs));
574
        $doc->setField('ID', $object->ID);
575
        $doc->setField('ClassName', $object->ClassName);
576
577
        foreach (SearchIntrospection::hierarchy(get_class($object), false) as $class) {
578
            $doc->addField('ClassHierarchy', $class);
579
        }
580
581
        // Add the user-specified fields
582
583
        foreach ($this->getFieldsIterator() as $name => $field) {
584
            if ($field['base'] === $base || (is_array($field['base']) && in_array($base, $field['base']))) {
585
                $this->_addField($doc, $object, $field);
586
            }
587
        }
588
589
        try {
590
            $this->getService()->addDocument($doc);
591
        } catch (Exception $e) {
592
            static::warn($e);
593
            return false;
594
        }
595
596
        return $doc;
597
    }
598
599
    public function add($object)
600
    {
601
        $class = get_class($object);
602
        $docs = array();
603
604
        foreach ($this->getClasses() as $searchclass => $options) {
605
            if ($searchclass == $class || ($options['include_children'] && is_subclass_of($class, $searchclass))) {
606
                $base = DataObject::getSchema()->baseDataClass($searchclass);
607
                $docs[] = $this->_addAs($object, $base, $options);
608
            }
609
        }
610
611
        return $docs;
612
    }
613
614
    public function canAdd($class)
615
    {
616
        foreach ($this->classes as $searchclass => $options) {
617
            if ($searchclass == $class || ($options['include_children'] && is_subclass_of($class, $searchclass))) {
618
                return true;
619
            }
620
        }
621
622
        return false;
623
    }
624
625
    public function delete($base, $id, $state)
626
    {
627
        $documentID = $this->getDocumentIDForState($base, $id, $state);
628
629
        try {
630
            $this->getService()->deleteById($documentID);
631
        } catch (Exception $e) {
632
            static::warn($e);
633
            return false;
634
        }
635
    }
636
637
    /**
638
     * Clear all records which do not match the given classname whitelist.
639
     *
640
     * Can also be used to trim an index when reducing to a narrower set of classes.
641
     *
642
     * Ignores current state / variant.
643
     *
644
     * @param array $classes List of non-obsolete classes in the same format as SolrIndex::getClasses()
645
     * @return bool Flag if successful
646
     */
647
    public function clearObsoleteClasses($classes)
648
    {
649
        if (empty($classes)) {
650
            return false;
651
        }
652
653
        // Delete all records which do not match the necessary classname rules
654
        $conditions = array();
655
        foreach ($classes as $class => $options) {
656
            if ($options['include_children']) {
657
                $conditions[] = "ClassHierarchy:{$class}";
658
            } else {
659
                $conditions[] = "ClassName:{$class}";
660
            }
661
        }
662
663
        // Delete records which don't match any of these conditions in this index
664
        $deleteQuery = "-(" . implode(' ', $conditions) . ")";
665
        $this
666
            ->getService()
667
            ->deleteByQuery($deleteQuery);
668
        return true;
669
    }
670
671
    public function commit()
672
    {
673
        try {
674
            $this->getService()->commit(false, false, false);
675
        } catch (Exception $e) {
676
            static::warn($e);
677
            return false;
678
        }
679
    }
680
681
    /**
682
     * @param SearchQuery $query
683
     * @param integer $offset
684
     * @param integer $limit
685
     * @param array $params Extra request parameters passed through to Solr
686
     * @return ArrayData Map with the following keys:
687
     *  - 'Matches': ArrayList of the matched object instances
688
     */
689
    public function search(SearchQuery $query, $offset = -1, $limit = -1, $params = array())
690
    {
691
        $service = $this->getService();
692
        $this->applySearchVariants($query);
693
694
        $q = array(); // Query
0 ignored issues
show
Unused Code introduced by
The assignment to $q is dead and can be removed.
Loading history...
695
        $fq = array(); // Filter query
696
        $qf = array(); // Query fields
697
        $hlq = array(); // Highlight query
698
699
        // Build the search itself
700
        $q = $this->getQueryComponent($query, $hlq);
701
702
        // If using boosting, set the clean term separately for highlighting.
703
        // See https://issues.apache.org/jira/browse/SOLR-2632
704
        if (array_key_exists('hl', $params) && !array_key_exists('hl.q', $params)) {
705
            $params['hl.q'] = implode(' ', $hlq);
706
        }
707
708
        // Filter by class if requested
709
        $classq = array();
710
        foreach ($query->classes as $class) {
711
            if (!empty($class['includeSubclasses'])) {
712
                $classq[] = 'ClassHierarchy:' . $this->sanitiseClassName($class['class']);
713
            } else {
714
                $classq[] = 'ClassName:' . $this->sanitiseClassName($class['class']);
715
            }
716
        }
717
        if ($classq) {
718
            $fq[] = '+(' . implode(' ', $classq) . ')';
719
        }
720
721
        // Filter by filters
722
        $fq = array_merge($fq, $this->getFiltersComponent($query));
723
724
        // Prepare query fields unless specified explicitly
725
        if (isset($params['qf'])) {
726
            $qf = $params['qf'];
727
        } else {
728
            $qf = $this->getQueryFields();
729
        }
730
        if (is_array($qf)) {
731
            $qf = implode(' ', $qf);
732
        }
733
        if ($qf) {
734
            $params['qf'] = $qf;
735
        }
736
737
        if (!headers_sent() && Director::isDev()) {
738
            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...
739
                header('X-Query: ' . implode(' ', $q));
740
            }
741
            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...
742
                header('X-Filters: "' . implode('", "', $fq) . '"');
743
            }
744
            if ($qf) {
745
                header('X-QueryFields: ' . $qf);
746
            }
747
        }
748
749
        if ($offset == -1) {
750
            $offset = $query->start;
0 ignored issues
show
Bug Best Practice introduced by
The property $start is declared protected in SilverStripe\FullTextSea...rch\Queries\SearchQuery. Since you implement __get, consider adding a @property or @property-read.
Loading history...
751
        }
752
        if ($limit == -1) {
753
            $limit = $query->limit;
0 ignored issues
show
Bug Best Practice introduced by
The property $limit is declared protected in SilverStripe\FullTextSea...rch\Queries\SearchQuery. Since you implement __get, consider adding a @property or @property-read.
Loading history...
754
        }
755
        if ($limit == -1) {
756
            $limit = SearchQuery::$default_page_size;
757
        }
758
759
        $params = array_merge($params, array('fq' => implode(' ', $fq)));
760
761
        $res = $service->search(
762
            $q ? implode(' ', $q) : '*:*',
763
            $offset,
764
            $limit,
765
            $params,
766
            \Apache_Solr_Service::METHOD_POST
767
        );
768
769
        $results = new ArrayList();
770
        if ($res->getHttpStatus() >= 200 && $res->getHttpStatus() < 300) {
771
            foreach ($res->response->docs as $doc) {
772
                $result = DataObject::get_by_id($doc->ClassName, $doc->ID);
773
                if ($result) {
774
                    $results->push($result);
775
776
                    // Add highlighting (optional)
777
                    $docId = $doc->_documentid;
778
                    if ($res->highlighting && $res->highlighting->$docId) {
779
                        // TODO Create decorator class for search results rather than adding arbitrary object properties
780
                        // TODO Allow specifying highlighted field, and lazy loading
781
                        // in case the search API needs another query (similar to SphinxSearchable->buildExcerpt()).
782
                        $combinedHighlights = array();
783
                        foreach ($res->highlighting->$docId as $field => $highlights) {
784
                            $combinedHighlights = array_merge($combinedHighlights, $highlights);
785
                        }
786
787
                        // Remove entity-encoded U+FFFD replacement character. It signifies non-displayable characters,
788
                        // and shows up as an encoding error in browsers.
789
                        $result->Excerpt = DBField::create_field(
790
                            'HTMLText',
791
                            str_replace(
792
                                '&#65533;',
793
                                '',
794
                                implode(' ... ', $combinedHighlights)
795
                            )
796
                        );
797
                    }
798
                }
799
            }
800
            $numFound = $res->response->numFound;
801
        } else {
802
            $numFound = 0;
803
        }
804
805
        $ret = array();
806
        $ret['Matches'] = new PaginatedList($results);
807
        $ret['Matches']->setLimitItems(false);
808
        // Tell PaginatedList how many results there are
809
        $ret['Matches']->setTotalItems($numFound);
810
        // Results for current page start at $offset
811
        $ret['Matches']->setPageStart($offset);
812
        // Results per page
813
        $ret['Matches']->setPageLength($limit);
814
815
        // Include spellcheck and suggestion data. Requires spellcheck=true in $params
816
        if (isset($res->spellcheck)) {
817
            // Expose all spellcheck data, for custom handling.
818
            $ret['Spellcheck'] = $res->spellcheck;
819
820
            // Suggestions. Requires spellcheck.collate=true in $params
821
            if (isset($res->spellcheck->suggestions->collation)) {
822
                // Extract string suggestion
823
                $suggestion = $this->getCollatedSuggestion($res->spellcheck->suggestions->collation);
824
825
                // The collation, including advanced query params (e.g. +), suitable for making another query
826
                // programmatically.
827
                $ret['Suggestion'] = $suggestion;
828
829
                // A human friendly version of the suggestion, suitable for 'Did you mean $SuggestionNice?' display.
830
                $ret['SuggestionNice'] = $this->getNiceSuggestion($suggestion);
831
832
                // A string suitable for appending to an href as a query string.
833
                // For example <a href="http://example.com/search?q=$SuggestionQueryString">$SuggestionNice</a>
834
                $ret['SuggestionQueryString'] = $this->getSuggestionQueryString($suggestion);
835
            }
836
        }
837
838
        $ret = new ArrayData($ret);
839
840
        // Enable extensions to add extra data from the response into
841
        // the returned results set.
842
        $this->extend('updateSearchResults', $ret, $res);
843
844
        return $ret;
845
    }
846
847
    /**
848
     * With a common set of variants that are relevant to at least one class in the list (from either the query or
849
     * the current index), allow them to alter the query to add their variant column conditions.
850
     *
851
     * @param SearchQuery $query
852
     */
853
    protected function applySearchVariants(SearchQuery $query)
854
    {
855
        $classes = count($query->classes) ? $query->classes : $this->getClasses();
856
857
        /** @var SearchVariant_Caller $variantCaller */
858
        $variantCaller = SearchVariant::withCommon($classes);
859
        $variantCaller->call('alterQuery', $query, $this);
860
    }
861
862
    /**
863
     * Solr requires namespaced classes to have double escaped backslashes
864
     *
865
     * @param  string $className   E.g. My\Object\Here
866
     * @param  string $replaceWith The replacement character(s) to use
867
     * @return string              E.g. My\\Object\\Here
868
     */
869
    public function sanitiseClassName($className, $replaceWith = '\\\\')
870
    {
871
        return str_replace('\\', $replaceWith, $className);
872
    }
873
874
    /**
875
     * Get the query (q) component for this search
876
     *
877
     * @param SearchQuery $searchQuery
878
     * @param array &$hlq Highlight query returned by reference
879
     * @return array
880
     */
881
    protected function getQueryComponent(SearchQuery $searchQuery, &$hlq = array())
882
    {
883
        $q = array();
884
        foreach ($searchQuery->search as $search) {
885
            $text = $search['text'];
886
            preg_match_all('/"[^"]*"|\S+/', $text, $parts);
887
888
            $fuzzy = $search['fuzzy'] ? '~' : '';
889
890
            foreach ($parts[0] as $part) {
891
                $fields = (isset($search['fields'])) ? $search['fields'] : array();
892
                if (isset($search['boost'])) {
893
                    $fields = array_merge($fields, array_keys($search['boost']));
894
                }
895
                if ($fields) {
896
                    $searchq = array();
897
                    foreach ($fields as $field) {
898
                        // Escape namespace separators in class names
899
                        $field = $this->sanitiseClassName($field);
900
901
                        $boost = (isset($search['boost'][$field])) ? '^' . $search['boost'][$field] : '';
902
                        $searchq[] = "{$field}:" . $part . $fuzzy . $boost;
903
                    }
904
                    $q[] = '+(' . implode(' OR ', $searchq) . ')';
905
                } else {
906
                    $q[] = '+' . $part . $fuzzy;
907
                }
908
                $hlq[] = $part;
909
            }
910
        }
911
        return $q;
912
    }
913
914
    /**
915
     * Parse all require constraints for inclusion in a filter query
916
     *
917
     * @param SearchQuery $searchQuery
918
     * @return array List of parsed string values for each require
919
     */
920
    protected function getRequireFiltersComponent(SearchQuery $searchQuery)
921
    {
922
        $fq = array();
923
        foreach ($searchQuery->require as $field => $values) {
924
            $requireq = array();
925
926
            foreach ($values as $value) {
927
                if ($value === SearchQuery::$missing) {
928
                    $requireq[] = "(*:* -{$field}:[* TO *])";
929
                } elseif ($value === SearchQuery::$present) {
930
                    $requireq[] = "{$field}:[* TO *]";
931
                } elseif ($value instanceof SearchQuery_Range) {
932
                    $start = $value->start;
933
                    if ($start === null) {
934
                        $start = '*';
935
                    }
936
                    $end = $value->end;
937
                    if ($end === null) {
938
                        $end = '*';
939
                    }
940
                    $requireq[] = "$field:[$start TO $end]";
941
                } else {
942
                    $requireq[] = $field . ':"' . $value . '"';
943
                }
944
            }
945
946
            $fq[] = '+(' . implode(' ', $requireq) . ')';
947
        }
948
        return $fq;
949
    }
950
951
    /**
952
     * Parse all exclude constraints for inclusion in a filter query
953
     *
954
     * @param SearchQuery $searchQuery
955
     * @return array List of parsed string values for each exclusion
956
     */
957
    protected function getExcludeFiltersComponent(SearchQuery $searchQuery)
958
    {
959
        $fq = array();
960
        foreach ($searchQuery->exclude as $field => $values) {
961
            // Handle namespaced class names
962
            $field = $this->sanitiseClassName($field);
963
964
            $excludeq = [];
965
            $missing = false;
966
967
            foreach ($values as $value) {
968
                if ($value === SearchQuery::$missing) {
969
                    $missing = true;
970
                } elseif ($value === SearchQuery::$present) {
971
                    $excludeq[] = "{$field}:[* TO *]";
972
                } elseif ($value instanceof SearchQuery_Range) {
973
                    $start = $value->start;
974
                    if ($start === null) {
975
                        $start = '*';
976
                    }
977
                    $end = $value->end;
978
                    if ($end === null) {
979
                        $end = '*';
980
                    }
981
                    $excludeq[] = "$field:[$start TO $end]";
982
                } else {
983
                    $excludeq[] = $field . ':"' . $value . '"';
984
                }
985
            }
986
987
            $fq[] = ($missing ? "+{$field}:[* TO *] " : '') . '-(' . implode(' ', $excludeq) . ')';
988
        }
989
        return $fq;
990
    }
991
992
    /**
993
     * @param SearchQuery $searchQuery
994
     * @return string
995
     * @throws \Exception
996
     */
997
    protected function getCriteriaComponent(SearchQuery $searchQuery)
998
    {
999
        if (count($searchQuery->getCriteria()) === 0) {
1000
            return null;
1001
        }
1002
1003
        if ($searchQuery->getAdapter() === null) {
1004
            throw new \Exception('SearchQuery does not have a SearchAdapter applied');
1005
        }
1006
1007
        // Need to start with a positive conjunction.
1008
        $ps = $searchQuery->getAdapter()->getPrependToCriteriaComponent();
1009
1010
        foreach ($searchQuery->getCriteria() as $clause) {
1011
            $clause->setAdapter($searchQuery->getAdapter());
1012
            $clause->appendPreparedStatementTo($ps);
1013
        }
1014
1015
        // Need to start with a positive conjunction.
1016
        $ps .= $searchQuery->getAdapter()->getAppendToCriteriaComponent();
1017
1018
        // Returned as an array because that's how `getFiltersComponent` expects it.
1019
        return $ps;
1020
    }
1021
1022
    /**
1023
     * Get all filter conditions for this search
1024
     *
1025
     * @param SearchQuery $searchQuery
1026
     * @return array
1027
     * @throws \Exception
1028
     */
1029
    public function getFiltersComponent(SearchQuery $searchQuery)
1030
    {
1031
        $criteriaComponent = $this->getCriteriaComponent($searchQuery);
1032
1033
        $components = array_merge(
1034
            $this->getRequireFiltersComponent($searchQuery),
1035
            $this->getExcludeFiltersComponent($searchQuery)
1036
        );
1037
1038
        if ($criteriaComponent !== null) {
0 ignored issues
show
introduced by
The condition $criteriaComponent !== null is always true.
Loading history...
1039
            $components[] = $criteriaComponent;
1040
        }
1041
1042
        return $components;
1043
    }
1044
1045
    protected $service;
1046
1047
    /**
1048
     * @return SolrService
1049
     */
1050
    public function getService()
1051
    {
1052
        if (!$this->service) {
1053
            $this->service = Solr::service(get_class($this));
1054
        }
1055
        return $this->service;
1056
    }
1057
1058
    public function setService(SolrService $service)
1059
    {
1060
        $this->service = $service;
1061
        return $this;
1062
    }
1063
1064
    /**
1065
     * Upload config for this index to the given store
1066
     *
1067
     * @param SolrConfigStore $store
0 ignored issues
show
Bug introduced by
The type SilverStripe\FullTextSearch\Solr\SolrConfigStore was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
1068
     */
1069
    public function uploadConfig($store)
1070
    {
1071
        // Upload the config files for this index
1072
        $store->uploadString(
1073
            $this->getIndexName(),
1074
            'schema.xml',
1075
            (string)$this->generateSchema()
1076
        );
1077
1078
        // Upload additional files
1079
        foreach (glob($this->getExtrasPath() . '/*') as $file) {
1080
            if (is_file($file)) {
1081
                $store->uploadFile($this->getIndexName(), $file);
1082
            }
1083
        }
1084
    }
1085
}
1086