Passed
Push — master ( 930971...519218 )
by Nicolaas
03:39
created

SearchApi::getAllDataObjects()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 4
Bugs 0 Features 1
Metric Value
cc 1
eloc 1
c 4
b 0
f 1
nc 1
nop 0
dl 0
loc 3
rs 10
1
<?php
2
3
namespace Sunnysideup\SiteWideSearch\Api;
4
5
use SilverStripe\Core\ClassInfo;
6
use SilverStripe\Core\Config\Configurable;
7
use SilverStripe\Core\Convert;
8
use SilverStripe\Core\Extensible;
9
use SilverStripe\Core\Injector\Injectable;
10
use SilverStripe\Core\Injector\Injector;
11
use SilverStripe\ORM\ArrayList;
12
use SilverStripe\ORM\DataObject;
13
use SilverStripe\ORM\DB;
14
use SilverStripe\ORM\FieldType\DBDatetime;
15
use SilverStripe\ORM\FieldType\DBField;
16
use SilverStripe\Security\LoginAttempt;
17
use SilverStripe\Security\MemberPassword;
18
use SilverStripe\Security\RememberLoginHash;
19
use SilverStripe\Versioned\ChangeSet;
20
use SilverStripe\Versioned\Versioned;
21
use SilverStripe\Versioned\ChangeSetItem;
22
use SilverStripe\View\ArrayData;
23
24
use SilverStripe\SessionManager\Models\LoginSession;
25
use Sunnysideup\SiteWideSearch\Helpers\FindClassesAndFields;
26
use Sunnysideup\SiteWideSearch\Helpers\FindEditableObjects;
27
28
class SearchApi
29
{
30
    use Extensible;
31
    use Configurable;
32
    use Injectable;
33
34
35
36
    protected $debug = false;
37
38
    protected $isQuickSearch = false;
39
40
    protected $searchWholePhrase = false;
41
42
    protected $baseClass = DataObject::class;
43
44
    protected $quickSearchType = '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
        $s = $this->securityCheckInput($s);
210
        $this->words = explode(' ', $s);
211
212
        return $this;
213
    }
214
215
216
    // public function __construct()
217
    // {
218
    //     Environment::increaseTimeLimitTo(300);
219
    //     Environment::setMemoryLimitMax(-1);
220
    //     Environment::increaseMemoryLimitTo(-1);
221
    // }
222
223
    public function buildCache(?string $word = ''): SearchApi
224
    {
225
        $this->getLinksInner($word);
226
227
        return $this;
228
229
    }
230
231
    public function getLinks(?string $word = ''): ArrayList
232
    {
233
        return $this->getLinksInner($word);
234
    }
235
236
    protected function getLinksInner(?string $word = ''): ArrayList
237
    {
238
        $this->initCache();
239
240
        //always do first ...
241
        $matches = $this->getMatches($word);
242
243
        $list = $this->turnMatchesIntoList($matches);
244
245
        $this->saveCache();
246
247
        return $list;
248
249
    }
250
251
    public function doReplacement(string $word, string $replace): int
252
    {
253
        $this->initCache();
254
        $count = 0;
255
        if($word) {
256
            $this->buildCache($word);
257
            $replace = $this->securityCheckInput($replace);
258
            foreach ($this->objects as $item) {
259
                $className = $item->ClassName;
260
                if ($item->canEdit()) {
261
                    $fields = $this->getAllValidFields($className);
262
                    foreach ($fields as $field) {
263
                        if(!$this->includeFieldTest($className, $field)) {
264
                            continue;
265
                        }
266
                        $new = str_replace($word, $replace, $item->{$field});
267
                        if ($new !== $item->{$field}) {
268
                            ++$count;
269
                            $item->{$field} = $new;
270
                            $this->writeAndPublishIfAppropriate($item);
271
272
                            if ($this->debug) {
273
                                DB::alteration_message('<h2>Match:  ' . $item->ClassName . $item->ID . '</h2>' . $new . '<hr />');
274
                            }
275
                        }
276
                    }
277
                }
278
            }
279
        }
280
281
        return $count;
282
    }
283
284
    protected function saveCache(): self
285
    {
286
        $this->getCache()->saveCache();
287
288
        return $this;
289
    }
290
291
292
    protected function initCache(): self
293
    {
294
        $this->getCache()->initCache();
295
296
        return $this;
297
    }
298
299
300
    protected function writeAndPublishIfAppropriate($item)
301
    {
302
        if ($item->hasExtension(Versioned::class)) {
303
            $myStage = Versioned::get_stage();
304
            Versioned::set_stage(Versioned::DRAFT);
305
            // is it on live and is live the same as draft
306
            $canBePublished = $item->isPublished() && !$item->isModifiedOnDraft();
307
            $item->writeToStage(Versioned::DRAFT);
308
            if ($canBePublished) {
309
                $item->publishSingle();
310
            }
311
            Versioned::set_stage($myStage);
312
        } else {
313
            $item->write();
314
        }
315
316
    }
317
318
    protected function getMatches(?string $word = ''): array
319
    {
320
        $startInner = 0;
321
        $startOuter = 0;
322
        if ($this->debug) {
323
            $startOuter = microtime(true);
324
        }
325
        $this->workOutInclusionsAndExclusions();
326
327
        // important to do this first
328
        if($word) {
329
            $this->setWordsAsString($word);
330
        }
331
        $this->workOutWordsForSearching();
332
        if ($this->debug) {
333
            DB::alteration_message('Words searched for ' . print_r($this->words, 1));
0 ignored issues
show
Bug introduced by
Are you sure print_r($this->words, 1) of type string|true can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

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