Skip to content

Commit f91a71c

Browse files
committed
hierarchyid AW-minimum-viable surface — fourth bacpac prerequisite. Ships the static factories (hierarchyid::Parse(str) / hierarchyid::GetRoot()), the five instance methods AW exercises (.GetLevel()smallint, .GetAncestor(n), .GetDescendant(child1, child2), .IsDescendantOf(other)bit, .ToString()nvarchar(4000)), lexicographic path comparison via ORDER BY, NULL handling, and Msg 6522 verbatim wording on invalid input. Unblocks [HumanResources].[Employee].[OrganizationNode] load.
**Storage**: new `HierarchyIdSqlType` (`Storage/HierarchyIdType.cs`) — variable-length `SqlTypeCategory.Other` type, system_type_id 240, user_type_id 128, max_length 892 (probe-confirmed against SQL Server 2025 on 2026-05-14). Internal representation is `int[][]` — outer array is the segment sequence, inner arrays are the dot-joined label tuples (so `/1/2.5/3/` is `[[1], [2, 5], [3]]`). `SqlValue.FromHierarchyId(int[][])` boxes the array into the reference slot; `SqlValue.AsHierarchyId` returns it. The encoder/decoder format on disk and via `CAST AS varbinary` is simulator-native (2-byte LE segment count, then per segment 2-byte LE label count + each label as 4-byte LE int32) — **not** SQL Server's documented variable-bit ordinal encoding. CAST round-trips within the simulator work; cross-engine byte transfer is intentionally deferred. **Parser**: `Expression.cs`'s binary-operator loop gains two cases. (1) `Operator { Character: ':' }` — recognizes `hierarchyid::` type-scope when the prior `Reference` is the bare 1-part name `hierarchyid` (case-insensitive via `Collation.Default`); requires a second `:` immediately after, then dispatches to `HierarchyIdStaticCall.Parse`. (2) `Operator { Character: '.' }` extended — when the dotted Name component matches one of the closed accept-list method names (`GetLevel` / `GetAncestor` / `GetDescendant` / `IsDescendantOf` / `ToString`) AND the very next token is `(` (peeked via `SaveCheckpoint`/`RestoreCheckpoint` so non-method-call uses fall through to multipart-Reference handling), dispatch to `HierarchyIdMethodCall.Parse` with the receiver expression intact. The closed-list shape means a column literally named `GetLevel` (etc.) followed by `.MethodName(...)` would route through hierarchyid dispatch; AW doesn't exercise this collision so it's accepted as a documented limitation. **Method semantics** (probe-confirmed): - `.GetLevel()` = segment count, surfaces as `smallint`. - `.GetAncestor(0)` = self; `.GetAncestor(N)` = path with last N segments trimmed; `.GetAncestor(level + 1)` and deeper return NULL; negative N raises Msg 6522. - `.GetDescendant(NULL, NULL)` = self + `[1]`; `.GetDescendant(c1, NULL)` = self + `[last_label(c1) + 1]`; `.GetDescendant(NULL, c2)` = self + `[last_label(c2) - 1]`. Both children present: c1 and c2 must be direct descendants of self (depth = self.depth + 1, prefix equals self) — otherwise Msg 6522; c1 must be < c2 — otherwise Msg 6522. Gap > 1 between c1's and c2's last labels picks the integer midpoint; adjacent siblings (gap = 1) extend c1 with sub-ordinal `.1` (e.g. `/1/`.GetDescendant(`/1/2/`, `/1/3/`) = `/1/2.1/`). - `.IsDescendantOf(other)` = true if self's segment-array starts with other's segment-array prefix (including the self-of-self case). - `.ToString()` = canonical string form, slash-bordered with dot-joined labels. **Comparison + ORDER BY**: lexicographic on segments, then on labels within segment. A shorter prefix sorts before its extensions (so `/1/` < `/1/2/`; `/1/2/` < `/1/2.1/`). Probe-confirmed canonical sequence `/`, `/-1/`, `/1/`, `/1/1/`, `/1/1/1/`, `/1/2/`, `/2/` enforced by the `OrderBy_FollowsLexicographicPathOrder` test. **Msg 6522**: new factory `SimulatedSqlException.InvalidHierarchyIdInput(detail)` carries SQL Server's wrapped wording (`A .NET Framework error occurred during execution of user-defined routine or aggregate "hierarchyid": Microsoft.SqlServer.Types.HierarchyIdException: 24001: SqlHierarchyId operation failed because input '<detail>' was not valid.`). Triggers: `Parse` on empty / missing-slash / double-slash / non-numeric input; `GetAncestor` with negative depth; `GetDescendant` with non-child argument or c1 >= c2. 50 new tests in `HierarchyIdTests.cs` (24 [TestMethod] cases, 31 DataRow combinations inflate to 50 total) cover Parse + ToString round-trip for positive/negative/zero/decimal segments, all parse error paths, GetLevel for varying depths, GetAncestor walking 0..level + beyond-root + negative-depth, all four GetDescendant combinations including the AW-style sub-ordinal extension, IsDescendantOf prefix containment, ORDER BY against the probe-canonical sequence, storage round-trip through a heap table, NULL hierarchyid round-trip, `DECLARE @h hierarchyid` + variable-method-call, NULL-argument handling for Parse and IsDescendantOf, and the `sys.types` row shape. Test counts: 4098 main (+50) / 227 internal / 328 EFCore / 58 analyzers — all green Debug + Release. `docs/claude/bacpac-prerequisites.md` flips the `hierarchyid` item from `[ ]` to `[x]` with the full implementation walkthrough including the closed accept-list method-name approach + the deferred byte-identical CAST gap; the order-of-operations sequence drops the duplicate hierarchyid bullet and the loader-baseline bullet gains a "re-probe the hierarchyid BCP wire format" reminder. `CLAUDE.md` moves `hierarchyid` out of `Not modeled` (only `geography` / `geometry` remain there), gains a Feature-reference index entry sibling to the existing UDDT / extended-properties ones, and the BACPAC entry shrinks its prerequisite list to the remaining items (DDL triggers / GRANT-REVOKE-DENY / xml / spatial / full-text). **Deferred follow-ups** (documented): byte-identical CAST encoding (reverse-engineer the recursive Stern-Brocot bit pattern — confirmed via probe to be a 6+ tier prefix structure with embedded sub-tier markers, e.g. tier 16..79 has 6 value bits at positions 3,4,6,8,9,10 of a 12-bit label with fixed `0`/`1` at positions 5/7); `.GetReparentedValue(oldRoot, newRoot)`; `.Read(BinaryReader)` / `.Write(BinaryWriter)`; GetDescendant's sub-ordinal placement for inputs that already carry decimal sub-ordinals (current rule produces a valid `c1 < result` but may not match SQL Server's exact choice — AW doesn't exercise).
1 parent 4eb35a4 commit f91a71c

