Added AllowFieldCountMismatch property to CsvConfiguration#985
Added AllowFieldCountMismatch property to CsvConfiguration#985michelebastione wants to merge 2 commits into
Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 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.
There was a problem hiding this comment.
I was aware of this when writing the PR, it was however an edge case already present before, so fixing it now would be a non trivial breaking change. I'm evaluating the best path forwards.
There was a problem hiding this comment.
@michelebastione, understood. Since the default-path behavior predates this PR and changing it could alter compatibility, I agree it should not block the opt-in AllowFieldCountMismatch work here.
Please keep the current scope focused on the enabled path. If you decide to defer the stricter default behavior, would you like me to open a follow-up GitHub issue to track the API/compatibility decision and regression coverage?
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.
87445aa to
d9f95b8
Compare
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