table()] ) ) { return self::$columns_cache[ $this->table()]; } self::$columns_cache[ $this->table()] = $wpdb->get_col( "DESC {$this->table()}" ); return self::$columns_cache[ $this->table()]; } /** * Returns the prepared SQL condition to filter items based on the "keep last" days rule. * * @return string */ protected function keep_days_filter() { global $wpdb; $keep_last = $this->current_keep_last(); if ( ! $keep_last || $keep_last['type'] !== 'days' || ! $this->date_column() ) { return ''; } $nb_days = $keep_last['value']; $sql = " AND {$this->date_column()} <= DATE_SUB(NOW(), INTERVAL %d DAY)"; return $wpdb->prepare( $sql, $nb_days ); } /** * Returns the prepared SQL condition to filter items based on the "keep last" items rule. * It can be either per-parent or from_total rule. * * @return string */ protected function keep_items_filter() { global $wpdb; $keep_last = $this->current_keep_last(); if ( ! $keep_last || $keep_last['type'] !== 'items' || ! $this->date_column() ) { return ''; } $limit = $keep_last['value']; /* ---------- per‑parent rule ---------- */ if ( $this->keep_last_mode() === 'per_parent' ) { $parent = $this->parent_column(); if ( ! $parent ) { return ''; } $sql = " AND ( SELECT COUNT(*) FROM {$this->table()} t2 WHERE t2.{$parent} = main.{$parent} AND {$this->base_where()} AND t2.{$this->date_column()} >= main.{$this->date_column()} ) > %d "; return $wpdb->prepare( $sql, $limit ); } /* ---------- from_total rule ---------- */ $sql = " AND {$this->pk()} NOT IN ( SELECT latest.{$this->pk()} FROM ( SELECT {$this->pk()} FROM {$this->table()} WHERE {$this->base_where()} ORDER BY {$this->date_column()} DESC LIMIT %d ) AS latest ) "; return $wpdb->prepare( $sql, $limit ); } /** * Returns the SQL expression to truncate the value column to a maximum length. * This is used to avoid displaying long values in the UI. * * @return string */ private function truncated_value() { $length = self::TRUNCATE_LENGTH; return "SUBSTRING(main.{$this->value_column()},1,{$length})"; } /** * Returns the current "keep last" settings for this handler run. * This can be overridden by the set_keep_last_override() method by any caller. * * @return array|false The current keep last settings or false if not set. */ private function current_keep_last() { // If an override is set, return it if ( $this->keep_last_config !== null ) { return $this->keep_last_config; // false or array } // If no override is set, return the global keep last settings return ADBC_General_Cleanup::get_keep_last( $this->items_type() ); } /** * Builds the prepared SQL branches for a specific site. * This is used to fetch data from multiple sites in a multisite environment. * * @param int $site_id The site ID to build the branch for. * @param array $args The arguments for the query. * @param string $maybe_order_by The ORDER BY clause if sorting is applied. * @param int $sub_limit The limit to set for this branch. * * @return array An array containing the SQL branch for the specified site. */ protected function build_branches_for_site( $site_id, $args, $maybe_order_by, $sub_limit, $needs_collation_fix = false ) { global $wpdb; // Build all select columns $columns[] = "main.{$this->pk()}"; if ( $needs_collation_fix ) { $collation = ! empty( $wpdb->collate ) ? $wpdb->collate : 'utf8mb4_unicode_ci'; $columns[] = "CONVERT(main.{$this->name_column()} USING utf8mb4) COLLATE {$collation} AS {$this->name_column()}"; $columns[] = "CONVERT({$this->truncated_value()} USING utf8mb4) COLLATE {$collation} AS {$this->value_column()}"; } else { $columns[] = "main.{$this->name_column()}"; $columns[] = "{$this->truncated_value()} AS {$this->value_column()}"; } if ( $this->include_size_in_list() ) { $columns[] = "{$this->size_expression()} AS size"; } $columns[] = "{$site_id} AS site_id"; $columns = array_merge( $columns, $this->extra_select() ); // extra select columns $select_columns = implode( ', ', $columns ); $sql = " SELECT {$select_columns} FROM {$this->table()} main {$this->extra_joins()} WHERE {$this->base_where()} {$this->keep_days_filter()} {$this->keep_items_filter()} {$this->search_filter( $args )} {$this->date_filter( $args )} {$this->size_filter( $args )} {$maybe_order_by} LIMIT %d "; $branch = '(' . $wpdb->prepare( $sql, $sub_limit ) . ')'; return [ $branch ]; } /** * Adds a composite ID to each row in the result set. * This is used to create a unique identifier for each item across sites. * * @param array $rows The rows to add the composite ID to. * * @return array The rows with the composite ID added. */ protected function add_composite_id( &$rows ) { foreach ( $rows as &$row ) { $row['composite_id'] = [ 'site_id' => (int) $row['site_id'], 'items_type' => $this->items_type(), 'id' => (int) $row[ $this->pk()], ]; } return $rows; } /** * Returns the prepared SQL condition to filter items based on a search term. * This is used to search for items by name or value. * * @param array $args The arguments for the search, including 'search_for' and 'search_in'. * * @return string The prepared SQL condition for the search filter, or an empty string if no search term is provided. */ protected function search_filter( $args ) { global $wpdb; $search_for = $args['search_for'] ?? ''; if ( $search_for === '' ) { return ''; } $like = '%' . $wpdb->esc_like( $search_for ) . '%'; switch ( $args['search_in'] ) { case 'name': $search_sql = $wpdb->prepare( " AND main.{$this->name_column()} LIKE %s", $like ); break; case 'value': $search_sql = $wpdb->prepare( " AND main.{$this->value_column()} LIKE %s", $like ); break; default: $search_sql = $wpdb->prepare( " AND ( main.{$this->name_column()} LIKE %s OR main.{$this->value_column()} LIKE %s )", $like, $like ); } return $search_sql; } /** * Returns the prepared SQL condition to filter items based on a date range. * This is used to filter items by their date column. * * @param array $args The arguments for the date filter, including 'start_date' and 'end_date'. * * @return string The prepared SQL condition for the date filter, or an empty string if no date column is defined. */ protected function date_filter( $args ) { global $wpdb; if ( ! $this->date_column() ) return ''; $sql = ''; if ( ! empty( $args['start_date'] ) ) $sql .= $wpdb->prepare( " AND {$this->date_column()} >= %s", $args['start_date'] . ' 00:00:00' ); if ( ! empty( $args['end_date'] ) ) $sql .= $wpdb->prepare( " AND {$this->date_column()} <= %s", $args['end_date'] . ' 23:59:59' ); return $sql; } /** * Returns the prepared SQL condition to filter items based on their size. * This is used to filter items by their total size across all columns. * * @param array $args The arguments for the size filter, including 'size' and 'size_unit'. * * @return string The prepared SQL condition for the size filter, or an empty string if no size is specified. */ protected function size_filter( $args ) { global $wpdb; if ( ! $this->include_size_in_list() ) { return ''; } // We check if there are args since it can be called by the count method without args $size = $args['size'] ?? 0; $size_unit = $args['size_unit'] ?? 'B'; if ( $size === 0 ) { return ''; } $bytes = ADBC_Common_Utils::convert_size_to_bytes( $size, $size_unit ); return $wpdb->prepare( " AND ( {$this->size_expression()} ) >= %d", $bytes ); } /** * Returns the SQL expression to calculate the total size of the item. * This is used to sum the lengths of all columns in the table. * * @return string The SQL expression for the total size. */ protected function size_expression() { $column_expressions = []; foreach ( $this->table_columns() as $column ) { $column_expressions[] = "COALESCE( LENGTH(main.`$column`), 0 )"; } $full_column_expression = implode( ' + ', $column_expressions ); return "($full_column_expression)"; } /** Public methods called by the ADBC_General_Cleanup main class ------------------ */ /** * Counts the total number of items and their total size across all sites. * This method is used to get the count without any filters. * * @return array{"count"=>int, "size"=>int} */ public function count() { return $this->count_filtered(); } /** * Counts the total number of items and their total size across all sites. * This method can be filtered by all filters available in the list method. * * @param array|null $args Optional arguments to filter the count, otherwise counts all items. * * @return array{"count"=>int, "size"=>int} */ public function count_filtered( $args = [] ) { global $wpdb; $site_id = $args['site_id'] ?? 'all'; $total = [ 'count' => 0, 'size' => 0, ]; foreach ( ADBC_Sites::instance()->get_sites_list( $site_id ) as $site ) { ADBC_Sites::instance()->switch_to_blog_id( $site['id'] ); // Make sure the tables exists in this blog if ( ! ADBC_Tables::is_table_exists( $this->table() ) ) { ADBC_Sites::instance()->restore_blog(); continue; // nothing to do on this blog } $sql = " SELECT COUNT({$this->pk()}) AS count, SUM({$this->size_expression()}) AS size FROM {$this->table()} main {$this->extra_joins()} WHERE {$this->base_where()} {$this->keep_days_filter()} {$this->keep_items_filter()} {$this->search_filter( $args )} {$this->date_filter( $args )} {$this->size_filter( $args )} "; $row = $wpdb->get_row( $sql, ARRAY_A ); if ( ! empty( $row ) ) { $total['count'] += (int) $row['count']; $total['size'] += (int) $row['size']; } ADBC_Sites::instance()->restore_blog(); } return $total; } /** * Lists the items of this type across all sites. * This method can be filtered by all filters available in the args array. * * @param array $args The arguments to filter the list. * * @return array An array of items with their details. */ public function list( $args ) { global $wpdb; $per_page = $args['items_per_page']; $page = $args['current_page']; $offset = ( $page - 1 ) * $per_page; $site_arg = $args['site_id']; $sort_col = $args['sort_by']; $sort_dir = $args['sort_order']; $apply_sort = in_array( $sort_col, $this->sortable_columns(), true ) && ( ( $site_arg === 'all' && $this->is_all_sites_sortable() ) || ( $site_arg !== 'all' ) ); $maybe_order_by = $apply_sort ? "ORDER BY {$sort_col} {$sort_dir}" : ''; // Resolve sites once. $sites = ADBC_Sites::instance()->get_sites_list( $site_arg ); // ── Single-site fast path ──────────────────────────────────── if ( count( $sites ) === 1 ) { $site = reset( $sites ); $rows = $this->list_single_site_rows( $site['id'], $args, $per_page, $offset, $maybe_order_by ); return $this->add_composite_id( $rows ); } // ── Multi-site path (UNION over branches) ──────────────────────────── $branch_batch_size = $offset + $per_page; $branches = []; $needs_collation_fix = ! ADBC_Database::is_collaction_unified( $this->table_suffix(), false, $site_arg ); foreach ( $sites as $site ) { ADBC_Sites::instance()->switch_to_blog_id( $site['id'] ); // Make sure the tables exists in this blog if ( ! ADBC_Tables::is_table_exists( $this->table() ) ) { ADBC_Sites::instance()->restore_blog(); continue; // nothing to do on this blog } $branches = array_merge( $branches, $this->build_branches_for_site( $site['id'], $args, // pass whole arg set for WHERE glue $maybe_order_by, // pass sorting column and order or null $branch_batch_size, // pass limit for this branch $needs_collation_fix ) ); ADBC_Sites::instance()->restore_blog(); } if ( empty( $branches ) ) { return []; } $union_sql = implode( "\nUNION ALL\n", $branches ); $sql = " SELECT * FROM ( {$union_sql} ) AS rows_merged {$maybe_order_by} LIMIT %d OFFSET %d "; $sql = $wpdb->prepare( $sql, $per_page, $offset ); $rows = $wpdb->get_results( $sql, ARRAY_A ); return $this->add_composite_id( $rows ); } /** * Lists items for a single site (no UNION/derived table). * * @param int $site_id Site ID. * @param array $args Filter / paging args. * @param int $per_page Items per page. * @param int $offset Offset for LIMIT/OFFSET. * @param string $maybe_order_by Already validated ORDER BY clause or ''. * * @return array Raw rows (ARRAY_A) without composite_id; caller can decorate. */ protected function list_single_site_rows( $site_id, $args, $per_page, $offset, $maybe_order_by ) { global $wpdb; ADBC_Sites::instance()->switch_to_blog_id( $site_id ); // Make sure the tables exists in this blog if ( ! ADBC_Tables::is_table_exists( $this->table() ) ) { ADBC_Sites::instance()->restore_blog(); return []; } // Build select columns $columns = []; $columns[] = "main.{$this->pk()}"; $columns[] = "main.{$this->name_column()}"; $columns[] = "{$this->truncated_value()} AS {$this->value_column()}"; if ( $this->include_size_in_list() ) { $columns[] = "{$this->size_expression()} AS size"; } $columns[] = "{$site_id} AS site_id"; $columns = array_merge( $columns, $this->extra_select() ); $select_columns = implode( ', ', $columns ); $sql = " SELECT {$select_columns} FROM {$this->table()} main {$this->extra_joins()} WHERE {$this->base_where()} {$this->keep_days_filter()} {$this->keep_items_filter()} {$this->search_filter( $args )} {$this->date_filter( $args )} {$this->size_filter( $args )} {$maybe_order_by} LIMIT %d OFFSET %d "; $sql = $wpdb->prepare( $sql, $per_page, $offset ); $rows = $wpdb->get_results( $sql, ARRAY_A ); ADBC_Sites::instance()->restore_blog(); return $rows; } /** * Purges all items of this type across all sites. * This method deletes all items that match the base WHERE clause and the keep last rules. * This method purges the items using either native or SQL method depending on the settings. * * @return int The number of deleted items. */ public function purge() { $cleanup_method = ADBC_Settings::instance()->get_setting( 'sql_or_native_cleanup_method' ); if ( $cleanup_method === 'native' ) return $this->purge_native(); else return $this->purge_sql(); } /** * Deletes the specified items across all sites. * The items should be an array of arrays with 'site_id' and 'id' keys. * This method deletes the items using either native or SQL method depending on the settings. * * @param array $items The items to delete, each item should have 'site_id' and 'id'. * * @return int The number of affected rows. */ public function delete( $items ) { $cleanup_method = ADBC_Settings::instance()->get_setting( 'sql_or_native_cleanup_method' ); if ( $cleanup_method === 'native' ) return $this->delete_native( $items ); else return $this->delete_sql( $items ); } /** * Deletes the specified items using the wordpress native delete helper function. * * @param array $items The items to delete, each item should have 'site_id' and 'id'. * * @return int The number of affected rows. */ protected function delete_native( $items ) { if ( empty( $items ) ) return 0; $by_site = []; foreach ( $items as $item ) { $by_site[ $item['site_id'] ][] = $item['id']; } $helper = $this->delete_helper(); $tail = $this->delete_helper_tail_args(); $affected = 0; foreach ( $by_site as $site_id => $ids ) { ADBC_Sites::instance()->switch_to_blog_id( $site_id ); foreach ( $ids as $id ) { $result = $helper( (int) $id, ...$tail ); if ( $result !== false && $result !== null ) { $affected++; } } ADBC_Sites::instance()->restore_blog(); } return $affected; } /** * Deletes the specified items using direct SQL queries. * * @param array $items The items to delete, each item should have 'site_id' and 'id'. * * @return int The number of affected rows. */ protected function delete_sql( $items ) { global $wpdb; if ( empty( $items ) ) return 0; $by_site = []; foreach ( $items as $item ) { $by_site[ $item['site_id'] ][] = $item['id']; } $affected = 0; foreach ( $by_site as $site_id => $ids ) { ADBC_Sites::instance()->switch_to_blog_id( $site_id ); $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); $sql = "DELETE FROM {$this->table()} main WHERE {$this->pk()} IN ( $placeholders )"; $sql = $wpdb->prepare( $sql, ...$ids ); $affected += $wpdb->query( $sql ); ADBC_Sites::instance()->restore_blog(); } return $affected; } /** * Purges all items of this type across all sites using wordpress native delete helper. * This method deletes all items that match the base WHERE clause and the keep last rules. * * @return int The number of deleted items. */ protected function purge_native() { global $wpdb; $keep_days_sql = $this->keep_days_filter(); $keep_items_sql = $this->keep_items_filter(); $helper = $this->delete_helper(); $tail = $this->delete_helper_tail_args(); $chunk = self::PURGE_CHUNK; // number of items to delete in one run $deleted = 0; foreach ( ADBC_Sites::instance()->get_sites_list() as $site ) { ADBC_Sites::instance()->switch_to_blog_id( $site['id'] ); // Make sure the tables exists in this blog if ( ! ADBC_Tables::is_table_exists( $this->table() ) ) { ADBC_Sites::instance()->restore_blog(); continue; // nothing to do on this blog } while ( true ) { $ids = $wpdb->get_col( " SELECT main.{$this->pk()} FROM {$this->table()} main {$this->extra_joins()} WHERE {$this->base_where()} {$keep_days_sql} {$keep_items_sql} LIMIT {$chunk} " ); if ( empty( $ids ) ) { break; } foreach ( $ids as $id ) { $helper( (int) $id, ...$tail ); $deleted++; } } ADBC_Sites::instance()->restore_blog(); } return $deleted; } /** * Purges all items of this type across all sites using direct SQL queries. * This method deletes all items that match the base WHERE clause and the keep last rules. * * @return int The number of deleted items. */ protected function purge_sql() { global $wpdb; $keep_days_sql = $this->keep_days_filter(); $keep_items_sql = $this->keep_items_filter(); $deleted = 0; foreach ( ADBC_Sites::instance()->get_sites_list() as $site ) { ADBC_Sites::instance()->switch_to_blog_id( $site['id'] ); // Make sure the tables exists in this blog if ( ! ADBC_Tables::is_table_exists( $this->table() ) ) { ADBC_Sites::instance()->restore_blog(); continue; // nothing to do on this blog } // Wrap in a derived table to dodge MySQL error 1093 $sql = " DELETE FROM {$this->table()} WHERE {$this->pk()} IN ( SELECT del_id FROM ( SELECT main.{$this->pk()} AS del_id FROM {$this->table()} AS main {$this->extra_joins()} WHERE {$this->base_where()} {$keep_days_sql} {$keep_items_sql} ) AS tmp ) "; $deleted += $wpdb->query( $sql ); ADBC_Sites::instance()->restore_blog(); } return $deleted; } /** * Sets the "keep last" config for this handler. * This can be used to set a custom rule for the "keep last" feature. * * @param array|false|null $value The value to set, can be NULL (use default keep_last setting), FALSE (no keep‐last for this run), or an array (custom rule). */ public function set_keep_last_config( $value ) { // NULL = use default keep_last settings (normal behavior) // FALSE = no keep‐last for this run // array = custom rule (same structure as default settings) $this->keep_last_config = $value; } /** * Checks if the given column is valid for sorting. * * @return bool True if the column is valid for sorting, false otherwise. */ public function is_valid_sortable_column( $column ) { return in_array( $column, $this->sortable_columns() ); } /** * Checks if the handler can have a "keep last" feature. * This is true if the handler has a date column defined. * * @return bool True if the handler can have a "keep last" feature, false otherwise. */ public function can_have_keep_last() { return $this->date_column() !== null; } }