Passed
Push — master ( e8ea42...382c9c )
by Nicolaas
03:44
created

SearchApi::getCache()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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

326
        $word = $this->securityCheckInput(/** @scrutinizer ignore-type */ $word);
Loading history...
327
328
        $this->workOutWords($word);
329
        if ($this->debug) {
330
            DB::alteration_message('Words searched for ' . implode(', ', $this->words));
331
        }
332
333
        $array = [];
334
335
        if (count($this->words)) {
336
            foreach ($this->getAllDataObjects() as $className) {
337
338
                if(!$this->includeClassName($className)) {
339
                    continue;
340
                }
341
342
                $array[$className] = [];
343
                $fields = $this->getAllValidFields($className);
344
                $filterAny = [];
345
                foreach ($fields as $field) {
346
                    if(!$this->includeField($className, $field)) {
347
                        continue;
348
                    }
349
                    $filterAny[$field . ':PartialMatch'] = $this->words;
350
                    if ($this->debug) {
351
                        DB::alteration_message(' ... ... Searching in ' . $className . '.' . $field);
352
                    }
353
                }
354
                if ([] !== $filterAny) {
355
                    if ($this->debug) {
356
                        $startInner = microtime(true);
357
                        DB::alteration_message(' ... Filter: ' . implode(', ', array_keys($filterAny)));
358
                    }
359
                    $array[$className] = $className::get()
360
                        ->filterAny($filterAny)
361
                        ->limit($this->Config()->get('limit_of_count_per_data_object'))
362
                        ->column('ID')
363
                    ;
364
                    if ($this->debug) {
365
                        $elaps = microtime(true) - $startInner;
366
                        DB::alteration_message('search for ' . $className . ' taken : ' . $elaps);
367
                    }
368
                }
369
370
                if ($this->debug) {
371
                    DB::alteration_message(' ... No fields in ' . $className);
372
                }
373
            }
374
        } else {
375
            $array = $this->getDefaultList();
376
        }
377
378
        if ($this->debug) {
379
            $elaps = microtime(true) - $startOuter;
380
            DB::alteration_message('seconds taken find results: ' . $elaps);
381
        }
382
383
        return $array;
384
    }
385
386
    protected function getDefaultList(): array
387
    {
388
        $back = $this->config()->get('hours_back_for_recent') ?? 24;
389
        $limit = $this->Config()->get('limit_per_class_for_recent') ?? 5;
390
        $threshold = strtotime('-' . $back . ' hours', DBDatetime::now()->getTimestamp());
391
        if (!$threshold) {
392
            $threshold = time() - 86400;
393
        }
394
395
        $array = [];
396
        $classNames = $this->getAllDataObjects();
397
        foreach ($classNames as $className) {
398
            if($this->includeClassName($className)) {
399
                $array[$className] = $className::get()
400
                    ->filter('LastEdited:GreaterThan', date('Y-m-d H:i:s', $threshold))
401
                    ->sort(['LastEdited' => 'DESC'])
402
                    ->limit($limit)
403
                    ->column('ID')
404
                ;
405
            }
406
        }
407
408
        return $array;
409
    }
410
411
    protected function turnArrayIntoObjects(array $matches, ?int $limit = 0): array
412
    {
413
        $start = 0;
414
        if (empty($this->objects)) {
415
            if (empty($limit)) {
416
                $limit = (int) $this->Config()->get('limit_of_count_per_data_object');
417
            }
418
419
            $this->objects = [];
420
            if ($this->debug) {
421
                DB::alteration_message('number of classes: ' . count($matches));
422
            }
423
424
            foreach ($matches as $className => $ids) {
425
                if ($this->debug) {
426
                    $start = microtime(true);
427
                    DB::alteration_message(' ... number of matches for : ' . $className . ': ' . count($ids));
428
                }
429
430
                if (count($ids)) {
431
                    $className = (string) $className;
432
                    $items = $className::get()
433
                        ->filter(['ID' => $ids, 'ClassName' => $className])
434
                        ->limit($limit)
435
                    ;
436
                    foreach ($items as $item) {
437
                        if ($item->canView()) {
438
                            $this->objects[] = $item;
439
                        }
440
                    }
441
                }
442
443
                if ($this->debug) {
444
                    $elaps = microtime(true) - $start;
445
                    DB::alteration_message('seconds taken to find objects in: ' . $className . ': ' . $elaps);
446
                }
447
            }
448
        }
449
450
        return $this->objects;
451
    }
