Skip to content

Commit 5433e91

Browse files
committed
BACPAC loader Phase I — hierarchyid binary wire decoder. AdventureWorks2025 row coverage rises from 759,833 → **760,167 / 760,167 (100%)**; every BCP shard loads cleanly. HR.Employee's 290 employees ship with their organization paths intact, Production.Document's 12 documents likewise. Cross-engine round-trip: SQL Server's hierarchyid varbinary payload → simulator's int[][] segment-array form via a brand-new HierarchyIdWireDecoder (SqlServerSimulator/Storage/Bacpac/HierarchyIdWireDecoder.cs).
**OrdPath binary encoding** (derived empirically by probing SQL Server 2025 across a synthetic `/N/` sweep over [0..300] + [-1..-50] + boundary values + the canonical AW hierarchyid byte ↔ path table for HR.Employee.OrganizationNode and Production.Document.DocumentNode). Each ordinal is a self-contained variable-bit sequence: prefix bits identify the range, value bits encode the position within range, a terminator bit `1` ends the ordinal. Multiple ordinals concatenate left-to-right, the byte stream pads with `0`s to a byte boundary, and the path ends when remaining bits are all zero. The four prefix codes the decoder ships: | Prefix | Bits | Range | Encoding length | Shape | |---|---|---|---|---| | `01` | 2 | [0..3] | 5 bits total | `01_XX_1` (2 value bits) | | `100` | 3 | [4..7] | 6 bits | `100_XX_1` (2 value bits) | | `101` | 3 | [8..15] | 7 bits | `101_XXX_1` (3 value bits) | | `110` | 3 | [16..79] | 12 bits | `110_AB_0_C_1_DEF_1` (6 value bits with structural-bit insertion at positions 6 = static `0` and 8 = static `1`) | The `110`-prefix encoding's structural bits at positions 6 and 8 are load-bearing for round-trip but carry no value information — they're likely artifacts of the original byte-aligned design but reproduce verbatim from probe data. Value composition: `value = (high2 << 4) | (midHigh << 3) | low3` for the 6-bit value, then `ordinal = 16 + value`. Probe-confirmed across [16..79] via /16/=`C110`, /17/=`C130`, /23/=`C1F0`, /24/=`C310`, /48/=`D110`, /79/=`DBF0` plus 32 sample points in between. AW's actual ordinal usage (probed via `SELECT OrganizationNode.ToString(), CAST(OrganizationNode AS varbinary(892))` against the reference instance's AdventureWorks2025 database) spans 0..22 with a single excursion to /6/ at the top level — all comfortably within the [0..79] envelope. **Deferred ordinal shapes** (raise `NotSupportedException` so the BCP file lands on Skipped if encountered): negative ordinals (`00` prefix, 1-byte single-byte encoding for [-1..-8], 2-byte for [-9..-24], etc. — encoding empirically reverse-engineerable but not exercised by AW), ordinals ≥ 80 (`1110` prefix and beyond — would extend the value-bit / structural-bit layout, also not exercised by AW), and the dotted-sub-ordinal form `/N.M/` (the encoding for `/1.5/` = `6460` differs from `/1/` followed by `/5/` — context-sensitive sub-ordinal encoding). All three combine into a follow-up bundle if a non-AW bacpac requires them. **`BitReader` ref struct** (private to `HierarchyIdWireDecoder`): `ReadBit()` / `ReadBits(N)` consume bits MSB-first within each byte; `HasMoreNonZeroBits()` peeks the remaining unread tail for any `1` to distinguish "more ordinals follow" from "padding tail". Ref-struct + readonly-where-applicable keeps the per-row decoding allocation-free for AW's 290-row HR.Employee hot path. **BcpRowReader integration**: new `EightBytePayload.HierarchyId` enum case dispatched from `DecodeColumn`'s 8-byte-prefix family alongside the MAX / xml / spatial cases (the 8-byte LE length-prefix wire shape is shared across the whole CLR-UDT + MAX family per the probe finding from Phase H). Decoded `int[][]` flows through `SqlValue.FromHierarchyId`; the simulator's existing hierarchyid storage encoder writes the segment-array LE form to row bytes, which round-trips back through `HierarchyIdSqlType.Decode` cleanly. Cross-engine CAST byte equality (the simulator's own `varbinary(hierarchyid)` matching SQL Server's documented binary form) is the symmetric companion work — the wire **decoder** ships, the wire **encoder** is the remaining piece for a future bundle. **Test coverage**: 45 new `HierarchyIdWireDecoderTests` in `SqlServerSimulator.Tests.Internal/Storage/`. Each `[DataRow]` pairs a probe-confirmed hex byte sequence with its canonical string form: range [0..3] (4 cases) + [4..7] (4) + [8..15] (8) + [16..79] (10 sample points) + multi-level paths (8 single-byte and multi-byte sample paths) + AW-derived ground-truth paths (8 cases from HR.Employee and Production.Document). Plus dedicated tests for: empty input → `/` root; large-ordinal input → `NotSupportedException`; negative-range input → `NotSupportedException`. Total decoder-test count: 45. **BacpacLoaderTests updates**: `Load_AW_Bcp_Data_Loads` row floor raised from `>= 759_500` to `== 760_167` (locked at full AW row count); `_DataFile` Skipped list locked at empty (zero failures). New `Load_AW_Hierarchyid_Column_Carries_Path` test exercises the end-to-end path: HumanResources.Employee row count = 290 with exactly 1 NULL OrganizationNode (the CEO at BusinessEntityID=1), employee 2's OrganizationNode.ToString() = `/1/`, Production.Document row count = 12 with the "Documents" root entry's DocumentNode.ToString() = `/`. The internal tests test count grows by 46 (45 decoder + 1 new loader) from 244 → 290. CLAUDE.md "BACPAC import" pointer rewrites to reflect 100% AW coverage + the new `HierarchyIdWireDecoder` deep-dive. `docs/claude/bacpac-prerequisites.md` Status section updates to "100% row coverage" + lifts the hierarchyid-blocked-rows caveat; the order-of-operations checklist marks the wire decoder shipped with the symmetric encoder (byte-identical CAST round-trip) called out as separate future work. The "BCP wire format" matrix's CLR-UDT row updates from "hierarchyid blocks via NotSupportedException" to "hierarchyid decodes via HierarchyIdWireDecoder".
1 parent f1be9d2 commit 5433e91

