|
1 | 1 | using System.Data.Common; |
| 2 | +using System.Globalization; |
2 | 3 | using System.Xml; |
3 | 4 | using System.Xml.Linq; |
4 | 5 |
|
@@ -41,28 +42,58 @@ public static void Apply(Stream modelStream, Simulation simulation, BacpacLoadRe |
41 | 42 | var model = doc.Root?.Element(Ns + "Model") |
42 | 43 | ?? throw new InvalidDataException("bacpac: <DataSchemaModel><Model> root not found."); |
43 | 44 |
|
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) |
48 | 51 | { |
49 | 52 | var type = element.Attribute("Type")?.Value |
50 | 53 | ?? throw new InvalidDataException("bacpac: <Element> missing Type attribute."); |
51 | | - var name = element.Attribute("Name")?.Value; |
52 | | - |
53 | 54 | _ = result.ElementCounts.TryGetValue(type, out var current); |
54 | 55 | 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 | + } |
55 | 69 |
|
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)) |
57 | 77 | { |
58 | | - case "SqlDatabaseOptions": |
| 78 | + case ("SqlDatabaseOptions", true): |
59 | 79 | EmitDatabaseOptions(element, connection, result); |
60 | 80 | break; |
61 | | - case "SqlSchema": |
| 81 | + case ("SqlSchema", true): |
62 | 82 | EmitSchema(name, connection); |
63 | 83 | 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; |
64 | 94 | 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.")); |
66 | 97 | break; |
67 | 98 | } |
68 | 99 | } |
@@ -197,4 +228,207 @@ private static void EmitDatabaseOptions(XElement element, DbConnection connectio |
197 | 228 | "3" => "SIMPLE", |
198 | 229 | _ => "FULL", |
199 | 230 | }; |
| 231 | + |
| 232 | + /// <summary> |
| 233 | + /// Emits <c>CREATE TYPE [schema].[name] FROM <builtin>[(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 <expr> [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; |
200 | 434 | } |
0 commit comments