From 23ecd0bdb5b88b1ed001a1ec20baefe01f3caaf4 Mon Sep 17 00:00:00 2001 From: David Stone Date: Sun, 16 Aug 2026 09:12:41 -0600 Subject: [PATCH 1/3] fix(duplication): preserve FluentCRM table schemas --- inc/compat/class-general-compat.php | 25 +++++++++++++++++++ .../WP_Ultimo/Duplication/MUCD_Data_Test.php | 23 ++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/inc/compat/class-general-compat.php b/inc/compat/class-general-compat.php index e56ffff4..4969ac56 100644 --- a/inc/compat/class-general-compat.php +++ b/inc/compat/class-general-compat.php @@ -119,6 +119,7 @@ public function init(): void { * @see https://fluentcrm.com/ */ add_action('wp_insert_site', [$this, 'fix_fluent_pro_site_duplication']); + add_filter('wu_mucd_should_copy_table', [$this, 'force_copy_fluentcrm_tables'], 10, 3); /** * WordPress Core - HTTPS scheme fix for subdomain installs. @@ -753,6 +754,30 @@ public function fix_fluent_pro_site_duplication(): void { } } + /** + * Ensure FluentCRM's per-site table schemas are copied during duplication. + * + * FluentCRM relationship tables can be empty on a template site while still + * being required by queries on the cloned site. The duplication optimizer + * normally skips empty custom tables, so force all FluentCRM tables through + * the normal schema-and-data copy path. + * + * @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. + * @return bool + */ + public function force_copy_fluentcrm_tables($copy, $table, $table_base_name) { + + if (str_starts_with((string) $table_base_name, 'fc_')) { + return true; + } + + return $copy; + } + /** * Fixes the URL scheme for new sites on subdomain installs. * diff --git a/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php b/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php index 3cbfcab7..03dc6bdf 100644 --- a/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php +++ b/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php @@ -695,6 +695,26 @@ public function test_should_copy_table_keeps_required_tables_even_when_empty() { ); } + /** + * Test empty FluentCRM tables are copied so their schemas exist on clones. + */ + public function test_should_copy_table_keeps_empty_fluentcrm_tables() { + global $wpdb; + + $table = $wpdb->get_blog_prefix() . 'fc_subscriber_pivot'; + + $this->create_table_selection_fixture($table); + \WP_Ultimo\Compat\General_Compat::get_instance(); + + try { + $this->assertTrue( + \MUCD_Data::should_copy_table($table, 'fc_subscriber_pivot', get_current_blog_id(), 123) + ); + } finally { + $this->drop_table_selection_fixture($table); + } + } + /** * Test filters can force-copy a table skipped by default. */ @@ -752,7 +772,8 @@ private function create_table_selection_fixture($table): void { $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. + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Test fixture table name cannot be bound. + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- 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))" ); } From c8b0ffe9158362fa042eaa6abbfec58c071b014d Mon Sep 17 00:00:00 2001 From: David Stone Date: Sun, 16 Aug 2026 09:29:31 -0600 Subject: [PATCH 2/3] fix(duplication): copy empty table schemas by default --- inc/compat/class-general-compat.php | 25 ------------------ inc/duplication/data.php | 11 ++++---- .../WP_Ultimo/Duplication/MUCD_Data_Test.php | 26 +++++++++++-------- 3 files changed, 20 insertions(+), 42 deletions(-) diff --git a/inc/compat/class-general-compat.php b/inc/compat/class-general-compat.php index 4969ac56..e56ffff4 100644 --- a/inc/compat/class-general-compat.php +++ b/inc/compat/class-general-compat.php @@ -119,7 +119,6 @@ public function init(): void { * @see https://fluentcrm.com/ */ add_action('wp_insert_site', [$this, 'fix_fluent_pro_site_duplication']); - add_filter('wu_mucd_should_copy_table', [$this, 'force_copy_fluentcrm_tables'], 10, 3); /** * WordPress Core - HTTPS scheme fix for subdomain installs. @@ -754,30 +753,6 @@ public function fix_fluent_pro_site_duplication(): void { } } - /** - * Ensure FluentCRM's per-site table schemas are copied during duplication. - * - * FluentCRM relationship tables can be empty on a template site while still - * being required by queries on the cloned site. The duplication optimizer - * normally skips empty custom tables, so force all FluentCRM tables through - * the normal schema-and-data copy path. - * - * @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. - * @return bool - */ - public function force_copy_fluentcrm_tables($copy, $table, $table_base_name) { - - if (str_starts_with((string) $table_base_name, 'fc_')) { - return true; - } - - return $copy; - } - /** * Fixes the URL scheme for new sites on subdomain installs. * diff --git a/inc/duplication/data.php b/inc/duplication/data.php index d32807b7..c366cec0 100644 --- a/inc/duplication/data.php +++ b/inc/duplication/data.php @@ -154,10 +154,9 @@ public static function db_copy_tables($from_site_id, $to_site_id) { * * 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. + * per-site tables are copied by default so plugins retain the complete schema + * they expect on the destination. Installations can opt into skipping empty + * optional tables through the dedicated filter. * * @since 2.5.1 * @@ -182,7 +181,7 @@ public static function should_copy_table($table, $table_base_name, $from_site_id * * 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. + * keeping runtime table exclusions centralized. * * @since 2.5.1 * @@ -297,7 +296,7 @@ private static function skip_empty_tables($from_site_id, $to_site_id) { * @param int $from_site_id Source site ID. * @param int $to_site_id Target site ID. */ - return (bool) apply_filters('wu_mucd_skip_empty_tables', true, $from_site_id, $to_site_id); + return (bool) apply_filters('wu_mucd_skip_empty_tables', false, $from_site_id, $to_site_id); } /** diff --git a/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php b/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php index 03dc6bdf..8355333f 100644 --- a/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php +++ b/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php @@ -642,18 +642,18 @@ public function test_should_copy_table_skips_runtime_tables() { } /** - * Test empty optional template tables are skipped by default. + * Test empty optional template tables are copied by default. */ - public function test_should_copy_table_skips_empty_optional_tables() { + public function test_should_copy_table_keeps_empty_optional_tables_by_default() { global $wpdb; - $table = $wpdb->get_blog_prefix() . 'wu_empty_runtime_fixture'; + $table = $wpdb->get_blog_prefix() . 'fc_subscriber_pivot'; $this->create_table_selection_fixture($table); try { - $this->assertFalse( - \MUCD_Data::should_copy_table($table, 'wu_empty_runtime_fixture', get_current_blog_id(), 123) + $this->assertTrue( + \MUCD_Data::should_copy_table($table, 'fc_subscriber_pivot', get_current_blog_id(), 123) ); } finally { $this->drop_table_selection_fixture($table); @@ -696,21 +696,25 @@ public function test_should_copy_table_keeps_required_tables_even_when_empty() { } /** - * Test empty FluentCRM tables are copied so their schemas exist on clones. + * Test installations can opt into skipping empty optional tables. */ - public function test_should_copy_table_keeps_empty_fluentcrm_tables() { + public function test_should_copy_table_can_skip_empty_optional_tables_when_enabled() { global $wpdb; - $table = $wpdb->get_blog_prefix() . 'fc_subscriber_pivot'; + $table = $wpdb->get_blog_prefix() . 'wu_empty_runtime_fixture'; + $filter = static function () { + return true; + }; $this->create_table_selection_fixture($table); - \WP_Ultimo\Compat\General_Compat::get_instance(); + add_filter('wu_mucd_skip_empty_tables', $filter, 10, 3); try { - $this->assertTrue( - \MUCD_Data::should_copy_table($table, 'fc_subscriber_pivot', get_current_blog_id(), 123) + $this->assertFalse( + \MUCD_Data::should_copy_table($table, 'wu_empty_runtime_fixture', get_current_blog_id(), 123) ); } finally { + remove_filter('wu_mucd_skip_empty_tables', $filter, 10); $this->drop_table_selection_fixture($table); } } From 1ba38e4b887689336590617bf0c0fb14877cc17d Mon Sep 17 00:00:00 2001 From: David Stone Date: Sun, 16 Aug 2026 14:12:01 -0600 Subject: [PATCH 3/3] test(duplication): isolate FluentCRM fixture --- tests/WP_Ultimo/Duplication/MUCD_Data_Test.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php b/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php index 8355333f..279f08ae 100644 --- a/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php +++ b/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php @@ -647,13 +647,14 @@ public function test_should_copy_table_skips_runtime_tables() { public function test_should_copy_table_keeps_empty_optional_tables_by_default() { global $wpdb; - $table = $wpdb->get_blog_prefix() . 'fc_subscriber_pivot'; + $table_base_name = 'fc_wp_ultimo_test_subscriber_pivot'; + $table = $wpdb->get_blog_prefix() . $table_base_name; $this->create_table_selection_fixture($table); try { $this->assertTrue( - \MUCD_Data::should_copy_table($table, 'fc_subscriber_pivot', get_current_blog_id(), 123) + \MUCD_Data::should_copy_table($table, $table_base_name, get_current_blog_id(), 123) ); } finally { $this->drop_table_selection_fixture($table);