Skip to content

Commit 20255ed

Browse files
committed
Bacpac round-trips XML schema collections: the loader dispatches SqlXmlSchemaCollection elements and binds typed-xml columns from SqlXmlTypeSpecifier's XmlSchemaCollection relationship, and sys.columns.xml_collection_id populates from the column's bound collection instead of constant 0.
1 parent eec1b8c commit 20255ed

8 files changed

Lines changed: 203 additions & 14 deletions

File tree

SqlServerSimulator.Tests/Bacpac/BacpacBuilder.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ public sealed partial class BacpacBuilder
4141
private readonly List<PermissionDef> _permissions = [];
4242
private readonly List<ViewIndexDef> _viewIndexes = [];
4343
private readonly List<UserDefinedDataTypeDef> _uddts = [];
44+
private readonly List<XmlSchemaCollectionDef> _xmlSchemaCollections = [];
4445
private readonly List<(string ElementType, string Name)> _silentlySkipped = [];
4546
private readonly List<(string ElementType, string Name)> _unknownElements = [];
4647
private string? _dspName;
@@ -243,6 +244,22 @@ public BacpacBuilder UserDefinedDataType(string schemaName, string typeName, str
243244
return this;
244245
}
245246

247+
/// <summary>
248+
/// Adds a <c>CREATE XML SCHEMA COLLECTION [schema].[name] AS N'…'</c>
249+
/// emission. <paramref name="xsdText"/> is the raw XSD source (no <c>N'…'</c>
250+
/// wrapping) — the builder wraps it into the complete T-SQL string literal
251+
/// DACFx stores in the <c>SchemaExpression</c> property's CDATA body (N
252+
/// prefix, quotes, doubled embedded quotes). Reference the collection from a
253+
/// typed-xml column via <see cref="TableBuilder.Column"/>'s
254+
/// <c>xmlSchemaCollection</c> argument.
255+
/// </summary>
256+
public BacpacBuilder XmlSchemaCollection(string schemaName, string collectionName, string xsdText)
257+
{
258+
_ = _schemas.Add(schemaName);
259+
_xmlSchemaCollections.Add(new XmlSchemaCollectionDef(schemaName, collectionName, xsdText));
260+
return this;
261+
}
262+
246263
/// <summary>
247264
/// Emits a <c>SqlPartitionFunction</c> element. The loader treats this
248265
/// as a silent no-op (filegroup-mapping metadata with no semantic effect
@@ -415,6 +432,9 @@ private void WriteModelXml(ZipArchive archive)
415432
foreach (var uddt in _uddts)
416433
model.Add(BuildUddtElement(ns, uddt));
417434

435+
foreach (var xsc in _xmlSchemaCollections)
436+
model.Add(BuildXmlSchemaCollectionElement(ns, xsc));
437+
418438
foreach (var seq in _sequences)
419439
model.Add(BuildSequenceElement(ns, seq));
420440

