Skip to content

Commit 90d22ea

Browse files
Add default header name generation for empty CSV headers (#40)
Empty or whitespace-only CSV headers are now automatically assigned default names (e.g., Column0, Column1) instead of causing errors, matching LumenWorks CsvReader behavior and fixing SQL bulk insert issues. Introduced CsvReaderOptions.DefaultHeaderName to allow customization of the generated header prefix. Added comprehensive tests for empty, whitespace, and custom header scenarios. Updated version numbers and changelog.
1 parent 1e3274c commit 90d22ea

File tree

7 files changed

+185
-11
lines changed

7 files changed

+185
-11
lines changed

dbatools.library.psd1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
#
88
@{
99
# Version number of this module.
10-
ModuleVersion = '2025.12.26'
10+
ModuleVersion = '2025.12.28'
1111

1212
# ID used to uniquely identify this module
1313
GUID = '00b61a37-6c36-40d8-8865-ac0180288c84'

project/Dataplat.Dbatools.Csv/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **Empty header name generation** - Empty or whitespace-only CSV headers are now automatically assigned default names (e.g., `Column0`, `Column1`) instead of throwing errors. This matches LumenWorks CsvReader behavior and fixes SQL bulk insert failures when CSV files have missing header names.
12+
- **`DefaultHeaderName` option** - New `CsvReaderOptions.DefaultHeaderName` property allows customizing the prefix for generated header names (default is `"Column"`).
13+
1014
## [1.1.10] - 2025-12-26
1115

1216
### Added

project/Dataplat.Dbatools.Csv/Dataplat.Dbatools.Csv.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
<!-- NuGet Package Metadata -->
99
<PackageId>Dataplat.Dbatools.Csv</PackageId>
10-
<Version>1.1.10</Version>
10+
<Version>1.1.15</Version>
1111
<Authors>Chrissy LeMaire</Authors>
1212
<Company>Dataplat</Company>
1313
<Product>Dataplat.Dbatools.Csv</Product>

project/dbatools.Tests/Csv/CsvDataReaderTest.cs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,143 @@ public void TestDuplicateHeaders_UseFirstOccurrence()
682682

683683
#endregion
684684

685+
#region Empty Header Tests
686+
687+
[TestMethod]
688+
public void TestEmptyHeader_GeneratesDefaultName()
689+
{
690+
// LumenWorks compatibility: empty headers become Column#
691+
// Reproduces the issue from dbatools where Import-DbaCsv failed
692+
// with empty headers causing SQL errors
693+
string csv = ",ValidHeader\nValue1,Value2";
694+
695+
using (var reader = CreateReaderFromString(csv))
696+
{
697+
Assert.AreEqual(2, reader.FieldCount);
698+
Assert.AreEqual("Column0", reader.GetName(0)); // Empty header -> Column0
699+
Assert.AreEqual("ValidHeader", reader.GetName(1));
700+
701+
Assert.IsTrue(reader.Read());
702+
Assert.AreEqual("Value1", reader.GetString(0));
703+
Assert.AreEqual("Value2", reader.GetString(1));
704+
}
705+
}
706+
707+
[TestMethod]
708+
public void TestMultipleEmptyHeaders_GeneratesUniqueNames()
709+
{
710+
// Multiple empty headers should each get a unique name based on their index
711+
string csv = ",,ValidHeader,\nA,B,C,D";
712+
713+
using (var reader = CreateReaderFromString(csv))
714+
{
715+
Assert.AreEqual(4, reader.FieldCount);
716+
Assert.AreEqual("Column0", reader.GetName(0));
717+
Assert.AreEqual("Column1", reader.GetName(1));
718+
Assert.AreEqual("ValidHeader", reader.GetName(2));
719+
Assert.AreEqual("Column3", reader.GetName(3));
720+
721+
Assert.IsTrue(reader.Read());
722+
Assert.AreEqual("A", reader.GetString(0));
723+
Assert.AreEqual("B", reader.GetString(1));
724+
Assert.AreEqual("C", reader.GetString(2));
725+
Assert.AreEqual("D", reader.GetString(3));
726+
}
727+
}
728+
729+
[TestMethod]
730+
public void TestWhitespaceOnlyHeader_GeneratesDefaultName()
731+
{
732+
// Whitespace-only headers should also be treated as empty
733+
string csv = " ,ValidHeader\nValue1,Value2";
734+
735+
using (var reader = CreateReaderFromString(csv))
736+
{
737+
Assert.AreEqual(2, reader.FieldCount);
738+
Assert.AreEqual("Column0", reader.GetName(0)); // Whitespace -> Column0
739+
Assert.AreEqual("ValidHeader", reader.GetName(1));
740+
}
741+
}
742+
743+
[TestMethod]
744+
public void TestCustomDefaultHeaderName()
745+
{
746+
// Users can customize the default header name prefix
747+
string csv = ",ValidHeader\nValue1,Value2";
748+
var options = new CsvReaderOptions { DefaultHeaderName = "Field" };
749+
750+
using (var reader = CreateReaderFromString(csv, options))
751+
{
752+
Assert.AreEqual(2, reader.FieldCount);
753+
Assert.AreEqual("Field0", reader.GetName(0)); // Custom prefix
754+
Assert.AreEqual("ValidHeader", reader.GetName(1));
755+
}
756+
}
757+
758+
[TestMethod]
759+
public void TestEmptyHeaderWithTrimming()
760+
{
761+
// When trimming is enabled, whitespace headers should still become Column#
762+
string csv = " , ValidHeader \nValue1,Value2";
763+
var options = new CsvReaderOptions { TrimmingOptions = ValueTrimmingOptions.All };
764+
765+
using (var reader = CreateReaderFromString(csv, options))
766+
{
767+
Assert.AreEqual(2, reader.FieldCount);
768+
Assert.AreEqual("Column0", reader.GetName(0)); // Trimmed empty -> Column0
769+
Assert.AreEqual("ValidHeader", reader.GetName(1)); // Trimmed
770+
}
771+
}
772+
773+
[TestMethod]
774+
public void TestEmptyHeaderInMiddle()
775+
{
776+
// Empty header in the middle of other headers
777+
string csv = "First,,Last\nA,B,C";
778+
779+
using (var reader = CreateReaderFromString(csv))
780+
{
781+
Assert.AreEqual(3, reader.FieldCount);
782+
Assert.AreEqual("First", reader.GetName(0));
783+
Assert.AreEqual("Column1", reader.GetName(1)); // Middle empty -> Column1
784+
Assert.AreEqual("Last", reader.GetName(2));
785+
786+
Assert.IsTrue(reader.Read());
787+
Assert.AreEqual("A", reader.GetString(0));
788+
Assert.AreEqual("B", reader.GetString(1));
789+
Assert.AreEqual("C", reader.GetString(2));
790+
}
791+
}
792+
793+
[TestMethod]
794+
public void TestDefaultHeaderNameValidation_RejectsNull()
795+
{
796+
Assert.ThrowsException<ArgumentNullException>(() =>
797+
{
798+
new CsvReaderOptions { DefaultHeaderName = null };
799+
});
800+
}
801+
802+
[TestMethod]
803+
public void TestDefaultHeaderNameValidation_RejectsEmpty()
804+
{
805+
Assert.ThrowsException<ArgumentException>(() =>
806+
{
807+
new CsvReaderOptions { DefaultHeaderName = "" };
808+
});
809+
}
810+
811+
[TestMethod]
812+
public void TestDefaultHeaderNameValidation_RejectsWhitespace()
813+
{
814+
Assert.ThrowsException<ArgumentException>(() =>
815+
{
816+
new CsvReaderOptions { DefaultHeaderName = " " };
817+
});
818+
}
819+
820+
#endregion
821+
685822
#region Culture Support Tests
686823

687824
[TestMethod]

project/dbatools/Csv/Reader/CsvDataReader.cs

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -611,15 +611,15 @@ private void ProcessHeaders()
611611
var lastOccurrence = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
612612
for (int i = 0; i < _fieldsBuffer.Count; i++)
613613
{
614-
string name = GetTrimmedHeaderName(_fieldsBuffer[i].Value);
614+
string name = GetTrimmedHeaderName(_fieldsBuffer[i].Value, i);
615615
lastOccurrence[name] = i;
616616
}
617617

618618
// Mark non-last occurrences for renaming
619619
var tempCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
620620
for (int i = 0; i < _fieldsBuffer.Count; i++)
621621
{
622-
string name = GetTrimmedHeaderName(_fieldsBuffer[i].Value);
622+
string name = GetTrimmedHeaderName(_fieldsBuffer[i].Value, i);
623623
if (lastOccurrence[name] != i)
624624
{
625625
// This is not the last occurrence, will be renamed
@@ -633,7 +633,7 @@ private void ProcessHeaders()
633633
// Second pass: create columns
634634
for (int i = 0; i < _fieldsBuffer.Count; i++)
635635
{
636-
string name = GetTrimmedHeaderName(_fieldsBuffer[i].Value);
636+
string name = GetTrimmedHeaderName(_fieldsBuffer[i].Value, i);
637637

638638
// Check include/exclude filters first
639639
if (!ShouldIncludeColumn(name))
@@ -657,13 +657,22 @@ private void ProcessHeaders()
657657
}
658658
}
659659

660-
private string GetTrimmedHeaderName(string name)
660+
private string GetTrimmedHeaderName(string name, int fieldIndex)
661661
{
662-
if (_options.TrimmingOptions != ValueTrimmingOptions.None && name != null)
662+
string result = name;
663+
664+
if (_options.TrimmingOptions != ValueTrimmingOptions.None && result != null)
663665
{
664-
return name.Trim();
666+
result = result.Trim();
665667
}
666-
return name ?? string.Empty;
668+
669+
// Generate default header name for empty or whitespace-only headers (LumenWorks compatibility)
670+
if (string.IsNullOrEmpty(result) || (result != null && result.Trim().Length == 0))
671+
{
672+
result = _options.DefaultHeaderName + fieldIndex;
673+
}
674+
675+
return result ?? string.Empty;
667676
}
668677

669678
private string HandleDuplicateHeader(string name, int fieldIndex)

project/dbatools/Csv/Reader/CsvReaderOptions.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,29 @@ public int MaxParseErrors
257257
/// </summary>
258258
public DuplicateHeaderBehavior DuplicateHeaderBehavior { get; set; } = DuplicateHeaderBehavior.ThrowException;
259259

260+
private string _defaultHeaderName = "Column";
261+
262+
/// <summary>
263+
/// Gets or sets the default header name prefix used for empty or whitespace-only headers.
264+
/// The column index will be appended to the specified name (e.g., "Column0", "Column1").
265+
/// Default is "Column".
266+
/// This matches the behavior of the LumenWorks CSV library.
267+
/// </summary>
268+
/// <exception cref="ArgumentNullException">Thrown when value is null.</exception>
269+
/// <exception cref="ArgumentException">Thrown when value is empty or whitespace-only.</exception>
270+
public string DefaultHeaderName
271+
{
272+
get => _defaultHeaderName;
273+
set
274+
{
275+
if (value == null)
276+
throw new ArgumentNullException(nameof(value), "DefaultHeaderName cannot be null.");
277+
if (string.IsNullOrWhiteSpace(value))
278+
throw new ArgumentException("DefaultHeaderName cannot be empty or whitespace-only.", nameof(value));
279+
_defaultHeaderName = value;
280+
}
281+
}
282+
260283
/// <summary>
261284
/// Gets or sets the culture to use for parsing numbers and dates.
262285
/// Default is InvariantCulture.
@@ -477,6 +500,7 @@ public CsvReaderOptions Clone()
477500
ExcludeColumns = ExcludeColumns != null ? new HashSet<string>(ExcludeColumns) : null,
478501
DistinguishEmptyFromNull = DistinguishEmptyFromNull,
479502
DuplicateHeaderBehavior = DuplicateHeaderBehavior,
503+
DefaultHeaderName = DefaultHeaderName,
480504
Culture = Culture,
481505
QuoteMode = QuoteMode,
482506
MismatchedFieldAction = MismatchedFieldAction,

project/dbatools/dbatools.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
<Product>dbatools</Product>
88
<Description>The dbatools PowerShell Module library</Description>
99
<Copyright>Copyright © 2025</Copyright>
10-
<AssemblyVersion>0.10.0.79</AssemblyVersion>
11-
<FileVersion>0.10.0.79</FileVersion>
10+
<AssemblyVersion>0.10.0.80</AssemblyVersion>
11+
<FileVersion>0.10.0.80</FileVersion>
1212
<AssemblyName>dbatools</AssemblyName>
1313
<SkipFunctionsDepsCopy>false</SkipFunctionsDepsCopy>
1414
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>

0 commit comments

Comments
 (0)