Skip to content

Commit b445675

Browse files
fix: parse xs:attribute columns in XSD parser (fixes #52)
Tables whose columns are declared as xs:attribute elements (common in VS Dataset Designer schemas) previously generated empty column lists, causing CS0103 build errors and a leading-comma AddRowAsync signature. Changes: - ParseTable now iterates xs:attribute children of xs:complexType in addition to xs:element children inside xs:sequence - ParseColumn handles both element and attribute nullability rules: use="required" -> AllowDBNull=false, absent/optional -> AllowDBNull=true - ExtractColumnNameFromXPath strips the leading @ prefix from attribute XPath references (xs:field xpath="@colname") so names match columns - All codegen: and msdata: column annotations work identically on xs:attribute columns; mixed element+attribute tables are supported Tests: - 3 new XSD fixtures: AttributeColumns, MixedColumns, AttributeFkColumns - 25 new unit tests covering parser, generator driver, and all edge cases - Integration schema and 18 integration tests for end-to-end verification Docs: - README: added xs:attribute and namespace-prefixed XPath to feature list - annotations-reference: new xs:attribute column declarations section - generating-from-xsd: fixed stale class names (DataSet prefix); added xs:attribute columns section - CHANGELOG: added [Unreleased] entry Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 49aaa2e commit b445675

12 files changed

Lines changed: 752 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## [Unreleased]
4+
5+
### Bug Fixes
6+
7+
* parse `xs:attribute` column declarations in XSD parser — tables using attribute-style columns (common in VS Dataset Designer schemas) now generate correct column field declarations, property accessors, `InitClass`, `InitVars`, and `AddRowAsync` parameter lists ([#52](https://github.com/MarcelRoozekrans/AdoNet.Async/issues/52)). `use="required"` marks the column non-nullable; absent `use` or `use="optional"` marks it nullable — consistent with `minOccurs="0"` for `xs:element` columns. Mixed `xs:element` + `xs:attribute` columns in the same table are supported. All `codegen:` and `msdata:` column annotations work identically on both forms.
8+
* strip leading `@` XPath prefix from `xs:field` references so attribute-column names in composite primary keys and foreign key constraints match the parsed column names
9+
310
## [1.1.3](https://github.com/MarcelRoozekrans/AdoNet.Async/compare/v1.1.2...v1.1.3) (2026-04-17)
411

512

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ if (!customer.IsEmailNull())
189189
var found = ds.Customer.FindByCustomerId(1);
190190
```
191191

192-
All `codegen:` and `msdata:` XSD annotations are supported: `typedName`, `typedPlural`, `typedParent`, `typedChildren`, `nullValue` (_throw, _null, _empty, replacement), `AutoIncrement`, `DefaultValue`, `ReadOnly`, `Expression`, `DataType`, composite primary keys, and foreign key constraints.
192+
All `codegen:` and `msdata:` XSD annotations are supported: `typedName`, `typedPlural`, `typedParent`, `typedChildren`, `nullValue` (_throw, _null, _empty, replacement), `AutoIncrement`, `DefaultValue`, `ReadOnly`, `Expression`, `DataType`, composite primary keys, and foreign key constraints. Columns may be declared as `xs:element` (inside `xs:sequence`) or as `xs:attribute` directly in the table's `xs:complexType` — both are fully supported, with `use="required"` marking an attribute column non-nullable. Namespace-prefixed XPath selectors (e.g. `mstns:TableName`) are handled transparently.
193193

194194
### JSON serialization with Newtonsoft.Json
195195

src/System.Data.Async.DataSet.Generator/Parsing/XsdParser.cs

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,13 @@ private static TableModel ParseTable(XElement tableElement)
170170
columns.Add(ParseColumn(col));
171171
}
172172
}
173+
174+
// xs:attribute elements represent columns when a table is defined without xs:sequence
175+
// (common in VS Dataset Designer schemas where columns are declared as XML attributes)
176+
foreach (var attr in complexType.Elements(Xs + "attribute"))
177+
{
178+
columns.Add(ParseColumn(attr));
179+
}
173180
}
174181

175182
return new TableModel(
@@ -186,8 +193,19 @@ private static ColumnModel ParseColumn(XElement colElement)
186193
var dataTypeOverride = (string?)colElement.Attribute(Msdata + "DataType");
187194
var clrType = dataTypeOverride ?? (xsdType != null ? XsdTypeMapper.TryMap(xsdType) : null) ?? "System.String";
188195

189-
var minOccurs = (string?)colElement.Attribute("minOccurs");
190-
var allowDbNull = string.Equals(minOccurs, "0", StringComparison.Ordinal);
196+
// xs:element uses minOccurs="0" to indicate nullable; xs:attribute is nullable by default
197+
// unless use="required" is specified (use="optional" or absent both mean nullable)
198+
bool allowDbNull;
199+
if (string.Equals(colElement.Name.LocalName, "attribute", StringComparison.Ordinal))
200+
{
201+
var use = (string?)colElement.Attribute("use");
202+
allowDbNull = !string.Equals(use, "required", StringComparison.OrdinalIgnoreCase);
203+
}
204+
else
205+
{
206+
var minOccurs = (string?)colElement.Attribute("minOccurs");
207+
allowDbNull = string.Equals(minOccurs, "0", StringComparison.Ordinal);
208+
}
191209

192210
var readOnly = ParseBool(colElement, Msdata + "ReadOnly");
193211
var expression = (string?)colElement.Attribute(Msdata + "Expression");
@@ -254,7 +272,11 @@ private static string ExtractColumnNameFromXPath(string xpath)
254272
{
255273
var idx = xpath.LastIndexOf('/');
256274
var localName = idx >= 0 ? xpath.Substring(idx + 1) : xpath;
257-
return StripNamespacePrefix(localName);
275+
localName = StripNamespacePrefix(localName);
276+
// xs:field xpath="@ColName" references an xs:attribute column — strip the leading @
277+
if (localName.Length > 0 && localName[0] == '@')
278+
localName = localName.Substring(1);
279+
return localName;
258280
}
259281

260282
private static string StripNamespacePrefix(string name)
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
using System.Data.Async.DataSet;
2+
using FluentAssertions;
3+
using Xunit;
4+
5+
namespace System.Data.Async.DataSet.Generator.Integration.Tests;
6+
7+
/// <summary>
8+
/// Integration tests for DataSets whose table columns are declared as xs:attribute
9+
/// elements in the XSD schema rather than xs:element within xs:sequence.
10+
/// Regression coverage for issue #52.
11+
/// </summary>
12+
public class AttributeColumnsTypedDataSetTests
13+
{
14+
[Fact]
15+
public void Can_Create_AttributeColumnsDS()
16+
{
17+
using var ds = new AsyncAttributeColumnsDS();
18+
ds.Entry.Should().NotBeNull();
19+
}
20+
21+
[Fact]
22+
public void Entry_Table_Has_Column_Properties()
23+
{
24+
using var ds = new AsyncAttributeColumnsDS();
25+
ds.Entry.RegionIdColumn.Should().NotBeNull();
26+
ds.Entry.CodeColumn.Should().NotBeNull();
27+
ds.Entry.DescriptionColumn.Should().NotBeNull();
28+
ds.Entry.LabelColumn.Should().NotBeNull();
29+
ds.Entry.TrackingIdColumn.Should().NotBeNull();
30+
ds.Entry.TagColumn.Should().NotBeNull();
31+
}
32+
33+
[Fact]
34+
public async Task Explicit_Optional_Attribute_Column_AllowDBNull_True()
35+
{
36+
using var ds = new AsyncAttributeColumnsDS();
37+
// use="optional" explicitly set — must be nullable, same as absent use
38+
ds.Entry.LabelColumn.AllowDBNull.Should().BeTrue();
39+
var row = await ds.Entry.AddEntryRowAsync(1, "NL");
40+
row.IsLabelNull().Should().BeTrue();
41+
await row.SetLabelAsync("west");
42+
row.Label.Should().Be("west");
43+
}
44+
45+
[Fact]
46+
public async Task Can_Add_Entry_Row()
47+
{
48+
using var ds = new AsyncAttributeColumnsDS();
49+
var row = await ds.Entry.AddEntryRowAsync(1, "NL");
50+
row.Should().BeOfType<AsyncAttributeColumnsDSEntryRow>();
51+
row.RegionId.Should().Be(1);
52+
row.Code.Should().Be("NL");
53+
}
54+
55+
[Fact]
56+
public void Required_Attribute_Column_AllowDBNull_False()
57+
{
58+
using var ds = new AsyncAttributeColumnsDS();
59+
ds.Entry.RegionIdColumn.AllowDBNull.Should().BeFalse();
60+
ds.Entry.CodeColumn.AllowDBNull.Should().BeFalse();
61+
}
62+
63+
[Fact]
64+
public void Optional_Attribute_Column_AllowDBNull_True()
65+
{
66+
using var ds = new AsyncAttributeColumnsDS();
67+
ds.Entry.DescriptionColumn.AllowDBNull.Should().BeTrue();
68+
}
69+
70+
[Fact]
71+
public async Task Optional_Attribute_Column_IsNull_When_Not_Set()
72+
{
73+
using var ds = new AsyncAttributeColumnsDS();
74+
var row = await ds.Entry.AddEntryRowAsync(1, "NL");
75+
row.IsDescriptionNull().Should().BeTrue();
76+
}
77+
78+
[Fact]
79+
public async Task Optional_Attribute_Column_Set_And_Read()
80+
{
81+
using var ds = new AsyncAttributeColumnsDS();
82+
var row = await ds.Entry.AddEntryRowAsync(1, "NL");
83+
await row.SetDescriptionAsync("Netherlands");
84+
row.IsDescriptionNull().Should().BeFalse();
85+
row.Description.Should().Be("Netherlands");
86+
}
87+
88+
[Fact]
89+
public async Task DataType_Override_Guid_Attribute_Column()
90+
{
91+
using var ds = new AsyncAttributeColumnsDS();
92+
ds.Entry.TrackingIdColumn.DataType.Should().Be(typeof(Guid));
93+
var row = await ds.Entry.AddEntryRowAsync(1, "NL");
94+
var guid = Guid.NewGuid();
95+
await row.SetTrackingIdAsync(guid);
96+
row.TrackingId.Should().Be(guid);
97+
}
98+
99+
[Fact]
100+
public async Task NullValue_Replacement_Attribute_Column()
101+
{
102+
using var ds = new AsyncAttributeColumnsDS();
103+
var row = await ds.Entry.AddEntryRowAsync(1, "NL");
104+
// Tag has codegen:nullValue="N/A" — null returns "N/A" instead of throwing
105+
row.IsTagNull().Should().BeTrue();
106+
row.Tag.Should().Be("N/A");
107+
}
108+
109+
[Fact]
110+
public async Task NullValue_Replacement_Attribute_Column_SetNull()
111+
{
112+
using var ds = new AsyncAttributeColumnsDS();
113+
var row = await ds.Entry.AddEntryRowAsync(1, "NL");
114+
await row.SetTagAsync("some-tag");
115+
row.Tag.Should().Be("some-tag");
116+
await row.SetTagNullAsync();
117+
row.Tag.Should().Be("N/A");
118+
}
119+
120+
[Fact]
121+
public async Task Composite_PK_FindBy_Works()
122+
{
123+
using var ds = new AsyncAttributeColumnsDS();
124+
await ds.Entry.AddEntryRowAsync(1, "NL");
125+
await ds.Entry.AddEntryRowAsync(1, "DE");
126+
await ds.Entry.AddEntryRowAsync(2, "NL");
127+
128+
var found = ds.Entry.FindByRegionIdCode(1, "DE");
129+
found.Should().NotBeNull();
130+
found!.RegionId.Should().Be(1);
131+
found.Code.Should().Be("DE");
132+
}
133+
134+
[Fact]
135+
public async Task Composite_PK_FindBy_Returns_Null_When_Not_Found()
136+
{
137+
using var ds = new AsyncAttributeColumnsDS();
138+
await ds.Entry.AddEntryRowAsync(1, "NL");
139+
140+
var notFound = ds.Entry.FindByRegionIdCode(99, "XX");
141+
notFound.Should().BeNull();
142+
}
143+
144+
[Fact]
145+
public async Task Entry_Count_Correct()
146+
{
147+
using var ds = new AsyncAttributeColumnsDS();
148+
await ds.Entry.AddEntryRowAsync(1, "NL");
149+
await ds.Entry.AddEntryRowAsync(1, "DE");
150+
ds.Entry.Count.Should().Be(2);
151+
}
152+
153+
[Fact]
154+
public void Clone_Returns_Typed_AttributeColumnsDS()
155+
{
156+
using var ds = new AsyncAttributeColumnsDS();
157+
using var clone = ds.Clone();
158+
clone.Should().NotBeNull();
159+
clone.Should().BeOfType<AsyncAttributeColumnsDS>();
160+
clone.Entry.Should().NotBeNull();
161+
}
162+
163+
[Fact]
164+
public async Task Row_Is_AsyncDataRow()
165+
{
166+
using var ds = new AsyncAttributeColumnsDS();
167+
await ds.Entry.AddEntryRowAsync(1, "NL");
168+
AsyncDataRow untyped = ds.Entry[0];
169+
untyped.Should().BeOfType<AsyncAttributeColumnsDSEntryRow>();
170+
}
171+
172+
[Fact]
173+
public async Task Remove_Row()
174+
{
175+
using var ds = new AsyncAttributeColumnsDS();
176+
var row = await ds.Entry.AddEntryRowAsync(1, "NL");
177+
ds.Entry.Count.Should().Be(1);
178+
await ds.Entry.RemoveEntryRowAsync(row);
179+
ds.Entry.Count.Should().Be(0);
180+
}
181+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?xml version="1.0" standalone="yes"?>
2+
<xs:schema id="AttributeColumnsDS"
3+
targetNamespace="http://tempuri.org/AttributeColumnsDS.xsd"
4+
xmlns:mstns="http://tempuri.org/AttributeColumnsDS.xsd"
5+
xmlns:xs="http://www.w3.org/2001/XMLSchema"
6+
xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"
7+
xmlns:codegen="urn:schemas-microsoft-com:xml-msprop">
8+
<xs:element name="AttributeColumnsDS" msdata:IsDataSet="true">
9+
<xs:complexType>
10+
<xs:choice maxOccurs="unbounded">
11+
<!--
12+
Table with all xs:attribute columns and a composite primary key.
13+
Exercises:
14+
- xs:attribute column parsing
15+
- use="required" -> AllowDBNull=false
16+
- absent use -> AllowDBNull=true (implicit optional)
17+
- use="optional" -> AllowDBNull=true (explicit optional)
18+
- msdata:DataType override (Guid)
19+
- codegen:nullValue replacement
20+
- @ prefix stripping in xs:field xpath
21+
-->
22+
<xs:element name="Entry">
23+
<xs:complexType>
24+
<xs:attribute name="RegionId" type="xs:int" use="required" />
25+
<xs:attribute name="Code" type="xs:string" use="required" />
26+
<xs:attribute name="Description" type="xs:string" />
27+
<xs:attribute name="Label" type="xs:string" use="optional" />
28+
<xs:attribute name="TrackingId" msdata:DataType="System.Guid" />
29+
<xs:attribute name="Tag" type="xs:string" codegen:nullValue="N/A" />
30+
</xs:complexType>
31+
</xs:element>
32+
</xs:choice>
33+
</xs:complexType>
34+
<xs:key name="PK_Entry" msdata:PrimaryKey="true">
35+
<xs:selector xpath=".//mstns:Entry" />
36+
<xs:field xpath="@RegionId" />
37+
<xs:field xpath="@Code" />
38+
</xs:key>
39+
</xs:element>
40+
</xs:schema>

tests/System.Data.Async.DataSet.Generator.Tests/GeneratorDriverTests.cs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,97 @@ public void NamespacePrefixed_Xsd_DataSet_Contains_No_Namespace_Prefix()
9999
text.Should().Contain("tableSUBCATEGORY.CATIDColumn");
100100
}
101101

102+
// --- xs:attribute column tests (issue #52) ---
103+
104+
[Fact]
105+
public void AttributeColumns_Xsd_Generates_No_Diagnostics()
106+
{
107+
var result = RunGenerator(LoadSchema("AttributeColumns.xsd"), "AttributeColumns.xsd");
108+
result.Diagnostics.Should().BeEmpty();
109+
}
110+
111+
[Fact]
112+
public void AttributeColumns_Xsd_DataTable_Contains_Column_Fields()
113+
{
114+
var result = RunGenerator(LoadSchema("AttributeColumns.xsd"), "AttributeColumns.xsd");
115+
var tableTree = result.GeneratedTrees.FirstOrDefault(t => t.FilePath.Contains("Entry.AsyncDataTable.g.cs"));
116+
tableTree.Should().NotBeNull();
117+
var text = tableTree!.GetText().ToString();
118+
// All three xs:attribute columns must be declared
119+
text.Should().Contain("columnRegionId");
120+
text.Should().Contain("columnCode");
121+
text.Should().Contain("columnDescription");
122+
}
123+
124+
[Fact]
125+
public void AttributeColumns_Xsd_AddRowAsync_Has_Required_Parameters()
126+
{
127+
var result = RunGenerator(LoadSchema("AttributeColumns.xsd"), "AttributeColumns.xsd");
128+
var tableTree = result.GeneratedTrees.FirstOrDefault(t => t.FilePath.Contains("Entry.AsyncDataTable.g.cs"));
129+
tableTree.Should().NotBeNull();
130+
var text = tableTree!.GetText().ToString();
131+
// AddEntryRowAsync must NOT have a leading comma — must have typed params for required cols
132+
text.Should().Contain("AddEntryRowAsync(");
133+
text.Should().NotContain("AddEntryRowAsync(,");
134+
text.Should().Contain("int regionId");
135+
text.Should().Contain("string code");
136+
}
137+
138+
[Fact]
139+
public void AttributeColumns_Xsd_Composite_FindBy_Generated()
140+
{
141+
var result = RunGenerator(LoadSchema("AttributeColumns.xsd"), "AttributeColumns.xsd");
142+
var tableTree = result.GeneratedTrees.FirstOrDefault(t => t.FilePath.Contains("Entry.AsyncDataTable.g.cs"));
143+
tableTree.Should().NotBeNull();
144+
var text = tableTree!.GetText().ToString();
145+
text.Should().Contain("FindByRegionIdCode(");
146+
}
147+
148+
// --- Mixed xs:element + xs:attribute column tests ---
149+
150+
[Fact]
151+
public void MixedColumns_Xsd_Generates_No_Diagnostics()
152+
{
153+
var result = RunGenerator(LoadSchema("MixedColumns.xsd"), "MixedColumns.xsd");
154+
result.Diagnostics.Should().BeEmpty();
155+
}
156+
157+
[Fact]
158+
public void MixedColumns_Xsd_DataTable_Contains_All_Four_Column_Fields()
159+
{
160+
var result = RunGenerator(LoadSchema("MixedColumns.xsd"), "MixedColumns.xsd");
161+
var tableTree = result.GeneratedTrees.FirstOrDefault(t => t.FilePath.Contains("Item.AsyncDataTable.g.cs"));
162+
tableTree.Should().NotBeNull();
163+
var text = tableTree!.GetText().ToString();
164+
// 2 xs:element + 2 xs:attribute
165+
text.Should().Contain("columnId");
166+
text.Should().Contain("columnName");
167+
text.Should().Contain("columnSource");
168+
text.Should().Contain("columnPriority");
169+
}
170+
171+
[Fact]
172+
public void AttributeColumns_Xsd_Guid_Attribute_Generated_With_Correct_Type()
173+
{
174+
var result = RunGenerator(LoadSchema("AttributeColumns.xsd"), "AttributeColumns.xsd");
175+
var tableTree = result.GeneratedTrees.FirstOrDefault(t => t.FilePath.Contains("Entry.AsyncDataTable.g.cs"));
176+
tableTree.Should().NotBeNull();
177+
var text = tableTree!.GetText().ToString();
178+
// msdata:DataType="System.Guid" attribute column
179+
text.Should().Contain("typeof(global::System.Guid)");
180+
}
181+
182+
[Fact]
183+
public void AttributeColumns_Xsd_NullValue_Replacement_In_Row()
184+
{
185+
var result = RunGenerator(LoadSchema("AttributeColumns.xsd"), "AttributeColumns.xsd");
186+
var rowTree = result.GeneratedTrees.FirstOrDefault(t => t.FilePath.Contains("Entry.AsyncDataRow.g.cs"));
187+
rowTree.Should().NotBeNull();
188+
var text = rowTree!.GetText().ToString();
189+
// codegen:nullValue="N/A" on Tag attribute
190+
text.Should().Contain("\"N/A\"");
191+
}
192+
102193
[Fact]
103194
public void Non_Xsd_Files_Are_Ignored()
104195
{

0 commit comments

Comments
 (0)