*/ private const DEFAULT_POST_TYPES = array( 'post' ); /** * Default post statuses this provider considers as candidates. * * @var array */ private const DEFAULT_POST_STATUSES = array( 'publish' ); private WordTokenizer $tokenizer; /** * @var array */ private array $post_types; /** * Post IDs to exclude from the candidate corpus. * * @var array */ private array $excluded_post_ids; /** * Only include posts published within this many months. * * 0 means no age limit. * * @var int */ private int $max_post_age_months; /** * Post statuses this provider considers as candidates. * * @var array */ private array $post_statuses; /** * Category term IDs whose posts are excluded from the candidate corpus. * * @var array */ private array $excluded_category_ids; /** * @since 1.0.0 * @since 1.8.0 Added the $post_statuses and $excluded_category_ids * parameters. * @since 1.10.0 Removed the corpus-size cap constructor parameter. * Two-phase candidate loading means no method on this class * needs a hard limit to keep per-request cost bounded, so a * parameter whose only effect was capping * {@see get_candidates_for()} at a configured count was no * longer worth keeping. * * @param WordTokenizer $tokenizer Tokenizer used to build the * candidate's stemmed word map. * @param array $post_types Optional. Post types to consider. * Default `['post']`. * @param array $excluded_post_ids Optional. Post IDs to exclude. * Default []. * @param int $max_post_age_months Optional. Only include posts * published within this many months. * 0 = no limit. Default 0. * @param array $post_statuses Optional. Post statuses to consider. * Default `['publish']`. * @param array $excluded_category_ids Optional. Category term IDs whose * posts are excluded. Default []. */ public function __construct( WordTokenizer $tokenizer, array $post_types = self::DEFAULT_POST_TYPES, array $excluded_post_ids = array(), int $max_post_age_months = 0, array $post_statuses = self::DEFAULT_POST_STATUSES, array $excluded_category_ids = array() ) { $this->tokenizer = $tokenizer; $this->post_types = array() === $post_types ? self::DEFAULT_POST_TYPES : array_values( $post_types ); $this->excluded_post_ids = array_values( array_filter( $excluded_post_ids, static fn( $id ) => $id > 0 ) ); $this->max_post_age_months = max( 0, $max_post_age_months ); $this->post_statuses = array() === $post_statuses ? self::DEFAULT_POST_STATUSES : array_values( $post_statuses ); $this->excluded_category_ids = array_values( array_filter( $excluded_category_ids, static fn( $id ) => $id > 0 ) ); } /** * Returns the candidate corpus for a source post. * * @since 1.0.0 * * @param PostModel $source The post being analysed. * * @return array */ public function get_candidates_for( PostModel $source ): array { // Terms are not yet a candidate type in v1.0 — taxonomy term // content lives in `term_description` and a separate provider // will be added when term support ships. if ( $source->is_term() ) { return array(); } $post_ids = $this->fetch_candidate_ids( $source->id ); if ( array() === $post_ids ) { return array(); } $keyword_map = $this->fetch_target_keywords( $post_ids ); $candidates = array(); foreach ( $post_ids as $post_id ) { $post = function_exists( 'get_post' ) ? get_post( $post_id ) : null; if ( null === $post || ! is_object( $post ) ) { continue; } // Defensive property access. In production WP_Post fills these // with strings, but unit tests pass stdClass fixtures, so we // fetch via property_exists and fall back to empty strings. // @phpstan-ignore-next-line nullCoalesce.property $status = (string) ( $post->post_status ?? '' ); // The query already requests only $this->post_statuses, but a // caller-provided get_posts()/WP_Query implementation is not // guaranteed to honor an array 'post_status' the same way core // does, so this membership check is the authoritative gate. if ( ! in_array( $status, $this->post_statuses, true ) ) { continue; } // @phpstan-ignore-next-line nullCoalesce.property $title = (string) ( $post->post_title ?? '' ); // @phpstan-ignore-next-line nullCoalesce.property $content = (string) ( $post->post_content ?? '' ); $words = CandidateWordMapBuilder::build( $title, $content, $this->tokenizer ); if ( array() === $words ) { continue; } $candidates[] = new Candidate( new PostModel( (int) $post->ID, PostModel::TYPE_POST, $title, $status, // @phpstan-ignore-next-line nullCoalesce.property (string) ( $post->post_name ?? '' ) ), $words, (int) array_sum( $words ), $keyword_map[ (int) $post->ID ] ?? array() ); } return $candidates; } /** * Returns every candidate's identity and title, without content. * * Queries `wp_posts` directly with an explicit column list (id, title, * slug, status) rather than `get_posts()`/`WP_Query`, and applies no * cap at all -- this method has no per-candidate content cost in the * first place, so there is nothing a cap would be protecting against. * * @since 1.10.0 * * @param PostModel $source The post being analysed. * * @return array */ public function get_light_candidates_for( PostModel $source ): array { if ( $source->is_term() ) { return array(); } $rows = $this->fetch_light_rows( $source->id ); if ( array() === $rows ) { return array(); } $post_ids = array(); foreach ( $rows as $row ) { if ( is_object( $row ) && isset( $row->ID ) ) { $post_ids[] = (int) $row->ID; } } $keyword_map = $this->fetch_target_keywords( $post_ids ); $light = array(); foreach ( $rows as $row ) { if ( ! is_object( $row ) || ! isset( $row->ID ) ) { continue; } $id = (int) $row->ID; $light[] = new LightCandidate( new PostModel( $id, PostModel::TYPE_POST, (string) ( $row->post_title ?? '' ), (string) ( $row->post_status ?? '' ), (string) ( $row->post_name ?? '' ) ), $keyword_map[ $id ] ?? array() ); } return $light; } /** * Returns fully-tokenised candidates for the given post ids. * * A single batched `wp_posts` query keyed on the requested id list -- * not `get_post()` in a loop -- reading `post_content` only for * exactly the ids the caller passed. Callers must have already * narrowed this list to gate-surviving candidates; this method does * not re-check any filter beyond the post-status membership re-check * {@see get_candidates_for()} already relies on for the same reason * (a caller-provided id list is not guaranteed to have applied every * configured filter). * * @since 1.10.0 * * @param array $post_ids Post ids to hydrate. * * @return array */ public function hydrate( array $post_ids ): array { global $wpdb; $post_ids = array_values( array_unique( array_filter( array_map( 'intval', $post_ids ), static fn( int $id ): bool => $id > 0 ) ) ); if ( array() === $post_ids || ! isset( $wpdb ) ) { return array(); } $posts_table = $wpdb->prefix . 'posts'; $placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ); // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Dynamic IN() placeholders are built from %d only; ids are the caller's own gate-surviving set, not user input. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_title, post_content, post_status, post_name FROM %i WHERE ID IN ({$placeholders})", array_merge( array( $posts_table ), $post_ids ) ) ); // phpcs:enable if ( ! is_array( $rows ) ) { return array(); } $ids_present = array(); foreach ( $rows as $row ) { if ( is_object( $row ) && isset( $row->ID ) ) { $ids_present[] = (int) $row->ID; } } $keyword_map = $this->fetch_target_keywords( $ids_present ); $candidates = array(); foreach ( $rows as $row ) { if ( ! is_object( $row ) || ! isset( $row->ID ) ) { continue; } $status = (string) ( $row->post_status ?? '' ); // Same re-check rationale as get_candidates_for(): the id list // came from a light query built with these same statuses, but // nothing enforces that a caller passed ids from this // provider's own get_light_candidates_for(). if ( ! in_array( $status, $this->post_statuses, true ) ) { continue; } $id = (int) $row->ID; $title = (string) ( $row->post_title ?? '' ); $content = (string) ( $row->post_content ?? '' ); $words = CandidateWordMapBuilder::build( $title, $content, $this->tokenizer ); if ( array() === $words ) { continue; } $candidates[] = new Candidate( new PostModel( $id, PostModel::TYPE_POST, $title, $status, (string) ( $row->post_name ?? '' ) ), $words, (int) array_sum( $words ), $keyword_map[ $id ] ?? array() ); } return $candidates; } /** * Returns the published post IDs to consider as candidates. * * Excludes the source id and any explicitly excluded post IDs. * Applies a date gate when `$max_post_age_months` is set and a * category exclusion when `$excluded_category_ids` is set. No longer * capped: `posts_per_page => -1` fetches every qualifying post. * * @since 1.0.0 * @since 1.8.0 Honors configured post statuses and excluded categories * instead of hardcoding 'publish' with no category filter. * @since 1.10.0 Removed the corpus-size cap. * * @param int $source_id Post ID to exclude. * * @return array */ private function fetch_candidate_ids( int $source_id ): array { if ( ! function_exists( 'get_posts' ) ) { return array(); } // Build the exclusion list: source post + any user-configured exclusions. $exclude = array_unique( array_merge( array( $source_id ), $this->excluded_post_ids ) ); $query_args = array( 'post_type' => $this->post_types, 'post_status' => $this->post_statuses, 'posts_per_page' => -1, 'orderby' => 'modified', 'order' => 'DESC', // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in -- Candidate search must exclude the edited source post and owner-configured exclusions. 'post__not_in' => $exclude, 'fields' => 'ids', 'no_found_rows' => true, ); // Only a single settings-sourced list, unlike post__not_in above // (which merges the source id with owner exclusions), so no // dedup/merge step is needed here. if ( array() !== $this->excluded_category_ids ) { $query_args['category__not_in'] = $this->excluded_category_ids; } // Apply age gate when configured. if ( $this->max_post_age_months > 0 ) { $query_args['date_query'] = array( array( 'after' => $this->max_post_age_months . ' months ago', 'inclusive' => true, ), ); } $ids = get_posts( $query_args ); if ( ! is_array( $ids ) ) { return array(); } $out = array(); foreach ( $ids as $id ) { $id = (int) $id; if ( $id > 0 && $id !== $source_id ) { $out[] = $id; } } return $out; } /** * Returns id/title/slug/status rows for every qualifying candidate, with * no upper bound and no `post_content` in the column list. * * A direct `wp_posts` query rather than `get_posts()`/`WP_Query` -- * unlike {@see fetch_candidate_ids()}, this never needs a per-row * `get_post()` follow-up call, because every column * {@see get_light_candidates_for()} needs is already in this result * set. Applies the same post type, status, exclusion, category, and * age filters as {@see fetch_candidate_ids()} so the two methods agree * on "what counts as a candidate" -- they differ only in which columns * they read and whether the result is capped. * * @since 1.10.0 * * @param int $source_id Post ID to exclude. * * @return array Rows with ID, post_title, post_name, and * post_status properties. */ private function fetch_light_rows( int $source_id ): array { global $wpdb; if ( ! isset( $wpdb ) ) { return array(); } $exclude = array_unique( array_merge( array( $source_id ), $this->excluded_post_ids ) ); $posts_table = $wpdb->prefix . 'posts'; $type_placeholders = implode( ',', array_fill( 0, count( $this->post_types ), '%s' ) ); $status_placeholders = implode( ',', array_fill( 0, count( $this->post_statuses ), '%s' ) ); $exclude_placeholders = implode( ',', array_fill( 0, count( $exclude ), '%d' ) ); $sql = 'SELECT ID, post_title, post_name, post_status FROM %i' . " WHERE post_type IN ({$type_placeholders})" . " AND post_status IN ({$status_placeholders})" . " AND ID NOT IN ({$exclude_placeholders})"; $args = array_merge( array( $posts_table ), $this->post_types, $this->post_statuses, $exclude ); // Apply age gate when configured, matching fetch_candidate_ids()'s // 'N months ago' cutoff. if ( $this->max_post_age_months > 0 ) { $sql .= ' AND post_date >= %s'; $args[] = gmdate( 'Y-m-d H:i:s', strtotime( '-' . $this->max_post_age_months . ' months' ) ); } // Category exclusion via a NOT IN subquery against the standard // term-relationship tables, matching fetch_candidate_ids()'s // 'category__not_in' WP_Query arg. if ( array() !== $this->excluded_category_ids ) { $term_relationships = $wpdb->prefix . 'term_relationships'; $term_taxonomy = $wpdb->prefix . 'term_taxonomy'; $category_placeholders = implode( ',', array_fill( 0, count( $this->excluded_category_ids ), '%d' ) ); $sql .= ' AND ID NOT IN (' . 'SELECT tr.object_id FROM %i AS tr' . ' INNER JOIN %i AS tt ON tr.term_taxonomy_id = tt.term_taxonomy_id' . " WHERE tt.taxonomy = 'category' AND tt.term_id IN ({$category_placeholders})" . ')'; $args = array_merge( $args, array( $term_relationships, $term_taxonomy ), $this->excluded_category_ids ); } $sql .= ' ORDER BY ID ASC'; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Dynamic IN() placeholders are built from %s/%d only; table names go through %i, not string interpolation. $rows = $wpdb->get_results( $wpdb->prepare( $sql, $args ) ); return is_array( $rows ) ? $rows : array(); } /** * Returns a map of post id → array of pre-stemmed target keywords. * * One query, indexed by post id. Posts without a row in the table * map to an empty array. * * @since 1.0.0 * * @param array $post_ids Post IDs to look up. * * @return array> */ private function fetch_target_keywords( array $post_ids ): array { global $wpdb; if ( ! isset( $wpdb ) || array() === $post_ids ) { return array(); } $placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ); $args = array_merge( array( TableNames::target_keywords(), PostModel::TYPE_POST ), $post_ids ); // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Dynamic IN() placeholders are built from %d only; cache lives at the engine level. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, stemmed FROM %i WHERE post_type = %s AND post_id IN ({$placeholders})", $args ) ); // phpcs:enable if ( ! is_array( $rows ) ) { return array(); } $out = array(); foreach ( $rows as $row ) { if ( ! is_object( $row ) || ! isset( $row->post_id, $row->stemmed ) ) { continue; } $pid = (int) $row->post_id; $kw = trim( (string) $row->stemmed ); if ( '' === $kw ) { continue; } if ( ! isset( $out[ $pid ] ) ) { $out[ $pid ] = array(); } $out[ $pid ][] = $kw; } return $out; } }