Skip to content

Commit b20bb22

Browse files
committed
BACPAC loader Phase B — tables + user-defined types. ModelXmlReader.Apply now runs in two dependency-ordered passes via a new RunPhase helper: phase 1 emits SqlDatabaseOptions + SqlSchema + SqlUserDefinedDataType, phase 2 emits SqlTable. Element list materializes once at the top of Apply and both passes iterate the same List<XElement>. Adding a phase 3+ for constraints / views / functions is just extending the (type, phase) switch.
`EmitTable` walks the Columns relationship and translates each `SqlSimpleColumn` to `[col] type[(args)] [IDENTITY(seed, increment)] [NULL|NOT NULL]`; `SqlComputedColumn` lands on Skipped pending its own phase. `EmitUserDefinedDataType` issues `CREATE TYPE … FROM <builtin>[(args)] [NULL|NOT NULL]` — reuses `TranslateTypeSpecifier` since the UDDT element's Type relationship + Length/Precision/Scale match the SqlTypeSpecifier shape exactly. Two notable translations: **`[sys].[sysname]` is inlined to `nvarchar(128)`** (the simulator's `SqlType.GetByName` keyword table doesn't include sysname; lossy on sys.columns surface but byte-identical for storage), and **`ROWGUIDCOL` is dropped with a Warning** (the simulator's CREATE TABLE parser doesn't accept it; metadata-only annotation, storage behavior unaffected since `DEFAULT NEWID()` arrives as a separate `SqlDefaultConstraint` element in Phase D). **Simulator gap fix**: `BuiltInResources.GetSysColumnMetadata` was missing the `HierarchyIdSqlType` arm — added `(892, 0, 0)` per the probed sys.types max_length. Pre-existing gap from the hierarchyid bundle that surfaced when the loader test exercised `sys.columns` against `HumanResources.Employee.OrganizationNode`. **Tests**: 2 new in `BacpacLoaderTests.cs` (6 total) — `sys.tables` count = 71, `sys.columns` 4-way join on `Production.ProductCategory` projects name + base-type + nullability + identity for each of the 4 columns (alias-typed Name col, IDENTITY-bearing PK, dropped-ROWGUIDCOL-with-warning column, plain datetime). Existing `Load_AW_Unhandled_Elements_Recorded_In_Skipped` flips SqlTable from IsNotEmpty to IsEmpty and adds SqlComputedColumn as a new Skipped check. `docs/claude/bacpac-prerequisites.md` + `CLAUDE.md` deliberately untouched; doc updates land when the baseline AW load works end-to-end rather than per-phase. Next bundle: Phase C constraints (PK / UQ / FK / CHECK / DEFAULT) as a phase-3 `ALTER TABLE … ADD CONSTRAINT` pass.
1 parent d1316ba commit b20bb22

3 files changed

Lines changed: 308 additions & 13 deletions

File tree

