$item Item. * @return array|null */ public static function normalize( array $item ) { $id = sanitize_key( (string) ( $item['id'] ?? '' ) ); if ( '' === $id || empty( $item['label'] ) ) { return null; } $kind = (string) ( $item['kind'] ?? 'cache' ); return array( 'id' => $id, 'kind' => in_array( $kind, array( 'settings', 'cache', 'content', 'marker', 'record' ), true ) ? $kind : 'cache', 'label' => (string) $item['label'], 'description' => (string) ( $item['description'] ?? '' ), 'count' => is_callable( $item['count'] ?? null ) ? $item['count'] : null, 'summary' => is_callable( $item['summary'] ?? null ) ? $item['summary'] : null, 'delete' => 'record' !== $kind && is_callable( $item['delete'] ?? null ) ? $item['delete'] : null, 'confirm' => (string) ( $item['confirm'] ?? '' ), ); } /** * How many things an item holds. * * @since 1.0.0 * @param array $item Normalized item. * @return int|null Null when not countable (records). */ public static function count( array $item ) { return $item['count'] ? max( 0, (int) call_user_func( $item['count'] ) ) : null; } /** * Human text for an item's count. * * @since 1.0.0 * @param array $item Normalized item. * @param int|null $count Count. * @return string */ public static function summary( array $item, $count ) { if ( null === $count ) { return ''; } if ( $item['summary'] ) { return (string) call_user_func( $item['summary'], $count ); } if ( 0 === $count ) { return __( 'Nothing stored', 'admin-powerups-for-woocommerce' ); } /* translators: %s: number of stored entries. */ return sprintf( _n( '%s entry', '%s entries', $count, 'admin-powerups-for-woocommerce' ), number_format_i18n( $count ) ); } /** * Delete everything an item holds, in batches (uninstall). * * @since 1.0.0 * @param array $item Normalized item. * @return void */ public static function purge( array $item ) { if ( ! $item['delete'] ) { return; } // Keep going until the item reports nothing left (bounded). for ( $i = 0; $i < 1000; $i++ ) { if ( (int) call_user_func( $item['delete'] ) <= 0 ) { break; } } } /* ------------------------------------------------------------------ builders */ /** * A plain option (cache or settings-like data). * * @since 1.0.0 * @param string $id Item ID. * @param string $option Option name. * @param string $label Label. * @param string $description Description. * @param string $kind Kind. * @return array */ public static function option( $id, $option, $label, $description, $kind = 'cache' ) { return array( 'id' => $id, 'kind' => $kind, 'label' => $label, 'description' => $description, 'count' => static fn() => false === get_option( $option ) ? 0 : 1, 'summary' => static fn( $count ) => $count ? __( 'Stored', 'admin-powerups-for-woocommerce' ) : __( 'Nothing stored', 'admin-powerups-for-woocommerce' ), 'delete' => static function () use ( $option ) { delete_option( $option ); return 0; }, ); } /** * A user meta key on any number of users. * * @since 1.0.0 * @param string $id Item ID. * @param string $key Meta key. * @param string $label Label. * @param string $description Description. * @param string $kind Kind. * @return array */ public static function user_meta( $id, $key, $label, $description, $kind = 'marker' ) { return array( 'id' => $id, 'kind' => $kind, 'label' => $label, 'description' => $description, 'count' => static function () use ( $key ) { global $wpdb; list( $join, $where ) = self::this_site_users(); return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->usermeta} um {$join} WHERE um.meta_key = %s {$where}", $key ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $join / $where come from this_site_users(): fixed SQL with the core table name and esc_sql()'d blog prefix only. }, 'summary' => static fn( $count ) => $count /* translators: %s: number of users. */ ? sprintf( _n( 'On %s user', 'On %s users', $count, 'admin-powerups-for-woocommerce' ), number_format_i18n( $count ) ) : __( 'Nothing stored', 'admin-powerups-for-woocommerce' ), 'delete' => static function () use ( $key ) { if ( ! is_multisite() ) { delete_metadata( 'user', 0, $key, '', true ); return 0; } // User meta is shared by the whole network: only touch this site's users. global $wpdb; list( $join, $where ) = self::this_site_users(); $ids = $wpdb->get_col( $wpdb->prepare( "SELECT um.user_id FROM {$wpdb->usermeta} um {$join} WHERE um.meta_key = %s {$where} LIMIT 1000", $key ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $join / $where come from this_site_users(): fixed SQL with the core table name and esc_sql()'d blog prefix only. foreach ( $ids as $user_id ) { delete_user_meta( (int) $user_id, $key ); } return count( $ids ) < 1000 ? 0 : (int) call_user_func( self::user_meta( '', $key, '', '' )['count'] ); }, ); } /** * SQL limiting a user meta query to users of the current site (multisite), or nothing. * * @return array{0: string, 1: string} JOIN and WHERE fragments (safe: built from $wpdb only). */ private static function this_site_users() { global $wpdb; if ( ! is_multisite() ) { return array( '', '' ); } $caps = esc_sql( $wpdb->get_blog_prefix() . 'capabilities' ); return array( "INNER JOIN {$wpdb->usermeta} site ON site.user_id = um.user_id AND site.meta_key = '{$caps}'", '' ); } /** * An order meta key, in HPOS and/or legacy storage (both when sync is on). * * @since 1.0.0 * @param string $id Item ID. * @param string $key Meta key. * @param string $label Label. * @param string $description Description. * @param string $kind Kind. * @return array */ public static function order_meta( $id, $key, $label, $description, $kind = 'marker' ) { // Table => [ row ID column, order ID column ]. $tables = static function () { global $wpdb; $list = array( $wpdb->postmeta => array( 'meta_id', 'post_id' ) ); $hpos = $wpdb->prefix . 'wc_orders_meta'; if ( $hpos === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $hpos ) ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery $list[ $hpos ] = array( 'id', 'order_id' ); } return $list; }; return array( 'id' => $id, 'kind' => $kind, 'label' => $label, 'description' => $description, 'count' => static function () use ( $key, $tables ) { global $wpdb; $max = 0; foreach ( array_keys( $tables() ) as $table ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- count of our own meta key. $max = max( $max, (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE meta_key = %s', $table, $key ) ) ); } return $max; }, 'summary' => static fn( $count ) => $count /* translators: %s: number of orders. */ ? sprintf( _n( 'On %s order', 'On %s orders', $count, 'admin-powerups-for-woocommerce' ), number_format_i18n( $count ) ) : __( 'Nothing stored', 'admin-powerups-for-woocommerce' ), 'delete' => static function () use ( $key, $tables ) { global $wpdb; $left = 0; $orders = array(); foreach ( $tables() as $table => $columns ) { list( $row_col, $order_col ) = $columns; // phpcs:disable WordPress.DB.DirectDatabaseQuery -- bulk cleanup of our own meta key; order caches are cleared below. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT %i AS row_id, %i AS order_id FROM %i WHERE meta_key = %s LIMIT %d', $row_col, $order_col, $table, $key, self::BATCH ) ); $ids = array_map( 'intval', wp_list_pluck( $rows, 'row_id' ) ); if ( $ids ) { $in = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $in is one %d placeholder per ID, all passed in the array. $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE %i IN ({$in})", array_merge( array( $table, $row_col ), $ids ) ) ); $orders = array_merge( $orders, array_map( 'intval', wp_list_pluck( $rows, 'order_id' ) ) ); } $left += (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE meta_key = %s', $table, $key ) ); // phpcs:enable } self::clear_order_caches( array_unique( $orders ) ); return $left; }, ); } /** * Forget cached copies of orders whose meta was deleted with SQL, so a persistent object cache * doesn't keep serving the old values (the same caches WooCommerce clears on a CRUD save). * * @since 1.0.0 * @param int[] $order_ids Order IDs. * @return void */ private static function clear_order_caches( array $order_ids ) { self::forget_orders( $order_ids ); } /** * Forget cached copies of orders that no longer exist (e.g. rows of the order tables whose * order is gone): cache deletes only, no lookups, and one orders transient version bump for * the batch. * * @since 1.0.0 * @param array $ids Order IDs (non-positive and non-numeric values are ignored). * @return void */ public static function forget_missing_orders( array $ids ): void { self::forget_orders( $ids, true ); } /** * Forget every cached copy of these orders (post cache, order meta cache, WooCommerce's order * cache, order transients, the HPOS data store cache). Call after changing orders or their meta * with SQL, so a persistent object cache doesn't keep serving the old values. `$missing`: see * forget_missing_orders(). * * @since 1.0.0 * @param array $ids Order IDs (non-positive and non-numeric values are ignored). * @param bool $missing The orders don't exist any more. * @return void */ public static function forget_orders( array $ids, bool $missing = false ): void { $order_ids = array(); foreach ( $ids as $id ) { if ( is_numeric( $id ) && (int) $id > 0 ) { $order_ids[ (int) $id ] = (int) $id; } } $order_ids = array_values( $order_ids ); if ( ! $order_ids ) { return; } $order_cache = null; if ( class_exists( OrderUtil::class ) && class_exists( OrderCache::class ) && function_exists( 'wc_get_container' ) && OrderUtil::orders_cache_usage_is_enabled() ) { $order_cache = wc_get_container()->get( OrderCache::class ); } $hpos_store = null; if ( class_exists( OrdersTableDataStore::class ) && function_exists( 'wc_get_container' ) ) { $hpos_store = wc_get_container()->get( OrdersTableDataStore::class ); } foreach ( $order_ids as $order_id ) { if ( $missing ) { // The order is gone: cache deletes only, no lookups. wp_cache_delete( $order_id, 'posts' ); wp_cache_delete( $order_id, 'post_meta' ); } else { clean_post_cache( $order_id ); // Legacy storage and HPOS placeholder/sync posts (includes post meta). } if ( class_exists( '\WC_Order' ) ) { wp_cache_delete( \WC_Order::generate_meta_cache_key( $order_id, 'orders' ), 'orders' ); } if ( $order_cache ) { $order_cache->remove( $order_id ); } if ( ! $missing && function_exists( 'wc_delete_shop_order_transients' ) ) { wc_delete_shop_order_transients( $order_id ); } } if ( $missing && class_exists( '\WC_Cache_Helper' ) ) { \WC_Cache_Helper::get_transient_version( 'orders', true ); // Once for the batch. } if ( $hpos_store && is_callable( array( $hpos_store, 'clear_cached_data' ) ) ) { $hpos_store->clear_cached_data( $order_ids ); // HPOS data store cache (orders + orders_meta). } } /** * Finished, failed and canceled background jobs of some hooks (Action Scheduler history). * Pending jobs are work in progress and are left alone. * * @since 1.0.0 * @param string $id Item ID. * @param string[] $hooks Action hooks. * @return array */ public static function job_history( $id, array $hooks ) { $query = static function ( $per_page, $format ) use ( $hooks ) { if ( ! class_exists( '\ActionScheduler' ) || ! \ActionScheduler::is_initialized() ) { return 'count' === $format ? 0 : array(); } $store = \ActionScheduler::store(); $out = 'count' === $format ? 0 : array(); foreach ( $hooks as $hook ) { foreach ( array( 'complete', 'failed', 'canceled' ) as $status ) { $args = array( 'hook' => $hook, 'status' => $status, 'group' => Jobs::GROUP, 'per_page' => $per_page, ); if ( 'count' === $format ) { $out += (int) $store->query_actions( $args, 'count' ); } else { $out = array_merge( $out, (array) $store->query_actions( $args ) ); } } } return $out; }; return array( 'id' => $id, 'kind' => 'cache', 'label' => __( 'Background job history', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Finished and failed background jobs, kept by WooCommerce for troubleshooting. Jobs still waiting to run are kept.', 'admin-powerups-for-woocommerce' ), 'count' => static fn() => (int) $query( -1, 'count' ), 'summary' => static fn( $count ) => $count /* translators: %s: number of jobs. */ ? sprintf( _n( '%s job', '%s jobs', $count, 'admin-powerups-for-woocommerce' ), number_format_i18n( $count ) ) : __( 'Nothing stored', 'admin-powerups-for-woocommerce' ), 'delete' => static function () use ( $query ) { $ids = array_slice( (array) $query( self::BATCH, 'ids' ), 0, self::BATCH ); foreach ( $ids as $action_id ) { try { \ActionScheduler::store()->delete_action( (int) $action_id ); } catch ( \Exception $e ) { unset( $e ); // Already gone. } } return (int) $query( -1, 'count' ); }, ); } /** * Store data a power-up created or changed. Explained, never deleted. * * @since 1.0.0 * @param string $id Item ID. * @param string $label Label. * @param string $description Why it stays. * @return array */ public static function record( $id, $label, $description ) { return array( 'id' => $id, 'kind' => 'record', 'label' => $label, 'description' => $description, ); } }