'reviews', 'orders' => 'orders', 'coupons' => 'coupons', 'customers' => 'customers', 'verify' => '', 'products' => 'catalog', 'terms' => 'terms', ); /** * Capability type (Runner::capabilities()) each phase deletes. */ const PHASE_TYPES = array( 'reviews' => 'reviews', 'orders' => 'orders', 'coupons' => 'coupons', 'customers' => 'customers', 'verify' => 'products', 'products' => 'products', 'terms' => 'products', ); /** * Results of process(). */ const DELETED = 'deleted'; const KEPT = 'kept'; const GONE = 'gone'; /** * Keep reasons (labels in reasons()). */ const FAILED = 'failed'; const IN_REAL_ORDERS = 'in_real_orders'; const HAS_REVIEWS = 'has_reviews'; const PARENT_KEPT = 'parent_kept'; const USED_ELSEWHERE = 'used_elsewhere'; const PROTECTED = 'protected'; const OTHER_ROLE = 'other_role'; const HAS_ORDERS = 'has_orders'; const HAS_CONTENT = 'has_content'; const IN_USE = 'in_use'; const UNVERIFIED = 'unverified'; const NOT_ALLOWED = 'not_allowed'; /** * Real-order check for the current product chunk (prime(), from the run's RealOrders walk): * IDs covered, those found in real orders, and those with too many lines to check. * * @var array{ids: array, real: array, over: array}|null */ private static $real = null; /** * Users of the current customer chunk with posts / comments (prime()). * * @var array{posts: array, comments: array}|null */ private static $content = null; /** * Keep reasons with their labels. * * @since 1.0.0 * @return array */ public static function reasons(): array { return array( self::IN_REAL_ORDERS => __( 'in real orders', 'admin-powerups-for-woocommerce' ), self::HAS_REVIEWS => __( 'has other reviews', 'admin-powerups-for-woocommerce' ), self::PARENT_KEPT => __( 'its product was kept', 'admin-powerups-for-woocommerce' ), self::USED_ELSEWHERE => __( 'used on real orders', 'admin-powerups-for-woocommerce' ), self::PROTECTED => __( 'your account or a network admin', 'admin-powerups-for-woocommerce' ), self::OTHER_ROLE => __( 'has another role', 'admin-powerups-for-woocommerce' ), self::HAS_ORDERS => __( 'has other orders', 'admin-powerups-for-woocommerce' ), self::HAS_CONTENT => __( 'has posts or comments', 'admin-powerups-for-woocommerce' ), self::IN_USE => __( 'still in use', 'admin-powerups-for-woocommerce' ), self::UNVERIFIED => __( 'can’t be confirmed as test data', 'admin-powerups-for-woocommerce' ), self::NOT_ALLOWED => __( 'you can’t delete it', 'admin-powerups-for-woocommerce' ), self::FAILED => __( 'couldn’t be deleted', 'admin-powerups-for-woocommerce' ), ); } /** * Read once what the keep checks need for a chunk of IDs: for customers two IN queries instead * of two table scans per customer (comments.user_id isn't indexed); for products the result of * the run's real-order walk (RealOrders), caught up to the newest order lines just before. * Products not primed stay as unverified: nothing is deleted without the check. * * @since 1.0.0 * @param string $phase Phase. * @param int[] $ids IDs of the chunk. * @param mixed $verify Products: the walk's state (`real`, `over`). * @return void */ public static function prime( string $phase, array $ids, $verify = null ): void { global $wpdb; self::$content = null; self::$real = null; $ids = array_values( array_filter( array_map( 'intval', $ids ) ) ); if ( 'products' === $phase && $ids ) { $verify = RealOrders::normalize( $verify ); self::$real = array( 'ids' => array_fill_keys( $ids, true ), 'real' => array_fill_keys( $verify['real'], true ), 'over' => array_fill_keys( $verify['over'], true ), ); return; } if ( 'customers' !== $phase || ! $ids ) { return; } $in = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- existence checks for one chunk; one %d per ID. $posts = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT post_author FROM %i WHERE post_author IN ({$in})", array_merge( array( $wpdb->posts ), $ids ) ) ); $comments = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT user_id FROM %i WHERE user_id IN ({$in})", array_merge( array( $wpdb->comments ), $ids ) ) ); // phpcs:enable self::$content = array( 'posts' => array_fill_keys( array_map( 'intval', (array) $posts ), true ), 'comments' => array_fill_keys( array_map( 'intval', (array) $comments ), true ), ); } /** * Process one object of a phase: delete it, or return why it stays. A string wrapper around * handle(), kept for tests. * * @internal * @since 1.0.0 * @param string $phase Phase key. * @param int $id Object ID. * @param string $batch Batch ID. * @return string '' when deleted (or no longer there / not this batch's), else a keep reason. */ public static function process( string $phase, int $id, string $batch ): string { $result = self::handle( $phase, $id, $batch ); return self::KEPT === $result['result'] ? $result['reason'] : ''; } /** * Process one object of a phase: delete it, or say why it stays, with details. * * @since 1.0.0 * @param string $phase Phase key. * @param int $id Object ID. * @param string $batch Batch ID. * @return array{result: string, type: string, reason: string} `result` DELETED, KEPT or GONE * (no longer there or not this batch's); * `type` the count key. */ public static function handle( string $phase, int $id, string $batch ): array { $type = $phase; $kind = 'post'; $item = ''; $order = null; switch ( $phase ) { case 'reviews': $kind = 'comment'; $item = 'review'; $exists = (bool) get_comment( $id ); break; case 'orders': $order = wc_get_order( $id ); $kind = 'order'; $exists = (bool) $order; $type = $order instanceof \WC_Order_Refund ? 'refunds' : 'orders'; $item = 'refunds' === $type ? 'refund' : 'order'; break; case 'coupons': $item = 'coupon'; $exists = 'shop_coupon' === get_post_type( $id ); break; case 'customers': $kind = 'user'; $item = 'customer'; $exists = (bool) get_userdata( $id ); break; case 'products': $post_type = get_post_type( $id ); $exists = in_array( $post_type, array( 'product', 'product_variation' ), true ); $type = 'product_variation' === $post_type ? 'variations' : 'products'; $item = 'variations' === $type ? 'variation' : 'product'; break; case 'terms': $kind = 'term'; $item = 'term'; $exists = get_term( $id, 'product_cat' ) instanceof \WP_Term; break; default: return self::result( self::KEPT, $type, self::FAILED ); } $marker = 'user' === $kind ? get_user_meta( $id, Marker::user_key(), true ) : ( 'order' === $kind ? ( $order ? $order->get_meta( Marker::META ) : '' ) : get_metadata( $kind, $id, Marker::META, true ) ); if ( ! $exists || (string) $marker !== $batch ) { return self::result( self::GONE, $type ); } if ( ! Marker::verified( $kind, $id, $batch ) ) { return self::result( self::KEPT, $type, self::UNVERIFIED ); } if ( ! self::allowed( $phase, $id ) ) { return self::result( self::KEPT, $type, self::NOT_ALLOWED ); } switch ( $phase ) { case 'coupons': $keep = self::keep_coupon( $id ); break; case 'customers': $keep = self::keep_customer( $id ); break; case 'products': $keep = self::keep_product( $id ); break; case 'terms': $keep = self::keep_term( $id ); break; default: $keep = ''; } if ( '' === $keep ) { /** * Filter whether a marked test object stays. Return a short reason (e.g. `my_plugin_uses_it`) * to keep it; this can only add reasons, never delete something the checks kept. * * @since 1.0.0 * @param string $reason '' (delete it). * @param string $type `product`, `variation`, `customer`, `order`, `refund`, `coupon`, `review` or `term`. * @param int $id Object ID. * @param string $batch Batch ID. */ $filtered = apply_filters( 'apwc_test_data_keep', '', $item, $id, $batch ); $keep = is_string( $filtered ) ? sanitize_key( $filtered ) : ''; } if ( '' !== $keep ) { return self::result( self::KEPT, $type, $keep ); } /** * Fires before a test object is deleted. * * @since 1.0.0 * @param string $type `product`, `variation`, `customer`, `order`, `refund`, `coupon`, `review` or `term`. * @param int $id Object ID. * @param string $batch Batch ID. */ do_action( 'apwc_test_data_before_delete', $item, $id, $batch ); try { $done = self::delete( $phase, $id ); } catch ( \Throwable $e ) { Log::failed( 'delete', $item, $id, $e ); $done = false; } return $done ? self::result( self::DELETED, $type ) : self::result( self::KEPT, $type, self::FAILED ); } /** * A process() result. * * @param string $result Result. * @param string $type Count key. * @param string $reason Keep reason. * @return array{result: string, type: string, reason: string} */ private static function result( string $result, string $type, string $reason = '' ): array { return array( 'result' => $result, 'type' => $type, 'reason' => $reason, ); } /** * Whether the current user may delete this very object (on top of the type capabilities). * * @param string $phase Phase. * @param int $id ID. * @return bool */ private static function allowed( string $phase, int $id ): bool { switch ( $phase ) { case 'reviews': return current_user_can( 'edit_comment', $id ); case 'orders': return current_user_can( 'delete_shop_orders' ); case 'customers': return current_user_can( is_multisite() ? 'remove_user' : 'delete_user', $id ); case 'terms': return current_user_can( 'delete_term', $id ); default: return current_user_can( 'delete_post', $id ); } } /** * Delete one object through CRUD. * * @param string $phase Phase. * @param int $id ID. * @return bool */ private static function delete( string $phase, int $id ): bool { switch ( $phase ) { case 'reviews': $product = (int) get_comment( $id )?->comment_post_ID; $done = (bool) wp_delete_comment( $id, true ); if ( $done && $product && class_exists( '\WC_Comments' ) ) { \WC_Comments::clear_transients( $product ); } return $done; case 'orders': $order = wc_get_order( $id ); if ( ! $order ) { return true; } if ( $order instanceof \WC_Order ) { foreach ( $order->get_refunds() as $refund ) { self::delete_order( $refund ); } } // Deleting takes back the sales WooCommerce recorded: Counters::recorded_sales() follows. return self::delete_order( $order ); case 'coupons': $coupon = new \WC_Coupon( $id ); return ! $coupon->get_id() || (bool) $coupon->delete( true ); case 'customers': return self::delete_customer( $id ); case 'products': $product = wc_get_product( $id ); if ( ! $product ) { return true; } $parent = $product->is_type( 'variation' ) ? $product->get_parent_id() : 0; $done = (bool) $product->delete( true ); if ( $parent ) { wc_delete_product_transients( $parent ); } return $done; case 'terms': return true === wp_delete_term( $id, 'product_cat' ); } return false; } /** * Force-delete an order or refund, then remove its own items if WooCommerce left them (HPOS * leaves a deleted refund's items behind). After the delete, so WooCommerce still sees the * items while it takes back the sales. * * @param \WC_Abstract_Order $order Order or refund. * @return bool */ private static function delete_order( \WC_Abstract_Order $order ): bool { global $wpdb; $id = (int) $order->get_id(); $done = (bool) $order->delete( true ); if ( $done && $id && ! wc_get_order( $id ) ) { // Only this (now deleted) order's own rows. Raw SQL: remove_order_items() may defer the // delete to a save that never comes. // phpcs:disable WordPress.DB.DirectDatabaseQuery -- leftovers of an order WooCommerce just deleted. $wpdb->query( $wpdb->prepare( 'DELETE im FROM %i im INNER JOIN %i oi ON oi.order_item_id = im.order_item_id WHERE oi.order_id = %d', $wpdb->prefix . 'woocommerce_order_itemmeta', $wpdb->prefix . 'woocommerce_order_items', $id ) ); $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE order_id = %d', $wpdb->prefix . 'woocommerce_order_items', $id ) ); // phpcs:enable wp_cache_delete( 'order-items-' . $id, 'orders' ); } return $done; } /** * Delete an account (single site), or remove it from this site (multisite; deleted from the * network too only by a super admin and only when it belongs to no other site). * * @param int $id User ID. * @return bool */ private static function delete_customer( int $id ): bool { require_once ABSPATH . 'wp-admin/includes/user.php'; if ( ! is_multisite() ) { return (bool) wp_delete_user( $id ); } $done = (bool) wp_delete_user( $id ); if ( ! $done ) { return false; // Still a member: keep its marker so it stays tracked. } delete_user_meta( $id, Marker::user_key() ); delete_user_meta( $id, Marker::user_sig_key() ); if ( ! get_blogs_of_user( $id ) && is_super_admin() && ! is_super_admin( $id ) ) { require_once ABSPATH . 'wp-admin/includes/ms.php'; wpmu_delete_user( $id ); } return true; } /** * Why a coupon stays: someone used it more often than our orders did. * * @param int $id Coupon ID. * @return string */ private static function keep_coupon( int $id ): string { $coupon = new \WC_Coupon( $id ); return $coupon->get_usage_count() > (int) get_post_meta( $id, Marker::USAGE, true ) ? self::USED_ELSEWHERE : ''; } /** * Why an account stays. * * @param int $id User ID. * @return string */ private static function keep_customer( int $id ): string { global $wpdb; $user = get_userdata( $id ); if ( ! $user ) { return self::FAILED; } if ( get_current_user_id() === $id || is_super_admin( $id ) ) { return self::PROTECTED; } if ( array( 'customer' ) !== array_values( (array) $user->roles ) || user_can( $user, 'edit_posts' ) ) { return self::OTHER_ROLE; } // Test accounts only ever have a test-domain email. $at = strrchr( (string) $user->user_email, '@' ); if ( false === $at || ! Words::is_reserved_domain( substr( $at, 1 ) ) ) { return self::UNVERIFIED; } $orders = wc_get_orders( array( 'customer_id' => $id, 'limit' => 1, 'return' => 'ids', 'type' => 'shop_order', 'status' => array_merge( array_keys( wc_get_order_statuses() ), array( 'trash', 'checkout-draft' ) ), ) ); if ( $orders ) { return self::HAS_ORDERS; } if ( null !== self::$content ) { return isset( self::$content['posts'][ $id ] ) || isset( self::$content['comments'][ $id ] ) ? self::HAS_CONTENT : ''; } // phpcs:disable WordPress.DB.DirectDatabaseQuery -- existence checks (not primed). $post = $wpdb->get_var( $wpdb->prepare( 'SELECT ID FROM %i WHERE post_author = %d LIMIT 1', $wpdb->posts, $id ) ); $comment = $post ? null : $wpdb->get_var( $wpdb->prepare( 'SELECT comment_ID FROM %i WHERE user_id = %d LIMIT 1', $wpdb->comments, $id ) ); // phpcs:enable return $post || $comment ? self::HAS_CONTENT : ''; } /** * Why a product or variation stays. * * @param int $id Product or variation ID. * @return string */ private static function keep_product( int $id ): string { global $wpdb; if ( 'product_variation' === get_post_type( $id ) ) { $parent = (int) wp_get_post_parent_id( $id ); // A variation whose parent is still here goes with it (or stays with it). if ( $parent && get_post_type( $parent ) ) { return self::PARENT_KEPT; } } if ( (int) get_post_meta( $id, 'total_sales', true ) > (int) get_post_meta( $id, Marker::SALES, true ) ) { return self::IN_REAL_ORDERS; } if ( null === self::$real || ! isset( self::$real['ids'][ $id ] ) ) { return self::UNVERIFIED; // Not checked against the order lines (not primed). } if ( isset( self::$real['real'][ $id ] ) ) { return self::IN_REAL_ORDERS; } if ( isset( self::$real['over'][ $id ] ) ) { return self::UNVERIFIED; // Too many order lines to check. } // Any review still there goes with the product: someone else's, or ours that was kept // (a review the user may not delete must not be deleted through its product). // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- existence check on the indexed post column. $review = $wpdb->get_var( $wpdb->prepare( 'SELECT comment_ID FROM %i WHERE comment_post_ID = %d LIMIT 1', $wpdb->comments, $id ) ); return $review ? self::HAS_REVIEWS : ''; } /** * Why a category stays: anything is still in it, or it has children that aren't going. * * @param int $id Term ID. * @return string */ private static function keep_term( int $id ): string { global $wpdb; $term = get_term( $id, 'product_cat' ); if ( ! $term instanceof \WP_Term ) { return self::FAILED; } // Counts skip private products, so look at the relationships themselves. // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- existence check on the term relationship index. $used = $wpdb->get_var( $wpdb->prepare( 'SELECT object_id FROM %i WHERE term_taxonomy_id = %d LIMIT 1', $wpdb->term_relationships, $term->term_taxonomy_id ) ); if ( $used ) { return self::IN_USE; } $children = get_terms( array( 'taxonomy' => 'product_cat', 'parent' => $id, 'hide_empty' => false, 'fields' => 'ids', 'number' => 1, ) ); return is_array( $children ) && $children ? self::IN_USE : ''; } }