Passed
Push — master ( e05871...d431a7 )
by Nicolaas
04:03
created

SearchApi::getIndexedFields()   B

Complexity

Conditions 11
Paths 3

Size

Total Lines 33
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 11
eloc 20
c 1
b 0
f 0
nc 3
nop 2
dl 0
loc 33
rs 7.3166

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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 $searchWholePhrase = false;
42
43
    protected $baseClass = DataObject::class;
44
45
    protected $excludedClasses = [];
46
47
    protected $excludedFields = [];
48
49
    protected $words = [];
50
51
    protected $replace = '';
52
53
    private $objects = [];
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 static $limit_of_count_per_data_object = 999;
93
94
    private static $hours_back_for_recent = 48;
95
96
    private static $limit_per_class_for_recent = 5;
97
98
    private static $default_exclude_classes = [
99
        MemberPassword::class,
100
        LoginAttempt::class,
101
        ChangeSet::class,
102
        ChangeSetItem::class,
103
        RememberLoginHash::class,
104
    ];
105
106
    private static $default_exclude_fields = [
107
        'ClassName',
108
        'LastEdited',
109
        'Created',
110
        'ID',
111
    ];
112
113
    public function setDebug(bool $b): SearchApi
114
    {
115
        $this->debug = $b;
116
117
        return $this;
118
    }
119
120
    public function setIsQuickSearch(bool $b): SearchApi
121
    {
122
        $this->isQuickSearch = $b;
123
124
        return $this;
125
    }
126
127
    public function setSearchWholePhrase(bool $b): SearchApi
128
    {
129
        $this->searchWholePhrase = $b;
130
131
        return $this;
132
    }
133
134
    public function setBaseClass(string $class): SearchApi
135
    {
136
        $this->baseClass = $class;
137
138
        return $this;
139
    }
140
141
    public function setExcludedClasses(array $a): SearchApi
142
    {
143
        $this->excludedClasses = $a;
144
145
        return $this;
146
    }
147
148
    public function setExcludedFields(array $a): SearchApi
149
    {
150
        $this->excludedFields = $a;
151
152
        return $this;
153
    }
154
155
    public function setWordsAsString(string $s): SearchApi
156
    {
157
        $this->words = explode(' ', $s);
158
159
        return $this;
160
    }
161
162
    public function setWords(array $a): SearchApi
163
    {
164
        $this->words = array_combine($a, $a);
165
166
        return $this;
167
    }
168
169
    public function addWord(string $s): SearchApi
170
    {
171
        $this->words[$s] = $s;
172
173
        return $this;
174
    }
175
176
    public function getFileCache()
177
    {
178
        return Injector::inst()->get(Cache::class);
179
    }
180
181
    public function initCache(): self
182
    {
183
        $this->cache = $this->getFileCache()->getCacheValues(self::CACHE_NAME);
184
185
        return $this;
186
    }
187
188
    public function saveCache(): self
189
    {
190
        $this->getFileCache()->setCacheValues(self::CACHE_NAME, $this->cache);
191
192
        return $this;
193
    }
194
195
    // public function __construct()
196
    // {
197
    //     Environment::increaseTimeLimitTo(300);
198
    //     Environment::setMemoryLimitMax(-1);
199
    //     Environment::increaseMemoryLimitTo(-1);
200
    // }
201
202
    public function getLinks(?string $word = ''): ArrayList
203
    {
204
        $this->initCache();
205
206
        //always do first ...
207
        $matches = $this->getMatches($word);
208
209
        $list = $this->turnMatchesIntoList($matches);
210
211
        $this->saveCache();
212
213
        return $list;
214
    }
215
216
    public function doReplacement(string $word, string $replace): int
217
    {
218
        $this->initCache();
219
        $count = 0;
220
        // we should have these already.
221
        foreach($this->objects as $item) {
222
            if($item->canEdit()) {
223
                $fields = $this->getAllValidFields($item->ClassName);
224
                foreach ($fields as $field) {
225
                    $new = str_replace($word, $replace, $item->$field);
226
                    if($new !== $item->$field) {
227
                        $count++;
228
                        $item->$field = $new;
229
                        $isPublished = false;
230
                        if($item->hasMethod('isPublished')) {
231
                            $isPublished = $item->isPublished();
232
                        }
233
                        $item->write();
234
                        if($isPublished) {
235
                            $item->publishRecursive();
236
                        }
237
                        if ($this->debug) { DB::alteration_message('<h2>Match:  '.$item->ClassName.$item->ID.'</h2>'.$new.'<hr />');}
238
                    }
239
                }
240
            }
241
        }
242
243
        return $count;
244
    }
245
246
    protected function getMatches(?string $word = ''): array
247
    {
248
        if ($this->debug) {$startOuter = microtime(true);}
249
250
        $this->workOutExclusions();
251
        $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

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