Skip to content

Commit 677b7bb

Browse files
committed
Add support for multiple column names.
1 parent a8e36e0 commit 677b7bb

10 files changed

Lines changed: 196 additions & 18 deletions

File tree

src/SepCsvSourceGenerator.Analyzer/AnalyzerReleases.Unshipped.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55

66
Rule ID | Category | Severity | Notes
77
--------|----------|----------|-------
8+
CSVGEN009 | Usage | Error | DiagnosticDescriptors

src/SepCsvSourceGenerator.Analyzer/CsvGenerator.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,7 @@ internal sealed class CsvDateFormatAttribute : CsvAttribute
3939
[global::System.AttributeUsage(global::System.AttributeTargets.Property | global::System.AttributeTargets.Field, AllowMultiple = false)]
4040
internal sealed class CsvHeaderNameAttribute : CsvAttribute
4141
{
42-
public CsvHeaderNameAttribute(string name) { Name = name; }
43-
public string Name { get; }
42+
public CsvHeaderNameAttribute(params string[] names) { }
4443
}
4544
4645
/// <summary>

src/SepCsvSourceGenerator.Analyzer/CsvPropertyDefinition.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ internal record CsvPropertyDefinition(
44
string Name,
55
string FullTypeName, // e.g., "System.Nullable<System.Int32>"
66
string UnderlyingTypeName, // e.g., "System.Int32"
7-
string HeaderName,
7+
string[] HeaderNames,
88
string? DateFormat,
99
bool IsRequiredMember,
1010
bool IsInitOnly,

src/SepCsvSourceGenerator.Analyzer/DiagnosticDescriptors.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,6 @@ internal static class DiagnosticDescriptors
2121
new("CSVGEN007", "No properties to parse", "The type '{0}' does not have any properties with the [CsvHeaderName] attribute", "Usage", DiagnosticSeverity.Warning, true);
2222
public static readonly DiagnosticDescriptor PropertyNotParsable =
2323
new("CSVGEN008", "Property not parsable", "Property '{0}' of type '{1}' is not parsable. It must be an enum or implement ISpanParsable<T>.", "Usage", DiagnosticSeverity.Error, true);
24+
public static readonly DiagnosticDescriptor HeaderNamesEmpty =
25+
new("CSVGEN009", "Missing header name", "Must specify one or more header names", "Usage", DiagnosticSeverity.Error, true);
2426
}

