last_error; } /** * Creates the live table when it is missing. * * Uses the migration's own schema so there is one definition of this * table, not a second copy here that could drift from it. * * @since 1.16.6 * * @param string $live Fully prefixed live table name. * * @return bool True when the table exists afterwards. */ private function ensure_live_table( string $live ): bool { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching if ( $live === $this->wpdb->get_var( $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $live ) ) ) { return true; } if ( ! function_exists( 'dbDelta' ) ) { if ( ! defined( 'ABSPATH' ) ) { return $this->fail( 'creating the missing stem-frequency table' ); } require_once ABSPATH . 'wp-admin/includes/upgrade.php'; } $migration = new \Postlinkly\Bootstrap\Migrations\Migration_005_AddStemFrequencyTable(); \dbDelta( $migration->schema_sql( $this->wpdb->get_charset_collate() ) ); // dbDelta() reports nothing useful, so existence is the only signal. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching if ( $live === $this->wpdb->get_var( $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $live ) ) ) { return true; } return $this->fail( 'creating the missing stem-frequency table' ); } /** * Records a failure with whatever the database reported alongside it. * * @since 1.16.2 * @since 1.18.3 Reduces the database's message to plain text. * * @param string $step What was being attempted. * * @return false Always, so callers can `return $this->fail( ... )`. */ private function fail( string $step ): bool { $db = self::plain_error( (string) $this->wpdb->last_error ); $this->last_error = '' !== $db ? $step . ': ' . $db : $step; return false; } /** * Reduces a database error to one line of plain text. * * On SQLite-backed WordPress the error arrives wrapped in the HTML debug * block WordPress prints — the query, the queries before it, and a full * backtrace — with the actual message in its `error_message` element. The * reason is shown on the Tools page as text, so only the message is kept. * * @since 1.18.3 * * @param string $error Raw `wpdb::$last_error`. * * @return string */ private static function plain_error( string $error ): string { if ( preg_match( '#class="error_message"[^>]*>(.*?)#s', $error, $m ) ) { $error = $m[1]; } $error = trim( (string) preg_replace( '/\s+/u', ' ', wp_strip_all_tags( $error ) ) ); return mb_strlen( $error ) > 300 ? mb_substr( $error, 0, 300 ) . '…' : $error; } /** * Replaces every row of the live table inside one transaction. * * The fallback for databases that cannot rename tables. Readers see * either the old rows or the new ones: nothing is visible until COMMIT, * and a failure part-way through is rolled back, leaving the old map in * place. * * @since 1.18.3 * * @param string $live Live table name. * @param array $rows Complete stem -> document-count map. * * @return bool */ private function replace_rows_in_place( string $live, array $rows ): bool { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- Transaction control and a prepared full-table delete on this plugin's own table. if ( false === $this->wpdb->query( 'START TRANSACTION' ) ) { return $this->fail( 'starting the transaction to replace the stem rows' ); } $ok = false !== $this->wpdb->query( $this->wpdb->prepare( 'DELETE FROM %i', $live ) ); if ( $ok ) { foreach ( array_chunk( $rows, self::INSERT_CHUNK_SIZE, true ) as $chunk ) { if ( ! $this->insert_chunk( $live, $chunk ) ) { $ok = false; break; } } } if ( ! $ok ) { // Captured before ROLLBACK, which resets wpdb::last_error. $this->fail( 'replacing the stem rows' ); $this->wpdb->query( 'ROLLBACK' ); return false; } if ( false === $this->wpdb->query( 'COMMIT' ) ) { return $this->fail( 'committing the replaced stem rows' ); } // phpcs:enable return true; } /** * @since 1.10.0 * * @param \wpdb|null $wpdb Optional database instance. Defaults to the * global `$wpdb`. */ public function __construct( ?\wpdb $wpdb = null ) { if ( null === $wpdb ) { global $wpdb; } $this->wpdb = $wpdb; } /** * Returns document-frequency counts for the given stems. * * A single batched `IN (...)` lookup — never one query per stem. Stems * with no row (never indexed, or every containing post since removed) * are simply absent from the result rather than mapped to zero; callers * already treat a missing key as "document frequency zero" (see * {@see \Postlinkly\Suggestion\Scoring\TitleMatchGate::compute_idf()}). * * @since 1.10.0 * * @param array $stems Stems to look up. * * @return array Stem -> document-count map. Only stems * that have a row are present. */ public function get_many( array $stems ): array { $stems = array_values( array_unique( array_filter( $stems, static fn( $stem ): bool => is_string( $stem ) && '' !== $stem ) ) ); if ( array() === $stems ) { return array(); } $placeholders = implode( ',', array_fill( 0, count( $stems ), '%s' ) ); $args = array_merge( array( $this->table() ), $stems ); // 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 %s only. $rows = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT stem, doc_count FROM %i WHERE stem IN ({$placeholders})", $args ) ); // phpcs:enable if ( ! is_array( $rows ) ) { return array(); } $out = array(); foreach ( $rows as $row ) { // Normalise to an array regardless of whether $wpdb returned // stdClass rows (the OBJECT default) or associative arrays // (ARRAY_A, what this class's queries request) -- a stdClass // casts to an array of its public properties, so one shape // handles both without a branch phpstan's wpdb stubs would // otherwise consider partly unreachable. $row = is_object( $row ) ? (array) $row : $row; $stem = is_array( $row ) ? ( $row['stem'] ?? null ) : null; $count = is_array( $row ) ? ( $row['doc_count'] ?? null ) : null; if ( ! is_string( $stem ) || '' === $stem || null === $count ) { continue; } $out[ $stem ] = max( 0, (int) $count ); } return $out; } /** * Returns the corpus document count. * * @since 1.10.0 * * @return int Zero when the map has not been built yet (fresh install, * rebuild never run) -- callers treat that as "fall back to * per-request derivation", not "corpus is genuinely empty". */ public function get_document_count(): int { return max( 0, (int) get_option( self::DOC_COUNT_OPTION, 0 ) ); } /** * Applies incremental +1/-1 adjustments for one indexed post's stems. * * Increments use a single batched `INSERT ... ON DUPLICATE KEY UPDATE * doc_count = doc_count + VALUES(doc_count)` so concurrent post saves * cannot lose updates to a read-modify-write race -- the same class of * race plan 011 fixed in the embedding queue. Decrements cannot use the * same statement shape: an `INSERT` whose row does not yet exist would * have to insert a negative value into the unsigned `doc_count` column. * They instead run as a single batched `UPDATE ... CASE` restricted to * the affected stems, which only ever touches existing rows and clamps * at zero. Rows a decrement brings to zero are deleted immediately after * so an untouched stem is absent from the table rather than lingering as * a zero-count row. * * @since 1.10.0 * * @param array $stem_deltas Stem -> net delta map (positive * to add, negative to remove; * zero entries are ignored). * @param int $doc_delta Corpus document-count delta * (+1, -1, or 0). * * @return bool False when a write failed. */ public function apply_delta( array $stem_deltas, int $doc_delta ): bool { $increments = array(); $decrements = array(); foreach ( $stem_deltas as $stem => $delta ) { if ( ! is_string( $stem ) || '' === $stem ) { continue; } $delta = (int) $delta; if ( $delta > 0 ) { $increments[ $stem ] = $delta; } elseif ( $delta < 0 ) { $decrements[ $stem ] = -$delta; } } $increments_ok = array() === $increments || $this->apply_increments( $increments ); $decrements_ok = array() === $decrements || $this->apply_decrements( $decrements ); if ( 0 !== $doc_delta ) { $this->adjust_document_count( $doc_delta ); } return $increments_ok && $decrements_ok; } /** * Replaces the entire stored frequency map (full rebuild). * * Builds the replacement data into a throwaway staging table, then * swaps it into the live table's place with a single `RENAME TABLE` * statement — MySQL executes a multi-table rename as one atomic * operation, so a concurrent reader sees either the complete previous * table or the complete new one, never a truncated or partially * repopulated live table. This also means a failure while building the * staging data (a failed insert chunk, or the staging table itself * failing to create) leaves the existing live table completely * untouched, unlike the previous truncate-then-repopulate approach, * where a failure partway through left the live table permanently * half-populated with no automatic recovery. * * @since 1.10.0 * @since 1.11.0 Builds into a staging table and swaps it in via * RENAME TABLE instead of truncating the live table directly, * closing the window where a concurrent reader could see a * stale document count paired against an empty/partial table. * @since 1.18.3 Works where tables cannot be renamed or copied with * CREATE TABLE ... LIKE — SQLite-backed WordPress (Studio, * Playground, the WordPress.org live preview) and hosts that * withhold RENAME: the staging table is created from the schema * definition, and a refused rename falls back to replacing the * live rows inside one transaction. * * @param array $stem_counts Complete stem -> document-count map. * @param int $doc_count Complete corpus document count. * * @return bool False when a write failed. The live table is left * unmodified on any failure path. */ public function replace_all( array $stem_counts, int $doc_count ): bool { $this->last_error = ''; $live = $this->table(); $staging = $this->staging_table(); // A missing live table would fail every rebuild forever: the // migration that creates it records its own version on success and // is never retried, so a table dropped afterwards -- a partial // restore, a host cleanup -- leaves the schema version claiming a // table that is not there. The plugin owns this table and knows its // shape, so it recreates it rather than failing until someone runs a // repair by hand. if ( ! $this->ensure_live_table( $live ) ) { return false; } // Drop first in case a previous interrupted rebuild left a stale // staging table behind -- CREATE TABLE would otherwise fail against // an existing name. $this->drop_table_if_exists( $staging ); // Created from the migration's own definition rather than with // CREATE TABLE ... LIKE, which SQLite-backed WordPress rejects. One // definition still describes both tables. $create_sql = ( new \Postlinkly\Bootstrap\Migrations\Migration_005_AddStemFrequencyTable() ) ->schema_sql( $this->wpdb->get_charset_collate(), $staging ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.NotPrepared -- Scratch table for this rebuild only; the name comes from TableNames and the definition is a fixed string. if ( false === $this->wpdb->query( $create_sql ) ) { return $this->fail( 'creating the staging table' ); } $rows = array(); foreach ( $stem_counts as $stem => $count ) { if ( ! is_string( $stem ) || '' === $stem ) { continue; } $count = (int) $count; if ( $count <= 0 ) { continue; } $rows[ $stem ] = $count; } $ok = true; $chunk_no = 0; foreach ( array_chunk( $rows, self::INSERT_CHUNK_SIZE, true ) as $chunk ) { ++$chunk_no; if ( ! $this->insert_chunk( $staging, $chunk ) ) { // Stop at the first failure: the remaining chunks would // overwrite last_error with whatever they hit next, and the // first failure is the one that explains the run. $ok = false; $this->fail( sprintf( 'writing stem rows (chunk %d of %d)', $chunk_no, (int) ceil( count( $rows ) / self::INSERT_CHUNK_SIZE ) ) ); break; } } if ( ! $ok ) { // Leave the live table untouched; drop the incomplete staging // attempt rather than leaving it for the next rebuild to trip // over. Dropping resets wpdb::last_error, so the reason is // already captured above. $this->drop_table_if_exists( $staging ); return false; } $previous = $live . '_previous'; $rename_sql = $this->wpdb->prepare( 'RENAME TABLE %i TO %i, %i TO %i', $live, $previous, $staging, $live ); // A refused rename is expected on SQLite and handled below, so it is // kept out of the error output and debug log that wpdb would // otherwise write on every rebuild. $suppressed = method_exists( $this->wpdb, 'suppress_errors' ) ? $this->wpdb->suppress_errors( true ) : null; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.NotPrepared -- Atomic two-pair swap; $rename_sql is prepared immediately above. $renamed = false !== $this->wpdb->query( $rename_sql ); if ( null !== $suppressed ) { $this->wpdb->suppress_errors( $suppressed ); } if ( ! $renamed ) { // SQLite-backed WordPress cannot rename tables at all, and // managed hosts commonly withhold the privilege. Replace the live // rows instead; the staging table has done its job of proving // every row could be written. $replaced = $this->replace_rows_in_place( $live, $rows ); $this->drop_table_if_exists( $staging ); if ( ! $replaced ) { return false; } } else { // The old live table is now sitting under a throwaway name -- // drop it. The swap itself already succeeded and is what matters // for correctness; a failure here just leaves inert clutter, not // a consistency problem, so its result isn't checked. $this->drop_table_if_exists( $previous ); } update_option( self::DOC_COUNT_OPTION, max( 0, $doc_count ), false ); return true; } /** * Inserts one chunk of stem -> count rows as a single multi-row INSERT. * * @since 1.10.0 * @since 1.11.0 Takes the target table explicitly instead of always * writing to the live table, so replace_all() can populate a * staging table before swapping it in. * * @param string $table Fully-prefixed target table name. * @param array $chunk Stem -> count rows for this chunk. * * @return bool */ private function insert_chunk( string $table, array $chunk ): bool { if ( array() === $chunk ) { return true; } $value_sql = implode( ',', array_fill( 0, count( $chunk ), '(%s,%d)' ) ); $args = array( $table ); foreach ( $chunk as $stem => $count ) { $args[] = $stem; $args[] = $count; } // `stem` is the primary key, and the column's collation decides what // counts as a duplicate. Under the shipped `utf8mb4_unicode_520_ci` // that includes case and accent variants -- `café` and `cafe`, `A` // and `a`, `3-5` and `3–5` -- so two stems that are distinct PHP // array keys can be one row to MySQL. A plain INSERT of both is a // duplicate-key error that aborts the whole rebuild, which is what // silently broke it on corpora containing any such pair. // // Merging rather than distinguishing is the right resolution because // reads use the same collation: get_many() looking up `cafe` already // matches a stored `café`, so keeping them apart was never // observable. MySQL performs the merge, since only MySQL knows its // own collation rules. A document containing both variants is counted // once per variant, marginally lowering that stem's IDF weight -- // accepted as far cheaper than the rebuild failing outright. // // This mirrors apply_increments() below, which has always used the // same clause. // Scoped as a block rather than a single-line ignore: the statement // spans several lines and the interpolation is flagged on the line // holding the string, which a one-line ignore above does not reach. // $value_sql is a fixed "(%s,%d)" shape repeated once per row and // holds no caller data; every value travels in $args through prepare(). // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $sql = $this->wpdb->prepare( "INSERT INTO %i (stem, doc_count) VALUES {$value_sql} ON DUPLICATE KEY UPDATE doc_count = doc_count + VALUES(doc_count)", $args ); // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is prepared immediately above. return false !== $this->wpdb->query( $sql ); } /** * Applies a batch of positive per-stem deltas via one INSERT. * * @since 1.10.0 * * @param array $increments Stem -> positive delta map. * * @return bool */ private function apply_increments( array $increments ): bool { $value_sql = implode( ',', array_fill( 0, count( $increments ), '(%s,%d)' ) ); $args = array( $this->table() ); foreach ( $increments as $stem => $delta ) { $args[] = $stem; $args[] = $delta; } // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- $value_sql is a fixed (%s,%d) shape repeated per row; all values flow through prepare(). $sql = $this->wpdb->prepare( "INSERT INTO %i (stem, doc_count) VALUES {$value_sql} ON DUPLICATE KEY UPDATE doc_count = doc_count + VALUES(doc_count)", $args ); return false !== $this->wpdb->query( $sql ); // phpcs:enable } /** * Applies a batch of negative per-stem deltas via one UPDATE, then * deletes any row the decrement brought to zero. * * @since 1.10.0 * * @param array $decrements Stem -> positive magnitude to * subtract (already sign-flipped * by the caller). * * @return bool */ private function apply_decrements( array $decrements ): bool { $table = $this->table(); $stems = array_keys( $decrements ); $case_sql = implode( ' ', array_fill( 0, count( $decrements ), 'WHEN %s THEN %d' ) ); $in_sql = implode( ',', array_fill( 0, count( $stems ), '%s' ) ); $args = array( $table ); foreach ( $decrements as $stem => $amount ) { $args[] = $stem; $args[] = $amount; } $args = array_merge( $args, $stems ); // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- $case_sql/$in_sql are fixed shapes repeated per row; all values flow through prepare(). $sql = $this->wpdb->prepare( "UPDATE %i SET doc_count = GREATEST(0, doc_count - CASE stem {$case_sql} ELSE 0 END) WHERE stem IN ({$in_sql})", $args ); $updated = false !== $this->wpdb->query( $sql ); // Rows the decrement brought to zero are dropped rather than kept as // dead zero-count rows -- a missing row and a zero-count row mean the // same thing to every reader, so leaving them around would only be // waste. $delete_args = array_merge( array( $table ), $stems ); $delete_sql = $this->wpdb->prepare( "DELETE FROM %i WHERE doc_count <= 0 AND stem IN ({$in_sql})", $delete_args ); $this->wpdb->query( $delete_sql ); // phpcs:enable return $updated; } /** * Adjusts the stored document count by a signed delta. * * WordPress options have no native compare-and-swap, so this narrows * the "lost update" race window with an optimistic retry: re-read * immediately before writing and, if another process's write landed in * between, retry against that fresh value instead of silently * overwriting it. * * @since 1.10.0 * * @param int $delta Signed adjustment. * * @return void */ private function adjust_document_count( int $delta ): void { for ( $attempt = 0; $attempt < 3; $attempt++ ) { $before = $this->get_document_count(); $after = max( 0, $before + $delta ); if ( $after === $before ) { return; } $current = $this->get_document_count(); if ( $current !== $before ) { // Another process wrote in between; retry against its value. continue; } update_option( self::DOC_COUNT_OPTION, $after, false ); return; } // Exhausted retries under heavy contention: apply once more against // whatever is current. A rare lost update here is an accepted // trade-off against blocking indefinitely. update_option( self::DOC_COUNT_OPTION, max( 0, $this->get_document_count() + $delta ), false ); } /** * Returns the fully-prefixed stem-frequency table name. * * Built from the repository's own `$wpdb` instance rather than calling * {@see TableNames::stem_frequency()} (which reads the global `$wpdb`) * so the repository's behaviour depends only on what was passed to its * constructor, not on global state a test may or may not have set up. * `TableNames::STEM_FREQUENCY` -- the bare, unprefixed name -- is still * the single source of truth for the table's identity. * * @since 1.10.0 */ private function table(): string { return $this->wpdb->prefix . TableNames::STEM_FREQUENCY; } /** * Returns the fully-prefixed staging table name used during a rebuild. * * Created and dropped entirely within {@see replace_all()} -- never a * permanent table, so it has no TableNames constant and is not part of * the migrated schema. See replace_all()'s docblock for why. * * @since 1.11.0 */ private function staging_table(): string { return $this->table() . '_staging'; } /** * Drops a table if it exists, ignoring failure. * * Used only for the throwaway staging/previous tables {@see * replace_all()} creates during a rebuild -- cleanup failing there * leaves inert clutter, not a correctness problem, so the result isn't * checked (mirrors the same judgement call made inline where this is * called). * * @since 1.11.0 * * @param string $table Fully-prefixed table name to drop. * * @return void */ private function drop_table_if_exists( string $table ): void { $sql = $this->wpdb->prepare( 'DROP TABLE IF EXISTS %i', $table ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.NotPrepared -- Throwaway staging/previous table cleanup; $sql is prepared immediately above. $this->wpdb->query( $sql ); } }