diff --git a/csv/src/main/java/tools/jackson/dataformat/csv/CsvParser.java b/csv/src/main/java/tools/jackson/dataformat/csv/CsvParser.java index b5911f69..d382d66e 100644 --- a/csv/src/main/java/tools/jackson/dataformat/csv/CsvParser.java +++ b/csv/src/main/java/tools/jackson/dataformat/csv/CsvParser.java @@ -645,6 +645,9 @@ protected void _readHeaderLine() throws JacksonException { final boolean failOnDupHeaders = CsvReadFeature.FAIL_ON_DUPLICATE_HEADER_COLUMNS.enabledIn(_formatFeatures); final Set seenNames = failOnDupHeaders ? new HashSet<>() : null; + // [dataformats-text#657]: Optionally match header names case-insensitively + final boolean caseInsensitive = CsvReadFeature.CASE_INSENSITIVE_HEADERS.enabledIn(_formatFeatures); + final boolean trimHeaderNames = CsvReadFeature.TRIM_HEADER_SPACES.enabledIn(_formatFeatures); while ((name = _reader.nextString()) != null) { // one more thing: always trim names, regardless of config settings @@ -660,9 +663,12 @@ protected void _readHeaderLine() throws JacksonException { "Duplicate header column \"%s\"", name)); } // See if "old" schema defined type; if so, use that type... - CsvSchema.Column prev = _schema.column(name); + // [dataformats-text#657]: optionally use case-insensitive lookup + CsvSchema.Column prev = caseInsensitive + ? _schema.columnIgnoreCase(name) + : _schema.column(name); if (prev != null) { - builder.addColumn(name, prev.getType()); + builder.addColumn(prev.getName(), prev.getType()); } else { builder.addColumn(name); } diff --git a/csv/src/main/java/tools/jackson/dataformat/csv/CsvReadFeature.java b/csv/src/main/java/tools/jackson/dataformat/csv/CsvReadFeature.java index b6842be4..0e77320d 100644 --- a/csv/src/main/java/tools/jackson/dataformat/csv/CsvReadFeature.java +++ b/csv/src/main/java/tools/jackson/dataformat/csv/CsvReadFeature.java @@ -212,6 +212,23 @@ public enum CsvReadFeature * @since 3.2 */ FAIL_ON_DUPLICATE_HEADER_COLUMNS(true), + + /** + * Feature that enables case-insensitive matching of header column names + * against schema column names. When enabled, a CSV header column named + * "TEMP_MAX" will match a schema column named "temp_max" (and vice versa). + *

+ * This is useful when used together with + * {@code MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES} to allow + * case-insensitive header matching at the parser level, preventing + * {@link #FAIL_ON_MISSING_HEADER_COLUMNS} from incorrectly reporting + * columns as missing when they differ only by case. + *

+ * Feature is disabled by default. + * + * @since 3.2 + */ + CASE_INSENSITIVE_HEADERS(false), ; private final boolean _defaultState; diff --git a/csv/src/main/java/tools/jackson/dataformat/csv/CsvSchema.java b/csv/src/main/java/tools/jackson/dataformat/csv/CsvSchema.java index 983e900a..f18a4f5c 100644 --- a/csv/src/main/java/tools/jackson/dataformat/csv/CsvSchema.java +++ b/csv/src/main/java/tools/jackson/dataformat/csv/CsvSchema.java @@ -1528,6 +1528,29 @@ public Column column(String name) { return _columnsByName.get(name); } + /** + * Case-insensitive variant of {@link #column(String)}: looks up a column + * by name, ignoring case differences. + * + * @param name Column name to look up (case-insensitive) + * @return Column with matching name, or {@code null} if not found + * + * @since 3.2 + */ + public Column columnIgnoreCase(String name) { + // Try exact match first for efficiency + Column col = _columnsByName.get(name); + if (col != null) { + return col; + } + for (Column c : _columns) { + if (c.getName().equalsIgnoreCase(name)) { + return c; + } + } + return null; + } + /** * Optimized variant where a hint is given as to likely index of the column * name. diff --git a/csv/src/test/java/tools/jackson/dataformat/csv/deser/CaseInsensitiveHeader657Test.java b/csv/src/test/java/tools/jackson/dataformat/csv/deser/CaseInsensitiveHeader657Test.java new file mode 100644 index 00000000..750e159c --- /dev/null +++ b/csv/src/test/java/tools/jackson/dataformat/csv/deser/CaseInsensitiveHeader657Test.java @@ -0,0 +1,124 @@ +package tools.jackson.dataformat.csv.deser; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.MappingIterator; + +import tools.jackson.dataformat.csv.*; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for [dataformats-text#657]: CSV header validation should support + * case-insensitive matching via {@link CsvReadFeature#CASE_INSENSITIVE_HEADERS}. + */ +public class CaseInsensitiveHeader657Test extends ModuleTestBase +{ + private final CsvMapper MAPPER = mapperForCsv(); + + // [dataformats-text#657]: case-insensitive header should match schema columns + @Test + public void testCaseInsensitiveHeaderMatch() throws Exception + { + CsvSchema schema = CsvSchema.builder() + .setUseHeader(true) + .setReorderColumns(true) + .addColumn("name") + .addColumn("temp_max") + .build(); + + // Header has upper-case "TEMP_MAX" but schema defines "temp_max" + String CSV = "name,TEMP_MAX\nRoger,42\n"; + + MappingIterator> it = MAPPER + .readerFor(Map.class) + .with(schema) + .with(CsvReadFeature.CASE_INSENSITIVE_HEADERS) + .readValues(CSV); + assertTrue(it.hasNext()); + Map result = it.nextValue(); + assertEquals("Roger", result.get("name")); + assertEquals("42", result.get("temp_max")); + } + + // [dataformats-text#657]: without the feature, case mismatch should still fail + @Test + public void testCaseSensitiveHeaderStillFails() throws Exception + { + CsvSchema schema = CsvSchema.builder() + .setUseHeader(true) + .setReorderColumns(true) + .addColumn("name") + .addColumn("temp_max") + .build(); + + String CSV = "name,TEMP_MAX\nRoger,42\n"; + + try { + MappingIterator> it = MAPPER + .readerFor(Map.class) + .with(schema) + .readValues(CSV); + it.nextValue(); + fail("Should fail with missing column when case-insensitive is disabled"); + } catch (CsvReadException e) { + verifyException(e, "Missing 1 header column: [\"temp_max\"]"); + } + } + + // [dataformats-text#657]: case-insensitive with reordered columns + @Test + public void testCaseInsensitiveReorderedColumns() throws Exception + { + CsvSchema schema = CsvSchema.builder() + .setUseHeader(true) + .setReorderColumns(true) + .addColumn("firstName") + .addColumn("lastName") + .addColumn("age") + .build(); + + // All upper-case and reordered + String CSV = "AGE,FIRSTNAME,LASTNAME\n25,John,Doe\n"; + + MappingIterator> it = MAPPER + .readerFor(Map.class) + .with(schema) + .with(CsvReadFeature.CASE_INSENSITIVE_HEADERS) + .readValues(CSV); + assertTrue(it.hasNext()); + Map result = it.nextValue(); + assertEquals("John", result.get("firstName")); + assertEquals("Doe", result.get("lastName")); + assertEquals("25", result.get("age")); + } + + // [dataformats-text#657]: case-insensitive should still detect truly missing columns + @Test + public void testCaseInsensitiveStillDetectsMissing() throws Exception + { + CsvSchema schema = CsvSchema.builder() + .setUseHeader(true) + .setReorderColumns(true) + .addColumn("name") + .addColumn("age") + .build(); + + // "agee" is a typo, not a case difference + String CSV = "name,agee\nRoger,18\n"; + + try { + MappingIterator> it = MAPPER + .readerFor(Map.class) + .with(schema) + .with(CsvReadFeature.CASE_INSENSITIVE_HEADERS) + .readValues(CSV); + it.nextValue(); + fail("Should fail: 'agee' is not a case-insensitive match for 'age'"); + } catch (CsvReadException e) { + verifyException(e, "Missing 1 header column: [\"age\"]"); + } + } +} diff --git a/release-notes/CREDITS b/release-notes/CREDITS index c93c081b..21dec6a9 100644 --- a/release-notes/CREDITS +++ b/release-notes/CREDITS @@ -71,3 +71,10 @@ Michael Carman (@mjcarman) * Contributed #623: Add support for YAML 1.2 non-finite (Infinite, -Infinite, NaN) numbers, Octal notation (3.2.0) + +Tomas Hugec (@polyoxidonium) + * Reported #657: (csv) CSV header validation ignores `ACCEPT_CASE_INSENSITIVE_PROPERTIES`: + add `CsvReadFeature.CASE_INSENSITIVE_HEADERS` + (3.2.0) + + \ No newline at end of file diff --git a/release-notes/VERSION b/release-notes/VERSION index d6a77eae..e60f496d 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -71,9 +71,13 @@ implementations) #638: (properties) Support for `StreamReadConstraints.maxNameLength` and `maxStringLength` for Properties parsing (fix by @cowtowncoder, w/ Claude code) -#643: `CsvParser` doesn't handle whitespace outside of quoted values correctly +#643: (csv) `CsvParser` doesn't handle whitespace outside of quoted values correctly (reported by @tomdz) (fix by @cowtowncoder, w/ Claude code) +#657: (csv) CSV header validation ignores `ACCEPT_CASE_INSENSITIVE_PROPERTIES`: + add `CsvReadFeature.CASE_INSENSITIVE_HEADERS` + (reported by Tomas H) + (fix by @cowtowncoder, w/ Claude code) 3.1.1 (27-Mar-2026)