query( $wpdb->prepare( "UPDATE `{$table}` SET `post_status` = %s, `post_date` = %s WHERE `post_id` = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name. $post->post_status, $post->post_date, $post->ID ) ); } } /** * A post group table was just created or altered: an empty one gets the * columns now; one holding rows waits for the CLI. * * @param string $entity_type Entity type. * @param string $group Group name. * @param string $table Table name. * @return void */ public static function on_schema_updated( $entity_type, $group, $table ): void { global $wpdb; if ( 'post' !== $entity_type || '' !== self::state( $group ) ) { return; } if ( null !== $wpdb->get_var( "SELECT 1 FROM `{$table}` LIMIT 1" ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name. return; } if ( self::add_columns( $group ) ) { self::set_state( $group, 'ready' ); } } /** * Copies for a row a flat write is about to create (empty when the * group's table has no such columns). * * @param int $post_id Post ID. * @param string $group Group name. * @return array{post_status?:string, post_date?:string} */ public static function insert_columns( int $post_id, string $group ): array { if ( '' === self::state( $group ) ) { return array(); } $post = get_post( $post_id ); if ( ! $post ) { return array(); } return array( 'post_status' => $post->post_status, 'post_date' => $post->post_date, ); } /** * Add the columns to every post group table and copy posts' values in. * * @param bool $dry_run Count mismatching rows only. * @param int $batch Rows per UPDATE. * @return array */ public static function run( bool $dry_run = false, int $batch = 1000 ): array { global $wpdb; $batch = max( 1, $batch ); $report = array(); foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) { $table = TMDO_Schema_Manager::get_table_name( 'post', $group ); if ( ! TMDO_Schema_Manager::table_exists( $table ) ) { continue; } $rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name. $report[ $group ] = array( 'rows' => $rows, 'mismatched' => '' === self::state( $group ) ? $rows : self::mismatches( $group ), ); if ( $dry_run || ! self::add_columns( $group ) ) { continue; } $max = (int) $wpdb->get_var( "SELECT MAX(id) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name. for ( $from = 0; $from < $max; $from += $batch ) { $wpdb->query( $wpdb->prepare( "UPDATE `{$table}` t JOIN `{$wpdb->posts}` p ON p.ID = t.post_id SET t.post_status = p.post_status, t.post_date = p.post_date WHERE t.id > %d AND t.id <= %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name. $from, $from + $batch ) ); } self::set_state( $group, 'ready' ); } return $report; } /** * Rows whose copies differ from posts (rows of deleted posts excluded). * * @param string $group Group name. * @return int */ public static function mismatches( string $group ): int { global $wpdb; $table = TMDO_Schema_Manager::get_table_name( 'post', $group ); return (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` t JOIN `{$wpdb->posts}` p ON p.ID = t.post_id WHERE NOT ( t.post_status <=> p.post_status ) OR NOT ( t.post_date <=> p.post_date )" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name. ); } /** * Add whichever of the two columns and the index the table lacks. * * @param string $group Group name. * @return bool False when the table is missing or the ALTER failed. */ private static function add_columns( string $group ): bool { global $wpdb; $table = TMDO_Schema_Manager::get_table_name( 'post', $group ); if ( ! TMDO_Schema_Manager::table_exists( $table ) ) { return false; } $columns = (array) $wpdb->get_col( "SHOW COLUMNS FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name. $indexes = array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name. $changes = array(); if ( ! in_array( 'post_status', $columns, true ) ) { // Same collation as posts.post_status: the table default may differ, // and comparing the two columns would then fail (illegal mix of collations). $source = $wpdb->get_row( "SHOW FULL COLUMNS FROM `{$wpdb->posts}` LIKE 'post_status'", ARRAY_A ); $collation = preg_replace( '/[^A-Za-z0-9_]/', '', (string) ( $source['Collation'] ?? '' ) ); $changes[] = 'ADD COLUMN `post_status` VARCHAR(20)' . ( '' !== $collation ? " COLLATE {$collation}" : '' ) . ' DEFAULT NULL'; } if ( ! in_array( 'post_date', $columns, true ) ) { $changes[] = 'ADD COLUMN `post_date` DATETIME DEFAULT NULL'; } if ( ! in_array( self::INDEX, $indexes, true ) ) { // Ends with post_id: the router reads the date route's ID tie-break // from it, so MySQL walks the index and stops at the LIMIT (2.4 PR-128). $changes[] = 'ADD INDEX `' . self::INDEX . '` (`post_status`, `post_date`, `post_id`)'; } if ( in_array( self::LEGACY_INDEX, $indexes, true ) ) { $changes[] = 'DROP INDEX `' . self::LEGACY_INDEX . '`'; } if ( $changes && false === $wpdb->query( "ALTER TABLE `{$table}` " . implode( ', ', $changes ) ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Fixed DDL on a registry table name. return false; } if ( '' === self::state( $group ) ) { self::set_state( $group, 'columns' ); } return true; } /** * Post groups whose table has the columns. * * @return string[] */ private static function groups_with_columns(): array { $states = get_option( self::OPTION, array() ); return is_array( $states ) ? array_keys( $states ) : array(); } /** * Drop a group from the recorded states. * * @param string $group Group name. * @return void */ private static function forget( string $group ): void { $states = get_option( self::OPTION, array() ); if ( is_array( $states ) && isset( $states[ $group ] ) ) { unset( $states[ $group ] ); update_option( self::OPTION, $states, true ); } } /** * Record a group's state. * * @param string $group Group name. * @param string $state 'columns' | 'ready'. * @return void */ private static function set_state( string $group, string $state ): void { $states = get_option( self::OPTION, array() ); $states = is_array( $states ) ? $states : array(); $states[ $group ] = $state; update_option( self::OPTION, $states, true ); } }