src/SepCsvSourceGenerator.Analyzer/Emitter.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,12 +159,28 @@ private void GenerateMethod(CsvMethodDefinition methodDef)
159159
// Get header indices
160160
foreach (var prop in methodDef.PropertiesToParse)
161161
{
162-
AppendLine($"if (!{methodDef.ReaderParameterName}.Header.TryIndexOf(\"{prop.HeaderName}\", out {prop.Name}Ndx))");
162+
var tryFindIndex = string.Join(" || ", prop.HeaderNames.Select(h => $"{methodDef.ReaderParameterName}.Header.TryIndexOf(\"{h}\", out {prop.Name}Ndx)"));
163+
if (prop.HeaderNames.Length == 1)
164+
{
165+
AppendLine($"if (!{tryFindIndex})");
166+
}
167+
else
168+
{
169+
AppendLine($"if (!({tryFindIndex}))");
170+
}
163171
AppendLine("{");
164172
IncreaseIndent();
165173
if (prop.IsRequiredMember)
166174
{
167-
AppendLine($"throw new global::System.ArgumentException($\"Missing required column '{prop.HeaderName}' for required property '{prop.Name}'.\");");
175+
if (prop.HeaderNames.Length == 1)
176+
{
177+
AppendLine($"throw new global::System.ArgumentException($\"Missing required column '{prop.HeaderNames[0]}' for required property '{prop.Name}'.\");");
178+
}
179+
else
180+
{
181+
var headerNames = string.Join("', '", prop.HeaderNames);
182+
AppendLine($"throw new global::System.ArgumentException($\"Missing required column with any of the following names: '{headerNames}' for required property '{prop.Name}'.\");");
183+
}
168184
}
169185
else
170186
{

src/SepCsvSourceGenerator.Analyzer/Parser.cs

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,32 @@ public List<CsvMethodDefinition> GetCsvMethodDefinitions(ImmutableArray<MethodDe
122122
// From here on, if we use "continue" we must raise a diagnostic.
123123
// This ensures we will either get NoPropertiesFound or some other diagnostic if something is wrong.
124124

125-
string? headerName = propertySymbol.Name;
126-
if (headerAttr != null && headerAttr.ConstructorArguments.Length == 1)
125+
string[] headerNames;
126+
if (headerAttr is not null)
127127
{
128-
headerName = headerAttr.ConstructorArguments[0].Value as string;
128+
if (headerAttr.ConstructorArguments.Length == 1 &&
129+
headerAttr.ConstructorArguments[0].Kind == TypedConstantKind.Array)
130+
{
131+
var values = headerAttr.ConstructorArguments[0].Values;
132+
if (values.IsDefaultOrEmpty)
133+
{
134+
Diag(Diagnostic.Create(DiagnosticDescriptors.HeaderNamesEmpty, propertySymbol.Locations.FirstOrDefault()!, propertySymbol.Name));
135+
continue;
136+
}
137+
headerNames = [.. values.Select(c => (string)c.Value!)];
138+
}
139+
else
140+
{
141+
Debug.Fail("There should be no non-array constructor for this attribute.");
142+
continue;
143+
}
144+
}
145+
else
146+
{
147+
headerNames = [propertySymbol.Name];
129148
}
130149

131-
if (string.IsNullOrWhiteSpace(headerName))
150+
if (headerNames.Any(string.IsNullOrWhiteSpace))
132151
{
133152
Diag(Diagnostic.Create(DiagnosticDescriptors.InvalidHeaderName, propertySymbol.Locations.FirstOrDefault()!, propertySymbol.Name));
134153
continue;
@@ -185,7 +204,7 @@ public List<CsvMethodDefinition> GetCsvMethodDefinitions(ImmutableArray<MethodDe
185204
propertySymbol.Name,
186205
propertySymbol.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
187206
underlyingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
188-
headerName!,
207+
headerNames,
189208
dateFormat,
190209
propertySymbol.IsRequired,
191210
propertySymbol.SetMethod?.IsInitOnly ?? false,
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// <auto-generated/>
2+
#nullable enable
3+
using nietras.SeparatedValues;
4+
using System.Globalization;
5+
using System.Runtime.CompilerServices;
6+
7+
namespace Test
8+
{
9+
public partial class MyRecord
10+
{
11+
public static async partial global::System.Collections.Generic.IAsyncEnumerable<global::Test.MyRecord> ParseRecords(SepReader reader, [EnumeratorCancellation] global::System.Threading.CancellationToken ct)
12+
{
13+
int NameNdx;
14+
15+
if (!(reader.Header.TryIndexOf("Name", out NameNdx) || reader.Header.TryIndexOf("Alias", out NameNdx)))
16+
{
17+
throw new global::System.ArgumentException($"Missing required column with any of the following names: 'Name', 'Alias' for required property 'Name'.");
18+
}
19+
20+
await foreach (SepReader.Row row in reader
21+
#if NET10_0_OR_GREATER
22+
.WithCancellation(ct).ConfigureAwait(false)
23+
#endif
24+
)
25+
{
26+
ct.ThrowIfCancellationRequested();
27+
28+
global::Test.MyRecord ret = new global::Test.MyRecord()
29+
{
30+
Name = row[NameNdx].Span.ToString(),
31+
};
32+
yield return ret;
33+
}
34+
}
35+
}
36+
}

tests/SepCsvSourceGenerator.Analyzer.Tests/CsvGeneratorEmitterTests.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,31 @@ public partial class MyRecord
424424
RunTestAsync(source, "OptionalCancellationToken.generated.txt");
425425
}
426426

427+
[Fact]
428+
public void Emitter_GeneratesCorrectCode_ForMultipleHeaderNames()
429+
{
430+
var source = @"
431+
using System;
432+
using System.Collections.Generic;
433+
using System.Threading;
434+
using AWise.SepCsvSourceGenerator;
435+
using nietras.SeparatedValues;
436+
437+
namespace Test
438+
{
439+
public partial class MyRecord
440+
{
441+
[CsvHeaderName(""Name"", ""Alias"")]
442+
public required string Name { get; init; }
443+
444+
[GenerateCsvParser]
445+
public static partial IAsyncEnumerable<MyRecord> ParseRecords(SepReader reader, CancellationToken ct);
446+
}
447+
}
448+
";
449+
RunTestAsync(source, "MultipleHeaderNames.generated.txt");
450+
}
451+
427452
private static void RunTestAsync(string source, string baselineFileName)
428453
{
429454
source = "#nullable enable\n" + source;

tests/SepCsvSourceGenerator.Analyzer.Tests/CsvGeneratorParserTests.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,41 @@ public partial class MyRecord
4949
Assert.Empty(diagnostics);
5050
}
5151

52+
[Fact]
53+
public void ValidMultipleHeaderNames()
54+
{
55+
var diagnostics = RunGenerator(@"
56+
public partial class MyRecord
57+
{
58+
[CsvHeaderName(""a"", ""b"")]
59+
public string? Name { get; set; }
60+
61+
[GenerateCsvParser]
62+
public partial IAsyncEnumerable<MyRecord> Parse(SepReader reader, CancellationToken cancellationToken);
63+
}
64+
");
65+
66+
Assert.Empty(diagnostics);
67+
}
68+
69+
[Fact]
70+
public void MissingHeaderNames()
71+
{
72+
var diagnostics = RunGenerator(@"
73+
public partial class MyRecord
74+
{
75+
[CsvHeaderName]
76+
public string? Name { get; set; }
77+
78+
[GenerateCsvParser]
79+
public partial IAsyncEnumerable<MyRecord> Parse(SepReader reader, CancellationToken cancellationToken);
80+
}
81+
");
82+
83+
var diag = Assert.Single(diagnostics);
84+
Assert.Equal("CSVGEN009", diag.Id);
85+
}
86+
5287
[Fact]
5388
public void InitNonNullableNonRequiredPropertyGeneratesWarning()
5489
{

tests/SepCsvSourceGenerator.Analyzer.Tests/RunGeneratedParserTests.cs

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ public enum MyEnum { A, B, C }
88
public class BaseRecord
99
{
1010
[CsvHeaderName("ID")]
11-
public int Id { get; set; }
11+
public required int Id { get; init; }
1212
}
1313
public partial class MyRecord : BaseRecord
1414
{
@@ -70,6 +70,21 @@ private static void Verify(IEnumerable<MyRecord> enumerable)
7070
Assert.Null(item2.MissingField);
7171
}
7272

73+
[Fact]
74+
public void MissingColumns()
75+
{
76+
using var reader = Sep.Reader().FromText("Name,Date,Enum,NullableInt\nJohn Doe,2023-01-15,A,42\nJane,2023-02-20,B,123");
77+
try
78+
{
79+
MyRecord.Parse(reader, CancellationToken.None).ToList();
80+
Assert.Fail("should throw");
81+
}
82+
catch (ArgumentException ex)
83+
{
84+
Assert.Equal("Missing required column 'ID' for required property 'Id'.", ex.Message);
85+
}
86+
}
87+
7388
[Fact]
7489
public void CancelEnumeration()
7590
{
@@ -114,10 +129,7 @@ public async Task CancelEnumerationAsync(bool tokenInParseAsync, bool tokenInGet
114129
}
115130
await enumerator.DisposeAsync();
116131
}
117-
}
118132

119-
public partial class RunGeneratedParserTests_WithNewDateTypes
120-
{
121133
public partial class MyRecordWithNewDates
122134
{
123135
[CsvHeaderName("ID")]
@@ -139,12 +151,10 @@ public partial class MyRecordWithNewDates
139151
public static partial IEnumerable<MyRecordWithNewDates> Parse(SepReader reader);
140152
}
141153

142-
const string CSV_CONTENT = "ID,Date,Time,Offset\n1,2024-01-01,13:14:15.123,2024-05-10T10:00:00.0000000-05:00";
143-
144154
[Fact]
145-
public void Parse()
155+
public void ParseNewDates()
146156
{
147-
using var reader = Sep.Reader().FromText(CSV_CONTENT);
157+
using var reader = Sep.Reader().FromText("ID,Date,Time,Offset\n1,2024-01-01,13:14:15.123,2024-05-10T10:00:00.0000000-05:00");
148158
var list = MyRecordWithNewDates.Parse(reader).ToList();
149159
var item1 = Assert.Single(list);
150160

@@ -153,4 +163,39 @@ public void Parse()
153163
Assert.Equal(new TimeOnly(13, 14, 15, 123), item1.Time);
154164
Assert.Equal(new DateTimeOffset(2024, 5, 10, 10, 0, 0, TimeSpan.FromHours(-5)), item1.Dto);
155165
}
166+
167+
public partial class MyRecordWithAliasedColumns
168+
{
169+
[CsvHeaderName("a", "b")]
170+
public required int Value { get; init; }
171+
172+
[GenerateCsvParser]
173+
public static partial IEnumerable<MyRecordWithAliasedColumns> Parse(SepReader reader);
174+
}
175+
176+
[Theory]
177+
[InlineData("a\n1")]
178+
[InlineData("b\n1")]
179+
public void ParseAliasedColumns(string fileContents)
180+
{
181+
using var reader = Sep.Reader().FromText(fileContents);
182+
var list = MyRecordWithAliasedColumns.Parse(reader).ToList();
183+
var item1 = Assert.Single(list);
184+
Assert.Equal(1, item1.Value);
185+
}
186+
187+
[Fact]
188+
public void MissingAliasedColumns()
189+
{
190+
using var reader = Sep.Reader().FromText("whoops,1");
191+
try
192+
{
193+
MyRecordWithAliasedColumns.Parse(reader).ToList();
194+
Assert.Fail("should throw");
195+
}
196+
catch (ArgumentException ex)
197+
{
198+
Assert.Equal("Missing required column with any of the following names: 'a', 'b' for required property 'Value'.", ex.Message);
199+
}
200+
}
156201
}

0 commit comments

Comments
 (0)