Completed
Push — master ( bee36e...8d4065 )
by Greg
14:04
created

SearchService   F

Complexity

Total Complexity 147

Size/Duplication

Total Lines 1167
Duplicated Lines 0 %

Importance

Changes 9
Bugs 0 Features 0
Metric Value
eloc 575
c 9
b 0
f 0
dl 0
loc 1167
rs 2
wmc 147

31 Methods

Rating   Name   Duplication   Size   Complexity  
A searchFamilies() 0 13 1
A __construct() 0 4 1
A searchRepositories() 0 9 1
A searchIndividualsInPlace() 0 15 1
A searchIndividuals() 0 13 1
A searchFamilyNames() 0 28 1
A searchNotes() 0 9 1
A searchSources() 0 8 1
A searchIndividualNames() 0 15 1
A searchMedia() 0 8 1
A searchFamiliesInPlace() 0 15 1
A searchSourcesByName() 0 9 1
A searchSubmitters() 0 9 1
A findMediaObjectsForMediaFile() 0 15 1
A searchSurnames() 0 13 1
A mediaRowMapper() 0 6 1
A familyRowMapper() 0 6 1
A repositoryRowMapper() 0 6 1
A submitterRowMapper() 0 6 1
A whereTrees() 0 7 1
A whereSearch() 0 8 3
A sourceRowMapper() 0 6 1
F searchIndividualsAdvanced() 0 440 95
B paginateQuery() 0 31 7
A individualRowMapper() 0 6 1
A rawGedcomFilter() 0 17 3
A noteRowMapper() 0 6 1
B searchIndividualsPhonetic() 0 64 9
A rowLimiter() 0 9 2
A searchPlaces() 0 49 2
A wherePhonetic() 0 6 3

How to fix   Complexity   

Complex Class

Complex classes like SearchService often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use SearchService, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2020 webtrees development team
6
 * This program is free software: you can redistribute it and/or modify
7
 * it under the terms of the GNU General Public License as published by
8
 * the Free Software Foundation, either version 3 of the License, or
9
 * (at your option) any later version.
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 * You should have received a copy of the GNU General Public License
15
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
16
 */
17
18
declare(strict_types=1);
19
20
namespace Fisharebest\Webtrees\Services;
21
22
use Closure;
23
use Fisharebest\Webtrees\Date;
24
use Fisharebest\Webtrees\Exceptions\HttpServiceUnavailableException;
25
use Fisharebest\Webtrees\Registry;
26
use Fisharebest\Webtrees\Family;
27
use Fisharebest\Webtrees\Gedcom;
28
use Fisharebest\Webtrees\GedcomRecord;
29
use Fisharebest\Webtrees\I18N;
30
use Fisharebest\Webtrees\Individual;
31
use Fisharebest\Webtrees\Media;
32
use Fisharebest\Webtrees\Note;
33
use Fisharebest\Webtrees\Place;
34
use Fisharebest\Webtrees\Repository;
35
use Fisharebest\Webtrees\Soundex;
36
use Fisharebest\Webtrees\Source;
37
use Fisharebest\Webtrees\Submitter;
38
use Fisharebest\Webtrees\Tree;
39
use Illuminate\Database\Capsule\Manager as DB;
40
use Illuminate\Database\Query\Builder;
41
use Illuminate\Database\Query\Expression;
42
use Illuminate\Database\Query\JoinClause;
43
use Illuminate\Support\Collection;
44
use stdClass;
45
46
use function addcslashes;
47
use function array_filter;
48
use function array_map;
49
use function array_unique;
50
use function explode;
51
use function implode;
52
use function mb_stripos;
53
use function preg_match;
54
use function preg_quote;
55
use function preg_replace;
56
57
use const PHP_INT_MAX;
58
59
/**
60
 * Search trees for genealogy records.
61
 */
