PluginProbe ʕ •ᴥ•ʔ
Advanced Database Cleaner – Optimize & Clean Database to Speed Up Site Performance / 4.2.0
Advanced Database Cleaner – Optimize & Clean Database to Speed Up Site Performance v4.2.0
4.2.0 trunk 1.0.0 1.1.0 1.1.1 1.2.0 1.2.1 1.2.2 1.2.3 1.3.0 1.3.1 1.3.5 1.3.6 1.3.7 2.0.0 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.1.0 3.1.1 3.1.2 3.1.3 3.1.4 3.1.5 3.1.6 3.1.7 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.1.0 4.1.1
advanced-database-cleaner / includes / models / class-adbc-transients.php
advanced-database-cleaner / includes / models Last commit date
class-adbc-common-model.php 7 months ago class-adbc-cron-jobs.php 2 months ago class-adbc-database.php 5 months ago class-adbc-options.php 4 months ago class-adbc-post-types.php 3 months ago class-adbc-posts-meta.php 4 months ago class-adbc-sites.php 7 months ago class-adbc-tables.php 3 months ago class-adbc-transients.php 4 months ago class-adbc-users-meta.php 4 months ago
class-adbc-transients.php
1199 lines
1 <?php
2
3 // Exit if accessed directly
4 if ( ! defined( 'ABSPATH' ) )
5 exit;
6
7 /**
8 * ADBC transients class.
9 *
10 * This class provides the transients functions
11 *
12 */
13 class ADBC_Transients {
14
15 private const BIG_TRANSIENTS_THRESHOLD_WARNING = 150 * 1024; // 150 KB. (If you change this value, change it as well in js filter message and slice)
16 private const TRUNCATE_LENGTH = 20; // Length to truncate the transient value for display
17
18 /**
19 * Get the transients list for the endpoint.
20 *
21 * @param array $filters Output of sanitize_filters().
22 *
23 * @return WP_REST_Response The list of transients.
24 */
25 public static function get_transients_list( $filters ) {
26
27 // Prepare variables
28 $transients_list = [];
29 $total_transients = 0;
30
31 $scan_counter = new ADBC_Scan_Counter();
32
33 $startRecord = ( $filters['current_page'] - 1 ) * $filters['items_per_page'];
34 $endRecord = $startRecord + $filters['items_per_page'];
35 $currentRecord = 0;
36
37 $limit = ADBC_Settings::instance()->get_setting( 'database_rows_batch' );
38 $offset = 0;
39
40 do { // Loop through all transients in batches of $limit to avoid memory issues
41
42 $transients = self::get_transients_list_batch( $filters, $limit, $offset );
43 $fetched_count = count( $transients );
44
45 if ( ADBC_VERSION_TYPE === 'PREMIUM' )
46 ADBC_Scan_Results::instance()->load_scan_results_to_items_rows( $transients, 'transients' ); // Load scan results to the transients rows
47 else
48 ADBC_Common_Model::load_scan_results_to_items_for_free_version( $transients ); // Load scan results to the transients rows for free version
49
50 ADBC_Hardcoded_Items::instance()->load_hardcoded_scan_results_to_items_rows( $transients, 'transients' ); // Load hardcoded items to the transients rows
51
52 foreach ( $transients as $index => $transient ) {
53
54 $scan_counter->refresh_categorization_count( $transient->belongs_to );
55
56 if ( ! ADBC_Common_Model::is_item_satisfies_belongs_to( $filters, $transient->belongs_to ) )
57 continue;
58
59 $total_transients++; // Count transients that satisfy all filters and belongs_to
60
61 // Only process the current batch if it's within the desired page range
62 if ( $currentRecord >= $startRecord && $currentRecord < $endRecord ) {
63
64 $transients_list[] = [
65 // This id is used to identify the option in the frontend and take actions on it
66 'composite_id' => [
67 'items_type' => 'transients',
68 'site_id' => (int) $transient->site_id,
69 'id' => (int) $transient->id,
70 'found_in' => $transient->found_in,
71 'name' => $transient->name,
72 ],
73 'id' => $transient->id,
74 'name' => $transient->name, // Used in the known addons modal & "show value modal". To be generic and work for all items types.
75 'transient_name' => $transient->name,
76 'value' => $transient->value,
77 'expired' => $transient->expired,
78 'timeout' => $transient->timeout,
79 'found_in' => $transient->found_in, // 'options' | 'sitemeta'
80 'size' => $transient->size,
81 'autoload' => $transient->autoload,
82 'site_id' => $transient->site_id,
83 'belongs_to' => $transient->belongs_to,
84 'known_plugins' => $transient->known_plugins,
85 'known_themes' => $transient->known_themes,
86 ];
87 }
88
89 $currentRecord++;
90 }
91
92 $offset += $limit;
93
94 } while ( $fetched_count == $limit ); // Continue if the last batch was full
95
96 // Loop over the $transients_list and $scan_counter add the plugins/themes names from the dictionary if they are empty
97 // This is because load_scan_results_to_rows() only loads the names of the plugins/themes that are currently installed
98 if ( ADBC_VERSION_TYPE === 'PREMIUM' )
99 ADBC_Dictionary::add_missing_addons_names_from_dictionary( $transients_list, $scan_counter, 'transients' );
100
101 // Calculate total number of pages to verify that the current page sent by the user is within the range
102 $total_real_pages = max( 1, ceil( $total_transients / $filters['items_per_page'] ) );
103
104 return ADBC_Rest::success( "", [
105 'items' => $transients_list,
106 'total_items' => $total_transients,
107 'real_current_page' => min( $filters['current_page'], $total_real_pages ),
108 'categorization_count' => $scan_counter->get_categorization_count(),
109 'plugins_count' => $scan_counter->get_plugins_count(),
110 'themes_count' => $scan_counter->get_themes_count(),
111 ] );
112 }
113
114 /**
115 * Get the transients list that satisfy the UI filters.
116 *
117 * @param array $filters Output of sanitize_filters().
118 * @param int $limit Limit for the number of rows to return.
119 * @param int $offset Offset for the number of rows to return.
120 *
121 * @return array List of transients that satisfy the filters.
122 */
123 private static function get_transients_list_batch( $filters, $limit, $offset ) {
124
125 global $wpdb;
126
127 $site_arg = $filters['site_id'];
128 $sort_col = $filters['sort_by'];
129 $sort_dir = $filters['sort_order'];
130
131 /* ──────────────────────────────────────────────────────────────
132 * Build a safe ORDER BY clause
133 * ─────────────────────────────────────────────────────────────*/
134 $allowed_columns = [
135 'transient_name' => '`name`',
136 'size' => '`size`',
137 'autoload' => '`autoload`',
138 'site_id' => '`site_id`',
139 'expired' => '`expired`',
140 'found_in' => '`found_in`',
141 ];
142
143 $sort_col = $filters['sort_by'] ?? '';
144 $sort_dir = strtoupper( $filters['sort_order'] ?? 'ASC' );
145 $sort_dir = $sort_dir === 'DESC' ? 'DESC' : 'ASC';
146
147 // Add 'order by' clause if the column is allowed.
148 $maybe_order_by = isset( $allowed_columns[ $sort_col ] )
149 ? "ORDER BY {$allowed_columns[ $sort_col ]} {$sort_dir}"
150 : '';
151
152 $branch_batch_size = $offset + $limit;
153
154 // ---- Build branches ---------------------------------------------
155 $branches = [];
156
157 $needs_collation_fix = ! ADBC_Database::is_collaction_unified( 'options', true, $site_arg );
158
159 foreach ( ADBC_Sites::instance()->get_sites_list( $site_arg ) as $site ) {
160
161 $branches = array_merge(
162 $branches,
163 self::build_branches_for_site(
164 $site['id'],
165 $filters, // pass whole arg set for WHERE glue
166 $maybe_order_by, // pass sorting column and order or null
167 $branch_batch_size, // pass limit for this branch
168 $needs_collation_fix
169 )
170 );
171
172 }
173
174 $union_sql = implode( "\nUNION ALL\n", $branches );
175
176 $sql = "
177 SELECT *
178 FROM ( {$union_sql} ) AS rows_merged
179 {$maybe_order_by}
180 LIMIT %d OFFSET %d
181 ";
182
183 $sql = $wpdb->prepare( $sql, $limit, $offset );
184
185 return $wpdb->get_results( $sql, OBJECT );
186
187 }
188
189 /**
190 * Returns the prepared SQL condition to filter transients based on search term.
191 * This is used to search for transients by name or value.
192 *
193 * @param array $filters The arguments for the size filter, including 'size' and 'size_unit'.
194 * @param array $template The sql template from which we get the table columns to search in (sitemeta, options)
195 *
196 * @return string The prepared SQL condition for the search filter, or an empty string if no no search term is provided.
197 */
198 private static function search_sql( $filters, $template ) {
199
200 global $wpdb;
201
202 $search_for = $filters['search_for'] ?? '';
203 $search_in = $filters['search_in'] ?? 'all';
204 $name_column = $template['name_col'];
205 $value_column = $template['value_col'];
206
207 if ( $search_for === '' ) {
208 return '';
209 }
210
211 $like = '%' . $wpdb->esc_like( $search_for ) . '%';
212
213 switch ( $search_in ) {
214 case 'name':
215 $search_sql = $wpdb->prepare( " AND {$name_column} LIKE %s", $like );
216 break;
217 case 'value':
218 $search_sql = $wpdb->prepare( " AND {$value_column} LIKE %s", $like );
219 break;
220 default:
221 $search_sql = $wpdb->prepare( " AND ( {$name_column} LIKE %s OR {$value_column} LIKE %s )", $like, $like );
222
223 }
224
225 return $search_sql;
226
227 }
228
229 /**
230 * Returns the prepared SQL condition to filter transients based on their size.
231 * This is used to filter transients by their total size across all columns.
232 *
233 * @param array $filters The arguments for the size filter, including 'size' and 'size_unit'.
234 * @param array $template The sql template from which we get the table columns (sitemeta, options) to calculate the size for.
235 *
236 * @return string The prepared SQL condition for the size filter, or an empty string if no size is specified.
237 */
238 private static function size_sql( $filters, $template ) {
239
240 global $wpdb;
241
242 $size = $filters['size'] ?? 0;
243 $size_unit = $filters['size_unit'] ?? 'B';
244
245 if ( $size === 0 ) {
246 return '';
247 }
248
249 $bytes = ADBC_Common_Utils::convert_size_to_bytes( $size, $size_unit );
250
251 // ——— build the expression with safe NULL-to-0 handling ———
252 if ( strpos( $template['name_col'], 'meta' ) !== false ) {
253 // sitemeta
254 $expr = '
255 COALESCE( LENGTH(a.meta_id ), 0 ) +
256 COALESCE( LENGTH(a.meta_key ), 0 ) +
257 COALESCE( LENGTH(a.meta_value), 0 )
258 ';
259 } else {
260 // options
261 $expr = '
262 COALESCE( LENGTH(a.option_id ), 0 ) +
263 COALESCE( LENGTH(a.option_name ), 0 ) +
264 COALESCE( LENGTH(a.option_value), 0 ) +
265 COALESCE( LENGTH(a.autoload ), 0 )
266 ';
267 }
268
269 // add the prepared comparison
270 return $wpdb->prepare( " AND ( {$expr} ) >= %d", $bytes );
271 }
272
273 /**
274 * Returns the SQL templates array to fetch transients depending on the current site.
275 *
276 * @return array The array of SQL templates to fetch transients for the current site, each item contains the 'sql', 'name_col' and 'val_col'
277 */
278 private static function get_sql_templates() {
279
280 global $wpdb;
281
282 $length = self::TRUNCATE_LENGTH;
283 $site_id = get_current_blog_id();
284
285 $templates = [
286 // Regular transients from options table
287 [
288 'sql' => "
289 SELECT a.option_name AS name,
290 a.option_id AS id,
291 SUBSTRING(a.option_value,1,$length) AS value,
292 b.option_value AS timeout,
293 {$site_id} AS site_id,
294 'options' AS found_in,
295 a.autoload AS autoload,
296 LENGTH(a.option_id) + LENGTH(a.option_name) + LENGTH(a.option_value) + LENGTH(a.autoload) AS size,
297 CASE
298 WHEN b.option_value IS NOT NULL AND b.option_value < UNIX_TIMESTAMP()
299 THEN 'yes'
300 ELSE 'no'
301 END AS expired
302 FROM {$wpdb->options} a
303 LEFT JOIN {$wpdb->options} b
304 ON b.option_name = CONCAT(
305 '_transient_timeout_',
306 SUBSTRING(a.option_name, CHAR_LENGTH('_transient_') + 1)
307 )
308 WHERE a.option_name LIKE '\_transient\_%'
309 AND a.option_name NOT LIKE '\_transient\_timeout\_%'
310 ",
311 'name_col' => 'a.option_name',
312 'value_col' => 'a.option_value',
313 ],
314 // Site transients from options table
315 [
316 'sql' => "
317 SELECT a.option_name AS name,
318 a.option_id AS id,
319 SUBSTRING(a.option_value,1,$length) AS value,
320 b.option_value AS timeout,
321 {$site_id} AS site_id,
322 'options' AS found_in,
323 a.autoload AS autoload,
324 LENGTH(a.option_id) + LENGTH(a.option_name) + LENGTH(a.option_value) + LENGTH(a.autoload) AS size,
325 CASE
326 WHEN b.option_value IS NOT NULL AND b.option_value < UNIX_TIMESTAMP()
327 THEN 'yes'
328 ELSE 'no'
329 END AS expired
330 FROM {$wpdb->options} a
331 LEFT JOIN {$wpdb->options} b
332 ON b.option_name = CONCAT(
333 '_site_transient_timeout_',
334 SUBSTRING(a.option_name, CHAR_LENGTH('_site_transient_') + 1)
335 )
336 WHERE a.option_name LIKE '\_site\_transient\_%'
337 AND a.option_name NOT LIKE '\_site\_transient\_timeout\_%'
338 ",
339 'name_col' => 'a.option_name',
340 'value_col' => 'a.option_value',
341 ],
342 ];
343
344 // Add sitemeta template for multisite main site
345 if ( is_multisite() && is_main_site( $site_id ) ) {
346 $templates[] = [
347 'sql' => "
348 SELECT a.meta_key AS name,
349 a.meta_id AS id,
350 SUBSTRING(a.meta_value,1,$length) AS value,
351 b.timeout_value AS timeout,
352 {$site_id} AS site_id,
353 'sitemeta' AS found_in,
354 'off' AS autoload,
355 LENGTH(a.meta_id) + LENGTH(a.meta_key) + LENGTH(a.meta_value) AS size,
356 CASE
357 WHEN b.timeout_value IS NOT NULL AND b.timeout_value < UNIX_TIMESTAMP()
358 THEN 'yes'
359 ELSE 'no'
360 END AS expired
361 FROM {$wpdb->sitemeta} a
362 LEFT JOIN (
363 SELECT meta_key,
364 MIN(CAST(meta_value AS UNSIGNED)) AS timeout_value
365 FROM {$wpdb->sitemeta}
366 WHERE meta_key LIKE '\_site\_transient\_timeout\_%'
367 GROUP BY meta_key
368 ) b
369 ON b.meta_key = CONCAT(
370 '_site_transient_timeout_',
371 SUBSTRING(a.meta_key, CHAR_LENGTH('_site_transient_') + 1)
372 )
373 WHERE a.meta_key LIKE '\_site\_transient\_%'
374 AND a.meta_key NOT LIKE '\_site\_transient\_timeout\_%'
375 ",
376 'name_col' => 'a.meta_key',
377 'value_col' => 'a.meta_value',
378 ];
379 }
380
381 return $templates;
382
383 }
384
385 /**
386 * Builds the prepared SQL branches for a specific site.
387 * This is used to fetch data from multiple sites in a multisite environment.
388 *
389 * @param int $site_id The site ID to build the branch for.
390 * @param array $filters The arguments for the query.
391 * @param string $maybe_order_by The ORDER BY clause if sorting is applied.
392 * @param int $sub_limit The limit to set for this branch.
393 *
394 * @return array An array containing the SQL branch for the specified site.
395 */
396 private static function build_branches_for_site( $site_id, $filters, $maybe_order_by, $sub_limit, $needs_collation_fix = false ) {
397
398 global $wpdb;
399
400 ADBC_Sites::instance()->switch_to_blog_id( $site_id );
401
402 $branches = [];
403
404 $collation = ! empty( $wpdb->collate ) ? $wpdb->collate : 'utf8mb4_unicode_ci';
405
406 foreach ( self::get_sql_templates() as $template ) {
407
408 $search = self::search_sql( $filters, $template );
409 $size = self::size_sql( $filters, $template );
410 $autoload = self::autoload_sql( $filters, $template );
411
412 // Build the inner query first
413 $inner_sql = "{$template['sql']}{$search}{$size}{$autoload}";
414
415 if ( $needs_collation_fix ) {
416 $inner_sql = "
417 SELECT
418 CONVERT(name USING utf8mb4) COLLATE {$collation} AS name,
419 id,
420 CONVERT(value USING utf8mb4) COLLATE {$collation} AS value,
421 CONVERT(timeout USING utf8mb4) COLLATE {$collation} AS timeout,
422 site_id,
423 CONVERT(found_in USING utf8mb4) COLLATE {$collation} AS found_in,
424 CONVERT(autoload USING utf8mb4) COLLATE {$collation} AS autoload,
425 size,
426 CONVERT(expired USING utf8mb4) COLLATE {$collation} AS expired
427 FROM ( {$inner_sql} ) AS t
428 ";
429 }
430
431 // Check if we need to filter by expired status
432 if ( $filters['expired'] !== 'all' ) {
433
434 // Wrap in subquery to filter on calculated expired column
435 $expired_filter = $filters['expired'] === 'yes' ? "expired = 'yes'" : "expired = 'no'";
436 $sql = "
437 SELECT * FROM ( {$inner_sql} ) AS inner_query
438 WHERE {$expired_filter}
439 {$maybe_order_by}
440 LIMIT %d
441 ";
442
443 } else {
444
445 $sql = "
446 {$inner_sql}
447 {$maybe_order_by}
448 LIMIT %d
449 ";
450
451 }
452
453 $branches[] = '(' . $wpdb->prepare( $sql, $sub_limit ) . ')';
454
455 }
456
457 ADBC_Sites::instance()->restore_blog();
458
459 return $branches;
460
461 }
462
463 /**
464 * Returns the prepared SQL condition to filter transients based on their autoload status.
465 * This is used to filter transients by their autoload status.
466 *
467 * @param array $filters The arguments for the autoload filter, including 'autoload'.
468 * @param array $template The sql template from which we get the table columns to get the autoload column from. (sitemeta, options)
469 *
470 * @return string The prepared SQL condition for the autoload filter, or an empty string if no autoload is specified.
471 */
472 private static function autoload_sql( $filters, $template ) {
473
474 $autoload_filter = $filters['autoload'] ?? 'all';
475
476 if ( $autoload_filter === 'all' ) {
477 return '';
478 }
479
480 // Determine the correct column name based on the template
481 $autoload_column = strpos( $template['name_col'], 'meta' ) !== false
482 ? "'off'" // sitemeta doesn't have autoload, so we use a literal
483 : 'a.autoload'; // options table uses a.autoload
484
485 switch ( $autoload_filter ) {
486 case 'yes':
487 return " AND {$autoload_column} IN ('yes','on','auto-on','auto')";
488 case 'no':
489 return " AND {$autoload_column} NOT IN ('yes','on','auto-on','auto')";
490 default:
491 return '';
492 }
493
494 }
495
496 /**
497 * Get transient names that still exist anywhere across the network from a provided list.
498 * This checks both options tables for all sites and the sitemeta table on the main site.
499 *
500 * @param array $transients_names List of transient option/meta keys to check for existence.
501 *
502 * @return array Existing names found across all sites.
503 */
504 public static function get_transients_names_that_exists_from_list( $transients_names ) {
505
506 global $wpdb;
507
508 if ( empty( $transients_names ) || ! is_array( $transients_names ) )
509 return [];
510
511 $branches = [];
512 $sites = ADBC_Sites::instance()->get_sites_list();
513 $in_placeholders = implode( ',', array_fill( 0, count( $transients_names ), '%s' ) );
514
515 foreach ( $sites as $site ) {
516 $prefix = $site['prefix'];
517 $options_table = $prefix . 'options';
518
519 // Options branch (regular and site transients that may live in options)
520 $sql_options = $wpdb->prepare(
521 "SELECT DISTINCT option_name AS name
522 FROM `{$options_table}`
523 WHERE option_name IN ( {$in_placeholders} )
524 AND option_name NOT LIKE %s
525 AND option_name NOT LIKE %s",
526 ...array_merge( $transients_names, [ '\_transient\_timeout\_%', '\_site\_transient\_timeout\_%' ] )
527 );
528 $branches[] = '(' . $sql_options . ')';
529
530 // Sitemeta branch only for multisite main site
531 $site_id = $site['id'];
532 if ( is_multisite() && $site_id !== null && is_main_site( $site_id ) ) {
533 $sitemeta_table = $prefix . 'sitemeta';
534 $sql_meta = $wpdb->prepare(
535 "SELECT DISTINCT meta_key AS name
536 FROM `{$sitemeta_table}`
537 WHERE meta_key IN ( {$in_placeholders} )
538 AND meta_key NOT LIKE %s",
539 ...array_merge( $transients_names, [ '\_site\_transient\_timeout\_%' ] )
540 );
541 $branches[] = '(' . $sql_meta . ')';
542 }
543 }
544
545 if ( empty( $branches ) )
546 return [];
547
548 $union_sql = implode( "\nUNION ALL\n", $branches );
549 $query = "SELECT DISTINCT name FROM ( {$union_sql} ) AS existing_names";
550
551 $existing_names = $wpdb->get_col( $query );
552
553 return array_values( array_unique( array_filter( (array) $existing_names ) ) );
554 }
555
556 /**
557 * Get the count of big transients in all sites.
558 *
559 * @return int Total count of big transients.
560 */
561 public static function count_big_transients() {
562
563 global $wpdb;
564
565 $threshold = self::BIG_TRANSIENTS_THRESHOLD_WARNING;
566
567 // LIKE patterns for filtering transients
568 $transient_like = $wpdb->esc_like( '_transient_' ) . '%';
569 $site_transient_like = $wpdb->esc_like( '_site_transient_' ) . '%';
570 $transient_timeout_like = $wpdb->esc_like( '_transient_timeout_' ) . '%';
571 $site_transient_timeout_like = $wpdb->esc_like( '_site_transient_timeout_' ) . '%';
572
573 $total_count = 0;
574
575 // Loop through all sites in multisite, or just current site in single site
576 $sites = ADBC_Sites::instance()->get_sites_list();
577
578 foreach ( $sites as $site ) {
579
580 $prefix = $site['prefix'];
581
582 // Options table count
583 $options_table = $prefix . 'options';
584
585 $sql_options = $wpdb->prepare(
586 "SELECT COUNT(*)
587 FROM `{$options_table}`
588 WHERE ( option_name LIKE %s OR option_name LIKE %s )
589 AND option_name NOT LIKE %s
590 AND option_name NOT LIKE %s
591 AND LENGTH(option_value) > %d",
592 $transient_like,
593 $site_transient_like,
594 $transient_timeout_like,
595 $site_transient_timeout_like,
596 $threshold
597 );
598
599 $total_count += (int) $wpdb->get_var( $sql_options );
600
601 // Sitemeta table count (only for multisite main site)
602 $site_id = $site['id'];
603
604 if ( is_multisite() && $site_id !== null && is_main_site( $site_id ) ) {
605 $sitemeta_table = $prefix . 'sitemeta';
606 $sql_sitemeta = $wpdb->prepare(
607 "SELECT COUNT(*)
608 FROM `{$sitemeta_table}`
609 WHERE meta_key LIKE %s
610 AND meta_key NOT LIKE %s
611 AND LENGTH(meta_value) > %d",
612 $site_transient_like,
613 $site_transient_timeout_like,
614 $threshold
615 );
616
617 $total_count += (int) $wpdb->get_var( $sql_sitemeta );
618
619 }
620
621 }
622
623 return $total_count;
624
625 }
626
627 /**
628 * Count the total number of transients that are not scanned.
629 *
630 * @return int Total not scanned transients.
631 */
632 public static function count_total_not_scanned_transients() {
633
634 $sites_prefixes = array_keys( ADBC_Sites::instance()->get_all_prefixes() );
635 $total_not_scanned = 0;
636 $limit = ADBC_Settings::instance()->get_setting( 'database_rows_batch' );
637
638 foreach ( $sites_prefixes as $site_prefix ) {
639
640 $offset = 0;
641
642 do { // Loop through all transients in batches of $limit to avoid memory issues
643
644 $transients = self::get_transients_names( $site_prefix, $limit, $offset, false );
645 $fetched_count = count( $transients );
646 $not_scanned_count = 0;
647
648 if ( ADBC_VERSION_TYPE === 'PREMIUM' )
649 $not_scanned_count = ADBC_Scan_Utils::count_not_scanned_items_in_list( 'transients', $transients );
650 else
651 $not_scanned_count = ADBC_Common_Model::count_not_scanned_items_in_list_for_free( 'transients', $transients );
652
653 $total_not_scanned += $not_scanned_count;
654
655 $offset += $limit;
656
657 } while ( $fetched_count == $limit ); // Continue if the last batch was full
658
659 }
660
661 return $total_not_scanned;
662
663 }
664
665 /**
666 * Get transients names for a specific site keyed by name
667 *
668 * @param string $site_prefix Site prefix.
669 * @param int $limit Limit.
670 * @param int $offset Offset.
671 * @param boolean $keyed wether or not to key the array by names
672 *
673 * @return array Associative transients names.
674 */
675 public static function get_transients_names( $site_prefix, $limit, $offset, $keyed = true ) {
676
677 global $wpdb;
678
679 // LIKE patterns
680 $transient_like = $wpdb->esc_like( '_transient_' ) . '%';
681 $site_transient_like = $wpdb->esc_like( '_site_transient_' ) . '%';
682 $transient_timeout_like = $wpdb->esc_like( '_transient_timeout_' ) . '%';
683 $site_transient_timeout_like = $wpdb->esc_like( '_site_transient_timeout_' ) . '%';
684
685 // Tables
686 $options_table = $site_prefix . 'options';
687
688 // Do we also have to union with sitemeta (multisite main site)?
689 $prefix_site_id = ADBC_Sites::instance()->get_site_id_from_prefix( $site_prefix );
690 $do_union = is_multisite() && $prefix_site_id !== null && is_main_site( $prefix_site_id );
691
692 if ( $do_union ) {
693
694 $sitemeta = $site_prefix . 'sitemeta';
695
696 $sql = "
697 SELECT name
698 FROM (
699 SELECT option_name AS name
700 FROM `{$options_table}`
701 WHERE ( option_name LIKE %s OR option_name LIKE %s )
702 AND option_name NOT LIKE %s
703 AND option_name NOT LIKE %s
704
705 UNION ALL
706
707 SELECT meta_key AS name
708 FROM `{$sitemeta}`
709 WHERE meta_key LIKE %s
710 AND meta_key NOT LIKE %s
711 ) AS all_transients
712 LIMIT %d OFFSET %d
713 ";
714
715 $query = $wpdb->prepare(
716 $sql,
717 $transient_like,
718 $site_transient_like,
719 $transient_timeout_like,
720 $site_transient_timeout_like,
721 $site_transient_like,
722 $site_transient_timeout_like,
723 $limit,
724 $offset
725 );
726
727 } else {
728 // Single-site (or non-main multisite blog) – only options table
729 $sql = "
730 SELECT option_name AS name
731 FROM `{$options_table}`
732 WHERE ( option_name LIKE %s OR option_name LIKE %s )
733 AND option_name NOT LIKE %s
734 AND option_name NOT LIKE %s
735 LIMIT %d OFFSET %d
736 ";
737
738 $query = $wpdb->prepare(
739 $sql,
740 $transient_like,
741 $site_transient_like,
742 $transient_timeout_like,
743 $site_transient_timeout_like,
744 $limit,
745 $offset
746 );
747 }
748
749 $names = $wpdb->get_col( $query );
750
751 if ( $keyed )
752 return array_fill_keys( $names, true );
753 else
754 return $names;
755
756 }
757
758 /**
759 * Get transients names from their id|table in a specific site prefix.
760 *
761 * @param string $site_prefix Site prefix of the transients.
762 * @param array $transients_map [ <int ID> => 'options'|'sitemeta', … ]
763 *
764 * @return array Associative transients names.
765 */
766 public static function get_transients_names_from_ids( $site_prefix, $transients_map ) {
767
768 global $wpdb;
769
770 if ( empty( $transients_map ) ) {
771 return [];
772 }
773
774 // LIKE patterns
775 $transient_like = $wpdb->esc_like( '_transient_' ) . '%';
776 $site_transient_like = $wpdb->esc_like( '_site_transient_' ) . '%';
777 $transient_timeout_like = $wpdb->esc_like( '_transient_timeout_' ) . '%';
778 $site_transient_timeout_like = $wpdb->esc_like( '_site_transient_timeout_' ) . '%';
779
780 // Partition incoming IDs by table
781 $option_ids = $sitemeta_ids = [];
782 foreach ( $transients_map as $transient_map ) {
783 // get the key and value of the array
784 $id = key( $transient_map );
785 $table = $transient_map[ $id ];
786 $table === 'options' ? $option_ids[] = (int) $id : $sitemeta_ids[] = (int) $id;
787 }
788
789 $transients_names = [];
790
791 // Fetch from options
792 if ( ! empty( $option_ids ) ) {
793
794 $options_table = $site_prefix . 'options';
795
796 $placeholders = implode( ',', array_fill( 0, count( $option_ids ), '%d' ) );
797
798 $sql = "
799 SELECT option_name AS name
800 FROM `{$options_table}`
801 WHERE option_id IN ( {$placeholders} )
802 AND ( option_name LIKE %s OR option_name LIKE %s )
803 AND option_name NOT LIKE %s
804 AND option_name NOT LIKE %s
805 "; // we add the second LIKE to avoid false positives
806 $args = array_merge( $option_ids,
807 [ $transient_like,
808 $site_transient_like,
809 $transient_timeout_like,
810 $site_transient_timeout_like
811 ]
812 );
813
814 $query = $wpdb->prepare( $sql, $args );
815
816 $transients_names = $wpdb->get_col( $query );
817
818 }
819
820 // Determine if we need to fetch from sitemeta
821 $prefix_site_id = ADBC_Sites::instance()->get_site_id_from_prefix( $site_prefix );
822 $get_from_sitemeta = ! empty( $sitemeta_ids ) && is_multisite() && $prefix_site_id !== null && is_main_site( $prefix_site_id );
823
824 // Fetch from wp_sitemeta (only site-wide transients)
825 if ( $get_from_sitemeta ) {
826
827 $sitemeta_table = $site_prefix . 'sitemeta';
828
829 $placeholders = implode( ',', array_fill( 0, count( $sitemeta_ids ), '%d' ) );
830
831 $sql = "
832 SELECT meta_key AS name
833 FROM `{$sitemeta_table}`
834 WHERE meta_id IN ( {$placeholders} )
835 AND meta_key LIKE %s
836 AND meta_key NOT LIKE %s
837 "; // we add the second LIKE to avoid false positives
838 $args = array_merge( $sitemeta_ids, [ $site_transient_like, $site_transient_timeout_like ] );
839
840 $query = $wpdb->prepare( $sql, $args );
841
842 $transients_names = array_merge( $transients_names, $wpdb->get_col( $query ) );
843
844 }
845
846 return array_fill_keys( $transients_names, true );
847
848 }
849
850 /**
851 * Delete transients.
852 *
853 * @param array $grouped_selected Selected transients to delete grouped by site ID.
854 * @return array An array of transients names that were not processed.
855 */
856 public static function delete_transients( $grouped_selected ) {
857
858 $cleanup_method = ADBC_Settings::instance()->get_setting( 'sql_or_native_cleanup_method' );
859
860 if ( $cleanup_method === 'native' ) {
861 return self::delete_transients_native( $grouped_selected );
862 }
863
864 return self::delete_transients_sql( $grouped_selected );
865
866 }
867
868 /**
869 * Deletes transients using the current WordPress native delete logic (unchanged).
870 *
871 * @param array $grouped_selected
872 * @return array Not processed transients names.
873 */
874 protected static function delete_transients_native( $grouped_selected ) {
875
876 $not_processed = [];
877
878 foreach ( $grouped_selected as $site_id => $group ) {
879
880 ADBC_Sites::instance()->switch_to_blog_id( $site_id );
881
882 foreach ( $group as $selected ) {
883
884 $full_name = $selected['name']; // full option / meta key
885 $found_in = $selected['found_in']; // 'options' | 'sitemeta'
886
887 // site_transient
888 if ( strpos( $full_name, '_site_transient_' ) === 0 ) {
889
890 $base_name = substr( $full_name, 16 ); // strip prefix
891
892 // Edge case where in multisite and site_transient is in options table
893 if ( is_multisite() && $found_in === 'options' ) {
894
895 if ( delete_option( $full_name ) ) {
896 delete_option( "_site_transient_timeout_$base_name" );
897 } else {
898 $not_processed[] = $selected['name'];
899 }
900
901 } else {
902
903 if ( ! delete_site_transient( $base_name ) ) {
904
905 // fallback to direct delete in sitemeta table
906 // This can happen if the transient have an invalid site ID in sitemeta
907 if ( $found_in === 'sitemeta' ) {
908
909 global $wpdb;
910
911 $timeout_key = "_site_transient_timeout_{$base_name}";
912
913 $sql = "DELETE FROM {$wpdb->sitemeta} WHERE meta_key IN ( %s, %s )";
914
915 $deleted_rows = $wpdb->query(
916 $wpdb->prepare( $sql, $full_name, $timeout_key )
917 );
918
919 if ( $deleted_rows !== false && $deleted_rows === 0 ) {
920 $not_processed[] = $selected['name']; // delete using direct query failed
921 }
922
923 } else { // delete using native wordpress function failed
924 $not_processed[] = $selected['name'];
925 }
926
927 }
928
929 }
930
931 continue;
932
933 }
934
935 // transient
936 if ( strpos( $full_name, '_transient_' ) === 0 ) {
937
938 $base_name = substr( $full_name, 11 );
939 if ( ! delete_transient( $base_name ) ) {
940 $not_processed[] = $selected['name'];
941 }
942
943 }
944
945 }
946
947 ADBC_Sites::instance()->restore_blog();
948
949 }
950
951 return $not_processed;
952
953 }
954
955 /**
956 * Deletes transients using direct SQL (bulk) for each site.
957 *
958 * @param array $grouped_selected
959 * @return array Not processed transients names.
960 */
961 protected static function delete_transients_sql( $grouped_selected ) {
962
963 global $wpdb;
964
965 $not_processed = [];
966
967 foreach ( $grouped_selected as $site_id => $group ) {
968
969 $option_keys = [];
970 $sitemeta_keys = [];
971
972 foreach ( $group as $selected ) {
973
974 $full_name = $selected['name']; // full option / meta key
975 $found_in = $selected['found_in']; // 'options' | 'sitemeta'
976
977 // site_transient
978 if ( strpos( $full_name, '_site_transient_' ) === 0 ) {
979
980 $base_name = substr( $full_name, 16 );
981 $timeout_key = "_site_transient_timeout_{$base_name}";
982
983 if ( is_multisite() && $found_in === 'sitemeta' ) {
984 $sitemeta_keys[] = $full_name;
985 $sitemeta_keys[] = $timeout_key;
986 } else {
987 $option_keys[] = $full_name;
988 $option_keys[] = $timeout_key;
989 }
990
991 continue;
992
993 }
994
995 // transient
996 if ( strpos( $full_name, '_transient_' ) === 0 ) {
997
998 $base_name = substr( $full_name, 11 );
999 $timeout_key = "_transient_timeout_{$base_name}";
1000
1001 $option_keys[] = $full_name;
1002 $option_keys[] = $timeout_key;
1003
1004 }
1005
1006 }
1007
1008 ADBC_Sites::instance()->switch_to_blog_id( $site_id );
1009
1010 // Bulk delete from options.
1011 if ( ! empty( $option_keys ) ) {
1012
1013 $placeholders = implode( ',', array_fill( 0, count( $option_keys ), '%s' ) );
1014
1015 $sql = "DELETE FROM {$wpdb->options} WHERE option_name IN ($placeholders)";
1016 $sql = $wpdb->prepare( $sql, ...$option_keys );
1017 $wpdb->query( $sql );
1018
1019 $check_sql = "SELECT option_name FROM {$wpdb->options} WHERE option_name IN ($placeholders)";
1020 $check_sql = $wpdb->prepare( $check_sql, ...$option_keys );
1021
1022 $remaining = $wpdb->get_col( $check_sql );
1023
1024 if ( ! empty( $remaining ) )
1025 foreach ( $remaining as $name )
1026 $not_processed[] = $name;
1027
1028 }
1029
1030 // Bulk delete from sitemeta.
1031 if ( ! empty( $sitemeta_keys ) ) {
1032
1033 $placeholders = implode( ',', array_fill( 0, count( $sitemeta_keys ), '%s' ) );
1034
1035 $sql = "DELETE FROM {$wpdb->sitemeta} WHERE meta_key IN ($placeholders)";
1036 $sql = $wpdb->prepare( $sql, ...$sitemeta_keys );
1037 $wpdb->query( $sql );
1038
1039 $check_sql = "SELECT meta_key FROM {$wpdb->sitemeta} WHERE meta_key IN ($placeholders)";
1040 $check_sql = $wpdb->prepare( $check_sql, ...$sitemeta_keys );
1041
1042 $remaining = $wpdb->get_col( $check_sql );
1043
1044 if ( ! empty( $remaining ) )
1045 foreach ( $remaining as $name )
1046 $not_processed[] = $name;
1047
1048 }
1049
1050 ADBC_Sites::instance()->restore_blog();
1051
1052 }
1053
1054 return $not_processed;
1055
1056 }
1057
1058 /**
1059 * Set autoload to "no" for grouped transients. Transients are grouped by site ID as key.
1060 *
1061 * @param array $grouped_selected Grouped selected transients to set autoload to "no".
1062 *
1063 * @return array An array of transients names that were not processed.
1064 */
1065 public static function set_autoload_to_no( $grouped_selected ) {
1066
1067 $autoload_value = function_exists( 'wp_autoload_values_to_autoload' ) ? 'off' : 'no';
1068 $not_processed = self::set_autoload( $grouped_selected, $autoload_value );
1069 return $not_processed;
1070
1071 }
1072
1073 /**
1074 * Set autoload to "yes" for grouped transients. Transients are grouped by site ID as key.
1075 *
1076 * @param array $grouped_selected Grouped selected transients to set autoload to "yes".
1077 *
1078 * @return array An array of transients names that were not processed.
1079 */
1080 public static function set_autoload_to_yes( $grouped_selected ) {
1081
1082 $autoload_value = function_exists( 'wp_autoload_values_to_autoload' ) ? 'on' : 'yes';
1083 $not_processed = self::set_autoload( $grouped_selected, $autoload_value );
1084 return $not_processed;
1085
1086 }
1087
1088 /**
1089 * Change autoload value for transients. Transients are grouped by site ID as key.
1090 *
1091 * @param array $grouped_selected Grouped selected transients to set autoload for.
1092 * @param string $autoload_value The value to set for autoload, typically "yes"/"no" (or "on"/"off" for WP 6.6.0 and above).
1093 *
1094 * @return array Normally should return an array of transients names that were not processed, but in this case it returns an empty array.
1095 */
1096 private static function set_autoload( $grouped_selected, $autoload_value ) {
1097
1098 global $wpdb;
1099
1100 foreach ( $grouped_selected as $site_id => $group ) {
1101
1102 if ( empty( $group ) )
1103 continue;
1104
1105 // get only ids where found_in !== "sitemeta" because sitemeta transients doesn't have autoload
1106 $ids = array_column(
1107 array_filter( $group, function ($item) {
1108 return $item['found_in'] !== 'sitemeta';
1109 } ),
1110 'id'
1111 );
1112
1113 if ( empty( $ids ) )
1114 continue;
1115
1116 $ids_placeholder = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
1117
1118 ADBC_Sites::instance()->switch_to_blog_id( $site_id );
1119
1120 $sql = $wpdb->prepare(
1121 "UPDATE {$wpdb->options}
1122 SET autoload = %s
1123 WHERE option_id IN ( $ids_placeholder )",
1124 ...array_merge( [ $autoload_value ], $ids )
1125 );
1126
1127 $wpdb->query( $sql );
1128
1129 // Clear the object cache so WordPress picks up the new autoload flags
1130 wp_cache_delete( 'alloptions', 'options' );
1131
1132 ADBC_Sites::instance()->restore_blog();
1133 }
1134
1135 return [];
1136
1137 }
1138
1139 /**
1140 * Get the total number of transients across all sites.
1141 *
1142 * @return int Total number of transients.
1143 */
1144 public static function get_total_transients_count() {
1145
1146 global $wpdb;
1147
1148 $total_transients = 0;
1149
1150 $sites = ADBC_Sites::instance()->get_sites_list();
1151
1152 // LIKE patterns for filtering transients
1153 $transient_like = $wpdb->esc_like( '_transient_' ) . '%';
1154 $site_transient_like = $wpdb->esc_like( '_site_transient_' ) . '%';
1155 $transient_timeout_like = $wpdb->esc_like( '_transient_timeout_' ) . '%';
1156 $site_transient_timeout_like = $wpdb->esc_like( '_site_transient_timeout_' ) . '%';
1157
1158 foreach ( $sites as $site ) {
1159
1160 $prefix = $site['prefix'];
1161 $options_table = $prefix . 'options';
1162
1163 // Count transients stored in options (regular and site transients that may live in options)
1164 $sql_options = $wpdb->prepare(
1165 "SELECT COUNT(*)
1166 FROM `{$options_table}`
1167 WHERE ( option_name LIKE %s OR option_name LIKE %s )
1168 AND option_name NOT LIKE %s
1169 AND option_name NOT LIKE %s",
1170 $transient_like,
1171 $site_transient_like,
1172 $transient_timeout_like,
1173 $site_transient_timeout_like
1174 );
1175
1176 $total_transients += (int) $wpdb->get_var( $sql_options );
1177
1178 // Count site-wide transients stored in sitemeta (only for multisite main site)
1179 $site_id = $site['id'];
1180 if ( is_multisite() && $site_id !== null && is_main_site( $site_id ) ) {
1181 $sitemeta_table = $prefix . 'sitemeta';
1182 $sql_sitemeta = $wpdb->prepare(
1183 "SELECT COUNT(*)
1184 FROM `{$sitemeta_table}`
1185 WHERE meta_key LIKE %s
1186 AND meta_key NOT LIKE %s",
1187 $site_transient_like,
1188 $site_transient_timeout_like
1189 );
1190
1191 $total_transients += (int) $wpdb->get_var( $sql_sitemeta );
1192 }
1193 }
1194
1195 return $total_transients;
1196
1197 }
1198
1199 }