SqlServerSimulator.Tests.Internal/Storage/BacpacLoaderTests.cs

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,68 @@ public void Load_AW_Element_Counts_Match_Probe()
8383
public void Load_AW_Unhandled_Elements_Recorded_In_Skipped()
8484
{
8585
_ = LoadAdventureWorks(out var diagnostics);
86-
// Every non-Schema, non-DatabaseOptions element should currently be
87-
// on Skipped — they'll move off as each emitter lands.
86+
// Phase A + B handled element types (SqlSchema, SqlDatabaseOptions,
87+
// SqlTable, SqlSimpleColumn) are off Skipped; everything else still
88+
// appears there awaiting future bundles.
8889
IsNotEmpty(diagnostics.Skipped);
89-
IsNotEmpty(diagnostics.Skipped.Where(s => s.ElementType == "SqlTable").ToList());
90+
IsEmpty(diagnostics.Skipped.Where(s => s.ElementType == "SqlTable").ToList());
9091
IsNotEmpty(diagnostics.Skipped.Where(s => s.ElementType == "SqlExtendedProperty").ToList());
92+
IsNotEmpty(diagnostics.Skipped.Where(s => s.ElementType == "SqlComputedColumn").ToList());
93+
}
94+
95+
[TestMethod]
96+
public void Load_AW_Tables_Land_With_Correct_Column_Counts()
97+
{
98+
var simulation = LoadAdventureWorks(out _);
99+
using var connection = (SimulatedDbConnection)simulation.CreateDbConnection();
100+
connection.Open();
101+
using var command = connection.CreateCommand();
102+
// 71 user tables. Pre-existing system tables (sys.*) are filtered by
103+
// the schema_id range (user schemas use ids >= 5; built-in sys = 4).
104+
command.CommandText = "SELECT COUNT(*) FROM sys.tables;";
105+
using var reader = command.ExecuteReader();
106+
IsTrue(reader.Read());
107+
AreEqual(71, reader.GetInt32(0));
108+
}
109+
110+
[TestMethod]
111+
public void Load_AW_Production_ProductCategory_Has_Expected_Columns()
112+
{
113+
var simulation = LoadAdventureWorks(out _);
114+
using var connection = (SimulatedDbConnection)simulation.CreateDbConnection();
115+
connection.Open();
116+
using var command = connection.CreateCommand();
117+
// ProductCategory has 4 cols: ProductCategoryID int IDENTITY PK,
118+
// Name [dbo].[Name] NOT NULL, rowguid uniqueidentifier NOT NULL
119+
// ROWGUIDCOL, ModifiedDate datetime NOT NULL.
120+
command.CommandText = """
121+
SELECT c.name, t.name AS type_name, c.is_nullable, c.is_identity
122+
FROM sys.columns c
123+
JOIN sys.tables tab ON c.object_id = tab.object_id
124+
JOIN sys.types t ON c.user_type_id = t.user_type_id
125+
JOIN sys.schemas s ON tab.schema_id = s.schema_id
126+
WHERE s.name = 'Production' AND tab.name = 'ProductCategory'
127+
ORDER BY c.column_id;
128+
""";
129+
using var reader = command.ExecuteReader();
130+
var rows = new List<(string Name, string TypeName, bool Nullable, bool Identity)>();
131+
while (reader.Read())
132+
{
133+
rows.Add((reader.GetString(0), reader.GetString(1), reader.GetBoolean(2), reader.GetBoolean(3)));
134+
}
135+
HasCount(4, rows);
136+
AreEqual("ProductCategoryID", rows[0].Name);
137+
AreEqual("int", rows[0].TypeName);
138+
IsFalse(rows[0].Nullable);
139+
IsTrue(rows[0].Identity);
140+
AreEqual("Name", rows[1].Name);
141+
// [dbo].[Name] is an alias over nvarchar(50); user_type_id surfaces the
142+
// alias's allocated id (>=256) and joining to sys.types resolves the
143+
// alias name. NOT NULL is preserved through the column declaration.
144+
IsFalse(rows[1].Nullable);
145+
AreEqual("rowguid", rows[2].Name);
146+
AreEqual("uniqueidentifier", rows[2].TypeName);
147+
AreEqual("ModifiedDate", rows[3].Name);
148+
AreEqual("datetime", rows[3].TypeName);
91149
}
92150
}

SqlServerSimulator/BuiltInResources.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2799,6 +2799,9 @@ private static (short MaxLength, byte Precision, byte Scale) GetSysColumnMetadat
27992799
// geography / geometry: same max_length = -1 reporting as xml,
28002800
// matching the probed sys.columns shape for spatial-typed columns.
28012801
SpatialSqlType => (-1, 0, 0),
2802+
// hierarchyid: 892-byte max representation per the probed
2803+
// sys.types row shape; no numeric precision/scale.
2804+
HierarchyIdSqlType => (892, 0, 0),
28022805
_ => throw new NotSupportedException($"No sys.columns metadata for {t}."),
28032806
};
28042807
}

SqlServerSimulator/Storage/Bacpac/ModelXmlReader.cs

Lines changed: 244 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Data.Common;
2+
using System.Globalization;
23
using System.Xml;
34
using System.Xml.Linq;
45

