''` counts a field as set; noindex alone is an exact `= '1'`. */ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber $sql = $wpdb->prepare( "SELECT ( SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ( {$post_type_placeholders} ) ) AS total, COUNT( DISTINCT CASE WHEN pm.meta_key = %s AND pm.meta_value <> '' THEN p.ID END ) AS with_schema, COUNT( DISTINCT CASE WHEN pm.meta_key = %s AND pm.meta_value <> '' THEN p.ID END ) AS with_title, COUNT( DISTINCT CASE WHEN pm.meta_key = %s AND pm.meta_value <> '' THEN p.ID END ) AS with_description, COUNT( DISTINCT CASE WHEN pm.meta_key = %s AND pm.meta_value = '1' THEN p.ID END ) AS noindexed FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id AND p.post_status = 'publish' AND p.post_type IN ( {$post_type_placeholders} ) WHERE pm.meta_key IN ( {$meta_key_placeholders} )", array_merge( $post_types, // The CASE arms above, in the order they appear. array( self::META_SCHEMA_TYPE, self::META_TITLE, self::META_DESCRIPTION, self::META_NOINDEX ), $post_types, $meta_keys ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Aggregate count with no core API equivalent; $sql is the prepared statement built directly above. The result is cached in self::TRANSIENT by the get() wrapper, which is the only caller — the sniff just can't see across the two methods. $row = $wpdb->get_row( $sql, ARRAY_A ); // Defaults, so a query that returns nothing at all reads as an empty site rather // than fataling on a missing key. $counts = array_map( 'intval', array_merge( array( 'total' => 0, 'with_schema' => 0, 'with_title' => 0, 'with_description' => 0, 'noindexed' => 0, ), is_array( $row ) ? $row : array() ) ); return array( 'total' => $counts['total'], 'with_schema' => $counts['with_schema'], 'with_title' => $counts['with_title'], 'with_description' => $counts['with_description'], // Search-engine visibility is the inverse of the per-post noindex meta: a // post is visible unless it's explicitly set to noindex (stored as '1'), so // most posts (no meta row) count as visible. 'with_search_visible' => max( 0, $counts['total'] - $counts['noindexed'] ), ); } /** * Hook the writes that can move the coverage counts. * * @return void */ public static function register_invalidation() { // Covers publish, unpublish, trash, untrash and scheduled posts going live — every // route by which a post enters or leaves the published set. add_action( 'transition_post_status', array( __CLASS__, 'invalidate_on_status_change' ), 10, 3 ); add_action( 'deleted_post', array( __CLASS__, 'invalidate_on_delete' ), 10, 2 ); foreach ( array( 'added_post_meta', 'updated_post_meta', 'deleted_post_meta' ) as $hook ) { add_action( $hook, array( __CLASS__, 'invalidate_on_meta_change' ), 10, 3 ); } } /** * Drop the cached counts when a post enters or leaves the published set. * * Known limitation: this hook only ever sees the post's new type, so converting a * published post to an uncounted post type (or the reverse) isn't caught here and * leaves `total` stale until the next tracked write or the transient's TTL expiry. * A direct `set_post_type()` bypasses every hook anyway, so the TTL backstop is what * ultimately bounds that staleness. * * @param string $new_status Status the post is moving to. * @param string $old_status Status the post is moving from. * @param \WP_Post|null $post The post being transitioned. * @return void */ public static function invalidate_on_status_change( $new_status, $old_status, $post ) { if ( ! $post instanceof \WP_Post || ! in_array( $post->post_type, self::post_types(), true ) ) { return; } // Draft to draft, pending to draft, and the like never touch the counts. if ( 'publish' !== $new_status && 'publish' !== $old_status ) { return; } self::invalidate(); } /** * Drop the cached counts when a post is deleted outright. * * Trashing already goes through `transition_post_status`; this catches a hard delete, * which for an already-trashed post transitions nothing. * * @param int $post_id Deleted post ID. * @param \WP_Post|null $post The post that was deleted. * @return void */ public static function invalidate_on_delete( $post_id, $post = null ) { if ( ! $post instanceof \WP_Post || ! in_array( $post->post_type, self::post_types(), true ) ) { return; } self::invalidate(); } /** * Drop the cached counts when one of the SEO fields they count is written. * * @param int|int[] $meta_id Meta row ID, or IDs on delete. Unused. * @param int $object_id Post the meta belongs to. Unused. * @param string $meta_key Meta key written. * @return void */ public static function invalidate_on_meta_change( $meta_id, $object_id, $meta_key ) { if ( ! in_array( $meta_key, self::meta_keys(), true ) ) { return; } self::invalidate(); } /** * Drop the cached counts. * * @return void */ private static function invalidate() { delete_transient( self::TRANSIENT ); } }