Reservations -> DB Check * * @since 2.3.4 */ class ALEXR_Database_Check { public function __construct() { add_action( 'admin_menu', [ $this, 'register_menu' ], 20 ); } /* ------------------------------------------------------------------ */ /* MENU */ /* ------------------------------------------------------------------ */ public function register_menu() { add_submenu_page( 'admin-alex-reservations', __( 'DB Check', 'alexr' ), __( 'DB Check', 'alexr' ), 'manage_options', 'alexr-database-check', [ $this, 'render_page' ] ); } /* ------------------------------------------------------------------ */ /* TABLES LIST (same as install.php) */ /* ------------------------------------------------------------------ */ private function get_expected_tables(): array { return [ 'restaurants', 'restaurant_meta', 'restaurant_setting', 'users', 'user_meta', 'customers', 'customer_meta', 'ctaggroups', 'ctags', 'customer_ctag', 'bookings', 'booking_meta', 'booking_notifications', 'booking_reviews', 'btaggroups', 'btags', 'booking_btag', 'restaurant_user', 'settings', 'notifications', 'payments', 'floors', 'areas', 'tables', 'combinations', 'combination_table', 'booking_table', 'roles', 'actions', 'daily_notifications', 'tokens', 'bookings_recurring', 'ttaggroups', 'ttags', 'table_ttag', 'layouts', 'automation_logs', 'booking_tracking', 'whatsapp_flows', 'whatsapp_conversations', 'whatsapp_messages', 'whatsapp_webhooks', 'api_keys', 'api_logs', 'api_keys_bot', 'api_logs_bot', 'url_shorteners', 'missed_call_logs', 'spaces', 'vouchers', 'voucher_redemptions' ]; } /* ------------------------------------------------------------------ */ /* EXPECTED COLUMNS */ /* Parsed from sql_create() + sql_update_columns_releases() */ /* ------------------------------------------------------------------ */ /** * Return ALL expected columns for a table: base (CREATE TABLE) plus * any columns ADDed by migrations, minus any DROPped by migrations. */ private function get_expected_columns( string $table_key ): array { $db_instance = WP_ALEXR()->{$table_key} ?? null; if ( ! $db_instance || ! method_exists( $db_instance, 'sql_create' ) ) { return []; } // 1. Base columns from sql_create() $sql = $db_instance->sql_create(); $columns = ! empty( $sql ) ? $this->parse_columns_from_create_sql( $sql ) : []; // 2. Read the source of sql_update_columns_releases() and apply ADD / DROP if ( method_exists( $db_instance, 'sql_update_columns_releases' ) ) { $source = $this->get_method_source( $db_instance, 'sql_update_columns_releases' ); if ( $source !== '' ) { $columns = $this->apply_migration_columns( $columns, $source ); } } return array_values( array_unique( $columns ) ); } /** * Parse column names from a CREATE TABLE statement. * Ignores PRIMARY KEY, KEY, CONSTRAINT, UNIQUE KEY, INDEX lines. */ private function parse_columns_from_create_sql( string $sql ): array { $columns = []; // Get everything between the first ( and the last ) if ( ! preg_match( '/CREATE TABLE[^(]+\((.+)\)\s*$/s', $sql, $m ) ) { return $columns; } $body = $m[1]; $lines = preg_split( '/\n/', $body ); foreach ( $lines as $line ) { $line = trim( $line, " \t\r\n," ); if ( $line === '' ) continue; // Skip non-column definitions $upper = strtoupper( $line ); if ( str_starts_with( $upper, 'PRIMARY KEY' ) || str_starts_with( $upper, 'KEY ' ) || str_starts_with( $upper, 'UNIQUE KEY' ) || str_starts_with( $upper, 'CONSTRAINT' ) || str_starts_with( $upper, 'INDEX' ) || str_starts_with( $upper, 'FOREIGN KEY' ) ) { continue; } // First token is the column name (may be back-ticked) if ( preg_match( '/^`?(\w+)`?\s+/', $line, $cm ) ) { $columns[] = strtolower( $cm[1] ); } } return $columns; } /** * Use Reflection to read the PHP source code of a method. */ private function get_method_source( object $instance, string $method ): string { try { $ref = new ReflectionMethod( $instance, $method ); $file = $ref->getFileName(); $start = $ref->getStartLine(); $end = $ref->getEndLine(); if ( ! $file || ! is_readable( $file ) ) { return ''; } $lines = file( $file ); if ( $lines === false ) { return ''; } // getStartLine / getEndLine are 1-based return implode( '', array_slice( $lines, $start - 1, $end - $start + 1 ) ); } catch ( \ReflectionException $e ) { return ''; } } /** * Parse ALTER TABLE ADD / DROP COLUMN from the raw PHP source of * sql_update_columns_releases() and apply them to the column list. * * Recognised patterns: * ALTER TABLE ... ADD [COLUMN] `col_name` ... * ALTER TABLE ... DROP COLUMN `col_name` */ private function apply_migration_columns( array $columns, string $source ): array { // ADD [COLUMN] `column_name` or ADD column_name if ( preg_match_all( '/ALTER\s+TABLE\s+.+?\s+ADD\s+(?:COLUMN\s+)?`?(\w+)`?\s+/i', $source, $matches ) ) { foreach ( $matches[1] as $col ) { $col = strtolower( $col ); // Skip non-column keywords that might match if ( in_array( $col, [ 'index', 'key', 'unique', 'primary', 'constraint', 'foreign' ], true ) ) { continue; } if ( ! in_array( $col, $columns, true ) ) { $columns[] = $col; } } } // DROP COLUMN `column_name` if ( preg_match_all( '/ALTER\s+TABLE\s+.+?\s+DROP\s+COLUMN\s+`?(\w+)`?/i', $source, $matches ) ) { foreach ( $matches[1] as $col ) { $col = strtolower( $col ); $columns = array_filter( $columns, fn( $c ) => $c !== $col ); } } return $columns; } /* ------------------------------------------------------------------ */ /* LIVE DATABASE INSPECTION */ /* ------------------------------------------------------------------ */ private function get_full_table_name( string $table_key ): string { $db_instance = WP_ALEXR()->{$table_key} ?? null; if ( $db_instance ) { return $db_instance->table_name; } // Fallback global $wpdb; return $wpdb->prefix . EVAVEL_DB_NAMESPACE . '_' . $table_key; } private function table_exists_in_db( string $full_name ): bool { global $wpdb; return $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $full_name ) ) === $full_name; } /** * Return associative array of column_name => column_type for a live table. */ private function get_live_columns( string $full_name ): array { global $wpdb; $rows = $wpdb->get_results( "SHOW COLUMNS FROM `{$full_name}`" ); $columns = []; foreach ( $rows as $row ) { $columns[ strtolower( $row->Field ) ] = $row->Type; } return $columns; } /** * Return charset & collation of a table. */ private function get_table_charset_info( string $full_name ): array { global $wpdb; $row = $wpdb->get_row( "SELECT TABLE_COLLATION, ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$full_name}'" ); if ( ! $row ) { return [ 'charset' => '?', 'collation' => '?', 'engine' => '?' ]; } $collation = $row->TABLE_COLLATION ?? '?'; if ( str_starts_with( $collation, 'utf8mb4' ) ) { $charset = 'utf8mb4'; } elseif ( str_starts_with( $collation, 'utf8' ) ) { $charset = 'utf8 (utf8mb3)'; } elseif ( str_starts_with( $collation, 'latin' ) ) { $charset = 'latin1'; } else { $charset = strtok( $collation, '_' ); } return [ 'charset' => $charset, 'collation' => $collation, 'engine' => $row->ENGINE ?? '?', ]; } /* ------------------------------------------------------------------ */ /* PRIMARY KEY / AUTO_INCREMENT */ /* ------------------------------------------------------------------ */ /** * What sql_create() declares: does the table define a PRIMARY KEY and an * AUTO_INCREMENT column? * * The declaration is parsed instead of assumed so a future table without * auto-increment does not raise a false alarm. */ private function get_expected_key_info( string $table_key ): array { $db_instance = WP_ALEXR()->{$table_key} ?? null; if ( ! $db_instance || ! method_exists( $db_instance, 'sql_create' ) ) { return [ 'primary_key' => false, 'auto_increment' => false ]; } $sql = (string) $db_instance->sql_create(); return [ 'primary_key' => (bool) preg_match( '/PRIMARY\s+KEY/i', $sql ), 'auto_increment' => (bool) preg_match( '/AUTO_INCREMENT/i', $sql ), ]; } /** * Live PRIMARY KEY / AUTO_INCREMENT for every table, in a single query. * * One query for all 40 tables instead of a SHOW INDEX per table. The list of * names is explicit (never LIKE '%srr%') so tables from other plugins — or * from another install sharing the database — are never picked up. * * information_schema.COLUMNS is used rather than TABLES.AUTO_INCREMENT, * which is NULL both when there is no auto-increment column and in other * engine-dependent cases. * * @param string[] $full_names * @return array [ table_name => ['primary_key' => col|null, 'auto_increment' => bool] ] */ private function get_live_keys_info( array $full_names ): array { global $wpdb; if ( empty( $full_names ) ) { return []; } $in_list = implode( ',', array_map( static fn( $name ) => "'" . esc_sql( $name ) . "'", $full_names ) ); $rows = $wpdb->get_results( "SELECT TABLE_NAME, COLUMN_NAME, COLUMN_KEY, EXTRA FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ({$in_list}) AND ( COLUMN_KEY = 'PRI' OR EXTRA LIKE '%auto_increment%' )" ); $info = []; foreach ( (array) $rows as $row ) { $table = $row->TABLE_NAME; if ( ! isset( $info[ $table ] ) ) { $info[ $table ] = [ 'primary_key' => null, 'auto_increment' => false ]; } if ( $row->COLUMN_KEY === 'PRI' && $info[ $table ]['primary_key'] === null ) { $info[ $table ]['primary_key'] = $row->COLUMN_NAME; } if ( stripos( (string) $row->EXTRA, 'auto_increment' ) !== false ) { $info[ $table ]['auto_increment'] = true; } } return $info; } /** * Damage assessment for a table whose key is broken. * * Knowing that the PRIMARY KEY is gone is only half the answer. What decides * whether this is a one-line ALTER TABLE or a data recovery is whether rows * have already been written with id = 0 (they all collide), so it is counted * here — and only for the tables already flagged, because it is a full scan * on a table that by definition has no index. * * @return array ['zero_ids' => int, 'duplicated_ids' => int] */ private function count_broken_ids( string $full_name, string $pk_column = 'id' ): array { global $wpdb; $table = esc_sql( $full_name ); $column = esc_sql( $pk_column ); // La columna puede no existir (tabla sin id) $has_column = $wpdb->get_var( "SHOW COLUMNS FROM `{$table}` LIKE '{$column}'" ); if ( ! $has_column ) { return [ 'zero_ids' => 0, 'duplicated_ids' => 0 ]; } $zero = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE `{$column}` = 0 OR `{$column}` IS NULL" ); $duplicated = (int) $wpdb->get_var( "SELECT COUNT(*) - COUNT(DISTINCT `{$column}`) FROM `{$table}`" ); return [ 'zero_ids' => $zero, 'duplicated_ids' => max( 0, $duplicated ), ]; } /* ------------------------------------------------------------------ */ /* BUILD THE FULL REPORT */ /* ------------------------------------------------------------------ */ private function build_report(): array { $tables = $this->get_expected_tables(); $report = []; // Claves primarias de todas las tablas en una sola consulta $full_names = array_map( [ $this, 'get_full_table_name' ], $tables ); $live_keys = $this->get_live_keys_info( $full_names ); foreach ( $tables as $table_key ) { $full_name = $this->get_full_table_name( $table_key ); $exists = $this->table_exists_in_db( $full_name ); $entry = [ 'key' => $table_key, 'full_name' => $full_name, 'exists' => $exists, 'charset' => '—', 'collation' => '—', 'engine' => '—', 'missing_columns' => [], 'extra_columns' => [], 'expected_columns' => [], 'live_columns' => [], 'primary_key' => null, 'auto_increment' => false, 'key_problems' => [], 'zero_ids' => 0, 'duplicated_ids' => 0, 'status' => 'missing', ]; if ( $exists ) { $charset_info = $this->get_table_charset_info( $full_name ); $entry['charset'] = $charset_info['charset']; $entry['collation'] = $charset_info['collation']; $entry['engine'] = $charset_info['engine']; $expected = $this->get_expected_columns( $table_key ); $live = $this->get_live_columns( $full_name ); $live_names = array_keys( $live ); $entry['expected_columns'] = $expected; $entry['live_columns'] = $live; $entry['missing_columns'] = array_values( array_diff( $expected, $live_names ) ); $entry['extra_columns'] = array_values( array_diff( $live_names, $expected ) ); // CLAVE PRIMARIA / AUTO_INCREMENT // // Sin una de las dos, cada INSERT escribe id = 0 y todas las // filas nuevas comparten clave: a partir de ahi cualquier // escritura por id afecta a todas ellas a la vez. $expected_key = $this->get_expected_key_info( $table_key ); $live_key = $live_keys[ $full_name ] ?? [ 'primary_key' => null, 'auto_increment' => false ]; $entry['primary_key'] = $live_key['primary_key']; $entry['auto_increment'] = $live_key['auto_increment']; if ( $expected_key['primary_key'] && $live_key['primary_key'] === null ) { $entry['key_problems'][] = 'No PRIMARY KEY'; } if ( $expected_key['auto_increment'] && ! $live_key['auto_increment'] ) { $entry['key_problems'][] = 'No AUTO_INCREMENT'; } // Solo si la clave esta rota: cuanto daño hay ya en los datos if ( ! empty( $entry['key_problems'] ) ) { $damage = $this->count_broken_ids( $full_name, $live_key['primary_key'] ?: 'id' ); $entry['zero_ids'] = $damage['zero_ids']; $entry['duplicated_ids'] = $damage['duplicated_ids']; } // La clave rota manda sobre cualquier otro problema if ( ! empty( $entry['key_problems'] ) ) { $entry['status'] = 'key_broken'; } elseif ( empty( $entry['missing_columns'] ) ) { $entry['status'] = 'ok'; } else { $entry['status'] = 'columns_missing'; } } $report[] = $entry; } return $report; } /* ------------------------------------------------------------------ */ /* RENDER */ /* ------------------------------------------------------------------ */ public function render_page() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( __( 'You do not have permission to access this page.', 'alexr' ) ); } $report = $this->build_report(); $total = count( $report ); $ok = count( array_filter( $report, fn( $r ) => $r['status'] === 'ok' ) ); $missing = count( array_filter( $report, fn( $r ) => $r['status'] === 'missing' ) ); $key_broken = array_values( array_filter( $report, fn( $r ) => $r['status'] === 'key_broken' ) ); $issues = $total - $ok - $missing - count( $key_broken ); $damaged_rows = array_sum( array_column( $key_broken, 'zero_ids' ) ); ?>
Without them the database cannot generate an id, so every new row is written with
id = 0 and they all end up sharing the same key. This normally happens
after a hosting migration or a data-only restore, which do not carry keys over.
row
already carry id = 0.
The key cannot simply be added back: those rows need a unique id first, and any row
pointing at them (assigned tables, tags, meta) has to be re-linked. Take a backup before
touching anything.
No row carries id = 0 yet, so restoring the key should be straightforward.
Do it before the next booking comes in.
Updating the plugin will not fix this: WordPress's dbDelta() does not
reliably add a missing PRIMARY KEY. It has to be restored on the database.
Tables: | OK: | Missing tables: | Broken keys: | Column issues: All tables are correct
| # | Table | Exists | Charset | Engine | Primary key | Columns | Issues |
|---|---|---|---|---|---|---|---|
|
|
✓ Yes ✗ No |
— |
—
✗
✓
auto_increment |
/ — |
Table does not exist
0 ): ?>
row with id = 0 0 ): ?> duplicated id Missing columns:
Missing columns:
✓
Extra columns: |
Generated at — Plugin v (Release )