Commit 6baecd30028 for woocommerce
commit 6baecd30028bab671f08769c00361029ffda0a65
Author: Peter Petrov <peter.petrov89@gmail.com>
Date: Mon Aug 10 15:37:46 2026 +0300
CSV Import: Create new variations of existing products when updating (#66903)
* Create new variations of existing variable products when importing CSV with "Update existing products"
* Add changefile(s) from automation for the following project(s): woocommerce
* Refuse variation creation for trashed parents and reused post IDs during update imports
* Fire the variation-creation filter only for rows that passed validation so it cannot bypass safety checks
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Require a variable parent before creating variations during update imports
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add strict types to the product CSV importer test file
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Require a SKU before creating variations during update imports to keep re-imports idempotent
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Refuse variation creation for rows carrying any explicit ID during update imports
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/66903-fix-26256-csv-import-new-variations b/plugins/woocommerce/changelog/66903-fix-26256-csv-import-new-variations
new file mode 100644
index 00000000000..349569c4307
--- /dev/null
+++ b/plugins/woocommerce/changelog/66903-fix-26256-csv-import-new-variations
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+CSV import now creates new variations of existing variable products when "Update existing products" is selected, instead of skipping them.
diff --git a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
index cd44bfb115d..350a7ea622f 100644
--- a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
+++ b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
@@ -1162,6 +1162,41 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
return implode( ', ', $row_data );
}
+ /**
+ * Whether a variation row that does not exist yet can be created under its parent product.
+ *
+ * @since 11.1.0
+ *
+ * @param array $parsed_data Parsed row data.
+ * @return bool
+ */
+ protected function can_create_variation( $parsed_data ) {
+ // A row ID cannot be honored when creating a new variation: reusing an existing
+ // post's ID would corrupt that post, and a nonexistent ID cannot be assigned.
+ if ( ! empty( $parsed_data['id'] ) ) {
+ return false;
+ }
+
+ // A CSV ID cannot be assigned to a new variation, so without a SKU the created variation
+ // could never be matched again and every re-import would duplicate it.
+ if ( empty( $parsed_data['sku'] ) ) {
+ return false;
+ }
+
+ if ( empty( $parsed_data['parent_id'] ) ) {
+ return false;
+ }
+
+ $parent = wc_get_product( $parsed_data['parent_id'] );
+
+ if ( ! $parent || ! $parent->is_type( ProductType::VARIABLE ) ) {
+ return false;
+ }
+
+ // A parent with the 'importing' status is a placeholder, meaning the parent does not exist either.
+ return ! in_array( $parent->get_status(), array( 'importing', ProductStatus::TRASH ), true );
+ }
+
/**
* Process importer.
*
@@ -1227,16 +1262,34 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
}
if ( $update_existing && ( isset( $parsed_data['id'] ) || isset( $parsed_data['sku'] ) ) && ! $id_exists && ! $sku_exists ) {
- $data['skipped'][] = new WP_Error(
- 'woocommerce_product_importer_error',
- esc_html__( 'No matching product exists to update.', 'woocommerce' ),
- array(
- 'id' => $id,
- 'sku' => esc_attr( $sku ),
- 'row' => $this->get_row_id( $parsed_data ),
- )
- );
- continue;
+ $create_variation = false;
+
+ if ( ProductType::VARIATION === ( $parsed_data['type'] ?? '' ) && $this->can_create_variation( $parsed_data ) ) {
+ /**
+ * Filters whether a new variation should be created for an existing variable product when updating existing products.
+ *
+ * Only fires for variation rows that passed validation, so it can veto the creation but not force it.
+ *
+ * @since 11.1.0
+ *
+ * @param bool $create_variation Whether to create the new variation instead of skipping the row.
+ * @param array $parsed_data Parsed row data.
+ */
+ $create_variation = apply_filters( 'woocommerce_product_import_create_variation_of_existing_product', true, $parsed_data );
+ }
+
+ if ( ! $create_variation ) {
+ $data['skipped'][] = new WP_Error(
+ 'woocommerce_product_importer_error',
+ esc_html__( 'No matching product exists to update.', 'woocommerce' ),
+ array(
+ 'id' => $id,
+ 'sku' => esc_attr( $sku ),
+ 'row' => $this->get_row_id( $parsed_data ),
+ )
+ );
+ continue;
+ }
}
$result = $this->process_item( $parsed_data );
diff --git a/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php b/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
index 8a272072ab9..4d9dd4f0e88 100644
--- a/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
+++ b/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
@@ -5,6 +5,8 @@
* @package WooCommerce\Tests\Importer.
*/
+declare( strict_types=1 );
+
use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductType;
@@ -152,6 +154,367 @@ class WC_Product_CSV_Importer_Test extends \WC_Unit_Test_Case {
$this->assertEquals( 'A product with this SKU already exists.', $error->get_error_message() );
}
+ /**
+ * @testdox Test that new variations of an existing variable product are created when updating existing products.
+ */
+ public function test_import_creates_new_variations_of_existing_products_26256() {
+ $attribute = new WC_Product_Attribute();
+ $attribute->set_name( 'Size' );
+ $attribute->set_options( array( 'S', 'M' ) );
+ $attribute->set_variation( true );
+
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Import 26256 Tee' );
+ $product->set_sku( 'IMPORT-26256-PARENT' );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ $csv_file = __DIR__ . '/import-adding-variations-26256-data.csv';
+ $args = array(
+ 'parse' => true,
+ 'update_existing' => true,
+ 'mapping' => array(
+ 'Type' => 'type',
+ 'SKU' => 'sku',
+ 'Name' => 'name',
+ 'Parent' => 'parent_id',
+ 'Attribute 1 name' => 'attributes:name1',
+ 'Attribute 1 value(s)' => 'attributes:value1',
+ 'Attribute 1 global' => 'attributes:taxonomy1',
+ 'Regular price' => 'regular_price',
+ ),
+ );
+ $importer = new WC_Product_CSV_Importer( $csv_file, $args );
+ $data = $importer->import();
+
+ $this->assertEquals( array( $product->get_id() ), $data['updated'], 'Expected the existing parent product to be updated' );
+ $this->assertCount( 1, $data['imported_variations'], 'Expected 1 imported variation, got ' . count( $data['imported_variations'] ) );
+ $this->assertEmpty( $data['failed'], 'Expected 0 failed products, got ' . count( $data['failed'] ) );
+ $this->assertCount( 1, $data['skipped'], 'Expected 1 skipped product, got ' . count( $data['skipped'] ) );
+ $this->assertEquals( 'No matching product exists to update.', $data['skipped'][0]->get_error_message() );
+
+ $variation = wc_get_product( $data['imported_variations'][0] );
+ $this->assertEquals( $product->get_id(), $variation->get_parent_id(), 'Expected the new variation to belong to the existing parent' );
+ $this->assertEquals( 'IMPORT-26256-L', $variation->get_sku() );
+ $this->assertEquals( array( 'size' => 'L' ), $variation->get_attributes() );
+
+ WC_Helper_Product::delete_product( $variation->get_id() );
+ WC_Helper_Product::delete_product( $product->get_id() );
+ }
+
+ /**
+ * @testdox Test that new variations are still skipped when updating existing products if the filter disables their creation.
+ */
+ public function test_import_skips_new_variations_when_creation_is_disabled_via_filter_26256() {
+ $attribute = new WC_Product_Attribute();
+ $attribute->set_name( 'Size' );
+ $attribute->set_options( array( 'S', 'M' ) );
+ $attribute->set_variation( true );
+
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Import 26256 Tee' );
+ $product->set_sku( 'IMPORT-26256-PARENT' );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ add_filter( 'woocommerce_product_import_create_variation_of_existing_product', '__return_false' );
+
+ $csv_file = __DIR__ . '/import-adding-variations-26256-data.csv';
+ $args = array(
+ 'parse' => true,
+ 'update_existing' => true,
+ 'mapping' => array(
+ 'Type' => 'type',
+ 'SKU' => 'sku',
+ 'Name' => 'name',
+ 'Parent' => 'parent_id',
+ 'Attribute 1 name' => 'attributes:name1',
+ 'Attribute 1 value(s)' => 'attributes:value1',
+ 'Attribute 1 global' => 'attributes:taxonomy1',
+ 'Regular price' => 'regular_price',
+ ),
+ );
+ $importer = new WC_Product_CSV_Importer( $csv_file, $args );
+ $data = $importer->import();
+
+ remove_filter( 'woocommerce_product_import_create_variation_of_existing_product', '__return_false' );
+
+ $this->assertEquals( array( $product->get_id() ), $data['updated'], 'Expected the existing parent product to be updated' );
+ $this->assertEmpty( $data['imported_variations'], 'Expected 0 imported variations, got ' . count( $data['imported_variations'] ) );
+ $this->assertCount( 2, $data['skipped'], 'Expected 2 skipped products, got ' . count( $data['skipped'] ) );
+
+ WC_Helper_Product::delete_product( $product->get_id() );
+ }
+
+ /**
+ * @testdox Test that new variations are not created for a trashed parent product when updating existing products, even if the filter tries to force them.
+ */
+ public function test_import_skips_new_variations_of_trashed_parents_26256() {
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Import 26256 Tee' );
+ $product->set_sku( 'IMPORT-26256-PARENT' );
+ $product->save();
+ wp_trash_post( $product->get_id() );
+
+ add_filter( 'woocommerce_product_import_create_variation_of_existing_product', '__return_true' );
+
+ $csv_file = trailingslashit( get_temp_dir() ) . 'import-26256-trashed-parent.csv';
+ file_put_contents( $csv_file, "Type,SKU,Name,Parent\nvariation,IMPORT-26256-L,Import 26256 Tee - L,IMPORT-26256-PARENT\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture written to the temp dir.
+
+ $args = array(
+ 'parse' => true,
+ 'update_existing' => true,
+ 'mapping' => array(
+ 'Type' => 'type',
+ 'SKU' => 'sku',
+ 'Name' => 'name',
+ 'Parent' => 'parent_id',
+ ),
+ );
+ $importer = new WC_Product_CSV_Importer( $csv_file, $args );
+ $data = $importer->import();
+ wp_delete_file( $csv_file );
+
+ remove_filter( 'woocommerce_product_import_create_variation_of_existing_product', '__return_true' );
+
+ $this->assertEmpty( $data['imported_variations'], 'Expected 0 imported variations, got ' . count( $data['imported_variations'] ) );
+ $this->assertCount( 1, $data['skipped'], 'Expected 1 skipped product, got ' . count( $data['skipped'] ) );
+ $this->assertFalse( wc_get_product_id_by_sku( 'IMPORT-26256-L' ) > 0, 'Expected no variation to be created for a trashed parent' );
+
+ WC_Helper_Product::delete_product( $product->get_id() );
+ }
+
+ /**
+ * @testdox Test that variation rows are skipped when the parent is not a variable product, even if the filter tries to force them.
+ */
+ public function test_import_skips_new_variations_of_non_variable_parents_26256() {
+ $product = new WC_Product_Simple();
+ $product->set_name( 'Import 26256 Simple' );
+ $product->set_sku( 'IMPORT-26256-PARENT' );
+ $product->save();
+
+ add_filter( 'woocommerce_product_import_create_variation_of_existing_product', '__return_true' );
+
+ $csv_file = trailingslashit( get_temp_dir() ) . 'import-26256-simple-parent.csv';
+ file_put_contents( $csv_file, "Type,SKU,Name,Parent\nvariation,IMPORT-26256-L,Import 26256 Simple - L,IMPORT-26256-PARENT\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture written to the temp dir.
+
+ $args = array(
+ 'parse' => true,
+ 'update_existing' => true,
+ 'mapping' => array(
+ 'Type' => 'type',
+ 'SKU' => 'sku',
+ 'Name' => 'name',
+ 'Parent' => 'parent_id',
+ ),
+ );
+ $importer = new WC_Product_CSV_Importer( $csv_file, $args );
+ $data = $importer->import();
+ wp_delete_file( $csv_file );
+
+ remove_filter( 'woocommerce_product_import_create_variation_of_existing_product', '__return_true' );
+
+ $this->assertEmpty( $data['imported_variations'], 'Expected 0 imported variations, got ' . count( $data['imported_variations'] ) );
+ $this->assertCount( 1, $data['skipped'], 'Expected 1 skipped product, got ' . count( $data['skipped'] ) );
+ $this->assertFalse( wc_get_product_id_by_sku( 'IMPORT-26256-L' ) > 0, 'Expected no variation to be created for a non-variable parent' );
+
+ WC_Helper_Product::delete_product( $product->get_id() );
+ }
+
+ /**
+ * @testdox Test that variation rows without a SKU are skipped when updating existing products, since re-importing them would duplicate the variation.
+ */
+ public function test_import_skips_new_variations_without_a_sku_26256() {
+ $attribute = new WC_Product_Attribute();
+ $attribute->set_name( 'Size' );
+ $attribute->set_options( array( 'S', 'M' ) );
+ $attribute->set_variation( true );
+
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Import 26256 Tee' );
+ $product->set_sku( 'IMPORT-26256-PARENT' );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ add_filter( 'woocommerce_product_import_create_variation_of_existing_product', '__return_true' );
+
+ $csv_file = trailingslashit( get_temp_dir() ) . 'import-26256-no-sku.csv';
+ file_put_contents( $csv_file, "ID,Type,SKU,Name,Parent\n,variation,,Import 26256 Tee - L,IMPORT-26256-PARENT\n999999999,variation,,Import 26256 Tee - M,IMPORT-26256-PARENT\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture written to the temp dir.
+
+ $args = array(
+ 'parse' => true,
+ 'update_existing' => true,
+ 'mapping' => array(
+ 'ID' => 'id',
+ 'Type' => 'type',
+ 'SKU' => 'sku',
+ 'Name' => 'name',
+ 'Parent' => 'parent_id',
+ ),
+ );
+ $importer = new WC_Product_CSV_Importer( $csv_file, $args );
+ $data = $importer->import();
+ wp_delete_file( $csv_file );
+
+ remove_filter( 'woocommerce_product_import_create_variation_of_existing_product', '__return_true' );
+
+ $this->assertEmpty( $data['imported_variations'], 'Expected 0 imported variations, got ' . count( $data['imported_variations'] ) );
+ $this->assertCount( 2, $data['skipped'], 'Expected 2 skipped products, got ' . count( $data['skipped'] ) );
+ $variations = wc_get_products(
+ array(
+ 'type' => ProductType::VARIATION,
+ 'parent' => $product->get_id(),
+ )
+ );
+ $this->assertCount( 0, $variations, 'Expected no variations to be created for rows without a SKU' );
+
+ WC_Helper_Product::delete_product( $product->get_id() );
+ }
+
+ /**
+ * @testdox Test that a variation row whose ID belongs to an existing post of another type is skipped when updating existing products, even if the filter tries to force it.
+ */
+ public function test_import_skips_new_variations_with_a_foreign_post_id_26256() {
+ $attribute = new WC_Product_Attribute();
+ $attribute->set_name( 'Size' );
+ $attribute->set_options( array( 'S', 'M' ) );
+ $attribute->set_variation( true );
+
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Import 26256 Tee' );
+ $product->set_sku( 'IMPORT-26256-PARENT' );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ $page_id = wp_insert_post(
+ array(
+ 'post_type' => 'page',
+ 'post_title' => 'Import 26256 Page',
+ 'post_status' => 'publish',
+ )
+ );
+
+ add_filter( 'woocommerce_product_import_create_variation_of_existing_product', '__return_true' );
+
+ $csv_file = trailingslashit( get_temp_dir() ) . 'import-26256-foreign-id.csv';
+ file_put_contents( $csv_file, "ID,Type,SKU,Name,Parent\n{$page_id},variation,IMPORT-26256-L,Import 26256 Tee - L,IMPORT-26256-PARENT\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture written to the temp dir.
+
+ $args = array(
+ 'parse' => true,
+ 'update_existing' => true,
+ 'mapping' => array(
+ 'ID' => 'id',
+ 'Type' => 'type',
+ 'SKU' => 'sku',
+ 'Name' => 'name',
+ 'Parent' => 'parent_id',
+ ),
+ );
+ $importer = new WC_Product_CSV_Importer( $csv_file, $args );
+ $data = $importer->import();
+ wp_delete_file( $csv_file );
+
+ remove_filter( 'woocommerce_product_import_create_variation_of_existing_product', '__return_true' );
+
+ $this->assertEmpty( $data['imported_variations'], 'Expected 0 imported variations, got ' . count( $data['imported_variations'] ) );
+ $this->assertCount( 1, $data['skipped'], 'Expected 1 skipped product, got ' . count( $data['skipped'] ) );
+ $this->assertEquals( 'page', get_post( $page_id )->post_type, 'Expected the existing page to keep its post type' );
+
+ wp_delete_post( $page_id, true );
+ WC_Helper_Product::delete_product( $product->get_id() );
+ }
+
+ /**
+ * @testdox Test that a variation row whose ID does not belong to any post is skipped when updating existing products, even if the filter tries to force it.
+ */
+ public function test_import_skips_new_variations_with_a_nonexistent_post_id_26256() {
+ $attribute = new WC_Product_Attribute();
+ $attribute->set_name( 'Size' );
+ $attribute->set_options( array( 'S', 'M' ) );
+ $attribute->set_variation( true );
+
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Import 26256 Tee' );
+ $product->set_sku( 'IMPORT-26256-PARENT' );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ $nonexistent_id = $product->get_id() + 1000;
+
+ add_filter( 'woocommerce_product_import_create_variation_of_existing_product', '__return_true' );
+
+ $csv_file = trailingslashit( get_temp_dir() ) . 'import-26256-nonexistent-id.csv';
+ file_put_contents( $csv_file, "ID,Type,SKU,Name,Parent\n{$nonexistent_id},variation,IMPORT-26256-L,Import 26256 Tee - L,IMPORT-26256-PARENT\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture written to the temp dir.
+
+ $args = array(
+ 'parse' => true,
+ 'update_existing' => true,
+ 'mapping' => array(
+ 'ID' => 'id',
+ 'Type' => 'type',
+ 'SKU' => 'sku',
+ 'Name' => 'name',
+ 'Parent' => 'parent_id',
+ ),
+ );
+ $importer = new WC_Product_CSV_Importer( $csv_file, $args );
+ $data = $importer->import();
+ wp_delete_file( $csv_file );
+
+ remove_filter( 'woocommerce_product_import_create_variation_of_existing_product', '__return_true' );
+
+ $this->assertEmpty( $data['imported_variations'], 'Expected 0 imported variations, got ' . count( $data['imported_variations'] ) );
+ $this->assertCount( 1, $data['skipped'], 'Expected 1 skipped product, got ' . count( $data['skipped'] ) );
+ $this->assertFalse( wc_get_product_id_by_sku( 'IMPORT-26256-L' ) > 0, 'Expected no variation to be created for a row with a nonexistent ID' );
+
+ WC_Helper_Product::delete_product( $product->get_id() );
+ }
+
+ /**
+ * @testdox Test that the filter enabling variation creation cannot cause non-variation rows to be imported when updating existing products.
+ */
+ public function test_import_filter_does_not_create_non_variation_rows_26256() {
+ $attribute = new WC_Product_Attribute();
+ $attribute->set_name( 'Size' );
+ $attribute->set_options( array( 'S', 'M' ) );
+ $attribute->set_variation( true );
+
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Import 26256 Tee' );
+ $product->set_sku( 'IMPORT-26256-PARENT' );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ add_filter( 'woocommerce_product_import_create_variation_of_existing_product', '__return_true' );
+
+ $csv_file = __DIR__ . '/import-adding-variations-26256-data.csv';
+ $args = array(
+ 'parse' => true,
+ 'update_existing' => true,
+ 'mapping' => array(
+ 'Type' => 'type',
+ 'SKU' => 'sku',
+ 'Name' => 'name',
+ 'Parent' => 'parent_id',
+ 'Attribute 1 name' => 'attributes:name1',
+ 'Attribute 1 value(s)' => 'attributes:value1',
+ 'Attribute 1 global' => 'attributes:taxonomy1',
+ 'Regular price' => 'regular_price',
+ ),
+ );
+ $importer = new WC_Product_CSV_Importer( $csv_file, $args );
+ $data = $importer->import();
+
+ remove_filter( 'woocommerce_product_import_create_variation_of_existing_product', '__return_true' );
+
+ $this->assertCount( 1, $data['imported_variations'], 'Expected 1 imported variation, got ' . count( $data['imported_variations'] ) );
+ $this->assertEmpty( $data['imported'], 'Expected 0 imported products, got ' . count( $data['imported'] ) );
+ $this->assertCount( 1, $data['skipped'], 'Expected the non-variation row to be skipped despite the filter' );
+
+ WC_Helper_Product::delete_product( $data['imported_variations'][0] );
+ WC_Helper_Product::delete_product( $product->get_id() );
+ }
+
/**
* @testdox Test that attributes with non-ASCII characters are correctly set to "Used for Variations" during import.
*/
diff --git a/plugins/woocommerce/tests/php/includes/importer/import-adding-variations-26256-data.csv b/plugins/woocommerce/tests/php/includes/importer/import-adding-variations-26256-data.csv
new file mode 100644
index 00000000000..b32412491f7
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/importer/import-adding-variations-26256-data.csv
@@ -0,0 +1,4 @@
+Type,SKU,Name,Parent,Attribute 1 name,Attribute 1 value(s),Attribute 1 global,Regular price
+variable,IMPORT-26256-PARENT,Import 26256 Tee,,Size,"S, M, L",0,
+variation,IMPORT-26256-L,Import 26256 Tee - L,IMPORT-26256-PARENT,Size,L,0,12
+simple,IMPORT-26256-MISSING,Import 26256 Missing,,,,,