6 files changed

Lines changed: 330 additions & 23 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
164164
- **Touching `XmlSqlType` (singleton in `Storage/XmlType.cs`, SystemTypeId=241, IsLob=true), `XmlSchemaCollection` storage class + `Schema.XmlSchemaCollections` dict + `Database.AllocateXmlCollectionId` (seeds at 65536 per probe), `HeapColumn.XmlSchemaCollection` per-column binding, the `xml`-recognizing arms of `SqlType.ResolveSimpleKeyword` / `Precedence` / `SystemTypeId`, the `xml(name)` / `xml(CONTENT name)` / `xml(DOCUMENT name)` column-type parsing in `ParseOneColumnIntoLists` (via `PeekIsXmlSchemaArgument` / `ParseXmlSchemaCollectionArgument`), `Simulation.Xml.cs` partial (CREATE XML SCHEMA COLLECTION / DROP XML SCHEMA COLLECTION / CREATE [PRIMARY] XML INDEX / FOR PATH|VALUE|PROPERTY secondary form), the `Xml` contextual keyword + `Keyword.Primary` dispatch in `TryParseCreate`, `XmlIndex` / `XmlSecondaryIndexType` storage on `HeapTable.XmlIndexes`, the `XmlMethodCall` expression in `Parser/Expressions/XmlMethodCall.cs` (closed accept-list of `value` / `nodes` / `query` / `exist` / `modify`, parses cleanly, throws `NotSupportedException` at `Run`), or the `sys.xml_schema_collections` / `sys.xml_indexes` catalog views** → [`docs/claude/bacpac-prerequisites.md`](docs/claude/bacpac-prerequisites.md) (the "`xml` data type + XML schema collections + XML methods + XML indexes" section carries the implementation detail).
165165
- **Touching `GeographySqlType` / `GeometrySqlType` (singletons in `Storage/SpatialType.cs`, both inherit `SpatialSqlType : SqlType(SqlTypeCategory.String)`, SystemTypeId=240, UserTypeId=130/129, IsLob=true), `SqlValue.FromGeography` / `FromGeometry` factories + the spatial branches in `FromString`, the `geography` / `geometry`-recognizing arms of `SqlType.ResolveSimpleKeyword` / `Precedence` / `SystemTypeId` / `UserTypeId`, the `SpatialMethodCall` expression in `Parser/Expressions/SpatialMethodCall.cs` (broad ~70-name accept-list of OGC + Microsoft-extension methods; parses cleanly + throws `NotSupportedException` at `Run` except `.ToString()` returns the stored WKT), the `SpatialStaticCall` expression in `Parser/Expressions/SpatialStaticCall.cs` (`geography::Parse(wkt)` / `geography::STGeomFromText(wkt, srid)` / `geometry::Point(x, y, srid)` work; other static methods throw), the `::` dispatch for `geography`/`geometry` in `Expression.cs` alongside `hierarchyid::`, the `.ToString()` polymorphic delegation in `HierarchyIdMethodCall.Run` (spatial receivers return WKT through the spatial path), the `SpatialIndex` storage class + `SpatialIndexKind` enum on `HeapTable.SpatialIndexes`, the `Simulation.Spatial.cs` partial (CREATE SPATIAL INDEX with USING / BOUNDING_BOX / GRIDS / CELLS_PER_OBJECT parsing + default tessellation_scheme), the `Spatial` contextual keyword routing in `TryParseCreate`, or the `sys.spatial_indexes` (23-col probe-confirmed) / `sys.spatial_index_tessellations` (16-col probe-confirmed) / `sys.spatial_reference_systems` (6-col, empty seed) catalog views** → [`docs/claude/bacpac-prerequisites.md`](docs/claude/bacpac-prerequisites.md) (the "`geography` / `geometry` data types" section carries the implementation detail).
166166
- **Adding a new top-level statement parser or changing the dispatch loop's statement-separator rules**[`docs/claude/grammar.md`](docs/claude/grammar.md) + [`docs/claude/control-flow.md`](docs/claude/control-flow.md).
167-
- **Working on BACPAC import (`Simulation.FromBacpac(string, out BacpacLoadResult)` + Stream overload, internal until externally consumed) — the model.xml dispatcher in `SqlServerSimulator/Storage/Bacpac/ModelXmlReader.cs` (8 phases: DB options + schemas + UDDTs → tables → constraints → FKs → indexes → views → programmable objects → extended properties → BCP data), the `BacpacReader` + `BcpRowReader` data path, the `BacpacLoadResult.Skipped` + `ElementCounts` + `Warnings` + `TableColumnIsAlias` diagnostics carriers, the wire-format matrix (fixed-raw / 1-byte-prefix / 2-byte-prefix / 8-byte-prefix tiers — see [`docs/claude/bacpac-prerequisites.md`](docs/claude/bacpac-prerequisites.md) "BCP wire format" section)** → [`docs/claude/bacpac-prerequisites.md`](docs/claude/bacpac-prerequisites.md). Loader baseline ships (2026-05-15) with AdventureWorks2025 at 99.96% row coverage (759,833 / 760,167); only remaining gap is hierarchyid binary decoding for 4 BCP shards (~334 rows). MAX types (varchar(MAX) / nvarchar(MAX) / varbinary(MAX)) + xml + CLR-UDT family all use the simple inline 8-byte-prefix wire shape (NOT TDS-PLP chunked form); plain `bit` uses 1-byte length prefix regardless of nullability (probe-confirmed via Production.Document.FolderFlag, the sole non-UDDT bit column in AW). Geography rows load with `SpatialLocation = NULL` (WKB→WKT decoding deferred).
167+
- **Working on BACPAC import (`Simulation.FromBacpac(string, out BacpacLoadResult)` + Stream overload, internal until externally consumed) — the model.xml dispatcher in `SqlServerSimulator/Storage/Bacpac/ModelXmlReader.cs` (8 phases: DB options + schemas + UDDTs → tables → constraints → FKs → indexes → views → programmable objects → extended properties → BCP data), the `BacpacReader` + `BcpRowReader` + `HierarchyIdWireDecoder` data path, the `BacpacLoadResult.Skipped` + `ElementCounts` + `Warnings` + `TableColumnIsAlias` diagnostics carriers, the wire-format matrix (fixed-raw / 1-byte-prefix / 2-byte-prefix / 8-byte-prefix tiers — see [`docs/claude/bacpac-prerequisites.md`](docs/claude/bacpac-prerequisites.md) "BCP wire format" section)** → [`docs/claude/bacpac-prerequisites.md`](docs/claude/bacpac-prerequisites.md). Loader baseline ships (2026-05-15) with AdventureWorks2025 at **100% row coverage** (760,167 / 760,167; zero BCP-file failures). MAX types (varchar(MAX) / nvarchar(MAX) / varbinary(MAX)) + xml + CLR-UDT family all use the simple inline 8-byte-prefix wire shape (NOT TDS-PLP chunked form); plain `bit` uses 1-byte length prefix regardless of nullability (probe-confirmed via Production.Document.FolderFlag, the sole non-UDDT bit column in AW). Hierarchyid wire decoder covers AW's [0..79] positive-ordinal envelope via a 4-prefix Huffman-style decoder (`01` / `100` / `101` / `110`); negative ordinals + ordinals ≥ 80 + dotted sub-ordinals raise `NotSupportedException` for a follow-up bundle to extend. Geography rows load with `SpatialLocation = NULL` (WKB→WKT decoding deferred).
168168

