> */ private static function order_parents(): array { global $wpdb; return array( array( 'table' => $wpdb->prefix . 'wc_orders', 'column' => 'id', 'optional' => true, ), array( 'table' => $wpdb->posts, 'column' => 'ID', ), ); } /** * The checks. * * @since 1.0.0 * @param Module $module Power-up (settings). * @return Check[] */ public static function build( Module $module ): array { global $wpdb; $p = $wpdb->prefix; $woo = array( BaseCheck::class, 'woo_unavailable' ); $orders = 'edit_shop_orders'; $checks = array(); $checks[] = new ExpiredSessions( array( 'id' => 'wc-expired-sessions', 'group' => 'woocommerce', 'label' => __( 'Expired shopper sessions', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Carts and session data of shoppers whose session has ended. WooCommerce removes these twice a day; a backlog means that job isn’t keeping up.', 'admin-powerups-for-woocommerce' ), 'consequence' => __( 'Deletes expired sessions. Shoppers with a live session keep their cart.', 'admin-powerups-for-woocommerce' ), 'risk' => DevGuard::SAFE, 'tables' => array( ExpiredSessions::table() ), 'unavailable' => $woo, ) ); $checks[] = new ExpiredTransients( array( 'id' => 'expired-transients', 'group' => 'woocommerce', 'label' => __( 'Expired transients', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Cached values whose time is up, from WooCommerce, WordPress and plugins. They’re rebuilt when needed. Their expiry is set by whoever stored them.', 'admin-powerups-for-woocommerce' ), 'consequence' => __( 'Deletes expired transients. Anything still needed is rebuilt the next time it’s used.', 'admin-powerups-for-woocommerce' ), 'risk' => DevGuard::SAFE, 'tables' => array( $wpdb->options ), 'unavailable' => $woo, ) ); $checks[] = new OrphanRowsCheck( array( 'id' => 'wc-orphan-order-items', 'group' => 'woocommerce', 'label' => __( 'Order items without an order', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Line items, shipping, fee and tax rows whose order no longer exists in either order storage.', 'admin-powerups-for-woocommerce' ), 'consequence' => __( 'Deletes those items and their details through WooCommerce. Existing orders aren’t touched.', 'admin-powerups-for-woocommerce' ), 'risk' => DevGuard::SAFE, 'object_cap' => $orders, 'slow' => true, 'unavailable' => $woo, 'targets' => array( array( 'table' => $p . 'woocommerce_order_items', 'key' => 'order_item_id', 'fk' => 'order_id', 'where' => 'positive', ), ), 'parents' => self::order_parents(), 'api_batch' => 200, 'deleter' => array( self::class, 'delete_order_items' ), 'sample' => array( 'columns' => array( 'order_item_id' => __( 'Item ID', 'admin-powerups-for-woocommerce' ), 'order_id' => __( 'Missing order ID', 'admin-powerups-for-woocommerce' ), 'order_item_type' => __( 'Type', 'admin-powerups-for-woocommerce' ), 'order_item_name' => __( 'Name', 'admin-powerups-for-woocommerce' ), ), ), ) ); $checks[] = new OrphanRowsCheck( array( 'id' => 'wc-orphan-itemmeta', 'group' => 'woocommerce', 'label' => __( 'Order item details without an item', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Order item meta whose order item no longer exists.', 'admin-powerups-for-woocommerce' ), 'consequence' => __( 'Deletes those rows. They belong to nothing, so nothing shows them.', 'admin-powerups-for-woocommerce' ), 'risk' => DevGuard::SAFE, 'object_cap' => $orders, 'slow' => true, 'unavailable' => $woo, 'targets' => array( array( 'table' => $p . 'woocommerce_order_itemmeta', 'key' => 'meta_id', 'fk' => 'order_item_id', 'where' => 'positive', ), ), 'parents' => array( array( 'table' => $p . 'woocommerce_order_items', 'column' => 'order_item_id', ), ), 'forget' => Helpers::forget_group( 'order_item_meta' ), 'sample' => Helpers::meta_sample( 'meta_id', 'order_item_id', __( 'Missing item ID', 'admin-powerups-for-woocommerce' ) ), ) ); $hpos_targets = array(); foreach ( array( 'wc_orders_meta', 'wc_order_addresses', 'wc_order_operational_data' ) as $table ) { $hpos_targets[] = array( 'table' => $p . $table, 'key' => 'id', 'fk' => 'order_id', ); } $checks[] = new OrphanRowsCheck( array( 'id' => 'wc-orphan-hpos-rows', 'group' => 'woocommerce', 'label' => __( 'Order details without an order', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Meta, address and operational rows in the order tables (high-performance order storage) whose order no longer exists there.', 'admin-powerups-for-woocommerce' ), 'consequence' => __( 'Deletes those rows. Existing orders aren’t touched.', 'admin-powerups-for-woocommerce' ), 'risk' => DevGuard::SAFE, 'object_cap' => $orders, 'slow' => true, 'unavailable' => $woo, 'targets' => $hpos_targets, 'parents' => array( array( 'table' => $p . 'wc_orders', 'column' => 'id', ), ), 'forget' => array( Cleanup::class, 'forget_missing_orders' ), 'sample' => array( 'columns' => array( 'id' => __( 'Row ID', 'admin-powerups-for-woocommerce' ), 'order_id' => __( 'Missing order ID', 'admin-powerups-for-woocommerce' ), ), ), ) ); $analytics = array( array( 'wc_order_stats', 'order_id' ), array( 'wc_order_product_lookup', 'order_item_id' ), array( 'wc_order_tax_lookup', 'order_id' ), array( 'wc_order_coupon_lookup', 'order_id' ), ); $checks[] = new OrphanRowsCheck( array( 'id' => 'wc-orphan-analytics', 'group' => 'woocommerce', 'label' => __( 'Analytics rows without an order', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Rows in WooCommerce Analytics’ order, product, tax and coupon tables whose order no longer exists in either order storage.', 'admin-powerups-for-woocommerce' ), 'consequence' => __( 'Deletes those rows, so Analytics stops counting orders that are gone.', 'admin-powerups-for-woocommerce' ), 'risk' => DevGuard::SAFE, 'object_cap' => $orders, 'slow' => true, 'unavailable' => $woo, 'targets' => array_map( static fn( $row ) => array( 'table' => $p . $row[0], 'key' => $row[1], 'fk' => 'order_id', ), $analytics ), 'parents' => self::order_parents(), 'forget' => static function (): void { $cache = 'Automattic\WooCommerce\Admin\API\Reports\Cache'; if ( class_exists( $cache ) && is_callable( array( $cache, 'invalidate' ) ) ) { call_user_func( array( $cache, 'invalidate' ) ); } }, 'sample' => array( 'columns' => array( 'order_id' => __( 'Missing order ID', 'admin-powerups-for-woocommerce' ), ), ), ) ); $checks[] = new OrphanRowsCheck( array( 'id' => 'wc-orphan-product-lookup', 'group' => 'woocommerce', 'label' => __( 'Product lookup rows without a product', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Rows in WooCommerce’s product and attribute lookup tables (used for sorting and filtering) whose product no longer exists.', 'admin-powerups-for-woocommerce' ), 'consequence' => __( 'Deletes those rows. Shop filters and sorting only ever show existing products.', 'admin-powerups-for-woocommerce' ), 'risk' => DevGuard::SAFE, 'object_cap' => 'delete_products', 'unavailable' => $woo, 'targets' => array( array( 'table' => $p . 'wc_product_meta_lookup', 'key' => 'product_id', 'fk' => 'product_id', ), array( 'table' => $p . 'wc_product_attributes_lookup', 'key' => 'product_id', 'fk' => 'product_id', ), ), 'parents' => array( array( 'table' => $wpdb->posts, 'column' => 'ID', 'types' => array( 'product', 'product_variation' ), ), ), 'sample' => array( 'columns' => array( 'product_id' => __( 'Missing product ID', 'admin-powerups-for-woocommerce' ), ), ), ) ); $lookup = $p . 'wc_customer_lookup'; $store = 'Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore'; $checks[] = new ApiRowsCheck( array( 'id' => 'wc-orphan-customer-lookup', 'group' => 'woocommerce', 'label' => __( 'Analytics customers whose account is gone', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Customers in WooCommerce Analytics linked to a user account that no longer exists. Guest customers are never included.', 'admin-powerups-for-woocommerce' ), 'consequence' => __( 'Removes those customers from Analytics → Customers. Their orders stay.', 'admin-powerups-for-woocommerce' ), 'risk' => DevGuard::REVIEW, 'tables' => array( $lookup, $wpdb->users ), 'unavailable' => static function () use ( $woo, $store ): string { $reason = (string) call_user_func( $woo ); if ( '' === $reason && ! is_callable( array( $store, 'delete_customer' ) ) ) { $reason = __( 'WooCommerce Analytics isn’t available on this site.', 'admin-powerups-for-woocommerce' ); } return $reason; }, 'api_batch' => 100, 'from' => static function () use ( $lookup ): array { global $wpdb; return array( 'FROM %i cl LEFT JOIN %i u ON u.ID = cl.user_id WHERE cl.user_id IS NOT NULL AND u.ID IS NULL', array( $lookup, $wpdb->users ) ); }, 'id_column' => 'cl.customer_id', 'delete' => static function ( int $id ) use ( $store ): bool { call_user_func( array( $store, 'delete_customer' ), $id ); return true; }, 'sample_select' => 'cl.customer_id, cl.user_id, cl.date_registered', 'sample_map' => static fn( array $rows ): array => array( 'columns' => array( 'customer' => __( 'Customer ID', 'admin-powerups-for-woocommerce' ), 'user' => __( 'Missing user ID', 'admin-powerups-for-woocommerce' ), 'registered' => __( 'Registered', 'admin-powerups-for-woocommerce' ), ), 'rows' => array_map( static fn( $row ) => array( 'customer' => BaseCheck::cell( $row->customer_id ), 'user' => BaseCheck::cell( $row->user_id ), 'registered' => BaseCheck::cell( $row->date_registered ), ), $rows ), ), ) ); $checks[] = new ApiRowsCheck( array( 'id' => 'wc-orphan-variations', 'group' => 'woocommerce', 'label' => __( 'Variations without a product', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Product variations whose parent product no longer exists.', 'admin-powerups-for-woocommerce' ), 'consequence' => __( 'Deletes those variations for good, with their lookups and meta. Orders keep their copy of the line item.', 'admin-powerups-for-woocommerce' ), 'risk' => DevGuard::REVIEW, 'object_cap' => 'delete_products', 'tables' => array( $wpdb->posts ), 'unavailable' => $woo, 'api_batch' => 25, 'from' => static function (): array { global $wpdb; return array( 'FROM %i v LEFT JOIN %i parent ON parent.ID = v.post_parent WHERE v.post_type = %s AND ( v.post_parent = 0 OR parent.ID IS NULL )', array( $wpdb->posts, $wpdb->posts, 'product_variation' ) ); }, 'id_column' => 'v.ID', 'delete' => static function ( int $id ): bool { $product = function_exists( 'wc_get_product' ) ? wc_get_product( $id ) : null; if ( $product instanceof \WC_Product ) { return (bool) $product->delete( true ); } return (bool) wp_delete_post( $id, true ); }, 'finish' => static function (): void { if ( function_exists( 'wc_delete_product_transients' ) ) { wc_delete_product_transients(); } }, 'sample_select' => 'v.ID, v.post_type, v.post_title, v.post_parent, v.post_date', 'sample_map' => static fn( array $rows ): array => Helpers::post_sample( $rows, __( 'Missing product ID', 'admin-powerups-for-woocommerce' ) ), ) ); $checks[] = new DownloadLogs( array( 'id' => 'wc-download-logs', 'group' => 'woocommerce', 'label' => __( 'Old download logs', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Records of each file download, with the customer’s IP address.', 'admin-powerups-for-woocommerce' ), 'consequence' => __( 'Deletes those logs, so those downloads leave Analytics → Downloads. Customers keep their download access.', 'admin-powerups-for-woocommerce' ), 'risk' => DevGuard::REVIEW, 'tables' => array( DownloadLogs::table() ), ...Helpers::threshold( $module, 'download_log_days' ), 'min_days' => 30, 'unavailable' => $woo, ) ); $checks[] = new WooLogs( array( 'id' => 'wc-expired-logs', 'group' => 'woocommerce', 'label' => __( 'Expired WooCommerce logs', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Log entries (webhook deliveries included) older than the retention set in WooCommerce → Status → Logs → Settings.', 'admin-powerups-for-woocommerce' ), 'consequence' => __( 'Deletes log entries past WooCommerce’s retention (log files through WooCommerce’s own clean-up).', 'admin-powerups-for-woocommerce' ), 'risk' => DevGuard::SAFE, 'slow' => true, 'tables' => array( WooLogs::table() ), 'days' => array( WooLogs::class, 'retention_days' ), 'unavailable' => $woo, ) ); return $checks; } /** * Deleter of the order items check: each item through WooCommerce (its meta and hooks), * checked again right before, stopping at the deadline. * * @since 1.0.0 * @param int[] $keys Item IDs. * @param array $target Target. * @param OrphanRowsCheck $check Check. * @param float $deadline microtime( true ) to stop at (0: none). * @return array{deleted: int, skipped: int, notes: array, last: int, stopped: bool} */ public static function delete_order_items( array $keys, array $target, OrphanRowsCheck $check, float $deadline = 0.0 ): array { $out = array( 'deleted' => 0, 'skipped' => 0, 'notes' => array(), 'last' => 0, 'stopped' => false, ); foreach ( $keys as $item_id ) { if ( $deadline > 0 && $out['last'] && microtime( true ) >= $deadline ) { $out['stopped'] = true; break; } $out['last'] = (int) $item_id; if ( ! $check->is_orphan( $target, (int) $item_id ) ) { continue; } try { wc_delete_order_item( (int) $item_id ); ++$out['deleted']; } catch ( \Throwable $e ) { unset( $e ); ++$out['skipped']; $out['notes']['error'] = ( $out['notes']['error'] ?? 0 ) + 1; } } return $out; } }