10 files changed

Lines changed: 1046 additions & 22 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,9 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
157157
- **Touching `CREATE [UNIQUE] [CLUSTERED | NONCLUSTERED] INDEX` (with `INCLUDE` / `WHERE` filter / `WITH (options)` clauses), `DROP INDEX [IF EXISTS] name ON table [, …]`, the `HeapTable.Indexes` list, UNIQUE-index enforcement (filter-aware Msg 2601 via `EnforceUniqueIndexes` / `EnforceUniqueIndexesForUpdate`), the PK-or-UQ-name DROP rejection (Msg 3723), or `sys.indexes` / `sys.index_columns`**[`docs/claude/indexes.md`](docs/claude/indexes.md).
158158
- **Touching table hints (`WITH (NOLOCK [, …])` on FROM sources / JOIN-RHS / UPDATE / DELETE targets, the legacy `(hint)` without-WITH form, `Msg 321` unknown-hint rejection), statement-level `OPTION (…)` hints (`RECOMPILE` / `MAXDOP` / `FAST` / `LOOP|HASH|MERGE JOIN` / `FORCE ORDER` / `OPTIMIZE FOR (UNKNOWN)` / `USE HINT(…)` / `MAXRECURSION` with its CTE-binding override), the `TableHintNames` / `OptionHintFirstWords` closed accept-lists, or `SkipBalancedParens` argument consumption**[`docs/claude/query-hints.md`](docs/claude/query-hints.md).
159159
- **Touching the `LockResource` / `LockManager` pair, the 8-mode compatibility matrix (Sch-S / Sch-M / IS / IX / SIX / S / U / X), `BatchContext.AcquireStatementLock` / `AcquireTransactionLock` / `AcquireDataLockIfApplicable` / `AcquireRowLockTxScoped` / `TouchRowForRead` / `WrapWithRowConflictChecks` / `ResolveSnapshotXidForRead`, the `DataLockPlan` struct that captures the per-row strategy, the row-lock dict on `HeapTable.RowLocks`, lock escalation (5000-threshold via `SimulatedDbTransaction.RowLockCountsByTable` + `EscalatedTables`), `SimulatedDbConnection.SessionIsolationLevel` + `SET TRANSACTION ISOLATION LEVEL` wiring, the hint surface (UPDLOCK / XLOCK / READPAST / TABLOCK / TABLOCKX / HOLDLOCK / SERIALIZABLE / REPEATABLEREAD / NOLOCK), `SimulatedDbConnection.Spid` / `LockTimeoutMillis` / `CurrentExecutingThreadId` / `WaitingOnResource`, the same-thread-deadlock check and cross-thread waiter-graph cycle detection (Msg 1205, auto-rollback of victim's tx), the lock-timeout path (Msg 1222), or the SNAPSHOT + RCSI machinery (`Database.AllowSnapshotIsolation` / `ReadCommittedSnapshot` / `AllocateTransactionCommitId`, `HeapTable.RowVersions` + `RowVersionChain` + `HistoricalVersion`, the `VersionStore` static helper for `CaptureWrite` / `FinalizePendingEntries` / `DiscardPendingEntries` / `ResolveVisibleVersion` / `CheckSnapshotUpdateConflict`, `SimulatedDbTransaction.SnapshotXid` / `PendingVersionEntries`, `BatchContext.RcsiStatementSnapshotXid`, `ALTER DATABASE … SET (ALLOW_SNAPSHOT_ISOLATION | READ_COMMITTED_SNAPSHOT)`, Msg 3952 + Msg 3960, `VersionStore.RunGarbageCollection` + `Database.ActiveSnapshotTxs`, or the `sys.dm_tran_version_store` / `sys.dm_tran_version_store_space_usage` / `sys.dm_tran_active_snapshot_database_transactions` DMVs via `VersionStoreDmvs`)** → [`docs/claude/locking.md`](docs/claude/locking.md).
160+
- **Touching `hierarchyid` (the `HierarchyIdSqlType` storage + segment-array `int[][]` internal representation in `Storage/HierarchyIdType.cs`, the `SqlValue.FromHierarchyId` / `AsHierarchyId` accessors, the `hierarchyid::Parse` / `hierarchyid::GetRoot` static-call dispatch in `HierarchyIdStaticCall`, the `.GetLevel` / `.GetAncestor` / `.GetDescendant` / `.IsDescendantOf` / `.ToString` instance-method dispatch in `HierarchyIdMethodCall`, the Msg 6522 verbatim wording in `SimulatedSqlException.TypeErrors.cs`, the `:` / `.<method>(` parser cases in `Expression.cs`, or the `sys.types` row shape — system_type_id 240, user_type_id 128, max_length 892)**[`docs/claude/bacpac-prerequisites.md`](docs/claude/bacpac-prerequisites.md) (the `hierarchyid` section carries the implementation detail; the byte-identical-CAST gap is deferred to the BACPAC loader bundle).
160161
- **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).
161-
- **Working on BACPAC import support (the eventual `Simulation.FromBacpac` / `FromBacPac` entry point), or knocking off any of its prerequisite features (UDDTs / alias types via `CREATE TYPE … FROM`, `hierarchyid` storage + methods, `sp_addextendedproperty` + `sys.extended_properties`, database-options parse-and-discard expansion for `ALTER DATABASE … SET (…)`, DDL triggers `ON DATABASE`, permission statements `GRANT`/`REVOKE`/`DENY`, `xml` + XML schema collections + XML methods + XML indexes, `geography`/`geometry` spatial types, full-text catalog/index + `CONTAINS`/`FREETEXT`)**[`docs/claude/bacpac-prerequisites.md`](docs/claude/bacpac-prerequisites.md). Working document with a feature checklist + the BCP wire-format type matrix; serves as the running scoping doc until the loader baseline lands.
162+
- **Working on BACPAC import support (the eventual `Simulation.FromBacpac` / `FromBacPac` entry point), or knocking off any of its remaining prerequisite features (DDL triggers `ON DATABASE`, permission statements `GRANT`/`REVOKE`/`DENY`, `xml` + XML schema collections + XML methods + XML indexes, `geography`/`geometry` spatial types, full-text catalog/index + `CONTAINS`/`FREETEXT`)**[`docs/claude/bacpac-prerequisites.md`](docs/claude/bacpac-prerequisites.md). Working document with a feature checklist + the BCP wire-format type matrix; serves as the running scoping doc until the loader baseline lands. Shipped prerequisites: UDDTs / alias types, `sp_addextendedproperty` + `sys.extended_properties`, ALTER DATABASE options accept-list, hierarchyid AW-minimum-viable surface.
162163

