Passed
Push — master ( e38cd3...9da129 )
by Nicolaas
10:43
created

SearchApi::doReplacement()   B

Complexity

Conditions 8
Paths 12

Size

Total Lines 28
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 18
c 0
b 0
f 0
nc 12
nop 2
dl 0
loc 28
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\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
use Sunnysideup\SiteWideSearch\Helpers\Cache;
24
use Sunnysideup\SiteWideSearch\Helpers\FindEditableObjects;
25
26
class SearchApi
27
{
28
    use Extensible;
29
    use Configurable;
30
    use Injectable;
31
32
    /**
33
     * @var string
34
     */
35
    private const CACHE_NAME = 'SearchApi';
36
37
    protected $debug = false;
38
39
    protected $isQuickSearch = false;
40
41
    protected $baseClass = DataObject::class;
42
43
    protected $excludedClasses = [];
44
45
    protected $excludedFields = [];
46
47
    protected $words = [];
48
49
    protected $replace = '';
50
51
    private $objects = [];
52
53
    /**
54
     * format is as follows:
55
     * ```php
56
     *      [
57
     *          'AllDataObjects' => [
58
     *              'BaseClassUsed' => [
59
     *                  0 => ClassNameA,
60
     *                  1 => ClassNameB,
61
     *              ],
62
     *          ],
63
     *          'AllValidFields' => [
64
     *              'ClassNameA' => [
65
     *                  'FieldA' => 'FieldA'
66
     *              ],
67
     *          ],
68
     *          'IndexedFields' => [
69
     *              'ClassNameA' => [
70
     *                  0 => ClassNameA,
71
     *                  1 => ClassNameB,
72
     *              ],
73
     *          ],
74
     *          'ListOfTextClasses' => [
75
     *              0 => ClassNameA,
76
     *              1 => ClassNameB,
77
     *          ],
78
     *          'ValidFieldTypes' => [
79
     *              'Varchar(30)' => true,
80
     *              'Boolean' => false,
81
     *          ],
82
     *     ],
83
     * ```
84
     * we use true rather than false to be able to use empty to work out if it has been tested before.
85
     *
86
     * @var array
87
     */
88
    protected $cache = [];
89
90
    private static $limit_of_count_per_data_object = 999;
91
92
    private static $hours_back_for_recent = 48;
93
94
    private static $limit_per_class_for_recent = 5;
95
96
    private static $default_exclude_classes = [
97
        MemberPassword::class,
98
        LoginAttempt::class,
99
        ChangeSet::class,
100
        ChangeSetItem::class,
101
        RememberLoginHash::class,
102
    ];
103
104
    private static $default_exclude_fields = [
105
        'ClassName',
106
        'LastEdited',
107
        'Created',
108
        'ID',
109
    ];
110
111
    public function setDebug(bool $b): SearchApi
112
    {
113
        $this->debug = $b;
114
115
        return $this;
116
    }
117
118
    public function setIsQuickSearch(bool $b): SearchApi
119
    {
120
        $this->isQuickSearch = $b;
121
122
        return $this;
123
    }
124
125
    public function setBaseClass(string $class): SearchApi
126
    {
127
        $this->baseClass = $class;
128
129
        return $this;
130
    }
131
132
    public function setExcludedClasses(array $a): SearchApi
133
    {
134
        $this->excludedClasses = $a;
135
136
        return $this;
137
    }
138
139
    public function setExcludedFields(array $a): SearchApi
140
    {
141
        $this->excludedFields = $a;
142
143
        return $this;
144
    }
145
146
    public function setWordsAsString(string $s): SearchApi
147
    {
148
        $this->words = explode(' ', $s);
149
150
        return $this;
151
    }
152
153
    public function setWords(array $a): SearchApi
154
    {
155
        $this->words = array_combine($a, $a);
156
157
        return $this;
158
    }
159
160
    public function addWord(string $s): SearchApi
161
    {
162
        $this->words[$s] = $s;
163
164
        return $this;
165
    }
166
167
    public function getFileCache()
168
    {
169
        return Injector::inst()->get(Cache::class);
170
    }
171
172
    public function initCache(): self
173
    {
174
        $this->cache = $this->getFileCache()->getCacheValues(self::CACHE_NAME);
175
176
        return $this;
177
    }
178
179
    public function saveCache(): self
180
    {
181
        $this->getFileCache()->setCacheValues(self::CACHE_NAME, $this->cache);
182
183
        return $this;
184
    }
185
186
    // public function __construct()
187
    // {
188
    //     Environment::increaseTimeLimitTo(300);
189
    //     Environment::setMemoryLimitMax(-1);
190
    //     Environment::increaseMemoryLimitTo(-1);
191
    // }
192
193
    public function getLinks(?string $word = ''): ArrayList
194
    {
195
        $this->initCache();
196
197
        //always do first ...
198
        $matches = $this->getMatches($word);
0 ignored issues
show
Bug introduced by
It seems like $word can also be of type null; however, parameter $word of Sunnysideup\SiteWideSear...SearchApi::getMatches() 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

198
        $matches = $this->getMatches(/** @scrutinizer ignore-type */ $word);
Loading history...
199
200
        $list = $this->turnMatchesIntoList($matches);
201
202
        $this->saveCache();
203
204
        return $list;
205
    }
206
207
    public function doReplacement(string $word, string $replace): int
208
    {
209
        $this->initCache();
210
        $count = 0;
211
        // we should have these already.
212
        foreach($this->objects as $item) {
213
            if($item->canEdit()) {
214
                $fields = $this->getAllValidFields($item->ClassName);
215
                foreach ($fields as $field) {
216
                    $new = str_replace($word, $replace, $item->$field);
217
                    if($new !== $item->$field) {
218
                        $count++;
219
                        $item->$field = $new;
220
                        $isPublished = false;
221
                        if($item->hasMethod('isPublished')) {
222
                            $isPublished = $item->isPublished();
223
                        }
224
                        $item->write();
225
                        if($isPublished) {
226
                            $item->publishRecursive();
227
                        }
228
                        if ($this->debug) { DB::alteration_message('<h2>Match:  '.$item->ClassName.$item->ID.'</h2>'.$new.'<hr />');}
229
                    }
230
                }
231
            }
232
        }
233
234
        return $count;
235
    }
236
237
    protected function getMatches(string $word = ''): array
238
    {
239
        if ($this->debug) {$startOuter = microtime(true);}
240
241
        $this->workOutExclusions();
242
        $this->workOutWords($word);
243
244
        if ($this->debug) {DB::alteration_message('Words searched for ' . implode(', ', $this->words));}
245
        $array = [];
246
247
        if (count($this->words)) {
248
            foreach ($this->getAllDataObjects() as $className) {
249
                if ($this->debug) {DB::alteration_message(' ... Searching in ' . $className);}
250
                if (! in_array($className, $this->excludedClasses, true)) {
251
                    $array[$className] = [];
252
                    $fields = $this->getAllValidFields($className);
253
                    $filterAny = [];
254
                    foreach ($fields as $field) {
255
                        if (! in_array($field, $this->excludedFields, true)) {
256
                            if ($this->debug) {DB::alteration_message(' ... ... Searching in ' . $className . '.' . $field);}
257
                            $filterAny[$field . ':PartialMatch'] = $this->words;
258
                        }
259
                    }
260
261
                    if ([] !== $filterAny) {
262
                        if ($this->debug) {$startInner = microtime(true); DB::alteration_message(' ... Filter: ' . implode(', ', array_keys($filterAny)));}
263
                        $array[$className] = $className::get()
264
                            ->filterAny($filterAny)
265
                            ->limit($this->Config()->get('limit_of_count_per_data_object'))
266
                            ->column('ID')
267
                        ;
268
                        if ($this->debug) {$elaps = microtime(true) - $startInner;DB::alteration_message('search for ' . $className . ' taken : ' . $elaps);}
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $startInner does not seem to be defined for all execution paths leading up to this point.
Loading history...
269
                    }
270
271
                    if ($this->debug) {DB::alteration_message(' ... No fields in ' . $className);}
272
                }
273
274
                if ($this->debug) {DB::alteration_message(' ... Skipping ' . $className);}
275
            }
276
        } else {
277
            $array = $this->getDefaultList();
278
        }
279
280
        if ($this->debug) {$elaps = microtime(true) - $startOuter;DB::alteration_message('seconds taken find results: ' . $elaps);}
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $startOuter does not seem to be defined for all execution paths leading up to this point.
Loading history...
281
        return $array;
282
    }
283
284
    protected function getDefaultList(): array
285
    {
286
        $back = $this->config()->get('hours_back_for_recent') ?? 24;
287
        $limit = $this->Config()->get('limit_per_class_for_recent') ?? 5;
288
        $threshold = strtotime('-' . $back . ' hours', DBDatetime::now()->getTimestamp());
289
        if (! $threshold) {
290
            $threshold = time() - 86400;
291
        }
292
293
        $array = [];
294
        $classNames = $this->getAllDataObjects();
295
        foreach ($classNames as $className) {
296
            if (! in_array($className, $this->excludedClasses, true)) {
297
                $array[$className] = $className::get()
298
                    ->filter('LastEdited:GreaterThan', date('Y-m-d H:i:s', $threshold))
299
                    ->sort('LastEdited', 'DESC')
300
                    ->limit($limit)
301
                    ->column('ID')
302
                ;
303
            }
304
        }
305
306
        return $array;
307
    }
308
309
    protected function turnArrayIntoObjects(array $matches, ?int $limit = 0) : array
310
    {
311
        if(empty($this->objects)) {
312
            if(! $limit) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $limit of type integer|null is loosely compared to false; this is ambiguous if the integer can be 0. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
313
                $limit = $this->Config()->get('limit_of_count_per_data_object');
314
            }
315
            $this->objects = [];
316
            if ($this->debug) {DB::alteration_message('number of classes: ' . count($matches));}
317
            foreach ($matches as $className => $ids) {
318
                if ($this->debug) {$start = microtime(true);DB::alteration_message(' ... number of matches for : ' . $className . ': ' . count($ids));}
319
                if (count($ids)) {
320
                    $className = (string) $className;
321
                    $items = $className::get()
322
                        ->filter(['ID' => $ids, 'ClassName' => $className])
323
                        ->limit($limit)
324
                    ;
325
                    foreach ($items as $item) {
326
                        if ($item->canView()) {
327
                            $this->objects[] = $item;
328
                        }
329
                    }
330
                }
331
                if ($this->debug) {$elaps = microtime(true) - $start;DB::alteration_message('seconds taken to find objects in: ' . $className . ': ' . $elaps);}
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $start does not seem to be defined for all execution paths leading up to this point.
Loading history...
332
            }
333
        }
334
335
        return $this->objects;
336
    }