@@ -1077,6 +1097,8 @@ internal sealed record ViewIndexDef(string ViewSchema, string ViewName, string I
10771097

10781098
internal sealed record UserDefinedDataTypeDef(string SchemaName, string TypeName, string BaseType, bool Nullable);
10791099

1100+
internal sealed record XmlSchemaCollectionDef(string SchemaName, string CollectionName, string XsdText);
1101+
10801102
sealed partial class BacpacBuilder
10811103
{
10821104
private static XElement BuildUddtElement(XNamespace ns, UserDefinedDataTypeDef uddt)
@@ -1131,6 +1153,21 @@ private static (string Base, string? Length, string? Precision, string? Scale, b
11311153
return (baseName, null, args[..commaIndex].Trim(), args[(commaIndex + 1)..].Trim(), false);
11321154
}
11331155

1156+
private static XElement BuildXmlSchemaCollectionElement(XNamespace ns, XmlSchemaCollectionDef xsc)
1157+
{
1158+
// DACFx stores the SchemaExpression as a complete N'…' string literal
1159+
// in the property's CDATA body — N prefix, surrounding quotes, doubled
1160+
// embedded quotes. Mirror that so the loader forwards it verbatim into
1161+
// the AS clause.
1162+
var literal = "N'" + xsc.XsdText.Replace("'", "''", StringComparison.Ordinal) + "'";
1163+
return new XElement(ns + "Element",
1164+
new XAttribute("Type", "SqlXmlSchemaCollection"),
1165+
new XAttribute("Name", $"[{xsc.SchemaName}].[{xsc.CollectionName}]"),
1166+
new XElement(ns + "Property",
1167+
new XAttribute("Name", "SchemaExpression"),
1168+
new XElement(ns + "Value", new XCData(literal))));
1169+
}
1170+
11341171
private static XElement BuildSequenceElement(XNamespace ns, SequenceDef seq)
11351172
{
11361173
var element = new XElement(ns + "Element",

SqlServerSimulator.Tests/Bacpac/BacpacLoaderTests.cs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1806,4 +1806,57 @@ public void GeographyPolygon_DecodesToPolygonWkt()
18061806
Contains("-122", wkt);
18071807
Contains("47", wkt);
18081808
}
1809+
1810+
[TestMethod]
1811+
public void XmlSchemaCollection_And_TypedXmlColumn_RoundTrip()
1812+
{
1813+
// A SqlXmlSchemaCollection element creates the collection in phase 1;
1814+
// a typed-xml column (SqlXmlTypeSpecifier + XmlSchemaCollection
1815+
// relationship) binds it so sys.columns.xml_collection_id joins back —
1816+
// the gap that made AW's re-exported bacpac lose typed xml.
1817+
const string xsd = "<xsd:schema xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\"><xsd:element name=\"Note\" type=\"xsd:string\" /></xsd:schema>";
1818+
using var bacpac = BacpacBuilder.Create()
1819+
.XmlSchemaCollection("dbo", "NoteSchema", xsd)
1820+
.Table("dbo", "Doc", t => t
1821+
.Column("Id", "int")
1822+
.Column("Body", "xml", nullable: true, xmlSchemaCollection: "[dbo].[NoteSchema]"))
1823+
.Build();
1824+
1825+
var sim = new Simulation();
1826+
sim.ImportBacpac(bacpac, out var diag);
1827+
IsEmpty(diag.Skipped);
1828+
AreEqual(1, diag.ElementCounts["SqlXmlSchemaCollection"]);
1829+
// The collection exists in the catalog.
1830+
AreEqual("NoteSchema", sim.ExecuteScalar("SELECT name FROM sys.xml_schema_collections WHERE name = 'NoteSchema';"));
1831+
// The typed column's xml_collection_id resolves back to the collection.
1832+
AreEqual("NoteSchema", sim.ExecuteScalar("""
1833+
SELECT x.name
1834+
FROM sys.columns c
1835+
JOIN sys.tables t ON c.object_id = t.object_id
1836+
JOIN sys.xml_schema_collections x ON c.xml_collection_id = x.xml_collection_id
1837+
WHERE t.name = 'Doc' AND c.name = 'Body';
1838+
"""));
1839+
}
1840+
1841+
[TestMethod]
1842+
public void UntypedXmlColumn_LoadsWith_ZeroXmlCollectionId()
1843+
{
1844+
// An untyped xml column carries no XmlSchemaCollection relationship;
1845+
// sys.columns.xml_collection_id reports the non-nullable 0 default.
1846+
using var bacpac = BacpacBuilder.Create()
1847+
.Table("dbo", "Doc", t => t
1848+
.Column("Id", "int")
1849+
.Column("Body", "xml", nullable: true))
1850+
.Build();
1851+
1852+
var sim = new Simulation();
1853+
sim.ImportBacpac(bacpac, out var diag);
1854+
IsEmpty(diag.Skipped);
1855+
AreEqual(0, sim.ExecuteScalar("""
1856+
SELECT c.xml_collection_id
1857+
FROM sys.columns c
1858+
JOIN sys.tables t ON c.object_id = t.object_id
1859+
WHERE t.name = 'Doc' AND c.name = 'Body';
1860+
"""));
1861+
}
18091862
}

SqlServerSimulator.Tests/Bacpac/TableBuilder.cs

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ internal TableBuilder(string schemaName, string tableName)
4646
/// IsNullable=True default, but for test setup NOT NULL is more
4747
/// useful so explicit assertions on NULL-handling become opt-in.
4848
/// </summary>
49-
public TableBuilder Column(string name, string sqlType, bool nullable = false, bool identity = false, int identitySeed = 1, int identityIncrement = 1, PeriodColumnKind periodKind = PeriodColumnKind.None, string? collation = null, bool rowGuidCol = false)
49+
public TableBuilder Column(string name, string sqlType, bool nullable = false, bool identity = false, int identitySeed = 1, int identityIncrement = 1, PeriodColumnKind periodKind = PeriodColumnKind.None, string? collation = null, bool rowGuidCol = false, string? xmlSchemaCollection = null)
5050
{
51-
_columns.Add(new ColumnDef(name, sqlType, nullable, identity, identitySeed, identityIncrement, periodKind, collation, rowGuidCol));
51+
_columns.Add(new ColumnDef(name, sqlType, nullable, identity, identitySeed, identityIncrement, periodKind, collation, rowGuidCol, xmlSchemaCollection));
5252
_order.Add((Computed: false, _columns.Count - 1));
5353
return this;
5454
}
@@ -462,12 +462,36 @@ private XElement ColumnElement(XNamespace ns, ColumnDef column)
462462
if (column.RowGuidCol)
463463
element.Add(PropertyElement(ns, "IsRowGuidColumn", "True"));
464464

465+
var typeSpecifier = column.XmlSchemaCollectionRef is { } collectionRef
466+
? XmlTypeSpecifierElement(ns, collectionRef)
467+
: TypeSpecifierElement(ns, column.SqlType);
465468
element.Add(new XElement(ns + "Relationship",
466469
new XAttribute("Name", "TypeSpecifier"),
467-
new XElement(ns + "Entry", TypeSpecifierElement(ns, column.SqlType))));
470+
new XElement(ns + "Entry", typeSpecifier)));
468471
return element;
469472
}
470473

