Commit 32651019149 for woocommerce
commit 326510191496a2da1aad66217b532580ea781522
Author: Miroslav Mitev <m1r0@users.noreply.github.com>
Date: Thu Jun 25 12:06:13 2026 +0300
Fix Blueprint import on sites with a different table prefix (#65942)
* Fix Blueprint import across sites with a different table prefix
Blueprint exported SQL steps with the source site's database table
prefix baked into the table names, so importing on a site with a
different prefix failed with "Table doesn't exist" for every runSql
step.
Export now emits a table prefix placeholder, and ImportRunSql resolves
it to the importing site's prefix before validating and running the
query.
* Centralize Blueprint table-prefix placeholder in RunSql factory
* Remove resolved RunSql constructor entries from PHPStan baseline
diff --git a/packages/php/blueprint/changelog/fix-wooplug-6319-blueprint-import-table-prefix b/packages/php/blueprint/changelog/fix-wooplug-6319-blueprint-import-table-prefix
new file mode 100644
index 00000000000..052f4821011
--- /dev/null
+++ b/packages/php/blueprint/changelog/fix-wooplug-6319-blueprint-import-table-prefix
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Resolve the table prefix placeholder in runSql steps to the importing site's database prefix, so Blueprints exported from a site with a different prefix import correctly.
diff --git a/packages/php/blueprint/src/Importers/ImportRunSql.php b/packages/php/blueprint/src/Importers/ImportRunSql.php
index 2b4bc3fbcda..337a96f50ba 100644
--- a/packages/php/blueprint/src/Importers/ImportRunSql.php
+++ b/packages/php/blueprint/src/Importers/ImportRunSql.php
@@ -48,6 +48,7 @@ class ImportRunSql implements StepProcessor {
$result = StepProcessorResult::success( RunSql::get_step_name() );
$sql = trim( $schema->sql->contents );
+ $sql = str_replace( RunSql::TABLE_PREFIX_PLACEHOLDER, $wpdb->prefix, $sql );
// Check if the query type is allowed.
if ( ! $this->is_allowed_query_type( $sql ) ) {
diff --git a/packages/php/blueprint/src/Steps/RunSql.php b/packages/php/blueprint/src/Steps/RunSql.php
index 4a4d1887dc8..8c77b8429f8 100644
--- a/packages/php/blueprint/src/Steps/RunSql.php
+++ b/packages/php/blueprint/src/Steps/RunSql.php
@@ -2,12 +2,26 @@
namespace Automattic\WooCommerce\Blueprint\Steps;
+use Automattic\WooCommerce\Blueprint\Util;
+
/**
* Class RunSql
*
* @package Automattic\WooCommerce\Blueprint\Steps
*/
class RunSql extends Step {
+ /**
+ * Placeholder for the database table prefix.
+ *
+ * Exported SQL uses this placeholder in place of the source site's table
+ * prefix. When the Blueprint is imported, the placeholder is replaced with
+ * the importing site's prefix, so Blueprints remain portable across sites
+ * that use different database table prefixes.
+ *
+ * @var string
+ */
+ public const TABLE_PREFIX_PLACEHOLDER = '{WC_BLUEPRINT_TABLE_PREFIX}';
+
/**
* Sql code to run.
*
@@ -33,6 +47,24 @@ class RunSql extends Step {
$this->name = $name;
}
+ /**
+ * Build a RunSql step for a database row, using the portable table-prefix
+ * placeholder in place of the live database prefix.
+ *
+ * Pass the unprefixed table name (e.g. 'woocommerce_shipping_zones'). The
+ * placeholder is prepended for you, so exported SQL stays portable across
+ * sites with different table prefixes. On import, ImportRunSql resolves the
+ * placeholder back to the importing site's prefix.
+ *
+ * @param array $row Row data keyed by column name.
+ * @param string $table Unprefixed table name.
+ * @param string $type One of insert, insert ignore, replace into.
+ * @return self
+ */
+ public static function from_table_row( array $row, string $table, string $type = 'replace into' ): self {
+ return new self( (string) Util::array_to_insert_sql( $row, self::TABLE_PREFIX_PLACEHOLDER . $table, $type ) );
+ }
+
/**
* Returns the name of this step.
*
diff --git a/packages/php/blueprint/tests/Unit/Importers/ImportRunSqlTest.php b/packages/php/blueprint/tests/Unit/Importers/ImportRunSqlTest.php
index ce703e900d2..05c66e73c6e 100644
--- a/packages/php/blueprint/tests/Unit/Importers/ImportRunSqlTest.php
+++ b/packages/php/blueprint/tests/Unit/Importers/ImportRunSqlTest.php
@@ -255,6 +255,40 @@ class ImportRunSqlTest extends TestCase {
$this->assertStringContainsString( 'Error executing SQL', $error_messages[0]['message'] );
}
+ /**
+ * Test that the table prefix placeholder is replaced with the local prefix
+ * before the query runs, so the query targets an existing local table.
+ */
+ public function test_process_replaces_table_prefix_placeholder(): void {
+ $schema = $this->create_sql_schema(
+ 'INSERT INTO `' . RunSql::TABLE_PREFIX_PLACEHOLDER . "posts` (post_title) VALUES ('Placeholder Post')",
+ 'test_placeholder_insert'
+ );
+
+ $result = $this->importer->process( $schema );
+
+ // Success proves the placeholder resolved to the real (existing) posts table.
+ $this->assertTrue( $result->is_success() );
+ }
+
+ /**
+ * Test that security checks run against the resolved table name, not the
+ * placeholder, so a placeholder that targets a protected table is rejected.
+ */
+ public function test_process_placeholder_resolves_before_protected_table_check(): void {
+ $schema = $this->create_sql_schema(
+ 'INSERT INTO `' . RunSql::TABLE_PREFIX_PLACEHOLDER . "users` (user_login) VALUES ('test_user')",
+ 'test_placeholder_protected'
+ );
+
+ $result = $this->importer->process( $schema );
+
+ $this->assertFalse( $result->is_success() );
+ $error_messages = $result->get_messages( 'error' );
+ $this->assertNotEmpty( $error_messages );
+ $this->assertStringContainsString( 'Modifications to admin users or roles are not allowed', $error_messages[0]['message'] );
+ }
+
/**
* Test that queries with multiple statements are rejected.
*/
diff --git a/packages/php/blueprint/tests/Unit/Steps/RunSqlTest.php b/packages/php/blueprint/tests/Unit/Steps/RunSqlTest.php
new file mode 100644
index 00000000000..a25c6e8395b
--- /dev/null
+++ b/packages/php/blueprint/tests/Unit/Steps/RunSqlTest.php
@@ -0,0 +1,54 @@
+<?php
+
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\Blueprint\Steps\RunSql;
+
+/**
+ * Unit tests for RunSql class.
+ */
+class RunSqlTest extends TestCase {
+ /**
+ * Test the static get_step_name method.
+ */
+ public function testGetStepName() {
+ $this->assertEquals( 'runSql', RunSql::get_step_name() );
+ }
+
+ /**
+ * Test that from_table_row prepends the portable table-prefix placeholder
+ * to the table name instead of a live database prefix, so the exported SQL
+ * stays portable across sites with different table prefixes.
+ */
+ public function testFromTableRowUsesPlaceholderPrefix() {
+ $step = RunSql::from_table_row(
+ array( 'name' => 'Zone A' ),
+ 'woocommerce_shipping_zones'
+ );
+
+ $this->assertInstanceOf( RunSql::class, $step );
+
+ $sql = $step->prepare_json_array()['sql']['contents'];
+
+ $this->assertStringContainsString(
+ RunSql::TABLE_PREFIX_PLACEHOLDER . 'woocommerce_shipping_zones',
+ $sql
+ );
+ // Defaults to an idempotent replace-into so re-imports don't duplicate rows.
+ $this->assertStringStartsWith( 'replace into', $sql );
+ }
+
+ /**
+ * Test that from_table_row honors a custom query type.
+ */
+ public function testFromTableRowAcceptsCustomType() {
+ $step = RunSql::from_table_row(
+ array( 'name' => 'Zone A' ),
+ 'woocommerce_shipping_zones',
+ 'insert'
+ );
+
+ $sql = $step->prepare_json_array()['sql']['contents'];
+
+ $this->assertStringStartsWith( 'insert', $sql );
+ }
+}
diff --git a/plugins/woocommerce/changelog/fix-wooplug-6319-blueprint-import-table-prefix b/plugins/woocommerce/changelog/fix-wooplug-6319-blueprint-import-table-prefix
new file mode 100644
index 00000000000..8aa61ac076d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-6319-blueprint-import-table-prefix
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Blueprint: export SQL steps using a table prefix placeholder instead of the source site's database prefix, so settings can be imported on sites that use a different table prefix.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 2b0f0b79b16..c6f0a356591 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -45765,12 +45765,6 @@ parameters:
count: 1
path: src/Admin/Features/Blueprint/Exporters/ExportWCSettingsIntegrations.php
- -
- message: '#^Parameter \#1 \$sql of class Automattic\\WooCommerce\\Blueprint\\Steps\\RunSql constructor expects string, string\|false given\.$#'
- identifier: argument.type
- count: 6
- path: src/Admin/Features/Blueprint/Exporters/ExportWCSettingsShipping.php
-
-
message: '#^Return type \(array\) of method Automattic\\WooCommerce\\Admin\\Features\\Blueprint\\Exporters\\ExportWCSettingsShipping\:\:export\(\) should be compatible with return type \(Automattic\\WooCommerce\\Blueprint\\Steps\\SetSiteOptions\) of method Automattic\\WooCommerce\\Admin\\Features\\Blueprint\\Exporters\\ExportWCSettings\:\:export\(\)$#'
identifier: method.childReturnType
@@ -45783,12 +45777,6 @@ parameters:
count: 1
path: src/Admin/Features/Blueprint/Exporters/ExportWCSettingsShipping.php
- -
- message: '#^Parameter \#1 \$sql of class Automattic\\WooCommerce\\Blueprint\\Steps\\RunSql constructor expects string, string\|false given\.$#'
- identifier: argument.type
- count: 1
- path: src/Admin/Features/Blueprint/Exporters/ExportWCSettingsTax.php
-
-
message: '#^Return type \(array\) of method Automattic\\WooCommerce\\Admin\\Features\\Blueprint\\Exporters\\ExportWCSettingsTax\:\:export\(\) should be compatible with return type \(Automattic\\WooCommerce\\Blueprint\\Steps\\SetSiteOptions\) of method Automattic\\WooCommerce\\Admin\\Features\\Blueprint\\Exporters\\ExportWCSettings\:\:export\(\)$#'
identifier: method.childReturnType
diff --git a/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCSettingsShipping.php b/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCSettingsShipping.php
index 33c93989dfa..3e65da400f4 100644
--- a/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCSettingsShipping.php
+++ b/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCSettingsShipping.php
@@ -6,7 +6,6 @@ namespace Automattic\WooCommerce\Admin\Features\Blueprint\Exporters;
use Automattic\WooCommerce\Blueprint\Steps\RunSql;
use Automattic\WooCommerce\Blueprint\Steps\SetSiteOptions;
-use Automattic\WooCommerce\Blueprint\Util;
/**
* Class ExportWCSettingsShipping
@@ -71,12 +70,12 @@ class ExportWCSettingsShipping extends ExportWCSettings {
);
$classes_steps = array_map(
- fn( $class_row ) => new RunSql( Util::array_to_insert_sql( $class_row, $wpdb->prefix . 'term_taxonomy', 'replace into' ) ),
+ fn( $class_row ) => RunSql::from_table_row( $class_row, 'term_taxonomy' ),
$classes
);
$terms = array_map(
- fn( $term ) => new RunSql( Util::array_to_insert_sql( $term, $wpdb->prefix . 'terms', 'replace into' ) ),
+ fn( $term ) => RunSql::from_table_row( $term, 'terms' ),
$this->get_terms( $classes )
);
@@ -128,7 +127,7 @@ class ExportWCSettingsShipping extends ExportWCSettings {
global $wpdb;
return array_map(
- fn( $zone ) => new RunSql( Util::array_to_insert_sql( $zone, $wpdb->prefix . 'woocommerce_shipping_zones', 'replace into' ) ),
+ fn( $zone ) => RunSql::from_table_row( $zone, 'woocommerce_shipping_zones' ),
$wpdb->get_results( "SELECT * FROM {$wpdb->prefix}woocommerce_shipping_zones", ARRAY_A )
);
}
@@ -142,7 +141,7 @@ class ExportWCSettingsShipping extends ExportWCSettings {
global $wpdb;
return array_map(
- fn( $location ) => new RunSql( Util::array_to_insert_sql( $location, $wpdb->prefix . 'woocommerce_shipping_zone_locations', 'replace into' ) ),
+ fn( $location ) => RunSql::from_table_row( $location, 'woocommerce_shipping_zone_locations' ),
$wpdb->get_results( "SELECT * FROM {$wpdb->prefix}woocommerce_shipping_zone_locations", ARRAY_A )
);
}
@@ -164,11 +163,11 @@ class ExportWCSettingsShipping extends ExportWCSettings {
return array_merge(
array_map(
- fn( $method ) => new RunSql( Util::array_to_insert_sql( $method, $wpdb->prefix . 'woocommerce_shipping_zone_methods', 'replace into' ) ),
+ fn( $method ) => RunSql::from_table_row( $method, 'woocommerce_shipping_zone_methods' ),
$methods
),
array_map(
- fn( $option ) => new RunSql( Util::array_to_insert_sql( $option, $wpdb->prefix . 'options', 'replace into' ) ),
+ fn( $option ) => RunSql::from_table_row( $option, 'options' ),
$method_options
)
);
diff --git a/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCSettingsTax.php b/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCSettingsTax.php
index 5f1dad169f5..17f76964e68 100644
--- a/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCSettingsTax.php
+++ b/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCSettingsTax.php
@@ -6,7 +6,6 @@ namespace Automattic\WooCommerce\Admin\Features\Blueprint\Exporters;
use Automattic\WooCommerce\Blueprint\UseWPFunctions;
use Automattic\WooCommerce\Blueprint\Steps\RunSql;
-use Automattic\WooCommerce\Blueprint\Util;
use Automattic\WooCommerce\Admin\Features\Blueprint\SettingOptions;
/**
@@ -90,10 +89,10 @@ class ExportWCSettingsTax extends ExportWCSettings {
*/
private function generateTaxRateSteps( string $table ): array {
global $wpdb;
- $table = $wpdb->prefix . $table;
+ $prefixed_table = $wpdb->prefix . $table;
return array_map(
- fn( $record ) => new RunSql( Util::array_to_insert_sql( $record, $table, 'replace into' ) ),
- $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i', $table ), ARRAY_A ),
+ fn( $record ) => RunSql::from_table_row( $record, $table ),
+ $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i', $prefixed_table ), ARRAY_A ),
);
}
}
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Blueprint/Exporters/ExportWCSettingsShippingTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Blueprint/Exporters/ExportWCSettingsShippingTest.php
new file mode 100644
index 00000000000..f60f3e7f79a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Blueprint/Exporters/ExportWCSettingsShippingTest.php
@@ -0,0 +1,62 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Admin\Features\Blueprint\Exporters;
+
+use Automattic\WooCommerce\Admin\Features\Blueprint\Exporters\ExportWCSettingsShipping;
+use Automattic\WooCommerce\Admin\Features\Blueprint\SettingOptions;
+use Automattic\WooCommerce\Blueprint\Steps\RunSql;
+use WC_Shipping_Zone;
+use WC_Unit_Test_Case;
+
+/**
+ * Test ExportWCSettingsShipping class.
+ */
+class ExportWCSettingsShippingTest extends WC_Unit_Test_Case {
+ /**
+ * Build the exporter with a stubbed settings page so export() only yields
+ * the shipping RunSql steps we care about here.
+ *
+ * @return ExportWCSettingsShipping
+ */
+ private function get_exporter(): ExportWCSettingsShipping {
+ $setting_options_mock = $this->getMockBuilder( SettingOptions::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $setting_options_mock->method( 'get_page_options' )->willReturn( array() );
+
+ return new ExportWCSettingsShipping( $setting_options_mock );
+ }
+
+ /**
+ * Test that exported SQL uses the table prefix placeholder instead of the
+ * local database prefix, so Blueprints import on sites with a different prefix.
+ */
+ public function test_exported_sql_uses_table_prefix_placeholder() {
+ global $wpdb;
+
+ $zone = new WC_Shipping_Zone();
+ $zone->set_zone_name( 'Placeholder Zone' );
+ $zone->save();
+ $zone->add_shipping_method( 'flat_rate' );
+
+ $steps = $this->get_exporter()->export();
+ $run_sql_step_found = false;
+
+ foreach ( $steps as $step ) {
+ if ( $step instanceof RunSql ) {
+ $run_sql_step_found = true;
+ $sql_content = $step->prepare_json_array()['sql']['contents'];
+
+ $this->assertStringContainsString( RunSql::TABLE_PREFIX_PLACEHOLDER, $sql_content );
+ // The literal local prefix must not be baked into the table name.
+ $this->assertStringNotContainsString( 'replace into `' . $wpdb->prefix, $sql_content );
+ }
+ }
+
+ $this->assertTrue( $run_sql_step_found, 'At least one RunSql step should be exported' );
+
+ $zone->delete( true );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Blueprint/Exporters/ExportWCSettingsTaxTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Blueprint/Exporters/ExportWCSettingsTaxTest.php
index 1da50c92414..87ba3e233c7 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Blueprint/Exporters/ExportWCSettingsTaxTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Blueprint/Exporters/ExportWCSettingsTaxTest.php
@@ -129,6 +129,57 @@ class ExportWCSettingsTaxTest extends WC_Unit_Test_Case {
WC_Tax::delete_tax_class_by( 'slug', $custom_tax_class['slug'] );
}
+ /**
+ * Test that exported SQL uses the table prefix placeholder instead of the
+ * local database prefix, so Blueprints import on sites with a different prefix.
+ */
+ public function test_exported_sql_uses_table_prefix_placeholder() {
+ global $wpdb;
+
+ $custom_tax_class = WC_Tax::create_tax_class( 'placeholder-test' );
+ $this->assertIsArray( $custom_tax_class );
+
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate_state' => 'NY',
+ 'tax_rate' => '4.0000',
+ 'tax_rate_name' => 'Placeholder Rate',
+ 'tax_rate_priority' => 1,
+ 'tax_rate_compound' => 0,
+ 'tax_rate_shipping' => 1,
+ 'tax_rate_order' => 0,
+ 'tax_rate_class' => $custom_tax_class['slug'],
+ )
+ );
+
+ $setting_options_mock = $this->getMockBuilder( \Automattic\WooCommerce\Admin\Features\Blueprint\SettingOptions::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $setting_options_mock->method( 'get_page_options' )->willReturn( array() );
+
+ $exporter = new ExportWCSettingsTax( $setting_options_mock );
+ $steps = $exporter->export();
+
+ $run_sql_step_found = false;
+
+ foreach ( $steps as $step ) {
+ if ( $step instanceof RunSql ) {
+ $run_sql_step_found = true;
+ $sql_content = $step->prepare_json_array()['sql']['contents'];
+
+ $this->assertStringContainsString( RunSql::TABLE_PREFIX_PLACEHOLDER, $sql_content );
+ // The literal local prefix must not be baked into the table name.
+ $this->assertStringNotContainsString( 'replace into `' . $wpdb->prefix, $sql_content );
+ }
+ }
+
+ $this->assertTrue( $run_sql_step_found, 'At least one RunSql step should be exported' );
+
+ WC_Tax::_delete_tax_rate( $tax_rate_id );
+ WC_Tax::delete_tax_class_by( 'slug', $custom_tax_class['slug'] );
+ }
+
/**
* Test export works when no custom tax classes exist.
*/