prepare() over already-prepared SQL, * where any surviving % is re-read as a placeholder. One prepare per query, at the call site. * * The scope is built from class constants, never from request data; only the generated %s * slots vary, and every value is bound. * * @param bool $include_approved Whether to include approved and pending comments. * @param bool $include_woo Whether to include WooCommerce's entries. * @return array SQL fragment first, its ordered values second. */ private static function scope_sql( $include_approved, $include_woo ) { $statuses = self::statuses( $include_approved ); $types = self::types( $include_woo ); $status_slots = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) ); $type_slots = implode( ', ', array_fill( 0, count( $types ), '%s' ) ); return array( "comment_approved IN ( {$status_slots} ) AND comment_type IN ( {$type_slots} )", array_merge( $statuses, $types ), ); } /** * How many comments are in scope. * * A dry-run count. Mutates nothing. * * @param bool $include_approved Whether to include approved and pending comments. * @param bool $include_woo Whether to include WooCommerce's entries. * @return int */ public static function count( $include_approved = false, $include_woo = false ) { global $wpdb; list( $where, $values ) = self::scope_sql( $include_approved, $include_woo ); // A live count for a destructive screen: caching it would offer to delete a number that // is no longer true. $where carries placeholders only; $values are bound by prepare(). // UnfinishedPrepare fires because every placeholder lives inside $where rather than in the // literal string, so the sniff sees values with nothing to bind to. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->comments} WHERE {$where}", $values ) ); } /** * Every count the settings screen needs, so the UI never has to guess. * * @return array */ public static function counts() { return array( 'spamTrash' => self::count( false, false ), 'all' => self::count( true, false ), 'spamTrashWoo' => self::count( false, true ), 'allWoo' => self::count( true, true ), 'wooActive' => self::woocommerce_active(), ); } /** * Delete one batch, permanently. * * Uses wp_delete_comment() with force, so comment meta goes too and post comment counts are * kept correct — deleting the rows directly would leave both behind. * * @param int $limit Maximum comments to delete. * @param bool $include_approved Whether to include approved and pending comments. * @param bool $include_woo Whether to include WooCommerce's entries. * @return int How many were deleted. */ public static function delete_batch( $limit = self::BATCH_SIZE, $include_approved = false, $include_woo = false ) { global $wpdb; $limit = max( 1, (int) $limit ); list( $where, $values ) = self::scope_sql( $include_approved, $include_woo ); $values[] = $limit; // Selecting the batch to hand to wp_delete_comment(); there is no cacheable result here, // and the rows are gone immediately after. Every value is bound by the prepare(). // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter $ids = $wpdb->get_col( $wpdb->prepare( "SELECT comment_ID FROM {$wpdb->comments} WHERE {$where} LIMIT %d", $values ) ); if ( ! $ids ) { return 0; } $deleted = 0; foreach ( $ids as $comment_id ) { if ( wp_delete_comment( (int) $comment_id, true ) ) { ++$deleted; } } return $deleted; } }