452
453
    protected function turnMatchesIntoList(array $matches): ArrayList
454
    {
455
        // helper
456
        //return values
457
        $list = ArrayList::create();
458
        $finder = Injector::inst()->get(FindEditableObjects::class);
459
        $finder->initCache($this->quickSearchType)
460
            ->setIncludedClasses($this->includedClasses)
461
            ->setExcludedClasses($this->excludedClasses);
462
463
        $items = $this->turnArrayIntoObjects($matches);
464
        foreach ($items as $item) {
465
            $link = $finder->getLink($item, $this->excludedClasses);
466
            if($item->canView()) {
467
                $cmsEditLink = $item->canEdit() ? $finder->getCMSEditLink($item) : '';
468
                $list->push(
469
                    ArrayData::create(
470
                        [
471
                            'HasLink' => (bool) $link,
472
                            'HasCMSEditLink' => (bool) $cmsEditLink,
473
                            'Link' => $link,
474
                            'CMSEditLink' => $cmsEditLink,
475
                            'ID' => $item->ID,
476
                            'LastEdited' => $item->LastEdited,
477
                            'Title' => $item->getTitle(),
478
                            'SingularName' => $item->i18n_singular_name(),
479
                            'SiteWideSearchSortValue' => $this->getSortValue($item),
480
                            'CMSThumbnail' => DBField::create_field('HTMLText', $finder->getCMSThumbnail($item)),
481
                        ]
482
                    )
483
                );
484
            }
485
        }
486
        $finder->saveCache();
487
488
        if(!empty($this->sortOverride)) {
489
            return $list->sort($this->sortOverride);
490
        } else {
491
            return $list->sort(['SiteWideSearchSortValue' => 'ASC']);
492
        }
493
    }
494
495
    protected function getSortValue($item)
496
    {
497
        $className = $item->ClassName;
498
        $fields = $this->getAllValidFields($className);
499
        $fullWords = implode(' ', $this->words);
500
501
        $done = false;
502
        $score = 0;
503
        if ($fullWords) {
504
            $fieldValues = [];
505
            $fieldValuesAll = '';
0 ignored issues
show
Unused Code introduced by
The assignment to $fieldValuesAll is dead and can be removed.
Loading history...
506
            foreach ($fields as $field) {
507
                $fieldValues[$field] = strtolower(strip_tags((string) $item->{$field}));
508
            }
509
510
            $fieldValuesAll = implode(' ', $fieldValues);
511
            $testWords = array_merge(
512
                [$fullWords],
513
                $this->words
514
            );
515
            $testWords = array_unique($testWords);
516
            foreach ($testWords as $wordKey => $word) {
517
                //match a exact field to full words / one word
518
                $fullWords = !(bool) $wordKey;
519
                if (false === $done) {
520
                    $count = 0;
521
                    foreach ($fieldValues as $fieldValue) {
522
                        ++$count;
523
                        if ($fieldValue === $word) {
524
                            $score += (int) $wordKey + $count;
525
                            $done = true;
526
527
                            break;
528
                        }
529
                    }
530
                }
531
532
                // the full string / any of the words are present?
533
                if (false === $done) {
534
                    $pos = strpos($fieldValuesAll, $word);
535
                    if (false !== $pos) {
536
                        $score += (($pos + 1) / strlen($word)) * 1000;
537
                        $done = true;
538
                    }
539
                }
540
541
                // all individual words are present
542
                if (false === $done) {
543
                    if ($fullWords) {
544
                        $score += 1000;
545
                        $allMatch = true;
546
                        foreach ($this->words as $tmpWord) {
547
                            $pos = strpos($fieldValuesAll, $tmpWord);
548
                            if (false === $pos) {
549
                                $allMatch = false;
550
551
                                break;
552
                            }
553
                        }
554
555
                        if ($allMatch) {
556
                            $done = true;
557
                        }
558
                    }
559
                }
560
            }
561
        }
562
563
        //the older the item, the higher the scoare
564
        //1104622247 = 1 jan 2005
565
        return $score + (1 / (strtotime($item->LastEdited) - 1104537600));
566
    }
567
568
    protected function workOutInclusionsAndExclusions()
