Passed
Push — master ( bb1745...de2527 )
by Nicolaas
10:14
created

SearchApi::getAllDataObjects()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 14
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 1
Metric Value
cc 3
eloc 7
c 3
b 0
f 1
nc 4
nop 0
dl 0
loc 14
rs 10
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\ChangeSetItem;
22
use SilverStripe\View\ArrayData;
23
24
use SilverStripe\SessionManager\Models\LoginSession;
0 ignored issues
show
Bug introduced by
The type SilverStripe\SessionManager\Models\LoginSession was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
25
use Sunnysideup\SiteWideSearch\Helpers\Cache;
26
use Sunnysideup\SiteWideSearch\Helpers\FindEditableObjects;
27
28
class SearchApi
29
{
30
    use Extensible;
31
    use Configurable;
32
    use Injectable;
33
34
    /**
35
     * @var string
36
     */
37
    private const CACHE_NAME = 'SearchApi';
38
39
    protected $debug = false;
40
41
    protected $isQuickSearch = false;
42
43
    protected $searchWholePhrase = false;
44
45
    protected $baseClass = DataObject::class;
46
47
    protected $excludedClasses = [];
48
49
    protected $excludedFields = [];
50
51
    protected $words = [];
52
53
    protected $replace = '';
54
55
    /**
56
     * format is as follows:
57
     * ```php
58
     *      [
59
     *          'AllDataObjects' => [
60
     *              'BaseClassUsed' => [
61
     *                  0 => ClassNameA,
62
     *                  1 => ClassNameB,
63
     *              ],
64
     *          ],
65
     *          'AllValidFields' => [
66
     *              'ClassNameA' => [
67
     *                  'FieldA' => 'FieldA'
68
     *              ],
69
     *          ],
70
     *          'IndexedFields' => [
71
     *              'ClassNameA' => [
72
     *                  0 => ClassNameA,
73
     *                  1 => ClassNameB,
74
     *              ],
75
     *          ],
76
     *          'ListOfTextClasses' => [
77
     *              0 => ClassNameA,
78
     *              1 => ClassNameB,
79
     *          ],
80
     *          'ValidFieldTypes' => [
81
     *              'Varchar(30)' => true,
82
     *              'Boolean' => false,
83
     *          ],
84
     *     ],
85
     * ```
86
     * we use true rather than false to be able to use empty to work out if it has been tested before.
87
     *
88
     * @var array
89
     */
90
    protected $cache = [];
91
92
    private $objects = [];
93
94
    private static $limit_of_count_per_data_object = 999;
95
96
    private static $hours_back_for_recent = 48;
97
98
    private static $limit_per_class_for_recent = 5;
99
100
    private static $default_exclude_classes = [
101
        MemberPassword::class,
102
        LoginAttempt::class,
103
        ChangeSet::class,
104
        ChangeSetItem::class,
105
        RememberLoginHash::class,
106
        LoginSession::class,
107
    ];
108
109
    private static $default_exclude_fields = [
110
        'ClassName',
111
        'LastEdited',
112
        'Created',
113
        'ID',
114
    ];
115
116
    public function setDebug(bool $b): SearchApi
117
    {
118
        $this->debug = $b;
119
120
        return $this;
121
    }
122
123
    public function setIsQuickSearch(bool $b): SearchApi
124
    {
125
        $this->isQuickSearch = $b;
126
127
        return $this;
128
    }
129
130
    public function setSearchWholePhrase(bool $b): SearchApi
131
    {
132
        $this->searchWholePhrase = $b;
133
134
        return $this;
135
    }
136
137
    public function setBaseClass(string $class): SearchApi
138
    {
139
        $this->baseClass = $class;
140
141
        return $this;
142
    }
143
144
    public function setExcludedClasses(array $a): SearchApi
145
    {
146
        $this->excludedClasses = $a;
147
148
        return $this;
149
    }
150
151
    public function setExcludedFields(array $a): SearchApi
152
    {
153
        $this->excludedFields = $a;
154
155
        return $this;
156
    }
157
158
    public function setWordsAsString(string $s): SearchApi
159
    {
160
        $this->words = explode(' ', $s);
161
162
        return $this;
163
    }
164
165
    public function setWords(array $a): SearchApi
166
    {
167
        $this->words = array_combine($a, $a);
168
169
        return $this;
170
    }
171
172
    public function addWord(string $s): SearchApi
173
    {
174
        $this->words[$s] = $s;
175
176
        return $this;
177
    }
178
179
    public function getFileCache()
180
    {
181
        return Injector::inst()->get(Cache::class);
182
    }
183
184
    public function initCache(): self
185
    {
186
        $this->cache = $this->getFileCache()->getCacheValues(self::CACHE_NAME);
187
188
        return $this;
189
    }
190
191
    public function saveCache(): self
192
    {
193
        $this->getFileCache()->setCacheValues(self::CACHE_NAME, $this->cache);
194
195
        return $this;
196
    }
197
198
    // public function __construct()
199
    // {
200
    //     Environment::increaseTimeLimitTo(300);
201
    //     Environment::setMemoryLimitMax(-1);
202
    //     Environment::increaseMemoryLimitTo(-1);
203
    // }
204
205
    public function buildCache(?string $word = ''): SearchApi
206
    {
207
        $this->getLinksInner($word);
208
209
        return $this;
210
211
    }
212
213
    public function getLinks(?string $word = ''): ArrayList
214
    {
215
        return $this->getLinksInner($word);
216
    }
217
218
    protected function getLinksInner(?string $word = ''): ArrayList
219
    {
220
        $this->initCache();
221
222
        //always do first ...
223
        $matches = $this->getMatches($word);
224
225
        $list = $this->turnMatchesIntoList($matches);
226
227
        $this->saveCache();
228
229
        return $list;
230
231
    }
232
233
    public function doReplacement(string $word, string $replace): int
234
    {
235
        $this->initCache();
236
        $count = 0;
237
        // we should have these already.
238
        foreach ($this->objects as $item) {
239
            if ($item->canEdit()) {
240
                $fields = $this->getAllValidFields($item->ClassName);
241
                foreach ($fields as $field) {
242
                    $new = str_replace($word, $replace, $item->{$field});
243
                    if ($new !== $item->{$field}) {
244
                        ++$count;
245
                        $item->{$field} = $new;
246
                        $isPublished = false;
247
                        if ($item->hasMethod('isPublished')) {
248
                            $isPublished = $item->isPublished();
249
                        }
250
251
                        $item->write();
252
                        if ($isPublished) {
253
                            $item->publishRecursive();
254
                        }
255
256
                        if ($this->debug) {
257
                            DB::alteration_message('<h2>Match:  ' . $item->ClassName . $item->ID . '</h2>' . $new . '<hr />');
258
                        }
259
                    }
260
                }
261
            }
262
        }
263
264
        return $count;
265
    }
266
267
    protected function getMatches(?string $word = ''): array
268
    {
269
        $startInner = 0;
270
        $startOuter = 0;
271
        if ($this->debug) {
272
            $startOuter = microtime(true);
273
        }
274
        $this->workOutExclusions();
275
        $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

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