Skip to content

Commit f89e164

Browse files
committed
BACPAC loader follow-ups — boolean-parser paren-wrapped value LHS + SqlPermissionStatement dispatcher. Two small bundles in one commit; combined, WWI's Skipped category count drops 4 → 2, AW's Skipped category count drops to 1 (collation-only). Both bundles ship permanent direct-SQL test coverage in addition to the bacpac round-trip regression.
**Boolean-parser paren-wrapped value LHS** (`SqlServerSimulator/Parser/BooleanExpression.cs`). The previous `ParseAtom` treated every leading `(` as a boolean group, so `WHERE (a + b) = 5` / `CHECK (((case_sum) = (1)))` / `HAVING (sum(v)) > 25` all surfaced as Msg 4145 syntax errors (probe-confirmed against SQL Server 2025: real SQL Server accepts every one of these). New `LookaheadValueLhs` saves a `ParserContext.SaveCheckpoint`, scans tokens forward tracking paren nesting until the matching `)`, peeks the next token, and restores. When the post-`)` token is a comparison or arithmetic operator (= < > <> != !< !> + - * / % & | ^) or one of LIKE / IS / IN / BETWEEN / NOT, the leading `(` is the start of a value expression on the LHS of a comparison — `ParseAtom` routes through `Expression.Parse` (which handles `Parenthesized` via its own grouped-expression dispatch) and then `ParseComparison`. Otherwise the existing boolean-group path runs. A top-level `,` inside the outer parens disqualifies the value-LHS interpretation (preserves the row-constructor `(a, b) IN (...)` Msg 4145 "near ','" wording from `RowConstructorIn_RejectedWithMatchingMsg4145`). No exceptions for control flow, no side-effects on aggregate / window collectors. Unblocks WWI's `CK_Sales_SpecialDeals_Exactly_One_NOT_NULL_Pricing_Option_Is_Required` (parsed shape `((case_sum) = (1))`). Permanent regression tests: 3 in `CheckConstraintTests.cs` (accept-conforming / reject-two-set / reject-all-null on the WWI CHECK shape) + 8 in `WhereTests.cs` (paren-arith LHS, doubly-paren LHS, BETWEEN with paren LHS, IN with paren LHS, paren-LHS coexisting with outer boolean-group, HAVING with paren-aggregate LHS, CASE-WHEN with paren-LHS, doubly-paren COMPARISON). **SqlPermissionStatement dispatcher** (`SqlServerSimulator/Storage/Bacpac/ModelXmlReader.cs`). Adds the phase-7 dispatcher slot + `EmitPermissionStatement`. AW + WWI each carried 2 SqlPermissionStatement model elements (`Grant.ViewAnyColumnEncryptionKeyDefinition.Database` / `Grant.ViewAnyColumnMasterKeyDefinition.Database`, both granting to the pre-seeded `public` role) that previously landed on Skipped as "Element type not yet handled by the loader". The new emitter parses the element's `Name`-encoded `[Action.PermissionCamelCase.Scope].[grantee].[grantor]` triple, splits the camel-case permission identifier via the new `CamelToSpaceSeparatedUpper` helper, pulls the grantee from the `Grantee` relationship, and dispatches a `GRANT|REVOKE|DENY <permission> TO <grantee>` statement through the existing GRANT parser (which already accepted the multi-word encryption-key permission keywords — only the loader-side translation was missing). Schema- / object-scope GRANTs would route through the same path with the `SecuredObject` relationship translated to an `ON` clause; deferred until a bacpac exercises that surface. Permanent regression test: `Load_WWI_Encryption_Key_Grants_Land_In_sys_database_permissions` confirms both `VIEW ANY COLUMN ENCRYPTION KEY DEFINITION` and `VIEW ANY COLUMN MASTER KEY DEFINITION` round-trip through `sys.database_permissions`. **Test-expectation + doc updates**: - `Load_WWI_Known_Gaps_Recorded_In_Skipped` drops the SqlCheckConstraint and SqlPermissionStatement category assertions; expected counts now: 81 SqlExtendedProperty + 1 SqlDatabaseOptions. - `Load_AW_Unhandled_Elements_Recorded_In_Skipped` no longer expects SqlPermissionStatement to appear on Skipped (AW's permission grants now land). - `docs/claude/bacpac-prerequisites.md`: step 8 + step 9 in the order-of-operations checklist capture the two new shipped bundles; the WWI gap inventory (step 12) strikes through both newly-cleared categories; the Status section reflects the new WWI count census (`7/7 CHECK constraints`, `2/2 encryption-key GRANTs` added to the row-by-row tally). - `CLAUDE.md`: the BACPAC import deep-dive pointer's WWI Skipped tally drops SqlCheckConstraint and SqlPermissionStatement.
1 parent d7442e4 commit f89e164

7 files changed

Lines changed: 336 additions & 24 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` (9 phases: DB options + schemas + UDDTs + **sequences + roles + table types + filegroups (skip)** → tables → constraints → FKs → (unused) → views → programmable objects → **deferred computed columns + indexes** → extended properties → BCP data), the `BacpacReader` + `BcpRowReader` + `HierarchyIdWireDecoder` data path, the `BacpacLoadResult.Skipped` + `ElementCounts` + `Warnings` + `TableColumnIsAlias` diagnostics carriers, the resilient-loader contract (per-element exceptions land on Skipped with `"Load failed: …"` prefix and don't abort the load; computed-column failures use `"Deferred: …"` prefix so the AW guard stays meaningful), the **BCP-filters-computed-columns contract** (`BacpacReader.LoadRowsFromBcp` strips columns with `HeapColumn.Computed != null` before passing to `BcpRowReader`/`RowEncoder` — DACFx-emitted BCP files exclude computed columns from the wire layout regardless of PERSISTED; the loader emits computed columns *without* the PERSISTED qualifier so the simulator recomputes on every read), 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); WideWorldImporters-Standard loads end-to-end with **48/48 tables + 26/26 sequences + 9/9 roles + 4/4 table types + 8/8 computed columns + 42/42 procedures + 94/94 indexes + 3/3 views + 1/1 scalar function + 4.7M rows**, remaining Skipped categories cataloged in `Load_WWI_Known_Gaps_Recorded_In_Skipped` (81 SqlExtendedProperty, 2 SqlPermissionStatement, 1 SqlCheckConstraint (paren-wrapped value subexpression at boolean-parser level), 1 SqlDatabaseOptions-Collation). 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 order-preserving prefix code (`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).
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` (9 phases: DB options + schemas + UDDTs + **sequences + roles + table types + filegroups (skip)** → tables → constraints → FKs → (unused) → views → programmable objects → **deferred computed columns + indexes** → extended properties → BCP data), the `BacpacReader` + `BcpRowReader` + `HierarchyIdWireDecoder` data path, the `BacpacLoadResult.Skipped` + `ElementCounts` + `Warnings` + `TableColumnIsAlias` diagnostics carriers, the resilient-loader contract (per-element exceptions land on Skipped with `"Load failed: …"` prefix and don't abort the load; computed-column failures use `"Deferred: …"` prefix so the AW guard stays meaningful), the **BCP-filters-computed-columns contract** (`BacpacReader.LoadRowsFromBcp` strips columns with `HeapColumn.Computed != null` before passing to `BcpRowReader`/`RowEncoder` — DACFx-emitted BCP files exclude computed columns from the wire layout regardless of PERSISTED; the loader emits computed columns *without* the PERSISTED qualifier so the simulator recomputes on every read), 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); WideWorldImporters-Standard loads end-to-end with **48/48 tables + 26/26 sequences + 9/9 roles + 4/4 table types + 8/8 computed columns + 42/42 procedures + 94/94 indexes + 3/3 views + 1/1 scalar function + 4.7M rows**, remaining Skipped categories cataloged in `Load_WWI_Known_Gaps_Recorded_In_Skipped` (81 SqlExtendedProperty, 1 SqlDatabaseOptions-Collation). 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 order-preserving prefix code (`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: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,15 +99,15 @@ public void Load_AW_Unhandled_Elements_Recorded_In_Skipped()
9999
// parser fix unblocked `dbo.ufnLeadingZeros`, which in turn
100100
// unblocked every AW computed column referencing it (and the
101101
// filtered indexes whose predicates depend on those columns).
102-
// SqlPermissionStatement remains — encryption-feature permissions
103-
// not yet wired to the simulator's GRANT surface.
104-
IsNotEmpty(diagnostics.Skipped);
102+
// The 2026-05-16 SqlPermissionStatement loader bundle dispatched
103+
// the 2 encryption-key GRANTs through the simulator's GRANT
104+
// parser, clearing AW's last large structural gap.
105105
IsEmpty(diagnostics.Skipped.Where(s => s.ElementType == "SqlTable").ToList());
106106
IsEmpty(diagnostics.Skipped.Where(s => s.ElementType == "SqlPrimaryKeyConstraint").ToList());
107107
IsEmpty(diagnostics.Skipped.Where(s => s.ElementType == "SqlForeignKeyConstraint").ToList());
108108
IsEmpty(diagnostics.Skipped.Where(s => s.ElementType == "SqlCheckConstraint").ToList());
109109
IsEmpty(diagnostics.Skipped.Where(s => s.ElementType == "SqlDefaultConstraint").ToList());
110-
IsNotEmpty(diagnostics.Skipped.Where(s => s.ElementType == "SqlPermissionStatement").ToList());
110+
IsEmpty(diagnostics.Skipped.Where(s => s.ElementType == "SqlPermissionStatement").ToList());
111111
}
112112