169169
## Not modeled
170170

SqlServerSimulator.Tests.Internal/Storage/BacpacLoaderTests.cs

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -259,34 +259,56 @@ private static int QueryCount(SimulatedDbConnection connection, string sql)
259259
public void Load_AW_Bcp_Data_Loads()
260260
{
261261
var simulation = LoadAdventureWorks(out var diagnostics);
262-
// AW carries 760,167 rows across 1103 BCP shards. The wire-format
263-
// decoder lands 759,833 (99.96%); the remaining ~334 live in four
264-
// BCP shards that exercise hierarchyid columns (HR.Employee +
265-
// Prod.Document × 2 + Prod.ProductDocument). hierarchyid's
266-
// variable-bit Huffman-style binary encoding is deferred to a
267-
// dedicated follow-up bundle.
262+
// AW carries 760,167 rows across 1103 BCP shards. With the
263+
// hierarchyid wire decoder landing alongside the MAX / xml /
264+
// geography paths, every shard loads cleanly: 100% row coverage,
265+
// 0 BCP-file failures.
268266
var rowsLoaded = diagnostics.ElementCounts.GetValueOrDefault("_DataRows", 0);
269-
IsGreaterThanOrEqualTo(759_500, rowsLoaded, $"expected >= 759.5K rows, got {rowsLoaded}");
267+
AreEqual(760_167, rowsLoaded);
270268

271-
// Production.ProductCategory's 4 rows must land (no exotic types).
272269
using var connection = (SimulatedDbConnection)simulation.CreateDbConnection();
273270
connection.Open();
274-
AreEqual(4, QueryCount(connection, "SELECT COUNT(*) FROM Production.ProductCategory;"));
275271

272+
// Production.ProductCategory's 4 rows must land (no exotic types).
273+
AreEqual(4, QueryCount(connection, "SELECT COUNT(*) FROM Production.ProductCategory;"));
276274
// Sales.SpecialOffer's 16 rows must land (exercises smallmoney +
277275
// nullable int + uniqueidentifier + alias-typed dbo.Flag).
278276
AreEqual(16, QueryCount(connection, "SELECT COUNT(*) FROM Sales.SpecialOffer;"));
279277

280-
// All remaining BCP failures are hierarchyid-blocked. Lock the
281-
// expected set to surface any regression in MAX/xml/geography paths
282-
// (those types loaded fine for AW after this bundle).
278+
// Lock the zero-failures state — any regression in MAX / xml /
279+
// geography / hierarchyid decoding surfaces here.
283280
var dataFileFailures = diagnostics.Skipped.Where(s => s.ElementType == "_DataFile").ToList();
284-
HasCount(4, dataFileFailures);
285-
foreach (var entry in dataFileFailures)
286-
{
287-
IsTrue(entry.Reason.Contains("hierarchyid", StringComparison.OrdinalIgnoreCase),
288-
$"unexpected BCP failure (only hierarchyid remains): {entry.ElementName}: {entry.Reason}");
289-
}
281+
IsEmpty(dataFileFailures);
282+
}
283+
284+
[TestMethod]
285+
public void Load_AW_Hierarchyid_Column_Carries_Path()
286+
{
287+
var simulation = LoadAdventureWorks(out _);
288+
using var connection = (SimulatedDbConnection)simulation.CreateDbConnection();
289+
connection.Open();
290+
291+
// HR.Employee carries 290 rows; OrganizationNode is hierarchyid
292+
// NULL for the CEO (BusinessEntityID=1) and a path for everyone
293+
// else. Verify the full count + a representative path.
294+
AreEqual(290, QueryCount(connection, "SELECT COUNT(*) FROM HumanResources.Employee;"));
295+
AreEqual(1, QueryCount(connection, "SELECT COUNT(*) FROM HumanResources.Employee WHERE OrganizationNode IS NULL;"));
296+
297+
using var command = connection.CreateCommand();
298+
command.CommandText = "SELECT OrganizationNode.ToString() FROM HumanResources.Employee WHERE BusinessEntityID = 2;";
299+
using var reader = command.ExecuteReader();
300+
IsTrue(reader.Read());
301+
AreEqual("/1/", reader.GetString(0));
302+
303+
// Production.Document.DocumentNode loads (12 rows in this bacpac;
304+
// probe-confirmed). The root row (DocumentNode = '/') has an empty
305+
// 8-byte payload.
306+
AreEqual(12, QueryCount(connection, "SELECT COUNT(*) FROM Production.Document;"));
307+
using var rootCmd = connection.CreateCommand();
308+
rootCmd.CommandText = "SELECT DocumentNode.ToString() FROM Production.Document WHERE Title = 'Documents';";
309+
using var rootReader = rootCmd.ExecuteReader();
310+
IsTrue(rootReader.Read());
311+
AreEqual("/", rootReader.GetString(0));
290312
}
291313

