*/ private array $post_types; /** * @var array */ private array $post_statuses; /** * @since 1.14.0 * * @param WordTokenizer $tokenizer Word tokenizer and stemmer. * @param StemFrequencyRepositoryInterface $repository Frequency map storage. * @param array $post_types Post types to index. * @param array $post_statuses Post statuses to index. */ public function __construct( WordTokenizer $tokenizer, StemFrequencyRepositoryInterface $repository, array $post_types, array $post_statuses ) { $this->tokenizer = $tokenizer; $this->repository = $repository; $this->post_types = array() !== $post_types ? $post_types : array( 'post' ); $this->post_statuses = array() !== $post_statuses ? $post_statuses : array( 'publish' ); } /** * @since 1.14.0 */ public function id(): string { return 'stem-frequency'; } /** * Suggestions cannot work without this map, so nothing gates it. * * @since 1.14.0 */ public function should_enrol(): bool { return true; } /** * @since 1.14.0 */ public function total(): int { global $wpdb; if ( ! isset( $wpdb ) ) { return 0; } $types = $this->placeholders( $this->post_types ); $statuses = $this->placeholders( $this->post_statuses ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Placeholder lists are built from %s only; this counts live corpus state that changes as the job runs. $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type IN ({$types}) AND post_status IN ({$statuses})", array_merge( $this->post_types, $this->post_statuses ) ) ); // phpcs:enable return (int) $count; } /** * Counts posts after $cursor until the deadline or the corpus ends. * * @since 1.14.0 * * @param Deadline $deadline Time budget for this batch. * @param int $cursor Highest post id already counted. */ public function process( Deadline $deadline, int $cursor ): StageResult { global $wpdb; if ( ! isset( $wpdb ) ) { return StageResult::failed( 'No database connection available.' ); } // A cursor of zero means a fresh pass. Any counts left by an abandoned // earlier run must be discarded, or its documents would be counted a // second time. $accumulator = 0 === $cursor ? array( 'stems' => array(), 'doc_count' => 0, ) : $this->load_accumulator(); $stems = $accumulator['stems']; $doc_count = $accumulator['doc_count']; $processed = 0; $last_id = $cursor; while ( ! $deadline->expired() ) { $rows = $this->next_chunk( $last_id ); if ( array() === $rows ) { return $this->finish( $stems, $doc_count, $processed ); } foreach ( $rows as $row ) { $words = CandidateWordMapBuilder::build( (string) ( $row->post_title ?? '' ), (string) ( $row->post_content ?? '' ), $this->tokenizer ); // A post with no meaningful words is not a candidate at all, // mirroring WpPostCandidateProvider's own rule. Counting it // would inflate the divisor every IDF value depends on. if ( array() !== $words ) { ++$doc_count; foreach ( array_keys( $words ) as $stem ) { $stems[ $stem ] = ( $stems[ $stem ] ?? 0 ) + 1; } } $last_id = max( $last_id, (int) $row->ID ); ++$processed; } if ( count( $rows ) < self::CHUNK_SIZE ) { return $this->finish( $stems, $doc_count, $processed ); } } $this->save_accumulator( $stems, $doc_count ); return StageResult::progress( $last_id, $processed ); } /** * Swaps the completed map into the live repository. * * The single write is the point: until it happens the engine keeps using * whatever map it already had, so a pass that never finishes degrades to * "no change" rather than to a corrupt corpus. * * @since 1.14.0 * * @param array $stems Accumulated stem counts. * @param int $doc_count Accumulated document count. * @param int $processed Posts handled in this batch. */ private function finish( array $stems, int $doc_count, int $processed ): StageResult { if ( ! $this->repository->replace_all( $stems, $doc_count ) ) { // The swap can fail for reasons this stage cannot fix — two stems // colliding as one primary key under the column's collation, for // instance. Reporting success would be the worst outcome: the job // would say "done" while the live map still holds whatever it held // before, and nothing anywhere would say the index is stale. // // The accumulator is deliberately NOT updated here. The runner // leaves a failed stage's cursor where it was, so writing // whole-corpus counts now would pair them with a mid-corpus // cursor and the retry would re-walk the tail, counting those // documents twice. Leaving both at their last committed point // keeps them consistent. // Say which step failed and what the database said. "Could not // write the map" alone gives the reader nothing to act on, and // the causes need opposite responses: a host that forbids // RENAME TABLE is a hosting problem, a missing table is a // migration that has not run. $reason = method_exists( $this->repository, 'last_error' ) ? (string) $this->repository->last_error() : ''; return StageResult::failed( '' !== $reason ? 'Could not write the stem-frequency map — failed while ' . $reason : 'Could not write the stem-frequency map.' ); } delete_option( self::ACCUMULATOR_OPTION ); return StageResult::finished( $processed ); } /** * Reads counts accumulated by earlier batches in this pass. * * @since 1.14.0 * * @return array{stems: array, doc_count: int} */ private function load_accumulator(): array { $stored = get_option( self::ACCUMULATOR_OPTION, array() ); return array( 'stems' => ( is_array( $stored ) && is_array( $stored['stems'] ?? null ) ) ? $stored['stems'] : array(), 'doc_count' => ( is_array( $stored ) && isset( $stored['doc_count'] ) ) ? (int) $stored['doc_count'] : 0, ); } /** * @since 1.14.0 * * @param array $stems Accumulated stem counts. * @param int $doc_count Accumulated document count. * * @return void */ private function save_accumulator( array $stems, int $doc_count ): void { update_option( self::ACCUMULATOR_OPTION, array( 'stems' => $stems, 'doc_count' => $doc_count, ), false ); } /** * Reads the next chunk of posts after $cursor. * * @since 1.14.0 * * @param int $cursor Highest post id already counted. * * @return array */ private function next_chunk( int $cursor ): array { global $wpdb; $types = $this->placeholders( $this->post_types ); $statuses = $this->placeholders( $this->post_statuses ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Placeholder lists are built from %s only; this reads live corpus state the job is walking. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_title, post_content FROM {$wpdb->posts} WHERE ID > %d AND post_type IN ({$types}) AND post_status IN ({$statuses}) ORDER BY ID ASC LIMIT %d", array_merge( array( $cursor ), $this->post_types, $this->post_statuses, array( self::CHUNK_SIZE ) ) ) ); // phpcs:enable return is_array( $rows ) ? $rows : array(); } /** * Builds a `%s` placeholder list of the given length. * * @since 1.14.0 * * @param array $values Values the placeholders stand for. */ private function placeholders( array $values ): string { return implode( ',', array_fill( 0, max( 1, count( $values ) ), '%s' ) ); } }