Passed
Push — master ( 382c9c...930971 )
by Nicolaas
03:34
created

SearchApi::includeFieldTest()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 10
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

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

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