163164
## Not modeled
164165

@@ -187,7 +188,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
187188
- **TRY/CATCH + THROW + RAISERROR + `@@ERROR` + `ERROR_*()` ship** with printf-style formatter (`%s %d/%i %u %o %x %X %% %ld %I64d`, width/precision/left-align/zero-pad), severity routing (0-10 informational, 11-18 catchable, `WITH SETERROR`/`NOWAIT` honored, `WITH LOG` uniformly raises Msg 2778), numeric `msg_id` raises Msg 2732 for 50000 / `< 13000` and Msg 18054 otherwise. `sys.messages` registry / `sp_addmessage` not modeled. See [`docs/claude/control-flow.md`](docs/claude/control-flow.md).
188189
- **Public `InfoMessage` event** — `SimulatedDbConnection.InfoMessage` ships as an `internal` event (with `internal SimulatedInfoMessageEventArgs` carrying `Message`, `LineNumber`, `Source`) and a `BatchContext`-side buffer that coalesces multiple `PRINT`s per command into one firing (joined with `\n`, line number = first contributing `PRINT`'s line). The internal surface is exercised through `SqlServerSimulator.Tests.Internal/PrintInfoMessageTests.cs`. Going *public* — exposing the event on the `SimulatedDbConnection` consumer surface for application use — is deferred pending the public-API shape decision (mirror SqlClient's `SqlInfoMessageEventArgs` exactly? trim to a minimal shape? add a `DbConnection`-compatible extension method instead of a typed event?). Subquery-in-operand still silently evaluates instead of raising Msg 1046 ("Subqueries are not allowed in this context"); non-string-value formatting routes through `SqlValue.CoerceTo(varchar(8000))` rather than SQL Server's PRINT-specific style 0 conventions (datetime → ISO instead of `"May 14 2026 12:00AM"`; money → `F4` instead of 2-decimal). The 8000-byte ANSI / 4000-character Unicode PRINT truncation isn't enforced — long strings pass through verbatim.
189190
- **`ALTER TABLE` modeled shapes**: `SET (SYSTEM_VERSIONING = OFF)`, `[WITH CHECK|NOCHECK] ADD CONSTRAINT … (PK | UNIQUE | FK | CHECK | DEFAULT)`, `DROP CONSTRAINT [IF EXISTS] name [, …]`, trust toggling via `(CHECK|NOCHECK) CONSTRAINT (ALL | name)`, `ADD [COLUMN] col TYPE [, …]` (multi-column, inline DEFAULT/IDENTITY/CHECK/FK/computed), `DROP COLUMN [IF EXISTS] col [, …]`, `ALTER COLUMN col TYPE [NULL|NOT NULL]` (single-column, type widening/narrowing, nullability flip). See [`docs/claude/alter-table.md`](docs/claude/alter-table.md). Out of scope: DROP PERIOD FOR SYSTEM_TIME, REBUILD, SWITCH PARTITION, SET-versioning-on, `ALTER COLUMN col ADD/DROP {PERSISTED|MASKED|ROWGUIDCOL|SPARSE}`, identity-column type change to non-integer, multi-constraint ADD (`ADD CONSTRAINT pk1 PK (a), CONSTRAINT fk1 FK …`) — all raise `NotSupportedException`.
190-
- `hierarchyid`, `geography`, `geometry`.
191+
- `geography`, `geometry``hierarchyid` ships the AW-minimum-viable surface (Parse / GetRoot / GetLevel / GetAncestor / GetDescendant / IsDescendantOf / ToString / comparison / `ORDER BY`); see [`docs/claude/bacpac-prerequisites.md`](docs/claude/bacpac-prerequisites.md) for the methods + the deferred byte-identical CAST encoding (currently simulator-native, to be replaced when the BACPAC loader bundle starts).
191192
- **Query hints**`WITH (hint, …)` on FROM/JOIN-RHS/INSERT/UPDATE/DELETE/MERGE targets and `OPTION (…)` ship; closed accept-lists raise `Msg 321` (unknown table hint) / `Msg 102` (unknown OPTION hint) verbatim; `Msg 1047`/`1065`/`1069`/`307`/`308` enforced; legacy bare-paren form FROM/JOIN-RHS only. `MAXRECURSION N` retains runtime effect; every other recognized hint parse-and-discards. Remaining gaps: FROM-source `(unknown)` without alias falls through to Msg 102 (real SQL Server raises Msg 207/321); `FORCESEEK(name(cols))` nested-form name validation isn't run. See [`docs/claude/query-hints.md`](docs/claude/query-hints.md).
192193

