Commit d142a9395d6 for woocommerce

commit d142a9395d683d2869afdef9c9509bcd607e2d64
Author: Justin P <228780+layoutd@users.noreply.github.com>
Date:   Fri Oct 30 10:14:57 2026 +0100

    Match CSV import rows by SKU when their ID does not exist on the site (#69991)

    * Fall back to SKU matching when an updated CSV row's ID does not exist

    * Add changelog entry for CSV import SKU fallback

    * Fail variation rows that name a parent which cannot be found

    * Refuse the SKU fallback across the variation boundary and share the row lookup

    * Add changelog entry for variation rows with a missing parent

    * Label failed import rows with the ID from the file

    * Trim the row SKU before the update fallback looks it up

    * Match each row with a repeated missing ID by its own SKU

    * Refuse the SKU fallback when the row would save the product as another type

    * Reword the mapping comment and reuse the update import test helper

    * Share one temp-file helper across the SKU fallback import tests

    * Check the variation parent before converting the product a row points at

    * Label import result rows with the ID cell of that row

    * Convert row cells to UTF-8 before the SKU fallback reads them

    * Update CSV import changelog entries to cover all merchant-facing fixes

    * Stop caching SKU matches as original ID mappings

    * Reset the row label state after import and drop the redundant label test

    * Make the pure type and encoding helpers static and lowercase the Type cell once

    * Explain rows whose SKU fallback declined a product of another type

    * Keep the parent for blank Parent cells and check untyped variation rows early

    * Require an existing variable parent for variation rows in update imports

    * Strip the export apostrophe from SKU cells and SKU references

    * Compare the SKU fallback type with the matched product's own type

    * Refine SKU fallback matching and SKU references after the final review

    * Refuse variation rows for non-product posts and apply attributes when no parent is named

    * Describe the no-SKU-column fix by the first column it used

    * Look up row SKUs as stored before their cleaned form

    * Require a parent when a variation row would convert an existing product

    * Check the kept parent of variation rows with a blank Parent cell

    * Drop the SKU fallback refusal messages and the CSV ID row label

    * Clean the row SKU like the SKU column, tighten the changelog entries and assert the refused rows' errors

diff --git a/plugins/woocommerce/changelog/fix-csv-import-escaped-sku b/plugins/woocommerce/changelog/fix-csv-import-escaped-sku
new file mode 100644
index 00000000000..a1da04639e0
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-csv-import-escaped-sku
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep SKUs that start with =, +, - or @ intact when re-importing a product CSV export.
diff --git a/plugins/woocommerce/changelog/fix-csv-import-no-sku-column b/plugins/woocommerce/changelog/fix-csv-import-no-sku-column
new file mode 100644
index 00000000000..e7ddbefee0d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-csv-import-no-sku-column
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Stop new-product CSV imports without a SKU column from saving the first column's value as the SKU.
diff --git a/plugins/woocommerce/changelog/fix-csv-import-sku-fallback b/plugins/woocommerce/changelog/fix-csv-import-sku-fallback
new file mode 100644
index 00000000000..a937f54533b
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-csv-import-sku-fallback
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Update the product matching a row's SKU when the row's ID is not on the site, and explain ID and SKU conflicts.
diff --git a/plugins/woocommerce/changelog/fix-csv-import-variation-missing-parent b/plugins/woocommerce/changelog/fix-csv-import-variation-missing-parent
new file mode 100644
index 00000000000..25ebaff84fe
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-csv-import-variation-missing-parent
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fail product CSV variation rows whose parent is missing or cannot hold them, instead of moving or detaching the variation.
diff --git a/plugins/woocommerce/includes/import/abstract-wc-product-importer.php b/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
index 01ebe413501..9b0b81f47dd 100644
--- a/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
+++ b/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
@@ -307,6 +307,43 @@ abstract class WC_Product_Importer implements WC_Importer_Interface {
 				}
 			}

+			$post_type        = empty( $data['id'] ) ? false : get_post_type( absint( $data['id'] ) );
+			$is_variation_row = isset( $data['type'] ) ? ProductType::VARIATION === $data['type'] : 'product_variation' === $post_type;
+
+			if ( $is_variation_row ) {
+				// get_product_object() would turn any post with this ID into a variation.
+				if ( $post_type && ! in_array( $post_type, array( 'product', 'product_variation' ), true ) ) {
+					return new WP_Error(
+						'woocommerce_product_csv_importer_invalid_id',
+						/* translators: %d: product ID */
+						sprintf( __( 'Invalid product ID %d.', 'woocommerce' ), absint( $data['id'] ) ),
+						array(
+							'id'     => absint( $data['id'] ),
+							'status' => 401,
+						)
+					);
+				}
+
+				// A blank or missing Parent keeps an existing variation's parent, as it does for a new or placeholder row,
+				// but an existing product turning into a variation needs one.
+				$converts_product = 'product' === $post_type && 'importing' !== get_post_status( absint( $data['id'] ) );
+
+				if ( ! $converts_product && '' === ( $data['parent_id'] ?? '' ) ) {
+					unset( $data['parent_id'] );
+
+					// The kept parent still has to hold the variation, or set_variation_data() would skip the row's attributes.
+					$current_parent = 'product_variation' === $post_type ? wp_get_post_parent_id( absint( $data['id'] ) ) : 0;
+					$parent         = $current_parent ? $this->get_variation_parent( $current_parent ) : null;
+				} else {
+					// Check the parent before get_product_object() turns the post into a variation, so a row with an unusable parent changes nothing.
+					$parent = $this->get_variation_parent( $data['parent_id'] ?? null );
+				}
+
+				if ( is_wp_error( $parent ) ) {
+					return $parent;
+				}
+			}
+
 			$object   = $this->get_product_object( $data );
 			$updating = false;

@@ -524,35 +561,50 @@ abstract class WC_Product_Importer implements WC_Importer_Interface {
 	}

 	/**
-	 * Set variation data.
+	 * Get the parent product a variation row names.
 	 *
-	 * @param WC_Product $variation Product instance.
-	 * @param array      $data    Item data.
-	 * @return WC_Product|WP_Error
-	 * @throws Exception If data cannot be set.
+	 * An update import never fills in a placeholder parent, so there the parent must be an existing variable product.
+	 *
+	 * @param mixed $parent_id Parent ID from the row or the variation's current parent, or null when neither exists.
+	 * @return WC_Product|WP_Error The parent, or an error when it cannot hold the variation.
 	 */