113113
[TestMethod]
@@ -613,9 +613,9 @@ public void Load_WWI_Known_Gaps_Recorded_In_Skipped()
613613
IsFalse(grouped.ContainsKey("SqlView"), "DECOMPRESS landed; Website.VehicleTemperatures view loads.");
614614
IsFalse(grouped.ContainsKey("SqlScalarFunction"), "WITH EXECUTE AS OWNER landed on scalar UDFs; Website.CalculateCustomerPrice loads.");
615615
IsFalse(grouped.ContainsKey("SqlFilegroup"), "Filegroup skip-with-diagnostic.");
616+
IsFalse(grouped.ContainsKey("SqlCheckConstraint"), "Paren-wrapped value LHS in boolean parser landed; WWI's CK_Sales_SpecialDeals_Exactly_One_NOT_NULL_Pricing_Option_Is_Required loads.");
617+
IsFalse(grouped.ContainsKey("SqlPermissionStatement"), "SqlPermissionStatement dispatcher entry landed; the 2 encryption-key VIEW grants emit through the GRANT parser.");
616618
AreEqual(81, grouped["SqlExtendedProperty"], "Extended properties on table-type columns + a handful of computed-col-adjacent columns the loader doesn't yet host-route.");
617-
AreEqual(1, grouped["SqlCheckConstraint"], "1 CHECK constraint with nested (value_expr)=(1) shape hits the boolean-parser's paren-wrapped-value-subexpression gap.");
618-
AreEqual(2, grouped["SqlPermissionStatement"], "GRANT VIEW ANY COLUMN [ENCRYPTION|MASTER] KEY DEFINITION TO public — encryption-feature permissions deferred (same gap as AW).");
619619
AreEqual(1, grouped["SqlDatabaseOptions"], "Non-default collation deferred.");
620620
}
621621

