cursor AND key <= cursor + chunk`, up to the MAX(key) * taken at the start) with LEFT JOIN … IS NULL lookups: a primary-key range plus eq_ref joins, * never a full scan per query. * * Clean picks up to a batch of orphan keys inside the current range, then deletes with * `DELETE c FROM child c LEFT JOIN parent … WHERE c.key IN (…) AND parent IS NULL`, so a row that * got a parent meanwhile stays. An optional `deleter` deletes through an API instead (it must * check is_orphan() right before each call). * * Extra arguments (see BaseCheck for the rest): * - `targets`: list of `table`, `key` (indexed integer column walked in ranges), `fk` (column * holding the parent ID) and optional `where` (`positive`: fk > 0, `not_null`: fk IS NOT NULL). * - `parents`: list of `table`, `column`, optional `types` (post types the parent row must have) * and `optional` (skipped when the table doesn't exist; a missing required parent table makes * the check unavailable, so rows are never judged against a table that isn't there). * - `forget`: callable( int[] $parent_ids ): void, clears caches after a delete. * - `deleter`: callable( int[] $keys, array $target, OrphanRowsCheck $check, float $deadline ): * array{deleted: int, skipped: int, notes?: array, last?: int, stopped?: bool}; it * must call $check->is_orphan() right before each delete and should stop at the deadline * (microtime), reporting the last key it handled. * - `api_batch`: rows per step for the deleter (at the default batch). * - `sample`: `columns` (column => label) and optional `meta` (`key`, `value` columns: the value * is masked when the key looks sensitive; `length_only` shows only its length). * * Every table must be in Checks::allowed_tables(); otherwise the check is refused (unavailable). * * @since 1.0.0 */ class OrphanRowsCheck extends BaseCheck { /** * Default rows per range. */ const CHUNK = 50000; /** * {@inheritDoc} * * @param array $args Facts. */ public function __construct( array $args ) { parent::__construct( array_merge( array( 'targets' => array(), 'parents' => array(), 'forget' => null, 'deleter' => null, 'api_batch' => 0, 'sample' => array(), ), $args ) ); } /** * True while clean() runs (availability was checked on entry): is_orphan() skips re-checking it. * * @var bool */ private $in_step = false; /** * {@inheritDoc} */ public function tables(): array { $tables = array(); foreach ( $this->targets() as $target ) { $tables[] = $target['table']; } foreach ( $this->parents( false ) as $parent ) { $tables[] = $parent['table']; } return array_values( array_unique( $tables ) ); } /** * {@inheritDoc} Also refuses tables outside the whitelist and missing parent tables. */ public function unavailable(): string { $allowed = Checks::allowed_tables(); foreach ( $this->tables() as $table ) { if ( ! in_array( $table, $allowed, true ) ) { return __( 'This check points at a table the clean-up doesn’t work on, so it’s refused.', 'admin-powerups-for-woocommerce' ); } } if ( ! $this->targets() || ! $this->parents( false ) ) { return __( 'This check isn’t set up correctly.', 'admin-powerups-for-woocommerce' ); } if ( is_callable( $this->args['unavailable'] ) ) { $reason = (string) call_user_func( $this->args['unavailable'] ); if ( '' !== $reason ) { return $reason; } } foreach ( $this->parents( false ) as $parent ) { if ( empty( $parent['optional'] ) && ! self::table_exists( $parent['table'] ) ) { return __( 'A table it compares against doesn’t exist on this site.', 'admin-powerups-for-woocommerce' ); } } if ( ! $this->existing_targets() ) { return __( 'Its database table doesn’t exist on this site.', 'admin-powerups-for-woocommerce' ); } return ''; } /** * Rows per key range. * * @since 1.0.0 * @return int */ public static function chunk(): int { /** * Filter how many keys one orphan scan query covers. Clamped to 5,000–200,000. * * @since 1.0.0 * @param int $rows Keys per range (default 50,000). */ return max( 5000, min( 200000, (int) apply_filters( 'apwc_database_cleanup_scan_chunk', self::CHUNK ) ) ); } /* ------------------------------------------------------------------ config */ /** * Valid targets, keyed by position. * * @return array */ private function targets(): array { $out = array(); foreach ( (array) $this->args['targets'] as $index => $target ) { if ( ! is_array( $target ) || ! self::names_ok( array( $target['table'] ?? null, $target['key'] ?? null, $target['fk'] ?? null ) ) ) { continue; } $out[ (int) $index ] = array( 'table' => (string) $target['table'], 'key' => (string) $target['key'], 'fk' => (string) $target['fk'], 'where' => in_array( $target['where'] ?? '', array( 'positive', 'not_null' ), true ) ? (string) $target['where'] : '', ); } return $out; } /** * Targets whose table exists. * * @return array */ private function existing_targets(): array { return array_filter( $this->targets(), static fn( $target ) => self::table_exists( $target['table'] ) ); } /** * Valid parents. * * @param bool $existing Only the ones whose table exists (optional ones are dropped otherwise). * @return array */ private function parents( bool $existing = true ): array { $out = array(); foreach ( (array) $this->args['parents'] as $parent ) { if ( ! is_array( $parent ) || ! self::names_ok( array( $parent['table'] ?? null, $parent['column'] ?? null ) ) ) { continue; } if ( $existing && ! self::table_exists( (string) $parent['table'] ) ) { continue; } $out[] = array( 'table' => (string) $parent['table'], 'column' => (string) $parent['column'], 'types' => array_values( array_filter( (array) ( $parent['types'] ?? array() ), 'is_string' ) ), 'optional' => ! empty( $parent['optional'] ), ); } return $out; } /** * Whether identifiers are plain names. * * @param array $names Names. * @return bool */ private static function names_ok( array $names ): bool { foreach ( $names as $name ) { if ( ! is_string( $name ) || ! preg_match( '/^[A-Za-z0-9_]{1,64}$/', $name ) ) { return false; } } return true; } /** * FROM … LEFT JOIN … and the orphan condition, with their arguments in order. * * @param array{table: string, key: string, fk: string, where: string} $target Target. * @return array{from: string, from_args: array, orphan: string, orphan_args: array} */ private function sql( array $target ): array { $from = 'FROM %i c'; $from_args = array( $target['table'] ); $where = array(); $args = array(); if ( 'positive' === $target['where'] ) { $where[] = 'c.%i > 0'; $args[] = $target['fk']; } elseif ( 'not_null' === $target['where'] ) { $where[] = 'c.%i IS NOT NULL'; $args[] = $target['fk']; } foreach ( $this->parents() as $i => $parent ) { $alias = 'p' . (int) $i; $from .= " LEFT JOIN %i {$alias} ON {$alias}.%i = c.%i"; array_push( $from_args, $parent['table'], $parent['column'], $target['fk'] ); if ( $parent['types'] ) { $from .= " AND {$alias}.post_type IN (" . self::placeholders( count( $parent['types'] ), '%s' ) . ')'; $from_args = array_merge( $from_args, $parent['types'] ); } $where[] = "{$alias}.%i IS NULL"; $args[] = $parent['column']; } return array( 'from' => $from, 'from_args' => $from_args, 'orphan' => implode( ' AND ', $where ), 'orphan_args' => $args, ); } /** * Smallest and largest key of a table. * * @param array{table: string, key: string, fk: string, where: string} $target Target. * @return array{0: int, 1: int} */ private static function bounds( array $target ): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- live bounds of the range walk. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT MIN(%i) AS lo, MAX(%i) AS hi FROM %i', $target['key'], $target['key'], $target['table'] ) ); if ( ! is_object( $row ) || null === $row->hi ) { return array( 0, 0 ); } return array( (int) $row->lo - 1, (int) $row->hi ); // From just below the first key (0 included). } /** * The cursor after a range: just below the next existing key (one index lookup), so gaps in * the keys are jumped over instead of walked range by range. * * @param array{table: string, key: string, fk: string, where: string} $target Target. * @param int $upper End of the range just done. * @param int $max Largest key. * @return int */ private static function skip( array $target, int $upper, int $max ): int { global $wpdb; if ( $upper >= $max ) { return $max; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one index lookup. $next = $wpdb->get_var( $wpdb->prepare( 'SELECT MIN(%i) FROM %i WHERE %i > %d', $target['key'], $target['table'], $target['key'], $upper ) ); return null === $next ? $max : max( $upper, (int) $next - 1 ); } /** * Move the state to the next existing target (or past the end). Sets its bounds. * * @param array $state State. * @return array{0: array, 1: array{table: string, key: string, fk: string, where: string}|null} */ private function position( array $state ): array { $targets = $this->existing_targets(); $index = isset( $state['t'] ) ? (int) $state['t'] : -1; if ( isset( $targets[ $index ], $state['cursor'], $state['max'] ) && (int) $state['cursor'] < (int) $state['max'] ) { return array( $state, $targets[ $index ] ); } foreach ( $targets as $candidate => $target ) { if ( $candidate <= $index ) { continue; } list( $lo, $hi ) = self::bounds( $target ); if ( $lo < $hi ) { $state = array_merge( $state, array( 't' => $candidate, 'cursor' => $lo, 'max' => $hi, ) ); return array( $state, $target ); } } return array( array_merge( $state, array( 't' => PHP_INT_MAX ) ), null ); } /* ------------------------------------------------------------------ count, sample, clean */ /** * {@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; if ( '' !== $this->unavailable() ) { // Fail closed: never judge rows against a missing or refused table. return array( 'count' => 0, 'done' => true, 'state' => array(), ); } $start = microtime( true ); $chunk = self::chunk(); $count = isset( $state['count'] ) ? (int) $state['count'] : 0; do { list( $state, $target ) = $this->position( $state ); if ( null === $target ) { return array( 'count' => $count, 'done' => true, 'state' => $state, ); } $sql = $this->sql( $target ); $upper = min( (int) $state['cursor'] + $chunk, (int) $state['max'] ); $args = array_merge( $sql['from_args'], array( $target['key'], (int) $state['cursor'], $target['key'], $upper ), $sql['orphan_args'] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql['from'] is built only from placeholders (identifiers via %i, from whitelisted and validated names); every value is passed to prepare(). $found = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) {$sql['from']} WHERE c.%i > %d AND c.%i <= %d AND {$sql['orphan']}", $args ) ); $count += $found; if ( $found > 0 && ! isset( $state['sample_from'] ) ) { // Where the first orphans are: the sample starts there (see sample()). $state['sample_from'] = array( 't' => (int) $state['t'], 'from' => (int) $state['cursor'], ); } $state['cursor'] = self::skip( $target, $upper, (int) $state['max'] ); $state['count'] = $count; } while ( self::time_left( $start, $budget ) ); list( $state, $target ) = $this->position( $state ); return array( 'count' => $count, 'done' => null === $target, 'state' => $state, ); } /** * Whether one row (by key) is still an orphan: for API deleters, right before each call. * * @since 1.0.0 * @param array{table: string, key: string, fk: string, where: string} $target Target. * @param int $key Key value. * @return bool */ public function is_orphan( array $target, int $key ): bool { global $wpdb; if ( ! $this->in_step && '' !== $this->unavailable() ) { return false; } $sql = $this->sql( $target ); $args = array_merge( $sql['from_args'], array( $target['key'], $key ), $sql['orphan_args'] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql['from'] is built only from placeholders (identifiers via %i, from whitelisted and validated names); every value is passed to prepare(). return (bool) $wpdb->get_var( $wpdb->prepare( "SELECT 1 {$sql['from']} WHERE c.%i = %d AND {$sql['orphan']} LIMIT 1", $args ) ); } /** * {@inheritDoc} * * @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; if ( '' !== $this->unavailable() ) { return self::cleaned( $state, true ); // Fail closed, as in count(). } $start = microtime( true ); $chunk = self::chunk(); $out = self::cleaned( $state, false ); $deleter = is_callable( $this->args['deleter'] ) ? $this->args['deleter'] : null; if ( $deleter ) { $batch = self::api_batch( $batch, max( 1, (int) $this->args['api_batch'] ) ); } $this->in_step = true; try { do { list( $state, $target ) = $this->position( $state ); if ( null === $target ) { $out['done'] = true; break; } $sql = $this->sql( $target ); $upper = min( (int) $state['cursor'] + $chunk, (int) $state['max'] ); $args = array_merge( array( $target['key'], $target['fk'] ), $sql['from_args'], array( $target['key'], (int) $state['cursor'], $target['key'], $upper ), $sql['orphan_args'], array( $target['key'], $batch ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql['from'] is built only from placeholders (identifiers via %i, from whitelisted and validated names); every value is passed to prepare(). $rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT c.%i AS k, c.%i AS f {$sql['from']} WHERE c.%i > %d AND c.%i <= %d AND {$sql['orphan']} ORDER BY c.%i LIMIT %d", $args ) ); if ( ! $rows ) { $state['cursor'] = self::skip( $target, $upper, (int) $state['max'] ); continue; } $keys = array_values( array_unique( array_map( static fn( $row ) => (int) $row->k, $rows ) ) ); $parents = array_values( array_unique( array_map( static fn( $row ) => (int) $row->f, $rows ) ) ); if ( $deleter ) { $result = (array) call_user_func( $deleter, $keys, $target, $this, $start + $budget ); $out['deleted'] += (int) ( $result['deleted'] ?? 0 ); $out['skipped'] += (int) ( $result['skipped'] ?? 0 ); foreach ( (array) ( $result['notes'] ?? array() ) as $note => $n ) { $out['notes'][ (string) $note ] = ( $out['notes'][ (string) $note ] ?? 0 ) + (int) $n; } if ( ! empty( $result['stopped'] ) && isset( $result['last'] ) ) { // Out of time part-way: carry on after the last item handled. $state['cursor'] = (int) $result['last']; if ( is_callable( $this->args['forget'] ) ) { call_user_func( $this->args['forget'], $parents ); } break; } } else { $del_args = array_merge( $sql['from_args'], array( $target['key'] ), $keys, $sql['orphan_args'] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- identifiers via %i; the orphan condition is checked again inside the DELETE. $gone = $wpdb->query( $wpdb->prepare( "DELETE c {$sql['from']} WHERE c.%i IN (" . self::placeholders( count( $keys ) ) . ") AND {$sql['orphan']}", $del_args ) ); $out['deleted'] += max( 0, (int) $gone ); if ( '' !== $wpdb->last_error ) { break; } } if ( is_callable( $this->args['forget'] ) ) { call_user_func( $this->args['forget'], $parents ); } $state['cursor'] = count( $rows ) < $batch ? $upper : (int) end( $keys ); } while ( self::time_left( $start, $budget ) ); } finally { $this->in_step = false; } if ( ! $out['done'] ) { list( $state, $target ) = $this->position( $state ); $out['done'] = null === $target; } $out['state'] = $state; return $out; } /** * Where the last scan first found orphans, as a walk state (target, cursor, current MAX), or * null when unknown, older than a day, or no longer inside the table. * * @return array{t: int, cursor: int, max: int}|null */ private function sample_hint(): ?array { $entry = Results::for_check( $this->id() ); $hint = is_array( $entry['sample_from'] ?? null ) ? $entry['sample_from'] : null; if ( ! $hint || ! isset( $hint['t'], $hint['from'] ) || (int) ( $entry['scanned_at'] ?? 0 ) < time() - Results::FRESH ) { return null; } $targets = $this->existing_targets(); $index = (int) $hint['t']; if ( ! isset( $targets[ $index ] ) ) { return null; } list( $lo, $hi ) = self::bounds( $targets[ $index ] ); $from = (int) $hint['from']; if ( $from < $lo || $from >= $hi ) { return null; } return array( 't' => $index, 'cursor' => $from, 'max' => $hi, ); } /** * {@inheritDoc} Starts where the last scan first found orphans (then walks the ranges before * it), or at the lowest key when that isn't known; at most 2 seconds. * * @param int $limit Rows (at most 10). * @return array{columns: array, rows: array>} */ public function sample( int $limit = 10 ): array { global $wpdb; $limit = max( 1, min( 10, $limit ) ); $config = (array) $this->args['sample']; $columns = array_filter( (array) ( $config['columns'] ?? array() ), 'is_string' ); $columns = array_filter( $columns, static fn( $label, $col ) => is_string( $col ) && (bool) preg_match( '/^[A-Za-z0-9_]{1,64}$/', $col ), ARRAY_FILTER_USE_BOTH ); $meta = isset( $config['meta']['key'], $config['meta']['value'] ) ? array( (string) $config['meta']['key'], (string) $config['meta']['value'] ) : null; $length_only = ! empty( $config['meta']['length_only'] ); $out = array( 'columns' => $columns, 'rows' => array(), ); if ( ! $columns || '' !== $this->unavailable() ) { return $out; } $start = microtime( true ); $chunk = self::chunk(); $hint = $this->sample_hint(); $state = $hint ?? array(); $found = 0; $stop = null; // Set on the second pass: walk from the lowest key up to where the first began. $select = implode( ', ', array_fill( 0, count( $columns ), 'c.%i' ) ); do { list( $state, $target ) = $this->position( $state ); $past = $stop && null !== $target && ( (int) $state['t'] > $stop['t'] || ( (int) $state['t'] === $stop['t'] && (int) $state['cursor'] >= $stop['cursor'] ) ); if ( null === $target || $past ) { if ( $hint && ! $stop ) { $stop = $hint; $state = array(); continue; } break; } $sql = $this->sql( $target ); $upper = min( (int) $state['cursor'] + $chunk, (int) $state['max'] ); if ( $stop && (int) $state['t'] === $stop['t'] ) { $upper = min( $upper, $stop['cursor'] ); // The first pass already read from there. } $need = $limit - count( $out['rows'] ); $args = array_merge( array_keys( $columns ), $sql['from_args'], array( $target['key'], (int) $state['cursor'], $target['key'], $upper ), $sql['orphan_args'], array( $target['key'], $need ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql['from'] is built only from placeholders (identifiers via %i, from whitelisted and validated names); every value is passed to prepare(). $rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT {$select} {$sql['from']} WHERE c.%i > %d AND c.%i <= %d AND {$sql['orphan']} ORDER BY c.%i LIMIT %d", $args ), ARRAY_A ); foreach ( $rows as $row ) { $line = array(); foreach ( array_keys( $columns ) as $col ) { $value = $row[ $col ] ?? ''; if ( $meta && $col === $meta[1] ) { $line[ $col ] = $length_only /* translators: %s: number of characters. */ ? sprintf( _n( '%s character', '%s characters', strlen( (string) $value ), 'admin-powerups-for-woocommerce' ), number_format_i18n( strlen( (string) $value ) ) ) : self::meta_cell( (string) ( $row[ $meta[0] ] ?? '' ), $value ); } else { $line[ $col ] = self::value_cell( $value ); } } $out['rows'][] = $line; } $state['cursor'] = self::skip( $target, $upper, (int) $state['max'] ); $found = count( $out['rows'] ); } while ( $found < $limit && self::time_left( $start, 2.0 ) ); return $out; } }