Passed
Push — master ( 15dbed...23823a )
by Nicolaas
17:52 queued 06:19
created

SearchApi::turnArrayIntoObjects()   C

Complexity

Conditions 12
Paths 5

Size

Total Lines 48
Code Lines 29

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 1
Metric Value
cc 12
eloc 29
c 3
b 0
f 1
nc 5
nop 2
dl 0
loc 48
rs 6.9666

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\Configurable;
7
use SilverStripe\Core\Convert;
8
use SilverStripe\Core\Extensible;
9
use SilverStripe\Core\Injector\Injectable;
10
use SilverStripe\Core\Injector\Injector;
11
use SilverStripe\ORM\ArrayList;
12
use SilverStripe\ORM\DataObject;
13
use SilverStripe\ORM\DB;
14
use SilverStripe\ORM\FieldType\DBDatetime;
15
use SilverStripe\ORM\FieldType\DBField;
16
use SilverStripe\Security\LoginAttempt;
17
use SilverStripe\Security\MemberPassword;
18
use SilverStripe\Security\RememberLoginHash;
19
use SilverStripe\SessionManager\Models\LoginSession;
20
use SilverStripe\Versioned\ChangeSet;
21
use SilverStripe\Versioned\ChangeSetItem;
22
use SilverStripe\Versioned\Versioned;
23
use SilverStripe\View\ArrayData;
24
use Sunnysideup\SiteWideSearch\Helpers\FindClassesAndFields;
25
use Sunnysideup\SiteWideSearch\Helpers\FindEditableObjects;
26
27
class SearchApi
28
{
29
    use Extensible;
30
    use Configurable;
31
    use Injectable;
32
33
    protected $debug = false;
34
35
    protected $isQuickSearch = false;
36
37
    protected $searchWholePhrase = false;
38
39
    protected $baseClass = DataObject::class;
40
41
    protected $quickSearchType = 'limited';
42
43
    protected $excludedClasses = [];
44
45
    protected $excludedClassesWithSubClassess = [];
46
47
    protected $includedClasses = [];
48
49
    protected $includedClassesWithSubClassess = [];
50
51
    protected $excludedFields = [];
52
53
    protected $includedFields = [];
54
55
    protected $includedClassFieldCombos = [];
56
57
    protected $defaultLists = [];
58
59
    protected $sortOverride;
60
61
    protected $words = [];
62
63
    protected $replace = '';
64
65
    private $objects = [];
66
67
    private static $limit_of_count_per_data_object = 999;
68
69
    private static $hours_back_for_recent = 48;
70
71
    private static $limit_per_class_for_recent = 5;
72
73
    private static $default_exclude_classes = [
74
        MemberPassword::class,
75
        LoginAttempt::class,
76
        ChangeSet::class,
77
        ChangeSetItem::class,
78
        RememberLoginHash::class,
79
        LoginSession::class,
80
    ];
81
82
    private static $default_exclude_fields = [
83
        'ClassName',
84
        'LastEdited',
85
        'Created',
86
        'ID',
87
        'CanViewType',
88
        'CanEditType',
89
    ];
90
91
    private static $default_include_classes = [];
92
93
    private static $default_include_fields = [];
94
95
    private static $default_include_class_field_combos = [];
96
97
    private static $default_lists = [];
98
99
    public function setDebug(bool $b): SearchApi
100
    {
101
        $this->debug = $b;
102
103
        return $this;
104
    }
105
106
    protected function getCache()
107
    {
108
        return FindClassesAndFields::inst($this->baseClass);
109
    }
110
111
    public function setQuickSearchType(string $nameOrType): SearchApi
112
    {
113
        if ($nameOrType === 'all') {
114
            $this->isQuickSearch = false;
115
            $this->quickSearchType = '';
116
        } elseif ($nameOrType === 'limited') {
117
            $this->isQuickSearch = true;
118
            $this->quickSearchType = '';
119
        } elseif (class_exists($nameOrType)) {
120
            $this->quickSearchType = $nameOrType;
121
            $object = Injector::inst()->get($nameOrType);
122
            $this->setIncludedClasses($object->getClassesToSearch());
123
            $this->setIncludedFields($object->getFieldsToSearch());
124
            $this->setIncludedClassFieldCombos($object->getIncludedClassFieldCombos());
125
            $this->setDefaultLists($object->getDefaultLists());
126
            $this->setSortOverride($object->getSortOverride());
127
        } else {
128
            user_error('QuickSearchType must be either "all" or "limited" or a defined quick search class. Provided was: ' . $nameOrType);
129
        }
130
131
        return $this;
132
    }
133
134
    public function setIsQuickSearch(bool $b): SearchApi
135
    {
136
        $this->isQuickSearch = $b;
137
138
        return $this;
139
    }
140
141
    public function setSearchWholePhrase(bool $b): SearchApi
142
    {
143
        $this->searchWholePhrase = $b;
144
145
        return $this;
146
    }
147
148
    public function setBaseClass(string $class): SearchApi
149
    {
150
        if (class_exists($class)) {
151
            $this->baseClass = $class;
152
        }
153
154
        return $this;
155
    }
156
157
    public function setExcludedClasses(array $a): SearchApi
158
    {
159
        $this->excludedClasses = $a;
160
161
        return $this;
162
    }
163
164
    public function setIncludedClasses(array $a): SearchApi
165
    {
166
        $this->includedClasses = $a;
167
        return $this;
168
    }
169
170
    public function setExcludedFields(array $a): SearchApi
171
    {
172
        $this->excludedFields = $a;
173
174
        return $this;
175
    }
176
177
    public function setIncludedFields(array $a): SearchApi
178
    {
179
        $this->includedFields = $a;
180
181
        return $this;
182
    }
183
184
    public function setIncludedClassFieldCombos(array $a): SearchApi
185
    {
186
        $this->includedClassFieldCombos = $a;
187
188
        return $this;
189
    }
190
191
    public function setDefaultLists(array $a): SearchApi
192
    {
193
        $this->defaultLists = $a;
194
195
        return $this;
196
    }
197
198
    public function setSortOverride(?array $a = null): SearchApi
199
    {
200
        $this->sortOverride = $a;
201
202
        return $this;
203
    }
204
205
    public function setWordsAsString(string $s): SearchApi
206
    {
207
        $s = $this->securityCheckInput($s);
208
        $this->words = explode(' ', $s);
209
210
        return $this;
211
    }
212
213
    // public function __construct()
214
    // {
215
    //     Environment::increaseTimeLimitTo(300);
216
    //     Environment::setMemoryLimitMax(-1);
217
    //     Environment::increaseMemoryLimitTo(-1);
218
    // }
219
220
    protected $cacheHasBeenBuilt;
221
222
    public function buildCache(?string $word = ''): SearchApi
223
    {
224
        if ($this->cacheHasBeenBuilt !== $word) {
225
            $this->getLinksInner($word);
226
            $this->cacheHasBeenBuilt = $word;
227
        }
228
        return $this;
229
    }
230
231
    public function getLinks(?string $word = ''): ArrayList
232
    {
233
        return $this->getLinksInner($word);
234
    }
235
236
    protected function getLinksInner(?string $word = ''): ArrayList
237
    {
238
        $this->initCache();
239
240
        //always do first ...
241
        $matches = $this->getMatches($word);
242
243
        $list = $this->turnMatchesIntoList($matches);
244
245
        $this->saveCache();
246
        return $list;
247
    }
248
249
    public function doReplacement(string $word, string $replace): int
250
    {
251
        $count = 0;
252
        if ($word !== '' && $word !== '0') {
253
            $this->buildCache($word);
254
            $replace = $this->securityCheckInput($replace);
255
            foreach ($this->objects as $item) {
256
                $className = $item->ClassName;
257
                if ($item->canEdit()) {
258
                    $fields = $this->getAllValidFields($className);
259
                    foreach ($fields as $field) {
260
                        if (! $this->includeFieldTest($className, $field)) {
261
                            continue;
262
                        }
263
                        $new = str_replace($word, $replace, $item->{$field});
264
                        if ($new !== $item->{$field}) {
265
                            ++$count;
266
                            $item->{$field} = $new;
267
                            $this->writeAndPublishIfAppropriate($item);
268
269
                            if ($this->debug) {
270
                                DB::alteration_message('<h2>Match:  ' . $item->ClassName . $item->ID . '</h2>' . $new . '<hr />');
271
                            }
272
                        }
273
                    }
274
                }
275
            }
276
        }
277
278
        return $count;
279
    }
280
281
    protected function saveCache(): self
282
    {
283
        $this->getCache()->saveCache();
284
285
        return $this;
286
    }
287
288
    protected function initCache(): self
289
    {
290
        $this->getCache()->initCache();
291
292
        return $this;
293
    }
294
295
    protected function writeAndPublishIfAppropriate($item)
296
    {
297
        if ($item->hasExtension(Versioned::class)) {
298
            $myStage = Versioned::get_stage();
299
            Versioned::set_stage(Versioned::DRAFT);
300
            // is it on live and is live the same as draft
301
            $canBePublished = $item->isPublished() && ! $item->isModifiedOnDraft();
302
            $item->writeToStage(Versioned::DRAFT);
303
            if ($canBePublished) {
304
                $item->publishSingle();
305
            }
306
            Versioned::set_stage($myStage);
307
        } else {
308
            $item->write();
309
        }
310
    }
311
312
    protected function getMatches(?string $word = ''): array
313
    {
314
        $startInner = 0;
315
        $startOuter = 0;
316
        if ($this->debug) {
317
            $startOuter = microtime(true);
318
        }
319
        $this->workOutInclusionsAndExclusions();
320
321
        // important to do this first
322
        if ($word) {
323
            $this->setWordsAsString($word);
324
        }
325
        $this->workOutWordsForSearching();
326
        if ($this->debug) {
327
            DB::alteration_message('Words searched for ' . print_r($this->words, 1));
0 ignored issues
show
Bug introduced by
Are you sure print_r($this->words, 1) of type string|true can be used in concatenation? ( Ignorable by Annotation )

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

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