| @@ -18,8 +18,23 @@ | ||
| 18 | 18 | */ |
| 19 | 19 | class Indexable_Repository { |
| 20 | 20 | |
| 21 | 21 | /** |
| 22 | + * The maximum number of comma-separated phrases honoured by a title-keyword search. | |
| 23 | + * Any phrases beyond this are ignored so an oversized list cannot blow up the query. | |
| 24 | + * | |
| 25 | + * @var int | |
| 26 | + */ | |
| 27 | + public const MAX_TITLE_KEYWORD_PHRASES = 10; | |
| 28 | + | |
| 29 | + /** | |
| 30 | + * The maximum page size honoured by a title-keyword search. | |
| 31 | + * | |
| 32 | + * @var int | |
| 33 | + */ | |
| 34 | + public const MAX_TITLE_KEYWORD_PAGE_SIZE = 100; | |
| 35 | + | |
| 36 | + /** | |
| 22 | 37 | * The indexable builder. |
| 23 | 38 | * |
| 24 | 39 | * @var Indexable_Builder |
| 25 | 40 | */ |
| @@ -397,8 +412,18 @@ | ||
| 397 | 412 | } |
| 398 | 413 | |
| 399 | 414 | $indexables_to_create = \array_diff( $object_ids, $indexables_available ); |
| 400 | 415 | |
| 416 | + if ( ! empty( $indexables_to_create ) ) { | |
| 417 | + // Warm the object caches for the whole batch, so each build below does not trigger its own uncached queries. | |
| 418 | + if ( $object_type === 'post' ) { | |
| 419 | + \_prime_post_caches( $indexables_to_create ); | |
| 420 | + } | |
| 421 | + elseif ( $object_type === 'term' ) { | |
| 422 | + \_prime_term_caches( $indexables_to_create ); | |
| 423 | + } | |
| 424 | + } | |
| 425 | + | |
| 401 | 426 | foreach ( $indexables_to_create as $indexable_to_create ) { |
| 402 | 427 | $indexables[] = $this->builder->build_for_id_and_type( $indexable_to_create, $object_type ); |
| 403 | 428 | } |
| 404 | 429 | } |
| @@ -408,9 +433,9 @@ | ||
| 408 | 433 | |
| 409 | 434 | /** |
| 410 | 435 | * Finds the indexables by id's. |
| 411 | 436 | * |
| 412 | - * @param array $indexable_ids The indexable id's. | |
| 437 | + * @param int[] $indexable_ids The indexable id's. | |
| 413 | 438 | * |
| 414 | 439 | * @return Indexable[] The found indexables. |
| 415 | 440 | */ |
| 416 | 441 | public function find_by_ids( array $indexable_ids ) { |
| @@ -465,9 +490,9 @@ | ||
| 465 | 490 | /** |
| 466 | 491 | * Returns all subpages with a given post_parent. |
| 467 | 492 | * |
| 468 | 493 | * @param int $post_parent The post parent. |
| 469 | - * @param array $exclude_ids The id's to exclude. | |
| 494 | + * @param int[] $exclude_ids The id's to exclude. | |
| 470 | 495 | * |
| 471 | 496 | * @return Indexable[] array of indexables. |
| 472 | 497 | */ |
| 473 | 498 | public function get_subpages_by_post_parent( $post_parent, $exclude_ids = [] ) { |
| @@ -511,8 +536,68 @@ | ||
| 511 | 536 | $query->order_by_desc( 'object_last_modified' ) |
| 512 | 537 | ->limit( $limit ); |
| 513 | 538 | |
| 514 | 539 | return $query->find_many(); |
| 540 | + } | |
| 541 | + | |
| 542 | + /** | |
| 543 | + * Finds posts whose breadcrumb title contains any of the given comma-separated phrases. | |
| 544 | + * | |
| 545 | + * The search string is a comma-separated list. Each value is matched as a whole | |
| 546 | + * contiguous substring of the breadcrumb title, and a post is returned when it | |
| 547 | + * contains any one of the values (a logical OR between values). For example, | |
| 548 | + * "hiking boots, trail" returns posts whose title contains "hiking boots" or "trail". | |
| 549 | + * | |
| 550 | + * Results are paginated and ordered most recently modified first (with the indexable | |
| 551 | + * id as a stable tiebreaker), so requesting a later page returns older matches. | |
| 552 | + * | |
| 553 | + * At most self::MAX_TITLE_KEYWORD_PHRASES phrases are honoured; any beyond that are ignored. | |
| 554 | + * The page size is clamped to the range 1..self::MAX_TITLE_KEYWORD_PAGE_SIZE. | |
| 555 | + * | |
| 556 | + * @param string $keywords The comma-separated phrases to match against the breadcrumb title. | |
| 557 | + * @param int $page The page of results to return, 1-based. | |
| 558 | + * @param int $page_size The number of posts per page. | |
| 559 | + * @param string $post_type The post type to restrict the search to. | |
| 560 | + * | |
| 561 | + * @return Indexable[] The matching indexables for the requested page, ordered by most recently modified. | |
| 562 | + */ | |
| 563 | + public function find_posts_by_title_keywords( string $keywords, int $page = 1, int $page_size = 10, string $post_type = 'post' ) { | |
| 564 | + $phrases = \array_map( 'trim', \explode( ',', $keywords ) ); | |
| 565 | + $phrases = \array_filter( | |
| 566 | + $phrases, | |
| 567 | + static function ( $phrase ) { | |
| 568 | + return $phrase !== ''; | |
| 569 | + }, | |
| 570 | + ); | |
| 571 | + $phrases = \array_slice( $phrases, 0, self::MAX_TITLE_KEYWORD_PHRASES ); | |
| 572 | + | |
| 573 | + // An empty search must not degrade into matching every post. | |
| 574 | + if ( empty( $phrases ) ) { | |
| 575 | + return []; | |
| 576 | + } | |
| 577 | + | |
| 578 | + $likes = \array_fill( 0, \count( $phrases ), 'breadcrumb_title LIKE %s' ); | |
| 579 | + $params = \array_map( | |
| 580 | + function ( $phrase ) { | |
| 581 | + return '%' . $this->wpdb->esc_like( $phrase ) . '%'; | |
| 582 | + }, | |
| 583 | + $phrases, | |
| 584 | + ); | |
| 585 | + | |
| 586 | + $page_size = \min( \max( 1, $page_size ), self::MAX_TITLE_KEYWORD_PAGE_SIZE ); | |
| 587 | + $offset = ( ( \max( 1, $page ) - 1 ) * $page_size ); | |
| 588 | + | |
| 589 | + $indexables = $this->query() | |
| 590 | + ->where( 'object_type', 'post' ) | |
| 591 | + ->where( 'object_sub_type', $post_type ) | |
| 592 | + ->where_raw( '( ' . \implode( ' OR ', $likes ) . ' )', \array_values( $params ) ) | |
| 593 | + ->order_by_desc( 'object_last_modified' ) | |
| 594 | + ->order_by_desc( 'id' ) | |
| 595 | + ->limit( $page_size ) | |
| 596 | + ->offset( $offset ) | |
| 597 | + ->find_many(); | |
| 598 | + | |
| 599 | + return \array_map( [ $this, 'upgrade_indexable' ], $indexables ); | |
| 515 | 600 | } |
| 516 | 601 | |
| 517 | 602 | /** |
| 518 | 603 | * Returns the most recently modified cornerstone content of a post type. |