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