193194
## Quirks (modeled, not byte-identical to SQL Server)
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
using static SqlServerSimulator.TestHelpers;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Behavioral tests for <c>hierarchyid</c>: tree-path storage, the static
8+
/// factories <c>hierarchyid::Parse</c> / <c>hierarchyid::GetRoot</c>, the
9+
/// instance methods <c>.GetLevel</c> / <c>.GetAncestor</c> /
10+
/// <c>.GetDescendant</c> / <c>.IsDescendantOf</c> / <c>.ToString</c>,
11+
/// path comparison via <c>ORDER BY</c>, and Msg 6522 verbatim wording on
12+
/// invalid input.
13+
/// </summary>
14+
/// <remarks>
15+
/// The CAST-to-varbinary byte form is simulator-native rather than SQL Server's
16+
/// documented variable-bit ordinal encoding (deferred to the BACPAC loader
17+
/// bundle). All other surfaces probe-match real SQL Server 2025 on 2026-05-14.
18+
/// </remarks>
19+
[TestClass]
20+
public sealed class HierarchyIdTests
21+
{
22+
[TestMethod]
23+
public void Parse_Root_ProducesEmptyPath()
24+
=> AreEqual("/", ExecuteScalar("select hierarchyid::Parse('/').ToString()"));
25+
26+
[TestMethod]
27+
public void GetRoot_ProducesSlashPath()
28+
=> AreEqual("/", ExecuteScalar("select hierarchyid::GetRoot().ToString()"));
29+
30+
[TestMethod]
31+
[DataRow("/1/", "/1/")]
32+
[DataRow("/1/2/", "/1/2/")]
33+
[DataRow("/1/2/3/", "/1/2/3/")]
34+
[DataRow("/-1/", "/-1/")]
35+
[DataRow("/0/", "/0/")]
36+
[DataRow("/1.1/", "/1.1/")]
37+
[DataRow("/1/2.5/3/", "/1/2.5/3/")]
38+
[DataRow("/100/", "/100/")]
39+
[DataRow("/-200/", "/-200/")]
40+
public void Parse_ToString_RoundTrip(string input, string expected)
41+
=> AreEqual(expected, ExecuteScalar($"select hierarchyid::Parse('{input}').ToString()"));
42+
43+
[TestMethod]
44+
[DataRow("")]
45+
[DataRow("/1")]
46+
[DataRow("1/")]
47+
[DataRow("//")]
48+
[DataRow("/1//2/")]
49+
[DataRow("/a/")]
50+
public void Parse_BadInput_RaisesMsg6522(string input)
51+
=> new Simulation().AssertSqlError($"select hierarchyid::Parse('{input}').ToString()", 6522);
52+
53+
[TestMethod]
54+
[DataRow("/", 0)]
55+
[DataRow("/1/", 1)]
56+
[DataRow("/1/2/", 2)]
57+
[DataRow("/1/2/3/", 3)]
58+
[DataRow("/1.1/", 1)]
59+
[DataRow("/1/2.5/3/", 3)]
60+
public void GetLevel_ReturnsSegmentCount(string path, int expected)
61+
=> AreEqual((short)expected, ExecuteScalar($"select hierarchyid::Parse('{path}').GetLevel()"));
62+
63+
[TestMethod]
64+
[DataRow("/1/2/3/", 0, "/1/2/3/")]
65+
[DataRow("/1/2/3/", 1, "/1/2/")]
66+
[DataRow("/1/2/3/", 2, "/1/")]
67+
[DataRow("/1/2/3/", 3, "/")]
68+
public void GetAncestor_WalksUpThePath(string path, int depth, string expected)
69+
=> AreEqual(expected, ExecuteScalar($"select hierarchyid::Parse('{path}').GetAncestor({depth}).ToString()"));
70+
71+
[TestMethod]
72+
public void GetAncestor_BeyondRoot_ReturnsNull()
73+
=> AreEqual(DBNull.Value, ExecuteScalar("select hierarchyid::Parse('/1/2/3/').GetAncestor(4).ToString()"));
74+
75+
[TestMethod]
76+
public void GetAncestor_NegativeDepth_RaisesMsg6522()
77+
=> new Simulation().AssertSqlError("select hierarchyid::Parse('/1/').GetAncestor(-1).ToString()", 6522);
78+
79+
[TestMethod]
80+
public void GetDescendant_BothNull_ProducesFirstChild()
81+
=> AreEqual("/1/", ExecuteScalar("select hierarchyid::Parse('/').GetDescendant(null, null).ToString()"));
82+
83+
[TestMethod]
84+
public void GetDescendant_AboveC1_IncrementsLastLabel()
85+
=> AreEqual("/2/", ExecuteScalar("select hierarchyid::Parse('/').GetDescendant(hierarchyid::Parse('/1/'), null).ToString()"));
86+
87+
[TestMethod]
88+
public void GetDescendant_BelowC2_DecrementsLastLabel()
89+
=> AreEqual("/0/", ExecuteScalar("select hierarchyid::Parse('/').GetDescendant(null, hierarchyid::Parse('/1/')).ToString()"));
90+
91+
[TestMethod]
92+
public void GetDescendant_GapBetweenC1AndC2_PicksMidpointInteger()
93+
=> AreEqual("/2/", ExecuteScalar("select hierarchyid::Parse('/').GetDescendant(hierarchyid::Parse('/1/'), hierarchyid::Parse('/3/')).ToString()"));
94+
95+
[TestMethod]
96+
public void GetDescendant_AdjacentSiblings_ExtendsWithSubOrdinal()
97+
=> AreEqual("/1.1/", ExecuteScalar("select hierarchyid::Parse('/').GetDescendant(hierarchyid::Parse('/1/'), hierarchyid::Parse('/2/')).ToString()"));
98+
99+
[TestMethod]
100+
public void GetDescendant_DeeperParent_PreservesSelfPath()
101+
=> AreEqual("/1/3/", ExecuteScalar("select hierarchyid::Parse('/1/').GetDescendant(hierarchyid::Parse('/1/2/'), null).ToString()"));
102+
103+
[TestMethod]
104+
public void GetDescendant_C1NotChildOfSelf_RaisesMsg6522()
105+
=> new Simulation().AssertSqlError("select hierarchyid::Parse('/1/').GetDescendant(hierarchyid::Parse('/2/'), null).ToString()", 6522);
106+
107+
[TestMethod]
108+
public void GetDescendant_C1GreaterThanC2_RaisesMsg6522()
109+
=> new Simulation().AssertSqlError("select hierarchyid::Parse('/1/').GetDescendant(hierarchyid::Parse('/1/3/'), hierarchyid::Parse('/1/2/')).ToString()", 6522);
110+
111+
[TestMethod]
112+
[DataRow("/", "/", true)]
113+
[DataRow("/1/", "/", true)]
114+
[DataRow("/1/2/", "/1/", true)]
115+
[DataRow("/1/2/", "/2/", false)]
116+
[DataRow("/", "/1/", false)]
117+
[DataRow("/1/2/3/", "/1/", true)]
118+
public void IsDescendantOf_FollowsPrefixContainment(string self, string ancestor, bool expected)
119+
=> AreEqual(expected, ExecuteScalar($"select hierarchyid::Parse('{self}').IsDescendantOf(hierarchyid::Parse('{ancestor}'))"));
120+
121+
[TestMethod]
122+
public void OrderBy_FollowsLexicographicPathOrder()
123+
{
124+
var sim = new Simulation();
125+
_ = sim.ExecuteNonQuery("""
126+
create table t (h hierarchyid not null);
127+
insert t values
128+
(hierarchyid::Parse('/2/')),
129+
(hierarchyid::Parse('/1/2/')),
130+
(hierarchyid::Parse('/1/')),
131+
(hierarchyid::Parse('/')),
132+
(hierarchyid::Parse('/1/1/')),
133+
(hierarchyid::Parse('/-1/')),
134+
(hierarchyid::Parse('/1/1/1/'))
135+
""");
136+
using var conn = sim.CreateOpenConnection();
137+
using var cmd = conn.CreateCommand();
138+
cmd.CommandText = "select h.ToString() from t order by h";
139+
using var reader = cmd.ExecuteReader();
140+
var actual = new List<string>();
141+
while (reader.Read())
142+
actual.Add(reader.GetString(0));
143+
var expected = new[] { "/", "/-1/", "/1/", "/1/1/", "/1/1/1/", "/1/2/", "/2/" };
144+
AreEqual(string.Join(",", expected), string.Join(",", actual));
145+
}
146+
147+
[TestMethod]
148+
public void Storage_RoundTripsThroughHeap()
149+
{
150+
var sim = new Simulation();
151+
_ = sim.ExecuteNonQuery("""
152+
create table t (id int not null primary key, h hierarchyid not null);
153+
insert t values (1, hierarchyid::Parse('/1/2/3/')), (2, hierarchyid::Parse('/-1/'))
154+
""");
155+
AreEqual("/1/2/3/", sim.ExecuteScalar("select h.ToString() from t where id = 1"));
156+
AreEqual("/-1/", sim.ExecuteScalar("select h.ToString() from t where id = 2"));
157+
}
158+
159+
[TestMethod]
160+
public void Null_Hierarchyid_RoundTripsAsNull()
161+
{
162+
var sim = new Simulation();
163+
_ = sim.ExecuteNonQuery("""
164+
create table t (id int not null primary key, h hierarchyid null);
165+
insert t values (1, null)
166+
""");
167+
AreEqual(DBNull.Value, sim.ExecuteScalar("select h from t where id = 1"));
168+
}
169+
170+
[TestMethod]
171+
public void DeclareVariable_AssignParse_ReadGetLevel()
172+
=> AreEqual((short)3, ExecuteScalar("""
173+
declare @h hierarchyid = hierarchyid::Parse('/1/2/3/');
174+
select @h.GetLevel()
175+
"""));
176+
177+
[TestMethod]
178+
public void Parse_NullArgument_ReturnsNullHierarchyid()
179+
=> AreEqual(DBNull.Value, ExecuteScalar("select hierarchyid::Parse(cast(null as nvarchar(100))).ToString()"));
180+
181+
[TestMethod]
182+
public void IsDescendantOf_NullArgument_ReturnsNullBit()
183+
=> AreEqual(DBNull.Value, ExecuteScalar("select hierarchyid::Parse('/1/').IsDescendantOf(cast(null as hierarchyid))"));
184+
185+
[TestMethod]
186+
public void SysTypes_ListsHierarchyId()
187+
{
188+
var sim = new Simulation();
189+
AreEqual(240, sim.ExecuteScalar("select cast(system_type_id as int) from sys.types where name = 'hierarchyid'"));
190+
AreEqual(128, sim.ExecuteScalar("select user_type_id from sys.types where name = 'hierarchyid'"));
191+
}
192+
}