-	protected function set_variation_data( &$variation, $data ) {
-		$parent = false;
-
-		// Check if parent exist.
-		if ( isset( $data['parent_id'] ) ) {
-			$parent = wc_get_product( $data['parent_id'] );
+	private function get_variation_parent( $parent_id ) {
+		$parent = null === $parent_id ? false : wc_get_product( $parent_id );

-			if ( $parent ) {
-				$variation->set_parent_id( $parent->get_id() );
-			}
-		}
-
-		// Stop if parent does not exists.
 		if ( ! $parent ) {
 			return new WP_Error( 'woocommerce_product_importer_missing_variation_parent_id', __( 'Variation cannot be imported: Missing parent ID or parent does not exist yet.', 'woocommerce' ), array( 'status' => 401 ) );
 		}

-		// Stop if parent is a product variation.
 		if ( $parent->is_type( ProductType::VARIATION ) ) {
 			return new WP_Error( 'woocommerce_product_importer_parent_set_as_variation', __( 'Variation cannot be imported: Parent product cannot be a product variation', 'woocommerce' ), array( 'status' => 401 ) );
 		}

+		if ( ! empty( $this->params['update_existing'] ) && ( ! $parent instanceof WC_Product_Variable || in_array( $parent->get_status(), array( 'importing', ProductStatus::TRASH ), true ) ) ) {
+			return new WP_Error( 'woocommerce_product_importer_variation_parent_not_variable', __( 'Variation cannot be imported: Parent product must be an existing variable product.', 'woocommerce' ), array( 'status' => 401 ) );
+		}
+
+		return $parent;
+	}
+
+	/**
+	 * Set variation data.
+	 *
+	 * The parent is checked by process_item() before this runs, so the error is only returned to other callers.
+	 *
+	 * @param WC_Product $variation Product instance.
+	 * @param array      $data    Item data.
+	 * @return WP_Error|null Error when the parent cannot hold the variation.
+	 * @throws Exception If data cannot be set.
+	 */
+	protected function set_variation_data( &$variation, $data ) {
+		$parent = $this->get_variation_parent( $data['parent_id'] ?? ( $variation->get_parent_id() ? $variation->get_parent_id() : null ) );
+
+		if ( is_wp_error( $parent ) ) {
+			return $parent;
+		}
+
+		$variation->set_parent_id( $parent->get_id() );
+
 		if ( isset( $data['raw_attributes'] ) ) {
 			$attributes        = array();
 			$parent_attributes = $this->get_variation_parent_attributes( $data['raw_attributes'], $parent );
@@ -597,6 +649,8 @@ abstract class WC_Product_Importer implements WC_Importer_Interface {

 			$variation->set_attributes( $attributes );
 		}
+
+		return null;
 	}

 	/**
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 075eec53a92..dcdee146341 100644
--- a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
+++ b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
@@ -268,6 +268,8 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 			return '';
 		}

+		$value = $this->unescape_data( $value );
+
 		// IDs are prefixed with id:.
 		if ( preg_match( '/^id:(\d+)$/', $value, $matches ) ) {
 			$id = intval( $matches[1] );
@@ -328,7 +330,9 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 	 * Parse the ID field.
 	 *
 	 * If we're not doing an update, create a placeholder product so mapping works
-	 * for rows following this one.
+	 * for rows following this one. When updating, an ID that does not exist on
+	 * this site falls back to the product matching the row's SKU, if the row can
+	 * safely update it.
 	 *
 	 * @param string $value Field value.
 	 *
@@ -350,10 +354,8 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {

 		// Not updating? Make sure we have a new placeholder for this ID.
 		if ( ! $this->params['update_existing'] ) {
-			$mapped_keys      = $this->get_mapped_keys();
-			$sku_column_index = absint( array_search( 'sku', $mapped_keys, true ) );
-			$row_sku          = isset( $this->raw_data[ $this->parsing_raw_data_index ][ $sku_column_index ] ) ? $this->raw_data[ $this->parsing_raw_data_index ][ $sku_column_index ] : '';
-			$id_from_sku      = $row_sku ? wc_get_product_id_by_sku( $row_sku ) : '';
+			$row_sku     = $this->parse_sku_field( $this->get_row_value( 'sku' ) );
+			$id_from_sku = $this->get_product_id_by_row_sku();

 			// If row has a SKU, make sure placeholder was not made already.
 			if ( $id_from_sku ) {
@@ -375,9 +377,147 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 			return $placeholder_id;
 		}

+		// An ID exported from another site may not exist here. Fall back to the row's SKU so the
+		// row updates the matching product instead of failing later with an invalid product error.
+		if ( ! in_array( get_post_type( $id ), array( 'product', 'product_variation' ), true ) ) {
+			$id_from_sku = $this->get_product_id_by_row_sku();
+
+			if ( $id_from_sku && $this->row_can_update_product( $id_from_sku ) ) {
+				return $id_from_sku;
+			}
+		}
+
 		return $id;
 	}

+	/**
+	 * Find the product with the SKU of the row being parsed, as stored or as the import cleans it.
+	 *
+	 * SKUs saved through code can keep characters the import would clean, so the stored form is tried first.
+	 *
+	 * @return int Product ID, or 0 when no product has the SKU.
+	 */
+	private function get_product_id_by_row_sku(): int {
+		$raw_sku   = $this->unescape_data( $this->get_row_value( 'sku' ) );
+		$clean_sku = sanitize_text_field( $raw_sku );
+
+		if ( '' === $clean_sku ) {
+			return 0;
+		}
+
+		$id = $raw_sku !== $clean_sku ? wc_get_product_id_by_sku( $raw_sku ) : 0;
+
+		return $id ? $id : wc_get_product_id_by_sku( $clean_sku );
+	}
+
+	/**
+	 * Get a column value of the row currently being parsed.
+	 *
+	 * @param string $key Mapped column key, such as 'sku' or 'type'.
+	 * @return string Cell value converted to UTF-8, or an empty string when the file has no such column.
+	 */
+	private function get_row_value( string $key ): string {
+		$column_index = array_search( $key, $this->get_mapped_keys(), true );
+
+		if ( false === $column_index ) {
+			return '';
+		}
+
+		return self::convert_to_utf8( (string) ( $this->raw_data[ $this->parsing_raw_data_index ][ $column_index ] ?? '' ) );
+	}
+
+	/**
+	 * Convert a cell from the file to UTF-8 before it is parsed.
+	 *
+	 * @param string $value Raw cell value.
+	 * @return string
+	 */
+	private static function convert_to_utf8( $value ): string {
+		if ( ! function_exists( 'mb_convert_encoding' ) ) {
+			return wp_check_invalid_utf8( $value, true );
+		}
+
+		$encoding = mb_detect_encoding( $value, mb_detect_order(), true );
+
+		return (string) mb_convert_encoding( $value, 'UTF-8', $encoding ? $encoding : 'UTF-8' );
+	}
+
+	/**
+	 * Whether the row can update the product its SKU matched.
+	 *
+	 * Another type would make get_product_object() convert the product. An `id:` reference in a row whose own ID
+	 * comes from another site most likely points at a product of that site as well.
+	 *
+	 * @param int $product_id Existing product ID matched by SKU.
+	 * @return bool
+	 */
+	private function row_can_update_product( int $product_id ): bool {
+		if ( ! $this->row_type_matches_product( $product_id ) ) {
+			return false;
+		}
+
+		foreach ( array( 'parent_id', 'grouped_products', 'upsell_ids', 'cross_sell_ids' ) as $key ) {
+			foreach ( $this->explode_values( $this->get_row_value( $key ) ) as $reference ) {
+				if ( 0 === strpos( $reference, 'id:' ) ) {
+					return false;
+				}
+			}
+		}
+
+		return true;
+	}
+
+	/**
+	 * Whether the row would save the product matched by SKU as the type it already has.
+	 *
+	 * A file without a Type column cannot convert anything.
+	 *
+	 * @param int $product_id Existing product ID matched by SKU.
+	 * @return bool
+	 */
+	private function row_type_matches_product( int $product_id ): bool {
+		if ( ! in_array( 'type', $this->get_mapped_keys(), true ) ) {
+			return true;
+		}
+
+		$row_type     = self::get_type_from_values( array_map( 'strtolower', $this->parse_comma_field( $this->get_row_value( 'type' ) ) ) );
+		$product_type = WC_Product_Factory::get_product_type( $product_id );
+
+		// Variations of extension types, such as subscription variations, report their own type.
+		if ( ProductType::VARIATION === $product_type && $row_type !== $product_type ) {
+			$product      = wc_get_product( $product_id );
+			$product_type = $product ? $product->get_type() : $product_type;
+		}
+
+		return $product_type === $row_type;
+	}
+
+	/**
+	 * Get the product type a parsed Type cell saves as.
+	 *
+	 * The cell also holds the virtual and downloadable flags. The first other value is the type, and an empty cell means simple.
+	 *
+	 * @param string[] $values Parsed Type cell values, lowercased.
+	 * @return string
+	 */
+	private static function get_type_from_values( array $values ): string {
+		$type = current( array_diff( $values, array( 'virtual', 'downloadable' ) ) );
+
+		return $type ? $type : ProductType::SIMPLE;
+	}
+
+	/**
+	 * Parse a SKU cell, removing the apostrophe the exporter puts before a leading =, +, - or @.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param string $value Field value.
+	 * @return string
+	 */
+	public function parse_sku_field( $value ) {
+		return sanitize_text_field( $this->unescape_data( $value ) );
+	}
+
 	/**
 	 * Parse relative comma-delineated field and return product ID.
 	 *
@@ -955,6 +1095,7 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 			'tag_ids_spaces'    => array( $this, 'parse_tags_spaces_field' ),
 			'shipping_class_id' => array( $this, 'parse_shipping_class_field' ),
 			'images'            => array( $this, 'parse_images_field' ),
+			'sku'               => array( $this, 'parse_sku_field' ),
 			'parent_id'         => array( $this, 'parse_relative_field' ),
 			'grouped_products'  => array( $this, 'parse_relative_comma_field' ),
 			'upsell_ids'        => array( $this, 'parse_relative_comma_field' ),
@@ -1065,13 +1206,7 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 			$data['type']         = array_map( 'strtolower', $data['type'] );
 			$data['virtual']      = in_array( 'virtual', $data['type'], true );
 			$data['downloadable'] = in_array( 'downloadable', $data['type'], true );
-
-			// Convert type to string.
-			$data['type'] = current( array_diff( $data['type'], array( 'virtual', 'downloadable' ) ) );
-
-			if ( ! $data['type'] ) {
-				$data['type'] = ProductType::SIMPLE;
-			}
+			$data['type']         = self::get_type_from_values( $data['type'] );
 		}

 		// Status is mapped from a special published field.
@@ -1228,7 +1363,6 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 	protected function set_parsed_data() {
 		$parse_functions = $this->get_formatting_callback();
 		$mapped_keys     = $this->get_mapped_keys();
-		$use_mb          = function_exists( 'mb_convert_encoding' );

 		// Parse the data.
 		foreach ( $this->raw_data as $row_index => $row ) {
@@ -1249,19 +1383,7 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 					continue;
 				}

-				// Convert UTF8.
-				if ( $use_mb ) {
-					$encoding = mb_detect_encoding( $value, mb_detect_order(), true );
-					if ( $encoding ) {
-						$value = mb_convert_encoding( $value, 'UTF-8', $encoding );
-					} else {
-						$value = mb_convert_encoding( $value, 'UTF-8', 'UTF-8' );
-					}
-				} else {
-					$value = wp_check_invalid_utf8( $value, true );
-				}
-
-				$data[ $mapped_keys[ $id ] ] = call_user_func( $parse_functions[ $id ], $value );
+				$data[ $mapped_keys[ $id ] ] = call_user_func( $parse_functions[ $id ], self::convert_to_utf8( $value ) );
 			}

 			/**
@@ -1481,6 +1603,7 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 			$global_unique_id = $this->normalize_global_unique_id( $parsed_data['global_unique_id'] ?? '' );
 			$id_exists        = false;
 			$sku_exists       = false;
+			$id_from_sku      = 0;

 			if ( $update_existing && ! $id && ! $sku && '' !== $global_unique_id ) {
 				$id = $this->match_product_id_by_global_unique_id( $global_unique_id, $parsed_data['type'] ?? '' );
@@ -1502,7 +1625,7 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 			}

 			if ( $sku ) {
-				$id_from_sku = wc_get_product_id_by_sku( $sku );
+				$id_from_sku = absint( wc_get_product_id_by_sku( $sku ) );
 				$product     = $id_from_sku ? wc_get_product( $id_from_sku ) : false;
 				$sku_exists  = $product && 'importing' !== $product->get_status();
 			}
@@ -1577,6 +1700,9 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 			$result = $this->process_item( $parsed_data );

 			if ( is_wp_error( $result ) ) {
+				if ( $id_exists && $sku_exists && $id !== $id_from_sku ) {
+					$result = $this->add_sku_conflict_hint( $result, $id, (string) $sku, $id_from_sku );
+				}
 				$result->add_data( array( 'row' => $this->get_row_id( $parsed_data ) ) );
 				$data['failed'][] = $result;
 			} elseif ( $result['updated'] ) {
@@ -1597,4 +1723,32 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {

 		return $data;
 	}
+
+	/**
+	 * Explain a failed row whose ID and SKU point at two different products.
+	 *
+	 * A failure about the row's own type is left as it is.
+	 *
+	 * @param WP_Error $error       Error returned when processing the row.
+	 * @param int      $id          Existing product ID the row's ID column matched.
+	 * @param string   $sku         SKU column value of the row.
+	 * @param int      $id_from_sku Product ID that already uses the row's SKU.
+	 * @return WP_Error
+	 */
+	private function add_sku_conflict_hint( WP_Error $error, int $id, string $sku, int $id_from_sku ): WP_Error {
+		if ( 'woocommerce_product_importer_invalid_type' === $error->get_error_code() ) {
+			return $error;
+		}
+
+		$message = sprintf(
+			/* translators: 1: original error message, 2: product ID matched by the row's ID, 3: SKU, 4: product ID that already uses the SKU */
+			__( '%1$s The ID of this row matches product #%2$d, but its SKU "%3$s" already belongs to product #%4$d. Point the ID and SKU at the same product, or remove one of them.', 'woocommerce' ),
+			$error->get_error_message(),
+			$id,
+			$sku,
+			$id_from_sku
+		);
+
+		return new WP_Error( $error->get_error_code(), $message, $error->get_error_data() );
+	}
 }
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index fac0ae6af89..a931dd08af2 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -19722,12 +19722,6 @@ parameters:
 			count: 1
 			path: includes/import/abstract-wc-product-importer.php

-		-
-			message: '#^Method WC_Product_Importer\:\:set_variation_data\(\) should return WC_Product\|WP_Error but return statement is missing\.$#'
-			identifier: return.missing
-			count: 2
-			path: includes/import/abstract-wc-product-importer.php
-
 		-
 			message: '#^Parameter \#2 \$callback of function array_filter expects \(callable\(string\)\: bool\)\|null, ''strlen'' given\.$#'
 			identifier: argument.type
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 de6b9ff619e..5f925f4d2ff 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
@@ -2452,6 +2452,651 @@ class WC_Product_CSV_Importer_Test extends \WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * Import temporary CSV contents, by default with "Update existing products" enabled.
+	 *
+	 * @param string     $csv             CSV contents.
+	 * @param array|null $mapping         Column mapping, or null for ID, Type, SKU and Name.
+	 * @param bool       $update_existing Whether existing products are updated.
+	 * @return array Import results.
+	 */
+	private function import_csv_rows( string $csv, ?array $mapping = null, bool $update_existing = true ): array {
+		$file = get_temp_dir() . 'product-import-' . wp_generate_uuid4() . '.csv';
+		file_put_contents( $file, $csv ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Temporary CSV fixture.
+
+		try {
+			$importer = new WC_Product_CSV_Importer(
+				$file,
+				array(
+					'parse'           => true,
+					'update_existing' => $update_existing,
+					'mapping'         => $mapping ?? array(
+						'ID'   => 'id',
+						'Type' => 'type',
+						'SKU'  => 'sku',
+						'Name' => 'name',
+					),
+				)
+			);
+
+			return $importer->import();
+		} finally {
+			wp_delete_file( $file );
+		}
+	}
+
+	/**
+	 * @testdox Should update the product matched by SKU when a row's ID does not exist on the site and existing products are being updated.
+	 */
+	public function test_import_falls_back_to_sku_when_id_does_not_exist_40274(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_sku( 'IMPORT-40274-A' );
+		$product->set_name( 'Original name' );
+		$product->save();
+
+		$missing_id = $product->get_id() + 1000;
+		$csv        = "ID,Type,SKU,Name\n{$missing_id},simple,IMPORT-40274-A,Updated by SKU\n" . ( $missing_id + 1 ) . ",simple,IMPORT-40274-NONE,Row with no match\n";
+
+		$data = $this->import_csv_rows( $csv );
+
+		$this->assertSame( array( $product->get_id() ), $data['updated'], 'The row with a missing ID updates the product matched by SKU' );
+		$this->assertEmpty( $data['failed'], 'A row whose ID is missing but whose SKU exists must not fail' );
+		$this->assertEmpty( $data['imported'], 'An update import never creates products' );
+		$this->assertCount( 1, $data['skipped'], 'A row matching neither ID nor SKU is still skipped' );
+		$this->assertSame( 'No matching product exists to update.', $data['skipped'][0]->get_error_message(), 'The skipped row says nothing matched' );
+		$this->assertSame( 'Updated by SKU', wc_get_product( $product->get_id() )->get_name(), 'The matched product gets the row\'s data' );
+		$this->assertSame( 0, wc_get_product_id_by_sku( 'IMPORT-40274-NONE' ), 'The unmatched row creates no product' );
+	}
+
+	/**
+	 * @testdox Should explain the conflict when a row's ID and SKU point at two different existing products.
+	 */
+	public function test_import_explains_id_and_sku_conflict_40274(): void {
+		$matched_by_id = WC_Helper_Product::create_simple_product();
+		$matched_by_id->set_sku( 'IMPORT-40274-B' );
+		$matched_by_id->save();
+
+		$owns_sku = WC_Helper_Product::create_simple_product();
+		$owns_sku->set_sku( 'IMPORT-40274-C' );
+		$owns_sku->save();
+
+		$csv = "ID,Type,SKU,Name\n{$matched_by_id->get_id()},simple,IMPORT-40274-C,Conflicting row\n";
+
+		$data = $this->import_csv_rows( $csv );
+
+		$this->assertEmpty( $data['updated'], 'A conflicting row updates nothing' );
+		$this->assertCount( 1, $data['failed'], 'A conflicting row fails' );
+		$message = $data['failed'][0]->get_error_message();
+		$this->assertStringContainsString( 'Invalid or duplicated SKU.', $message, 'The original error is kept' );
+		$this->assertStringContainsString( sprintf( 'matches product #%d', $matched_by_id->get_id() ), $message, 'The hint names the product matched by ID' );
+		$this->assertStringContainsString( sprintf( 'belongs to product #%d', $owns_sku->get_id() ), $message, 'The hint names the product that owns the SKU' );
+		$this->assertSame( 'IMPORT-40274-B', wc_get_product( $matched_by_id->get_id() )->get_sku(), 'The product matched by ID keeps its own SKU' );
+	}
+
+	/**
+	 * @testdox Should update a variation matched by SKU when its ID does not exist and its parent resolves.
+	 */
+	public function test_import_falls_back_to_sku_for_variation_rows_40274(): void {
+		$parent = WC_Helper_Product::create_variation_product();
+		$parent->set_sku( 'IMPORT-40274-PARENT' );
+		$parent->save();
+		$variation = wc_get_product( $parent->get_children()[0] );
+		$variation->set_sku( 'IMPORT-40274-VAR' );
+		$variation->save();
+
+		$missing_id = $variation->get_id() + 1000;
+		$csv        = "ID,Type,SKU,Regular price,Parent\n{$missing_id},variation,IMPORT-40274-VAR,42,IMPORT-40274-PARENT\n";
+
+		$data = $this->import_csv_rows(
+			$csv,
+			array(
+				'ID'            => 'id',
+				'Type'          => 'type',
+				'SKU'           => 'sku',
+				'Regular price' => 'regular_price',
+				'Parent'        => 'parent_id',
+			)
+		);
+
+		$this->assertSame( array( $variation->get_id() ), $data['updated'], 'The variation row updates the variation matched by SKU' );
+		$this->assertEmpty( $data['failed'], 'A variation row with a valid parent does not fail' );
+		$updated = wc_get_product( $variation->get_id() );
+		$this->assertSame( '42', $updated->get_regular_price(), 'The matched variation gets the row\'s price' );
+		$this->assertSame( $parent->get_id(), $updated->get_parent_id(), 'The variation must stay attached to its parent' );
+	}
+
+	/**
+	 * @testdox Should fail a variation row whose parent cannot be found instead of detaching the variation.
+	 */
+	public function test_import_fails_variation_row_with_missing_parent_40274(): void {
+		$parent = WC_Helper_Product::create_variation_product();
+		$parent->set_sku( 'IMPORT-40274-PARENT-B' );
+		$parent->save();
+		$variation = wc_get_product( $parent->get_children()[0] );
+		$variation->set_sku( 'IMPORT-40274-VAR-B' );
+		$variation->save();
+		$original_name = $variation->get_name();
+
+		$missing_id = $variation->get_id() + 1000;
+		$csv        = "ID,Type,SKU,Name,Parent\n{$missing_id},variation,IMPORT-40274-VAR-B,Renamed variation,id:" . ( $missing_id + 1 ) . "\n";
+
+		$data = $this->import_csv_rows(
+			$csv,
+			array(
+				'ID'     => 'id',
+				'Type'   => 'type',
+				'SKU'    => 'sku',
+				'Name'   => 'name',
+				'Parent' => 'parent_id',
+			)
+		);
+
+		$this->assertEmpty( $data['updated'], 'A row with a missing parent updates nothing' );
+		$this->assertCount( 1, $data['failed'], 'A row with a missing parent fails' );
+		$this->assertStringContainsString( 'parent does not exist', $data['failed'][0]->get_error_message(), 'The failure names the missing parent' );
+		$unchanged = wc_get_product( $variation->get_id() );
+		$this->assertSame( $parent->get_id(), $unchanged->get_parent_id(), 'A failed row must not change the parent' );
+		$this->assertSame( $original_name, $unchanged->get_name(), 'A failed row must not change the name' );
+	}
+
+	/**
+	 * @testdox Should not match a row to a product of the other post type by SKU, so nothing is converted.
+	 */
+	public function test_import_sku_fallback_refuses_variation_to_simple_match_40274(): void {
+		$simple = WC_Helper_Product::create_simple_product();
+		$simple->set_sku( 'IMPORT-40274-SIMPLE' );
+		$simple->save();
+
+		$missing_id = $simple->get_id() + 1000;
+		$csv        = "ID,Type,SKU,Name\n{$missing_id},variation,IMPORT-40274-SIMPLE,Should stay a simple product\n";
+
+		$data = $this->import_csv_rows( $csv );
+
+		$this->assertEmpty( $data['updated'], 'A variation row must not update a simple product' );
+		$this->assertCount( 1, $data['failed'], 'A variation row for a simple product fails' );
+		$this->assertStringContainsString( 'Invalid or duplicated SKU.', $data['failed'][0]->get_error_message(), 'The row fails on the SKU the simple product owns' );
+		$this->assertSame( 'product', get_post_type( $simple->get_id() ), 'The simple product must not be converted to a variation' );
+	}
+
+	/**
+	 * @testdox Should not match by SKU when the type the row would save differs from the matched product's type.
+	 */
+	public function test_import_sku_fallback_refuses_other_saved_types_40274(): void {
+		$parent = WC_Helper_Product::create_variation_product();
+		$parent->set_sku( 'IMPORT-40274-TYPE-PARENT' );
+		$parent->save();
+		$variation = wc_get_product( $parent->get_children()[0] );
+		$variation->set_sku( 'IMPORT-40274-TYPE-VAR' );
+		$variation->save();
+
+		$missing_id = $variation->get_id() + 1000;
+		$csv        = "ID,Type,SKU,Name\n" .
+			"{$missing_id},,IMPORT-40274-TYPE-VAR,Empty type saves as simple\n" .
+			( $missing_id + 1 ) . ",simple,IMPORT-40274-TYPE-PARENT,Simple row for a variable product\n";
+
+		$data = $this->import_csv_rows( $csv );
+
+		$this->assertEmpty( $data['updated'], 'Neither row may update the product matched by SKU' );
+		$this->assertCount( 2, $data['failed'], 'Both rows fail' );
+		$this->assertSame( 'Invalid product.', $data['failed'][0]->get_error_message(), 'The empty-type row fails on its missing ID' );
+		$this->assertSame( 'Invalid product.', $data['failed'][1]->get_error_message(), 'The simple row fails on its missing ID' );
+		$this->assertSame( 'product_variation', get_post_type( $variation->get_id() ), 'The variation must not be converted to a simple product' );
+		$this->assertSame( ProductType::VARIABLE, WC_Product_Factory::get_product_type( $parent->get_id() ), 'The variable product must not be converted to a simple product' );
+	}
+
+	/**
+	 * @testdox Should not use the first column as the SKU when the file has no SKU column.
+	 */
+	public function test_import_without_sku_column_does_not_use_first_column_as_sku_40274(): void {
+		$csv = "ID,Type,Name\n987654,simple,Product without a SKU column\n";
+
+		$data = $this->import_csv_rows(
+			$csv,
+			array(
+				'ID'   => 'id',
+				'Type' => 'type',
+				'Name' => 'name',
+			),
+			false
+		);
+
+		$this->assertCount( 1, $data['imported'], 'The row is imported as a new product' );
+		$this->assertSame( '', wc_get_product( $data['imported'][0] )->get_sku(), 'The ID must not be saved as the SKU' );
+	}
+
+	/**
+	 * @testdox Should match by SKU when the SKU cell has surrounding spaces, as the saved SKU is trimmed too.
+	 */
+	public function test_import_sku_fallback_trims_the_row_sku_40274(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_sku( 'IMPORT-40274-TRIM' );
+		$product->save();
+
+		$missing_id = $product->get_id() + 1000;
+		$csv        = "ID,Type,SKU,Name\n{$missing_id},simple,\" IMPORT-40274-TRIM \",Updated by trimmed SKU\n";
+
+		$data = $this->import_csv_rows( $csv );
+
+		$this->assertSame( array( $product->get_id() ), $data['updated'], 'The padded SKU matches the product' );
+		$this->assertSame( 'Updated by trimmed SKU', wc_get_product( $product->get_id() )->get_name(), 'The matched product gets the row\'s data' );
+	}
+
+	/**
+	 * @testdox Should match each row by its own SKU when the file repeats a missing ID, so one product is not updated twice.
+	 */
+	public function test_import_sku_fallback_does_not_reuse_mapping_for_repeated_ids_40274(): void {
+		$product_a = WC_Helper_Product::create_simple_product();
+		$product_a->set_sku( 'IMPORT-40274-REPEAT-A' );
+		$product_a->set_name( 'Original A' );
+		$product_a->save();
+
+		$product_c = WC_Helper_Product::create_simple_product();
+		$product_c->set_sku( 'IMPORT-40274-REPEAT-C' );
+		$product_c->save();
+		wp_set_object_terms( $product_c->get_id(), 'import-40274-kept-tag', 'product_tag' );
+
+		$id_one = $product_c->get_id() + 1000;
+		$id_two = $id_one + 1;
+		$csv    = "ID,Type,SKU,Name\n" .
+			"{$id_one},simple,IMPORT-40274-REPEAT-A,Title A\n" .
+			"{$id_one},simple,IMPORT-40274-REPEAT-B,Title B\n" .
+			"{$id_two},simple,IMPORT-40274-REPEAT-C,Title C\n" .
+			"{$id_two},variation,IMPORT-40274-REPEAT-C,Title C\n";
+
+		$data = $this->import_csv_rows( $csv );
+
+		$this->assertSame( array( $product_a->get_id(), $product_c->get_id() ), $data['updated'], 'Only the rows whose own SKU matches update a product' );
+		$this->assertCount( 1, $data['skipped'], 'The repeated ID with an unknown SKU is skipped' );
+		$this->assertSame( 'No matching product exists to update.', $data['skipped'][0]->get_error_message(), 'The unknown SKU matches nothing' );
+		$this->assertCount( 1, $data['failed'], 'The repeated ID with a variation type is refused' );
+		$updated_a = wc_get_product( $product_a->get_id() );
+		$this->assertSame( 'Title A', $updated_a->get_name(), 'Product A keeps the first row\'s name' );
+		$this->assertSame( 'IMPORT-40274-REPEAT-A', $updated_a->get_sku(), 'Product A keeps its SKU' );
+		$this->assertSame( 'product', get_post_type( $product_c->get_id() ), 'The simple product must not be converted to a variation' );
+		$this->assertTrue( has_term( 'import-40274-kept-tag', 'product_tag', $product_c->get_id() ), 'The simple product keeps its tags' );
+	}
+
+	/**
+	 * @testdox Should fail a variation row with an unusable parent, or an ID that is not a product, before converting the post its ID points at.
+	 */
+	public function test_import_checks_variation_parent_before_converting_the_product_40274(): void {
+		$simple = WC_Helper_Product::create_simple_product();
+		$simple->set_sku( 'IMPORT-40274-PARENT-CHECK' );
+		$simple->save();
+		wp_set_object_terms( $simple->get_id(), 'import-40274-parent-check-tag', 'product_tag' );
+		$blank_parent = WC_Helper_Product::create_simple_product();
+		$page         = self::factory()->post->create( array( 'post_type' => 'page' ) );
+
+		$csv = "ID,Type,SKU,Name,Parent\n" .
+			"{$simple->get_id()},variation,,Should stay simple,id:" . ( $simple->get_id() + 1000 ) . "\n" .
+			"{$blank_parent->get_id()},variation,,Should stay simple too,\n" .
+			"{$page},variation,IMPORT-40274-PARENT-CHECK,Should stay a page,\n";
+
+		$data = $this->import_csv_rows(
+			$csv,
+			array(
+				'ID'     => 'id',
+				'Type'   => 'type',
+				'SKU'    => 'sku',
+				'Name'   => 'name',
+				'Parent' => 'parent_id',
+			)
+		);
+
+		$this->assertCount( 3, $data['failed'], 'Each variation row fails' );
+		$this->assertSame( 'woocommerce_product_importer_missing_variation_parent_id', $data['failed'][0]->get_error_code(), 'The row fails on its missing parent' );
+		$this->assertSame( 'woocommerce_product_importer_missing_variation_parent_id', $data['failed'][1]->get_error_code(), 'A product turning into a variation needs a parent' );
+		$this->assertSame( 'woocommerce_product_csv_importer_invalid_id', $data['failed'][2]->get_error_code(), 'A post that is not a product is refused' );
+		$this->assertSame( 'product', get_post_type( $simple->get_id() ), 'The product must not be converted to a variation' );
+		$this->assertSame( 'product', get_post_type( $blank_parent->get_id() ), 'The product with a blank Parent cell must not be converted' );
+		$this->assertSame( 'page', get_post_type( $page ), 'The page must not be converted to a variation' );
+		$this->assertTrue( has_term( 'import-40274-parent-check-tag', 'product_tag', $simple->get_id() ), 'The product keeps its tags' );
+	}
+
+	/**
+	 * @testdox Should fail an update row whose parent is not an existing variable product instead of moving the variation.
+	 */
+	public function test_import_update_requires_an_existing_variable_parent_40274(): void {
+		$parent = WC_Helper_Product::create_variation_product();
+		$parent->set_sku( 'IMPORT-40274-REAL-PARENT' );
+		$parent->save();
+		$children = $parent->get_children();
+
+		$simple = WC_Helper_Product::create_simple_product();
+		$simple->set_sku( 'IMPORT-40274-SIMPLE-PARENT' );
+		$simple->save();
+
+		$placeholder = WC_Helper_Product::create_variation_product();
+		wp_update_post(
+			array(
+				'ID'          => $placeholder->get_id(),
+				'post_status' => 'importing',
+			)
+		);
+
+		$first  = wc_get_product( $children[0] );
+		$second = wc_get_product( $children[1] );
+		$third  = wc_get_product( $children[2] );
+		$csv    = "ID,Type,SKU,Name,Parent\n" .
+			"{$first->get_id()},variation,{$first->get_sku()},Unknown parent,IMPORT-40274-NO-SUCH-PARENT\n" .
+			"{$second->get_id()},variation,{$second->get_sku()},Simple parent,IMPORT-40274-SIMPLE-PARENT\n" .
+			"{$third->get_id()},variation,{$third->get_sku()},Importing parent,id:{$placeholder->get_id()}\n";
+
+		$data = $this->import_csv_rows(
+			$csv,
+			array(
+				'ID'     => 'id',
+				'Type'   => 'type',
+				'SKU'    => 'sku',
+				'Name'   => 'name',
+				'Parent' => 'parent_id',
+			)
+		);
+
+		$this->assertEmpty( $data['updated'], 'No variation may be moved' );
+		$this->assertCount( 3, $data['failed'], 'Each row fails' );
+		$this->assertSame( 'woocommerce_product_importer_variation_parent_not_variable', $data['failed'][0]->get_error_code(), 'An unknown parent SKU, resolved to a placeholder, is refused' );
+		$this->assertSame( 'woocommerce_product_importer_variation_parent_not_variable', $data['failed'][1]->get_error_code(), 'A simple parent is refused' );
+		$this->assertSame( 'woocommerce_product_importer_variation_parent_not_variable', $data['failed'][2]->get_error_code(), 'A variable parent still being imported is refused' );
+		$this->assertSame( $parent->get_id(), wc_get_product( $first->get_id() )->get_parent_id(), 'The first variation keeps its parent' );
+		$this->assertSame( $parent->get_id(), wc_get_product( $second->get_id() )->get_parent_id(), 'The second variation keeps its parent' );
+		$this->assertSame( $parent->get_id(), wc_get_product( $third->get_id() )->get_parent_id(), 'The third variation keeps its parent' );
+	}
+
+	/**
+	 * @testdox Should keep a variation's parent and apply its attributes when its Parent cell is blank, and check the parent of rows without a Type column.
+	 */
+	public function test_import_blank_parent_keeps_parent_and_untyped_rows_are_checked_40274(): void {
+		$parent   = WC_Helper_Product::create_variation_product();
+		$children = $parent->get_children();
+		$csv      = "ID,Name,Parent,Attribute 1 name,Attribute 1 value(s),Attribute 1 global\n" .
+			"{$children[0]},Blank parent,,size,large,1\n" .
+			"{$children[1]},Missing parent,id:" . ( max( $children ) + 1000 ) . ",size,large,1\n";
+
+		$data = $this->import_csv_rows(
+			$csv,
+			array(
+				'ID'                   => 'id',
+				'Name'                 => 'name',
+				'Parent'               => 'parent_id',
+				'Attribute 1 name'     => 'attributes:name1',
+				'Attribute 1 value(s)' => 'attributes:value1',
+				'Attribute 1 global'   => 'attributes:taxonomy1',
+			)
+		);
+
+		$this->assertSame( array( $children[0] ), $data['updated'], 'The row with a blank Parent cell is updated' );
+		$this->assertSame( $parent->get_id(), wc_get_product( $children[0] )->get_parent_id(), 'A blank Parent cell keeps the parent' );
+		$this->assertSame( 'large', wc_get_product( $children[0] )->get_attribute( 'pa_size' ), 'The row\'s attributes still apply' );
+		$this->assertCount( 1, $data['failed'], 'The row naming a missing parent fails' );
+		$this->assertSame( 'woocommerce_product_importer_missing_variation_parent_id', $data['failed'][0]->get_error_code(), 'The untyped row fails on its parent' );
+		$this->assertSame( $parent->get_id(), wc_get_product( $children[1] )->get_parent_id(), 'The failed row keeps the parent' );
+	}
+
+	/**
+	 * @testdox Should not add the SKU hint to a failure about the row's own type.
+	 */
+	public function test_import_does_not_add_sku_hint_to_invalid_type_errors_40274(): void {
+		$matched_by_id = WC_Helper_Product::create_simple_product();
+		$owns_sku      = WC_Helper_Product::create_simple_product();
+		$owns_sku->set_sku( 'IMPORT-40274-HINT' );
+		$owns_sku->save();
+
+		$data = $this->import_csv_rows( "ID,Type,SKU,Name\n{$matched_by_id->get_id()},not-a-type,IMPORT-40274-HINT,Bad type\n" );
+
+		$this->assertCount( 1, $data['failed'], 'A row with an unknown type fails' );
+		$this->assertSame( 'Invalid product type.', $data['failed'][0]->get_error_message(), 'The type error is shown on its own' );
+	}
+
+	/**
+	 * @testdox Should match SKUs and SKU references that the exporter escaped with a leading apostrophe.
+	 */
+	public function test_import_unescapes_exported_skus_40274(): void {
+		$parent = WC_Helper_Product::create_variation_product();
+		$parent->set_sku( '-IMPORT-40274-DASH-PARENT' );
+		$parent->save();
+		$variation = wc_get_product( $parent->get_children()[0] );
+		$variation->set_sku( '=IMPORT-40274-DASH-VAR' );
+		$variation->save();
+
+		$missing_id = $variation->get_id() + 1000;
+		$csv        = "ID,Type,SKU,Regular price,Parent\n{$missing_id},variation,'=IMPORT-40274-DASH-VAR,42,'-IMPORT-40274-DASH-PARENT\n";
+
+		$data = $this->import_csv_rows(
+			$csv,
+			array(
+				'ID'            => 'id',
+				'Type'          => 'type',
+				'SKU'           => 'sku',
+				'Regular price' => 'regular_price',
+				'Parent'        => 'parent_id',
+			)
+		);
+
+		$this->assertSame( array( $variation->get_id() ), $data['updated'], 'The escaped SKU matches the variation' );
+		$updated = wc_get_product( $variation->get_id() );
+		$this->assertSame( '=IMPORT-40274-DASH-VAR', $updated->get_sku(), 'The SKU is saved without the apostrophe' );
+		$this->assertSame( $parent->get_id(), $updated->get_parent_id(), 'The escaped parent SKU resolves to the parent' );
+	}
+
+	/**
+	 * @testdox Should match by SKU a variation whose class reports an extension's variation subtype.
+	 */
+	public function test_import_sku_fallback_matches_extension_variation_subtypes_40274(): void {
+		$subtype_class = get_class(
+			new class() extends WC_Product_Variation {
+				/**
+				 * Get the extension's variation subtype.
+				 *
+				 * @return string
+				 */
+				public function get_type() {
+					return 'import-40274-sub-variation';
+				}
+			}
+		);
+
+		$parent       = WC_Helper_Product::create_variation_product();
+		$variation_id = $parent->get_children()[0];
+		$variation    = wc_get_product( $variation_id );
+		$variation->set_sku( 'IMPORT-40274-SUBTYPE' );
+		$variation->save();
+
+		$use_subtype = function ( $classname, $product_type, $context, $product_id ) use ( $subtype_class, $variation_id ) {
+			return ( 'import-40274-sub-variation' === $product_type || $variation_id === (int) $product_id ) ? $subtype_class : $classname;
+		};
+		$export_type = function ( $types ) {
+			return $types + array( 'import-40274-sub-variation' => 'Subtype variations' );
+		};
+		$data_store  = function ( $stores ) {
+			return $stores + array( 'product-import-40274-sub-variation' => 'WC_Product_Variation_Data_Store_CPT' );
+		};
+		add_filter( 'woocommerce_product_class', $use_subtype, 10, 4 );
+		add_filter( 'woocommerce_exporter_product_types', $export_type );
+		add_filter( 'woocommerce_data_stores', $data_store );
+
+		$data = $this->import_csv_rows( "ID,Type,SKU,Name\n" . ( $variation_id + 1000 ) . ",import-40274-sub-variation,IMPORT-40274-SUBTYPE,Subtype row\n" );
+
+		$this->assertSame( array( $variation_id ), $data['updated'], 'The subtype row updates the variation matched by SKU' );
+		$this->assertSame( 'product_variation', get_post_type( $variation_id ), 'The variation stays a variation' );
+	}
+
+	/**
+	 * @testdox Should not match by SKU a row that refers to other products by IDs from the site its own ID came from.
+	 */
+	public function test_import_sku_fallback_refuses_rows_with_id_references_40274(): void {
+		$parent    = WC_Helper_Product::create_variation_product();
+		$variation = wc_get_product( $parent->get_children()[0] );
+		$variation->set_sku( 'IMPORT-40274-ID-REF' );
+		$variation->save();
+		$unrelated = WC_Helper_Product::create_variation_product();
+
+		$csv = "ID,Type,SKU,Name,Parent\n" . ( $unrelated->get_id() + 1000 ) . ",variation,IMPORT-40274-ID-REF,From another site,id:{$unrelated->get_id()}\n";
+
+		$data = $this->import_csv_rows(
+			$csv,
+			array(
+				'ID'     => 'id',
+				'Type'   => 'type',
+				'SKU'    => 'sku',
+				'Name'   => 'name',
+				'Parent' => 'parent_id',
+			)
+		);
+
+		$this->assertEmpty( $data['updated'], 'The row must not update the variation matched by SKU' );
+		$this->assertCount( 1, $data['failed'], 'The row fails' );
+		$this->assertSame( $parent->get_id(), wc_get_product( $variation->get_id() )->get_parent_id(), 'The variation keeps its parent' );
+	}
+
+	/**
+	 * @testdox Should match a variation by SKU when the file has no Type column.
+	 */
+	public function test_import_sku_fallback_without_type_column_40274(): void {
+		$parent    = WC_Helper_Product::create_variation_product();
+		$variation = wc_get_product( $parent->get_children()[0] );
+		$variation->set_sku( 'IMPORT-40274-NO-TYPE' );
+		$variation->save();
+
+		$data = $this->import_csv_rows(
+			"ID,SKU,Regular price\n" . ( $variation->get_id() + 1000 ) . ",IMPORT-40274-NO-TYPE,31\n",
+			array(
+				'ID'            => 'id',
+				'SKU'           => 'sku',
+				'Regular price' => 'regular_price',
+			)
+		);
+
+		$this->assertSame( array( $variation->get_id() ), $data['updated'], 'The row updates the variation matched by SKU' );
+		$this->assertSame( '31', wc_get_product( $variation->get_id() )->get_regular_price(), 'The variation gets the row\'s price' );
+		$this->assertSame( $parent->get_id(), wc_get_product( $variation->get_id() )->get_parent_id(), 'The variation keeps its parent' );
+	}
+
+	/**
+	 * @testdox Should resolve a SKU reference to a product whose stored SKU keeps characters the import would clean.
+	 */
+	public function test_import_resolves_references_to_raw_stored_skus_40274(): void {
+		$parent = WC_Helper_Product::create_variation_product();
+		$parent->set_sku( 'IMPORT-40274  RAW' );
+		$parent->save();
+		$variation_id = $parent->get_children()[0];
+
+		$data = $this->import_csv_rows(
+			"ID,Type,Parent,Regular price\n{$variation_id},variation,IMPORT-40274  RAW,29\n",
+			array(
+				'ID'            => 'id',
+				'Type'          => 'type',
+				'Parent'        => 'parent_id',
+				'Regular price' => 'regular_price',
+			)
+		);
+
+		$this->assertSame( array( $variation_id ), $data['updated'], 'The row with the raw parent SKU is updated' );
+		$this->assertSame( $parent->get_id(), wc_get_product( $variation_id )->get_parent_id(), 'The reference resolves to the parent' );
+	}
+
+	/**
+	 * @testdox Should find a product by its stored SKU when creating products, even if the import would clean that SKU.
+	 */
+	public function test_import_create_finds_products_by_raw_stored_sku_40274(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_sku( 'IMPORT-40274  STORED' );
+		$product->save();
+
+		$data = $this->import_csv_rows( "ID,Type,SKU,Name\n" . ( $product->get_id() + 1000 ) . ",simple,IMPORT-40274  STORED,Not a duplicate\n", null, false );
+
+		$this->assertEmpty( $data['imported'], 'No duplicate product is created' );
+		$this->assertCount( 1, $data['skipped'], 'The row is skipped' );
+		$this->assertSame( 'A product with this ID already exists.', $data['skipped'][0]->get_error_message(), 'The row resolves to the existing product' );
+	}
+
+	/**
+	 * @testdox Should fail a variation row for an existing product when the file has no Parent column.
+	 */
+	public function test_import_variation_row_without_parent_column_does_not_convert_a_product_40274(): void {
+		$simple = WC_Helper_Product::create_simple_product();
+
+		$data = $this->import_csv_rows(
+			"ID,Type,Name\n{$simple->get_id()},variation,Should stay simple\n",
+			array(
+				'ID'   => 'id',
+				'Type' => 'type',
+				'Name' => 'name',
+			)
+		);
+
+		$this->assertCount( 1, $data['failed'], 'The row fails' );
+		$this->assertSame( 'woocommerce_product_importer_missing_variation_parent_id', $data['failed'][0]->get_error_code(), 'The row fails on its missing parent' );
+		$this->assertSame( 'product', get_post_type( $simple->get_id() ), 'The product must not be converted to a variation' );
+	}
+
+	/**
+	 * @testdox Should not match by SKU a product whose stored type is not registered, so the type is not rewritten.
+	 */
+	public function test_import_sku_fallback_keeps_unregistered_stored_types_40274(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_sku( 'IMPORT-40274-UNREGISTERED' );
+		$product->save();
+		wp_set_object_terms( $product->get_id(), 'import-40274-unregistered', 'product_type' );
+
+		$data = $this->import_csv_rows( "ID,Type,SKU,Name\n" . ( $product->get_id() + 1000 ) . ",simple,IMPORT-40274-UNREGISTERED,Renamed\n" );
+
+		$this->assertEmpty( $data['updated'], 'The row must not update the product' );
+		$this->assertTrue( has_term( 'import-40274-unregistered', 'product_type', $product->get_id() ), 'The stored type is kept' );
+	}
+
+	/**
+	 * @testdox Should let code outside the importer call the SKU formatting callback.
+	 */
+	public function test_import_sku_callback_is_callable_from_outside_the_importer_40274(): void {
+		$wrap_callbacks = function ( $callbacks ) {
+			return array_map(
+				function ( $callback ) {
+					return function ( $value ) use ( $callback ) {
+						return call_user_func( $callback, $value );
+					};
+				},
+				$callbacks
+			);
+		};
+		add_filter( 'woocommerce_product_importer_formatting_callbacks', $wrap_callbacks );
+
+		$data = $this->import_csv_rows( "Type,SKU,Name\nsimple,'-IMPORT-40274-WRAPPED,Wrapped callbacks\n", null, false );
+
+		$this->assertCount( 1, $data['imported'], 'The row is imported' );
+		$this->assertSame( '-IMPORT-40274-WRAPPED', wc_get_product( $data['imported'][0] )->get_sku(), 'The SKU callback ran' );
+	}
+
+	/**
+	 * @testdox Should fail a variation row with a blank Parent cell when the variation's current parent can no longer hold it.
+	 */
+	public function test_import_blank_parent_checks_the_current_parent_40274(): void {
+		$parent       = WC_Helper_Product::create_variation_product();
+		$variation_id = $parent->get_children()[0];
+		wp_update_post(
+			array(
+				'ID'          => $parent->get_id(),
+				'post_status' => 'trash',
+			)
+		);
+
+		$data = $this->import_csv_rows(
+			"ID,Name,Parent,Attribute 1 name,Attribute 1 value(s),Attribute 1 global\n{$variation_id},Trashed parent,,size,large,1\n",
+			array(
+				'ID'                   => 'id',
+				'Name'                 => 'name',
+				'Parent'               => 'parent_id',
+				'Attribute 1 name'     => 'attributes:name1',
+				'Attribute 1 value(s)' => 'attributes:value1',
+				'Attribute 1 global'   => 'attributes:taxonomy1',
+			)
+		);
+
+		$this->assertEmpty( $data['updated'], 'The row must not be reported as updated' );
+		$this->assertSame( 'woocommerce_product_importer_variation_parent_not_variable', $data['failed'][0]->get_error_code(), 'The row fails on its trashed parent' );
+		$this->assertSame( 'small', wc_get_product( $variation_id )->get_attribute( 'pa_size' ), 'The attributes are unchanged' );
+	}
+
 	/**
 	 * @testdox Should keep double-quoted values together and leave other values untouched when splitting comma-separated fields.
 	 * @dataProvider provider_parse_comma_field_quoted_values