337
338
    protected function turnMatchesIntoList(array $matches): ArrayList
339
    {
340
        // helper
341
        //return values
342
        $list = ArrayList::create();
343
        $finder = Injector::inst()->get(FindEditableObjects::class);
344
        $finder->initCache();
345
        $items = $this->turnArrayIntoObjects($matches);
346
        foreach($items as $item) {
347
            $link = $finder->getLink($item, $this->excludedClasses);
348
            $cmsEditLink = '';
349
            if ($item->canEdit()) {
350
                $cmsEditLink = $finder->getCMSEditLink($item, $this->excludedClasses);
351
            }
352
353
            $list->push(
354
                ArrayData::create(
355
                    [
356
                        'HasLink' => (bool) $link,
357
                        'HasCMSEditLink' => (bool) $cmsEditLink,
358
                        'Link' => $link,
359
                        'CMSEditLink' => $cmsEditLink,
360
                        'Object' => $item,
361
                        'SiteWideSearchSortValue' => $this->getSortValue($item),
362
                    ]
363
                )
364
            );
365
        }
366
367
        $finder->saveCache();
368
369
        return $list->sort('SiteWideSearchSortValue', 'ASC');
370
    }
371
372
    protected function getSortValue($item)
373
    {
374
        $className = $item->ClassName;
375
        $fields = $this->getAllValidFields($className);
376
        $fullWords = implode(' ', $this->words);
377
378
        $done = false;
379
        $score = 0;
380
        if ($fullWords) {
381
            $fieldValues = [];
382
            $fieldValuesAll = '';
0 ignored issues
show
Unused Code introduced by
The assignment to $fieldValuesAll is dead and can be removed.
Loading history...
383
            foreach ($fields as $field) {
384
                $fieldValues[$field] = strtolower(strip_tags($item->{$field}));
385
            }
386
387
            $fieldValuesAll = implode(' ', $fieldValues);
388
            $testWords = array_merge(
389
                [$fullWords],
390
                $this->words
391
            );
392
            $testWords = array_unique($testWords);
393
            foreach ($testWords as $wordKey => $word) {
394
                //match a exact field to full words / one word
395
                $fullWords = ! (bool) $wordKey;
396
                if (false === $done) {
397
                    $count = 0;
398
                    foreach ($fieldValues as $fieldValue) {
399
                        ++$count;
400
                        if ($fieldValue === $word) {
401
                            $score += (int) $wordKey + $count;
402
                            $done = true;
403
404
                            break;
405
                        }
406
                    }
407
                }
408
409
                // the full string / any of the words are present?
410
                if (false === $done) {
411
                    $pos = strpos($fieldValuesAll, $word);
412
                    if (false !== $pos) {
413
                        $score += (($pos + 1) / strlen($word)) * 1000;
414
                        $done = true;
415
                    }
416
                }
417
418
                // all individual words are present
419
                if (false === $done) {
420
                    if ($fullWords) {
421
                        $score += 1000;
422
                        $allMatch = true;
423
                        foreach ($this->words as $tmpWord) {
424
                            $pos = strpos($fieldValuesAll, $tmpWord);
425
                            if (false === $pos) {
426
                                $allMatch = false;
427
428
                                break;
429
                            }
430
                        }
431
432
                        if ($allMatch) {
433
                            $done = true;
434
                        }
435
                    }
436
                }
437
            }
438
        }
439
440
        //the older the item, the higher the scoare
441
        //1104622247 = 1 jan 2005
442
        return $score + (1 / (strtotime($item->LastEdited) - 1104537600));
443
    }