474+
/// <summary>
475+
/// Emits a <c>SqlXmlTypeSpecifier</c> bound to a schema collection — the
476+
/// shape DACFx uses for a typed-xml column (<c>xml([schema].[collection])</c>).
477+
/// A <c>Type</c> relationship to the <c>[xml]</c> built-in plus an
478+
/// <c>XmlSchemaCollection</c> relationship to the collection reference.
479+
/// </summary>
480+
private static XElement XmlTypeSpecifierElement(XNamespace ns, string collectionRef) =>
481+
new(ns + "Element",
482+
new XAttribute("Type", "SqlXmlTypeSpecifier"),
483+
new XElement(ns + "Relationship",
484+
new XAttribute("Name", "Type"),
485+
new XElement(ns + "Entry",
486+
new XElement(ns + "References",
487+
new XAttribute("Name", "[xml]"),
488+
new XAttribute("ExternalSource", "BuiltIns")))),
489+
new XElement(ns + "Relationship",
490+
new XAttribute("Name", "XmlSchemaCollection"),
491+
new XElement(ns + "Entry",
492+
new XElement(ns + "References",
493+
new XAttribute("Name", collectionRef)))));
494+
471495
private static XElement TypeSpecifierElement(XNamespace ns, string sqlType)
472496
{
473497
// Bracketed 2-part name → UDDT reference (no ExternalSource, no
@@ -550,7 +574,7 @@ public enum PeriodColumnKind
550574
}
551575

552576
/// <summary>Column metadata captured by <see cref="TableBuilder.Column"/>.</summary>
553-
internal readonly record struct ColumnDef(string Name, string SqlType, bool Nullable, bool Identity = false, int IdentitySeed = 1, int IdentityIncrement = 1, PeriodColumnKind PeriodKind = PeriodColumnKind.None, string? Collation = null, bool RowGuidCol = false);
577+
internal readonly record struct ColumnDef(string Name, string SqlType, bool Nullable, bool Identity = false, int IdentitySeed = 1, int IdentityIncrement = 1, PeriodColumnKind PeriodKind = PeriodColumnKind.None, string? Collation = null, bool RowGuidCol = false, string? XmlSchemaCollectionRef = null);
554578

555579
/// <summary>Base for constraint declarations accumulated on a table.</summary>
556580
internal abstract record ConstraintDef(string Name);

SqlServerSimulator.Tests/XmlTests.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,24 @@ public void XmlColumn_WithSchemaCollection_Binds()
8383
AreEqual("<hi/>", sim.ExecuteScalar("select body from dbo.doc"));
8484
}
8585

