Passed
Push — master ( 7a6648...0ae06f )
by Nicolaas
03:58
created

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

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