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-tables.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-tables.php
1202 lines
1 <?php
2
3 // Exit if accessed directly
4 if ( ! defined( 'ABSPATH' ) )
5 exit;
6
7 /**
8 * ADBC tables class.
9 *
10 * This class provides tables functions.
11 */
12 class ADBC_Tables {
13
14 private const TIME_TO_REFRESH_TO_REPAIR_TRANSIENT = 3600; // 1 hour in seconds
15
16 // Transient key for InnoDB conversion lock. Value: [ table_name => lock_timestamp, ... ]
17 private const INNODB_LOCK_TRANSIENT = 'adbc_plugin_innodb_conversion_lock';
18
19 // Per-table lock duration in seconds. Determined by each table's timestamp
20 private const INNODB_LOCK_DURATION = 900; // 15 minutes
21
22 /**
23 * Get the tables list for the endpoint.
24 *
25 * @param array $filters Output of sanitize_filters().
26 *
27 * @return WP_REST_Response The list of tables.
28 */
29 public static function get_tables_list( $filters ) {
30
31 $show_tables_with_invalid_prefix = ADBC_Settings::instance()->get_setting( 'show_tables_with_invalid_prefix' );
32
33 // Prepare variables
34 $tables_list = [];
35 $total_tables = 0;
36
37 $scan_counter = new ADBC_Scan_Counter();
38
39 $total_database_size = (float) ADBC_Database::get_database_size_sql( false );
40
41 $startRecord = ( $filters['current_page'] - 1 ) * $filters['items_per_page'];
42 $endRecord = $startRecord + $filters['items_per_page'];
43 $currentRecord = 0;
44
45 $limit = ADBC_Settings::instance()->get_setting( 'database_rows_batch' );
46 $offset = 0;
47
48 do { // Loop through all tables in batches of $limit to avoid memory issues
49
50 $tables = self::get_tables_list_batch( $filters['sort_by'], $filters['sort_order'], $limit, $offset );
51 $fetched_count = count( $tables );
52
53 // If the user want to not show tables with invalid prefix, remove them from the list
54 if ( $show_tables_with_invalid_prefix === "0" )
55 self::remove_tables_with_invalid_prefix_from_rows( $tables );
56
57 self::add_tables_data_to_rows( $tables ); // Add site id, prefix and table name without prefix
58
59 if ( ADBC_VERSION_TYPE === 'PREMIUM' )
60 ADBC_Scan_Results::instance()->load_scan_results_to_tables_rows( $tables ); // Load scan results to the tables rows
61 else
62 ADBC_Common_Model::load_scan_results_to_items_for_free_version( $tables ); // Load scan results to the tables rows for free version
63
64 ADBC_Hardcoded_Items::instance()->load_hardcoded_scan_results_to_tables_rows( $tables ); // Load hardcoded items to the tables rows
65
66 foreach ( $tables as $table_name => $table_data ) {
67
68 /* ──────────────────────────────────────────────────────────────────────────────────
69 * Ignore tables that don't satisfy the filters and belongs_to, then process the rest
70 * ─────────────────────────────────────────────────────────────────────────────────*/
71
72 if ( ! self::is_table_satisfies_filters( $table_name, $table_data, $filters ) )
73 continue;
74
75
76 $scan_counter->refresh_categorization_count( $table_data->belongs_to );
77
78 if ( ! ADBC_Common_Model::is_item_satisfies_belongs_to( $filters, $table_data->belongs_to ) )
79 continue;
80
81 $total_tables++; // Count tables that satisfy all filters and belongs_to
82
83 // Only process the current batch if it's within the desired page range
84 if ( $currentRecord >= $startRecord && $currentRecord < $endRecord ) {
85
86 $size_percent = $total_database_size > 0
87 ? round( ( $table_data->size / $total_database_size ) * 100, 2 )
88 : 0;
89
90 $tables_list[] = [
91 // This id is used to identify the table in the frontend and take actions on it
92 'composite_id' => [
93 'items_type' => 'tables',
94 'name' => $table_name,
95 ],
96 'table_name' => $table_name,
97 'name' => $table_data->table_name_without_prefix, // Used in the known addons modal
98 'prefix' => $table_data->prefix,
99 'name_without_prefix' => $table_data->table_name_without_prefix,
100 'size' => $table_data->size,
101 'size_percent' => $size_percent,
102 'rows' => $table_data->rows,
103 'overhead' => ADBC_Common_Utils::format_bytes( $table_data->overhead ),
104 'raw_overhead' => $table_data->overhead,
105 'type' => $table_data->type,
106 'site_id' => $table_data->site_id,
107 'belongs_to' => $table_data->belongs_to,
108 'known_plugins' => $table_data->known_plugins,
109 'known_themes' => $table_data->known_themes
110 ];
111 }
112
113 $currentRecord++;
114 }
115
116 $offset += $limit;
117
118 } while ( $fetched_count == $limit ); // Continue if the last batch was full
119
120 // Loop over the $tables_list and $scan_counter add the plugins/themes names from the dictionary if they are empty
121 // This is because load_scan_results_to_tables_rows() only loads the names of the plugins/themes that are currently installed
122 if ( ADBC_VERSION_TYPE === 'PREMIUM' )
123 ADBC_Dictionary::add_missing_addons_names_from_dictionary( $tables_list, $scan_counter, 'tables' );
124
125 // Calculate total number of pages to verify that the current page sent by the user is within the range
126 $total_real_pages = max( 1, ceil( $total_tables / $filters['items_per_page'] ) );
127
128 return ADBC_Rest::success( "", [
129 'items' => $tables_list,
130 'total_items' => $total_tables,
131 'real_current_page' => min( $filters['current_page'], $total_real_pages ),
132 'categorization_count' => $scan_counter->get_categorization_count(),
133 'plugins_count' => $scan_counter->get_plugins_count(),
134 'themes_count' => $scan_counter->get_themes_count()
135 ] );
136 }
137
138 /**
139 * Get the tables list with the given order by SQL, limit and offset.
140 *
141 * @param string $order_by_sql The order by SQL.
142 * @param int $limit The limit.
143 * @param int $offset The offset.
144 * @return array The list of tables.
145 */
146 public static function get_tables_list_batch( $sort_by, $sort_order, $limit, $offset ) {
147
148 global $wpdb;
149
150 /* ──────────────────────────────────────────────────────────────
151 * Build a safe ORDER BY clause
152 * ─────────────────────────────────────────────────────────────*/
153 $allowed_columns = [
154 'table_name' => '`table_name`',
155 'size' => '`size`',
156 'rows' => '`rows`',
157 'type' => '`type`',
158 'overhead' => '`overhead`'
159 ];
160 $sort_col = $sort_by ?? '';
161 $sort_dir = strtoupper( $sort_order ?? 'ASC' );
162 $sort_dir = $sort_dir === 'DESC' ? 'DESC' : 'ASC';
163
164 // Special handling for sorting by site_id
165 if ( $sort_col === 'site_id' ) {
166 $prefix_list = ADBC_Sites::instance()->get_all_prefixes(); // [ prefix => site_id ]
167
168 // Ensure longest-prefix-first matching to handle nested/similar prefixes accurately
169 uksort( $prefix_list, function ($a, $b) {
170 $lenA = strlen( (string) $a );
171 $lenB = strlen( (string) $b );
172 if ( $lenA === $lenB )
173 return 0;
174 return ( $lenA > $lenB ) ? -1 : 1; // Desc by length
175 } );
176
177 $case_parts = [];
178 foreach ( $prefix_list as $prefix => $site_id ) {
179 $like = $wpdb->esc_like( $prefix ) . '%';
180 $case_parts[] = "WHEN `TABLE_NAME` LIKE '{$like}' THEN " . absint( $site_id );
181 }
182 if ( ! empty( $case_parts ) ) {
183 $case_expr = '(CASE ' . implode( ' ', $case_parts ) . ' ELSE 2147483647 END)';
184 $order_by_sql = "ORDER BY {$case_expr} {$sort_dir}";
185 }
186 } else {
187 // Add 'order by' clause if the column is allowed.
188 $order_by_sql = isset( $allowed_columns[ $sort_col ] )
189 ? "ORDER BY {$allowed_columns[ $sort_col ]} {$sort_dir}"
190 : '';
191 }
192
193 $sql_rows = $wpdb->prepare(
194 "SELECT
195 `TABLE_NAME` AS `table_name`,
196 (`DATA_LENGTH` + `INDEX_LENGTH`) AS `size`,
197 `TABLE_ROWS` AS `rows`,
198 `DATA_FREE` as `overhead`,
199 `ENGINE` AS `type`
200 FROM
201 `information_schema`.`TABLES`
202 WHERE
203 `TABLE_SCHEMA` = %s
204 $order_by_sql
205 LIMIT %d OFFSET %d",
206 DB_NAME,
207 absint( $limit ),
208 absint( $offset )
209 );
210
211 return $wpdb->get_results( $sql_rows, OBJECT_K );
212 }
213
214 /**
215 * Get database tables count.
216 *
217 * @return int Database tables count.
218 */
219 public static function get_total_tables_count() {
220
221 global $wpdb;
222 $sql = $wpdb->prepare( "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = %s", DB_NAME );
223 $count = $wpdb->get_var( $sql );
224 return $count;
225 }
226
227 /**
228 * Get the count of tables with invalid prefix.
229 *
230 * @return int The count of tables with invalid prefix.
231 */
232 public static function get_total_tables_with_invalid_prefix_count() {
233
234 global $wpdb;
235
236 $sql = $wpdb->prepare( "SELECT `TABLE_NAME` FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = %s", DB_NAME );
237
238 $all_tables = $wpdb->get_col( $sql );
239
240 $count = 0;
241 foreach ( $all_tables as $table ) {
242 if ( ! self::is_table_having_valid_prefix( $table ) ) {
243 $count++;
244 }
245 }
246
247 return $count;
248
249 }
250
251 /**
252 * Get the tables names with or without prefix for the given limit and offset.
253 *
254 * @param int $limit The limit.
255 * @param int $offset The offset.
256 * @param bool $with_prefix True to include the prefix in the table name, false otherwise.
257 * @param bool $return_invalid_prefix_tables True to return the tables with invalid prefix, false otherwise.
258 * @return array The list of tables names with or without prefix as keys of the associative array.
259 */
260 public static function get_tables_names( $limit, $offset, $with_prefix = true, $return_invalid_prefix_tables = true ) {
261
262 global $wpdb;
263
264 $tables_names = [];
265
266 $sql_rows = $wpdb->prepare(
267 "SELECT `TABLE_NAME` FROM `information_schema`.`TABLES`
268 WHERE `TABLE_SCHEMA` = %s
269 LIMIT %d OFFSET %d",
270 DB_NAME,
271 absint( $limit ),
272 absint( $offset )
273 );
274
275 $tables_names_with_prefix = $wpdb->get_col( $sql_rows );
276
277 foreach ( $tables_names_with_prefix as $table ) {
278
279 // Don't add the table if it is not having a valid prefix and we don't want to return invalid prefix tables
280 if ( $return_invalid_prefix_tables === false && ! self::is_table_having_valid_prefix( $table ) ) {
281 continue;
282 }
283
284 // If we don't want to return the prefix, remove it from the table name
285 if ( $with_prefix === false ) {
286 $table = self::remove_prefix_from_table_name( $table );
287 }
288
289 $tables_names[ $table ] = true; // Use the table name as the key and true as dummy value
290
291 }
292
293 return $tables_names;
294
295 }
296
297 /**
298 * Get the table prefix, blog id and table name without prefix.
299 *
300 * @param string $table_name The table name.
301 * @return array The table prefix, blog id and table name without prefix.
302 */
303 public static function get_table_prefix_and_blog_id( $table_name ) {
304
305 $prefix_list = ADBC_Sites::instance()->get_all_prefixes();
306 $found_prefix = '';
307 $table_site_id = 'N/A'; // Do not change this default value, it is used elsewhere.
308
309 // Find the longest matching prefix
310 foreach ( $prefix_list as $prefix => $site_id ) {
311 if ( strpos( $table_name, $prefix ) === 0 && strlen( $prefix ) > strlen( $found_prefix ) ) {
312 $found_prefix = $prefix;
313 $table_site_id = $site_id;
314 }
315 }
316
317 // Prepare the table name without prefix
318 $table_name_without_prefix = $found_prefix ? substr( $table_name, strlen( $found_prefix ) ) : $table_name;
319
320 return [
321 'prefix' => $found_prefix,
322 'site_id' => $table_site_id,
323 'table_name_without_prefix' => $table_name_without_prefix
324 ];
325
326 }
327
328 /**
329 * Check if the table is having valid prefix.
330 *
331 * @param string $table_name The table name.
332 * @return bool True if the table is having valid prefix, false otherwise.
333 */
334 public static function is_table_having_valid_prefix( $table_name ) {
335
336 $prefix_list = ADBC_Sites::instance()->get_all_prefixes();
337
338 foreach ( $prefix_list as $prefix => $site_id ) {
339
340 if ( strpos( $table_name, $prefix ) === 0 ) {
341 return true;
342 }
343 }
344
345 return false;
346 }
347
348 /**
349 * Remove the prefix from the table name.
350 *
351 * @param string $table_name The table name.
352 * @return string The table name without prefix.
353 */
354 public static function remove_prefix_from_table_name( $table_name ) {
355
356 $table_info = self::get_table_prefix_and_blog_id( $table_name );
357 return $table_info['table_name_without_prefix'];
358
359 }
360
361 /**
362 * Get the count and list of tables to repair.
363 *
364 * @return array The list of tables to repair. The first element is the count of tables, the second element is the list of tables.
365 */
366 public static function get_tables_to_repair() {
367
368 global $wpdb;
369 $transient = get_transient( 'adbc_plugin_tables_to_repair' );
370
371 // Check if the transient is set and has a value
372 if ( $transient !== false && is_array( $transient ) )
373 return [ count( $transient ), $transient ];
374
375 // If the transient is not set, does not have a value or is expired, refresh it
376 $corrupted_tables = [];
377 $limit = ADBC_Settings::instance()->get_setting( 'database_rows_batch' );
378 $offset = 0;
379 $limit_for_sql = 20; // Number of tables to run CHECK TABLE on at once
380 $db_dot = DB_NAME . '.'; // used for stripping later
381 $db_dot_length = strlen( $db_dot );
382 $quick = is_multisite() ? 'QUICK' : ''; // QUICK = header-only scan, harmless on busy sites
383
384 do { // Loop through all tables in batches
385
386 $tables = self::get_tables_names( $limit, $offset, true, true ); // Get all the tables names with their prefixes
387 $fetched_count = count( $tables );
388 $tables_names = array_keys( $tables );
389
390 // Execute CHECK TABLE in batches of $limit_for_sql
391 for ( $mini_offset = 0; $mini_offset < $fetched_count; $mini_offset += $limit_for_sql ) {
392
393 $batch = array_slice( $tables_names, $mini_offset, $limit_for_sql );
394 $quoted = '`' . implode( '`, `', $batch ) . '`';
395 $results = $wpdb->get_results( "CHECK TABLE $quoted {$quick}" );
396
397 foreach ( $results as $row ) {
398
399 if ( strtolower( $row->Msg_type ) == 'error' && stripos( $row->Msg_text, 'corrupt' ) !== false ) {
400
401 // strip "dbname." from the table name. Because $row->Table is dbname.table_name
402 $table = stripos( $row->Table, $db_dot ) === 0
403 ? substr( $row->Table, $db_dot_length )
404 : $row->Table;
405
406 $corrupted_tables[] = $table;
407 }
408 }
409 }
410
411 $offset += $limit;
412
413 } while ( $fetched_count == $limit ); // Continue if the last batch was full
414
415 $corrupted_tables = array_unique( $corrupted_tables );
416 set_transient( 'adbc_plugin_tables_to_repair', $corrupted_tables, self::TIME_TO_REFRESH_TO_REPAIR_TRANSIENT );
417 return [ count( $corrupted_tables ), $corrupted_tables ];
418 }
419
420 /**
421 * Filter tables by the given filters.
422 *
423 * @param string $table_name The table name.
424 * @param object $table_data The table data.
425 * @param array $filters The filters.
426 * @return bool True if the table satisfies the filters, false otherwise.
427 */
428 public static function is_table_satisfies_filters( $table_name, $table_data, $filters ) {
429
430 // Filter by search
431 if ( ! empty( $filters['search_for'] ) && strpos( $table_name, $filters['search_for'] ) === false ) {
432 return false;
433 }
434
435 // Filter by "to_optimize"
436 if ( $filters['table_status'] === 'to_optimize' && ( $table_data->overhead <= 0 || $table_data->type === "InnoDB" ) ) {
437 return false;
438 }
439
440 // Filter by "to_repair"
441 if ( $filters['table_status'] === 'to_repair' ) {
442
443 // Repair tables works only for MyISAM, ARCHIVE and CSV tables
444 if ( ! in_array( $table_data->type, [ 'MyISAM', 'ARCHIVE', 'CSV' ], true ) )
445 return false;
446
447 // Get the list of corrupted tables from the transient
448 $corrupted_tables_transient = get_transient( 'adbc_plugin_tables_to_repair' );
449 if ( $corrupted_tables_transient === false || ! is_array( $corrupted_tables_transient ) || empty( $corrupted_tables_transient ) )
450 return false;
451
452 // Check if the table is in the list of corrupted tables
453 if ( ! in_array( $table_name, $corrupted_tables_transient, true ) )
454 return false;
455 }
456
457 // Filter by "valid_prefix"
458 if ( $filters['prefix_status'] === 'valid_prefix' && ! self::is_table_having_valid_prefix( $table_name ) ) {
459 return false;
460 }
461
462 // Filter by "invalid_prefix"
463 if ( $filters['prefix_status'] === 'invalid_prefix' && self::is_table_having_valid_prefix( $table_name ) ) {
464 return false;
465 }
466
467 // Filter by size
468 $size_filter = ADBC_Common_Utils::convert_size_to_bytes( $filters['size'], $filters['size_unit'] );
469 if ( $filters['size'] > 0 && $table_data->size < $size_filter ) {
470 return false;
471 }
472
473 // Filter by site ID
474 if ( $filters['site_id'] != 'all' && $table_data->site_id != $filters['site_id'] ) {
475 return false;
476 }
477
478 return true;
479
480 }
481
482 /**
483 * Optimize the list of the provided tables.
484 *
485 * @param array $tables_names The list of tables names to optimize.
486 * @return array The list of tables that were not optimized.
487 */
488 public static function optimize_tables( $tables_names ) {
489
490 global $wpdb;
491 $not_optimized = [];
492
493 // Loop through the list of tables and optimize them
494 foreach ( $tables_names as $table_name ) {
495
496 $result = $wpdb->get_results( "OPTIMIZE TABLE `{$table_name}`" );
497
498 // Check if the table is optimized successfully.
499 foreach ( $result as $row ) {
500 if ( $row->Msg_type == 'status' ) {
501 if ( strtolower( $row->Msg_text ) == 'table is already up to date' || strtolower( $row->Msg_text ) == 'ok' ) {
502 $wpdb->query( "ANALYZE TABLE `{$table_name}`" ); // Analyze the table to update the table data
503 } else {
504 $not_optimized[] = $table_name; // If the query failed, add the table name to the not optimized list
505 }
506 }
507 }
508
509 }
510
511 return $not_optimized;
512 }
513
514 /**
515 * Delete the list of the provided tables.
516 *
517 * @param array $tables_names The list of tables names to delete.
518 * @return array The list of tables that were not deleted.
519 */
520 public static function delete_tables( $tables_names ) {
521
522 global $wpdb;
523 $not_deleted = [];
524
525 // Loop through the selected tables and delete them
526 foreach ( $tables_names as $table_name ) {
527
528 $deleted = $wpdb->query( "DROP TABLE IF EXISTS `{$table_name}`" );
529
530 if ( ! $deleted )
531 $not_deleted[] = $table_name; // If the query failed, add the table name to the not deleted list
532 }
533
534 // Delete the transient to force refresh the count of the tables to repair
535 delete_transient( 'adbc_plugin_tables_to_repair' );
536
537 return $not_deleted;
538 }
539
540 /**
541 * Empty the list of the provided tables.
542 *
543 * @param array $tables_names The list of tables names to empty.
544 * @return array The list of tables that were not emptied.
545 */
546 public static function empty_tables( $tables_names ) {
547
548 global $wpdb;
549 $not_processed = [];
550
551 // Loop through the selected tables and optimize them
552 foreach ( $tables_names as $table_name ) {
553
554 $emptied = $wpdb->query( "TRUNCATE TABLE `{$table_name}`" );
555
556 if ( $emptied ) {
557 $wpdb->query( "ANALYZE TABLE `{$table_name}`" ); // If the query succeeded, analyze the table to update the table data
558 } else {
559 $not_processed[] = $table_name; // If the query failed, add the table name to the not processed list
560 }
561 }
562
563 return $not_processed;
564 }
565
566 /**
567 * Repair the list of the provided tables.
568 *
569 * @param array $tables_names The list of tables names to repair.
570 * @return array The list of tables that were not repaired.
571 */
572 public static function repair_tables( $tables_names ) {
573
574 global $wpdb;
575 $not_repaired = [];
576
577 // Loop through the selected tables and repair them
578 foreach ( $tables_names as $table_name ) {
579
580 $result = $wpdb->get_results( "REPAIR TABLE `{$table_name}`" );
581
582 // Check if the table is repaired successfully
583 foreach ( $result as $row ) {
584 if ( strtolower( $row->Msg_type ) == 'error' && stripos( $row->Msg_text, 'corrupt' ) !== false ) {
585 $not_repaired[] = $table_name; // If the query failed, add the table name to the not repaired list
586 break; // Break the loop if an error is found
587 } else {
588 $wpdb->query( "ANALYZE TABLE `{$table_name}`" ); // If the query succeeded, analyze the table to update the table data
589 }
590 }
591 }
592
593 // Delete the transient to force refresh the count of the tables to repair
594 delete_transient( 'adbc_plugin_tables_to_repair' );
595
596 return $not_repaired;
597 }
598
599 /**
600 * Refresh counts for the list of the provided tables by running ANALYZE.
601 *
602 * @param array $tables_names The list of tables names to analyze.
603 * @return array The list of tables for which the counts could not be refreshed.
604 */
605 public static function refresh_tables_counts( $tables_names ) {
606
607 global $wpdb;
608 $not_processed = [];
609
610 foreach ( $tables_names as $table_name ) {
611
612 $analyzed = $wpdb->query( "ANALYZE TABLE `{$table_name}`" );
613
614 if ( ! $analyzed )
615 $not_processed[] = $table_name;
616 }
617
618 return $not_processed;
619 }
620
621 /**
622 * Add tables data to the rows array by reference: site id, prefix and table name without prefix.
623 * Used by the table endpoint class only.
624 *
625 * @param array $tables_rows The tables rows array to add the tables data to.
626 * @return void
627 */
628 public static function add_tables_data_to_rows( &$tables_rows ) {
629
630 foreach ( $tables_rows as $table_name => $table_data ) {
631
632 $table_info = self::get_table_prefix_and_blog_id( $table_name );
633 $tables_rows[ $table_name ]->site_id = $table_info['site_id']; // Site id is "N/A" for tables with invalid prefix
634 $tables_rows[ $table_name ]->prefix = $table_info['prefix'];
635 $tables_rows[ $table_name ]->table_name_without_prefix = $table_info['table_name_without_prefix'];
636
637 }
638 }
639
640 /**
641 * Remove tables with invalid prefix from the rows array by reference.
642 * Used by the table endpoint class only.
643 *
644 * @param array $tables_rows The tables rows array to remove the tables data from.
645 * @return void
646 */
647 public static function remove_tables_with_invalid_prefix_from_rows( &$tables_rows ) {
648
649 foreach ( $tables_rows as $table_name => $table_data ) {
650 if ( ! self::is_table_having_valid_prefix( $table_name ) ) {
651 unset( $tables_rows[ $table_name ] );
652 }
653 }
654 }
655
656 /**
657 * Get all tables names, sizes, total rows and total columns for analytics.
658 *
659 * @return array All tables data, the table name as the key and the table data as the value.
660 */
661 public static function get_all_tables_info_for_analytics() {
662
663 global $wpdb;
664
665 $query =
666 "SELECT
667 table_name AS table_name,
668 (data_length + index_length) AS size,
669 table_rows AS total_rows,
670 (SELECT COUNT(*) FROM information_schema.columns
671 WHERE table_schema = DATABASE()
672 AND table_name = t.table_name) AS total_columns
673 FROM information_schema.tables t
674 WHERE table_schema = DATABASE();
675 ";
676 $results = $wpdb->get_results( $query, ARRAY_A );
677
678 // Format the results to be an associative array with the table name as the key
679 $formatted_results = [];
680
681 foreach ( $results as $row ) {
682 $formatted_results[ $row['table_name'] ] = [
683 's' => (float) $row['size'],
684 'r' => (int) $row['total_rows'],
685 'c' => (int) $row['total_columns']
686 ];
687 }
688
689 return $formatted_results;
690
691 }
692
693 /**
694 * Run ANALYZE SQL command on all tables to force MySQL to update the tables statistics.
695 *
696 * @return void
697 */
698 public static function analyze_all_tables() {
699
700 global $wpdb;
701
702 // Get all tables in the database
703 $tables = $wpdb->get_col( "SHOW TABLES" );
704
705 foreach ( $tables as $table ) {
706 $wpdb->query( "ANALYZE TABLE `$table`" );
707 }
708
709 }
710
711 /**
712 * Get all existing WordPress core tables in the database with the prefix.
713 *
714 * @return array The list of all WordPress core tables with the prefix.
715 */
716 public static function get_all_wp_core_tables_with_prefix() {
717
718 $wp_core_tables = ADBC_Hardcoded_Items::instance()->get_wordpress_items( 'tables' );
719 $all_existing_prefixes = ADBC_Sites::instance()->get_all_prefixes();
720
721 // Prepare the list of tables with prefix
722 $wp_core_tables_with_prefix = [];
723 foreach ( $wp_core_tables as $table_name => $_ ) {
724
725 foreach ( $all_existing_prefixes as $prefix => $site_id )
726 $wp_core_tables_with_prefix[] = $prefix . $table_name;
727
728 }
729
730 return $wp_core_tables_with_prefix;
731
732 }
733
734 /**
735 * Get the list of tables to optimize and their overhead.
736 *
737 * @return array The list of tables to optimize as objects, with the table name and the overhead as attributes.
738 */
739 public static function get_tables_to_optimize() {
740
741 global $wpdb;
742 $sql = "SELECT `TABLE_NAME` AS `table_name`,
743 `DATA_FREE` as `overhead`
744 FROM `information_schema`.`TABLES`
745 WHERE `TABLE_SCHEMA` = %s
746 AND `DATA_FREE` > 0
747 AND `ENGINE` != 'InnoDB'
748 ";
749 $query = $wpdb->prepare( $sql, DB_NAME );
750
751 $results = $wpdb->get_results( $query, OBJECT_K );
752
753 return $results;
754
755 }
756
757 /**
758 * Check if the MySQL server supports the InnoDB storage engine.
759 *
760 * @return bool True if InnoDB is supported, false otherwise.
761 */
762 public static function is_innodb_supported() {
763
764 global $wpdb;
765
766 $engines = $wpdb->get_results( "SHOW ENGINES", OBJECT );
767
768 if ( ! is_array( $engines ) || empty( $engines ) )
769 return false;
770
771 foreach ( $engines as $engine ) {
772
773 if ( ! isset( $engine->Engine ) )
774 continue;
775
776 if ( strcasecmp( $engine->Engine, 'InnoDB' ) !== 0 )
777 continue;
778
779 // Some MySQL/MariaDB versions expose Support as a property indicating availability.
780 if ( isset( $engine->Support ) ) {
781 $support = strtoupper( (string) $engine->Support );
782 if ( in_array( $support, [ 'YES', 'DEFAULT' ], true ) )
783 return true;
784 } else {
785 // If Support column is missing, be conservative and assume it's available.
786 return true;
787 }
788 }
789
790 return false;
791
792 }
793
794 /**
795 * Check if the actionscheduler table exists.
796 *
797 * @param string $table_type The type of the Actions Scheduler table to check for ('actions', 'logs'...)
798 * @return bool True if the table exists in any site, false otherwise.
799 * */
800 public static function is_actionscheduler_table_exists( $table_type = 'actions' ) {
801
802 global $wpdb;
803
804 $exists = false;
805
806 foreach ( ADBC_Sites::instance()->get_sites_list() as $site ) {
807
808 ADBC_Sites::instance()->switch_to_blog_id( $site['id'] );
809
810 $table_name = $wpdb->prefix . 'actionscheduler_' . $table_type;
811 $exists = (bool) $wpdb->get_var(
812 $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name )
813 );
814
815 if ( $exists ) {
816 ADBC_Sites::instance()->restore_blog();
817 break;
818 }
819
820 ADBC_Sites::instance()->restore_blog();
821
822 }
823
824 return $exists;
825
826 }
827
828 /**
829 * Check if a table exists in the current database.
830 *
831 * @param string $table_name The name of the table to check for.
832 * @return bool True if the table exists, false otherwise.
833 */
834 public static function is_table_exists( $table_name ) {
835 global $wpdb;
836
837 $exists = (bool) $wpdb->get_var(
838 $wpdb->prepare(
839 "SHOW TABLES LIKE %s",
840 $wpdb->esc_like( $table_name )
841 )
842 );
843
844 return $exists;
845 }
846
847 /**
848 * Count the total number of tables that are not scanned.
849 *
850 * @return int Total tables that are not scanned.
851 */
852 public static function count_total_not_scanned_tables() {
853
854 $total_not_scanned = 0;
855
856 $show_tables_with_invalid_prefix = ADBC_Settings::instance()->get_setting( 'show_tables_with_invalid_prefix' );
857 $show_tables_with_invalid_prefix = $show_tables_with_invalid_prefix === '1';
858
859 $limit = ADBC_Settings::instance()->get_setting( 'database_rows_batch' );
860 $offset = 0;
861
862 do {
863
864 $tables_names = self::get_tables_names( $limit, $offset, false, $show_tables_with_invalid_prefix );
865 $fetched_count = count( $tables_names );
866 $not_scanned_count = 0;
867 $tables_names = array_keys( $tables_names ); // Get the tables names as an array
868
869 if ( ADBC_VERSION_TYPE === 'PREMIUM' )
870 $not_scanned_count = ADBC_Scan_Utils::count_not_scanned_items_in_list( 'tables', $tables_names );
871 else
872 $not_scanned_count = ADBC_Common_Model::count_not_scanned_items_in_list_for_free( 'tables', $tables_names );
873
874 $total_not_scanned += $not_scanned_count;
875
876 $offset += $limit;
877
878 } while ( $fetched_count == $limit ); // Continue if the last batch was full
879
880 return $total_not_scanned;
881
882 }
883
884 /**
885 * Count the total number of tables to optimize.
886 *
887 * @return int Total tables to optimize.
888 */
889 public static function count_total_tables_to_optimize() {
890 return count( self::get_tables_to_optimize() );
891 }
892
893 /**
894 * Count the total number of tables to repair.
895 *
896 * @return int Total tables to repair.
897 */
898 public static function count_total_tables_to_repair() {
899 return self::get_tables_to_repair()[0];
900 }
901
902 /**
903 * Get the list of column names for a given table.
904 *
905 * @param string $table_name The table name.
906 * @return array List of column names (strings).
907 */
908 public static function get_table_columns( $table_name ) {
909
910 global $wpdb;
911
912 // $table_name is already validated via is_table_exists() in the endpoint layer.
913 $results = $wpdb->get_col( "SHOW COLUMNS FROM `{$table_name}`", 0 );
914
915 if ( ! is_array( $results ) ) {
916 return [];
917 }
918
919 return array_map( 'strval', $results );
920 }
921
922 /**
923 * Get the rows of a table.
924 *
925 * @param string $table_name The table name.
926 * @param array $filters The filters.
927 * @return array The rows of the table.
928 */
929 public static function get_table_rows( $table_name, $filters ) {
930
931 global $wpdb;
932
933 // All filters are already validated, casted and defaulted at the endpoint level.
934 $current_page = $filters['current_page'];
935 $items_per_page = $filters['items_per_page'];
936 $sort_by = $filters['sort_by'];
937 $sort_order = $filters['sort_order'];
938
939 // Validate sort_by against real columns of the table. If invalid, do not sort.
940 $columns = self::get_table_columns( $table_name );
941 $order_by_sql = '';
942
943 if ( in_array( $sort_by, $columns, true ) ) {
944 $order_by_sql = "ORDER BY `{$sort_by}` {$sort_order}";
945 }
946
947 // Total items for this table (no additional filters for now).
948 $total_items = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table_name}`" );
949
950 $offset = ( $current_page - 1 ) * $items_per_page;
951
952 // Fetch the requested page of rows.
953 $query = $wpdb->prepare(
954 "SELECT * FROM `{$table_name}` {$order_by_sql} LIMIT %d OFFSET %d",
955 $items_per_page,
956 $offset
957 );
958
959 $rows = $wpdb->get_results( $query, ARRAY_A );
960
961 // Follow the same "real current page" logic used elsewhere.
962 $total_real_pages = $items_per_page > 0
963 ? max( 1, (int) ceil( $total_items / $items_per_page ) )
964 : 1;
965
966 return [
967 'items' => is_array( $rows ) ? $rows : [],
968 'total_items' => $total_items,
969 'real_current_page' => min( $current_page, $total_real_pages ),
970 ];
971
972 }
973
974 /**
975 * Get the structure of a table.
976 *
977 * @param string $table_name The table name.
978 * @return array The structure of the table.
979 */
980 public static function get_table_structure( $table_name ) {
981
982 global $wpdb;
983
984 // 1) Columns
985 $columns = $wpdb->get_results( "SHOW FULL COLUMNS FROM `{$table_name}`", ARRAY_A );
986
987 // 2) Indexes (PRIMARY, UNIQUE, INDEX, FULLTEXT, etc.)
988 $indexes = $wpdb->get_results( "SHOW INDEX FROM `{$table_name}`", ARRAY_A );
989
990 // 3) Table status (engine, collation, auto_increment, row_format, comment, etc.)
991 $status = $wpdb->get_row(
992 $wpdb->prepare(
993 "SHOW TABLE STATUS WHERE `Name` = %s",
994 $table_name
995 ),
996 ARRAY_A
997 );
998
999 // 4) CREATE TABLE statement (full DDL)
1000 $create_row = $wpdb->get_row( "SHOW CREATE TABLE `{$table_name}`", ARRAY_A );
1001 $create_sql = '';
1002 if ( is_array( $create_row ) ) {
1003 // Key is usually 'Create Table', but fall back defensively.
1004 if ( isset( $create_row['Create Table'] ) ) {
1005 $create_sql = $create_row['Create Table'];
1006 } else {
1007 $values = array_values( $create_row );
1008 $create_sql = isset( $values[1] ) ? $values[1] : '';
1009 }
1010 }
1011
1012 return [
1013 'columns' => is_array( $columns ) ? $columns : [],
1014 'indexes' => is_array( $indexes ) ? $indexes : [],
1015 'table_status' => is_array( $status ) ? $status : [],
1016 'create_statement' => $create_sql,
1017 ];
1018
1019 }
1020
1021 /**
1022 * Check if a table is corrupted.
1023 *
1024 * @param string $table_name The table name.
1025 *
1026 * @return bool True if the table is corrupted, false otherwise.
1027 */
1028 public static function is_table_corrupted( $table_name ) {
1029 $corrupted_tables = self::get_tables_to_repair()[1];
1030 return in_array( $table_name, $corrupted_tables, true );
1031 }
1032
1033 /******************************************************
1034 * Start of InnoDB conversion locking functions
1035 ******************************************************/
1036
1037 /**
1038 * Convert the provided tables to InnoDB, with per-table locking.
1039 *
1040 * For each table the method:
1041 * 1. Checks if already InnoDB → removes from lock if present, counts as success.
1042 * 2. Checks if locked by another process (timestamp-based) → skips.
1043 * 3. Locks the table, runs ALTER TABLE, unlocks on completion.
1044 * If the script dies during ALTER TABLE the lock expires based on its timestamp.
1045 *
1046 * @param array $tables_names The list of table names to convert.
1047 *
1048 * @return array { 'not_converted': string[], 'skipped_locked': int }
1049 */
1050 public static function convert_tables_to_innodb( $tables_names ) {
1051
1052 global $wpdb;
1053 $not_converted = [];
1054 $skipped_locked = 0;
1055
1056 foreach ( $tables_names as $table_name ) {
1057
1058 if ( self::is_table_innodb( $table_name ) ) {
1059 self::unlock_table_conversion( $table_name );
1060 continue;
1061 }
1062
1063 if ( self::is_table_conversion_locked( $table_name ) ) {
1064 $skipped_locked++;
1065 continue;
1066 }
1067
1068 self::lock_table_conversion( $table_name );
1069
1070 if ( self::is_table_corrupted( $table_name ) ) {
1071 $not_converted[] = $table_name;
1072 continue;
1073 }
1074
1075 $converted = $wpdb->query( "ALTER TABLE `{$table_name}` ENGINE=InnoDB" );
1076
1077 if ( $converted ) {
1078 $wpdb->query( "ANALYZE TABLE `{$table_name}`" );
1079 } else {
1080 $not_converted[] = $table_name;
1081 }
1082
1083 self::unlock_table_conversion( $table_name );
1084
1085 }
1086
1087 return [
1088 'not_converted' => $not_converted,
1089 'skipped_locked' => $skipped_locked,
1090 ];
1091 }
1092
1093 /**
1094 * Read the lock data from the transient.
1095 *
1096 * @return array Associative array of table_name => lock_timestamp.
1097 */
1098 private static function get_lock_data() {
1099 $lock = get_transient( self::INNODB_LOCK_TRANSIENT );
1100 return is_array( $lock ) ? $lock : [];
1101 }
1102
1103 /**
1104 * Persist the lock data. Prunes expired entries before saving.
1105 * Deletes the transient entirely when no active entries remain.
1106 *
1107 * @param array $lock Associative array of table_name => lock_timestamp.
1108 *
1109 * @return void
1110 */
1111 private static function save_lock_data( $lock ) {
1112
1113 $now = time();
1114
1115 foreach ( array_keys( $lock ) as $table ) {
1116 if ( ( $now - (int) $lock[ $table ] ) >= self::INNODB_LOCK_DURATION )
1117 unset( $lock[ $table ] );
1118 }
1119
1120 if ( empty( $lock ) ) {
1121 delete_transient( self::INNODB_LOCK_TRANSIENT );
1122 } else {
1123 set_transient( self::INNODB_LOCK_TRANSIENT, $lock, self::INNODB_LOCK_DURATION );
1124 }
1125 }
1126
1127 /**
1128 * Check whether a table is currently locked for InnoDB conversion based on its timestamp.
1129 *
1130 * @param string $table_name Table name.
1131 *
1132 * @return bool True if the table is locked (timestamp within INNODB_LOCK_DURATION).
1133 */
1134 private static function is_table_conversion_locked( $table_name ) {
1135
1136 $lock = self::get_lock_data();
1137
1138 if ( ! isset( $lock[ $table_name ] ) )
1139 return false;
1140
1141 return ( time() - (int) $lock[ $table_name ] ) < self::INNODB_LOCK_DURATION;
1142 }
1143
1144 /**
1145 * Acquire a conversion lock for a single table (timestamp = now).
1146 *
1147 * @param string $table_name Table name.
1148 *
1149 * @return void
1150 */
1151 private static function lock_table_conversion( $table_name ) {
1152
1153 $lock = self::get_lock_data();
1154 $lock[ $table_name ] = time();
1155 self::save_lock_data( $lock );
1156 }
1157
1158 /**
1159 * Release the conversion lock for a single table.
1160 *
1161 * @param string $table_name Table name.
1162 *
1163 * @return void
1164 */
1165 private static function unlock_table_conversion( $table_name ) {
1166
1167 $lock = self::get_lock_data();
1168
1169 if ( ! isset( $lock[ $table_name ] ) )
1170 return;
1171
1172 unset( $lock[ $table_name ] );
1173 self::save_lock_data( $lock );
1174 }
1175
1176 /**
1177 * Check whether the given table is already using the InnoDB engine.
1178 *
1179 * @param string $table_name Table name.
1180 *
1181 * @return bool True if the table engine is InnoDB, false otherwise.
1182 */
1183 private static function is_table_innodb( $table_name ) {
1184
1185 global $wpdb;
1186
1187 $engine = $wpdb->get_var(
1188 $wpdb->prepare(
1189 "SELECT `ENGINE` FROM `information_schema`.`TABLES` WHERE `TABLE_SCHEMA` = %s AND `TABLE_NAME` = %s",
1190 DB_NAME,
1191 $table_name
1192 )
1193 );
1194
1195 return strtoupper( (string) $engine ) === 'INNODB';
1196 }
1197
1198 /******************************************************
1199 * End of InnoDB conversion locking functions
1200 ******************************************************/
1201
1202 }