Skip to content

Added AllowFieldCountMismatch property to CsvConfiguration#985

Draft
michelebastione wants to merge 1 commit into
mini-software:masterfrom
michelebastione:csv-jagged-rows
Draft

Added AllowFieldCountMismatch property to CsvConfiguration#985
michelebastione wants to merge 1 commit into
mini-software:masterfrom
michelebastione:csv-jagged-rows

Conversation

@michelebastione

@michelebastione michelebastione commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

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

    • Added an option to interpret empty CSV fields as default values.
    • Added support for reading rows with varying numbers of fields when field-count mismatches are allowed.
    • Improved handling of missing and additional columns during CSV import.
  • Bug Fixes

    • Corrected reported column names for missing-column errors.
  • Deprecation

    • Replaced the previous empty-string handling option with the new configuration setting.

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.
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

CsvReader.QueryAsync now handles empty fields and variable-width rows through the new ReadEmptyFieldsAsDefault option and existing mismatch configuration. Tests cover headered, headerless, and mapped jagged CSV rows.

Changes

CSV jagged-row handling

Layer / File(s) Summary
Empty-field configuration contract
src/MiniExcel.Csv/CsvConfiguration.cs
Adds ReadEmptyFieldsAsDefault and makes obsolete ReadEmptyStringAsNull delegate to it.
Row parsing and result mapping
src/MiniExcel.Csv/CsvReader.cs
Refactors row parsing, mismatch validation, synthetic columns, filler fields, and empty-field conversion for headered and headerless CSV data.
Jagged-row and exception coverage
tests/MiniExcel.Csv.Tests/Main/MiniExcelCsvAsyncTests.cs, tests/MiniExcel.Csv.Tests/Main/MiniExcelCsvTests.cs, tests/MiniExcel.Csv.Tests/Main/Models.cs
Adds jagged-row tests and DTO fields, and updates column-name and dictionary assertions.

Estimated code review effort: 3 (Moderate) | ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly names the main change: adding CsvConfiguration support for mismatched CSV field counts.
Linked Issues check ✅ Passed The changes add AllowFieldCountMismatch, update CSV parsing to tolerate jagged rows, and add tests matching issue #979.
Out of Scope Changes check ✅ Passed The extra CSV empty-field and test/model updates appear to support the same jagged-row parsing feature and are not unrelated.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 3266e8b and 87445aa.

📒 Files selected for processing (5)
  • src/MiniExcel.Csv/CsvConfiguration.cs
  • src/MiniExcel.Csv/CsvReader.cs
  • tests/MiniExcel.Csv.Tests/Main/MiniExcelCsvAsyncTests.cs
  • tests/MiniExcel.Csv.Tests/Main/MiniExcelCsvTests.cs
  • tests/MiniExcel.Csv.Tests/Main/Models.cs

Comment on lines +67 to +133
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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 hasHeaderRow mode, headerRow.Add(...) is skipped (line 96-97 guarded by the config flag), but the loop still does resultRow[headerRow[i]] for i >= headerRow.Count — this throws an unhandled KeyNotFoundException instead of the documented ColumnNotFoundException.
  • In headerless mode, the same case never crashes (value assignment uses CellReferenceConverter.GetAlphabeticalIndex(i), not a headerRow lookup), but it also silently accepts the extra fields — so AllowFieldCountMismatch = false doesn'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.

Suggested change
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.

@michelebastione
michelebastione marked this pull request as draft July 19, 2026 16:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] - Csv read error: Column 32 not found in Row 2

1 participant