Added AllowFieldCountMismatch property to CsvConfiguration#985
Added AllowFieldCountMismatch property to CsvConfiguration#985michelebastione wants to merge 1 commit into
Conversation
The implementation of this property makes jagged rows being returned as they are instead of having an exception being thrown when a mismatched number of fields is found.
📝 WalkthroughWalkthrough
ChangesCSV jagged-row handling
Estimated code review effort: 3 (Moderate) | ~20 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/MiniExcel.Csv/CsvReader.cs`:
- Around line 67-133: Update the invalid-row check before the header and
headerless branches to reject any field-count mismatch when
AllowFieldCountMismatch is false, not only rows where fields.Length is less than
headerRow.Count. Preserve the existing ColumnNotFoundException construction and
ensure both hasHeaderRow paths exit through this check before assigning fields.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f567c7b8-2687-4a1f-bf0f-0c366b9d374b
📒 Files selected for processing (5)
src/MiniExcel.Csv/CsvConfiguration.cssrc/MiniExcel.Csv/CsvReader.cstests/MiniExcel.Csv.Tests/Main/MiniExcelCsvAsyncTests.cstests/MiniExcel.Csv.Tests/Main/MiniExcelCsvTests.cstests/MiniExcel.Csv.Tests/Main/Models.cs
| var fields = Split(finalRow); | ||
|
|
||
| // invalid row check | ||
| if (read.Length < headRows.Count) | ||
| if (fields.Length < headerRow.Count && !_config.AllowFieldCountMismatch) | ||
| { | ||
| var colIndex = read.Length; | ||
| var headers = headRows.ToDictionary(x => x.Value, x => x.Key); | ||
| var rowValues = read | ||
| .Select((x, i) => new KeyValuePair<string, object>(headRows[i], x)) | ||
| var colIndex = fields.Length; | ||
| var headers = headerRow.ToDictionary(x => x.Value, x => x.Key); | ||
| var rowValues = fields | ||
| .Select((x, i) => new KeyValuePair<string, object>(headerRow[i], x)) | ||
| .ToDictionary(x => x.Key, x => x.Value); | ||
|
|
||
| throw new ColumnNotFoundException(columnIndex: null, headRows[colIndex], [], rowIndex, headers, rowValues, $"Csv read error: Column {colIndex} not found in Row {rowIndex}"); | ||
| throw new ColumnNotFoundException(columnIndex: null, headerRow[colIndex], [], rowIndex, headers, rowValues, $"Csv read error: Column {colIndex} not found in Row {rowIndex}"); | ||
| } | ||
|
|
||
| //header | ||
| // with header | ||
| if (hasHeaderRow) | ||
| { | ||
| if (firstRow) | ||
| { | ||
| firstRow = false; | ||
| for (int i = 0; i <= read.Length - 1; i++) | ||
| headRows.Add(i, read[i]); | ||
| for (int i = 0; i < fields.Length; i++) | ||
| headerRow.Add(i, fields[i]); | ||
|
|
||
| continue; | ||
| } | ||
|
|
||
| var headCell = ExpandoHelper.CreateEmptyByHeaders(headRows); | ||
| for (int i = 0; i <= read.Length - 1; i++) | ||
| headCell[headRows[i]] = read[i]; | ||
| var resultRow = ExpandoHelper.CreateEmptyByHeaders(headerRow); | ||
| for (int i = 0; i < fields.Length; i++) | ||
| { | ||
| if (i >= headerRow.Count && _config.AllowFieldCountMismatch) | ||
| headerRow.Add(i, $"Col{i + 1}"); | ||
|
|
||
| yield return headCell; | ||
| continue; | ||
| } | ||
| var isFillerField = i >= headerRow.Count && _config.AllowFieldCountMismatch; | ||
| var field = isFillerField ? "" : fields[i]; | ||
|
|
||
| //body | ||
| if (firstRow) // record first row as reference | ||
| { | ||
| firstRow = false; | ||
| for (int i = 0; i <= read.Length - 1; i++) | ||
| headRows.Add(i, $"c{i + 1}"); | ||
| } | ||
| var treatEmptyFieldAsNull = _config.ReadEmptyFieldsAsDefault && field is ""; | ||
| resultRow[headerRow[i]] = treatEmptyFieldAsNull ? null : field; | ||
| } | ||
|
|
||
| // todo: can we find a way to remove the redundant cell conversions for CSV? | ||
| var cell = ExpandoHelper.CreateEmptyByIndices(read.Length - 1, 0); | ||
| if (_config.ReadEmptyStringAsNull) | ||
| { | ||
| for (int i = 0; i <= read.Length - 1; i++) | ||
| cell[CellReferenceConverter.GetAlphabeticalIndex(i)] = read[i] is var value and not "" ? value : null; | ||
| yield return resultRow; | ||
| } | ||
| else | ||
| { | ||
| for (int i = 0; i <= read.Length - 1; i++) | ||
| cell[CellReferenceConverter.GetAlphabeticalIndex(i)] = read[i]; | ||
| } | ||
| if (firstRow) // use first row as reference | ||
| { | ||
| firstRow = false; | ||
| for (int i = 0; i < fields.Length; i++) | ||
| headerRow.Add(i, $"Col{i + 1}"); | ||
| } | ||
|
|
||
| // todo: can we find a way to remove the redundant cell conversions for CSV? | ||
| var resultRow = ExpandoHelper.CreateEmptyByIndices(fields.Length - 1, 0); | ||
| for (int i = 0; i < fields.Length; i++) | ||
| { | ||
| if (i >= headerRow.Count && _config.AllowFieldCountMismatch) | ||
| headerRow.Add(i, $"Col{i + 1}"); | ||
|
|
||
| yield return cell; | ||
| var isFillerField = i >= headerRow.Count && _config.AllowFieldCountMismatch; | ||
| var field = isFillerField ? "" : fields[i]; | ||
|
|
||
| var index = CellReferenceConverter.GetAlphabeticalIndex(i); | ||
| var treatEmptyFieldAsNull = _config.ReadEmptyFieldsAsDefault && field is ""; | ||
| resultRow[index] = treatEmptyFieldAsNull ? null : field; | ||
| } | ||
|
|
||
| yield return resultRow; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Mismatch check only guards short rows — long rows crash (header mode) or silently bypass the flag (headerless mode).
The invalid-row check (fields.Length < headerRow.Count) only fires when a row has fewer fields than the header. When a row has more fields than the header and AllowFieldCountMismatch is false (the default):
- In
hasHeaderRowmode,headerRow.Add(...)is skipped (line 96-97 guarded by the config flag), but the loop still doesresultRow[headerRow[i]]fori >= headerRow.Count— this throws an unhandledKeyNotFoundExceptioninstead of the documentedColumnNotFoundException. - In headerless mode, the same case never crashes (value assignment uses
CellReferenceConverter.GetAlphabeticalIndex(i), not aheaderRowlookup), but it also silently accepts the extra fields — soAllowFieldCountMismatch = falsedoesn't enforce anything for over-long rows there either.
Neither branch is covered by the new jagged-row tests (which only exercise AllowFieldCountMismatch = true), so this regresses on any ordinary CSV row that happens to have one extra field when the flag is left at its default.
🐛 Proposed fix: make the mismatch check symmetric
// invalid row check
- if (fields.Length < headerRow.Count && !_config.AllowFieldCountMismatch)
+ if (headerRow.Count > 0 && fields.Length != headerRow.Count && !_config.AllowFieldCountMismatch)
{
- var colIndex = fields.Length;
+ var colIndex = Math.Min(fields.Length, headerRow.Count);
var headers = headerRow.ToDictionary(x => x.Value, x => x.Key);
var rowValues = fields
- .Select((x, i) => new KeyValuePair<string, object>(headerRow[i], x))
+ .Select((x, i) => new KeyValuePair<string, object>(headerRow.TryGetValue(i, out var h) ? h : $"Col{i + 1}", x))
.ToDictionary(x => x.Key, x => x.Value);
+ var missingColumnName = headerRow.TryGetValue(colIndex, out var name) ? name : $"Col{colIndex + 1}";
- throw new ColumnNotFoundException(columnIndex: null, headerRow[colIndex], [], rowIndex, headers, rowValues, $"Csv read error: Column {colIndex} not found in Row {rowIndex}");
+ throw new ColumnNotFoundException(columnIndex: null, missingColumnName, [], rowIndex, headers, rowValues, $"Csv read error: Column {colIndex} not found in Row {rowIndex}");
}Fixing this single check resolves both branches, since neither the header-mode assignment loop nor the headerless loop is reached once mismatched rows are rejected up front when the flag is off.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| var fields = Split(finalRow); | |
| // invalid row check | |
| if (read.Length < headRows.Count) | |
| if (fields.Length < headerRow.Count && !_config.AllowFieldCountMismatch) | |
| { | |
| var colIndex = read.Length; | |
| var headers = headRows.ToDictionary(x => x.Value, x => x.Key); | |
| var rowValues = read | |
| .Select((x, i) => new KeyValuePair<string, object>(headRows[i], x)) | |
| var colIndex = fields.Length; | |
| var headers = headerRow.ToDictionary(x => x.Value, x => x.Key); | |
| var rowValues = fields | |
| .Select((x, i) => new KeyValuePair<string, object>(headerRow[i], x)) | |
| .ToDictionary(x => x.Key, x => x.Value); | |
| throw new ColumnNotFoundException(columnIndex: null, headRows[colIndex], [], rowIndex, headers, rowValues, $"Csv read error: Column {colIndex} not found in Row {rowIndex}"); | |
| throw new ColumnNotFoundException(columnIndex: null, headerRow[colIndex], [], rowIndex, headers, rowValues, $"Csv read error: Column {colIndex} not found in Row {rowIndex}"); | |
| } | |
| //header | |
| // with header | |
| if (hasHeaderRow) | |
| { | |
| if (firstRow) | |
| { | |
| firstRow = false; | |
| for (int i = 0; i <= read.Length - 1; i++) | |
| headRows.Add(i, read[i]); | |
| for (int i = 0; i < fields.Length; i++) | |
| headerRow.Add(i, fields[i]); | |
| continue; | |
| } | |
| var headCell = ExpandoHelper.CreateEmptyByHeaders(headRows); | |
| for (int i = 0; i <= read.Length - 1; i++) | |
| headCell[headRows[i]] = read[i]; | |
| var resultRow = ExpandoHelper.CreateEmptyByHeaders(headerRow); | |
| for (int i = 0; i < fields.Length; i++) | |
| { | |
| if (i >= headerRow.Count && _config.AllowFieldCountMismatch) | |
| headerRow.Add(i, $"Col{i + 1}"); | |
| yield return headCell; | |
| continue; | |
| } | |
| var isFillerField = i >= headerRow.Count && _config.AllowFieldCountMismatch; | |
| var field = isFillerField ? "" : fields[i]; | |
| //body | |
| if (firstRow) // record first row as reference | |
| { | |
| firstRow = false; | |
| for (int i = 0; i <= read.Length - 1; i++) | |
| headRows.Add(i, $"c{i + 1}"); | |
| } | |
| var treatEmptyFieldAsNull = _config.ReadEmptyFieldsAsDefault && field is ""; | |
| resultRow[headerRow[i]] = treatEmptyFieldAsNull ? null : field; | |
| } | |
| // todo: can we find a way to remove the redundant cell conversions for CSV? | |
| var cell = ExpandoHelper.CreateEmptyByIndices(read.Length - 1, 0); | |
| if (_config.ReadEmptyStringAsNull) | |
| { | |
| for (int i = 0; i <= read.Length - 1; i++) | |
| cell[CellReferenceConverter.GetAlphabeticalIndex(i)] = read[i] is var value and not "" ? value : null; | |
| yield return resultRow; | |
| } | |
| else | |
| { | |
| for (int i = 0; i <= read.Length - 1; i++) | |
| cell[CellReferenceConverter.GetAlphabeticalIndex(i)] = read[i]; | |
| } | |
| if (firstRow) // use first row as reference | |
| { | |
| firstRow = false; | |
| for (int i = 0; i < fields.Length; i++) | |
| headerRow.Add(i, $"Col{i + 1}"); | |
| } | |
| // todo: can we find a way to remove the redundant cell conversions for CSV? | |
| var resultRow = ExpandoHelper.CreateEmptyByIndices(fields.Length - 1, 0); | |
| for (int i = 0; i < fields.Length; i++) | |
| { | |
| if (i >= headerRow.Count && _config.AllowFieldCountMismatch) | |
| headerRow.Add(i, $"Col{i + 1}"); | |
| yield return cell; | |
| var isFillerField = i >= headerRow.Count && _config.AllowFieldCountMismatch; | |
| var field = isFillerField ? "" : fields[i]; | |
| var index = CellReferenceConverter.GetAlphabeticalIndex(i); | |
| var treatEmptyFieldAsNull = _config.ReadEmptyFieldsAsDefault && field is ""; | |
| resultRow[index] = treatEmptyFieldAsNull ? null : field; | |
| } | |
| yield return resultRow; | |
| } | |
| var fields = Split(finalRow); | |
| // invalid row check | |
| if (headerRow.Count > 0 && fields.Length != headerRow.Count && !_config.AllowFieldCountMismatch) | |
| { | |
| var colIndex = Math.Min(fields.Length, headerRow.Count); | |
| var headers = headerRow.ToDictionary(x => x.Value, x => x.Key); | |
| var rowValues = fields | |
| .Select((x, i) => new KeyValuePair<string, object>(headerRow.TryGetValue(i, out var h) ? h : $"Col{i + 1}", x)) | |
| .ToDictionary(x => x.Key, x => x.Value); | |
| var missingColumnName = headerRow.TryGetValue(colIndex, out var name) ? name : $"Col{colIndex + 1}"; | |
| throw new ColumnNotFoundException(columnIndex: null, missingColumnName, [], rowIndex, headers, rowValues, $"Csv read error: Column {colIndex} not found in Row {rowIndex}"); | |
| } | |
| // with header | |
| if (hasHeaderRow) | |
| { | |
| if (firstRow) | |
| { | |
| firstRow = false; | |
| for (int i = 0; i < fields.Length; i++) | |
| headerRow.Add(i, fields[i]); | |
| continue; | |
| } | |
| var resultRow = ExpandoHelper.CreateEmptyByHeaders(headerRow); | |
| for (int i = 0; i < fields.Length; i++) | |
| { | |
| if (i >= headerRow.Count && _config.AllowFieldCountMismatch) | |
| headerRow.Add(i, $"Col{i + 1}"); | |
| var isFillerField = i >= headerRow.Count && _config.AllowFieldCountMismatch; | |
| var field = isFillerField ? "" : fields[i]; | |
| var treatEmptyFieldAsNull = _config.ReadEmptyFieldsAsDefault && field is ""; | |
| resultRow[headerRow[i]] = treatEmptyFieldAsNull ? null : field; | |
| } | |
| yield return resultRow; | |
| } | |
| else | |
| { | |
| if (firstRow) // use first row as reference | |
| { | |
| firstRow = false; | |
| for (int i = 0; i < fields.Length; i++) | |
| headerRow.Add(i, $"Col{i + 1}"); | |
| } | |
| // todo: can we find a way to remove the redundant cell conversions for CSV? | |
| var resultRow = ExpandoHelper.CreateEmptyByIndices(fields.Length - 1, 0); | |
| for (int i = 0; i < fields.Length; i++) | |
| { | |
| if (i >= headerRow.Count && _config.AllowFieldCountMismatch) | |
| headerRow.Add(i, $"Col{i + 1}"); | |
| var isFillerField = i >= headerRow.Count && _config.AllowFieldCountMismatch; | |
| var field = isFillerField ? "" : fields[i]; | |
| var index = CellReferenceConverter.GetAlphabeticalIndex(i); | |
| var treatEmptyFieldAsNull = _config.ReadEmptyFieldsAsDefault && field is ""; | |
| resultRow[index] = treatEmptyFieldAsNull ? null : field; | |
| } | |
| yield return resultRow; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/MiniExcel.Csv/CsvReader.cs` around lines 67 - 133, Update the invalid-row
check before the header and headerless branches to reject any field-count
mismatch when AllowFieldCountMismatch is false, not only rows where
fields.Length is less than headerRow.Count. Preserve the existing
ColumnNotFoundException construction and ensure both hasHeaderRow paths exit
through this check before assigning fields.
The implementation of this property makes jagged rows being returned as they are instead of having an exception being thrown when a mismatched number of fields is found.
Resolves #979
Summary by CodeRabbit
New Features
Bug Fixes
Deprecation