86+
[TestMethod]
87+
public void TypedXmlColumn_SysColumnsXmlCollectionId_JoinsBackToCollection()
88+
{
89+
var sim = new Simulation();
90+
_ = sim.ExecuteNonQuery("create xml schema collection xsc1 as N'<xsd:schema/>'");
91+
_ = sim.ExecuteNonQuery("create table dbo.doc (id int, body xml(xsc1))");
92+
// The typed column's sys.columns.xml_collection_id resolves to the
93+
// collection through sys.xml_schema_collections (the join DacFx's
94+
// reverse-engineering query relies on).
95+
AreEqual("xsc1", sim.ExecuteScalar("""
96+
select x.name from sys.columns c
97+
join sys.xml_schema_collections x on c.xml_collection_id = x.xml_collection_id
98+
where c.object_id = object_id('dbo.doc') and c.name = 'body'
99+
"""));
100+
// The untyped id column reports the non-nullable 0 default.
101+
AreEqual(0, sim.ExecuteScalar("select xml_collection_id from sys.columns where object_id = object_id('dbo.doc') and name = 'id'"));
102+
}
103+
86104
[TestMethod]
87105
public void XmlColumn_WithContentDiscriminator_Parses()
88106
{

SqlServerSimulator/BuiltInResources.CoreObjects.cs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -306,10 +306,11 @@ HeapColumn[] ColumnsShape() =>
306306
new("is_sparse", SqlType.Bit, null, true),
307307
// Probe-confirmed constants (SQL Server 2025, 2026-07-15) that SMO's
308308
// SSMS Object-Explorer column / index / key sub-node queries read
309-
// off sys.all_columns: no XML documents, XML-schema collections,
310-
// column sets, dropped ledger columns, or vector columns are
311-
// modeled, so is_xml_document / is_column_set / is_dropped_ledger_column
312-
// are 0, xml_collection_id is 0, and the vector_* pair is NULL.
309+
// off sys.all_columns: no XML documents, column sets, dropped ledger
310+
// columns, or vector columns are modeled, so is_xml_document /
311+
// is_column_set / is_dropped_ledger_column are 0 and the vector_*
312+
// pair is NULL. xml_collection_id carries the bound schema
313+
// collection's id for a typed-xml column (0 when untyped / non-xml).
313314
new("is_xml_document", SqlType.Bit, null, false),
314315
new("xml_collection_id", SqlType.Int32, null, false),
315316
new("is_column_set", SqlType.Bit, null, true),
@@ -370,6 +371,14 @@ SqlValue AnsiPaddedFor(HeapColumn c) =>
370371
c.Type.SystemTypeId is 165 or 167 or 173 or 175 or 231 or 239 ? trueBit : falseBit;
371372
SqlValue DefaultObjectIdFor(HeapColumn c) =>
372373
c.DefaultConstraint is { } df ? SqlValue.FromInt32(df.ObjectId) : zeroInt;
374+
// xml_collection_id is the id of the schema collection a typed-xml
375+
// column binds (0 for untyped xml and every non-xml column — real SQL
376+
// Server's non-nullable-int convention for this column). The binding
377+
// lives on HeapColumn.XmlSchemaCollection, set by the xml(collection)
378+
// column DDL. View / inline-TVF output columns never carry a binding,
379+
// so their rows keep the 0 default below.
380+
SqlValue XmlCollectionIdFor(HeapColumn c) =>
381+
c.XmlSchemaCollection is { } coll ? SqlValue.FromInt32(coll.Id) : zeroInt;
373382
// The per-database default collation flows from CurrentDatabase.
374383
// The captured defaultCollation arg is a legacy fallback; today the
375384
// active database's CollationName drives the value, with per-column
@@ -404,7 +413,7 @@ SqlValue CollationFor(HeapColumn c) =>
404413
CollationFor(col),
405414
falseBit,
406415
falseBit,
407-
zeroInt,
416+
XmlCollectionIdFor(col),
408417
falseBit,
409418
falseBit,
410419
nullInt,
@@ -545,7 +554,7 @@ SqlValue CollationFor(HeapColumn c) =>
545554
CollationFor(col),
546555
falseBit,
547556
falseBit,
548-
zeroInt,
557+
XmlCollectionIdFor(col),
549558
falseBit,
550559
falseBit,
551560
nullInt,

SqlServerSimulator/Storage/Bacpac/ModelXmlReader.cs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ private static void RunPhase(List<XElement> elements, DbConnection connection, B
168168
("SqlSequence", 1) => Run(() => EmitSequence(element, name, connection)),
169169
("SqlRole", 1) => Run(() => EmitRole(element, name, connection)),
170170
("SqlTableType", 1) => Run(() => EmitTableType(element, name, connection, result)),
171+
("SqlXmlSchemaCollection", 1) => Run(() => EmitXmlSchemaCollection(element, name, connection)),
171172
("SqlTable", 2) => Run(() => EmitTable(element, name, connection, result, deferredComputedTables)),
172173
("SqlPrimaryKeyConstraint", 3) => Run(() => EmitKeyConstraint(element, name, connection, isPrimary: true)),
173174
("SqlUniqueConstraint", 3) => Run(() => EmitKeyConstraint(element, name, connection, isPrimary: false)),
@@ -251,7 +252,8 @@ private static bool Run(Action action)
251252
/// </summary>
252253
private static bool IsHandledByAnotherPhase(string type) => type
253254
is "SqlDatabaseOptions" or "SqlSchema" or "SqlUserDefinedDataType"
254-
or "SqlSequence" or "SqlRole" or "SqlTableType" or "SqlFilegroup"
255+
or "SqlSequence" or "SqlRole" or "SqlTableType" or "SqlXmlSchemaCollection"
256+
or "SqlFilegroup"
255257
or "SqlPartitionFunction" or "SqlPartitionScheme" or "SqlColumnStoreIndex"
256258
or "SqlTable"
257259
or "SqlPrimaryKeyConstraint" or "SqlUniqueConstraint"
@@ -541,6 +543,32 @@ private static void EmitTableType(XElement element, string? qualifiedName, DbCon
541543
_ = command.ExecuteNonQuery();
542544
}
543545

546+
/// <summary>
547+
/// Emits <c>CREATE XML SCHEMA COLLECTION [schema].[name] AS &lt;literal&gt;</c>
548+
/// for a <c>SqlXmlSchemaCollection</c> element. The <c>SchemaExpression</c>
549+
/// property carries a complete T-SQL string literal in its CDATA body — the
550+
/// <c>N'…'</c> prefix, the surrounding quotes, and any doubled embedded
551+
/// quotes are all present — so the loader forwards it verbatim into the
552+
/// <c>AS</c> clause. Emitted in phase 1 alongside the other type-namespace
553+
/// objects (alias types / table types) so any table whose column binds
554+
/// <c>xml([schema].[collection])</c> resolves the reference when the table
555+
/// is created in phase 2.
556+
/// </summary>
557+
private static void EmitXmlSchemaCollection(XElement element, string? qualifiedName, DbConnection connection)
558+
{
559+
if (string.IsNullOrEmpty(qualifiedName))
560+
throw new InvalidDataException("bacpac: SqlXmlSchemaCollection element missing Name attribute.");
561+
562+
var schemaExpression = ReadScriptProperty(element, "SchemaExpression")
563+
?? throw new InvalidDataException($"bacpac: SqlXmlSchemaCollection '{qualifiedName}' missing SchemaExpression.");
564+
565+
using var command = connection.CreateCommand();
566+
#pragma warning disable CA2100 // bacpac content is caller-trusted; the loader is a translator, not an end-user input handler
567+
command.CommandText = $"CREATE XML SCHEMA COLLECTION {qualifiedName} AS {schemaExpression};";
568+
#pragma warning restore CA2100
569+
_ = command.ExecuteNonQuery();
570+
}
571+
544572
/// <summary>
545573
/// Emits <c>CREATE ROLE [name] [AUTHORIZATION owner]</c> for a
546574
/// <c>SqlRole</c> element. The <c>Authorizer</c> relationship points at
@@ -908,6 +936,18 @@ private static string TranslateTypeSpecifier(XElement typeSpec)
908936
// simulator's Schema.AliasTypes lookup runs through the qualified path.
909937
var renderedTypeName = isBuiltin ? NormalizeBuiltinName(typeName) : typeName;
910938

939+
// Typed-xml column: a SqlXmlTypeSpecifier carries an XmlSchemaCollection
940+
// relationship naming the [schema].[collection] the xml value is bound
941+
// to. Emit xml([schema].[collection]) so the column records the binding
942+
// (surfaced through sys.columns.xml_collection_id) — the collection was
943+
// created in phase 1, ahead of this table. Only the default CONTENT
944+
// facet is handled: AW carries no XmlStyle/DOCUMENT property (probe:
945+
// zero occurrences), so a DOCUMENT facet would need a separate property
946+
// read and CONTENT/DOCUMENT emission here.
947+
var xmlCollectionRef = ReadSingleReference(typeSpec, "XmlSchemaCollection");
948+
if (xmlCollectionRef is not null)
949+
return $"{renderedTypeName}({xmlCollectionRef})";
950+
911951
var isMax = ReadBoolProperty(typeSpec, "IsMax", defaultValue: false);
912952
var length = ReadStringProperty(typeSpec, "Length");
913953
var precision = ReadStringProperty(typeSpec, "Precision");

docs/claude/backlog.md

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)