diff --git a/csv/src/main/java/tools/jackson/dataformat/csv/CsvWriteFeature.java b/csv/src/main/java/tools/jackson/dataformat/csv/CsvWriteFeature.java index 8712aa25..4ad95eca 100644 --- a/csv/src/main/java/tools/jackson/dataformat/csv/CsvWriteFeature.java +++ b/csv/src/main/java/tools/jackson/dataformat/csv/CsvWriteFeature.java @@ -56,6 +56,20 @@ public enum CsvWriteFeature */ ALWAYS_QUOTE_EMPTY_STRINGS(false), + /** + * Feature that determines whether String values with leading or trailing + * whitespace (any character {@code <= 0x0020}, including space and tab) + * should be forced to be quoted. + * This is useful for interoperability with CSV parsers that trim unquoted + * whitespace. + *

+ * Default value is {@code false} so that leading/trailing whitespace + * does not by itself trigger quoting. + * + * @since 3.2 + */ + QUOTE_STRINGS_WITH_LEADING_TRAILING_WHITESPACE(false), + /** * Feature that determines whether values written as Nymbers (from {@code java.lang.Number} * valued POJO properties) should be forced to be quoted, regardless of whether they diff --git a/csv/src/main/java/tools/jackson/dataformat/csv/impl/CsvEncoder.java b/csv/src/main/java/tools/jackson/dataformat/csv/impl/CsvEncoder.java index 83ef2199..5f5aa8f5 100644 --- a/csv/src/main/java/tools/jackson/dataformat/csv/impl/CsvEncoder.java +++ b/csv/src/main/java/tools/jackson/dataformat/csv/impl/CsvEncoder.java @@ -91,33 +91,36 @@ public class CsvEncoder */ protected final int _cfgMinSafeChar; - protected int _csvFeatures; + protected final int _csvFeatures; /** * Marker flag used to determine if to do optimal (aka "strict") quoting * checks or not (looser conservative check) */ - protected boolean _cfgOptimalQuoting; + protected final boolean _cfgOptimalQuoting; protected final boolean _cfgAllowsComments; - protected boolean _cfgIncludeMissingTail; + protected final boolean _cfgIncludeMissingTail; - protected boolean _cfgAlwaysQuoteStrings; + protected final boolean _cfgAlwaysQuoteStrings; - protected boolean _cfgAlwaysQuoteEmptyStrings; + protected final boolean _cfgAlwaysQuoteEmptyStrings; // @since 2.16 - protected boolean _cfgAlwaysQuoteNumbers; + protected final boolean _cfgAlwaysQuoteNumbers; - protected boolean _cfgEscapeQuoteCharWithEscapeChar; + // @since 3.2 + protected final boolean _cfgQuoteLeadingTrailingWhitespace; - protected boolean _cfgEscapeControlCharWithEscapeChar; + protected final boolean _cfgEscapeQuoteCharWithEscapeChar; + + protected final boolean _cfgEscapeControlCharWithEscapeChar; /** * @since 2.14 */ - protected boolean _cfgUseFastDoubleWriter; + protected final boolean _cfgUseFastDoubleWriter; protected final char _cfgQuoteCharEscapeChar; @@ -207,6 +210,7 @@ public CsvEncoder(IOContext ctxt, int csvFeatures, Writer out, CsvSchema schema, _cfgAlwaysQuoteStrings = CsvWriteFeature.ALWAYS_QUOTE_STRINGS.enabledIn(csvFeatures); _cfgAlwaysQuoteEmptyStrings = CsvWriteFeature.ALWAYS_QUOTE_EMPTY_STRINGS.enabledIn(csvFeatures); _cfgAlwaysQuoteNumbers = CsvWriteFeature.ALWAYS_QUOTE_NUMBERS.enabledIn(csvFeatures); + _cfgQuoteLeadingTrailingWhitespace = CsvWriteFeature.QUOTE_STRINGS_WITH_LEADING_TRAILING_WHITESPACE.enabledIn(csvFeatures); _cfgEscapeQuoteCharWithEscapeChar = CsvWriteFeature.ESCAPE_QUOTE_CHAR_WITH_ESCAPE_CHAR.enabledIn(csvFeatures); _cfgEscapeControlCharWithEscapeChar = CsvWriteFeature.ESCAPE_CONTROL_CHARS_WITH_ESCAPE_CHAR.enabledIn(csvFeatures); @@ -259,6 +263,7 @@ public CsvEncoder(CsvEncoder base, CsvSchema newSchema) _cfgAlwaysQuoteStrings = base._cfgAlwaysQuoteStrings; _cfgAlwaysQuoteEmptyStrings = base._cfgAlwaysQuoteEmptyStrings; _cfgAlwaysQuoteNumbers = base._cfgAlwaysQuoteNumbers; + _cfgQuoteLeadingTrailingWhitespace = base._cfgQuoteLeadingTrailingWhitespace; _cfgEscapeQuoteCharWithEscapeChar = base._cfgEscapeQuoteCharWithEscapeChar; _cfgEscapeControlCharWithEscapeChar = base._cfgEscapeControlCharWithEscapeChar; @@ -302,8 +307,7 @@ private void _verifyConfiguration(CsvSchema schema) } } } - - + private final char _getQuoteCharEscapeChar( final boolean escapeQuoteCharWithEscapeChar, final int quoteCharacter, @@ -342,22 +346,6 @@ public CsvEncoder withSchema(CsvSchema schema) { return new CsvEncoder(this, schema); } - /* - public CsvEncoder overrideFormatFeatures(int feat) { - if (feat != _csvFeatures) { - _csvFeatures = feat; - _cfgOptimalQuoting = CsvGenerator.Feature.STRICT_CHECK_FOR_QUOTING.enabledIn(feat); - _cfgIncludeMissingTail = !CsvGenerator.Feature.OMIT_MISSING_TAIL_COLUMNS.enabledIn(feat); - _cfgAlwaysQuoteStrings = CsvGenerator.Feature.ALWAYS_QUOTE_STRINGS.enabledIn(feat); - _cfgAlwaysQuoteEmptyStrings = CsvGenerator.Feature.ALWAYS_QUOTE_EMPTY_STRINGS.enabledIn(feat); - _cfgAlwaysQuoteNumbers = CsvGenerator.Feature.ALWAYS_QUOTE_NUMBERS.enabledIn(feat); - _cfgEscapeQuoteCharWithEscapeChar = CsvGenerator.Feature.ESCAPE_QUOTE_CHAR_WITH_ESCAPE_CHAR.enabledIn(feat); - _cfgEscapeControlCharWithEscapeChar = Feature.ESCAPE_CONTROL_CHARS_WITH_ESCAPE_CHAR.enabledIn(feat); - } - return this; - } - */ - public CsvEncoder setOutputEscapes(int[] esc) { _outputEscapes = (esc != null) ? esc : sOutputEscapes; return this; @@ -1156,6 +1144,14 @@ protected boolean _mayNeedQuotes(String value, int length, int columnIndex) if (_cfgQuoteCharacter < 0) { return false; } + // [dataformats-text#210]: check for leading/trailing whitespace + if (_cfgQuoteLeadingTrailingWhitespace && length > 0) { + char first = value.charAt(0); + char last = value.charAt(length - 1); + if (first <= ' ' || last <= ' ') { + return true; + } + } // may skip checks unless we want exact checking if (_cfgOptimalQuoting) { // 31-Dec-2014, tatu: Comment lines start with # so quote if starts with # diff --git a/csv/src/test/java/tools/jackson/dataformat/csv/ser/QuoteLeadingTrailingWhitespace210Test.java b/csv/src/test/java/tools/jackson/dataformat/csv/ser/QuoteLeadingTrailingWhitespace210Test.java new file mode 100644 index 00000000..115222e5 --- /dev/null +++ b/csv/src/test/java/tools/jackson/dataformat/csv/ser/QuoteLeadingTrailingWhitespace210Test.java @@ -0,0 +1,84 @@ +package tools.jackson.dataformat.csv.ser; + +import org.junit.jupiter.api.Test; + +import tools.jackson.dataformat.csv.*; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +// [dataformats-text#210]: Quote strings with leading/trailing whitespace +public class QuoteLeadingTrailingWhitespace210Test extends ModuleTestBase +{ + private final CsvMapper MAPPER = mapperForCsv(); + + @Test + public void testLeadingSpaceQuoted() throws Exception + { + final CsvSchema schema = MAPPER.schemaFor(IdDesc.class) + .withLineSeparator("\n"); + String csv = MAPPER.writer(schema) + .with(CsvWriteFeature.QUOTE_STRINGS_WITH_LEADING_TRAILING_WHITESPACE) + .writeValueAsString(new IdDesc(" hello", "world")); + assertEquals("\" hello\",world\n", csv); + } + + @Test + public void testTrailingSpaceQuoted() throws Exception + { + final CsvSchema schema = MAPPER.schemaFor(IdDesc.class) + .withLineSeparator("\n"); + String csv = MAPPER.writer(schema) + .with(CsvWriteFeature.QUOTE_STRINGS_WITH_LEADING_TRAILING_WHITESPACE) + .writeValueAsString(new IdDesc("hello", "world ")); + assertEquals("hello,\"world \"\n", csv); + } + + @Test + public void testBothLeadingAndTrailingSpaceQuoted() throws Exception + { + final CsvSchema schema = MAPPER.schemaFor(IdDesc.class) + .withLineSeparator("\n"); + String csv = MAPPER.writer(schema) + .with(CsvWriteFeature.QUOTE_STRINGS_WITH_LEADING_TRAILING_WHITESPACE) + .writeValueAsString(new IdDesc(" both ", " ends ")); + assertEquals("\" both \",\" ends \"\n", csv); + } + + @Test + public void testTabCountsAsWhitespace() throws Exception + { + final CsvSchema schema = MAPPER.schemaFor(IdDesc.class) + .withLineSeparator("\n"); + String csv = MAPPER.writer(schema) + .with(CsvWriteFeature.QUOTE_STRINGS_WITH_LEADING_TRAILING_WHITESPACE) + .writeValueAsString(new IdDesc("\thello", "world")); + assertEquals("\"\thello\",world\n", csv); + } + + @Test + public void testNoQuotingWithoutFeature() throws Exception + { + final CsvSchema schema = MAPPER.schemaFor(IdDesc.class) + .withLineSeparator("\n"); + // With strict quoting (optimal), leading spaces should NOT trigger quoting + // unless the new feature is enabled + String csv = MAPPER.writer(schema) + .with(CsvWriteFeature.STRICT_CHECK_FOR_QUOTING) + .without(CsvWriteFeature.QUOTE_STRINGS_WITH_LEADING_TRAILING_WHITESPACE) + .writeValueAsString(new IdDesc(" hello", "world ")); + assertEquals(" hello,world \n", csv); + } + + @Test + public void testNoWhitespaceNoQuoting() throws Exception + { + final CsvSchema schema = MAPPER.schemaFor(IdDesc.class) + .withLineSeparator("\n"); + // Strings without leading/trailing whitespace should not be affected + String csv = MAPPER.writer(schema) + .with(CsvWriteFeature.QUOTE_STRINGS_WITH_LEADING_TRAILING_WHITESPACE) + .with(CsvWriteFeature.STRICT_CHECK_FOR_QUOTING) + .writeValueAsString(new IdDesc("hello", "world")); + assertEquals("hello,world\n", csv); + } +} diff --git a/release-notes/VERSION b/release-notes/VERSION index 3240d611..d6a77eae 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -30,6 +30,10 @@ implementations) #109: (yaml) `YAMLMapper` doesn't close output file if an error occurs (reported by Martin W) (fix by @cowtowncoder, w/ Claude code) +#210: (csv) Quote strings with leading/trailing spaces in csv + (`CsvWriteFeature.QUOTE_STRINGS_WITH_LEADING_TRAILING_WHITESPACE`) + (requested by @sfzhi) + (implemented by @cowtowncoder, w/ Claude code) #214: (yaml) Expose custom tags via `YAMLParser.getRawTag()` (requested by Alexander T) (fix by @cowtowncoder, w/ Claude code)