569
    {
570
        $this->excludedClasses = array_unique(
571
            array_merge(
572
                $this->Config()->get('default_exclude_classes'),
573
                $this->excludedClasses
574
            )
575
        );
576
        $this->excludedClasses = $this->includeSubClasses($this->excludedClasses);
577
        $this->includedClasses = array_unique(
578
            array_merge(
579
                $this->Config()->get('default_include_classes'),
580
                $this->includedClasses
581
            )
582
        );
583
        $this->includedClasses = $this->includeSubClasses($this->includedClasses);
584
        $this->excludedFields = array_unique(
585
            array_merge(
586
                $this->Config()->get('default_exclude_fields'),
587
                $this->excludedFields
588
            )
589
        );
590
591
        $this->includedFields = array_unique(
592
            array_merge(
593
                $this->Config()->get('default_include_fields'),
594
                $this->includedFields
595
            )
596
        );
597
        $this->includedClassFieldCombos = array_unique(
598
            array_merge(
599
                $this->Config()->get('default_include_class_field_combos'),
600
                $this->includedClassFieldCombos
601
            )
602
        );
603
    }
604
605
    protected function workOutWords(string $word = ''): array
606
    {
607
        if ($this->searchWholePhrase) {
608
            $this->words = [implode(' ', $this->words)];
609
        }
610
611
        if ($word) {
612
            $this->words[] = $word;
613
        }
614
615
        if (!count($this->words)) {
616
            user_error('No word has been provided');
617
        }
618
619
        $this->words = array_unique($this->words);
620
        $this->words = array_filter($this->words);
621
        $this->words = array_map('strtolower', $this->words);
622
623
        return $this->words;
624
    }
625
626
    protected function getAllDataObjects(): array
627
    {
628
        return $this->getCache()->getAllDataObjects();
629
    }
630
631
    protected function getAllValidFields(string $className): array
632
    {
633
        $fields = $this->getCache()->getAllValidFields($className, $this->isQuickSearch, $this->includedFields);
634
        if(isset($this->includedClassFieldCombos[$className])) {
635
            foreach($this->includedClassFieldCombos[$className] as $name => $type) {
636
                $fields[] = $name;
637
            }
638
        }
639
        return array_unique($fields);
640
    }
641
642
643
    protected function includeClassName(string $className): bool
644
    {
645
        if(count($this->excludedClasses) && in_array($className, $this->excludedClasses, true)) {
646
            if ($this->debug) {
647
                DB::alteration_message(' ... Skipping as excluded ' . $className);
648
            }
649
            return false;
650
        }
651
        if(count($this->includedClasses) && !in_array($className, $this->includedClasses, true)) {
652
            if ($this->debug) {
653
                DB::alteration_message(' ... Skipping as not included ' . $className);
654
            }
655
            return false;
656
        }
657
        if ($this->debug) {
658
            DB::alteration_message(' ... including ' . $className);
659
        }
660
661
        return true;
662
    }
663
664
    protected function includeField(string $className, string $field): bool
665
    {
666
        if(isset($this->includedClassFieldCombos[$className][$field])) {
667
            return true;
668
        } elseif(in_array($field, $this->includedFields, true)) {
669
            return true;
670
        } elseif (!in_array($field, $this->excludedFields, true)) {
671
            return true;
672
        } else {
673
            false;
0 ignored issues
show
Bug Best Practice introduced by
In this branch, the function will implicitly return null which is incompatible with the type-hinted return boolean. Consider adding a return statement or allowing null as return value.

For hinted functions/methods where all return statements with the correct type are only reachable via conditions, ?null? gets implicitly returned which may be incompatible with the hinted type. Let?s take a look at an example:

interface ReturnsInt {
    public function returnsIntHinted(): int;
}

class MyClass implements ReturnsInt {
    public function returnsIntHinted(): int
    {
        if (foo()) {
            return 123;
        }
        // here: null is implicitly returned
    }
}
Loading history...
674
        }
675
    }
676
677
    protected function includeSubClasses(array $classes): array
678
    {
679
        $toAdd = [];
680
        foreach($classes as $class) {
681
            $toAdd = array_merge($toAdd, ClassInfo::subclassesFor($class, false));
682
        }
683
        return array_unique(array_merge($classes, $toAdd));
684
    }
685
686
    protected function securityCheckInput(string $word): string
687
    {
688
        $word = trim($word);
689
        return Convert::raw2sql($word);
690
    }
691
}
692