} */ private static function where(): array { global $wpdb; $parts = array(); $args = array(); foreach ( self::prefixes() as $prefix ) { $parts[] = 'option_name LIKE %s'; $args[] = $wpdb->esc_like( $prefix ) . '%'; } $args[] = time(); return array( '(' . implode( ' OR ', $parts ) . ') AND CAST(option_value AS UNSIGNED) < %d', $args ); } /** * {@inheritDoc} * * @param array $state State from the last call. * @param float $budget Seconds to spend. * @return array{count: int, done: bool, state: array} */ public function count( array $state, float $budget ): array { global $wpdb; list( $where, $args ) = self::where(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- live count; $where holds placeholders only. $count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE {$where}", array_merge( array( $wpdb->options ), $args ) ) ); return array( 'count' => $count, 'done' => true, 'state' => array(), ); } /** * Expired timeout rows. * * @param int $limit Rows. * @return array */ private static function expired( int $limit ): array { global $wpdb; list( $where, $args ) = self::where(); $args[] = $limit; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- live read; $where holds placeholders only. return (array) $wpdb->get_results( $wpdb->prepare( "SELECT option_name, option_value FROM %i WHERE {$where} ORDER BY option_name LIMIT %d", array_merge( array( $wpdb->options ), $args ) ) ); } /** * Transient name and whether it is a site transient, from a timeout row name. * * @param string $option Timeout option name. * @return array{0: string, 1: bool} */ private static function parse( string $option ): array { if ( str_starts_with( $option, '_site_transient_timeout_' ) ) { return array( substr( $option, strlen( '_site_transient_timeout_' ) ), true ); } return array( substr( $option, strlen( '_transient_timeout_' ) ), false ); } /** * {@inheritDoc} In batches of expired timeout rows (option_name index order): the values * go first, then the timeouts, and each DELETE checks again that the timeout has passed, so a * transient set anew meanwhile stays. The options caches of the names are cleared. * * @param array $state State from the last call. * @param int $batch Rows per statement. * @param float $budget Seconds to spend. * @return array{deleted: int, skipped: int, notes: array, done: bool, state: array} */ public function clean( array $state, int $batch, float $budget ): array { global $wpdb; $out = self::cleaned( $state, false ); $start = microtime( true ); $limit = min( self::BATCH, max( 1, $batch ) ); do { $rows = self::expired( $limit ); $now = time(); $names = array( '_transient_' => array(), '_site_transient_' => array(), ); foreach ( $rows as $row ) { list( $name, $site ) = self::parse( (string) $row->option_name ); $names[ $site ? '_site_transient_' : '_transient_' ][] = $name; } $gone = 0; foreach ( $names as $prefix => $list ) { if ( ! $list ) { continue; } $timeout = $prefix . 'timeout_'; $values = array_map( static fn( $name ) => $prefix . $name, $list ); $timeouts = array_map( static fn( $name ) => $timeout . $name, $list ); $in = self::placeholders( count( $list ), '%s' ); // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $in is one %s placeholder per name (values passed to prepare()); the timeout is checked again inside each DELETE; caches are cleared below. $wpdb->query( $wpdb->prepare( "DELETE v FROM %i v INNER JOIN %i t ON t.option_name = CONCAT( %s, SUBSTRING( v.option_name, %d ) ) WHERE v.option_name IN ({$in}) AND CAST( t.option_value AS UNSIGNED ) < %d", array_merge( array( $wpdb->options, $wpdb->options, $timeout, strlen( $prefix ) + 1 ), $values, array( $now ) ) ) ); $gone += (int) $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE option_name IN ({$in}) AND CAST( option_value AS UNSIGNED ) < %d", array_merge( array( $wpdb->options ), $timeouts, array( $now ) ) ) ); // phpcs:enable self::forget( array_merge( $values, $timeouts ) ); } $out['deleted'] += $gone; if ( $rows ) { $out['progress'] = true; // Rows looked at (even if all were renewed meanwhile). } if ( count( $rows ) < $limit || 0 === $gone || '' !== $wpdb->last_error ) { $out['done'] = '' === $wpdb->last_error; break; } } while ( self::time_left( $start, $budget ) ); return $out; } /** * Clear the options caches of deleted option names. * * @param string[] $names Option names. * @return void */ private static function forget( array $names ): void { $all = wp_cache_get( 'alloptions', 'options' ); $changed = false; foreach ( $names as $name ) { wp_cache_delete( $name, 'options' ); if ( is_array( $all ) && isset( $all[ $name ] ) ) { unset( $all[ $name ] ); $changed = true; } } if ( $changed ) { wp_cache_set( 'alloptions', $all, 'options' ); } } /** * {@inheritDoc} Name, expiry and probable owner; never the value. * * @param int $limit Rows (at most 10). * @return array{columns: array, rows: array>} */ public function sample( int $limit = 10 ): array { $out = array(); foreach ( self::expired( max( 1, min( 10, $limit ) ) ) as $row ) { list( $name ) = self::parse( (string) $row->option_name ); $owner = Owners::guess( $name, 'option' ); $out[] = array( 'name' => self::value_cell( $name ), 'expired' => self::date_cell( (int) $row->option_value ), 'owner' => self::cell( self::owner_label( $owner ) ), ); } return array( 'columns' => array( 'name' => __( 'Transient', 'admin-powerups-for-woocommerce' ), 'expired' => __( 'Expired', 'admin-powerups-for-woocommerce' ), 'owner' => __( 'Probable owner', 'admin-powerups-for-woocommerce' ), ), 'rows' => $out, ); } /** * Owner as text. * * @param array $owner Owners::guess() result. * @return string */ private static function owner_label( array $owner ): string { switch ( $owner['type'] ?? '' ) { case 'core': return __( 'WordPress', 'admin-powerups-for-woocommerce' ); case 'woocommerce': return __( 'WooCommerce', 'admin-powerups-for-woocommerce' ); case 'plugin': case 'theme': return '' !== ( $owner['name'] ?? '' ) ? (string) $owner['name'] : (string) ( $owner['slug'] ?? '' ); default: return __( 'Unknown', 'admin-powerups-for-woocommerce' ); } } }