SqlServerSimulator/Errors/SimulatedSqlException.TypeErrors.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,19 @@ internal static SimulatedSqlException IncompatibleDataTypesInOperator(SqlType a,
411411
internal static SimulatedSqlException NegativeLengthNotAllowed(string function) =>
412412
new($"Invalid length parameter passed to the {function} function.", 537, 16, 3);
413413

414+
/// <summary>
415+
/// Mimics SQL Server error 6522: an input to a hierarchyid method
416+
/// (<c>Parse</c>, <c>GetAncestor</c> with out-of-range depth,
417+
/// <c>GetDescendant</c> with mismatched children, etc.) violates the
418+
/// hierarchyid contract. Real SQL Server wraps these as ".NET Framework
419+
/// error … during execution of user-defined routine or aggregate
420+
/// 'hierarchyid'"; the simulator surfaces a concise actionable message
421+
/// with the same number so apps doing <c>TRY/CATCH</c> on Msg 6522 still
422+
/// see the same code.
423+
/// </summary>
424+
internal static SimulatedSqlException InvalidHierarchyIdInput(string detail) =>
425+
new($"A .NET Framework error occurred during execution of user-defined routine or aggregate \"hierarchyid\": Microsoft.SqlServer.Types.HierarchyIdException: 24001: SqlHierarchyId operation failed because input '{detail}' was not valid.", 6522, 16, 1);
426+
414427
/// <summary>
415428
/// Returns the type name SQL Server uses in Msg 402 / 206 / 529 for a
416429
/// date/time type: the family root (e.g. <c>datetime2</c>,

0 commit comments

Comments
 (0)