Passed
Push — master ( 19e85c...7a6648 )
by Nicolaas
10:46
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 $searchTemplateName = '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
129
    public function setDebug(bool $b): SearchApi
130
    {
131
        $this->debug = $b;
132
133
        return $this;
134
    }
135
136
    public function setSearchTemplateName(string $s): SearchApi
137
    {
138
        $this->searchTemplateName = $s;
139
140
        return $this;
141
    }
142
143
    public function setIsQuickSearch(bool $b): SearchApi
144
    {
145
        $this->isQuickSearch = $b;
146
147
        return $this;
148
    }
149
150
    public function setSearchWholePhrase(bool $b): SearchApi
151
    {
152
        $this->searchWholePhrase = $b;
153
154
        return $this;
155
    }
156
157
    public function setBaseClass(string $class): SearchApi
158
    {
159
        $this->baseClass = $class;
160
161
        return $this;
162
    }
163
164
    public function setExcludedClasses(array $a): SearchApi
165
    {
166
        $this->excludedClasses = $a;
167
168
        return $this;
169
    }
170
171
    public function setIncludedClasses(array $a): SearchApi
172
    {
173
        $this->includedClasses = $a;
174
        return $this;
175
    }
176
177
    public function setExcludedFields(array $a): SearchApi
178
    {
179
        $this->excludedFields = $a;
180
181
        return $this;
182
    }
183
184
    public function setIncludedFields(array $a): SearchApi
185
    {
186
        $this->includedFields = $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
    public function getFileCache()
213
    {
214
        return Injector::inst()->get(Cache::class);
215
    }
216
217
    public function initCache(): self
218
    {
219
        $this->cache = $this->getFileCache()->getCacheValues(self::CACHE_NAME . '_' . $this->searchTemplateName);
220
221
        return $this;
222
    }
223
224
    public function saveCache(): self
225
    {
226
        $this->getFileCache()->setCacheValues(self::CACHE_NAME . '_' . $this->searchTemplateName, $this->cache);
227
228
        return $this;
229
    }
230
231
    // public function __construct()
232
    // {
233
    //     Environment::increaseTimeLimitTo(300);
234
    //     Environment::setMemoryLimitMax(-1);
235
    //     Environment::increaseMemoryLimitTo(-1);
236
    // }
237
238
    public function buildCache(?string $word = ''): SearchApi
239
    {
240
        $this->getLinksInner($word);
241
242
        return $this;
243
244
    }
245
246
    public function getLinks(?string $word = ''): ArrayList
247
    {
248
        return $this->getLinksInner($word);
249
    }
250
251
    protected function getLinksInner(?string $word = ''): ArrayList
252
    {
253
        $this->initCache();
254
255
        //always do first ...
256
        $matches = $this->getMatches($word);
257
258
        $list = $this->turnMatchesIntoList($matches);
259
260
        $this->saveCache();
261
262
        return $list;
263
264
    }
265
266
    public function doReplacement(string $word, string $replace): int
267
    {
268
        $this->initCache();
269
        $count = 0;
270
        // we should have these already.
271
        foreach ($this->objects as $item) {
272
            if ($item->canEdit()) {
273
                $fields = $this->getAllValidFields($item->ClassName);
274
                foreach ($fields as $field) {
275
                    $new = str_replace($word, $replace, $item->{$field});
276
                    if ($new !== $item->{$field}) {
277
                        ++$count;
278
                        $item->{$field} = $new;
279
                        $this->writeAndPublishIfAppropriate($item);
280
281
                        if ($this->debug) {
282
                            DB::alteration_message('<h2>Match:  ' . $item->ClassName . $item->ID . '</h2>' . $new . '<hr />');
283
                        }
284
                    }
285
                }
286
            }
287
        }
288
289
        return $count;
290
    }
291
292
    protected function writeAndPublishIfAppropriate($item)
293
    {
294
        if ($item->hasExtension(Versioned::class)) {
295
            $myStage = Versioned::get_stage();
296
            Versioned::set_stage(Versioned::DRAFT);
297
            // is it on live and is live the same as draft
298
            $canBePublished = $item->isPublished() && !$item->isModifiedOnDraft();
299
            $item->writeToStage(Versioned::DRAFT);
300
            if ($canBePublished) {
301
                $item->publishSingle();
302
            }
303
            Versioned::set_stage($myStage);
304
        } else {
305
            $item->write();
306
        }
307
308
    }
309
310
    protected function getMatches(?string $word = ''): array
311
    {
312
        $startInner = 0;
313
        $startOuter = 0;
314
        if ($this->debug) {
315
            $startOuter = microtime(true);
316
        }
317
        $this->workOutInclusionsAndExclusions();
318
        $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

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