Passed
Push — 2.1 ( 4f7222...fd5fab )
by Greg
08:07
created

SearchService::iLike()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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