Commit 3e9d9373e4b for woocommerce
commit 3e9d9373e4b319955f39f54542d08e827cb39e3a
Author: Justin P <228780+layoutd@users.noreply.github.com>
Date: Tue Oct 27 17:40:41 2026 +0100
Keep double-quoted values together in comma-separated CSV import fields (#69990)
* Keep double-quoted values together when splitting CSV import fields
* Add changelog entry for quoted CSV import values
* Only let unbalanced quotes open or close a grouped CSV value
* Reword changelog entry for quoted CSV import values
* Drop the PHPStan baseline entry the CSV fix resolves
* Set the since tag on group_quoted_values to the version trunk now targets
* Parse attribute defaults like attribute values so a quoted default still matches
* Only unwrap a single quoted segment when it holds exactly two quotes
* Tighten the quoted-value docblock example and the changelog entry
* Leave a lone quote segment as it is when grouping quoted values
* Unescape a split attribute default and cover more quoted cells in tests
diff --git a/plugins/woocommerce/changelog/fix-csv-import-quoted-attribute-values b/plugins/woocommerce/changelog/fix-csv-import-quoted-attribute-values
new file mode 100644
index 00000000000..31729a520b7
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-csv-import-quoted-attribute-values
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep double-quoted values together when importing product CSV lists, such as attribute values and tags.
diff --git a/plugins/woocommerce/includes/import/abstract-wc-product-importer.php b/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
index 772cca78478..01ebe413501 100644
--- a/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
+++ b/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
@@ -883,8 +883,8 @@ abstract class WC_Product_Importer implements WC_Importer_Interface {
}
/**
- * Explode CSV cell values using commas by default, and handling escaped
- * separators.
+ * Explode CSV cell values using commas by default, handling escaped
+ * separators and values wrapped in double quotes.
*
* @since 3.2.0
* @param string $value Value to explode.
@@ -892,13 +892,99 @@ abstract class WC_Product_Importer implements WC_Importer_Interface {
* @return array
*/
protected function explode_values( $value, $separator = ',' ) {
- $value = str_replace( '\\,', '::separator::', $value );
- $values = explode( $separator, $value );
- $values = array_map( array( $this, 'explode_values_formatter' ), $values );
+ $value = str_replace( '\\,', '::separator::', $value );
+ $segments = explode( $separator, $value );
+ $values = $this->group_quoted_values( is_array( $segments ) ? $segments : array( $value ), $separator );
+ $values = array_map( array( $this, 'explode_values_formatter' ), $values );
return $values;
}
+ /**
+ * Re-join exploded segments that were wrapped in double quotes, so a quoted value
+ * can contain the separator, as in `Red, "Blue, navy", Green`. Quotes that are
+ * balanced inside a segment (`"Large" size`) never open or close a value.
+ *
+ * @since 11.4.0
+ *
+ * @param string[] $segments Segments produced by splitting the cell on the separator.
+ * @param string $separator Separator the segments were split on.
+ * @return string[]
+ */
+ protected function group_quoted_values( array $segments, $separator ) {
+ $values = array();
+ $count = count( $segments );
+ $unclosed_from = $count;
+
+ for ( $i = 0; $i < $count; $i++ ) {
+ $segment = trim( $segments[ $i ] );
+ $end = false;
+
+ if ( $this->opens_quoted_value( $segment ) ) {
+ if ( $this->is_self_closed_quoted_value( $segment ) ) {
+ $end = $i;
+ } elseif ( $i < $unclosed_from ) {
+ for ( $end = $i + 1; $end < $count; $end++ ) {
+ if ( $this->closes_quoted_value( trim( $segments[ $end ] ) ) ) {
+ break;
+ }
+ }
+
+ // Nothing after this segment closes a quote, so later openers cannot be closed either.
+ if ( $end >= $count ) {
+ $unclosed_from = $i;
+ $end = false;
+ }
+ }
+ }
+
+ if ( false === $end ) {
+ $values[] = $segments[ $i ];
+ continue;
+ }
+
+ $quoted = trim( implode( $separator, array_slice( $segments, $i, $end - $i + 1 ) ) );
+ $values[] = (string) substr( $quoted, 1, -1 );
+ $i = $end;
+ }
+
+ return $values;
+ }
+
+ /**
+ * Whether a trimmed segment starts a double-quoted value: it begins with a quote that the
+ * segment either closes itself (`"Large"`) or leaves open (`"Blue`, an odd number of quotes).
+ * A lone `"` is left as it is.
+ *
+ * @param string $segment Trimmed segment.
+ * @return bool
+ */
+ private function opens_quoted_value( string $segment ): bool {
+ return strlen( $segment ) > 1 && '"' === substr( $segment, 0, 1 ) && ( $this->is_self_closed_quoted_value( $segment ) || 1 === substr_count( $segment, '"' ) % 2 );
+ }
+
+ /**
+ * Whether a trimmed segment is a complete double-quoted value on its own, such as `"Large"`.
+ * A segment with more quotes, such as `"Pro" or "Max"`, is ambiguous and is left as it is.
+ *
+ * @param string $segment Trimmed segment.
+ * @return bool
+ */
+ private function is_self_closed_quoted_value( string $segment ): bool {
+ return strlen( $segment ) > 1 && '"' === substr( $segment, 0, 1 ) && '"' === substr( $segment, -1 ) && 2 === substr_count( $segment, '"' );
+ }
+
+ /**
+ * Whether a trimmed segment ends a double-quoted value opened by an earlier segment: it ends
+ * with a quote that is not balanced inside the segment, such as `navy"` but not `15" Wheel"` or a lone `"`.
+ *
+ * @param string $segment Trimmed segment.
+ * @return bool
+ */
+ private function closes_quoted_value( string $segment ): bool {
+ return strlen( $segment ) > 1 && '"' === substr( $segment, -1 ) && 1 === substr_count( $segment, '"' ) % 2;
+ }
+
/**
* Remove formatting and trim each value.
*
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 42c1b0c9d0c..b7177108d19 100644
--- a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
+++ b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
@@ -409,6 +409,26 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
return array_map( 'wc_clean', $this->explode_values( $value ) );
}
+ /**
+ * Parse an attribute default value from a CSV.
+ *
+ * The default is matched against the attribute options with a strict comparison, so
+ * it goes through the same parsing as the values: a quoted default loses its quotes
+ * the way the quoted option does. A cell that would split into several values is
+ * kept whole, since the default is a single value.
+ *
+ * @since 11.4.0
+ *
+ * @param string $value Field value.
+ *
+ * @return string
+ */
+ public function parse_attribute_default_field( $value ) {
+ $values = $this->parse_comma_field( $value );
+
+ return 1 === count( $values ) ? $values[0] : wc_clean( $this->unescape_data( $value ) );
+ }
+
/**
* Parse a field that is generally '1' or '0' but can be something else.
*
@@ -925,6 +945,7 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
*/
$prefix_match_data_formatting = array(
'attributes:value' => array( $this, 'parse_comma_field' ),
+ 'attributes:default' => array( $this, 'parse_attribute_default_field' ),
'attributes:visible' => array( $this, 'parse_bool_field' ),
'attributes:taxonomy' => array( $this, 'parse_bool_field' ),
'downloads:url' => array( $this, 'parse_download_file_field' ),
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index c445ec1e922..0fbf9bcb600 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -19746,12 +19746,6 @@ parameters:
count: 2
path: includes/import/abstract-wc-product-importer.php
- -
- message: '#^Parameter \#2 \$array of function array_map expects array, list\<string\>\|false given\.$#'
- identifier: argument.type
- count: 1
- 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/legacy/unit-tests/importer/product.php b/plugins/woocommerce/tests/legacy/unit-tests/importer/product.php
index 15afe87edac..185a419c3d7 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/importer/product.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/importer/product.php
@@ -406,6 +406,7 @@ class WC_Tests_Product_CSV_Importer extends WC_Unit_Test_Case {
'attributes:value1' => array( $importer, 'parse_comma_field' ),
'attributes:visible1' => array( $importer, 'parse_bool_field' ),
'attributes:taxonomy1' => array( $importer, 'parse_bool_field' ),
+ 'attributes:default1' => array( $importer, 'parse_attribute_default_field' ),
'downloads:url1' => array( $importer, 'parse_download_file_field' ),
'meta:_my_field' => 'wp_kses_post',
// Columns that merely contain a special name must fall back to wc_clean.
@@ -416,7 +417,6 @@ class WC_Tests_Product_CSV_Importer extends WC_Unit_Test_Case {
'a downloads:url1' => 'wc_clean',
// Special columns handled elsewhere still get the default callback.
'attributes:name1' => 'wc_clean',
- 'attributes:default1' => 'wc_clean',
'downloads:name1' => 'wc_clean',
);
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 8ed928009ad..18fa7d90186 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
@@ -2115,4 +2115,106 @@ class WC_Product_CSV_Importer_Test extends \WC_Unit_Test_Case {
wp_delete_file( $file );
}
}
+
+ /**
+ * @testdox Should keep double-quoted values together and leave other values untouched when splitting comma-separated fields.
+ * @dataProvider provider_parse_comma_field_quoted_values
+ *
+ * @param string $value Raw CSV cell value.
+ * @param array $expected Expected list of values.
+ */
+ public function test_parse_comma_field_handles_quoted_values( string $value, array $expected ): void {
+ $importer = new WC_Product_CSV_Importer( __DIR__ . '/sample.csv' );
+
+ $this->assertSame( $expected, $importer->parse_comma_field( $value ) );
+ }
+
+ /**
+ * Data provider for test_parse_comma_field_handles_quoted_values.
+ *
+ * @return array[]
+ */
+ public function provider_parse_comma_field_quoted_values(): array {
+ return array(
+ 'quoted value with commas' => array( 'Red, "Blue, navy", Green', array( 'Red', 'Blue, navy', 'Green' ) ),
+ 'two quoted groups' => array( '"Blue, navy", "Red, dark"', array( 'Blue, navy', 'Red, dark' ) ),
+ 'whole cell quoted' => array( '"New, no box. Multiple quantity, minor shelf wear."', array( 'New, no box. Multiple quantity, minor shelf wear.' ) ),
+ 'backslash-escaped comma' => array( 'Red\\, Green, Blue', array( 'Red, Green', 'Blue' ) ),
+ 'escaped comma inside quotes' => array( '"Red\\, Green, Blue", Black', array( 'Red, Green, Blue', 'Black' ) ),
+ 'quote inside a value' => array( 'Red, 5" Wheel, Green', array( 'Red', '5" Wheel', 'Green' ) ),
+ 'quote not closing a segment' => array( '"Large" size, Small', array( '"Large" size', 'Small' ) ),
+ 'closed quote then inch mark' => array( '"Large" size, 15"', array( '"Large" size', '15"' ) ),
+ 'balanced quotes in both' => array( '"Pro" edition, Small "S"', array( '"Pro" edition', 'Small "S"' ) ),
+ 'quotes inside a group' => array( '"a, "b", c"', array( 'a, "b", c' ) ),
+ 'quoted words in one value' => array( '"Pro" or "Max", Small', array( '"Pro" or "Max"', 'Small' ) ),
+ 'nested quotes in one value' => array( '"Pro "P" edition"', array( '"Pro "P" edition"' ) ),
+ 'lone quote segments' => array( 'a, ", b, "', array( 'a', '"', 'b', '"' ) ),
+ 'empty quoted value' => array( '""', array( '' ) ),
+ 'unterminated quote' => array( 'Red, "Blue', array( 'Red', '"Blue' ) ),
+ 'several unterminated quotes' => array( '"a, "b, c', array( '"a', '"b', 'c' ) ),
+ 'plain list' => array( 'Red, Green', array( 'Red', 'Green' ) ),
+ );
+ }
+
+ /**
+ * @testdox Should keep a double-quoted tag together when tags are separated by spaces.
+ */
+ public function test_parse_tags_spaces_field_handles_quoted_values(): void {
+ $importer = new WC_Product_CSV_Importer( __DIR__ . '/sample.csv' );
+
+ $term_ids = $importer->parse_tags_spaces_field( '"New York" Boston 15"' );
+
+ $this->assertSame( array( 'New York', 'Boston', '15"' ), array_map( fn( $id ) => get_term( $id, 'product_tag' )->name, $term_ids ) );
+ }
+
+ /**
+ * @testdox Should parse an attribute default like a single attribute value.
+ * @testWith ["\"Premium\"", "Premium"]
+ * ["\"Blue, navy\"", "Blue, navy"]
+ * ["Blue, navy", "Blue, navy"]
+ * ["Basic", "Basic"]
+ * ["'=Blue, navy", "=Blue, navy"]
+ * ["", ""]
+ *
+ * @param string $value Raw CSV cell value.
+ * @param string $expected Expected default.
+ */
+ public function test_parse_attribute_default_field( string $value, string $expected ): void {
+ $importer = new WC_Product_CSV_Importer( __DIR__ . '/sample.csv' );
+
+ $this->assertSame( $expected, $importer->parse_attribute_default_field( $value ) );
+ }
+
+ /**
+ * @testdox Should keep a quoted attribute default matched to its unquoted option when importing a variable product.
+ */
+ public function test_import_keeps_quoted_attribute_default(): void {
+ $csv_file = trailingslashit( get_temp_dir() ) . 'import-quoted-attribute-default.csv';
+ file_put_contents( $csv_file, "SKU,Type,Name,Attribute 1 name,Attribute 1 value(s),Attribute 1 default,Attribute 1 visible,Attribute 1 global\nIMPORT-QUOTED-DEFAULT,variable,Quoted default,Edition,\"\"\"Premium\"\", Basic\",\"\"\"Premium\"\"\",1,0\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture written to the temp dir.
+
+ $importer = new WC_Product_CSV_Importer(
+ $csv_file,
+ array(
+ 'parse' => true,
+ 'mapping' => array(
+ 'SKU' => 'sku',
+ 'Type' => 'type',
+ 'Name' => 'name',
+ 'Attribute 1 name' => 'attributes:name1',
+ 'Attribute 1 value(s)' => 'attributes:value1',
+ 'Attribute 1 default' => 'attributes:default1',
+ 'Attribute 1 visible' => 'attributes:visible1',
+ 'Attribute 1 global' => 'attributes:taxonomy1',
+ ),
+ )
+ );
+ $data = $importer->import();
+
+ wp_delete_file( $csv_file );
+
+ $this->assertCount( 1, $data['imported'] );
+ $product = wc_get_product( $data['imported'][0] );
+ $this->assertSame( array( 'Premium', 'Basic' ), $product->get_attributes()['edition']->get_options() );
+ $this->assertSame( array( 'edition' => 'Premium' ), $product->get_default_attributes() );
+ }
}