Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 202 additions & 8 deletions inc/duplication/data.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Rename the new filters to use the wu_ prefix.

These are new public extension points, but mucd_* violates the repository hook naming contract. Rename them before release, and update the tests accordingly. As per coding guidelines, “Hooks (actions and filters) must use the wu_ prefix”. <coding_guidelines>

Proposed hook rename
-return (bool) apply_filters('mucd_should_copy_table', $copy, $table, $table_base_name, $from_site_id, $to_site_id);
+return (bool) apply_filters('wu_mucd_should_copy_table', $copy, $table, $table_base_name, $from_site_id, $to_site_id);

-$runtime_tables = (array) apply_filters('mucd_runtime_tables_to_ignore', $runtime_tables);
+$runtime_tables = (array) apply_filters('wu_mucd_runtime_tables_to_ignore', $runtime_tables);

-return (bool) apply_filters('mucd_skip_empty_tables', true, $from_site_id, $to_site_id);
+return (bool) apply_filters('wu_mucd_skip_empty_tables', true, $from_site_id, $to_site_id);

-$required_tables = (array) apply_filters('mucd_required_tables_to_copy', $required_tables);
+$required_tables = (array) apply_filters('wu_mucd_required_tables_to_copy', $required_tables);

Also applies to: 251-251, 300-300, 333-333

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@inc/duplication/data.php` at line 195, The new public filter hooks are using
the wrong prefix and should be renamed to match the repository hook contract.
Update the hook names in the duplication flow methods that call apply_filters,
including the table-copy hook in the data duplication logic and the other
related filter sites noted in this diff, replacing every mucd_* hook name with
the corresponding wu_* name. Then update any tests and expectations that
reference these hook names so they match the renamed hooks.

Source: Coding guidelines

}

/**
* 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fail open and avoid full table counts for emptiness checks.

get_table_row_count() casts a failed count query to 0, so a transient SQL/count failure can make an optional table look empty and skip its schema. The predicate only needs row existence; probe one row and copy on probe errors.

Proposed fail-open existence probe
-			return 0 === self::get_table_row_count($table);
+			return ! self::table_has_rows($table);
 		}
@@
-		private static function get_table_row_count($table) {
+		private static function table_has_rows($table) {
+			global $wpdb;
 
-			$count = self::do_sql_query('SELECT COUNT(*) FROM `' . $table . '`', 'var', false);
+			$wpdb->last_error = '';
+			$has_rows         = self::do_sql_query('SELECT 1 FROM `' . $table . '` LIMIT 1', 'var', false);
 
-			return (int) $count;
+			if ('' !== $wpdb->last_error) {
+				return true;
+			}
+
+			return null !== $has_rows;
 		}

Also applies to: 346-350

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@inc/duplication/data.php` at line 277, The emptiness check currently relies
on get_table_row_count(), which can hide a transient count failure by returning
0 and incorrectly treat a table as empty. Update the table-existence predicate
in the duplication/data.php logic to probe for a single row instead of doing a
full COUNT, and make the check fail open so probe errors still preserve the
schema copy path. Apply the same change anywhere the same empty-table check is
duplicated, including the other affected block near the schema-copy logic.

}

/**
* 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
*
Expand Down
125 changes: 125 additions & 0 deletions tests/WP_Ultimo/Duplication/MUCD_Data_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,12 @@
* Test try_replace with serialized array data.
*/
public function test_try_replace_serialized_array() {
$data = serialize(['url' => 'https://example.com/old-site/page']);

Check warning on line 93 in tests/WP_Ultimo/Duplication/MUCD_Data_Test.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

serialize() found. Serialized data has known vulnerability problems with Object Injection. JSON is generally a better approach for serializing data. See https://www.owasp.org/index.php/PHP_Object_Injection
$row = ['meta_value' => $data];

Check warning on line 94 in tests/WP_Ultimo/Duplication/MUCD_Data_Test.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Detected usage of meta_value, possible slow query.

$result = \MUCD_Data::try_replace($row, 'meta_value', 'example.com/old-site', 'example.com/new-site');

$unserialized = unserialize($result);

Check warning on line 98 in tests/WP_Ultimo/Duplication/MUCD_Data_Test.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

unserialize() found. Serialized data has known vulnerability problems with Object Injection. JSON is generally a better approach for serializing data. See https://www.owasp.org/index.php/PHP_Object_Injection
$this->assertEquals('https://example.com/new-site/page', $unserialized['url']);
}

Expand All @@ -103,17 +103,17 @@
* Test try_replace with nested serialized data.
*/
public function test_try_replace_nested_serialized_array() {
$data = serialize([

Check warning on line 106 in tests/WP_Ultimo/Duplication/MUCD_Data_Test.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

serialize() found. Serialized data has known vulnerability problems with Object Injection. JSON is generally a better approach for serializing data. See https://www.owasp.org/index.php/PHP_Object_Injection
'settings' => [
'link' => 'https://example.com/old-site/about',
'icon' => 'fa-home',
],
'content' => 'Visit https://example.com/old-site for more',
]);
$row = ['meta_value' => $data];

Check warning on line 113 in tests/WP_Ultimo/Duplication/MUCD_Data_Test.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Detected usage of meta_value, possible slow query.

$result = \MUCD_Data::try_replace($row, 'meta_value', 'example.com/old-site', 'example.com/new-site');
$unserialized = unserialize($result);

Check warning on line 116 in tests/WP_Ultimo/Duplication/MUCD_Data_Test.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

unserialize() found. Serialized data has known vulnerability problems with Object Injection. JSON is generally a better approach for serializing data. See https://www.owasp.org/index.php/PHP_Object_Injection

$this->assertEquals('https://example.com/new-site/about', $unserialized['settings']['link']);
$this->assertEquals('Visit https://example.com/new-site for more', $unserialized['content']);
Expand All @@ -135,12 +135,12 @@
* Test try_replace with double-serialized data.
*/
public function test_try_replace_double_serialized() {
$data = serialize(serialize(['url' => 'https://example.com/old-site/page']));

Check warning on line 138 in tests/WP_Ultimo/Duplication/MUCD_Data_Test.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

serialize() found. Serialized data has known vulnerability problems with Object Injection. JSON is generally a better approach for serializing data. See https://www.owasp.org/index.php/PHP_Object_Injection

Check warning on line 138 in tests/WP_Ultimo/Duplication/MUCD_Data_Test.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

serialize() found. Serialized data has known vulnerability problems with Object Injection. JSON is generally a better approach for serializing data. See https://www.owasp.org/index.php/PHP_Object_Injection
$row = ['meta_value' => $data];

Check warning on line 139 in tests/WP_Ultimo/Duplication/MUCD_Data_Test.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Detected usage of meta_value, possible slow query.

$result = \MUCD_Data::try_replace($row, 'meta_value', 'example.com/old-site', 'example.com/new-site');

$unserialized = unserialize(unserialize($result));

Check warning on line 143 in tests/WP_Ultimo/Duplication/MUCD_Data_Test.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

unserialize() found. Serialized data has known vulnerability problems with Object Injection. JSON is generally a better approach for serializing data. See https://www.owasp.org/index.php/PHP_Object_Injection
$this->assertEquals('https://example.com/new-site/page', $unserialized['url']);
}

Expand Down Expand Up @@ -629,6 +629,131 @@
$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.
*
Expand Down
Loading