@@ -41,28 +42,58 @@ public static void Apply(Stream modelStream, Simulation simulation, BacpacLoadRe
4142
var model = doc.Root?.Element(Ns + "Model")
4243
?? throw new InvalidDataException("bacpac: <DataSchemaModel><Model> root not found.");
4344

44-
using var connection = simulation.CreateDbConnection();
45-
connection.Open();
46-
47-
foreach (var element in model.Elements(Ns + "Element"))
45+
// Materialize once so dependency-ordered passes can iterate the same
46+
// element list multiple times. The cost is one O(N) walk + per-element
47+
// XElement reference retention — 1000s of elements at most, negligible
48+
// memory-wise even for huge bacpacs.
49+
var elements = model.Elements(Ns + "Element").ToList();
50+
foreach (var element in elements)
4851
{
4952
var type = element.Attribute("Type")?.Value
5053
?? throw new InvalidDataException("bacpac: <Element> missing Type attribute.");
51-
var name = element.Attribute("Name")?.Value;
52-
5354
_ = result.ElementCounts.TryGetValue(type, out var current);
5455
result.ElementCounts[type] = current + 1;
56+
}
57+
58+
using var connection = simulation.CreateDbConnection();
59+
connection.Open();
60+
61+
// Phase 1: database options + schemas + UDDTs. These don't depend on
62+
// any other DDL and must land before tables (column types may
63+
// reference an alias type, FK definitions may reference a schema, etc.).
64+
RunPhase(elements, connection, result, isPhase1: true);
65+
// Phase 2: tables. Constraints / indexes / views / etc. arrive in
66+
// later phases as they're added.
67+
RunPhase(elements, connection, result, isPhase1: false);
68+
}
5569

