suppress_errors( true ); try { $columns = $wpdb->get_col( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $table ) ); $this->check( $wpdb ); $changes = []; foreach ( [ 'user_id' => 'customer_user_id', 'member_id' => 'customer_member_id' ] as $old => $new ) { if ( in_array( $old, $columns, true ) && ! in_array( $new, $columns, true ) ) { $changes[] = "CHANGE $old $new bigint unsigned DEFAULT NULL"; } elseif ( ! in_array( $new, $columns, true ) || in_array( $old, $columns, true ) ) { $this->fail(); } } if ( ! in_array( 'created_by_user_id', $columns, true ) ) { $changes[] = 'ADD created_by_user_id bigint unsigned DEFAULT NULL AFTER customer_member_id'; } if ( ! in_array( 'participant_count', $columns, true ) ) { $changes[] = "ADD participant_count smallint unsigned NOT NULL DEFAULT '1' AFTER created_by_user_id"; } if ( ! in_array( 'booking_source', $columns, true ) ) { $changes[] = "ADD booking_source varchar(20) NOT NULL DEFAULT 'web' AFTER participant_count"; } // Preflight overflow before touching DDL. Legacy guests remain as historical rows. $overflow = $wpdb->get_var( $wpdb->prepare( 'SELECT booking_id FROM %i GROUP BY booking_id HAVING COUNT(*) > 65535 LIMIT 1', $participants ) ); $this->check( $wpdb ); if ( null !== $overflow ) { $this->fail(); } if ( $changes ) { $wpdb->query( $wpdb->prepare( 'ALTER TABLE %i ' . implode( ', ', $changes ), $table ) ); $this->check( $wpdb ); } // NULL creator is an explicit unfinished-backfill marker; retry never recomputes completed rows. $wpdb->query( 'START TRANSACTION' ); $this->check( $wpdb ); try { $wpdb->query( $wpdb->prepare( 'UPDATE %i b SET participant_count = GREATEST(1, (SELECT COUNT(*) FROM %i p WHERE p.booking_id = b.id)), booking_source = %s, created_by_user_id = customer_user_id WHERE created_by_user_id IS NULL', $table, $participants, 'web' ) ); $this->check( $wpdb ); $wpdb->query( 'COMMIT' ); $this->check( $wpdb ); } catch ( \Throwable $error ) { $wpdb->query( 'ROLLBACK' ); throw $error; } // Keep canonical physical column order. Renames preserve the existing ownership indexes. $wpdb->query( $wpdb->prepare( 'ALTER TABLE %i MODIFY created_by_user_id bigint unsigned NOT NULL AFTER customer_member_id, MODIFY participant_count smallint unsigned NOT NULL DEFAULT \'1\' AFTER created_by_user_id, MODIFY booking_source varchar(20) NOT NULL DEFAULT \'web\' AFTER participant_count', $table ) ); $this->check( $wpdb ); $indexes = $wpdb->get_col( $wpdb->prepare( 'SHOW INDEX FROM %i', $table ), 2 ); $this->check( $wpdb ); if ( ! in_array( 'idx_asb_bookings_creator', $indexes, true ) ) { $wpdb->query( $wpdb->prepare( 'ALTER TABLE %i ADD KEY idx_asb_bookings_creator (created_by_user_id)', $table ) ); $this->check( $wpdb ); } Schema::verify( $wpdb, $table, ( in_array( 'external_contact_snapshot', $columns, true ) ? Schema::externalContacts( $wpdb ) : ( in_array( 'cancellation_policy_snapshot', $columns, true ) ? Schema::cancellation( $wpdb ) : ( in_array( 'pricing_snapshot', $columns, true ) ? Schema::pricing( $wpdb ) : Schema::bookingModel( $wpdb ) ) ) )[ $table ] ); } finally { $wpdb->suppress_errors( $previous ); } } private function check( \wpdb $wpdb ): void { if ( $wpdb->last_error ) { $this->fail(); } } private function fail(): never { throw new RuntimeException( __( 'The booking model migration could not be completed. Existing data has been retained.', 'abece-booking' ) ); } }