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.
 	 */