62
class SearchService
63
{
64
    // Do not attempt to show search results larger than this/
65
    protected const MAX_SEARCH_RESULTS = 5000;
66
67
    /** @var TreeService */
68
    private $tree_service;
69
70
    /**
71
     * SearchService constructor.
72
     *
73
     * @param TreeService $tree_service
74
     */
75
    public function __construct(
76
        TreeService $tree_service
77
    ) {
78
        $this->tree_service = $tree_service;
79
    }
80
81
    /**
82
     * @param Tree[]   $trees
83
     * @param string[] $search
84
     *
85
     * @return Collection<Family>
86
     */
87
    public function searchFamilies(array $trees, array $search): Collection
88
    {
89
        $query = DB::table('families');
90
91
        $this->whereTrees($query, 'f_file', $trees);
92
        $this->whereSearch($query, 'f_gedcom', $search);
93
94
        return $query
95
            ->get()
96
            ->each($this->rowLimiter())
97
            ->map($this->familyRowMapper())
98
            ->filter(GedcomRecord::accessFilter())
99
            ->filter($this->rawGedcomFilter($search));
100
    }
101
102
    /**
103
     * Search for families by name.
104
     *
105
     * @param Tree[]   $trees
106
     * @param string[] $search
107
     * @param int      $offset
108
     * @param int      $limit
109
     *
110
     * @return Collection<Family>
111
     */
112
    public function searchFamilyNames(array $trees, array $search, int $offset = 0, int $limit = PHP_INT_MAX): Collection
113
    {
114
        $query = DB::table('families')
115
            ->leftJoin('name AS husb_name', static function (JoinClause $join): void {
116
                $join
117
                    ->on('husb_name.n_file', '=', 'families.f_file')
118
                    ->on('husb_name.n_id', '=', 'families.f_husb')
119
                    ->where('husb_name.n_type', '<>', '_MARNM');
120
            })
121
            ->leftJoin('name AS wife_name', static function (JoinClause $join): void {
122
                $join
123
                    ->on('wife_name.n_file', '=', 'families.f_file')
124
                    ->on('wife_name.n_id', '=', 'families.f_wife')
125
                    ->where('wife_name.n_type', '<>', '_MARNM');
126
            });
127
128
        $prefix = DB::connection()->getTablePrefix();
129
        $field  = new Expression('COALESCE(' . $prefix . "husb_name.n_full, '') || COALESCE(" . $prefix . "wife_name.n_full, '')");
130
131
        $this->whereTrees($query, 'f_file', $trees);
132
        $this->whereSearch($query, $field, $search);
133
134
        $query
135
            ->orderBy('husb_name.n_sort')
136
            ->orderBy('wife_name.n_sort')
137
            ->select(['families.*', 'husb_name.n_sort', 'wife_name.n_sort']);
138
139
        return $this->paginateQuery($query, $this->familyRowMapper(), GedcomRecord::accessFilter(), $offset, $limit);
140
    }
141
142
    /**
143
     * @param Place $place
144
     *
145
     * @return Collection<Family>
146
     */
147
    public function searchFamiliesInPlace(Place $place): Collection
148
    {
149
        return DB::table('families')
150
            ->join('placelinks', static function (JoinClause $query) {
151
                $query
152
                    ->on('families.f_file', '=', 'placelinks.pl_file')
153
                    ->on('families.f_id', '=', 'placelinks.pl_gid');
154
            })
155
            ->where('f_file', '=', $place->tree()->id())
156
            ->where('pl_p_id', '=', $place->id())
157
            ->select(['families.*'])
158
            ->get()
159
            ->each($this->rowLimiter())
160
            ->map($this->familyRowMapper())
161
            ->filter(GedcomRecord::accessFilter());
162
    }
163
164
    /**
165
     * @param Tree[]   $trees
166
     * @param string[] $search
167
     *
168
     * @return Collection<Individual>
169
     */
170
    public function searchIndividuals(array $trees, array $search): Collection
171
    {
172
        $query = DB::table('individuals');
173
174
        $this->whereTrees($query, 'i_file', $trees);
175
        $this->whereSearch($query, 'i_gedcom', $search);
176
177
        return $query
178
            ->get()
179
            ->each($this->rowLimiter())
180
            ->map($this->individualRowMapper())
181
            ->filter(GedcomRecord::accessFilter())
182
            ->filter($this->rawGedcomFilter($search));
183
    }
184
185
    /**
186
     * Search for individuals by name.
187
     *
188
     * @param Tree[]   $trees
189
     * @param string[] $search
190
     * @param int      $offset
191
     * @param int      $limit
192
     *
193
     * @return Collection<Individual>
194
     */
195
    public function searchIndividualNames(array $trees, array $search, int $offset = 0, int $limit = PHP_INT_MAX): Collection
196
    {
197
        $query = DB::table('individuals')
198
            ->join('name', static function (JoinClause $join): void {
199
                $join
200
                    ->on('name.n_file', '=', 'individuals.i_file')
201
                    ->on('name.n_id', '=', 'individuals.i_id');
202
            })
203
            ->orderBy('n_sort')
204
            ->select(['individuals.*', 'n_sort']);
205
206
        $this->whereTrees($query, 'i_file', $trees);
207
        $this->whereSearch($query, 'n_full', $search);
208
209
        return $this->paginateQuery($query, $this->individualRowMapper(), GedcomRecord::accessFilter(), $offset, $limit);
210
    }
211
212
    /**
213
     * @param Place $place
214
     *
215
     * @return Collection<Individual>
216
     */
217
    public function searchIndividualsInPlace(Place $place): Collection
218
    {
219
        return DB::table('individuals')
220
            ->join('placelinks', static function (JoinClause $join) {
221
                $join
222
                    ->on('i_file', '=', 'pl_file')
223
                    ->on('i_id', '=', 'pl_gid');
224
            })
225
            ->where('i_file', '=', $place->tree()->id())
226
            ->where('pl_p_id', '=', $place->id())
227
            ->select(['individuals.*'])
228
            ->get()
229
            ->each($this->rowLimiter())
230
            ->map($this->individualRowMapper())
231
            ->filter(GedcomRecord::accessFilter());
232
    }
233
234
    /**
235
     * Search for media objects.
236
     *
237
     * @param Tree[]   $trees
238
     * @param string[] $search
239
     * @param int      $offset
240
     * @param int      $limit
241
     *
242
     * @return Collection<Media>
243
     */
244
    public function searchMedia(array $trees, array $search, int $offset = 0, int $limit = PHP_INT_MAX): Collection
245
    {
246
        $query = DB::table('media');
247
248
        $this->whereTrees($query, 'media.m_file', $trees);
249
        $this->whereSearch($query, 'm_gedcom', $search);
250
251
        return $this->paginateQuery($query, $this->mediaRowMapper(), GedcomRecord::accessFilter(), $offset, $limit);
252
    }
253
254
    /**
255
     * Search for notes.
256
     *
257
     * @param Tree[]   $trees
258
     * @param string[] $search
259
     * @param int      $offset
260
     * @param int      $limit
261
     *
262
     * @return Collection<Note>
263
     */
264
    public function searchNotes(array $trees, array $search, int $offset = 0, int $limit = PHP_INT_MAX): Collection
265
    {
266
        $query = DB::table('other')
267
            ->where('o_type', '=', 'NOTE');
268
269
        $this->whereTrees($query, 'o_file', $trees);
270
        $this->whereSearch($query, 'o_gedcom', $search);
271
272
        return $this->paginateQuery($query, $this->noteRowMapper(), GedcomRecord::accessFilter(), $offset, $limit);
273
    }
274
275
    /**
276
     * Search for repositories.
277
     *
278
     * @param Tree[]   $trees
279
     * @param string[] $search
280
     * @param int      $offset
281
     * @param int      $limit
282
     *
283
     * @return Collection<Repository>
284
     */
285
    public function searchRepositories(array $trees, array $search, int $offset = 0, int $limit = PHP_INT_MAX): Collection
286
    {
287
        $query = DB::table('other')
288
            ->where('o_type', '=', 'REPO');
289
290
        $this->whereTrees($query, 'o_file', $trees);
291
        $this->whereSearch($query, 'o_gedcom', $search);
292
293
        return $this->paginateQuery($query, $this->repositoryRowMapper(), GedcomRecord::accessFilter(), $offset, $limit);
294
    }
295
296
    /**
297
     * Search for sources.
298
     *
299
     * @param Tree[]   $trees
300
     * @param string[] $search
301
     * @param int      $offset
302
     * @param int      $limit
303
     *
304
     * @return Collection<Source>
305
     */
306
    public function searchSources(array $trees, array $search, int $offset = 0, int $limit = PHP_INT_MAX): Collection
307
    {
308
        $query = DB::table('sources');
309
310
        $this->whereTrees($query, 's_file', $trees);
311
        $this->whereSearch($query, 's_gedcom', $search);
312
313
        return $this->paginateQuery($query, $this->sourceRowMapper(), GedcomRecord::accessFilter(), $offset, $limit);
314
    }
315
316
    /**
317
     * Search for sources by name.
318
     *
319
     * @param Tree[]   $trees
320
     * @param string[] $search
321
     * @param int      $offset
322
     * @param int      $limit
323
     *
324
     * @return Collection<Source>
325
     */
326
    public function searchSourcesByName(array $trees, array $search, int $offset = 0, int $limit = PHP_INT_MAX): Collection
327
    {
328
        $query = DB::table('sources')
329
            ->orderBy('s_name');
330
331
        $this->whereTrees($query, 's_file', $trees);
332
        $this->whereSearch($query, 's_name', $search);
333
334
        return $this->paginateQuery($query, $this->sourceRowMapper(), GedcomRecord::accessFilter(), $offset, $limit);
335
    }
336
337
    /**
338
     * Search for sources.
339
     *
340
     * @param Tree[]   $trees
341
     * @param string[] $search
342
     * @param int      $offset
343
     * @param int      $limit
344
     *
345
     * @return Collection<string>
346
     */
347
    public function searchSurnames(array $trees, array $search, int $offset = 0, int $limit = PHP_INT_MAX): Collection
348
    {
349
        $query = DB::table('name');
350
351
        $this->whereTrees($query, 'n_file', $trees);
352
        $this->whereSearch($query, 'n_surname', $search);
353
354
        return $query
355
            ->groupBy(['n_surname'])
356
            ->orderBy('n_surname')
357
            ->skip($offset)
358
            ->take($limit)
359
            ->pluck('n_surname');
360
    }
361
362
    /**
363
     * Search for submitters.
364
     *
365
     * @param Tree[]   $trees
366
     * @param string[] $search
367
     * @param int      $offset
368
     * @param int      $limit
369
     *
370
     * @return Collection<Submitter>
371
     */
372
    public function searchSubmitters(array $trees, array $search, int $offset = 0, int $limit = PHP_INT_MAX): Collection
373
    {
374
        $query = DB::table('other')
375
            ->where('o_type', '=', 'SUBM');
376
377
        $this->whereTrees($query, 'o_file', $trees);
378
        $this->whereSearch($query, 'o_gedcom', $search);
379
380
        return $this->paginateQuery($query, $this->submitterRowMapper(), GedcomRecord::accessFilter(), $offset, $limit);
381
    }
382
383
    /**
384
     * Search for places.
385
     *
386
     * @param Tree   $tree
387
     * @param string $search
388
     * @param int    $offset
389
     * @param int    $limit
390
     *
391
     * @return Collection<Place>
392
     */
393
    public function searchPlaces(Tree $tree, string $search, int $offset = 0, int $limit = PHP_INT_MAX): Collection
394
    {
395
        $query = DB::table('places AS p0')
396
            ->where('p0.p_file', '=', $tree->id())
397
            ->leftJoin('places AS p1', 'p1.p_id', '=', 'p0.p_parent_id')
398
            ->leftJoin('places AS p2', 'p2.p_id', '=', 'p1.p_parent_id')
399
            ->leftJoin('places AS p3', 'p3.p_id', '=', 'p2.p_parent_id')
400
            ->leftJoin('places AS p4', 'p4.p_id', '=', 'p3.p_parent_id')
401
            ->leftJoin('places AS p5', 'p5.p_id', '=', 'p4.p_parent_id')
402
            ->leftJoin('places AS p6', 'p6.p_id', '=', 'p5.p_parent_id')
403
            ->leftJoin('places AS p7', 'p7.p_id', '=', 'p6.p_parent_id')
404
            ->leftJoin('places AS p8', 'p8.p_id', '=', 'p7.p_parent_id')
405
            ->orderBy('p0.p_place')
406
            ->orderBy('p1.p_place')
407
            ->orderBy('p2.p_place')
408
            ->orderBy('p3.p_place')
409
            ->orderBy('p4.p_place')
410
            ->orderBy('p5.p_place')
411
            ->orderBy('p6.p_place')
412
            ->orderBy('p7.p_place')
413
            ->orderBy('p8.p_place')
414
            ->select([
415
                'p0.p_place AS place0',
416
                'p1.p_place AS place1',
417
                'p2.p_place AS place2',
418
                'p3.p_place AS place3',
419
                'p4.p_place AS place4',
420
                'p5.p_place AS place5',
421
                'p6.p_place AS place6',
422
                'p7.p_place AS place7',
423
                'p8.p_place AS place8',
424
            ]);
425
426
        // Filter each level of the hierarchy.
427
        foreach (explode(',', $search, 9) as $level => $string) {
428
            $query->where('p' . $level . '.p_place', 'LIKE', '%' . addcslashes($string, '\\%_') . '%');
429
        }
430
431
        $row_mapper = static function (stdClass $row) use ($tree): Place {
432
            $place = implode(', ', array_filter((array) $row));
433
434
            return new Place($place, $tree);
435
        };
436
437
        $filter = static function (): bool {
438
            return true;
439
        };
440
441
        return $this->paginateQuery($query, $row_mapper, $filter, $offset, $limit);
442
    }
443
444
    /**
445
     * @param Tree[]   $trees
446
     * @param string[] $fields
447
     * @param string[] $modifiers
448
     *
449
     * @return Collection<Individual>
450
     */
451
    public function searchIndividualsAdvanced(array $trees, array $fields, array $modifiers): Collection
452
    {
453
        $fields = array_filter($fields);
454
455
        $query = DB::table('individuals')
456
            ->select(['individuals.*'])
457
            ->distinct();
458
459
        $this->whereTrees($query, 'i_file', $trees);
460
461
        // Join the following tables
462
        $father_name   = false;
463
        $mother_name   = false;
464
        $spouse_family = false;
465
        $indi_name     = false;
466
        $indi_dates    = [];
467
        $fam_dates     = [];
468
        $indi_plac     = false;
469
        $fam_plac      = false;
470
471
        foreach ($fields as $field_name => $field_value) {
472
            if ($field_value !== '') {
473
                // Fields can have up to 4 parts, but we only need the first 3 to identify
474
                // which tables to select
475
                $field_parts = explode(':', $field_name . '::');
476
477
                if ($field_parts[0] === 'FAMC') {
478
                    // Parent name - FAMC:[HUSB|WIFE]:NAME:[GIVN|SURN]
479
                    if ($field_parts[1] === 'HUSB') {
480
                        $father_name = true;
481
                    } else {
482
                        $mother_name = true;
483
                    }
484
                } elseif ($field_parts[0] === 'NAME') {
485
                    // Individual name - NAME:[GIVN|SURN]
486
                    $indi_name = true;
487
                } elseif ($field_parts[0] === 'FAMS') {
488
                    // Family facts - FAMS:NOTE or FAMS:[FACT]:[DATE|PLAC]
489
                    $spouse_family = true;
490
                    if ($field_parts[2] === 'DATE') {
491
                        $fam_dates[] = $field_parts[1];
492
                    } elseif ($field_parts[2] === 'PLAC') {
493
                        $fam_plac = true;
494
                    }
495
                } else {
496
                    // Individual facts - [FACT] or [FACT]:[DATE|PLAC]
497
                    if ($field_parts[1] === 'DATE') {
498
                        $indi_dates[] = $field_parts[0];
499
                    } elseif ($field_parts[1] === 'PLAC') {
500
                        $indi_plac = true;
501
                    }
502
                }
503
            }
504
        }
505
506
        if ($father_name || $mother_name) {
507
            $query->join('link AS l1', static function (JoinClause $join): void {
508
                $join
509
                    ->on('l1.l_file', '=', 'individuals.i_file')
510
                    ->on('l1.l_from', '=', 'individuals.i_id')
511
                    ->where('l1.l_type', '=', 'FAMC');
512
            });
513
514
            if ($father_name) {
515
                $query->join('link AS l2', static function (JoinClause $join): void {
516
                    $join
517
                        ->on('l2.l_file', '=', 'l1.l_file')
518
                        ->on('l2.l_from', '=', 'l1.l_to')
519
                        ->where('l2.l_type', '=', 'HUSB');
520
                });
521
                $query->join('name AS father_name', static function (JoinClause $join): void {
522
                    $join
523
                        ->on('father_name.n_file', '=', 'l2.l_file')
524
                        ->on('father_name.n_id', '=', 'l2.l_to');
525
                });
526
            }
527
528
            if ($mother_name) {
529
                $query->join('link AS l3', static function (JoinClause $join): void {
530
                    $join
531
                        ->on('l3.l_file', '=', 'l1.l_file')
532
                        ->on('l3.l_from', '=', 'l1.l_to')
533
                        ->where('l3.l_type', '=', 'WIFE');
534
                });
535
                $query->join('name AS mother_name', static function (JoinClause $join): void {
536
                    $join
537
                        ->on('mother_name.n_file', '=', 'l3.l_file')
538
                        ->on('mother_name.n_id', '=', 'l3.l_to');
539
                });
540
            }
541
        }
542
543
        if ($spouse_family) {
544
            $query->join('link AS l4', static function (JoinClause $join): void {
545
                $join
546
                    ->on('l4.l_file', '=', 'individuals.i_file')
547
                    ->on('l4.l_from', '=', 'individuals.i_id')
548
                    ->where('l4.l_type', '=', 'FAMS');
549
            });
550
            $query->join('families AS spouse_families', static function (JoinClause $join): void {
551
                $join
552
                    ->on('spouse_families.f_file', '=', 'l4.l_file')
553
                    ->on('spouse_families.f_id', '=', 'l4.l_to');
554
            });
555
        }
556
557
        if ($indi_name) {
558
            $query->join('name AS individual_name', static function (JoinClause $join): void {
559
                $join
560
                    ->on('individual_name.n_file', '=', 'individuals.i_file')
561
                    ->on('individual_name.n_id', '=', 'individuals.i_id');
562
            });
563
        }
564
565
        foreach (array_unique($indi_dates) as $indi_date) {
566
            $query->join('dates AS date_' . $indi_date, static function (JoinClause $join) use ($indi_date): void {
567
                $join
568
                    ->on('date_' . $indi_date . '.d_file', '=', 'individuals.i_file')
569
                    ->on('date_' . $indi_date . '.d_gid', '=', 'individuals.i_id');
570
            });
571
        }
572
573
        foreach (array_unique($fam_dates) as $fam_date) {
574
            $query->join('dates AS date_' . $fam_date, static function (JoinClause $join) use ($fam_date): void {
575
                $join
576
                    ->on('date_' . $fam_date . '.d_file', '=', 'spouse_families.f_file')
577
                    ->on('date_' . $fam_date . '.d_gid', '=', 'spouse_families.f_id');
578
            });
579
        }
580
581
        if ($indi_plac) {
582
            $query->join('placelinks AS individual_placelinks', static function (JoinClause $join): void {
583
                $join
584
                    ->on('individual_placelinks.pl_file', '=', 'individuals.i_file')
585
                    ->on('individual_placelinks.pl_gid', '=', 'individuals.i_id');
586
            });
587
            $query->join('places AS individual_places', static function (JoinClause $join): void {
588
                $join
589
                    ->on('individual_places.p_file', '=', 'individual_placelinks.pl_file')
590
                    ->on('individual_places.p_id', '=', 'individual_placelinks.pl_p_id');
591
            });
592
        }
593
594
        if ($fam_plac) {
595
            $query->join('placelinks AS familyl_placelinks', static function (JoinClause $join): void {
596
                $join
597
                    ->on('familyl_placelinks.pl_file', '=', 'individuals.i_file')
598
                    ->on('familyl_placelinks.pl_gid', '=', 'individuals.i_id');
599
            });
600
            $query->join('places AS family_places', static function (JoinClause $join): void {
601
                $join
602
                    ->on('family_places.p_file', '=', 'familyl_placelinks.pl_file')
603
                    ->on('family_places.p_id', '=', 'familyl_placelinks.pl_p_id');
604
            });
605
        }
606
607
        foreach ($fields as $field_name => $field_value) {
608
            $parts = explode(':', $field_name . ':::');
609
            if ($parts[0] === 'NAME') {
610
                // NAME:*
611
                switch ($parts[1]) {
612
                    case 'GIVN':
613
                        switch ($modifiers[$field_name]) {
614
                            case 'EXACT':
615
                                $query->where('individual_name.n_givn', '=', $field_value);
616
                                break;
617
                            case 'BEGINS':
618
                                $query->where('individual_name.n_givn', 'LIKE', $field_value . '%');
619
                                break;
620
                            case 'CONTAINS':
621
                                $query->where('individual_name.n_givn', 'LIKE', '%' . $field_value . '%');
622
                                break;
623
                            case 'SDX_STD':
624
                                $sdx = Soundex::russell($field_value);
625
                                if ($sdx !== '') {
626
                                    $this->wherePhonetic($query, 'individual_name.n_soundex_givn_std', $sdx);
627
                                } else {
628
                                    // No phonetic content? Use a substring match
629
                                    $query->where('individual_name.n_givn', 'LIKE', '%' . $field_value . '%');
630
                                }
631
                                break;
632
                            case 'SDX': // SDX uses DM by default.
633
                            case 'SDX_DM':
634
                                $sdx = Soundex::daitchMokotoff($field_value);
635
                                if ($sdx !== '') {
636
                                    $this->wherePhonetic($query, 'individual_name.n_soundex_givn_dm', $sdx);
637
                                } else {
638
                                    // No phonetic content? Use a substring match
639
                                    $query->where('individual_name.n_givn', 'LIKE', '%' . $field_value . '%');
640
                                }
641
                                break;
642
                        }
643
                        unset($fields[$field_name]);
644
                        break;
645
                    case 'SURN':
646
                        switch ($modifiers[$field_name]) {
647
                            case 'EXACT':
648
                                $query->where(function (Builder $query) use ($field_value): void {
649
                                    $query
650
                                        ->where('individual_name.n_surn', '=', $field_value)
651
                                        ->orWhere('individual_name.n_surname', '=', $field_value);
652
                                });
653
                                break;
654
                            case 'BEGINS':
655
                                $query->where(function (Builder $query) use ($field_value): void {
656
                                    $query
657
                                        ->where('individual_name.n_surn', 'LIKE', $field_value . '%')
658
                                        ->orWhere('individual_name.n_surname', 'LIKE', $field_value . '%');
659
                                });
660
                                break;
661
                            case 'CONTAINS':
662
                                $query->where(function (Builder $query) use ($field_value): void {
663
                                    $query
664
                                        ->where('individual_name.n_surn', 'LIKE', '%' . $field_value . '%')
665
                                        ->orWhere('individual_name.n_surname', 'LIKE', '%' . $field_value . '%');
666
                                });
667
                                break;
668
                            case 'SDX_STD':
669
                                $sdx = Soundex::russell($field_value);
670
                                if ($sdx !== '') {
671
                                    $this->wherePhonetic($query, 'individual_name.n_soundex_surn_std', $sdx);
672
                                } else {
673
                                    // No phonetic content? Use a substring match
674
                                    $query->where(function (Builder $query) use ($field_value): void {
675
                                        $query
676
                                            ->where('individual_name.n_surn', 'LIKE', '%' . $field_value . '%')
677
                                            ->orWhere('individual_name.n_surname', 'LIKE', '%' . $field_value . '%');
678
                                    });
679
                                }
680
                                break;
681
                            case 'SDX': // SDX uses DM by default.
682
                            case 'SDX_DM':
683
                                $sdx = Soundex::daitchMokotoff($field_value);
684
                                if ($sdx !== '') {
685
                                    $this->wherePhonetic($query, 'individual_name.n_soundex_surn_dm', $sdx);
686
                                } else {
687
                                    // No phonetic content? Use a substring match
688
                                    $query->where(function (Builder $query) use ($field_value): void {
689
                                        $query
690
                                            ->where('individual_name.n_surn', 'LIKE', '%' . $field_value . '%')
691
                                            ->orWhere('individual_name.n_surname', 'LIKE', '%' . $field_value . '%');
692
                                    });
693
                                }
694
                                break;
695
                        }
696
                        unset($fields[$field_name]);
697
                        break;
698
                    case 'NICK':
699
                    case '_MARNM':
700
                    case '_HEB':
701
                    case '_AKA':
702
                        $like = "%\n1 " . $parts[0] . "%\n2 " . $parts[1] . ' %' . preg_quote($field_value, '/') . '%';
703
                        $query->where('individuals.i_gedcom', 'LIKE', $like);
704
                        break;
705
                }
706
            } elseif ($parts[1] === 'DATE') {
707
                // *:DATE
708
                $date = new Date($field_value);
709
                if ($date->isOK()) {
710
                    $delta = 365 * ($modifiers[$field_name] ?? 0);
711
                    $query
712
                        ->where('date_' . $parts[0] . '.d_fact', '=', $parts[0])
713
                        ->where('date_' . $parts[0] . '.d_julianday1', '>=', $date->minimumJulianDay() - $delta)
714
                        ->where('date_' . $parts[0] . '.d_julianday2', '<=', $date->maximumJulianDay() + $delta);
715
                }
716
                unset($fields[$field_name]);
717
            } elseif ($parts[0] === 'FAMS' && $parts[2] === 'DATE') {
718
                // FAMS:*:DATE
719
                $date = new Date($field_value);
720
                if ($date->isOK()) {
721
                    $delta = 365 * $modifiers[$field_name];
722
                    $query
723
                        ->where('date_' . $parts[1] . '.d_fact', '=', $parts[1])
724
                        ->where('date_' . $parts[1] . '.d_julianday1', '>=', $date->minimumJulianDay() - $delta)
725
                        ->where('date_' . $parts[1] . '.d_julianday2', '<=', $date->maximumJulianDay() + $delta);
726
                }
727
                unset($fields[$field_name]);
728
            } elseif ($parts[1] === 'PLAC') {
729
                // *:PLAC
730
                // SQL can only link a place to a person/family, not to an event.
731
                $query->where('individual_places.p_place', 'LIKE', '%' . $field_value . '%');
732
            } elseif ($parts[0] === 'FAMS' && $parts[2] === 'PLAC') {
733
                // FAMS:*:PLAC
734
                // SQL can only link a place to a person/family, not to an event.
735
                $query->where('family_places.p_place', 'LIKE', '%' . $field_value . '%');
736
            } elseif ($parts[0] === 'FAMC' && $parts[2] === 'NAME') {
737
                $table = $parts[1] === 'HUSB' ? 'father_name' : 'mother_name';
738
                // NAME:*
739
                switch ($parts[3]) {
740
                    case 'GIVN':
741
                        switch ($modifiers[$field_name]) {
742
                            case 'EXACT':
743
                                $query->where($table . '.n_givn', '=', $field_value);
744
                                break;
745
                            case 'BEGINS':
746
                                $query->where($table . '.n_givn', 'LIKE', $field_value . '%');
747
                                break;
748
                            case 'CONTAINS':
749
                                $query->where($table . '.n_givn', 'LIKE', '%' . $field_value . '%');
750
                                break;
751
                            case 'SDX_STD':
752
                                $sdx = Soundex::russell($field_value);
753
                                if ($sdx !== '') {
754
                                    $this->wherePhonetic($query, $table . '.n_soundex_givn_std', $sdx);
755
                                } else {
756
                                    // No phonetic content? Use a substring match
757
                                    $query->where($table . '.n_givn', 'LIKE', '%' . $field_value . '%');
758
                                }
759
                                break;
760
                            case 'SDX': // SDX uses DM by default.
761
                            case 'SDX_DM':
762
                                $sdx = Soundex::daitchMokotoff($field_value);
763
                                if ($sdx !== '') {
764
                                    $this->wherePhonetic($query, $table . '.n_soundex_givn_dm', $sdx);
765
                                } else {
766
                                    // No phonetic content? Use a substring match
767
                                    $query->where($table . '.n_givn', 'LIKE', '%' . $field_value . '%');
768
                                }
769
                                break;
770
                        }
771
                        break;
772
                    case 'SURN':
773
                        switch ($modifiers[$field_name]) {
774
                            case 'EXACT':
775
                                $query->where($table . '.n_surn', '=', $field_value);
776
                                break;
777
                            case 'BEGINS':
778
                                $query->where($table . '.n_surn', 'LIKE', $field_value . '%');
779
                                break;
780
                            case 'CONTAINS':
781
                                $query->where($table . '.n_surn', 'LIKE', '%' . $field_value . '%');
782
                                break;
783
                            case 'SDX_STD':
784
                                $sdx = Soundex::russell($field_value);
785
                                if ($sdx !== '') {
786
                                    $this->wherePhonetic($query, $table . '.n_soundex_surn_std', $sdx);
787
                                } else {
788
                                    // No phonetic content? Use a substring match
789
                                    $query->where($table . '.n_surn', 'LIKE', '%' . $field_value . '%');
790
                                }
791
                                break;
792
                            case 'SDX': // SDX uses DM by default.
793
                            case 'SDX_DM':
794
                                $sdx = Soundex::daitchMokotoff($field_value);
795
                                if ($sdx !== '') {
796
                                    $this->wherePhonetic($query, $table . '.n_soundex_surn_dm', $sdx);
797
                                } else {
798
                                    // No phonetic content? Use a substring match
799
                                    $query->where($table . '.n_surn', 'LIKE', '%' . $field_value . '%');
800
                                }
801
                                break;
802
                        }
803
                        break;
804
                }
805
                unset($fields[$field_name]);
806
            } elseif ($parts[0] === 'FAMS') {
807
                // e.g. searches for occupation, religion, note, etc.
808
                // Initial matching only.  Need PHP to apply filter.
809
                $query->where('spouse_families.f_gedcom', 'LIKE', "%\n1 " . $parts[1] . ' %' . $field_value . '%');
810
            } elseif ($parts[1] === 'TYPE') {
811
                // e.g. FACT:TYPE or EVEN:TYPE
812
                // Initial matching only.  Need PHP to apply filter.
813
                $query->where('individuals.i_gedcom', 'LIKE', "%\n1 " . $parts[0] . "%\n2 TYPE %" . $field_value . '%');
814
            } else {
815
                // e.g. searches for occupation, religion, note, etc.
816
                // Initial matching only.  Need PHP to apply filter.
817
                $query->where('individuals.i_gedcom', 'LIKE', "%\n1 " . $parts[0] . '%' . $parts[1] . '%' . $field_value . '%');
818
            }
819
        }
820
821
        return $query
822
            ->get()
823
            ->each($this->rowLimiter())
824
            ->map($this->individualRowMapper())
825
            ->filter(GedcomRecord::accessFilter())
826
            ->filter(static function (Individual $individual) use ($fields): bool {
827
                // Check for searches which were only partially matched by SQL
828
                foreach ($fields as $field_name => $field_value) {
829
                    $parts = explode(':', $field_name . '::::');
830
831
                    // NAME:*
832
                    if ($parts[0] === 'NAME') {
833
                        $regex = '/\n1 NAME.*(?:\n2.*)*\n2 ' . $parts[1] . ' .*' . preg_quote($field_value, '/') . '/i';
834
835
                        if (preg_match($regex, $individual->gedcom())) {
836
                            continue;
837
                        }
838
839
                        return false;
840
                    }
841
842
                    $regex = '/' . preg_quote($field_value, '/') . '/i';
843
844
                    // *:PLAC
845
                    if ($parts[1] === 'PLAC') {
846
                        foreach ($individual->facts([$parts[0]]) as $fact) {
847
                            if (preg_match($regex, $fact->place()->gedcomName())) {
848
                                continue 2;
849
                            }
850
                        }
851
                        return false;
852
                    }
853
854
                    // FAMS:*:PLAC
855
                    if ($parts[0] === 'FAMS' && $parts[2] === 'PLAC') {
856
                        foreach ($individual->spouseFamilies() as $family) {
857
                            foreach ($family->facts([$parts[1]]) as $fact) {
858
                                if (preg_match($regex, $fact->place()->gedcomName())) {
859
                                    continue 2;
860
                                }
861
                            }
862
                        }
863
                        return false;
864
                    }
865
866
                    // e.g. searches for occupation, religion, note, etc.
867
                    if ($parts[0] === 'FAMS') {
868
                        foreach ($individual->spouseFamilies() as $family) {
869
                            foreach ($family->facts([$parts[1]]) as $fact) {
870
                                if (preg_match($regex, $fact->value())) {
871
                                    continue 3;
872
                                }
873
                            }
874
                        }
875
                        return false;
876
                    }
877
878
                    // e.g. FACT:TYPE or EVEN:TYPE
879
                    if ($parts[1] === 'TYPE' || $parts[1] === '_WT_USER') {
880
                        foreach ($individual->facts([$parts[0]]) as $fact) {
881
                            if (preg_match($regex, $fact->attribute($parts[1]))) {
882
                                continue 2;
883
                            }
884
                        }
885
886
                        return false;
887
                    }
888
                }
889
890
                return true;
891
            });
892
    }
893
894
    /**
895
     * @param string $soundex
896
     * @param string $lastname
897
     * @param string $firstname
898
     * @param string $place
899
     * @param Tree[] $search_trees
900
     *
901
     * @return Collection<Individual>
902
     */
903
    public function searchIndividualsPhonetic(string $soundex, string $lastname, string $firstname, string $place, array $search_trees): Collection
904
    {
905
        switch ($soundex) {
906
            default:
907
            case 'Russell':
908
                $givn_sdx   = Soundex::russell($firstname);
909
                $surn_sdx   = Soundex::russell($lastname);
910
                $plac_sdx   = Soundex::russell($place);
911
                $givn_field = 'n_soundex_givn_std';
912
                $surn_field = 'n_soundex_surn_std';
913
                $plac_field = 'p_std_soundex';
914
                break;
915
            case 'DaitchM':
916
                $givn_sdx   = Soundex::daitchMokotoff($firstname);
917
                $surn_sdx   = Soundex::daitchMokotoff($lastname);
918
                $plac_sdx   = Soundex::daitchMokotoff($place);
919
                $givn_field = 'n_soundex_givn_dm';
920
                $surn_field = 'n_soundex_surn_dm';
921
                $plac_field = 'p_dm_soundex';
922
                break;
923
        }
924
925
        // Nothing to search for? Return nothing.
926
        if ($givn_sdx === '' && $surn_sdx === '' && $plac_sdx === '') {
927
            return new Collection();
928
        }
929
930
        $query = DB::table('individuals')
931
            ->select(['individuals.*'])
932
            ->distinct();
933
934
        $this->whereTrees($query, 'i_file', $search_trees);
935
936
        if ($plac_sdx !== '') {
937
            $query->join('placelinks', static function (JoinClause $join): void {
938
                $join
939
                    ->on('placelinks.pl_file', '=', 'individuals.i_file')
940
                    ->on('placelinks.pl_gid', '=', 'individuals.i_id');
941
            });
942
            $query->join('places', static function (JoinClause $join): void {
943
                $join
944
                    ->on('places.p_file', '=', 'placelinks.pl_file')
945
                    ->on('places.p_id', '=', 'placelinks.pl_p_id');
946
            });
947
948
            $this->wherePhonetic($query, $plac_field, $plac_sdx);
949
        }
950
951
        if ($givn_sdx !== '' || $surn_sdx !== '') {
952
            $query->join('name', static function (JoinClause $join): void {
953
                $join
954
                    ->on('name.n_file', '=', 'individuals.i_file')
955
                    ->on('name.n_id', '=', 'individuals.i_id');
956
            });
957
958
            $this->wherePhonetic($query, $givn_field, $givn_sdx);
959
            $this->wherePhonetic($query, $surn_field, $surn_sdx);
960
        }
961
962
        return $query
963
            ->get()
964
            ->each($this->rowLimiter())
965
            ->map($this->individualRowMapper())
966
            ->filter(GedcomRecord::accessFilter());
967
    }
968
969
    /**
970
     * Paginate a search query.
971
     *
972
     * @param Builder $query      Searches the database for the desired records.
973
     * @param Closure $row_mapper Converts a row from the query into a record.
974
     * @param Closure $row_filter
975
     * @param int     $offset     Skip this many rows.
976
     * @param int     $limit      Take this many rows.
977
     *
978
     * @return Collection<mixed>
979
     */
980
    private function paginateQuery(Builder $query, Closure $row_mapper, Closure $row_filter, int $offset, int $limit): Collection
981
    {
982
        $collection = new Collection();
983
984
        foreach ($query->cursor() as $row) {
985
            $record = $row_mapper($row);
986
            // searchIndividualNames() and searchFamilyNames() can return duplicate rows,
987
            // where individuals have multiple names - and we need to sort results by name.
988
            if ($collection->containsStrict($record)) {
989
                continue;
990
            }
991
            // If the object has a method "canShow()", then use it to filter for privacy.
992
            if ($row_filter($record)) {
993
                if ($offset > 0) {
994
                    $offset--;
995
                } else {
996
                    if ($limit > 0) {
997
                        $collection->push($record);
998
                    }
999
1000
                    $limit--;
1001
1002
                    if ($limit === 0) {
1003
                        break;
1004
                    }
1005
                }
1006
            }
1007
        }
1008
1009
1010
        return $collection;
1011
    }
1012
1013
    /**
1014
     * Apply search filters to a SQL query column.  Apply collation rules to MySQL.
1015
     *
1016
     * @param Builder           $query
1017
     * @param Expression|string $field
1018
     * @param string[]          $search_terms
1019
     */
1020
    private function whereSearch(Builder $query, $field, array $search_terms): void
1021
    {
1022
        if ($field instanceof Expression) {
1023
            $field = $field->getValue();
1024
        }
1025
1026
        foreach ($search_terms as $search_term) {
1027
            $query->where(new Expression($field), 'LIKE', '%' . addcslashes($search_term, '\\%_') . '%');
1028
        }
1029
    }
1030
1031
    /**
1032
     * Apply soundex search filters to a SQL query column.
1033
     *
1034
     * @param Builder           $query
1035
     * @param Expression|string $field
1036
     * @param string            $soundex
1037
     */
1038
    private function wherePhonetic(Builder $query, $field, string $soundex): void
1039
    {
1040
        if ($soundex !== '') {
1041
            $query->where(static function (Builder $query) use ($soundex, $field): void {
1042
                foreach (explode(':', $soundex) as $sdx) {
1043
                    $query->orWhere($field, 'LIKE', '%' . $sdx . '%');
1044
                }
1045
            });
1046
        }
1047
    }
1048
1049
    /**
1050
     * @param Builder $query
1051
     * @param string  $tree_id_field
1052
     * @param Tree[]  $trees
1053
     */
1054
    private function whereTrees(Builder $query, string $tree_id_field, array $trees): void
1055
    {
1056
        $tree_ids = array_map(static function (Tree $tree): int {
1057
            return $tree->id();
1058
        }, $trees);
1059
1060
        $query->whereIn($tree_id_field, $tree_ids);
1061
    }
1062
1063
    /**
1064
     * Find the media object that uses a particular media file.
1065
     *
1066
     * @param string $file
1067
     *
1068
     * @return Media[]
1069
     */
1070
    public function findMediaObjectsForMediaFile(string $file): array
1071
    {
1072
        return DB::table('media')
1073
            ->join('media_file', static function (JoinClause $join): void {
1074
                $join
1075
                    ->on('media_file.m_file', '=', 'media.m_file')
1076
                    ->on('media_file.m_id', '=', 'media.m_id');
1077
            })
1078
            ->join('gedcom_setting', 'media.m_file', '=', 'gedcom_setting.gedcom_id')
1079
            ->where(new Expression('setting_value || multimedia_file_refn'), '=', $file)
1080
            ->select(['media.*'])
1081
            ->distinct()
1082
            ->get()
1083
            ->map($this->mediaRowMapper())
1084
            ->all();
1085
    }
1086
1087
    /**
1088
     * A closure to filter records by privacy-filtered GEDCOM data.
1089
     *
1090
     * @param array<string> $search_terms
1091
     *
1092
     * @return Closure
1093
     */
1094
    private function rawGedcomFilter(array $search_terms): Closure
1095
    {
1096
        return static function (GedcomRecord $record) use ($search_terms): bool {
1097
            // Ignore non-genealogy fields
1098
            $gedcom = preg_replace('/\n\d (?:_UID|_WT_USER) .*/', '', $record->gedcom());
1099
1100
            // Ignore matches in links
1101
            $gedcom = preg_replace('/\n\d ' . Gedcom::REGEX_TAG . '( @' . Gedcom::REGEX_XREF . '@)?/', '', $gedcom);
1102
1103
            // Re-apply the filtering
1104
            foreach ($search_terms as $search_term) {
1105
                if (mb_stripos($gedcom, $search_term) === false) {
1106
                    return false;
1107
                }
1108
            }
1109
1110
            return true;
1111
        };
1112
    }
1113
1114
    /**
1115
     * Searching for short or common text can give more results than the system can process.
1116
     *
1117
     * @param int $limit
1118
     *
1119
     * @return Closure
1120
     */
1121
    private function rowLimiter(int $limit = self::MAX_SEARCH_RESULTS): Closure
1122
    {
1123
        return static function () use ($limit): void {
1124
            static $n = 0;
1125
1126
            if (++$n > $limit) {
1127
                $message = I18N::translate('The search returned too many results.');
1128
1129
                throw new HttpServiceUnavailableException($message);
1130
            }
1131
        };
1132
    }
1133
1134
    /**
1135
     * Convert a row from any tree in the families table into a family object.
1136
     *
1137
     * @return Closure
1138
     */
1139
    private function familyRowMapper(): Closure
1140
    {
1141
        return function (stdClass $row): Family {
1142
            $tree = $this->tree_service->find((int) $row->f_file);
1143
1144
            return Registry::familyFactory()->mapper($tree)($row);
1145
        };
1146
    }
1147
1148
    /**
1149
     * Convert a row from any tree in the individuals table into an individual object.
1150
     *
1151
     * @return Closure
1152
     */
1153
    private function individualRowMapper(): Closure
1154
    {
1155
        return function (stdClass $row): Individual {
1156
            $tree = $this->tree_service->find((int) $row->i_file);
1157
1158
            return Registry::individualFactory()->mapper($tree)($row);
1159
        };
1160
    }
1161
1162
    /**
1163
     * Convert a row from any tree in the media table into an media object.
1164
     *
1165
     * @return Closure
1166
     */
1167
    private function mediaRowMapper(): Closure
1168
    {
1169
        return function (stdClass $row): Media {
1170
            $tree = $this->tree_service->find((int) $row->m_file);
1171
1172
            return Registry::mediaFactory()->mapper($tree)($row);
1173
        };
1174
    }
1175
1176
    /**
1177
     * Convert a row from any tree in the other table into a note object.
1178
     *
1179
     * @return Closure
1180
     */
1181
    private function noteRowMapper(): Closure
1182
    {
1183
        return function (stdClass $row): Note {
1184
            $tree = $this->tree_service->find((int) $row->o_file);
1185
1186
            return Registry::noteFactory()->mapper($tree)($row);
1187
        };
1188
    }
1189
1190
    /**
1191
     * Convert a row from any tree in the other table into a repository object.
1192
     *
1193
     * @return Closure
1194
     */
1195
    private function repositoryRowMapper(): Closure
1196
    {
1197
        return function (stdClass $row): Repository {
1198
            $tree = $this->tree_service->find((int) $row->o_file);
1199
1200
            return Registry::repositoryFactory()->mapper($tree)($row);
1201
        };
1202
    }
1203
1204
    /**
1205
     * Convert a row from any tree in the sources table into a source object.
1206
     *
1207
     * @return Closure
1208
     */
1209
    private function sourceRowMapper(): Closure
1210
    {
1211
        return function (stdClass $row): Source {
1212
            $tree = $this->tree_service->find((int) $row->s_file);
1213
1214
            return Registry::sourceFactory()->mapper($tree)($row);
1215
        };
1216
    }
1217
1218
    /**
1219
     * Convert a row from any tree in the other table into a submitter object.
1220
     *
1221
     * @return Closure
1222
     */
1223
    private function submitterRowMapper(): Closure
1224
    {
1225
        return function (stdClass $row): Submitter {
1226
            $tree = $this->tree_service->find((int) $row->o_file);
1227
1228
            return Registry::submitterFactory()->mapper($tree)($row);
1229
        };
1230
    }
1231
}
1232