| @@ -71,10 +71,22 @@ | ||
| 71 | 71 | 'clean_sql_tpl' => "DELETE FROM %1\$scomments WHERE comment_approved = 'trash'", |
| 72 | 72 | ), |
| 73 | 73 | 'expired_transients' => array( |
| 74 | 74 | 'label' => 'Expired Transients', |
| 75 | - 'scan_sql_tpl' => "SELECT COUNT(*) FROM %1\$soptions WHERE option_name LIKE '_transient_timeout_%%' AND CAST(option_value AS UNSIGNED) < UNIX_TIMESTAMP()", | |
| 76 | - 'clean_sql_tpl' => "DELETE a, b FROM %1\$soptions a INNER JOIN %1\$soptions b ON b.option_name = REPLACE(a.option_name, '_transient_timeout_', '_transient_') WHERE a.option_name LIKE '_transient_timeout_%%' AND CAST(a.option_value AS UNSIGNED) < UNIX_TIMESTAMP()", | |
| 75 | + // Scan counts one row per EXPIRED transient — the timeout | |
| 76 | + // row — across BOTH the normal (_transient_timeout_*) and the | |
| 77 | + // site/network (_site_transient_timeout_*) families. The | |
| 78 | + // parentheses around the two LIKEs are required so the AND | |
| 79 | + // expiry condition applies to both, not just the second. | |
| 80 | + // (FBS-82149 Bug 1: site transients were never matched; | |
| 81 | + // Bug 3: scan counts logical transients = 1 per timeout row.) | |
| 82 | + 'scan_sql_tpl' => "SELECT COUNT(*) FROM %1\$soptions WHERE ( option_name LIKE '_transient_timeout_%%' OR option_name LIKE '_site_transient_timeout_%%' ) AND CAST( option_value AS UNSIGNED ) < UNIX_TIMESTAMP()", | |
| 83 | + // Clean deletes the timeout row AND its value sibling via a | |
| 84 | + // LEFT JOIN so an ORPHAN timeout row (value sibling missing) | |
| 85 | + // is still removed — an INNER JOIN silently kept those, so a | |
| 86 | + // "clean" never drove the scan count to zero. REPLACE maps | |
| 87 | + // both families to their value-key name. (FBS-82149 Bug 2.) | |
| 88 | + 'clean_sql_tpl' => "DELETE a, b FROM %1\$soptions a LEFT JOIN %1\$soptions b ON b.option_name = REPLACE( REPLACE( a.option_name, '_site_transient_timeout_', '_site_transient_' ), '_transient_timeout_', '_transient_' ) WHERE ( a.option_name LIKE '_transient_timeout_%%' OR a.option_name LIKE '_site_transient_timeout_%%' ) AND CAST( a.option_value AS UNSIGNED ) < UNIX_TIMESTAMP()", | |
| 77 | 89 | ), |
| 78 | 90 | 'orphan_postmeta' => array( |
| 79 | 91 | 'label' => 'Orphan Post Meta', |
| 80 | 92 | 'scan_sql_tpl' => "SELECT COUNT(*) FROM %1\$spostmeta pm LEFT JOIN %1\$sposts p ON p.ID = pm.post_id WHERE p.ID IS NULL", |
| @@ -97,14 +109,11 @@ | ||
| 97 | 109 | public static function scan( $wpdb_in = null ): array { |
| 98 | 110 | $wpdb = self::wpdb( $wpdb_in ); |
| 99 | 111 | $out = array(); |
| 100 | 112 | foreach ( self::types() as $key => $spec ) { |
| 101 | - $sql = sprintf( $spec['scan_sql_tpl'], $wpdb->prefix ); | |
| 102 | - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SQL is built from a hardcoded template registry (types()); only $wpdb->prefix is interpolated. Read-only scan called from a manage_options REST endpoint; results aren't cached on purpose so the user always sees current row counts. | |
| 103 | - $count = (int) $wpdb->get_var( $sql ); | |
| 104 | 113 | $out[ $key ] = array( |
| 105 | 114 | 'label' => $spec['label'], |
| 106 | - 'count' => $count, | |
| 115 | + 'count' => self::count_for( $spec, $wpdb ), | |
| 107 | 116 | ); |
| 108 | 117 | } |
| 109 | 118 | return $out; |
| 110 | 119 | } |
| @@ -109,8 +118,22 @@ | ||
| 109 | 118 | return $out; |
| 110 | 119 | } |
| 111 | 120 | |
| 112 | 121 | /** |
| 122 | + * Run a type's scan SQL and return its COUNT. Shared by scan() and by | |
| 123 | + * clean() (before/after) so the "rows removed" number is always in the | |
| 124 | + * same unit the UI shows. (FBS-82149 Bug 3.) | |
| 125 | + * | |
| 126 | + * @param array{scan_sql_tpl:string} $spec | |
| 127 | + * @param \wpdb $wpdb | |
| 128 | + */ | |
| 129 | + private static function count_for( array $spec, $wpdb ): int { | |
| 130 | + $sql = sprintf( $spec['scan_sql_tpl'], $wpdb->prefix ); | |
| 131 | + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SQL is built from a hardcoded template registry (types()); only $wpdb->prefix is interpolated. Read-only count; not cached so the user always sees current row counts. | |
| 132 | + return (int) $wpdb->get_var( $sql ); | |
| 133 | + } | |
| 134 | + | |
| 135 | + /** | |
| 113 | 136 | * Destructive cleanup for the supplied list of type slugs. Anything |
| 114 | 137 | * not in the static types() table is silently skipped — never run |
| 115 | 138 | * arbitrary SQL because someone POSTed an unexpected slug. |
| 116 | 139 | * |
| @@ -128,14 +151,24 @@ | ||
| 128 | 151 | foreach ( $types as $key ) { |
| 129 | 152 | if ( ! isset( $registry[ $key ] ) ) { |
| 130 | 153 | continue; |
| 131 | 154 | } |
| 155 | + // Report `affected` in the SAME unit the scan counts + the UI | |
| 156 | + // button shows: the reduction in the scan count (logical items), | |
| 157 | + // not raw rows deleted. Without this the paired transient DELETE | |
| 158 | + // (timeout + value = 2 rows per transient) reported "2" while the | |
| 159 | + // button said "(1)". Measure before/after so every type — paired | |
| 160 | + // or single-row — reports a consistent, user-meaningful count. | |
| 161 | + // (FBS-82149 Bug 3.) | |
| 162 | + $before = self::count_for( $registry[ $key ], $wpdb ); | |
| 163 | + | |
| 132 | 164 | $sql = sprintf( $registry[ $key ]['clean_sql_tpl'], $wpdb->prefix ); |
| 133 | 165 | // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SQL built from a hardcoded template registry (types()); only $wpdb->prefix is interpolated. Destructive cleanup explicitly bypasses the object cache. |
| 134 | - $affected = (int) $wpdb->query( $sql ); | |
| 135 | - if ( $affected < 0 ) { | |
| 136 | - $affected = 0; // wpdb returns -1 on error. | |
| 137 | - } | |
| 166 | + $wpdb->query( $sql ); | |
| 167 | + | |
| 168 | + $after = self::count_for( $registry[ $key ], $wpdb ); | |
| 169 | + $affected = max( 0, $before - $after ); | |
| 170 | + | |
| 138 | 171 | $results[ $key ] = $affected; |
| 139 | 172 | $total += $affected; |
| 140 | 173 | } |
| 141 | 174 | |