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

src/Solr/SolrIndex.php (3 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

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 = [
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';
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();
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')) {
117
            array_unshift($indexParts, $indexPrefix);
118
        }
119
120
        if ($indexSuffix = Environment::getEnv('SS_SOLR_INDEX_SUFFIX')) {
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'))
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'))
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) {
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) {
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) {
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])) {
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,
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) {
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);
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') {
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)) {
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') {
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)) {
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) {
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))) {
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))) {
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) {
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) {
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
$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
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) {
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) {
761
                header('X-Query: '.implode(' ', $q));
762
            }
763
            if ($fq) {
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
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
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) {
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;
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;
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) {
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) {
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