444
445
    protected function workOutExclusions()
446
    {
447
        $this->excludedClasses = array_unique(
448
            array_merge(
449
                $this->Config()->get('default_exclude_classes'),
450
                $this->excludedClasses
451
            )
452
        );
453
        $this->excludedFields = array_unique(
454
            array_merge(
455
                $this->Config()->get('default_exclude_fields'),
456
                $this->excludedFields
457
            )
458
        );
459
    }
460
461
    protected function workOutWords(string $word = ''): array
462
    {
463
        if ($word) {
464
            $this->words[] = $word;
465
        }
466
467
        if (! count($this->words)) {
468
            user_error('No word has been provided');
469
        }
470
471
        $this->words = array_unique($this->words);
472
        $this->words = array_filter($this->words);
473
        $this->words = array_map('strtolower', $this->words);
474
475
        return $this->words;
476
    }
477
478
    protected function getAllDataObjects(): array
479
    {
480
        if ($this->debug) {DB::alteration_message('Base Class: ' . $this->baseClass);}
481
        if (! isset($this->cache['AllDataObjects'][$this->baseClass])) {
482
            $this->cache['AllDataObjects'][$this->baseClass] = array_values(
483
                ClassInfo::subclassesFor($this->baseClass, false)
484
            );
485
            $this->cache['AllDataObjects'][$this->baseClass] = array_unique($this->cache['AllDataObjects'][$this->baseClass]);
486
        }
487
488
        return $this->cache['AllDataObjects'][$this->baseClass];
489
    }