56-
switch (type)
70+
private static void RunPhase(List<XElement> elements, DbConnection connection, BacpacLoadResult result, bool isPhase1)
71+
{
72+
foreach (var element in elements)
73+
{
74+
var type = element.Attribute("Type")?.Value!;
75+
var name = element.Attribute("Name")?.Value;
76+
switch ((type, isPhase1))
5777
{
58-
case "SqlDatabaseOptions":
78+
case ("SqlDatabaseOptions", true):
5979
EmitDatabaseOptions(element, connection, result);
6080
break;
61-
case "SqlSchema":
81+
case ("SqlSchema", true):
6282
EmitSchema(name, connection);
6383
break;
84+
case ("SqlUserDefinedDataType", true):
85+
EmitUserDefinedDataType(element, name, connection);
86+
break;
87+
case ("SqlTable", false):
88+
EmitTable(element, name, connection, result);
89+
break;
90+
case (_, true):
91+
// Skipped recording happens in phase 2's default arm so
92+
// an element doesn't get reported twice.
93+
break;
6494
default:
65-
result.Skipped.Add(new BacpacSkipped(type, name, "Element type not yet handled by the loader."));
95+
if (type is not "SqlDatabaseOptions" and not "SqlSchema" and not "SqlUserDefinedDataType" and not "SqlTable")
96+
result.Skipped.Add(new BacpacSkipped(type, name, "Element type not yet handled by the loader."));
6697
break;
6798
}
6899
}
@@ -197,4 +228,207 @@ private static void EmitDatabaseOptions(XElement element, DbConnection connectio
197228
"3" => "SIMPLE",
198229
_ => "FULL",
199230
};
231+
232+
/// <summary>
233+
/// Emits <c>CREATE TYPE [schema].[name] FROM &lt;builtin&gt;[(args)] [NULL | NOT NULL]</c>
234+
/// for a <c>SqlUserDefinedDataType</c> element. The element's Type relationship
235+
/// + Length / Precision / Scale properties match the SqlTypeSpecifier shape exactly,
236+
/// so <see cref="TranslateTypeSpecifier"/> reuses cleanly. Default nullability when
237+
/// IsNullable is absent is True (matches SQL Server's CREATE TYPE default).
238+
/// </summary>
239+
private static void EmitUserDefinedDataType(XElement element, string? qualifiedName, DbConnection connection)
240+
{
241+
if (string.IsNullOrEmpty(qualifiedName))
242+
throw new InvalidDataException("bacpac: SqlUserDefinedDataType element missing Name attribute.");
243+
244+
var typeDdl = TranslateTypeSpecifier(element);
245+
var isNullable = ReadBoolProperty(element, "IsNullable", defaultValue: true);
246+
var nullability = isNullable ? " NULL" : " NOT NULL";
247+
248+
using var command = connection.CreateCommand();
249+
#pragma warning disable CA2100 // bacpac content is caller-trusted; the loader is a translator, not an end-user input handler
250+
command.CommandText = $"CREATE TYPE {qualifiedName} FROM {typeDdl}{nullability};";
251+
#pragma warning restore CA2100
252+
_ = command.ExecuteNonQuery();
253+
}
254+
255+
/// <summary>
256+
/// Emits <c>CREATE TABLE [schema].[name] (col1 TYPE [IDENTITY] [ROWGUIDCOL] [NULL|NOT NULL], …)</c>
257+
/// for a <c>SqlTable</c> element. Only <c>SqlSimpleColumn</c> entries are
258+
/// translated in this phase; <c>SqlComputedColumn</c> entries land on
259+
/// <see cref="BacpacLoadResult.Skipped"/> (a follow-up phase ties them
260+
/// into the same CREATE TABLE via the simulator's <c>AS &lt;expr&gt; [PERSISTED]</c>
261+
/// computed-column grammar). Constraints (PK / UQ / FK / CHECK / DEFAULT)
262+
/// arrive as separate top-level Elements; they layer onto the table later
263+
/// via <c>ALTER TABLE … ADD CONSTRAINT</c>.
264+
/// </summary>
265+
private static void EmitTable(XElement element, string? qualifiedName, DbConnection connection, BacpacLoadResult result)
266+
{
267+
if (string.IsNullOrEmpty(qualifiedName))
268+
throw new InvalidDataException("bacpac: SqlTable element missing Name attribute.");
269+
270+
var columnsRelationship = element.Elements(Ns + "Relationship")
271+
.FirstOrDefault(r => r.Attribute("Name")?.Value == "Columns")
272+
?? throw new InvalidDataException($"bacpac: SqlTable '{qualifiedName}' has no Columns relationship.");
273+
274+
var columnDdls = new List<string>();
275+
foreach (var columnElement in columnsRelationship.Elements(Ns + "Entry").Elements(Ns + "Element"))
276+
{
277+
var columnType = columnElement.Attribute("Type")?.Value;
278+
var columnName = columnElement.Attribute("Name")?.Value;
279+
switch (columnType)
280+
{
281+
case "SqlSimpleColumn":
282+
columnDdls.Add(TranslateSimpleColumn(columnElement, columnName, result));
283+
break;
284+
case "SqlComputedColumn":
285+
// Deferred to a follow-up phase. Recording on Skipped so
286+
// the caller has a per-column inventory of what's missing.
287+
result.Skipped.Add(new BacpacSkipped(
288+
"SqlComputedColumn",
289+
columnName,
290+
$"Computed column on '{qualifiedName}' deferred to a follow-up phase."));
291+
break;
292+
default:
293+
result.Skipped.Add(new BacpacSkipped(
294+
columnType ?? "<unknown>",
295+
columnName,
296+
$"Unrecognized column element on '{qualifiedName}'."));
297+
break;
298+
}
299+
}
300+
301+
if (columnDdls.Count == 0)
302+
throw new InvalidDataException($"bacpac: SqlTable '{qualifiedName}' has no recognized columns.");
303+
304+
var sql = $"CREATE TABLE {qualifiedName} ({string.Join(", ", columnDdls)});";
305+
using var command = connection.CreateCommand();
306+
#pragma warning disable CA2100 // bacpac content is caller-trusted; the loader is a translator, not an end-user input handler
307+
command.CommandText = sql;
308+
#pragma warning restore CA2100
309+
_ = command.ExecuteNonQuery();
310+
}
311+
312+
/// <summary>
313+
/// Builds the per-column DDL fragment for a single <c>SqlSimpleColumn</c>
314+
/// element. Output shape:
315+
/// <c>[col] type[(args)] [IDENTITY(seed, increment)] [ROWGUIDCOL] [NULL|NOT NULL]</c>.
316+
/// IDENTITY defaults to (1,1); ROWGUIDCOL only emits when
317+
/// <c>IsRowGuidColumn=True</c>; the explicit NULL/NOT NULL marker comes
318+
/// from <c>IsNullable</c> (default True per probe).
319+
/// </summary>
320+
private static string TranslateSimpleColumn(XElement columnElement, string? qualifiedColumnName, BacpacLoadResult result)
321+
{
322+
if (string.IsNullOrEmpty(qualifiedColumnName))
323+
throw new InvalidDataException("bacpac: SqlSimpleColumn missing Name attribute.");
324+
325+
// Name shape: [schema].[table].[column] — take the trailing bracketed
326+
// segment as the column leaf.
327+
var lastDot = qualifiedColumnName.LastIndexOf('.');
328+
var columnLeaf = lastDot < 0 ? qualifiedColumnName : qualifiedColumnName[(lastDot + 1)..];
329+
330+
var isNullable = ReadBoolProperty(columnElement, "IsNullable", defaultValue: true);
331+
var isIdentity = ReadBoolProperty(columnElement, "IsIdentity", defaultValue: false);
332+
var isRowGuid = ReadBoolProperty(columnElement, "IsRowGuidColumn", defaultValue: false);
333+
var identitySeed = ReadStringProperty(columnElement, "IdentitySeed");
334+
var identityIncrement = ReadStringProperty(columnElement, "IdentityIncrement");
335+
336+
var typeSpec = columnElement.Elements(Ns + "Relationship")
337+
.FirstOrDefault(r => r.Attribute("Name")?.Value == "TypeSpecifier")
338+
?.Elements(Ns + "Entry").Elements(Ns + "Element")
339+
.FirstOrDefault(e => e.Attribute("Type")?.Value is "SqlTypeSpecifier" or "SqlXmlTypeSpecifier")
340+
?? throw new InvalidDataException($"bacpac: column '{qualifiedColumnName}' missing TypeSpecifier.");
341+
342+
var typeDdl = TranslateTypeSpecifier(typeSpec);
343+
344+
var identityClause = isIdentity
345+
? $" IDENTITY({identitySeed ?? "1"}, {identityIncrement ?? "1"})"
346+
: "";
347+
if (isRowGuid)
348+
{
349+
// ROWGUIDCOL is metadata-only — it tells SQL Server which
350+
// uniqueidentifier column NEWID()/NEWSEQUENTIALID() defaults to
351+
// for $rowguid pseudo-column references. The simulator's CREATE
352+
// TABLE parser doesn't accept the clause; storage shape and DML
353+
// are unaffected by its absence (DEFAULT NEWID() arrives as a
354+
// separate SqlDefaultConstraint element). Record a Warning once
355+
// so the diagnostics report names the deferred surface.
356+
result.Warnings.Add($"ROWGUIDCOL clause on column '{qualifiedColumnName}' dropped — the simulator doesn't model this metadata annotation; storage behavior is unaffected.");
357+
}
358+
var nullability = isNullable ? " NULL" : " NOT NULL";
359+
return $"{columnLeaf} {typeDdl}{identityClause}{nullability}";
360+
}
361+
362+
/// <summary>
363+
/// Translates a <c>SqlTypeSpecifier</c> element to its T-SQL surface form.
364+
/// Handles bracketed built-ins (<c>[int]</c> / <c>[sys].[sysname]</c> /
365+
/// <c>[sys].[hierarchyid]</c>), user-defined alias types
366+
/// (<c>[dbo].[Name]</c>), and the four argument shapes: <c>Length</c>,
367+
/// <c>IsMax</c>, <c>Precision</c> alone, <c>Precision+Scale</c>, and
368+
/// <c>Scale</c> alone (datetime2 / time / datetimeoffset).
369+
/// </summary>
370+
private static string TranslateTypeSpecifier(XElement typeSpec)
371+
{
372+
var typeRef = typeSpec.Elements(Ns + "Relationship")
373+
.FirstOrDefault(r => r.Attribute("Name")?.Value == "Type")
374+
?.Elements(Ns + "Entry").Elements(Ns + "References").FirstOrDefault()
375+
?? throw new InvalidDataException("bacpac: TypeSpecifier missing Type reference.");
376+
377+
var typeName = typeRef.Attribute("Name")?.Value
378+
?? throw new InvalidDataException("bacpac: Type reference missing Name attribute.");
379+
var isBuiltin = typeRef.Attribute("ExternalSource")?.Value == "BuiltIns";
380+
381+
// Normalize the name into the form a CREATE TABLE column-type position
382+
// can use. Built-ins drop their brackets and any [sys]. prefix so
383+
// hierarchyid / sysname / etc. reach the parser as bare identifiers;
384+
// user-defined alias types keep their bracketed 2-part shape so the
385+
// simulator's Schema.AliasTypes lookup runs through the qualified path.
386+
var renderedTypeName = isBuiltin ? NormalizeBuiltinName(typeName) : typeName;
387+
388+
var isMax = ReadBoolProperty(typeSpec, "IsMax", defaultValue: false);
389+
var length = ReadStringProperty(typeSpec, "Length");
390+
var precision = ReadStringProperty(typeSpec, "Precision");
391+
var scale = ReadStringProperty(typeSpec, "Scale");
392+
393+
// Argument-shape cascade: IsMax wins, then Length, then
394+
// Precision[+Scale], then Scale (datetime2 / time / datetimeoffset),
395+
// then bare type. The simulator's parser accepts the trailing-paren
396+
// shape verbatim.
397+
return (isMax, length, precision, scale) switch
398+
{
399+
(true, _, _, _) => $"{renderedTypeName}(MAX)",
400+
(_, not null, _, _) => $"{renderedTypeName}({length})",
401+
(_, _, not null, not null) => string.Create(CultureInfo.InvariantCulture, $"{renderedTypeName}({precision}, {scale})"),
402+
(_, _, not null, _) => $"{renderedTypeName}({precision})",
403+
(_, _, _, not null) => $"{renderedTypeName}({scale})",
404+
_ => renderedTypeName,
405+
};
406+
}
407+
408+
/// <summary>
409+
/// Strips the brackets and any <c>[sys].</c> qualifier from a built-in
410+
/// type reference. <c>[int]</c> → <c>int</c>, <c>[sys].[hierarchyid]</c>
411+
/// → <c>hierarchyid</c>. The simulator's <see cref="SqlType.GetByName"/>
412+
/// keyword table doesn't include <c>sysname</c> (it's a sys-schema alias
413+
/// over nvarchar(128) rather than a parser keyword); the loader expands
414+
/// the alias inline so DACFx's <c>[sys].[sysname]</c> reaches the parser
415+
/// as <c>nvarchar(128)</c>. Surface fidelity loss: <c>sys.columns</c>
416+
/// reports nvarchar(128) instead of sysname for the affected columns —
417+
/// acceptable for the loader baseline (storage shape is identical).
418+
/// </summary>
419+
private static string NormalizeBuiltinName(string bracketedName)
420+
{
421+
var lastDot = bracketedName.LastIndexOf('.');
422+
var lastSegment = lastDot < 0 ? bracketedName : bracketedName[(lastDot + 1)..];
423+
var bare = lastSegment.Trim('[', ']');
424+
return string.Equals(bare, "sysname", StringComparison.OrdinalIgnoreCase) ? "nvarchar(128)" : bare;
425+
}
426+
427+
private static bool ReadBoolProperty(XElement element, string name, bool defaultValue) =>
428+
ReadStringProperty(element, name) is { } value ? IsTrue(value) : defaultValue;
429+
430+
private static string? ReadStringProperty(XElement element, string name) =>
431+
element.Elements(Ns + "Property")
432+
.FirstOrDefault(p => p.Attribute("Name")?.Value == name)
433+
?.Attribute("Value")?.Value;
200434
}

0 commit comments

Comments
 (0)