xref: /webtrees/app/Services/SearchService.php (revision 4947e7089adb08f24bf1eef67994a106a8df60aa)
1<?php
2/**
3 * webtrees: online genealogy
4 * Copyright (C) 2019 webtrees development team
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License
14 * along with this program. If not, see <http://www.gnu.org/licenses/>.
15 */
16declare(strict_types=1);
17
18namespace Fisharebest\Webtrees\Services;
19
20use Closure;
21use Fisharebest\Localization\Locale\LocaleInterface;
22use Fisharebest\Webtrees\Date;
23use Fisharebest\Webtrees\Family;
24use Fisharebest\Webtrees\Gedcom;
25use Fisharebest\Webtrees\GedcomRecord;
26use Fisharebest\Webtrees\Individual;
27use Fisharebest\Webtrees\Media;
28use Fisharebest\Webtrees\Note;
29use Fisharebest\Webtrees\Place;
30use Fisharebest\Webtrees\Repository;
31use Fisharebest\Webtrees\Soundex;
32use Fisharebest\Webtrees\Source;
33use Fisharebest\Webtrees\Tree;
34use Illuminate\Database\Capsule\Manager as DB;
35use Illuminate\Database\Query\Builder;
36use Illuminate\Database\Query\Expression;
37use Illuminate\Database\Query\JoinClause;
38use Illuminate\Support\Collection;
39use stdClass;
40use function mb_stripos;
41
42/**
43 * Search trees for genealogy records.
44 */
45class SearchService
46{
47    /** @var LocaleInterface */
48    private $locale;
49
50    /**
51     * SearchService constructor.
52     *
53     * @param LocaleInterface $locale
54     */
55    public function __construct(LocaleInterface $locale)
56    {
57        $this->locale = $locale;
58    }
59
60    /**
61     * @param Tree[]   $trees
62     * @param string[] $search
63     *
64     * @return Collection|Family[]
65     */
66    public function searchFamilies(array $trees, array $search): Collection
67    {
68        $query = DB::table('families');
69
70        $this->whereTrees($query, 'f_file', $trees);
71        $this->whereSearch($query, 'f_gedcom', $search);
72
73        return $query
74            ->get()
75            ->map(Family::rowMapper())
76            ->filter(GedcomRecord::accessFilter())
77            ->filter($this->rawGedcomFilter($search));
78    }
79
80    /**
81     * Search for families by name.
82     *
83     * @param Tree[]   $trees
84     * @param string[] $search
85     * @param int      $offset
86     * @param int      $limit
87     *
88     * @return Collection|Family[]
89     */
90    public function searchFamilyNames(array $trees, array $search, int $offset = 0, int $limit = PHP_INT_MAX): Collection
91    {
92        $query = DB::table('families')
93            ->join('name AS husb_name', function (JoinClause $join): void {
94                $join
95                    ->on('husb_name.n_file', '=', 'families.f_file')
96                    ->on('husb_name.n_id', '=', 'families.f_husb');
97            })
98            ->join('name AS wife_name', function (JoinClause $join): void {
99                $join
100                    ->on('wife_name.n_file', '=', 'families.f_file')
101                    ->on('wife_name.n_id', '=', 'families.f_wife');
102            })
103            ->where('wife_name.n_type', '<>', '_MARNM')
104            ->where('husb_name.n_type', '<>', '_MARNM');
105
106        $prefix = DB::connection()->getTablePrefix();
107        $field  = DB::raw('(' . $prefix . 'husb_name.n_full || ' . $prefix . 'wife_name.n_full)');
108
109        $this->whereTrees($query, 'f_file', $trees);
110        $this->whereSearch($query, $field, $search);
111
112        $query
113            ->orderBy('husb_name.n_sort')
114            ->orderBy('wife_name.n_sort')
115            ->select(['families.*', 'husb_name.n_sort', 'wife_name.n_sort'])
116            ->distinct();
117
118        return $this->paginateQuery($query, Family::rowMapper(), GedcomRecord::accessFilter(), $offset, $limit);
119    }
120
121    /**
122     * @param Tree[]   $trees
123     * @param string[] $search
124     *
125     * @return Collection|Individual[]
126     */
127    public function searchIndividuals(array $trees, array $search): Collection
128    {
129        $query = DB::table('individuals');
130
131        $this->whereTrees($query, 'i_file', $trees);
132        $this->whereSearch($query, 'i_gedcom', $search);
133
134        return $query
135            ->get()
136            ->map(Individual::rowMapper())
137            ->filter(GedcomRecord::accessFilter())
138            ->filter($this->rawGedcomFilter($search));
139    }
140
141    /**
142     * Search for individuals by name.
143     *
144     * @param Tree[]   $trees
145     * @param string[] $search
146     * @param int      $offset
147     * @param int      $limit
148     *
149     * @return Collection|Individual[]
150     */
151    public function searchIndividualNames(array $trees, array $search, int $offset = 0, int $limit = PHP_INT_MAX): Collection
152    {
153        $query = DB::table('individuals')
154            ->join('name', function (JoinClause $join): void {
155                $join
156                    ->on('name.n_file', '=', 'individuals.i_file')
157                    ->on('name.n_id', '=', 'individuals.i_id');
158            })
159            ->orderBy('n_sort')
160            ->select(['individuals.*', 'n_sort', 'n_num'])
161            ->distinct();
162
163        $this->whereTrees($query, 'i_file', $trees);
164        $this->whereSearch($query, 'n_full', $search);
165
166        return $this->paginateQuery($query, Individual::rowMapper(), GedcomRecord::accessFilter(), $offset, $limit);
167    }
168
169    /**
170     * Search for media objects.
171     *
172     * @param Tree[]   $trees
173     * @param string[] $search
174     * @param int      $offset
175     * @param int      $limit
176     *
177     * @return Collection|Media[]
178     */
179    public function searchMedia(array $trees, array $search, int $offset = 0, int $limit = PHP_INT_MAX): Collection
180    {
181        $query = DB::table('media');
182
183        $this->whereTrees($query, 'media.m_file', $trees);
184        $this->whereSearch($query, 'm_gedcom', $search);
185
186        return $this->paginateQuery($query, Media::rowMapper(), GedcomRecord::accessFilter(), $offset, $limit);
187    }
188
189    /**
190     * Search for notes.
191     *
192     * @param Tree[]   $trees
193     * @param string[] $search
194     * @param int      $offset
195     * @param int      $limit
196     *
197     * @return Collection|Note[]
198     */
199    public function searchNotes(array $trees, array $search, int $offset = 0, int $limit = PHP_INT_MAX): Collection
200    {
201        $query = DB::table('other')
202            ->where('o_type', '=', 'NOTE');
203
204        $this->whereTrees($query, 'o_file', $trees);
205        $this->whereSearch($query, 'o_gedcom', $search);
206
207        return $this->paginateQuery($query, Note::rowMapper(), GedcomRecord::accessFilter(), $offset, $limit);
208    }
209
210    /**
211     * Search for repositories.
212     *
213     * @param Tree[]   $trees
214     * @param string[] $search
215     * @param int      $offset
216     * @param int      $limit
217     *
218     * @return Collection|Repository[]
219     */
220    public function searchRepositories(array $trees, array $search, int $offset = 0, int $limit = PHP_INT_MAX): Collection
221    {
222        $query = DB::table('other')
223            ->where('o_type', '=', 'REPO');
224
225        $this->whereTrees($query, 'o_file', $trees);
226        $this->whereSearch($query, 'o_gedcom', $search);
227
228        return $this->paginateQuery($query, Repository::rowMapper(), GedcomRecord::accessFilter(), $offset, $limit);
229    }
230
231    /**
232     * Search for sources.
233     *
234     * @param Tree[]   $trees
235     * @param string[] $search
236     * @param int      $offset
237     * @param int      $limit
238     *
239     * @return Collection|Source[]
240     */
241    public function searchSources(array $trees, array $search, int $offset = 0, int $limit = PHP_INT_MAX): Collection
242    {
243        $query = DB::table('sources');
244
245        $this->whereTrees($query, 's_file', $trees);
246        $this->whereSearch($query, 's_gedcom', $search);
247
248        return $this->paginateQuery($query, Source::rowMapper(), GedcomRecord::accessFilter(), $offset, $limit);
249    }
250
251    /**
252     * Search for sources by name.
253     *
254     * @param Tree[]   $trees
255     * @param string[] $search
256     * @param int      $offset
257     * @param int      $limit
258     *
259     * @return Collection|Source[]
260     */
261    public function searchSourcesByName(array $trees, array $search, int $offset = 0, int $limit = PHP_INT_MAX): Collection
262    {
263        $query = DB::table('sources')
264            ->orderBy('s_name');
265
266        $this->whereTrees($query, 's_file', $trees);
267        $this->whereSearch($query, 's_name', $search);
268
269        return $this->paginateQuery($query, Source::rowMapper(), GedcomRecord::accessFilter(), $offset, $limit);
270    }
271
272    /**
273     * Search for submitters.
274     *
275     * @param Tree[]   $trees
276     * @param string[] $search
277     * @param int      $offset
278     * @param int      $limit
279     *
280     * @return Collection|GedcomRecord[]
281     */
282    public function searchSubmitters(array $trees, array $search, int $offset = 0, int $limit = PHP_INT_MAX): Collection
283    {
284        $query = DB::table('other')
285            ->where('o_type', '=', 'SUBM');
286
287        $this->whereTrees($query, 'o_file', $trees);
288        $this->whereSearch($query, 'o_gedcom', $search);
289
290        return $this->paginateQuery($query, GedcomRecord::rowMapper(), GedcomRecord::accessFilter(), $offset, $limit);
291    }
292
293    /**
294     * Search for places.
295     *
296     * @param Tree   $tree
297     * @param string $search
298     * @param int    $offset
299     * @param int    $limit
300     *
301     * @return Collection|Place[]
302     */
303    public function searchPlaces(Tree $tree, string $search, int $offset = 0, int $limit = PHP_INT_MAX): Collection
304    {
305        $query = DB::table('places AS p0')
306            ->where('p0.p_file', '=', $tree->id())
307            ->leftJoin('places AS p1', 'p1.p_id', '=', 'p0.p_parent_id')
308            ->leftJoin('places AS p2', 'p2.p_id', '=', 'p1.p_parent_id')
309            ->leftJoin('places AS p3', 'p3.p_id', '=', 'p2.p_parent_id')
310            ->leftJoin('places AS p4', 'p4.p_id', '=', 'p3.p_parent_id')
311            ->leftJoin('places AS p5', 'p5.p_id', '=', 'p4.p_parent_id')
312            ->leftJoin('places AS p6', 'p6.p_id', '=', 'p5.p_parent_id')
313            ->leftJoin('places AS p7', 'p7.p_id', '=', 'p6.p_parent_id')
314            ->leftJoin('places AS p8', 'p8.p_id', '=', 'p7.p_parent_id')
315            ->orderBy('p0.p_place')
316            ->orderBy('p1.p_place')
317            ->orderBy('p2.p_place')
318            ->orderBy('p3.p_place')
319            ->orderBy('p4.p_place')
320            ->orderBy('p5.p_place')
321            ->orderBy('p6.p_place')
322            ->orderBy('p7.p_place')
323            ->orderBy('p8.p_place')
324            ->select([
325                'p0.p_place AS place0',
326                'p1.p_place AS place1',
327                'p2.p_place AS place2',
328                'p3.p_place AS place3',
329                'p4.p_place AS place4',
330                'p5.p_place AS place5',
331                'p6.p_place AS place6',
332                'p7.p_place AS place7',
333                'p8.p_place AS place8',
334            ]);
335
336        // Filter each level of the hierarchy.
337        foreach (explode(',', $search, 9) as $level => $string) {
338            $query->whereContains('p' . $level . '.p_place', $string);
339        }
340
341        $row_mapper = function (stdClass $row) use ($tree): Place {
342            $place = implode(', ', array_filter((array) $row));
343
344            return new Place($place, $tree);
345        };
346
347        $filter = function (): bool {
348            return true;
349        };
350
351        return $this->paginateQuery($query, $row_mapper, $filter, $offset, $limit);
352    }
353
354    /**
355     * @param Tree[]   $trees
356     * @param string[] $fields
357     * @param string[] $modifiers
358     *
359     * @return Collection|Individual[]
360     */
361    public function searchIndividualsAdvanced(array $trees, array $fields, array $modifiers): Collection
362    {
363        $fields = array_filter($fields);
364
365        $query = DB::table('individuals')
366            ->select(['individuals.*'])
367            ->distinct();
368
369        $this->whereTrees($query, 'i_file', $trees);
370
371        // Join the following tables
372        $father_name   = false;
373        $mother_name   = false;
374        $spouse_family = false;
375        $indi_name     = false;
376        $indi_date     = false;
377        $fam_date      = false;
378        $indi_plac     = false;
379        $fam_plac      = false;
380
381        foreach ($fields as $field_name => $field_value) {
382            if ($field_value !== '') {
383                if (substr($field_name, 0, 14) === 'FAMC:HUSB:NAME') {
384                    $father_name = true;
385                } elseif (substr($field_name, 0, 14) === 'FAMC:WIFE:NAME') {
386                    $mother_name = true;
387                } elseif (substr($field_name, 0, 4) === 'NAME') {
388                    $indi_name = true;
389                } elseif (strpos($field_name, ':DATE') !== false) {
390                    if (substr($field_name, 0, 4) === 'FAMS') {
391                        $fam_date      = true;
392                        $spouse_family = true;
393                    } else {
394                        $indi_date = true;
395                    }
396                } elseif (strpos($field_name, ':PLAC') !== false) {
397                    if (substr($field_name, 0, 4) === 'FAMS') {
398                        $fam_plac      = true;
399                        $spouse_family = true;
400                    } else {
401                        $indi_plac = true;
402                    }
403                } elseif ($field_name === 'FAMS:NOTE') {
404                    $spouse_family = true;
405                }
406            }
407        }
408
409        if ($father_name || $mother_name) {
410            $query->join('link AS l1', function (JoinClause $join): void {
411                $join
412                    ->on('l1.l_file', '=', 'individuals.i_file')
413                    ->on('l1.l_from', '=', 'individuals.i_id')
414                    ->where('l1.l_type', '=', 'FAMC');
415            });
416
417            if ($father_name) {
418                $query->join('link AS l2', function (JoinClause $join): void {
419                    $join
420                        ->on('l2.l_file', '=', 'l1.l_file')
421                        ->on('l2.l_from', '=', 'l1.l_to')
422                        ->where('l2.l_type', '=', 'HUSB');
423                });
424                $query->join('name AS father_name', function (JoinClause $join): void {
425                    $join
426                        ->on('father_name.n_file', '=', 'l2.l_file')
427                        ->on('father_name.n_id', '=', 'l2.l_to');
428                });
429            }
430
431            if ($mother_name) {
432                $query->join('link AS l3', function (JoinClause $join): void {
433                    $join
434                        ->on('l3.l_file', '=', 'l1.l_file')
435                        ->on('l3.l_from', '=', 'l1.l_to')
436                        ->where('l3.l_type', '=', 'WIFE');
437                });
438                $query->join('name AS mother_name', function (JoinClause $join): void {
439                    $join
440                        ->on('mother_name.n_file', '=', 'l3.l_file')
441                        ->on('mother_name.n_id', '=', 'l3.l_to');
442                });
443            }
444        }
445
446        if ($spouse_family) {
447            $query->join('link AS l4', function (JoinClause $join): void {
448                $join
449                    ->on('l4.l_file', '=', 'individuals.i_file')
450                    ->on('l4.l_from', '=', 'individuals.i_id')
451                    ->where('l4.l_type', '=', 'FAMS');
452            });
453            $query->join('families AS spouse_families', function (JoinClause $join): void {
454                $join
455                    ->on('spouse_families.f_file', '=', 'l4.l_file')
456                    ->on('spouse_families.f_id', '=', 'l4.l_to');
457            });
458        }
459
460        if ($indi_name) {
461            $query->join('name AS individual_name', function (JoinClause $join): void {
462                $join
463                    ->on('individual_name.n_file', '=', 'individuals.i_file')
464                    ->on('individual_name.n_id', '=', 'individuals.i_id');
465            });
466        }
467
468        if ($indi_date) {
469            $query->join('dates AS individual_dates', function (JoinClause $join): void {
470                $join
471                    ->on('individual_dates.d_file', '=', 'individuals.i_file')
472                    ->on('individual_dates.d_gid', '=', 'individuals.i_id');
473            });
474        }
475
476        if ($fam_date) {
477            $query->join('dates AS family_dates', function (JoinClause $join): void {
478                $join
479                    ->on('family_dates.d_file', '=', 'spouse_families.f_file')
480                    ->on('family_dates.d_gid', '=', 'spouse_families.f_id');
481            });
482        }
483
484        if ($indi_plac) {
485            $query->join('placelinks AS individual_placelinks', function (JoinClause $join): void {
486                $join
487                    ->on('individual_placelinks.pl_file', '=', 'individuals.i_file')
488                    ->on('individual_placelinks.pl_gid', '=', 'individuals.i_id');
489            });
490            $query->join('places AS individual_places', function (JoinClause $join): void {
491                $join
492                    ->on('individual_places.p_file', '=', 'individual_placelinks.pl_file')
493                    ->on('individual_places.p_id', '=', 'individual_placelinks.pl_p_id');
494            });
495        }
496
497        if ($fam_plac) {
498            $query->join('placelinks AS familyl_placelinks', function (JoinClause $join): void {
499                $join
500                    ->on('familyl_placelinks.pl_file', '=', 'individuals.i_file')
501                    ->on('familyl_placelinks.pl_gid', '=', 'individuals.i_id');
502            });
503            $query->join('places AS family_places', function (JoinClause $join): void {
504                $join
505                    ->on('family_places.p_file', '=', 'familyl_placelinks.pl_file')
506                    ->on('family_places.p_id', '=', 'familyl_placelinks.pl_p_id');
507            });
508        }
509
510        foreach ($fields as $field_name => $field_value) {
511            $parts = preg_split('/:/', $field_name . '::::');
512            if ($parts[0] === 'NAME') {
513                // NAME:*
514                switch ($parts[1]) {
515                    case 'GIVN':
516                        switch ($modifiers[$field_name]) {
517                            case 'EXACT':
518                                $query->where('individual_name.n_givn', '=', $field_value);
519                                break;
520                            case 'BEGINS':
521                                $query->where('individual_name.n_givn', 'LIKE', $field_value . '%');
522                                break;
523                            case 'CONTAINS':
524                                $query->where('individual_name.n_givn', 'LIKE', '%' . $field_value . '%');
525                                break;
526                            case 'SDX_STD':
527                                $sdx = Soundex::russell($field_value);
528                                if ($sdx !== '') {
529                                    $this->wherePhonetic($query, 'individual_name.n_soundex_givn_std', $sdx);
530                                } else {
531                                    // No phonetic content? Use a substring match
532                                    $query->where('individual_name.n_givn', 'LIKE', '%' . $field_value . '%');
533                                }
534                                break;
535                            case 'SDX': // SDX uses DM by default.
536                            case 'SDX_DM':
537                                $sdx = Soundex::daitchMokotoff($field_value);
538                                if ($sdx !== '') {
539                                    $this->wherePhonetic($query, 'individual_name.n_soundex_givn_dm', $sdx);
540                                } else {
541                                    // No phonetic content? Use a substring match
542                                    $query->where('individual_name.n_givn', 'LIKE', '%' . $field_value . '%');
543                                }
544                                break;
545                        }
546                        break;
547                    case 'SURN':
548                        switch ($modifiers[$field_name]) {
549                            case 'EXACT':
550                                $query->where('individual_name.n_surn', '=', $field_value);
551                                break;
552                            case 'BEGINS':
553                                $query->where('individual_name.n_surn', 'LIKE', $field_value . '%');
554                                break;
555                            case 'CONTAINS':
556                                $query->where('individual_name.n_surn', 'LIKE', '%' . $field_value . '%');
557                                break;
558                            case 'SDX_STD':
559                                $sdx = Soundex::russell($field_value);
560                                if ($sdx !== '') {
561                                    $this->wherePhonetic($query, 'individual_name.n_soundex_surn_std', $sdx);
562                                } else {
563                                    // No phonetic content? Use a substring match
564                                    $query->where('individual_name.n_surn', 'LIKE', '%' . $field_value . '%');
565                                }
566                                break;
567                            case 'SDX': // SDX uses DM by default.
568                            case 'SDX_DM':
569                                $sdx = Soundex::daitchMokotoff($field_value);
570                                if ($sdx !== '') {
571                                    $this->wherePhonetic($query, 'individual_name.n_soundex_surn_dm', $sdx);
572                                } else {
573                                    // No phonetic content? Use a substring match
574                                    $query->where('individual_name.n_surn', 'LIKE', '%' . $field_value . '%');
575                                }
576                                break;
577                        }
578                        break;
579                    case 'NICK':
580                    case '_MARNM':
581                    case '_HEB':
582                    case '_AKA':
583                        $query
584                            ->where('individual_name', '=', $parts[1])
585                            ->where('individual_name', 'LIKE', '%' . $field_value . '%');
586                        break;
587                }
588                unset($fields[$field_name]);
589            } elseif ($parts[1] === 'DATE') {
590                // *:DATE
591                $date = new Date($field_value);
592                if ($date->isOK()) {
593                    $delta = 365 * ($modifiers[$field_name] ?? 0);
594                    $query
595                        ->where('individual_dates.d_fact', '=', $parts[0])
596                        ->where('individual_dates.d_julianday1', '>=', $date->minimumJulianDay() - $delta)
597                        ->where('individual_dates.d_julianday2', '<=', $date->minimumJulianDay() + $delta);
598                }
599                unset($fields[$field_name]);
600            } elseif ($parts[0] === 'FAMS' && $parts[2] === 'DATE') {
601                // FAMS:*:DATE
602                $date = new Date($field_value);
603                if ($date->isOK()) {
604                    $delta = 365 * $modifiers[$field_name];
605                    $query
606                        ->where('family_dates.d_fact', '=', $parts[1])
607                        ->where('family_dates.d_julianday1', '>=', $date->minimumJulianDay() - $delta)
608                        ->where('family_dates.d_julianday2', '<=', $date->minimumJulianDay() + $delta);
609                }
610                unset($fields[$field_name]);
611            } elseif ($parts[1] === 'PLAC') {
612                // *:PLAC
613                // SQL can only link a place to a person/family, not to an event.
614                $query->where('individual_places.p_place', 'LIKE', '%' . $field_value . '%');
615            } elseif ($parts[0] === 'FAMS' && $parts[2] === 'PLAC') {
616                // FAMS:*:PLAC
617                // SQL can only link a place to a person/family, not to an event.
618                $query->where('family_places.p_place', 'LIKE', '%' . $field_value . '%');
619            } elseif ($parts[0] === 'FAMC' && $parts[2] === 'NAME') {
620                $table = $parts[1] === 'HUSB' ? 'father_name' : 'mother_name';
621                // NAME:*
622                switch ($parts[3]) {
623                    case 'GIVN':
624                        switch ($modifiers[$field_name]) {
625                            case 'EXACT':
626                                $query->where($table . '.n_givn', '=', $field_value);
627                                break;
628                            case 'BEGINS':
629                                $query->where($table . '.n_givn', 'LIKE', $field_value . '%');
630                                break;
631                            case 'CONTAINS':
632                                $query->where($table . '.n_givn', 'LIKE', '%' . $field_value . '%');
633                                break;
634                            case 'SDX_STD':
635                                $sdx = Soundex::russell($field_value);
636                                if ($sdx !== '') {
637                                    $this->wherePhonetic($query, $table . '.n_soundex_givn_std', $sdx);
638                                } else {
639                                    // No phonetic content? Use a substring match
640                                    $query->where($table . '.n_givn', 'LIKE', '%' . $field_value . '%');
641                                }
642                                break;
643                            case 'SDX': // SDX uses DM by default.
644                            case 'SDX_DM':
645                                $sdx = Soundex::daitchMokotoff($field_value);
646                                if ($sdx !== '') {
647                                    $this->wherePhonetic($query, $table . '.n_soundex_givn_dm', $sdx);
648                                } else {
649                                    // No phonetic content? Use a substring match
650                                    $query->where($table . '.n_givn', 'LIKE', '%' . $field_value . '%');
651                                }
652                                break;
653                        }
654                        break;
655                    case 'SURN':
656                        switch ($modifiers[$field_name]) {
657                            case 'EXACT':
658                                $query->where($table . '.n_surn', '=', $field_value);
659                                break;
660                            case 'BEGINS':
661                                $query->where($table . '.n_surn', 'LIKE', $field_value . '%');
662                                break;
663                            case 'CONTAINS':
664                                $query->where($table . '.n_surn', 'LIKE', '%' . $field_value . '%');
665                                break;
666                            case 'SDX_STD':
667                                $sdx = Soundex::russell($field_value);
668                                if ($sdx !== '') {
669                                    $this->wherePhonetic($query, $table . '.n_soundex_surn_std', $sdx);
670                                } else {
671                                    // No phonetic content? Use a substring match
672                                    $query->where($table . '.n_surn', 'LIKE', '%' . $field_value . '%');
673                                }
674                                break;
675                            case 'SDX': // SDX uses DM by default.
676                            case 'SDX_DM':
677                                $sdx = Soundex::daitchMokotoff($field_value);
678                                if ($sdx !== '') {
679                                    $this->wherePhonetic($query, $table . '.n_soundex_surn_dm', $sdx);
680                                } else {
681                                    // No phonetic content? Use a substring match
682                                    $query->where($table . '.n_surn', 'LIKE', '%' . $field_value . '%');
683                                }
684                                break;
685                        }
686                        break;
687                }
688                unset($fields[$field_name]);
689            } elseif ($parts[0] === 'FAMS') {
690                // e.g. searches for occupation, religion, note, etc.
691                // Initial matching only.  Need PHP to apply filter.
692                $query->where('families.f_gedcom', 'LIKE', "%\n1 " . $parts[1] . ' %' . $field_value . '%');
693            } elseif ($parts[1] === 'TYPE') {
694                // e.g. FACT:TYPE or EVEN:TYPE
695                // Initial matching only.  Need PHP to apply filter.
696                $query->where('individuals.i_gedcom', 'LIKE', "%\n1 " . $parts[0] . '%\n2 TYPE %' . $field_value . '%');
697            } else {
698                // e.g. searches for occupation, religion, note, etc.
699                // Initial matching only.  Need PHP to apply filter.
700                $query->where('individuals.i_gedcom', 'LIKE', "%\n1 " . $parts[0] . ' %' . $field_value . '%');
701            }
702        }
703
704        return $query
705            ->get()
706            ->map(Individual::rowMapper())
707            ->filter(GedcomRecord::accessFilter())
708            ->filter(function (Individual $individual) use ($fields): bool {
709                // Check for XXXX:PLAC fields, which were only partially matched by SQL
710                foreach ($fields as $field_name => $field_value) {
711                    $regex = '/' . preg_quote($field_value, '/') . '/i';
712
713                    $parts = preg_split('/:/', $field_name . '::::');
714
715                    if ($parts[1] === 'PLAC') {
716                        // *:PLAC
717                        foreach ($individual->facts([$parts[0]]) as $fact) {
718                            if (preg_match($regex, $fact->place()->getGedcomName())) {
719                                return true;
720                            }
721                        }
722                    } elseif ($parts[0] === 'FAMS' && $parts[2] === 'PLAC') {
723                        // FAMS:*:PLAC
724                        foreach ($individual->getSpouseFamilies() as $family) {
725                            foreach ($family->facts([$parts[1]]) as $fact) {
726                                if (preg_match($regex, $fact->place()->getGedcomName())) {
727                                    return true;
728                                }
729                            }
730                        }
731                    } elseif ($parts[0] === 'FAMS') {
732                        // e.g. searches for occupation, religion, note, etc.
733                        foreach ($individual->getSpouseFamilies() as $family) {
734                            foreach ($family->facts([$parts[1]]) as $fact) {
735                                if (preg_match($regex, $fact->value())) {
736                                    return true;
737                                }
738                            }
739                        }
740                    } elseif ($parts[1] === 'TYPE') {
741                        // e.g. FACT:TYPE or EVEN:TYPE
742                        foreach ($individual->facts([$parts[0]]) as $fact) {
743                            if (preg_match($regex, $fact->attribute('TYPE'))) {
744                                return true;
745                            }
746                        }
747                    } else {
748                        // e.g. searches for occupation, religion, note, etc.
749                        foreach ($individual->facts([$parts[0]]) as $fact) {
750                            if (preg_match($regex, $fact->value())) {
751                                return true;
752                            }
753                        }
754                    }
755
756                    // No match
757                    return false;
758                }
759
760                return true;
761            });
762    }
763
764    /**
765     * @param string $soundex
766     * @param string $lastname
767     * @param string $firstname
768     * @param string $place
769     * @param Tree[] $search_trees
770     *
771     * @return Collection|Individual[]
772     */
773    public function searchIndividualsPhonetic(string $soundex, string $lastname, string $firstname, string $place, array $search_trees): Collection
774    {
775        switch ($soundex) {
776            default:
777            case 'Russell':
778                $givn_sdx   = Soundex::russell($firstname);
779                $surn_sdx   = Soundex::russell($lastname);
780                $plac_sdx   = Soundex::russell($place);
781                $givn_field = 'n_soundex_givn_std';
782                $surn_field = 'n_soundex_surn_std';
783                $plac_field = 'p_std_soundex';
784                break;
785            case 'DaitchM':
786                $givn_sdx   = Soundex::daitchMokotoff($firstname);
787                $surn_sdx   = Soundex::daitchMokotoff($lastname);
788                $plac_sdx   = Soundex::daitchMokotoff($place);
789                $givn_field = 'n_soundex_givn_dm';
790                $surn_field = 'n_soundex_surn_dm';
791                $plac_field = 'p_dm_soundex';
792                break;
793        }
794
795        // Nothing to search for? Return nothing.
796        if ($givn_sdx === '' && $surn_sdx === '' && $plac_sdx === '') {
797            return new Collection();
798        }
799
800        $query = DB::table('individuals')
801            ->select(['individuals.*'])
802            ->distinct();
803
804        $this->whereTrees($query, 'i_file', $search_trees);
805
806        if ($plac_sdx !== '') {
807            $query->join('placelinks', function (JoinClause $join): void {
808                $join
809                    ->on('placelinks.pl_file', '=', 'individuals.i_file')
810                    ->on('placelinks.pl_gid', '=', 'individuals.i_id');
811            });
812            $query->join('places', function (JoinClause $join): void {
813                $join
814                    ->on('places.p_file', '=', 'placelinks.pl_file')
815                    ->on('places.p_id', '=', 'placelinks.pl_p_id');
816            });
817
818            $this->wherePhonetic($query, $plac_field, $plac_sdx);
819        }
820
821        if ($givn_sdx !== '' || $surn_sdx !== '') {
822            $query->join('name', function (JoinClause $join): void {
823                $join
824                    ->on('name.n_file', '=', 'individuals.i_file')
825                    ->on('name.n_id', '=', 'individuals.i_id');
826            });
827
828            $this->wherePhonetic($query, $givn_field, $givn_sdx);
829            $this->wherePhonetic($query, $surn_field, $surn_sdx);
830        }
831
832        return $query
833            ->get()
834            ->map(Individual::rowMapper())
835            ->filter(GedcomRecord::accessFilter());
836    }
837
838    /**
839     * Paginate a search query.
840     *
841     * @param Builder $query      Searches the database for the desired records.
842     * @param Closure $row_mapper Converts a row from the query into a record.
843     * @param Closure $row_filter
844     * @param int     $offset     Skip this many rows.
845     * @param int     $limit      Take this many rows.
846     *
847     * @return Collection
848     */
849    private function paginateQuery(Builder $query, Closure $row_mapper, Closure $row_filter, int $offset, int $limit): Collection
850    {
851        $collection = new Collection();
852
853        foreach ($query->cursor() as $row) {
854            $record = $row_mapper($row);
855            // If the object has a method "canShow()", then use it to filter for privacy.
856            if ($row_filter($record)) {
857                if ($offset > 0) {
858                    $offset--;
859                } else {
860                    if ($limit > 0) {
861                        $collection->push($record);
862                    }
863
864                    $limit--;
865
866                    if ($limit === 0) {
867                        break;
868                    }
869                }
870            }
871        }
872
873        return $collection;
874    }
875
876    /**
877     * Apply search filters to a SQL query column.  Apply collation rules to MySQL.
878     *
879     * @param Builder           $query
880     * @param Expression|string $field
881     * @param string[]          $search_terms
882     */
883    private function whereSearch(Builder $query, $field, array $search_terms): void
884    {
885        if ($field instanceof Expression) {
886            $field = $field->getValue();
887        }
888
889        $field = DB::raw($field . ' /*! COLLATE ' . 'utf8_' . $this->locale->collation() . ' */');
890
891        foreach ($search_terms as $search_term) {
892            $query->whereContains($field, $search_term);
893        }
894    }
895
896    /**
897     * Apply soundex search filters to a SQL query column.
898     *
899     * @param Builder           $query
900     * @param Expression|string $field
901     * @param string            $soundex
902     */
903    private function wherePhonetic(Builder $query, $field, string $soundex): void
904    {
905        if ($soundex !== '') {
906            $query->where(function (Builder $query) use ($soundex, $field): void {
907                foreach (explode(':', $soundex) as $sdx) {
908                    $query->orWhere($field, 'LIKE', '%' . $sdx . '%');
909                }
910            });
911        }
912    }
913
914    /**
915     * @param Builder $query
916     * @param string  $tree_id_field
917     * @param Tree[]  $trees
918     */
919    private function whereTrees(Builder $query, string $tree_id_field, array $trees): void
920    {
921        $tree_ids = array_map(function (Tree $tree) {
922            return $tree->id();
923        }, $trees);
924
925        $query->whereIn($tree_id_field, $tree_ids);
926    }
927
928    /**
929     * A closure to filter records by privacy-filtered GEDCOM data.
930     *
931     * @param array $search_terms
932     *
933     * @return Closure
934     */
935    private function rawGedcomFilter(array $search_terms): Closure
936    {
937        return function (GedcomRecord $record) use ($search_terms): bool {
938            // Ignore non-genealogy fields
939            $gedcom = preg_replace('/\n\d (?:_UID) .*/', '', $record->gedcom());
940
941            // Ignore matches in links
942            $gedcom = preg_replace('/\n\d ' . Gedcom::REGEX_TAG . '( @' . Gedcom::REGEX_XREF . '@)?/', '', $gedcom);
943
944            // Re-apply the filtering
945            foreach ($search_terms as $search_term) {
946                if (mb_stripos($gedcom, $search_term) === false) {
947                    return false;
948                }
949            }
950
951            return true;
952        };
953    }
954}
955