Passed
Push — master ( 081d39...3cb69a )
by Nicolaas
03:44
created

SearchApi::setSortOverride()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 1
dl 0
loc 5
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace Sunnysideup\SiteWideSearch\Api;
4
5
use SilverStripe\Core\ClassInfo;
6
use SilverStripe\Core\Config\Config;
7
use SilverStripe\Core\Config\Configurable;
8
use SilverStripe\Core\Environment;
9
use SilverStripe\Core\Extensible;
10
use SilverStripe\Core\Injector\Injectable;
11
use SilverStripe\Core\Injector\Injector;
12
use SilverStripe\ORM\ArrayList;
13
use SilverStripe\ORM\DataObject;
14
use SilverStripe\ORM\DB;
15
use SilverStripe\ORM\FieldType\DBDatetime;
16
use SilverStripe\ORM\FieldType\DBField;
17
use SilverStripe\ORM\FieldType\DBString;
18
use SilverStripe\Security\LoginAttempt;
19
use SilverStripe\Security\MemberPassword;
20
use SilverStripe\Security\RememberLoginHash;
21
use SilverStripe\Versioned\ChangeSet;
22
use SilverStripe\Versioned\Versioned;
23
use SilverStripe\Versioned\ReadingMode;
24
use SilverStripe\Versioned\ChangeSetItem;
25
use SilverStripe\View\ArrayData;
26
27
use SilverStripe\SessionManager\Models\LoginSession;
28
use Sunnysideup\SiteWideSearch\Helpers\Cache;
29
use Sunnysideup\SiteWideSearch\Helpers\FindEditableObjects;
30
31
class SearchApi
32
{
33
    use Extensible;
34
    use Configurable;
35
    use Injectable;
36
37
    /**
38
     * @var string
39
     */
40
    private const CACHE_NAME = 'SearchApi';
41
42
    protected $debug = false;
43
44
    protected $isQuickSearch = false;
45
46
    protected $searchWholePhrase = false;
47
48
    protected $baseClass = DataObject::class;
49
50
    protected $quickSearchType = 'all';
51
52
    protected $excludedClasses = [];
53
54
    protected $includedClasses = [];
55
56
    protected $excludedFields = [];
57
58
    protected $includedFields = [];
59
60
    protected $sortOverride = null;
61
62
    protected $words = [];
63
64
    protected $replace = '';
65
66
    /**
67
     * format is as follows:
68
     * ```php
69
     *      [
70
     *          'AllDataObjects' => [
71
     *              'BaseClassUsed' => [
72
     *                  0 => ClassNameA,
73
     *                  1 => ClassNameB,
74
     *              ],
75
     *          ],
76
     *          'AllValidFields' => [
77
     *              'ClassNameA' => [
78
     *                  'FieldA' => 'FieldA'
79
     *              ],
80
     *          ],
81
     *          'IndexedFields' => [
82
     *              'ClassNameA' => [
83
     *                  0 => ClassNameA,
84
     *                  1 => ClassNameB,
85
     *              ],
86
     *          ],
87
     *          'ListOfTextClasses' => [
88
     *              0 => ClassNameA,
89
     *              1 => ClassNameB,
90
     *          ],
91
     *          'ValidFieldTypes' => [
92
     *              'Varchar(30)' => true,
93
     *              'Boolean' => false,
94
     *          ],
95
     *     ],
96
     * ```
97
     * we use true rather than false to be able to use empty to work out if it has been tested before.
98
     *
99
     * @var array
100
     */
101
    protected $cache = [];
102
103
    private $objects = [];
104
105
    private static $limit_of_count_per_data_object = 999;
106
107
    private static $hours_back_for_recent = 48;
108
109
    private static $limit_per_class_for_recent = 5;
110
111
    private static $default_exclude_classes = [
112
        MemberPassword::class,
113
        LoginAttempt::class,
114
        ChangeSet::class,
115
        ChangeSetItem::class,
116
        RememberLoginHash::class,
117
        LoginSession::class,
118
    ];
119
120
    private static $default_exclude_fields = [
121
        'ClassName',
122
        'LastEdited',
123
        'Created',
124
        'ID',
125
    ];
126
127
    private static $default_include_classes = [];
128
129
    private static $default_include_fields = [];
130
131
    public function setDebug(bool $b): SearchApi
132
    {
133
        $this->debug = $b;
134
135
        return $this;
136
    }
137
138
    public function setQuickSearchType(string $s): SearchApi
139
    {
140
        if($s === 'all') {
141
            $this->isQuickSearch = false;
142
            $this->quickSearchType = '';
143
        } elseif($s === 'limited') {
144
            $this->isQuickSearch = true;
145
            $this->quickSearchType = '';
146
        } elseif(class_exists($s)) {
147
            $this->quickSearchType = $s;
148
            $object = Injector::inst()->get($s);
149
            $this->setIncludedClasses($object->getClassesToSearch());
150
            $this->setIncludedFields($object->getFieldsToSearch());
151
            $this->setSortOverride($object->getSortOverride());
152
        } else {
153
            user_error('QuickSearchType must be either "all" or "limited" or a defined quick search class. Provided was: ' . $s);
154
        }
155
156
        return $this;
157
    }
158
159
    public function setIsQuickSearch(bool $b): SearchApi
160
    {
161
        $this->isQuickSearch = $b;
162
163
        return $this;
164
    }
165
166
    public function setSearchWholePhrase(bool $b): SearchApi
167
    {
168
        $this->searchWholePhrase = $b;
169
170
        return $this;
171
    }
172
173
    public function setBaseClass(string $class): SearchApi
174
    {
175
        $this->baseClass = $class;
176
177
        return $this;
178
    }
179
180
    public function setExcludedClasses(array $a): SearchApi
181
    {
182
        $this->excludedClasses = $a;
183
184
        return $this;
185
    }
186
187
    public function setIncludedClasses(array $a): SearchApi
188
    {
189
        $this->includedClasses = $a;
190
        return $this;
191
    }
192
193
    public function setExcludedFields(array $a): SearchApi
194
    {
195
        $this->excludedFields = $a;
196
197
        return $this;
198
    }
199
200
    public function setIncludedFields(array $a): SearchApi
201
    {
202
        $this->includedFields = $a;
203
204
        return $this;
205
    }
206
207
    public function setSortOverride(?array $a = null): SearchApi
208
    {
209
        $this->sortOverride = $a;
210
211
        return $this;
212
    }
213
214
    public function setWordsAsString(string $s): SearchApi
215
    {
216
        $this->words = explode(' ', $s);
217
218
        return $this;
219
    }
220
221
    public function setWords(array $a): SearchApi
222
    {
223
        $this->words = array_combine($a, $a);
224
225
        return $this;
226
    }
227
228
    public function addWord(string $s): SearchApi
229
    {
230
        $this->words[$s] = $s;
231
232
        return $this;
233
    }
234
235
    public function getFileCache()
236
    {
237
        return Injector::inst()->get(Cache::class);
238
    }
239
240
    public function initCache(): self
241
    {
242
        $this->cache = $this->getFileCache()->getCacheValues(self::CACHE_NAME . '_' . $this->quickSearchType);
243
244
        return $this;
245
    }
246
247
    public function saveCache(): self
248
    {
249
        $this->getFileCache()->setCacheValues(self::CACHE_NAME . '_' . $this->quickSearchType, $this->cache);
250
251
        return $this;
252
    }
253
254
    // public function __construct()
255
    // {
256
    //     Environment::increaseTimeLimitTo(300);
257
    //     Environment::setMemoryLimitMax(-1);
258
    //     Environment::increaseMemoryLimitTo(-1);
259
    // }
260
261
    public function buildCache(?string $word = ''): SearchApi
262
    {
263
        $this->getLinksInner($word);
264
265
        return $this;
266
267
    }
268
269
    public function getLinks(?string $word = ''): ArrayList
270
    {
271
        return $this->getLinksInner($word);
272
    }
273
274
    protected function getLinksInner(?string $word = ''): ArrayList
275
    {
276
        $this->initCache();
277
278
        //always do first ...
279
        $matches = $this->getMatches($word);
280
281
        $list = $this->turnMatchesIntoList($matches);
282
283
        $this->saveCache();
284
285
        return $list;
286
287
    }
288
289
    public function doReplacement(string $word, string $replace): int
290
    {
291
        $this->initCache();
292
        $count = 0;
293
        // we should have these already.
294
        foreach ($this->objects as $item) {
295
            if ($item->canEdit()) {
296
                $fields = $this->getAllValidFields($item->ClassName);
297
                foreach ($fields as $field) {
298
                    $new = str_replace($word, $replace, $item->{$field});
299
                    if ($new !== $item->{$field}) {
300
                        ++$count;
301
                        $item->{$field} = $new;
302
                        $this->writeAndPublishIfAppropriate($item);
303
304
                        if ($this->debug) {
305
                            DB::alteration_message('<h2>Match:  ' . $item->ClassName . $item->ID . '</h2>' . $new . '<hr />');
306
                        }
307
                    }
308
                }
309
            }
310
        }
311
312
        return $count;
313
    }
314
315
    protected function writeAndPublishIfAppropriate($item)
316
    {
317
        if ($item->hasExtension(Versioned::class)) {
318
            $myStage = Versioned::get_stage();
319
            Versioned::set_stage(Versioned::DRAFT);
320
            // is it on live and is live the same as draft
321
            $canBePublished = $item->isPublished() && !$item->isModifiedOnDraft();
322
            $item->writeToStage(Versioned::DRAFT);
323
            if ($canBePublished) {
324
                $item->publishSingle();
325
            }
326
            Versioned::set_stage($myStage);
327
        } else {
328
            $item->write();
329
        }
330
331
    }
332
333
    protected function getMatches(?string $word = ''): array
334
    {
335
        $startInner = 0;
336
        $startOuter = 0;
337
        if ($this->debug) {
338
            $startOuter = microtime(true);
339
        }
340
        $this->workOutInclusionsAndExclusions();
341
        $this->workOutWords($word);
0 ignored issues
show
Bug introduced by
It seems like $word can also be of type null; however, parameter $word of Sunnysideup\SiteWideSear...archApi::workOutWords() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

341
        $this->workOutWords(/** @scrutinizer ignore-type */ $word);
Loading history...
342
        if ($this->debug) {
343
            DB::alteration_message('Words searched for ' . implode(', ', $this->words));
344
        }
345
346
        $array = [];
347
348
        if (count($this->words)) {
349
            foreach ($this->getAllDataObjects() as $className) {
350
351
                if ($this->debug) {
352
                    DB::alteration_message(' ... Searching in ' . $className);
353
                }
354
                if(count($this->includedClasses) && !in_array($className, $this->includedClasses, true)) {
355
                    continue;
356
                }
357
                if (!in_array($className, $this->excludedClasses, true)) {
358
359
                    $array[$className] = [];
360
                    $fields = $this->getAllValidFields($className);
361
                    $filterAny = [];
362
                    foreach ($fields as $field) {
363
                        if(count($this->includedFields) && !in_array($field, $this->includedFields, true)) {
364
                            continue;
365
                        }
366
367
                        if (!in_array($field, $this->excludedFields, true) || in_array($field, $this->includedFields, true)) {
368
                            if ($this->debug) {
369
                                DB::alteration_message(' ... ... Searching in ' . $className . '.' . $field);
370
                            }
371
372
                            $filterAny[$field . ':PartialMatch'] = $this->words;
373
                        }
374
                    }
375
376
                    if ([] !== $filterAny) {
377
                        if ($this->debug) {
378
                            $startInner = microtime(true);
379
                            DB::alteration_message(' ... Filter: ' . implode(', ', array_keys($filterAny)));
380
                        }
381
382
                        $array[$className] = $className::get()
383
                            ->filterAny($filterAny)
384
                            ->limit($this->Config()->get('limit_of_count_per_data_object'))
385
                            ->column('ID')
386
                        ;
387
                        if ($this->debug) {
388
                            $elaps = microtime(true) - $startInner;
389
                            DB::alteration_message('search for ' . $className . ' taken : ' . $elaps);
390
                        }
391
                    }
392
393
                    if ($this->debug) {
394
                        DB::alteration_message(' ... No fields in ' . $className);
395
                    }
396
                }
397
398
                if ($this->debug) {
399
                    DB::alteration_message(' ... Skipping ' . $className);
400
                }
401
            }
402
        } else {
403
            $array = $this->getDefaultList();
404
        }
405
406
        if ($this->debug) {
407
            $elaps = microtime(true) - $startOuter;
408
            DB::alteration_message('seconds taken find results: ' . $elaps);
409
        }
410
411
        return $array;
412
    }
413
414
    protected function getDefaultList(): array
415
    {
416
        $back = $this->config()->get('hours_back_for_recent') ?? 24;
417
        $limit = $this->Config()->get('limit_per_class_for_recent') ?? 5;
418
        $threshold = strtotime('-' . $back . ' hours', DBDatetime::now()->getTimestamp());
419
        if (!$threshold) {
420
            $threshold = time() - 86400;
421
        }
422
423
        $array = [];
424
        $classNames = $this->getAllDataObjects();
425
        foreach ($classNames as $className) {
426
            if(count($this->includedClasses) && !in_array($className, $this->includedClasses, true)) {
427
                continue;
428
            }
429
            if (!in_array($className, $this->excludedClasses, true)) {
430
                $array[$className] = $className::get()
431
                    ->filter('LastEdited:GreaterThan', date('Y-m-d H:i:s', $threshold))
432
                    ->sort(['LastEdited' => 'DESC'])
433
                    ->limit($limit)
434
                    ->column('ID')
435
                ;
436
            }
437
        }
438
439
        return $array;
440
    }
441
442
    protected function turnArrayIntoObjects(array $matches, ?int $limit = 0): array
443
    {
444
        $start = 0;
445
        if (empty($this->objects)) {
446
            if (empty($limit)) {
447
                $limit = (int) $this->Config()->get('limit_of_count_per_data_object');
448
            }
449
450
            $this->objects = [];
451
            if ($this->debug) {
452
                DB::alteration_message('number of classes: ' . count($matches));
453
            }
454
455
            foreach ($matches as $className => $ids) {
456
                if ($this->debug) {
457
                    $start = microtime(true);
458
                    DB::alteration_message(' ... number of matches for : ' . $className . ': ' . count($ids));
459
                }
460
461
                if (count($ids)) {
462
                    $className = (string) $className;
463
                    $items = $className::get()
464
                        ->filter(['ID' => $ids, 'ClassName' => $className])
465
                        ->limit($limit)
466
                    ;
467
                    foreach ($items as $item) {
468
                        if ($item->canView()) {
469
                            $this->objects[] = $item;
470
                        }
471
                    }
472
                }
473
474
                if ($this->debug) {
475
                    $elaps = microtime(true) - $start;
476
                    DB::alteration_message('seconds taken to find objects in: ' . $className . ': ' . $elaps);
477
                }
478
            }
479
        }
480
481
        return $this->objects;
482
    }
483
484
    protected function turnMatchesIntoList(array $matches): ArrayList
485
    {
486
        // helper
487
        //return values
488
        $list = ArrayList::create();
489
        $finder = Injector::inst()->get(FindEditableObjects::class);
490
        $finder->initCache($this->quickSearchType)
491
            ->setIncludedClasses($this->includedClasses)
492
            ->setExcludedClasses($this->excludedClasses);
493
494
        $items = $this->turnArrayIntoObjects($matches);
495
        foreach ($items as $item) {
496
            $link = $finder->getLink($item, $this->excludedClasses);
497
            if($item->canView()) {
498
                $cmsEditLink = $item->canEdit() ? $finder->getCMSEditLink($item) : '';
499
                $list->push(
500
                    ArrayData::create(
501
                        [
502
                            'HasLink' => (bool) $link,
503
                            'HasCMSEditLink' => (bool) $cmsEditLink,
504
                            'Link' => $link,
505
                            'CMSEditLink' => $cmsEditLink,
506
                            'ID' => $item->ID,
507
                            'Title' => $item->getTitle(),
508
                            'SingularName' => $item->i18n_singular_name(),
509
                            'SiteWideSearchSortValue' => $this->getSortValue($item),
510
                            'CMSThumbnail' => DBField::create_field('HTMLText', $finder->getCMSThumbnail($item)),
511
                        ]
512
                    )
513
                );
514
            }
515
        }
516
517
        $finder->saveCache();
518
        if(!empty($this->sortOverride)) {
519
            return $list->sort($this->sortOverride);
520
        } else {
521
            return $list->sort(['SiteWideSearchSortValue' => 'ASC']);
522
        }
523
    }
524
525
    protected function getSortValue($item)
526
    {
527
        $className = $item->ClassName;
528
        $fields = $this->getAllValidFields($className);
529
        $fullWords = implode(' ', $this->words);
530
531
        $done = false;
532
        $score = 0;
533
        if ($fullWords) {
534
            $fieldValues = [];
535
            $fieldValuesAll = '';
0 ignored issues
show
Unused Code introduced by
The assignment to $fieldValuesAll is dead and can be removed.
Loading history...
536
            foreach ($fields as $field) {
537
                $fieldValues[$field] = strtolower(strip_tags((string) $item->{$field}));
538
            }
539
540
            $fieldValuesAll = implode(' ', $fieldValues);
541
            $testWords = array_merge(
542
                [$fullWords],
543
                $this->words
544
            );
545
            $testWords = array_unique($testWords);
546
            foreach ($testWords as $wordKey => $word) {
547
                //match a exact field to full words / one word
548
                $fullWords = !(bool) $wordKey;
549
                if (false === $done) {
550
                    $count = 0;
551
                    foreach ($fieldValues as $fieldValue) {
552
                        ++$count;
553
                        if ($fieldValue === $word) {
554
                            $score += (int) $wordKey + $count;
555
                            $done = true;
556
557
                            break;
558
                        }
559
                    }
560
                }
561
562
                // the full string / any of the words are present?
563
                if (false === $done) {
564
                    $pos = strpos($fieldValuesAll, $word);
565
                    if (false !== $pos) {
566
                        $score += (($pos + 1) / strlen($word)) * 1000;
567
                        $done = true;
568
                    }
569
                }
570
571
                // all individual words are present
572
                if (false === $done) {
573
                    if ($fullWords) {
574
                        $score += 1000;
575
                        $allMatch = true;
576
                        foreach ($this->words as $tmpWord) {
577
                            $pos = strpos($fieldValuesAll, $tmpWord);
578
                            if (false === $pos) {
579
                                $allMatch = false;
580
581
                                break;
582
                            }
583
                        }
584
585
                        if ($allMatch) {
586
                            $done = true;
587
                        }
588
                    }
589
                }
590
            }
591
        }
592
593
        //the older the item, the higher the scoare
594
        //1104622247 = 1 jan 2005
595
        return $score + (1 / (strtotime($item->LastEdited) - 1104537600));
596
    }
597
598
    protected function workOutInclusionsAndExclusions()
599
    {
600
        $this->excludedClasses = array_unique(
601
            array_merge(
602
                $this->Config()->get('default_exclude_classes'),
603
                $this->excludedClasses
604
            )
605
        );
606
        $this->excludedFields = array_unique(
607
            array_merge(
608
                $this->Config()->get('default_exclude_fields'),
609
                $this->excludedFields
610
            )
611
        );
612
        $this->includedClasses = array_unique(
613
            array_merge(
614
                $this->Config()->get('default_include_classes'),
615
                $this->includedClasses
616
            )
617
        );
618
        $this->includedFields = array_unique(
619
            array_merge(
620
                $this->Config()->get('default_include_fields'),
621
                $this->includedFields
622
            )
623
        );
624
    }
625
626
    protected function workOutWords(string $word = ''): array
627
    {
628
        if ($this->searchWholePhrase) {
629
            $this->words = [implode(' ', $this->words)];
630
        }
631
632
        if ($word) {
633
            $this->words[] = $word;
634
        }
635
636
        if (!count($this->words)) {
637
            user_error('No word has been provided');
638
        }
639
640
        $this->words = array_unique($this->words);
641
        $this->words = array_filter($this->words);
642
        $this->words = array_map('strtolower', $this->words);
643
644
        return $this->words;
645
    }
646
647
    protected function getAllDataObjects(): array
648
    {
649
        if ($this->debug) {
650
            DB::alteration_message('Base Class: ' . $this->baseClass);
651
        }
652
653
        if (!isset($this->cache['AllDataObjects'][$this->baseClass])) {
654
            $this->cache['AllDataObjects'][$this->baseClass] = array_values(
655
                ClassInfo::subclassesFor($this->baseClass, false)
656
            );
657
            $this->cache['AllDataObjects'][$this->baseClass] = array_unique($this->cache['AllDataObjects'][$this->baseClass]);
658
        }
659
660
        return $this->cache['AllDataObjects'][$this->baseClass];
661
    }
662
663
    protected function getAllValidFields(string $className): array
664
    {
665
        if (!isset($this->cache['AllValidFields'][$className])) {
666
            $array = [];
667
            $fullList = Config::inst()->get($className, 'db') + ['ID' => 'Int', 'Created' => 'DBDatetime', 'LastEdited' => 'DBDatetime', 'ClassName' => 'Varchar'];
668
            if (is_array($fullList)) {
669
                if ($this->isQuickSearch) {
670
                    $fullList = $this->getIndexedFields(
671
                        $className,
672
                        $fullList
673
                    );
674
                }
675
                foreach ($fullList as $name => $type) {
676
                    if ($this->isValidFieldType($className, $name, $type)) {
677
                        $array[] = $name;
678
                    } elseif(in_array($name, $this->includedFields, true)) {
679
                        if(in_array($name, $fullList, true)) {
680
                            user_error('Field ' . $name . ' is both included and excluded');
681
                        }
682
                        $array[] = $name;
683
                    }
684
685
                }
686
            }
687
688
            $this->cache['AllValidFields'][$className] = $array;
689
        }
690
691
        return $this->cache['AllValidFields'][$className];
692
    }
693
694
    protected function getIndexedFields(string $className, array $dbFields): array
695
    {
696
        if (!isset($this->cache['IndexedFields'][$className])) {
697
            $this->cache['IndexedFields'][$className] = [];
698
            $indexes = Config::inst()->get($className, 'indexes');
699
            if (is_array($indexes)) {
700
                foreach ($indexes as $key => $field) {
701
                    if (isset($dbFields[$key])) {
702
                        $this->cache['IndexedFields'][$className][$key] = $dbFields[$key];
703
                    } elseif (is_array($field)) {
704
                        foreach ($field as $test) {
705
                            if (is_array($test)) {
706
                                if (isset($test['columns'])) {
707
                                    $test = $test['columns'];
708
                                } else {
709
                                    continue;
710
                                }
711
                            }
712
713
                            $testArray = explode(',', $test);
714
                            foreach ($testArray as $testInner) {
715
                                $testInner = trim($testInner);
716
                                if (isset($dbFields[$testInner])) {
717
                                    $this->cache['IndexedFields'][$className][$testInner] = $dbFields[$key];
718
                                }
719
                            }
720
                        }
721
                    }
722
                }
723
            }
724
        }
725
726
        return $this->cache['IndexedFields'][$className];
727
    }
728
729
    protected function isValidFieldType(string $className, string $fieldName, string $type): bool
730
    {
731
        if (!isset($this->cache['ValidFieldTypes'][$type])) {
732
            $this->cache['ValidFieldTypes'][$type] = false;
733
            $singleton = Injector::inst()->get($className);
734
            $field = $singleton->dbObject($fieldName);
735
            if ($fieldName !== 'ClassName' && $field instanceof DBString) {
736
                $this->cache['ValidFieldTypes'][$type] = true;
737
            }
738
        }
739
740
        return $this->cache['ValidFieldTypes'][$type];
741
    }
742
}
743