292314
[TestMethod]
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
using SqlServerSimulator.Storage.Bacpac;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator.Storage;
5+
6+
/// <summary>
7+
/// Decoder fidelity tests for <see cref="HierarchyIdWireDecoder"/>. Ground
8+
/// truth via a synthetic + AdventureWorks2025-derived probe sweep against
9+
/// SQL Server 2025 on 2026-05-15 (Kardax7 reference).
10+
/// </summary>
11+
[TestClass]
12+
public sealed class HierarchyIdWireDecoderTests
13+
{
14+
[TestMethod]
15+
// Range [0..3] — `01` prefix, 2 value bits, 5-bit encoding.
16+
[DataRow("48", "/0/")]
17+
[DataRow("58", "/1/")]
18+
[DataRow("68", "/2/")]
19+
[DataRow("78", "/3/")]
20+
// Range [4..7] — `100` prefix, 2 value bits, 6-bit encoding.
21+
[DataRow("84", "/4/")]
22+
[DataRow("8C", "/5/")]
23+
[DataRow("94", "/6/")]
24+
[DataRow("9C", "/7/")]
25+
// Range [8..15] — `101` prefix, 3 value bits, 7-bit encoding.
26+
[DataRow("A2", "/8/")]
27+
[DataRow("A6", "/9/")]
28+
[DataRow("AA", "/10/")]
29+
[DataRow("AE", "/11/")]
30+
[DataRow("B2", "/12/")]
31+
[DataRow("B6", "/13/")]
32+
[DataRow("BA", "/14/")]
33+
[DataRow("BE", "/15/")]
34+
// Range [16..79] — `110` prefix, 6 value bits with structural-bit
35+
// insertion, 12-bit encoding.
36+
[DataRow("C110", "/16/")]
37+
[DataRow("C130", "/17/")]
38+
[DataRow("C170", "/19/")]
39+
[DataRow("C1F0", "/23/")]
40+
[DataRow("C310", "/24/")]
41+
[DataRow("C9B0", "/37/")]
42+
[DataRow("CBF0", "/47/")]
43+
[DataRow("D110", "/48/")]
44+
[DataRow("D9F0", "/71/")]
45+
[DataRow("DBF0", "/79/")]
46+
// Multi-level paths — encodings concatenate, then pad to byte boundary.
47+
[DataRow("5AC0", "/1/1/")]
48+
[DataRow("5B40", "/1/2/")]
49+
[DataRow("6AC0", "/2/1/")]
50+
[DataRow("5AD6", "/1/1/1/")]
51+
[DataRow("5ADA", "/1/1/2/")]
52+
[DataRow("5B5E", "/1/2/3/")]
53+
[DataRow("5B5F08", "/1/2/3/4/")]
54+
[DataRow("5B5F0C60", "/1/2/3/4/5/")]
55+
// AW-derived ground truth (HR.Employee subset).
56+
[DataRow("5AE1", "/1/1/4/")]
57+
[DataRow("5AE158", "/1/1/4/1/")]
58+
[DataRow("957540", "/6/1/10/")]
59+
[DataRow("9574C0", "/6/1/9/")]
60+
[DataRow("957440", "/6/1/8/")]
61+
[DataRow("7AD6", "/3/1/1/")]
62+
[DataRow("7AD6B0", "/3/1/1/1/")]
63+
[DataRow("95EF", "/6/3/3/")]
64+
public void Decode_KnownEncoding_RoundTripsToCanonicalString(string hex, string expectedPath)
65+
{
66+
var bytes = HexToBytes(hex);
67+
var path = HierarchyIdWireDecoder.Decode(bytes);
68+
var actual = HierarchyIdSqlType.PathToString(path);
69+
AreEqual(expectedPath, actual);
70+
}
71+
72+
[TestMethod]
73+
public void Decode_EmptyBytes_ReturnsRoot()
74+
{
75+
var path = HierarchyIdWireDecoder.Decode([]);
76+
AreEqual("/", HierarchyIdSqlType.PathToString(path));
77+
}
78+
79+
[TestMethod]
80+
public void Decode_LargeOrdinal_RaisesNotSupported()
81+
{
82+
// /80/ uses the `1110` prefix (range [80..207]) which the decoder
83+
// doesn't yet handle. Surfaces via NotSupportedException so the BCP
84+
// loader's per-file try/catch can route it to Skipped.
85+
var bytes = HexToBytes("E00440");
86+
_ = ThrowsExactly<NotSupportedException>(() => HierarchyIdWireDecoder.Decode(bytes));
87+
}
88+
89+
[TestMethod]
90+
public void Decode_NegativeOrdinal_RaisesNotSupported()
91+
{
92+
// /-1/ uses the `00` prefix range — also deferred.
93+
var bytes = HexToBytes("3F80");
94+
_ = ThrowsExactly<NotSupportedException>(() => HierarchyIdWireDecoder.Decode(bytes));
95+
}
96+
97+
private static byte[] HexToBytes(string hex)
98+
{
99+
var bytes = new byte[hex.Length / 2];
100+
for (var i = 0; i < bytes.Length; i++)
101+
bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
102+
return bytes;
103+
}
104+
}

