Passed
Push — master ( 7d8f18...081d39 )
by Nicolaas
09:10
created

SearchApi::getAllValidFields()   B

Complexity

Conditions 8
Paths 4

Size

Total Lines 29
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

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

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