From 9dcf4a9c8d41b0008778f3b9a84f20d646b48f9a Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 30 Jun 2026 05:07:57 -0600 Subject: [PATCH] wip: optimize template table copy --- inc/duplication/data.php | 210 +++++++++++++++++- .../WP_Ultimo/Duplication/MUCD_Data_Test.php | 125 +++++++++++ 2 files changed, 327 insertions(+), 8 deletions(-) diff --git a/inc/duplication/data.php b/inc/duplication/data.php index 6bac8a8d..00439ac5 100644 --- a/inc/duplication/data.php +++ b/inc/duplication/data.php @@ -114,17 +114,10 @@ public static function db_copy_tables($from_site_id, $to_site_id) { $from_site_table = self::get_existing_blog_tables($from_site_id); } - $tables_to_ignore = [ - 'actionscheduler_actions', - 'actionscheduler_claims', - 'actionscheduler_groups', - 'actionscheduler_logs', - ]; - foreach ($from_site_table as $table) { $table_base_name = substr((string) $table, $from_site_prefix_length); - if (in_array($table_base_name, $tables_to_ignore, true)) { + if ( ! self::should_copy_table($table, $table_base_name, $from_site_id, $to_site_id)) { continue; } @@ -156,6 +149,207 @@ public static function db_copy_tables($from_site_id, $to_site_id) { return $saved_options; } + /** + * Determine whether a source table should be copied to the duplicated site. + * + * Runtime-only tables are skipped by default because queues, logs, caches, + * analytics, and sessions are regenerated on the destination site. Empty + * optional/custom tables are also skipped to avoid spending most of the + * duplication time cloning schemas with no template data. WordPress core + * content/config tables are always copied because wpmu_create_blog() creates + * destination defaults that must be replaced with template values. + * + * @since 2.5.1 + * + * @param string $table Full source table name. + * @param string $table_base_name Source table name without blog prefix. + * @param int $from_site_id Source site ID. + * @param int $to_site_id Target site ID. + * @return bool Whether the table should be copied. + */ + public static function should_copy_table($table, $table_base_name, $from_site_id, $to_site_id) { + + $copy = true; + + if (self::is_runtime_table($table_base_name)) { + $copy = false; + } elseif (self::should_skip_empty_table($table, $table_base_name, $from_site_id, $to_site_id)) { + $copy = false; + } + + /** + * Filter whether a source table should be copied during site duplication. + * + * Returning true forces a table to be copied; returning false skips it. + * This preserves extension control over custom table duplication while + * keeping the default path optimized for empty/runtime template tables. + * + * @since 2.5.1 + * + * @param bool $copy Whether the table should be copied. + * @param string $table Full source table name. + * @param string $table_base_name Source table name without blog prefix. + * @param int $from_site_id Source site ID. + * @param int $to_site_id Target site ID. + */ + return (bool) apply_filters('mucd_should_copy_table', $copy, $table, $table_base_name, $from_site_id, $to_site_id); + } + + /** + * Check whether a source table is runtime-only data that should not be cloned. + * + * @since 2.5.1 + * + * @param string $table_base_name Source table name without blog prefix. + * @return bool Whether the table is runtime-only. + */ + public static function is_runtime_table($table_base_name) { + + $runtime_tables = [ + 'actionscheduler_actions', + 'actionscheduler_claims', + 'actionscheduler_groups', + 'actionscheduler_logs', + 'blc_filters', + 'blc_instances', + 'blc_links', + 'blc_synch', + 'ewwwio_images', + 'icl_background_task', + 'icl_translate_job', + 'icl_translation_batches', + 'litespeed_avatar', + 'litespeed_img_optm', + 'litespeed_img_optming', + 'litespeed_url', + 'litespeed_url_file', + 'quform_entries', + 'quform_entry_data', + 'quform_entry_entry_labels', + 'quform_entry_labels', + 'redirection_404', + 'redirection_logs', + 'woocommerce_api_keys', + 'woocommerce_downloadable_product_permissions', + 'woocommerce_log', + 'woocommerce_sessions', + 'yoast_indexable', + 'yoast_indexable_hierarchy', + 'yoast_migrations', + 'yoast_primary_term', + 'yoast_seo_links', + 'yoast_seo_meta', + ]; + + /** + * Filter runtime-only table base names skipped during site duplication. + * + * @since 2.5.1 + * + * @param array $runtime_tables Runtime-only table base names. + */ + $runtime_tables = (array) apply_filters('mucd_runtime_tables_to_ignore', $runtime_tables); + + return in_array($table_base_name, $runtime_tables, true); + } + + /** + * Determine whether an empty source table can be skipped. + * + * @since 2.5.1 + * + * @param string $table Full source table name. + * @param string $table_base_name Source table name without blog prefix. + * @param int $from_site_id Source site ID. + * @param int $to_site_id Target site ID. + * @return bool Whether the table should be skipped because it is empty. + */ + private static function should_skip_empty_table($table, $table_base_name, $from_site_id, $to_site_id) { + + if ( ! self::skip_empty_tables($from_site_id, $to_site_id)) { + return false; + } + + if (self::is_required_table($table_base_name)) { + return false; + } + + return 0 === self::get_table_row_count($table); + } + + /** + * Check whether empty optional/custom tables should be skipped. + * + * @since 2.5.1 + * + * @param int $from_site_id Source site ID. + * @param int $to_site_id Target site ID. + * @return bool Whether empty optional/custom tables should be skipped. + */ + private static function skip_empty_tables($from_site_id, $to_site_id) { + + /** + * Filter whether empty optional/custom source tables are skipped. + * + * @since 2.5.1 + * + * @param bool $skip_empty_tables Whether to skip empty optional tables. + * @param int $from_site_id Source site ID. + * @param int $to_site_id Target site ID. + */ + return (bool) apply_filters('mucd_skip_empty_tables', true, $from_site_id, $to_site_id); + } + + /** + * Check whether a table is required for a faithful template clone. + * + * @since 2.5.1 + * + * @param string $table_base_name Source table name without blog prefix. + * @return bool Whether the table should be copied even when empty. + */ + private static function is_required_table($table_base_name) { + + $required_tables = [ + 'commentmeta', + 'comments', + 'links', + 'options', + 'postmeta', + 'posts', + 'term_relationships', + 'term_taxonomy', + 'termmeta', + 'terms', + ]; + + /** + * Filter required table base names copied even when empty. + * + * @since 2.5.1 + * + * @param array $required_tables Required table base names. + */ + $required_tables = (array) apply_filters('mucd_required_tables_to_copy', $required_tables); + + return in_array($table_base_name, $required_tables, true); + } + + /** + * Count rows in a source table. + * + * @since 2.5.1 + * + * @param string $table Full source table name. + * @return int Source table row count. + */ + private static function get_table_row_count($table) { + + $count = self::do_sql_query('SELECT COUNT(*) FROM `' . $table . '`', 'var', false); + + return (int) $count; + } + /** * Get tables to copy if duplicated site is primary site * diff --git a/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php b/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php index b0fcce16..7b0803ec 100644 --- a/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php +++ b/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php @@ -629,6 +629,131 @@ public function test_get_primary_key_is_cached() { $this->assertEquals('meta_id', $pk1); } + /** + * Test runtime-only tables are excluded from template copies by default. + */ + public function test_should_copy_table_skips_runtime_tables() { + global $wpdb; + + $table = $wpdb->get_blog_prefix() . 'actionscheduler_actions'; + + $this->assertFalse( + \MUCD_Data::should_copy_table($table, 'actionscheduler_actions', get_current_blog_id(), 123) + ); + } + + /** + * Test empty optional template tables are skipped by default. + */ + public function test_should_copy_table_skips_empty_optional_tables() { + global $wpdb; + + $table = $wpdb->get_blog_prefix() . 'wu_empty_runtime_fixture'; + + $this->create_table_selection_fixture($table); + + try { + $this->assertFalse( + \MUCD_Data::should_copy_table($table, 'wu_empty_runtime_fixture', get_current_blog_id(), 123) + ); + } finally { + $this->drop_table_selection_fixture($table); + } + } + + /** + * Test non-empty optional template tables are still copied. + */ + public function test_should_copy_table_keeps_non_empty_optional_tables() { + global $wpdb; + + $table = $wpdb->get_blog_prefix() . 'wu_non_empty_fixture'; + + $this->create_table_selection_fixture($table); + $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Test fixture insert. + $table, + ['value' => 'copy-me'], + ['%s'] + ); + + try { + $this->assertTrue( + \MUCD_Data::should_copy_table($table, 'wu_non_empty_fixture', get_current_blog_id(), 123) + ); + } finally { + $this->drop_table_selection_fixture($table); + } + } + + /** + * Test required WordPress tables are copied even when empty. + */ + public function test_should_copy_table_keeps_required_tables_even_when_empty() { + global $wpdb; + + $this->assertTrue( + \MUCD_Data::should_copy_table($wpdb->comments, 'comments', get_current_blog_id(), 123) + ); + } + + /** + * Test filters can force-copy a table skipped by default. + */ + public function test_should_copy_table_filter_can_force_copy() { + global $wpdb; + + $table = $wpdb->get_blog_prefix() . 'actionscheduler_actions'; + + $filter = function ($copy, $source_table, $table_base_name) use ($table) { + if ($table === $source_table && 'actionscheduler_actions' === $table_base_name) { + return true; + } + + return $copy; + }; + + add_filter( + 'mucd_should_copy_table', + $filter, + 10, + 3 + ); + + try { + $this->assertTrue( + \MUCD_Data::should_copy_table($table, 'actionscheduler_actions', get_current_blog_id(), 123) + ); + } finally { + remove_filter('mucd_should_copy_table', $filter, 10); + } + } + + /** + * Create a temporary table used by table-selection tests. + * + * @param string $table Table name. + */ + private function create_table_selection_fixture($table): void { + global $wpdb; + + $this->drop_table_selection_fixture($table); + + $wpdb->query( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Test fixture table name cannot be bound. + "CREATE TABLE `{$table}` (id bigint(20) unsigned NOT NULL AUTO_INCREMENT, value varchar(20) NOT NULL DEFAULT '', PRIMARY KEY (id))" + ); + } + + /** + * Drop a temporary table used by table-selection tests. + * + * @param string $table Table name. + */ + private function drop_table_selection_fixture($table): void { + global $wpdb; + + $wpdb->query("DROP TABLE IF EXISTS `{$table}`"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Test fixture table name cannot be bound. + } + /** * Test that serialized boolean false (b:0;) is handled correctly. *