Issues (10)

src/Api/SearchApi.php (3 issues)

1
<?php
2
3
namespace Sunnysideup\SiteWideSearch\Api;
4
5
use SilverStripe\Control\Director;
6
use SilverStripe\Core\ClassInfo;
7
use SilverStripe\Core\Config\Configurable;
8
use SilverStripe\Core\Convert;
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\DBField;
17
use SilverStripe\Security\LoginAttempt;
18
use SilverStripe\Security\MemberPassword;
19
use SilverStripe\Security\RememberLoginHash;
20
use SilverStripe\SessionManager\Models\LoginSession;
21
use SilverStripe\Versioned\ChangeSet;
22
use SilverStripe\Versioned\ChangeSetItem;
23
use SilverStripe\Versioned\Versioned;
24
use SilverStripe\View\ArrayData;
25
use Sunnysideup\SiteWideSearch\Helpers\FindClassesAndFields;
26
use Sunnysideup\SiteWideSearch\Helpers\FindEditableObjects;
27
28
class SearchApi
29
{
30
    use Extensible;
31
    use Configurable;
32
    use Injectable;
33
34
    protected $debug = false;
35
36
    protected $showReplacements = false;
37
38
    protected $isQuickSearch = false;
39
40
    protected $dryRunForReplacement = false;
41
42
    protected $searchWholePhrase = false;
43
44
    protected $bypassCanMethods = false;
45
46
    protected $baseClass = DataObject::class;
47
48
    protected $quickSearchType = 'limited';
49
50
    protected $excludedClasses = [];
51
52
    protected $excludedClassesWithSubClassess = [];
53
54
    protected $includedClasses = [];
55
56
    protected $includedClassesWithSubClassess = [];
57
58
    protected $excludedFields = [];
59
60
    protected $includedFields = [];
61
62
    protected $includedClassFieldCombos = [];
63
64
    protected $defaultLists = [];
65
66
    protected $sortOverride;
67
68
    protected $words = [];
69
70
    protected $replace = '';
71
72
    private $objects = [];
73
74
    private static $limit_of_count_per_data_object = 999;
75
76
    private static $hours_back_for_recent = 48;
77
78
    private static $limit_per_class_for_recent = 5;
79
80
    private static $default_exclude_classes = [
81
        MemberPassword::class,
82
        LoginAttempt::class,
83
        ChangeSet::class,
84
        ChangeSetItem::class,
85
        RememberLoginHash::class,
86
        LoginSession::class,
87
        'SilverStripe\\UserForms\\Model\\Submission\\SubmittedFormField'
88
    ];
89
90
    private static $default_exclude_fields = [
91
        'ClassName',
92
        'LastEdited',
93
        'Created',
94
        'ID',
95
        'CanViewType',
96
        'CanEditType',
97
    ];
98
99
    private static $default_include_classes = [];
100
101
    private static $default_include_fields = [];
102
103
    private static $default_include_class_field_combos = [];
104
105
    private static $default_lists = [];
106
107
    public function setDebug(bool $b): SearchApi
108
    {
109
        $this->debug = $b;
110
111
        return $this;
112
    }
113
114
    public function setShowReplacements(bool $b): SearchApi
115
    {
116
        $this->showReplacements = $b;
117
118
        return $this;
119
    }
120
121
    protected function getCache()
122
    {
123
        return FindClassesAndFields::inst($this->baseClass);
124
    }
125
126
    public function setQuickSearchType(string $nameOrType): SearchApi
127
    {
128
        if ($nameOrType === 'all') {
129
            $this->isQuickSearch = false;
130
            $this->quickSearchType = '';
131
        } elseif ($nameOrType === 'limited') {
132
            $this->isQuickSearch = true;
133
            $this->quickSearchType = '';
134
        } elseif (class_exists($nameOrType)) {
135
            $this->quickSearchType = $nameOrType;
136
            $object = Injector::inst()->get($nameOrType);
137
            $this->setIncludedClasses($object->getClassesToSearch());
138
            $this->setIncludedFields($object->getFieldsToSearch());
139
            $this->setIncludedClassFieldCombos($object->getIncludedClassFieldCombos());
140
            $this->setDefaultLists($object->getDefaultLists());
141
            $this->setSortOverride($object->getSortOverride());
142
        } else {
143
            user_error('QuickSearchType must be either "all" or "limited" or a defined quick search class. Provided was: ' . $nameOrType);
144
        }
145
146
        return $this;
147
    }
148
149
    public function setIsQuickSearch(bool $b): SearchApi
150
    {
151
        $this->isQuickSearch = $b;
152
153
        return $this;
154
    }
155
156
    public function setDryRunForReplacement(bool $b): SearchApi
157
    {
158
        $this->dryRunForReplacement = $b;
159
160
        return $this;
161
    }
162
163
164
    public function setSearchWholePhrase(bool $b): SearchApi
165
    {
166
        $this->searchWholePhrase = $b;
167
168
        return $this;
169
    }
170
171
    public function setBypassCanMethods(bool $b): SearchApi
172
    {
173
        if (! Director::is_cli()) {
174
            user_error('setBypassCanMethods() is only available in CLI mode. Use with caution as it will bypass all canView() and canEdit() checks.', E_USER_WARNING);
175
        } else {
176
            $this->bypassCanMethods = $b;
177
        }
178
        return $this;
179
    }
180
181
    public function setBaseClass(string $class): SearchApi
182
    {
183
        if (class_exists($class)) {
184
            $this->baseClass = $class;
185
        }
186
187
        return $this;
188
    }
189
190
    public function setExcludedClasses(array $a): SearchApi
191
    {
192
        $this->excludedClasses = $a;
193
194
        return $this;
195
    }
196
197
    public function setIncludedClasses(array $a): SearchApi
198
    {
199
        $this->includedClasses = $a;
200
        return $this;
201
    }
202
203
    public function setExcludedFields(array $a): SearchApi
204
    {
205
        $this->excludedFields = $a;
206
207
        return $this;
208
    }
209
210
    public function setIncludedFields(array $a): SearchApi
211
    {
212
        $this->includedFields = $a;
213
214
        return $this;
215
    }
216
217
    public function setIncludedClassFieldCombos(array $a): SearchApi
218
    {
219
        $this->includedClassFieldCombos = $a;
220
221
        return $this;
222
    }
223
224
    public function setDefaultLists(array $a): SearchApi
225
    {
226
        $this->defaultLists = $a;
227
228
        return $this;
229
    }
230
231
    public function setSortOverride(?array $a = null): SearchApi
232
    {
233
        $this->sortOverride = $a;
234
235
        return $this;
236
    }
237
238
    public function setWordsAsString(string $s): SearchApi
239
    {
240
        $s = $this->securityCheckInput($s);
241
        $this->words = explode(' ', $s);
242
243
        return $this;
244
    }
245
246
    // public function __construct()
247
    // {
248
    //     Environment::increaseTimeLimitTo(300);
249
    //     Environment::setMemoryLimitMax(-1);
250
    //     Environment::increaseMemoryLimitTo(-1);
251
    // }
252
253
    protected string $cacheHasBeenBuilt = '';
254
255
    public function buildCache(?string $word = ''): SearchApi
256
    {
257
        if ($this->cacheHasBeenBuilt !== $word) {
258
            $this->getLinksInner($word);
259
            $this->cacheHasBeenBuilt = $word;
260
        }
261
        return $this;
262
    }
263
264
    public function getLinks(?string $word = '', ?string $type = ''): ArrayList
265
    {
266
        return $this->getLinksInner($word, $type);
267
    }
268
269
    protected function getLinksInner(?string $word = '', ?string $type = ''): ArrayList
270
    {
271
        $this->initCache();
272
273
        //always do first ...
274
        $matches = $this->getMatches($word, $type);
275
276
        $list = $this->turnMatchesIntoList($matches);
277
278
        $this->saveCache();
279
        return $list;
280
    }
281
282
283
284
    public function doReplacementURL(string $word, string $replace): int
285
    {
286
        return $this->doReplacement($word, $replace, 'url');
287
    }
288
289
    public function doReplacement(string $word, string $replace, ?string $type = ''): int
290
    {
291
        $count = 0;
292
        $dryRunNote = $this->dryRunForReplacement ? ' (DRY RUN) ' : '';
293
        if ($word !== '' && $word !== '0') {
294
            $this->buildCache($word);
295
            $replace = $this->securityCheckInput($replace);
296
            if (strpos('://', $word) !== false) {
297
                $type = 'url';
298
            }
299
            foreach ($this->objects as $item) {
300
                if ($item->canEdit() || $this->bypassCanMethods) {
301
                    $className = $item->ClassName;
302
                    $fields = $this->getAllValidFields($className);
303
                    foreach ($fields as $field) {
304
                        if (! $item->{$field} || ! is_string($item->{$field})) {
305
                            continue;
306
                        }
307
                        if (strpos($item->{$field}, $word) === false) {
308
                            continue;
309
                        }
310
                        if (! $this->includeFieldTest($className, $field)) {
311
                            continue;
312
                        }
313
                        if ($type === 'url') {
314
                            $escapedFrom = preg_quote($word, '/');
315
                            // It replaces exact matches of $escapedFrom in $item->{$field} only if it is full word, followed by space, quote, ?, #, or end of string, preserving the slash if present.
316
                            $new = preg_replace_callback(
317
                                '/\b' . $escapedFrom . '(\/?)(?=[\s"\']|\?|#|$)/',
318
                                fn($matches) => $replace . ($matches[1] ?? ''),
319
                                $item->{$field}
320
                            );
321
                        } else {
322
                            $new = str_replace($word, $replace, $item->{$field});
323
                        }
324
                        if ($new === $item->{$field}) {
325
                            continue;
326
                        }
327
                        ++$count;
328
                        if ($this->showReplacements) {
329
                            DB::alteration_message(
330
                                '.... .... ' . $dryRunNote .
331
                                    $item->ClassName . '.' .  $item->ID .
332
                                    ' replace ' . $word . ' with ' . $replace .
333
                                    ' (' . $type . ') in field ' . $field,
334
                                'changed'
335
                            );
336
                        }
337
                        if ($this->dryRunForReplacement) {
338
                            continue;
339
                        }
340
                        $item->{$field} = $new;
341
                        $this->writeAndPublishIfAppropriate($item);
342
                    }
343
                } else {
344
                    if ($this->showReplacements) {
345
                        DB::alteration_message('.... .... ' . $item->ClassName . '.' . $item->ID . ' cannot be edited, so no replacement done', 'deleted');
346
                    }
347
                }
348
            }
349
        }
350
351
        return $count;
352
    }
353
354
    protected function saveCache(): self
355
    {
356
        $this->getCache()->saveCache();
357
358
        return $this;
359
    }
360
361
    protected function initCache(): self
362
    {
363
        $this->getCache()->initCache();
364
365
        return $this;
366
    }
367
368
    protected function writeAndPublishIfAppropriate($item)
369
    {
370
        if ($item->hasExtension(Versioned::class)) {
371
            $myStage = Versioned::get_stage();
372
            Versioned::set_stage(Versioned::DRAFT);
373
            // is it on live and is live the same as draft
374
            $canBePublished = $item->isPublished() && ! $item->isModifiedOnDraft();
375
            $item->writeToStage(Versioned::DRAFT);
376
            if ($canBePublished) {
377
                $item->publishSingle();
378
            }
379
            Versioned::set_stage($myStage);
380
        } else {
381
            $item->write();
382
        }
383
    }
384
385
    protected function getMatches(?string $word = '', ?string $type = ''): array
0 ignored issues
show
The parameter $type is not used and could be removed. ( Ignorable by Annotation )

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

385
    protected function getMatches(?string $word = '', /** @scrutinizer ignore-unused */ ?string $type = ''): array

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
386
    {
387
        $startInner = 0;
388
        $startOuter = 0;
389
        if ($this->debug) {
390
            $startOuter = microtime(true);
391
        }
392
        $this->workOutInclusionsAndExclusions();
393
394
        // important to do this first
395
        if ($word) {
396
            $this->setWordsAsString($word);
397
        }
398
        $this->workOutWordsForSearching();
399
        if ($this->debug) {
400
            DB::alteration_message('Words searched for ' . print_r($this->words, 1));
0 ignored issues
show
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

400
            DB::alteration_message('Words searched for ' . /** @scrutinizer ignore-type */ print_r($this->words, 1));
Loading history...
401
        }
402
403
        $array = [];
404
405
        if (count($this->words) > 0) {
406
            foreach ($this->getAllDataObjects() as $className) {
407
                if (! $this->includeClassTest($className)) {
408
                    continue;
409
                }
410
411
                $array[$className] = [];
412
                $fields = $this->getAllValidFields($className);
413
                $filterAny = [];
414
                foreach ($fields as $field) {
415
                    if (! $this->includeFieldTest($className, $field)) {
416
                        continue;
417
                    }
418
                    $filterAny[$field . ':PartialMatch'] = $this->words;
419
                    if ($this->debug) {
420
                        DB::alteration_message(' ... ... Searching in ' . $className . '.' . $field);
421
                    }
422
                }
423
                if ([] !== $filterAny) {
424
                    if ($this->debug) {
425
                        $startInner = microtime(true);
426
                        DB::alteration_message(' ... Filter: ' . implode(', ', array_keys($filterAny)));
427
                    }
428
                    $defaultList = $this->getDefaultList($className);
429
                    if ($defaultList === []) {
430
                        $array[$className] = $className::get();
431
                    }
432
                    $array[$className] = $array[$className]->filter(['ClassName' => $className]);
433
                    $array[$className] = $array[$className]
434
                        ->filterAny($filterAny)
435
                        ->limit($this->Config()->get('limit_of_count_per_data_object'))
436
                        ->columnUnique('ID');
437
                    if ($this->debug) {
438
                        $elaps = microtime(true) - $startInner;
439
                        DB::alteration_message('search for ' . $className . ' taken : ' . $elaps);
440
                    }
441
                }
442
443
                if ($this->debug) {
444
                    DB::alteration_message(' ... No fields in ' . $className);
445
                }
446
            }
447
        } else {
448
            $array = $this->getDefaultResults();
449
        }
450
451
        if ($this->debug) {
452
            $elaps = microtime(true) - $startOuter;
453
            DB::alteration_message('seconds taken find results: ' . $elaps);
454
        }
455
456
        return $array;
457
    }
458
459
    protected function getDefaultResults(): array
460
    {
461
        $back = $this->config()->get('hours_back_for_recent') ?: 24;
462
        $limit = $this->Config()->get('limit_per_class_for_recent') ?: 5;
463
        $threshold = strtotime('-' . $back . ' hours', DBDatetime::now()->getTimestamp());
464
        if (! $threshold) {
465
            $threshold = time() - 86400;
466
        }
467
468
        $array = [];
469
        $classNames = $this->getAllDataObjects();
470
        foreach ($classNames as $className) {
471
            if ($this->includeClassTest($className)) {
472
                $array[$className] = $className::get()
473
                    ->filter('LastEdited:GreaterThan', date('Y-m-d H:i:s', $threshold))
474
                    ->sort(['LastEdited' => 'DESC'])
475
                    ->limit($limit)
476
                    ->column('ID');
477
            }
478
        }
479
480
        return $array;
481
    }
482
483
    /**
484
     * weeds out doubles
485
     */
486
    protected function turnArrayIntoObjects(array $matches, ?int $limit = 0): array
487
    {
488
        $start = 0;
489
        $fullListCheck = [];
490
491
        if (empty($this->objects)) {
492
            if ($limit === null || $limit === 0) {
493
                $limit = (int) $this->Config()->get('limit_of_count_per_data_object');
494
            }
495
496
            $this->objects = [];
497
            if ($this->debug) {
498
                DB::alteration_message('number of classes: ' . count($matches));
499
            }
500
501
            foreach ($matches as $className => $ids) {
502
                if ($this->debug) {
503
                    $start = microtime(true);
504
                    DB::alteration_message(' ... number of matches for : ' . $className . ': ' . count($ids));
505
                }
506
507
                if (count($ids) > 0) {
508
                    $className = (string) $className;
509
                    $items = $className::get()
510
                        ->filter(['ID' => $ids, 'ClassName' => $className])
511
                        ->limit($limit);
512
                    foreach ($items as $item) {
513
                        if (isset($fullListCheck[$item->ClassName][$item->ID])) {
514
                            continue;
515
                        }
516
                        if ($item->canView() || $this->bypassCanMethods) {
517
                            $fullListCheck[$item->ClassName][$item->ID] = true;
518
                            $this->objects[] = $item;
519
                        } else {
520
                            $fullListCheck[$item->ClassName][$item->ID] = false;
521
                        }
522
                    }
523
                }
524
525
                if ($this->debug) {
526
                    $elaps = microtime(true) - $start;
527
                    DB::alteration_message('seconds taken to find objects in: ' . $className . ': ' . $elaps);
528
                }
529
            }
530
        }
531
532
        return $this->objects;
533
    }
534
535
    protected function turnMatchesIntoList(array $matches): ArrayList
536
    {
537
        // helper
538
        //return values
539
        $list = ArrayList::create();
540
        $finder = Injector::inst()->get(FindEditableObjects::class);
541
        $finder->initCache(md5(serialize($this->excludedClassesWithSubClassess)))
542
            ->setExcludedClasses($this->excludedClassesWithSubClassess);
543
544
        $items = $this->turnArrayIntoObjects($matches);
545
        foreach ($items as $item) {
546
            if ($item->canView() || $this->bypassCanMethods) {
547
                $link = $finder->getLink($item);
548
                $cmsEditLink = trim($item->canEdit() ? $finder->getCMSEditLink($item) : '');
549
                $list->push(
550
                    ArrayData::create(
551
                        [
552
                            'HasLink' => (bool) $link,
553
                            'HasCMSEditLink' => (bool) $cmsEditLink,
554
                            'Link' => $link,
555
                            'CMSEditLink' => $cmsEditLink,
556
                            'ID' => $item->ID,
557
                            'LastEdited' => $item->LastEdited,
558
                            'Title' => $item->getTitle(),
559
                            'ClassName' => $item->ClassName,
560
                            'SingularName' => $item->i18n_singular_name(),
561
                            'SiteWideSearchSortValue' => $this->getSortValue($item),
562
                            'CMSThumbnail' => DBField::create_field('HTMLText', $finder->getCMSThumbnail($item)),
563
                        ]
564
                    )
565
                );
566
            }
567
        }
568
        $finder->saveCache();
569
570
        if (! empty($this->sortOverride)) {
571
            return $list->sort($this->sortOverride);
572
        } else {
573
            return $list->sort(['SiteWideSearchSortValue' => 'ASC']);
574
        }
575
    }
576
577
    protected function getSortValue($item)
578
    {
579
        $className = $item->ClassName;
580
        $fields = $this->getAllValidFields($className);
581
        $fullWords = implode(' ', $this->words);
582
583
        $done = false;
584
        $score = 0;
585
        if ($fullWords !== '' && $fullWords !== '0') {
586
            $fieldValues = [];
587
            $fieldValuesAll = '';
0 ignored issues
show
The assignment to $fieldValuesAll is dead and can be removed.
Loading history...
588
            foreach ($fields as $field) {
589
                $fieldValues[$field] = strtolower(strip_tags((string) $item->{$field}));
590
            }
591
592
            $fieldValuesAll = implode(' ', $fieldValues);
593
            $testWords = array_merge(
594
                [$fullWords],
595
                $this->words
596
            );
597
            $testWords = array_unique($testWords);
598
            foreach ($testWords as $wordKey => $word) {
599
                //match a exact field to full words / one word
600
                $fullWords = ! (bool) $wordKey;
601
                if (false === $done) {
602
                    $count = 0;
603
                    foreach ($fieldValues as $fieldValue) {
604
                        ++$count;
605
                        if ($fieldValue === $word) {
606
                            $score += (int) $wordKey + $count;
607
                            $done = true;
608
609
                            break;
610
                        }
611
                    }
612
                }
613
614
                // the full string / any of the words are present?
615
                if (false === $done) {
616
                    $pos = strpos($fieldValuesAll, $word);
617
                    if (false !== $pos) {
618
                        $score += (($pos + 1) / strlen($word)) * 1000;
619
                        $done = true;
620
                    }
621
                }
622
623
                // all individual words are present
624
                if (false === $done && $fullWords) {
625
                    $score += 1000;
626
                    $allMatch = true;
627
                    foreach ($this->words as $tmpWord) {
628
                        $pos = strpos($fieldValuesAll, $tmpWord);
629
                        if (false === $pos) {
630
                            $allMatch = false;
631
632
                            break;
633
                        }
634
                    }
635
                    if ($allMatch) {
636
                        $done = true;
637
                    }
638
                }
639
            }
640
        }
641
642
        //the older the item, the higher the scoare
643
        //1104622247 = 1 jan 2005
644
        return $score + (1 / (strtotime($item->LastEdited) - 1104537600));
645
    }
646
647
    protected function workOutInclusionsAndExclusions()
648
    {
649
        $this->excludedClasses = array_unique(
650
            array_merge(
651
                $this->Config()->get('default_exclude_classes'),
652
                $this->excludedClasses
653
            )
654
        );
655
        $this->excludedClassesWithSubClassess = $this->includeSubClasses($this->excludedClasses);
656
        $this->includedClasses = array_unique(
657
            array_merge(
658
                $this->Config()->get('default_include_classes'),
659
                $this->includedClasses
660
            )
661
        );
662
        $this->includedClassesWithSubClassess = $this->includeSubClasses($this->includedClasses);
663
        $this->excludedFields = array_unique(
664
            array_merge(
665
                $this->Config()->get('default_exclude_fields'),
666
                $this->excludedFields
667
            )
668
        );
669
670
        $this->includedFields = array_unique(
671
            array_merge(
672
                $this->Config()->get('default_include_fields'),
673
                $this->includedFields
674
            )
675
        );
676
        $this->includedClassFieldCombos = array_unique(
677
            array_merge(
678
                $this->Config()->get('default_include_class_field_combos'),
679
                $this->includedClassFieldCombos
680
            )
681
        );
682
        $this->defaultLists = array_unique(
683
            array_merge(
684
                $this->Config()->get('default_lists'),
685
                $this->defaultLists
686
            )
687
        );
688
    }
689
690
    protected function workOutWordsForSearching()
691
    {
692
        if ($this->searchWholePhrase) {
693
            $this->words = [implode(' ', $this->words)];
694
        }
695
696
        if (count($this->words) === 0) {
697
            user_error('No word has been provided');
698
        }
699
700
        $this->words = array_map('trim', $this->words);
701
        $this->words = array_map('strtolower', $this->words);
702
        $this->words = array_unique($this->words);
703
        $this->words = array_filter($this->words);
704
    }
705
706
    protected function getAllDataObjects(): array
707
    {
708
        return $this->getCache()->getAllDataObjects();
709
    }
710
711
    protected function getAllValidFields(string $className): array
712
    {
713
        return $this->getCache()->getAllValidFields($className, $this->isQuickSearch, $this->includedFields, $this->includedClassFieldCombos);
714
    }
715
716
    protected function includeClassTest(string $className): bool
717
    {
718
        if (count($this->includedClassesWithSubClassess) && ! in_array($className, $this->includedClassesWithSubClassess, true)) {
719
            if ($this->debug) {
720
                DB::alteration_message(' ... Skipping as not included ' . $className);
721
            }
722
            return false;
723
        }
724
        if (count($this->excludedClassesWithSubClassess) && in_array($className, $this->excludedClassesWithSubClassess, true)) {
725
            if ($this->debug) {
726
                DB::alteration_message(' ... Skipping as excluded ' . $className);
727
            }
728
            return false;
729
        }
730
        if ($this->debug) {
731
            DB::alteration_message(' ... including ' . $className);
732
        }
733
734
        return true;
735
    }
736
737
    protected function includeFieldTest(string $className, string $field): bool
738
    {
739
        if (isset($this->includedClassFieldCombos[$className][$field])) {
740
            return true;
741
        } elseif (count($this->includedFields) > 0) {
742
            return in_array($field, $this->includedFields, true);
743
        } elseif (count($this->excludedFields) > 0) {
744
            return ! in_array($field, $this->includedFields, true);
745
        } else {
746
            return false;
747
        }
748
    }
749
750
    protected function includeSubClasses(array $classes): array
751
    {
752
        $toAdd = [];
753
        foreach ($classes as $class) {
754
            $toAdd = array_merge($toAdd, ClassInfo::subclassesFor($class, false));
755
        }
756
        return array_unique(array_merge($classes, $toAdd));
757
    }
758
759
    protected function securityCheckInput(string $word): string
760
    {
761
        $word = trim($word);
762
        return Convert::raw2sql($word);
763
    }
764
765
    protected function getDefaultList(string $className): array
766
    {
767
        return $this->defaultLists[$className] ?? [];
768
    }
769
}
770