SqlServerSimulator/Storage/Bacpac/BcpRowReader.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ private static SqlValue DecodeColumn(PushbackStream stream, HeapColumn column, b
112112
// chunk markers — probe-confirmed against AW on 2026-05-15).
113113
if (type is XmlSqlType) return ReadEightBytePrefixed(stream, type, EightBytePayload.Xml);
114114
if (type is GeographySqlType or GeometrySqlType) return ReadEightBytePrefixed(stream, type, EightBytePayload.SpatialDeferToNull);
115+
if (type is HierarchyIdSqlType) return ReadEightBytePrefixed(stream, type, EightBytePayload.HierarchyId);
115116
if (type is VarcharSqlType vc && vc.length == -1) return ReadEightBytePrefixed(stream, type, EightBytePayload.VarcharMax);
116117
if (type is NVarcharSqlType nv && nv.length == -1) return ReadEightBytePrefixed(stream, type, EightBytePayload.NVarcharMax);
117118
if (type is VarbinarySqlType vb && vb.length == -1) return ReadEightBytePrefixed(stream, type, EightBytePayload.VarbinaryMax);
@@ -147,6 +148,16 @@ private enum EightBytePayload
147148
/// bundle; this lets the row load without breaking column count.
148149
/// </summary>
149150
SpatialDeferToNull,
151+
/// <summary>
152+
/// Hierarchyid: read the length + N bytes, decode the OrdPath
153+
/// binary form via <see cref="HierarchyIdWireDecoder.Decode"/>, and
154+
/// return a hierarchyid <see cref="SqlValue"/>. The decoder covers
155+
/// the [0..79] ordinal range and raises
156+
/// <see cref="NotSupportedException"/> for the negative range +
157+
/// ordinals &gt;= 80; both bubble up to the per-file try/catch in
158+
/// <see cref="BacpacReader"/> so the whole file lands on Skipped.
159+
/// </summary>
160+
HierarchyId,
150161
}
151162

152163
/// <summary>
@@ -176,6 +187,7 @@ private static SqlValue ReadEightBytePrefixed(PushbackStream stream, SqlType typ
176187
EightBytePayload.VarbinaryMax => SqlValue.FromVarbinary(data),
177188
EightBytePayload.Xml => SqlValue.FromXml(Encoding.Unicode.GetString(data)),
178189
EightBytePayload.SpatialDeferToNull => SqlValue.Null(type),
190+
EightBytePayload.HierarchyId => SqlValue.FromHierarchyId(HierarchyIdWireDecoder.Decode(data)),
179191
_ => throw new InvalidOperationException($"unknown EightBytePayload {kind}"),
180192
};
181193
}

0 commit comments

Comments
 (0)