Passed
Push — master ( 998027...4a541f )
by Nicolaas
06:15 queued 03:12
created

SearchApi::workOutWords()   A

Complexity

Conditions 4
Paths 8

Size

Total Lines 19
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

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

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