@@ -635,6 +635,60 @@ public void Load_WWI_Computed_Columns_Land_With_is_computed_Set()
635635
AreEqual(8, reader.GetInt32(0));
636636
}
637637

638+
[TestMethod]
639+
public void Load_WWI_Encryption_Key_Grants_Land_In_sys_database_permissions()
640+
{
641+
// The 2 encryption-key VIEW grants
642+
// (GRANT VIEW ANY COLUMN ENCRYPTION KEY DEFINITION TO public,
643+
// GRANT VIEW ANY COLUMN MASTER KEY DEFINITION TO public) — both
644+
// database-scope, granted to the pre-seeded `public` role.
645+
// Verify they round-trip through sys.database_permissions.
646+
var simulation = LoadWideWorldImporters(out _);
647+
using var connection = (SimulatedDbConnection)simulation.CreateDbConnection();
648+
connection.Open();
649+
using var command = connection.CreateCommand();
650+
command.CommandText = """
651+
SELECT permission_name
652+
FROM sys.database_permissions p
653+
JOIN sys.database_principals g ON p.grantee_principal_id = g.principal_id
654+
WHERE g.name = 'public'
655+
AND permission_name LIKE 'VIEW ANY COLUMN%'
656+
ORDER BY permission_name;
657+
""";
658+
using var reader = command.ExecuteReader();
659+
var names = new List<string>();
660+
while (reader.Read())
661+
names.Add(reader.GetString(0));
662+
HasCount(2, names);
663+
AreEqual("VIEW ANY COLUMN ENCRYPTION KEY DEFINITION", names[0]);
664+
AreEqual("VIEW ANY COLUMN MASTER KEY DEFINITION", names[1]);
665+
}
666+
667+
[TestMethod]
668+
public void Load_WWI_ParenWrappedValueLhs_Check_Loaded_And_Enforces()
669+
{
670+
// CK_Sales_SpecialDeals_Exactly_One_NOT_NULL_Pricing_Option_Is_Required
671+
// is the canonical paren-wrapped-value-LHS CHECK in WWI: the parsed
672+
// expression is `((case_sum) = (1))`. Probe-confirmed against SQL
673+
// Server 2025: the constraint rejects 0-set and 2-set inserts. The
674+
// BCP-loaded rows already satisfy it; verify the constraint enforces
675+
// on new inserts as a proxy for "did this CHECK actually land".
676+
var simulation = LoadWideWorldImporters(out _);
677+
using var connection = (SimulatedDbConnection)simulation.CreateDbConnection();
678+
connection.Open();
679+
using var command = connection.CreateCommand();
680+
command.CommandText = """
681+
INSERT INTO Sales.SpecialDeals
682+
(SpecialDealID, StockItemID, CustomerID, BuyingGroupID, StockGroupID, DealDescription,
683+
StartDate, EndDate, DiscountAmount, DiscountPercentage, UnitPrice, LastEditedBy)
684+
VALUES (99999, NULL, NULL, NULL, NULL, N'Bad — two pricing options',
685+
'2030-01-01', '2030-12-31', 5.00, 10.0, NULL, 1);
686+
""";
687+
var ex = Throws<SimulatedSqlException>(() => command.ExecuteNonQuery());
688+
AreEqual("547", ex.Data["HelpLink.EvtID"]);
689+
Contains("CK_Sales_SpecialDeals_Exactly_One_NOT_NULL_Pricing_Option_Is_Required", ex.Message);
690+
}
691+
638692
[TestMethod]
639693
public void Load_WWI_Persisted_Computed_Column_Evaluates_On_Read()
640694
{

SqlServerSimulator.Tests/CheckConstraintTests.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,4 +251,49 @@ insert t values (1, 2)
251251
var ex = Assert.Throws<DbException>(() => simulation.ExecuteNonQuery("insert t values (5, 3)"));
252252
Assert.AreEqual("547", ex.Data["HelpLink.EvtID"]);
253253
}
254+
255+
/// <summary>
256+
/// Paren-wrapped value LHS in CHECK: DACFx emits the
257+
/// <c>((value_expr) = (literal))</c> shape (WWI's
258+
/// <c>CK_Sales_SpecialDeals_Exactly_One_NOT_NULL_Pricing_Option_Is_Required</c>
259+
/// is the canonical instance). The boolean parser disambiguates the
260+
/// leading <c>(</c> via a token-lookahead at the matching <c>)</c>.
261+
/// </summary>
262+
[TestMethod]
263+
public void TableLevelCheck_ParenWrappedValueLhs_AcceptsConforming()
264+
=> Assert.AreEqual(1, new Simulation().ExecuteScalar("""
265+
create table t (a int, b int, c int,
266+
check (((case when a is null then 0 else 1 end
267+
+ case when b is null then 0 else 1 end)
268+
+ case when c is null then 0 else 1 end) = (1)));
269+
insert t values (1, null, null);
270+
select count(*) from t
271+
"""));
272+
273+
[TestMethod]
274+
public void TableLevelCheck_ParenWrappedValueLhs_RejectsTwoSet()
275+
{
276+
var ex = Assert.Throws<DbException>(() => new Simulation().ExecuteNonQuery("""
277+
create table t (a int, b int, c int,
278+
constraint ck_one_only check (((case when a is null then 0 else 1 end
279+
+ case when b is null then 0 else 1 end)
280+
+ case when c is null then 0 else 1 end) = (1)));
281+
insert t values (1, 2, null)
282+
"""));
283+
Assert.AreEqual("547", ex.Data["HelpLink.EvtID"]);
284+
Assert.Contains("ck_one_only", ex.Message);
285+
}
286+
287+
[TestMethod]
288+
public void TableLevelCheck_ParenWrappedValueLhs_RejectsAllNull()
289+
{
290+
var ex = Assert.Throws<DbException>(() => new Simulation().ExecuteNonQuery("""
291+
create table t (a int, b int, c int,
292+
check (((case when a is null then 0 else 1 end
293+
+ case when b is null then 0 else 1 end)
294+
+ case when c is null then 0 else 1 end) = (1)));
295+
insert t values (null, null, null)
296+
"""));
297+
Assert.AreEqual("547", ex.Data["HelpLink.EvtID"]);
298+
}
254299
}

0 commit comments

Comments
 (0)