490
491
    protected function getAllValidFields(string $className): array
492
    {
493
        if (! isset($this->cache['AllValidFields'][$className])) {
494
            $array = [];
495
            $fullList = Config::inst()->get($className, 'db');
496
            if (is_array($fullList)) {
497
                if ($this->isQuickSearch) {
498
                    $fullList = $this->getIndexedFields(
499
                        $className,
500
                        $fullList
501
                    );
502
                }
503
504
                foreach ($fullList as $name => $type) {
505
                    if ($this->isValidFieldType($className, $name, $type)) {
506
                        $array[] = $name;
507
                    }
508
                }
509
            }
510
511
            $this->cache['AllValidFields'][$className] = $array;
512
        }
513
514
        return $this->cache['AllValidFields'][$className];
515
    }
516
517
    protected function getIndexedFields(string $className, array $dbFields): array
518
    {
519
        if (! isset($this->cache['IndexedFields'][$className])) {
520
            $this->cache['IndexedFields'][$className] = [];
521
            $indexes = Config::inst()->get($className, 'indexes');
522
            if (is_array($indexes)) {
523
                foreach ($indexes as $key => $field) {
524
                    if (isset($dbFields[$key])) {
525
                        $this->cache['IndexedFields'][$className][$key] = $dbFields[$key];
526
                    } elseif (is_array($field)) {
527
                        foreach ($field as $test) {
528
                            if (is_array($test)) {
529
                                if (isset($test['columns'])) {
530
                                    $test = $test['columns'];
531
                                } else {
532
                                    continue;
533
                                }
534
                            }
535
536
                            $testArray = explode(',', $test);
537
                            foreach ($testArray as $testInner) {
538
                                $testInner = trim($testInner);
539
                                if (isset($dbFields[$testInner])) {
540
                                    $this->cache['IndexedFields'][$className][$testInner] = $dbFields[$key];
541
                                }
542
                            }
543
                        }
544
                    }
545
                }
546
            }
547
        }
548
549
        return $this->cache['IndexedFields'][$className];
550
    }
551
552
    protected function isValidFieldType(string $className, string $fieldName, string $type): bool
553
    {
554
        if (! isset($this->cache['ValidFieldTypes'][$type])) {
555
            $this->cache['ValidFieldTypes'][$type] = false;
556
            $singleton = Injector::inst()->get($className);
557
            $field = $singleton->dbObject($fieldName);
558
            if ($field instanceof DBString) {
559
                $this->cache['ValidFieldTypes'][$type] = true;
560
            }
561
        }
562
563
        return $this->cache['ValidFieldTypes'][$type];
564
    }
565
}
566