prefix` plus a * hard-coded literal, never from caller input; and a failed query returns null * rather than an empty result, so a caller can tell "nothing to score" from * "the query did not run". * * Eligibility (published, a currently configured and allowed post type) is * expressed once per query as a join against `$wpdb->posts`, so a row for a * post that is no longer published or whose type has left the configured list * is excluded from every summary, listing and bounded selection without ever * being deleted - preserving the historical row Section 11's AC2 requires. * * @since 1.5.0 */ final class AnswerDensityStore { /** * Returns the prefixed answer density table name. * * @since 1.5.0 * * @return string Table name. */ public function table() { return Database::table_name( 'answer_density' ); } /** * Returns the table name backtick-quoted for interpolation. * * @since 1.5.0 * * @return string Quoted table identifier. */ private function quoted_table() { return '`' . str_replace( '`', '', $this->table() ) . '`'; } /** * Returns one post's stored result. * * @since 1.5.0 * * @param int $post_id Post id. * @return array{post_id: int, post_type: string, total_headings: int, question_headings: int, direct_answers: int, buried_answers: int, derivative_answers: int, score: int, state: string, algorithm_version: int, content_hash: string, diagnostics: array, questions: array, score_breakdown: array, scanned_at: string}|null Decoded row, or null when none exists or the query failed. */ public function get( $post_id ) { global $wpdb; $sql = $wpdb->prepare( 'SELECT * FROM ' . $this->quoted_table() . ' WHERE post_id = %d', (int) $post_id ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only the table identifier is interpolated; see quoted_table(). The id is bound. return $this->run( static function () use ( $wpdb, $sql ) { return $wpdb->get_row( $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Statement prepared by the caller; reads the plugin's own table. }, static function ( $row ) { return is_array( $row ) ? self::decode_row( $row ) : null; } ); } /** * Writes a successful scan result. * * Atomic by the table's unique `post_id` key: an existing row is updated in * place, a missing one is inserted, and nothing in between is observable. * * @since 1.5.0 * * @param int $post_id Post id. * @param string $post_type Post type at scan time. * @param array $summary Summary from AnswerDensityScorer::summarize(). * @param array $details JSON-ready record from AnswerDensityScorer::details_payload(). * @param string $scanned_at_utc UTC `Y-m-d H:i:s` instant. * @return bool True when the row was written. */ public function save_scanned( $post_id, $post_type, array $summary, array $details, $scanned_at_utc ) { return $this->upsert( $post_id, $post_type, array( 'total_headings' => (int) $summary['total_headings'], 'question_headings' => (int) $summary['question_count'], 'direct_answers' => (int) $summary['direct_answers'], 'buried_answers' => (int) $summary['buried_answers'], 'derivative_answers' => (int) $summary['unanswered'], 'score' => (int) $summary['score'], 'state' => AnswerDensityParser::STATE_SCANNED, ), $details, $scanned_at_utc ); } /** * Writes an over-budget error result: no score, nothing to classify. * * @since 1.5.0 * * @param int $post_id Post id. * @param string $post_type Post type at scan time. * @param array $parsed Error result from AnswerDensityParser::analyze(). * @param string $scanned_at_utc UTC `Y-m-d H:i:s` instant. * @return bool True when the row was written. */ public function save_error( $post_id, $post_type, array $parsed, $scanned_at_utc ) { return $this->upsert( $post_id, $post_type, array( 'total_headings' => 0, 'question_headings' => 0, 'direct_answers' => 0, 'buried_answers' => 0, 'derivative_answers' => 0, 'score' => 0, 'state' => AnswerDensityParser::STATE_ERROR, ), array( 'algorithm_version' => AnswerDensityParser::ALGORITHM_VERSION, 'content_hash' => $parsed['content_hash'], 'state' => $parsed['state'], 'diagnostics' => $parsed['diagnostics'], 'counts' => array( 'total_headings' => 0, 'question_count' => 0, 'direct_answers' => 0, 'buried_answers' => 0, 'unanswered' => 0, ), 'score_breakdown' => null, 'questions' => array(), ), $scanned_at_utc ); } /** * Performs the atomic upsert shared by a scanned and an error result. * * @since 1.5.0 * * @param int $post_id Post id. * @param string $post_type Post type at scan time. * @param array $columns Numeric column values, keyed by column name. * @param array $details JSON-ready record for the `details` column. * @param string $scanned_at_utc UTC `Y-m-d H:i:s` instant. * @return bool True when the row was written. */ private function upsert( $post_id, $post_type, array $columns, array $details, $scanned_at_utc ) { global $wpdb; $encoded = wp_json_encode( $details ); if ( ! is_string( $encoded ) ) { return false; } $sql = $wpdb->prepare( 'INSERT INTO ' . $this->quoted_table() . ' ' // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only the table identifier is interpolated; see quoted_table(). Every value is bound below. . '(post_id, post_type, total_headings, question_headings, direct_answers, buried_answers, derivative_answers, score, state, algorithm_version, details, scanned_at) ' . 'VALUES (%d, %s, %d, %d, %d, %d, %d, %d, %s, %d, %s, %s) ' . 'ON DUPLICATE KEY UPDATE post_type = VALUES(post_type), total_headings = VALUES(total_headings), ' . 'question_headings = VALUES(question_headings), direct_answers = VALUES(direct_answers), ' . 'buried_answers = VALUES(buried_answers), derivative_answers = VALUES(derivative_answers), ' . 'score = VALUES(score), state = VALUES(state), algorithm_version = VALUES(algorithm_version), ' . 'details = VALUES(details), scanned_at = VALUES(scanned_at)', (int) $post_id, (string) $post_type, (int) $columns['total_headings'], (int) $columns['question_headings'], (int) $columns['direct_answers'], (int) $columns['buried_answers'], (int) $columns['derivative_answers'], (int) $columns['score'], (string) $columns['state'], AnswerDensityParser::ALGORITHM_VERSION, $encoded, (string) $scanned_at_utc ); return $this->run( static function () use ( $wpdb, $sql ) { return $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Statement prepared by the caller; atomic upsert on the plugin's own table. }, static function ( $result ) { return false !== $result; } ) ?? false; } /** * Deletes one post's stored result. * * Called when a post leaves `publish` or is deleted outright. Deleting * nothing - the row never existed, or was already removed - is success, * not a failure to report. * * @since 1.5.0 * * @param int $post_id Post id. * @return bool True when the statement ran without error. */ public function delete( $post_id ) { global $wpdb; $sql = $wpdb->prepare( 'DELETE FROM ' . $this->quoted_table() . ' WHERE post_id = %d', (int) $post_id ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Only the table identifier is interpolated; see quoted_table(). The id is bound. return null !== $this->run( static function () use ( $wpdb, $sql ) { return $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Statement prepared by the caller; deletes from the plugin's own table. }, static function ( $result ) { return (int) $result; } ); } /** * Returns up to `$limit` eligible post ids that are stale or never scanned. * * "Stale or never scanned" is a never-scored post, a previously errored * one, one scored under an older algorithm version, or one whose content * has changed since its last scan - read from `post_modified_gmt` against * the stored `scanned_at`, which needs no stored content hash to answer. * * Ordered oldest-successful-scan first with never-scanned ranked ahead of * every scanned row, then by post id. No separate cursor is kept: every * attempt this method's caller makes - success or failure - writes a fresh * `scanned_at`, which is what moves an attempted row to the back of this * same ordering for the next run. Repeated runs therefore sweep the whole * eligible set in turn without a persisted position, which is the * "equivalent deterministic continuation" the contract allows as an * alternative to an explicit cursor. * * @since 1.5.0 * * @param array $post_types Currently configured and allowed post types. * @param int $limit Maximum rows to return. * @return array|null Post ids, or null when the query failed. */ public function stale_batch( array $post_types, $limit ) { global $wpdb; if ( array() === $post_types ) { return array(); } $placeholders = implode( ',', array_fill( 0, count( $post_types ), '%s' ) ); $query = 'SELECT p.ID FROM ' . $wpdb->posts . ' p LEFT JOIN ' . $this->quoted_table() . ' d ON d.post_id = p.ID ' . "WHERE p.post_status = 'publish' AND p.post_type IN ($placeholders) AND (d.post_id IS NULL OR d.state = 'error' OR d.algorithm_version <> %d OR p.post_modified_gmt > d.scanned_at) ORDER BY (d.post_id IS NULL) DESC, d.scanned_at ASC, p.ID ASC LIMIT %d"; $sql = $wpdb->prepare( $query, array_merge( $post_types, array( AnswerDensityParser::ALGORITHM_VERSION, max( 1, (int) $limit ) ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $wpdb->posts and the density table identifier carry no caller data; every value is bound above and prepared here. return $this->run( static function () use ( $wpdb, $sql ) { return $wpdb->get_col( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Statement prepared by the caller. }, static function ( $ids ) { return array_map( 'intval', (array) $ids ); } ); } /** * Returns every eligible published post id, ascending, for a bulk scan. * * Deliberately unfiltered by staleness: an explicit "Scan All Published * Content" request re-scores everything in its deterministic snapshot, * which is what makes the snapshot stable for the whole bulk job even as * content keeps changing underneath it. * * @since 1.5.0 * * @param array $post_types Currently configured and allowed post types. * @return array|null Post ids ascending, or null when the query failed. */ public function eligible_snapshot( array $post_types ) { global $wpdb; if ( array() === $post_types ) { return array(); } $placeholders = implode( ',', array_fill( 0, count( $post_types ), '%s' ) ); $query = 'SELECT p.ID FROM ' . $wpdb->posts . ' p ' . "WHERE p.post_status = 'publish' AND p.post_type IN ($placeholders) " . 'ORDER BY p.ID ASC'; $sql = $wpdb->prepare( $query, $post_types ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $wpdb->posts carries no caller data; the type list is bound above and prepared here. return $this->run( static function () use ( $wpdb, $sql ) { return $wpdb->get_col( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Statement prepared by the caller. }, static function ( $ids ) { return array_map( 'intval', (array) $ids ); } ); } /** * Returns one page of the worst-first admin table. * * Unscanned and errored rows sort ahead of every scored row: they are the * items an administrator cannot yet trust a score for at all, which is a * stronger reason to look than a low but known one. Ties inside each tier * fall back to the post title under the site locale, then the post id, so * the page is stable across requests. * * The full matching set (not just the requested page) is fetched and * sorted once per request. A locale-aware title comparison can only * produce a globally correct order - and therefore correct pagination - * by resolving every tie across the whole result set at once; resolving * ties one LIMIT/OFFSET page at a time could place a tied title on the * wrong side of a page boundary. This trades one larger query for * correctness; it is bounded by the number of eligible posts, which this * screen already counts in full for the "total" figure. * * @since 1.5.0 * * @param array $post_types Currently configured and allowed post types. * @param string $type_filter One configured type, or '' for all of them. * @param int $per_page Rows per page. * @param int $offset Rows to skip. * @return array{rows: array>, total: int}|null Page of rows and the total eligible count, or null when the query failed. */ public function listing( array $post_types, $type_filter, $per_page, $offset ) { global $wpdb; $types = '' === $type_filter ? $post_types : array_values( array_intersect( $post_types, array( $type_filter ) ) ); if ( array() === $types ) { return array( 'rows' => array(), 'total' => 0, ); } $placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) ); $where = "WHERE p.post_status = 'publish' AND p.post_type IN ($placeholders)"; $query = 'SELECT p.ID, p.post_title, p.post_type, d.score, d.question_headings, d.direct_answers, d.buried_answers, d.derivative_answers, d.state, d.scanned_at, d.algorithm_version, d.details, ' . "CASE WHEN d.post_id IS NULL THEN 0 WHEN d.state = 'error' THEN 1 ELSE 2 END AS tier " . 'FROM ' . $wpdb->posts . ' p LEFT JOIN ' . $this->quoted_table() . " d ON d.post_id = p.ID $where ORDER BY tier ASC, d.score ASC, p.post_title ASC, p.ID ASC"; $sql = $wpdb->prepare( $query, $types ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $wpdb->posts and the density table identifier carry no caller data; the type list is bound above and prepared here. $all = $this->run( static function () use ( $wpdb, $sql ) { return $wpdb->get_results( $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Statement prepared above. }, static function ( $rows ) { return self::decode_listing_rows( (array) $rows ); } ); if ( null === $all ) { return null; } $sorted = self::locale_aware_tie_break( $all ); return array( 'rows' => array_slice( $sorted, max( 0, (int) $offset ), max( 1, (int) $per_page ) ), 'total' => count( $sorted ), ); } /** * Decodes raw listing rows into the admin table's row shape. * * @since 1.7.1 * * @param array> $rows Raw `$wpdb->get_results()` rows. * @return array> Decoded rows, still in the database's own order. */ private static function decode_listing_rows( array $rows ) { $out = array(); foreach ( $rows as $row ) { $decoded = isset( $row['details'] ) ? json_decode( (string) $row['details'], true ) : null; $decoded = is_array( $decoded ) ? $decoded : array(); $score_breakdown = isset( $decoded['score_breakdown'] ) && is_array( $decoded['score_breakdown'] ) ? $decoded['score_breakdown'] : array(); $out[] = array( 'post_id' => (int) $row['ID'], 'post_title' => (string) $row['post_title'], 'post_type' => (string) $row['post_type'], 'score' => null === $row['score'] ? null : (int) $row['score'], 'questions' => null === $row['question_headings'] ? null : (int) $row['question_headings'], 'direct' => null === $row['direct_answers'] ? null : (int) $row['direct_answers'], 'buried' => null === $row['buried_answers'] ? null : (int) $row['buried_answers'], 'unanswered' => null === $row['derivative_answers'] ? null : (int) $row['derivative_answers'], 'state' => null === $row['state'] ? null : (string) $row['state'], 'scanned_at' => null === $row['scanned_at'] ? null : (string) $row['scanned_at'], 'algorithm_version' => null === $row['algorithm_version'] ? null : (int) $row['algorithm_version'], 'score_breakdown' => $score_breakdown, 'tier' => (int) $row['tier'], ); } return $out; } /** * Re-sorts same-tier, same-score runs by a site-locale-aware title * comparison, then post id. * * The database query above already orders rows by tier, then score, then * (as a deterministic fallback) post title and post id under the * database's own collation - so when the `intl` extension is unavailable * this method is a no-op and the admin table keeps that previous, * disclosed behavior rather than silently guessing at a locale-aware * order it cannot actually perform. * * @since 1.7.1 * * @param array> $rows Decoded rows, already ordered by tier, score, title and id. * @return array> Rows with each tier/score run re-sorted by locale-aware title, then post id. */ private static function locale_aware_tie_break( array $rows ) { if ( ! class_exists( 'Collator' ) ) { return $rows; } $collator = new \Collator( get_locale() ); $groups = array(); $group_key = null; $bucket = array(); foreach ( $rows as $row ) { $key = $row['tier'] . '|' . ( null === $row['score'] ? '' : $row['score'] ); if ( $key !== $group_key ) { if ( array() !== $bucket ) { $groups[] = $bucket; } $bucket = array(); $group_key = $key; } $bucket[] = $row; } if ( array() !== $bucket ) { $groups[] = $bucket; } $out = array(); foreach ( $groups as $bucket ) { if ( count( $bucket ) > 1 ) { usort( $bucket, static function ( $a, $b ) use ( $collator ) { $cmp = $collator->compare( $a['post_title'], $b['post_title'] ); $cmp = false === $cmp ? 0 : $cmp; return 0 !== $cmp ? $cmp : ( $a['post_id'] <=> $b['post_id'] ); } ); } $out = array_merge( $out, $bucket ); } return $out; } /** * Returns the dashboard aggregates: average score, scanned count and a * per-type breakdown, over successfully scanned, currently in-scope rows. * * @since 1.5.0 * * @param array $post_types Currently configured and allowed post types. * @return array{average_score: float|null, scanned_count: int, breakdown: array}|null Aggregates, or null when the query failed. */ public function summary( array $post_types ) { global $wpdb; if ( array() === $post_types ) { return array( 'average_score' => null, 'scanned_count' => 0, 'breakdown' => array(), ); } $placeholders = implode( ',', array_fill( 0, count( $post_types ), '%s' ) ); $query = 'SELECT d.post_type AS post_type, d.score AS score FROM ' . $wpdb->posts . ' p INNER JOIN ' . $this->quoted_table() . ' d ON d.post_id = p.ID ' . "WHERE p.post_status = 'publish' AND d.state = 'scanned' AND p.post_type IN ($placeholders)"; $sql = $wpdb->prepare( $query, $post_types ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $wpdb->posts and the density table identifier carry no caller data; the type list is bound above and prepared here. return $this->run( static function () use ( $wpdb, $sql ) { return $wpdb->get_results( $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Statement prepared above. }, static function ( $rows ) { $rows = (array) $rows; $count = count( $rows ); $breakdown = array(); $total = 0; foreach ( $rows as $row ) { $type = (string) $row['post_type']; $breakdown[ $type ] = isset( $breakdown[ $type ] ) ? $breakdown[ $type ] + 1 : 1; $total += (int) $row['score']; } return array( 'average_score' => $count > 0 ? $total / $count : null, 'scanned_count' => $count, 'breakdown' => $breakdown, ); } ); } /** * Decodes a raw row into its public shape. * * @since 1.5.0 * * @param array $row Raw database row. * @return array{post_id: int, post_type: string, total_headings: int, question_headings: int, direct_answers: int, buried_answers: int, derivative_answers: int, score: int, state: string, algorithm_version: int, content_hash: string, diagnostics: array, questions: array, score_breakdown: array, scanned_at: string} Decoded row. */ private static function decode_row( array $row ) { $decoded = isset( $row['details'] ) ? json_decode( (string) $row['details'], true ) : null; $decoded = is_array( $decoded ) ? $decoded : array(); return array( 'post_id' => (int) $row['post_id'], 'post_type' => (string) $row['post_type'], 'total_headings' => (int) $row['total_headings'], 'question_headings' => (int) $row['question_headings'], 'direct_answers' => (int) $row['direct_answers'], 'buried_answers' => (int) $row['buried_answers'], 'derivative_answers' => (int) $row['derivative_answers'], 'score' => (int) $row['score'], 'state' => (string) $row['state'], 'algorithm_version' => (int) $row['algorithm_version'], 'content_hash' => isset( $decoded['content_hash'] ) ? (string) $decoded['content_hash'] : '', 'diagnostics' => isset( $decoded['diagnostics'] ) && is_array( $decoded['diagnostics'] ) ? $decoded['diagnostics'] : array(), 'questions' => isset( $decoded['questions'] ) && is_array( $decoded['questions'] ) ? $decoded['questions'] : array(), 'score_breakdown' => isset( $decoded['score_breakdown'] ) && is_array( $decoded['score_breakdown'] ) ? $decoded['score_breakdown'] : array(), 'scanned_at' => (string) $row['scanned_at'], ); } /** * Runs one statement with errors suppressed and maps a successful result. * * @since 1.5.0 * * @param callable $query Runs the statement and returns $wpdb's raw result. * @param callable $map Converts a successful raw result to the public shape. * @return mixed|null Mapped result, or null when the statement failed. */ private function run( callable $query, callable $map ) { global $wpdb; $previous = $wpdb->suppress_errors( true ); $wpdb->flush(); $result = $query(); $failed = false === $result || '' !== (string) $wpdb->last_error; $wpdb->suppress_errors( $previous ); return $failed ? null : $map( $result ); } }