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-options.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-options.php
920 lines
1 <?php
2
3 // Exit if accessed directly
4 if ( ! defined( 'ABSPATH' ) )
5 exit;
6
7 /**
8 * ADBC options class.
9 *
10 * This class provides the options functions.
11 */
12 class ADBC_Options {
13
14 private const BIG_OPTION_THRESHOLD_WARNING = 150 * 1024; // 150 KB. (If you change this value, change it as well in js filter message and slice)
15 private const TRUNCATE_LENGTH = 20; // Length to truncate the option value for display
16
17 /**
18 * Get the options list for the endpoint.
19 *
20 * @param array $filters Output of sanitize_filters().
21 *
22 * @return WP_REST_Response The list of options.
23 */
24 public static function get_options_list( $filters ) {
25
26 // Prepare variables
27 $options_list = [];
28 $total_options = 0;
29
30 $scan_counter = new ADBC_Scan_Counter();
31
32 $startRecord = ( $filters['current_page'] - 1 ) * $filters['items_per_page'];
33 $endRecord = $startRecord + $filters['items_per_page'];
34 $currentRecord = 0;
35
36 $limit = ADBC_Settings::instance()->get_setting( 'database_rows_batch' );
37 $offset = 0;
38
39 do { // Loop through all options in batches of $limit to avoid memory issues
40
41 $options = self::get_options_list_batch( $filters, $limit, $offset );
42 $fetched_count = count( $options );
43
44 if ( ADBC_VERSION_TYPE === 'PREMIUM' )
45 ADBC_Scan_Results::instance()->load_scan_results_to_items_rows( $options, 'options' ); // Load scan results to the options rows
46 else
47 ADBC_Common_Model::load_scan_results_to_items_for_free_version( $options ); // Load scan results to the options rows for free version
48
49 ADBC_Hardcoded_Items::instance()->load_hardcoded_scan_results_to_items_rows( $options, 'options' ); // Load hardcoded items to the options rows
50
51 foreach ( $options as $index => $option ) {
52
53 $scan_counter->refresh_categorization_count( $option->belongs_to );
54
55 if ( ! ADBC_Common_Model::is_item_satisfies_belongs_to( $filters, $option->belongs_to ) )
56 continue;
57
58 $total_options++; // Count options that satisfy all filters and belongs_to
59
60 // Only process the current batch if it's within the desired page range
61 if ( $currentRecord >= $startRecord && $currentRecord < $endRecord ) {
62
63 $options_list[] = [
64 // This id is used to identify the option in the frontend and take actions on it
65 'composite_id' => [
66 'items_type' => 'options',
67 'site_id' => (int) $option->site_id,
68 'id' => (int) $option->option_id,
69 'name' => $option->name,
70 ],
71 'id' => $option->option_id,
72 'name' => $option->name, // Used in the known addons modal & "show value modal". To be generic and work for all items types.
73 'option_name' => $option->name,
74 'value' => $option->value,
75 'size' => $option->size,
76 'autoload' => $option->autoload,
77 'site_id' => $option->site_id,
78 'belongs_to' => $option->belongs_to,
79 'known_plugins' => $option->known_plugins,
80 'known_themes' => $option->known_themes,
81 ];
82 }
83
84 $currentRecord++;
85 }
86
87 $offset += $limit;
88
89 } while ( $fetched_count == $limit ); // Continue if the last batch was full
90
91 // Loop over the $options_list and $scan_counter add the plugins/themes names from the dictionary if they are empty
92 // This is because load_scan_results_to_rows() only loads the names of the plugins/themes that are currently installed
93 if ( ADBC_VERSION_TYPE === 'PREMIUM' )
94 ADBC_Dictionary::add_missing_addons_names_from_dictionary( $options_list, $scan_counter, 'options' );
95
96 // Calculate total number of pages to verify that the current page sent by the user is within the range
97 $total_real_pages = max( 1, ceil( $total_options / $filters['items_per_page'] ) );
98
99 return ADBC_Rest::success( "", [
100 'items' => $options_list,
101 'total_items' => $total_options,
102 'real_current_page' => min( $filters['current_page'], $total_real_pages ),
103 'categorization_count' => $scan_counter->get_categorization_count(),
104 'plugins_count' => $scan_counter->get_plugins_count(),
105 'themes_count' => $scan_counter->get_themes_count(),
106 ] );
107 }
108
109 /**
110 * Get the options list that satisfy the UI filters.
111 *
112 * @param array $filters Output of sanitize_filters().
113 * @param int $limit Limit for the number of rows to return.
114 * @param int $offset Offset for the number of rows to return.
115 *
116 * @return array List of options that satisfy the filters.
117 */
118 private static function get_options_list_batch( $filters, $limit, $offset ) {
119
120 global $wpdb;
121 $sites_list = ADBC_Sites::instance()->get_sites_list( $filters['site_id'] );
122 $is_single_site_query = ( count( $sites_list ) === 1 );
123
124 /* ──────────────────────────────────────────────────────────────
125 * Build a safe ORDER BY clause
126 * ─────────────────────────────────────────────────────────────*/
127 $allowed_columns = [
128 'option_name' => '`name`',
129 'size' => '`size`',
130 'autoload' => '`autoload`',
131 'site_id' => '`site_id`',
132 ];
133
134 $sort_col = $filters['sort_by'] ?? '';
135 $sort_dir = strtoupper( $filters['sort_order'] ?? 'ASC' );
136 $sort_dir = ( $sort_dir === 'DESC' ) ? 'DESC' : 'ASC';
137
138 // Add 'order by' clause if the column is allowed.
139 $order_by_sql = isset( $allowed_columns[ $sort_col ] )
140 ? "ORDER BY {$allowed_columns[ $sort_col ]} {$sort_dir}"
141 : '';
142
143 /* ──────────────────────────────────────────────────────────────
144 * Single-site path (no UNION, no derived table)
145 * ─────────────────────────────────────────────────────────────*/
146 if ( $is_single_site_query ) {
147
148 $site = reset( $sites_list );
149 $table_name = $site['prefix'] . 'options';
150 $site_id = $site['id'];
151
152 $sql = self::prepare_options_list_sql_for_single_site(
153 $site_id,
154 $table_name,
155 $filters,
156 $order_by_sql,
157 $limit,
158 $offset
159 );
160
161 return $wpdb->get_results( $sql, OBJECT );
162 }
163
164 /* ──────────────────────────────────────────────────────────────
165 * Multisite path (UNION across all sites)
166 * ─────────────────────────────────────────────────────────────*/
167 $union_queries = [];
168
169 // Offset starts from 0, so we need to add the limit to it to increment the number of rows to fetch in each iteration.
170 $total_rows_to_fetch = $offset + $limit;
171 foreach ( $sites_list as $site ) {
172 $table_name = $site['prefix'] . 'options'; // Get the options table name for the current site
173 $site_id = $site['id']; // Get the site ID for the current site
174 $union_queries[] = self::prepare_options_list_sql_for_union(
175 $site_id,
176 $table_name,
177 $filters,
178 $order_by_sql,
179 $total_rows_to_fetch
180 );
181 }
182
183 $union_sql = implode( "\nUNION ALL\n", $union_queries );
184
185 $sql = $wpdb->prepare(
186 "SELECT *
187 FROM ( {$union_sql} ) AS rows_merged
188 {$order_by_sql}
189 LIMIT %d OFFSET %d
190 ",
191 $limit,
192 $offset
193 );
194
195 return $wpdb->get_results( $sql, OBJECT );
196 }
197
198 /**
199 * Prepare a SQL query string to get the options list for a single site
200 * (no UNION, no derived table).
201 *
202 * @param int $site_id Site ID to query.
203 * @param string $table_name Options table name to query.
204 * @param array $filters Output of sanitize_filters().
205 * @param string $order_by_sql SQL query clause to order the results.
206 * @param int $limit Limit for the number of rows to return.
207 * @param int $offset Offset for the number of rows to return.
208 *
209 * @return string SQL query to get the options list for a single site.
210 */
211 private static function prepare_options_list_sql_for_single_site( $site_id, $table_name, $filters, $order_by_sql, $limit, $offset ) {
212
213 global $wpdb;
214
215 $truncate_length = self::TRUNCATE_LENGTH;
216 $autoloaded_values = self::get_values_to_autoload();
217
218 $params = [ absint( $site_id ) ]; // %d for site_id in SELECT
219
220 /* ──────────────────────────────────────────────────────────────
221 * Assemble the dynamic WHERE parts
222 * ─────────────────────────────────────────────────────────────*/
223 $where = [
224 '`option_name` NOT LIKE %s', // skip transients
225 '`option_name` NOT LIKE %s', // skip site transients
226 ];
227
228 $params[] = '\_transient\_%';
229 $params[] = '\_site\_transient\_%';
230
231 /* — Autoload filter — */
232 if ( isset( $filters['autoload'] ) && $filters['autoload'] !== 'all' && ! empty( $autoloaded_values ) ) {
233
234 $placeholders = implode( ',', array_fill( 0, count( $autoloaded_values ), '%s' ) );
235
236 if ( $filters['autoload'] === 'yes' ) {
237 $where[] = "`autoload` IN ($placeholders)";
238 } else {
239 $where[] = "`autoload` NOT IN ($placeholders)";
240 }
241
242 $params = array_merge( $params, $autoloaded_values );
243 }
244
245 /* — Size ≥ threshold — */
246 if ( ! empty( $filters['size'] ) && (int) $filters['size'] > 0 ) {
247 $bytes = ADBC_Common_Utils::convert_size_to_bytes(
248 $filters['size'],
249 $filters['size_unit']
250 );
251 $where[] = 'OCTET_LENGTH(`option_value`) >= %d';
252 $params[] = $bytes;
253 }
254
255 /* — Search filter — */
256 if ( ! empty( $filters['search_for'] ) && ! empty( $filters['search_in'] ) ) {
257
258 $needle = '%' . $wpdb->esc_like( $filters['search_for'] ) . '%';
259
260 switch ( $filters['search_in'] ) {
261 case 'name':
262 $where[] = '`option_name` LIKE %s';
263 $params[] = $needle;
264 break;
265
266 case 'value':
267 $where[] = '`option_value` LIKE %s';
268 $params[] = $needle;
269 break;
270
271 case 'all':
272 $where[] = '(`option_name` LIKE %s OR `option_value` LIKE %s)';
273 $params[] = $needle; // for option_name
274 $params[] = $needle; // for option_value
275 break;
276 }
277 }
278
279 $where_sql = 'WHERE ' . implode( ' AND ', $where );
280
281 // Add limit & offset at the end of the params array
282 $params[] = absint( $limit );
283 $params[] = absint( $offset );
284
285 /* ──────────────────────────────────────────────────────────────
286 * Final SQL
287 * ─────────────────────────────────────────────────────────────*/
288 $sql = $wpdb->prepare(
289 "SELECT
290 `option_name` AS name,
291 `option_id` AS option_id,
292 SUBSTRING(`option_value`, 1, {$truncate_length}) AS value,
293 `autoload` AS autoload,
294 OCTET_LENGTH(`option_value`) AS size,
295 %d AS site_id
296 FROM {$table_name}
297 {$where_sql}
298 {$order_by_sql}
299 LIMIT %d OFFSET %d
300 ",
301 ...$params
302 );
303
304 return $sql;
305 }
306
307
308 /**
309 * Prepare a SQL query string to get the options list that satisfy the UI filters. It will be used in a UNION query to get all options in all sites.
310 *
311 * @param int $site_id Site ID to query.
312 * @param string $table_name Options table name to query.
313 * @param array $filters Output of sanitize_filters().
314 * @param string $order_by_sql SQL query clause to order the results.
315 * @param int $total_rows_to_fetch Limit for the number of rows to return.
316 *
317 * @return string SQL query to get the options list.
318 */
319 private static function prepare_options_list_sql_for_union( $site_id, $table_name, $filters, $order_by_sql, $total_rows_to_fetch ) {
320
321 global $wpdb;
322 $truncate_length = self::TRUNCATE_LENGTH;
323 $autoloaded_values = self::get_values_to_autoload();
324 $params = [ absint( $site_id ) ]; // Place the site_id at the beginning of the params array
325
326 $needs_fix = ! ADBC_Database::is_collaction_unified( 'options', false, $filters['site_id'] );
327
328 $target_collation = ! empty( $wpdb->collate ) ? $wpdb->collate : 'utf8mb4_unicode_ci';
329
330 if ( $needs_fix ) {
331 $name_expr = "CONVERT(`option_name` USING utf8mb4) COLLATE {$target_collation}";
332 $value_expr = "CONVERT(SUBSTRING(`option_value`, 1, {$truncate_length}) USING utf8mb4) COLLATE {$target_collation}";
333 $autoload_expr = "CONVERT(`autoload` USING utf8mb4) COLLATE {$target_collation}";
334 } else {
335 $name_expr = "`option_name`";
336 $value_expr = "SUBSTRING(`option_value`, 1, {$truncate_length})";
337 $autoload_expr = "`autoload`";
338 }
339
340 /* ──────────────────────────────────────────────────────────────
341 * Assemble the dynamic WHERE parts
342 * ─────────────────────────────────────────────────────────────*/
343 $where = [
344 '`option_name` NOT LIKE %s', // skip site transients
345 '`option_name` NOT LIKE %s',
346 ];
347
348 $params[] = '\_transient\_%';
349 $params[] = '\_site\_transient\_%';
350
351 /* — Autoload filter — */
352 if ( isset( $filters['autoload'] ) && $filters['autoload'] !== 'all' ) {
353 $placeholders = implode( ',', array_fill( 0, count( $autoloaded_values ), '%s' ) );
354
355 if ( $filters['autoload'] === 'yes' ) {
356 $where[] = "`autoload` IN ($placeholders)";
357 } else {
358 $where[] = "`autoload` NOT IN ($placeholders)";
359 }
360
361 $params = array_merge( $params, $autoloaded_values );
362 }
363
364 /* — Size ≥ threshold — */
365 if ( ! empty( $filters['size'] ) && (int) $filters['size'] > 0 ) {
366 $bytes = ADBC_Common_Utils::convert_size_to_bytes(
367 $filters['size'],
368 $filters['size_unit']
369 );
370 $where[] = 'OCTET_LENGTH(`option_value`) >= %d';
371 $params[] = $bytes;
372 }
373
374 /* — Search filter — */
375 if ( ! empty( $filters['search_for'] ) && ! empty( $filters['search_in'] ) ) {
376
377 $needle = '%' . $wpdb->esc_like( $filters['search_for'] ) . '%';
378
379 switch ( $filters['search_in'] ) {
380 case 'name':
381 $where[] = '`option_name` LIKE %s';
382 $params[] = $needle;
383 break;
384
385 case 'value':
386 $where[] = '`option_value` LIKE %s';
387 $params[] = $needle;
388 break;
389
390 case 'all':
391 // Search in both columns
392 $where[] = '(`option_name` LIKE %s OR `option_value` LIKE %s)';
393 $params[] = $needle; // for option_name
394 $params[] = $needle; // for option_value
395 break;
396 }
397 }
398
399 $where_sql = 'WHERE ' . implode( ' AND ', $where );
400 $params[] = absint( $total_rows_to_fetch ); // Add the limit to the params array
401
402 /* ──────────────────────────────────────────────────────────────
403 * 3. Final SQL and fetch
404 * ─────────────────────────────────────────────────────────────*/
405 $sql = $wpdb->prepare(
406 "SELECT
407 {$name_expr} AS name,
408 `option_id` AS option_id,
409 {$value_expr} AS value,
410 {$autoload_expr} AS autoload,
411 OCTET_LENGTH(`option_value`) AS size,
412 %d AS site_id
413 FROM {$table_name}
414 {$where_sql}
415 {$order_by_sql}
416 LIMIT %d
417 ",
418 ...$params
419 );
420
421 return '(' . $sql . ')';
422 }
423
424 /**
425 * Count the size of all autoloaded options in the wp_options table.
426 *
427 * @return array Array with autoloaded size and health status.
428 */
429 public static function count_autoload_size_using_sql() {
430
431 global $wpdb;
432 $autoload_values = self::get_values_to_autoload();
433
434 // Build "%s,%s,%s" for $wpdb->prepare()
435 $in_placeholders = implode( ',', array_fill( 0, count( $autoload_values ), '%s' ) );
436
437 $sql = $wpdb->prepare(
438 "SELECT
439 COALESCE(
440 SUM(
441 CASE WHEN autoload IN ($in_placeholders)
442 THEN LENGTH(option_value)
443 END
444 ),
445 0
446 )
447 FROM {$wpdb->options}
448 ",
449 ...$autoload_values
450 );
451
452 $autoloaded_size = (int) $wpdb->get_var( $sql );
453 $autoload_limit_in_bytes = self::get_autoload_warning_limit();
454 $autoload_health = $autoloaded_size > $autoload_limit_in_bytes ? 'bad' : 'good';
455
456 return [
457 'autoloaded_size' => ADBC_Common_Utils::format_bytes( $autoloaded_size ),
458 'autoload_health' => $autoload_health,
459 'autoload_limit' => ADBC_Common_Utils::format_bytes( $autoload_limit_in_bytes ),
460 ];
461 }
462
463 /**
464 * Get the autoload warning limit from WordPress filter or default value.
465 * This warning limit is used to determine if the autoloaded options size is healthy or not.
466 *
467 * @return int Autoload warning limit in bytes.
468 */
469 public static function get_autoload_warning_limit() {
470
471 // Get the autoload limit from WordPress filter (allows customization via site_status_autoloaded_options_size_limit)
472 // This filter was introduced in WordPress 6.6.0, so we check version for backward compatibility
473 $default_limit = 800000; // 800 KB - WordPress default recommendation
474 global $wp_version;
475 if ( version_compare( $wp_version, '6.6.0', '>=' ) ) {
476 // WordPress 6.6.0+ - use the filter (allows customization)
477 $autoload_limit_bytes = apply_filters( 'site_status_autoloaded_options_size_limit', $default_limit );
478 } else {
479 // WordPress < 6.6.0 - use default limit (filter doesn't exist yet)
480 $autoload_limit_bytes = $default_limit;
481 }
482
483 return $autoload_limit_bytes;
484 }
485
486 /**
487 * Get the count of big options in all sites.
488 *
489 * @return int Total count of big options.
490 */
491 public static function count_big_options() {
492
493 global $wpdb;
494 $total_big_options = 0;
495
496 $sites_prefixes = array_keys( ADBC_Sites::instance()->get_all_prefixes() );
497
498 foreach ( $sites_prefixes as $site_prefix ) {
499
500 $table = $site_prefix . "options";
501
502 $count = (int) $wpdb->get_var(
503 $wpdb->prepare(
504 "SELECT COUNT(*)
505 FROM `$table`
506 WHERE option_name NOT LIKE %s
507 AND option_name NOT LIKE %s
508 AND OCTET_LENGTH(option_value) > %d",
509 '\_transient\_%',
510 '\_site\_transient\_%',
511 self::BIG_OPTION_THRESHOLD_WARNING
512 )
513 );
514
515 $total_big_options += $count;
516 }
517
518 return $total_big_options;
519 }
520
521 /**
522 * Count the total number of options that are not scanned.
523 *
524 * @return int Total not scanned options.
525 */
526 public static function count_total_not_scanned_options() {
527
528 $sites_prefixes = array_keys( ADBC_Sites::instance()->get_all_prefixes() );
529 $total_not_scanned = 0;
530 $limit = ADBC_Settings::instance()->get_setting( 'database_rows_batch' );
531
532 foreach ( $sites_prefixes as $site_prefix ) {
533
534 $offset = 0;
535
536 do { // Loop through all options in batches of $limit to avoid memory issues
537
538 $options = self::get_options_names( $site_prefix, $limit, $offset, false );
539 $fetched_count = count( $options );
540 $not_scanned_count = 0;
541
542 if ( ADBC_VERSION_TYPE === 'PREMIUM' )
543 $not_scanned_count = ADBC_Scan_Utils::count_not_scanned_items_in_list( "options", $options );
544 else
545 $not_scanned_count = ADBC_Common_Model::count_not_scanned_items_in_list_for_free( "options", $options );
546
547 $total_not_scanned += $not_scanned_count;
548
549 $offset += $limit;
550
551 } while ( $fetched_count == $limit ); // Continue if the last batch was full
552
553 }
554
555 return $total_not_scanned;
556
557 }
558
559 /**
560 * Return an array with values of autoload that should be autoloaded
561 *
562 * @return array Autoload values. E.g. [ "yes", "on"... ]
563 */
564 public static function get_values_to_autoload() {
565
566 $autoload_values = [ "yes" ]; // Default value is "yes" prior to WP 6.6.0
567 if ( function_exists( 'wp_autoload_values_to_autoload' ) )
568 $autoload_values = wp_autoload_values_to_autoload(); // WP 6.6.0 and above have this function to get values that should be autoloaded
569
570 return $autoload_values;
571 }
572
573 /**
574 * Get total of all options in wp_options table, excluding transients.
575 *
576 * @return int Options count.
577 */
578 public static function get_total_options_count() {
579
580 global $wpdb;
581
582 $total_options = 0;
583
584 $sites_list = ADBC_Sites::instance()->get_sites_list();
585
586 foreach ( $sites_list as $site ) {
587
588 $table_name = $site['prefix'] . 'options';
589
590 $count = (int) $wpdb->get_var(
591 $wpdb->prepare(
592 "SELECT COUNT(*)
593 FROM {$table_name}
594 WHERE option_name NOT LIKE %s AND option_name NOT LIKE %s",
595 '\_transient\_%', '\_site\_transient\_%'
596 )
597 );
598
599 $total_options += $count;
600
601 }
602
603 return $total_options;
604
605 }
606
607 /**
608 * Get options names for a specific site excluding transients.
609 *
610 * @param string $site_prefix Site prefix.
611 * @param int $limit Limit.
612 * @param int $offset Offset.
613 * @param boolean $keyed wether or not to key the array by names
614 *
615 * @return array Associative options names.
616 */
617 public static function get_options_names( $site_prefix, $limit, $offset, $keyed = true ) {
618
619 global $wpdb;
620 $table = $site_prefix . 'options'; // $site_prefix is safe to use here as it is validated in the calling function.
621
622 $query = $wpdb->prepare(
623 "SELECT option_name FROM `$table`
624 WHERE option_name NOT LIKE %s
625 AND option_name NOT LIKE %s
626 LIMIT %d OFFSET %d",
627 '\_transient\_%',
628 '\_site\_transient\_%',
629 absint( $limit ),
630 absint( $offset )
631 );
632
633 $options_names = $wpdb->get_col( $query );
634
635 if ( $keyed )
636 return array_fill_keys( $options_names, true );
637 else
638 return $options_names;
639
640 }
641
642 /**
643 * Get options names from their ids in a specific site prefix excluding transients.
644 *
645 * @param string $site_prefix Site prefix of the options.
646 * @param array $options_ids Options ids to get their names.
647 *
648 * @return array Associative options names.
649 */
650 public static function get_options_names_from_ids( $site_prefix, $options_ids ) {
651
652 global $wpdb;
653 $table = $site_prefix . 'options'; // $site_prefix is safe to use here as it is validated in the calling function.
654
655 if ( empty( $options_ids ) )
656 return [];
657
658 $in_placeholders = implode( ',', array_fill( 0, count( $options_ids ), '%d' ) );
659
660 // Prepare args to pass to the query.
661 $args = array_merge(
662 $options_ids, // the %d placeholders
663 [ '\_transient\_%', '\_site\_transient\_%' ] // the %s placeholders
664 );
665
666 $query = $wpdb->prepare(
667 "SELECT option_name FROM `$table`
668 WHERE option_id IN ($in_placeholders)
669 AND option_name NOT LIKE %s
670 AND option_name NOT LIKE %s",
671 ...$args
672 );
673
674 $options_names = $wpdb->get_col( $query );
675
676 // transform the options names array to associative array with the option name as key and true as value
677 $options_names = array_fill_keys( $options_names, true );
678
679 return $options_names;
680
681 }
682
683 /**
684 * Set autoload to "no" for grouped options. Options are grouped by site ID as key.
685 *
686 * @param array $grouped_selected Grouped selected options to set autoload to "no".
687 *
688 * @return array An array of options names that were not processed.
689 */
690 public static function set_autoload_to_no( $grouped_selected ) {
691
692 $autoload_value = function_exists( 'wp_autoload_values_to_autoload' ) ? 'off' : 'no';
693 $not_processed = self::set_autoload( $grouped_selected, $autoload_value );
694 return $not_processed;
695
696 }
697
698 /**
699 * Set autoload to "yes" for grouped options. Options are grouped by site ID as key.
700 *
701 * @param array $grouped_selected Grouped selected options to set autoload to "yes".
702 *
703 * @return array An array of options names that were not processed.
704 */
705 public static function set_autoload_to_yes( $grouped_selected ) {
706
707 $autoload_value = function_exists( 'wp_autoload_values_to_autoload' ) ? 'on' : 'yes';
708 $not_processed = self::set_autoload( $grouped_selected, $autoload_value );
709 return $not_processed;
710
711 }
712
713 /**
714 * Change autoload value for options. Options are grouped by site ID as key.
715 *
716 * @param array $grouped_selected Grouped selected options to set autoload for.
717 * @param string $autoload_value The value to set for autoload, typically "yes"/"no" (or "on"/"off" for WP 6.6.0 and above).
718 *
719 * @return array Normally should return an array of options names that were not processed, but in this case it returns an empty array.
720 */
721 private static function set_autoload( $grouped_selected, $autoload_value ) {
722
723 global $wpdb;
724
725 foreach ( $grouped_selected as $site_id => $group ) {
726
727 if ( empty( $group ) )
728 continue;
729
730 $ids = array_column( $group, 'id' );
731 $ids_placeholder = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
732
733 ADBC_Sites::instance()->switch_to_blog_id( $site_id );
734
735 $sql = $wpdb->prepare(
736 "UPDATE {$wpdb->options}
737 SET autoload = %s
738 WHERE option_id IN ( $ids_placeholder )",
739 ...array_merge( [ $autoload_value ], $ids )
740 );
741
742 $wpdb->query( $sql );
743
744 // Clear the object cache so WordPress picks up the new autoload flags
745 wp_cache_delete( 'alloptions', 'options' );
746
747 ADBC_Sites::instance()->restore_blog();
748 }
749
750 return [];
751 }
752
753 /**
754 * Delete grouped options. Options are grouped by site ID as key.
755 *
756 * Expected input shape:
757 * [
758 * 1 => [ [ 'id' => 123, 'name' => 'foo' ], ... ],
759 * 2 => [ [ 'id' => 456, 'name' => 'bar' ], ... ],
760 * ]
761 *
762 * @param array $grouped_selected Grouped selected options to delete.
763 * @return array An array of option names that were not processed (not deleted).
764 */
765 public static function delete_options( $grouped_selected ) {
766
767 $cleanup_method = ADBC_Settings::instance()->get_setting( 'sql_or_native_cleanup_method' );
768
769 if ( $cleanup_method === 'native' ) {
770 return self::delete_options_native( $grouped_selected );
771 }
772
773 return self::delete_options_sql( $grouped_selected );
774
775 }
776
777 /**
778 * Deletes options using WordPress native delete_option() where safe.
779 *
780 * Important edge case:
781 * delete_option() effectively operates on a sanitized option name; if the stored option_name has
782 * leading/trailing spaces, delete_option( $name ) can be misleading (it will not target the exact row).
783 * For such cases, we fall back to SQL-by-ID even in "native" mode to avoid leaving undeletable rows.
784 *
785 * @param array $grouped_selected
786 * @return array Not processed option names.
787 */
788 protected static function delete_options_native( $grouped_selected ) {
789
790 global $wpdb;
791
792 $not_processed = [];
793
794 foreach ( $grouped_selected as $site_id => $group ) {
795
796 ADBC_Sites::instance()->switch_to_blog_id( $site_id );
797
798 foreach ( $group as $selected ) {
799
800 // check if the option have a leading or ending space which will be trimmed by delete_option()
801 $can_be_trimmed = $selected['name'] !== trim( $selected['name'] );
802
803 $success = false;
804
805 // try deleting using standard wordpress function if the trimming will not mislead the name
806 if ( ! $can_be_trimmed )
807 $success = delete_option( $selected['name'] );
808
809 // try deleting using direct sql by option id to be sure there's no problem in the name
810 if ( ! $success )
811 $success = $wpdb->delete( $wpdb->options, array( 'option_id' => $selected['id'] ) );
812
813 // if the deletion failed, add the option name to the not processed list
814 if ( ! $success )
815 $not_processed[] = $selected['name'];
816
817 }
818
819 ADBC_Sites::instance()->restore_blog();
820
821 }
822
823 return $not_processed;
824
825 }
826
827 /**
828 * Deletes options using direct SQL (bulk delete by option_id) for each site.
829 *
830 * @param array $grouped_selected
831 * @return array Not processed option names.
832 */
833 protected static function delete_options_sql( $grouped_selected ) {
834
835 global $wpdb;
836
837 $not_processed = [];
838
839 foreach ( $grouped_selected as $site_id => $group ) {
840
841 $ids = [];
842 foreach ( $group as $selected )
843 $ids[] = $selected['id'];
844
845 ADBC_Sites::instance()->switch_to_blog_id( $site_id );
846
847 $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
848
849 // Bulk delete.
850 $sql = "DELETE FROM {$wpdb->options} WHERE option_id IN ($placeholders)";
851 $sql = $wpdb->prepare( $sql, ...$ids );
852 $wpdb->query( $sql );
853
854 // Identify any remaining rows (not processed) deterministically.
855 $check_sql = "SELECT option_name FROM {$wpdb->options} WHERE option_id IN ($placeholders)";
856 $check_sql = $wpdb->prepare( $check_sql, ...$ids );
857
858 $remaining_names = $wpdb->get_col( $check_sql );
859
860 if ( ! empty( $remaining_names ) )
861 foreach ( $remaining_names as $opt_name )
862 $not_processed[] = $opt_name;
863
864 ADBC_Sites::instance()->restore_blog();
865
866 }
867
868 return $not_processed;
869
870 }
871
872 /**
873 * Get options names that exist from a list of options names.
874 *
875 * @param array $options_names The list of options names to check.
876 *
877 * @return array The list of options names that exist.
878 */
879 public static function get_options_names_that_exists_from_list( $options_names ) {
880 global $wpdb;
881
882 // Normalize and validate input
883 if ( empty( $options_names ) || ! is_array( $options_names ) )
884 return [];
885
886 // Build UNION branches over all sites' options tables
887 $branches = [];
888 $sites = ADBC_Sites::instance()->get_sites_list();
889
890 // Prepare the IN() placeholders once (same for all branches)
891 $in_placeholders = implode( ',', array_fill( 0, count( $options_names ), '%s' ) );
892
893 foreach ( $sites as $site ) {
894 $table = $site['prefix'] . 'options';
895
896 // Each branch is a fully prepared subquery to safely embed into the UNION
897 $sql = $wpdb->prepare(
898 "SELECT DISTINCT option_name AS name
899 FROM `{$table}`
900 WHERE option_name IN ( {$in_placeholders} )
901 AND option_name NOT LIKE %s
902 AND option_name NOT LIKE %s",
903 ...array_merge( $options_names, [ '\_transient\_%', '\_site\_transient\_%' ] )
904 );
905
906 $branches[] = '(' . $sql . ')';
907 }
908
909 if ( empty( $branches ) )
910 return [];
911
912 $union_sql = implode( "\nUNION ALL\n", $branches );
913 $query = "SELECT DISTINCT name FROM ( {$union_sql} ) AS existing_names";
914
915 $existing_names = $wpdb->get_col( $query );
916
917 return array_values( array_unique( array_filter( (array) $existing_names ) ) );
918 }
919
920 }