Skip to content

Commit 4edabac

Browse files
committed
feat(mssql-json): enable JSON data type - type mapping, error codes and fixtures (Phase 2)
Treat SQL Server 2025+ JSON columns exactly like string columns (2026-06-09 design). Two-line production delta plus test fixture and unit tests (T002-T007). - TypeHelper._sqlDbTypeToType[SqlDbType.Json] = typeof(string) (T004) - MsSqlDbExceptionParser BadRequestExceptionCodes += 13608-13614 so SQL JSON errors map to HTTP 400 (T006) - SqlTypeConstants: json -> supported (T004) - profiles table + 5 seed rows + Profile entity fixture (T002/T003) - unit tests for json type mapping and 400 error mapping (T005/T007) No changes to PostgreSQL/MySQL/DwSql/CosmosDB (FR-012).
1 parent e16e87e commit 4edabac

8 files changed

Lines changed: 198 additions & 7 deletions

File tree

specs/001-mssql-json-type/tasks.md

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,90 @@ both prerequisites and fails fast if either is missing.
5252

5353
---
5454

55+
## PR Delivery Plan (phased)
56+
57+
This feature ships as **4 phased PRs**. "Phase" here is the *delivery /
58+
PR* phase (coarse-grained) and is distinct from the fine-grained
59+
`## Phase 1..7` execution sections further down this file. The
60+
mapping column ties each PR back to those execution sections and task
61+
IDs.
62+
63+
The **production delta is only two functional lines** (T004 + T006);
64+
every other task is a test fixture or an integration test. The split
65+
below is therefore driven by review load, not by risk to `main`.
66+
67+
### Phase 1 — .NET 10 + SqlClient 6.x upgrade ✅ MERGED (PR #3656)
68+
69+
**Title**: `[Phase 1] Upgrade to .NET 10 and Microsoft.Data.SqlClient 6.x`
70+
71+
**Description**:
72+
> Joint prerequisite for MSSQL JSON support. Bumps the target framework
73+
> `net8.0 → net10.0` across every `src/**/*.csproj`, pins the SDK in
74+
> `global.json` (`10.0.x`), updates `dotnet-version` in CI workflows,
75+
> and upgrades `Microsoft.Data.SqlClient 5.2.3 → 6.x`. Required because
76+
> `SqlDbType.Json` is a BCL enum value (added in .NET 9) that does not
77+
> resolve on .NET 8 regardless of SqlClient version. Also refreshes the
78+
> SqlClient SNI license under `external_licenses/` and regenerates the
79+
> NOTICE file. No functional DAB behavior change.
80+
81+
**Covers**: upstream dependency (this file's "Upstream Dependency" section) · **Status**: merged, `main` now ships `net10.0` + SqlClient 6.1.5.
82+
83+
### Phase 2 — JSON engine enablement ← current PR
84+
85+
**Title**: `[Phase 2] Enable MSSQL JSON data type — type mapping, error codes & fixtures`
86+
87+
**Description**:
88+
> Delivers the entire functional behavior for SQL Server 2025+ `JSON`
89+
> columns by treating them exactly like `string` columns (2026-06-09
90+
> design). Two-line production delta:
91+
> - `TypeHelper._sqlDbTypeToType[SqlDbType.Json] = typeof(string)` so
92+
> `JSON` columns flow through every existing string read/write path.
93+
> - `MsSqlDbExceptionParser.BadRequestExceptionCodes += 13608–13614`
94+
> so SQL Server JSON-validation errors surface as HTTP `400` (not `500`).
95+
>
96+
> Adds the `Profile` / `profiles` test fixture (table + 5 seed rows +
97+
> DAB entity) and two unit tests. The 13608–13614 range is the starting
98+
> set and is pruned to the actually-triggered numbers in Phase 4.
99+
> No changes to PostgreSQL / MySQL / DwSql / CosmosDB (FR-012).
100+
101+
**Covers**: execution Phases 2–4 · **Tasks**: T002–T007 · **FRs**: FR-001, FR-004, FR-007, FR-012, FR-014.
102+
103+
### Phase 3 — CRUD integration tests
104+
105+
**Title**: `[Phase 3] MSSQL JSON — CRUD integration tests (REST / GraphQL / MCP)`
106+
107+
**Description**:
108+
> Test-only PR (no production code). Pins the happy-path behavior of
109+
> JSON columns across all surfaces under `TestCategory=MsSql`:
110+
> schema-discovery (OpenAPI `type: string`, GraphQL `String` scalar,
111+
> MCP describe_entities parity — US1), read single & collection
112+
> (US2/US3), insert (US4), update via PUT/PATCH/GraphQL (US5) and
113+
> delete (US6). Confirms Phase 2's engine change is correct end-to-end.
114+
115+
**Covers**: execution Phase 5, stories US1–US6 · **Tasks**: T008–T019 · **FRs**: FR-001, FR-002, FR-003, FR-004, FR-005, FR-006, FR-008, FR-011, FR-013, FR-017.
116+
117+
### Phase 4 — Errors, filters, edge cases, regression & polish
118+
119+
**Title**: `[Phase 4] MSSQL JSON — error handling, filter/orderby pass-through, edge cases & regression`
120+
121+
**Description**:
122+
> Test-only PR that completes coverage and hardens the feature.
123+
> - SQL Server JSON errors → HTTP `400` / GraphQL `BAD_REQUEST` with the
124+
> SQL error number in the body (US7); uses these tests to **prune** the
125+
> speculative 13608–13614 error-code set added in Phase 2 to the
126+
> numbers SQL Server actually raises.
127+
> - `$filter` / `$orderby` pass-through in both success and SQL-rejected
128+
> directions (US9) — no DAB operator allow-list; everything forwards to
129+
> SQL Server.
130+
> - Null handling (US8) and edge cases: unicode round-trip, nested object,
131+
> array payload.
132+
> - Non-MSSQL regression guard (diff + baseline test counts for
133+
> PostgreSQL / MySQL / CosmosDB), `dotnet format`, and quickstart smoke.
134+
135+
**Covers**: execution Phase 5 (US7–US9 + edge cases) and Phases 6–7 · **Tasks**: T020–T034 · **FRs**: FR-007, FR-008, FR-009, FR-010, FR-016, SC-001, SC-002, SC-004.
136+
137+
---
138+
55139
## Phase 1: Pre-flight (Blocking)
56140

57141
**Purpose**: Confirm the upstream prerequisite PR has landed before
@@ -73,8 +157,8 @@ files.
73157
**Purpose**: Create the `Profile` / `profiles` test fixture so
74158
integration tests have a database table and DAB entity to bind to.
75159

76-
- [ ] T002 Add the `profiles` table and the 5 seed rows defined in [data-model.md](./data-model.md) §Table Definition to [src/Service.Tests/DatabaseSchema-MsSql.sql](../../src/Service.Tests/DatabaseSchema-MsSql.sql). Format per copilot-instructions.md MSSQL rules (poorsql, 4-space indent, trailing commas). **MUST NOT touch** `DatabaseSchema-PostgreSql.sql`, `DatabaseSchema-MySql.sql`, `DatabaseSchema-DwSql.sql`, or any CosmosDB schema file (FR-012, Principle I).
77-
- [ ] T003 Add the `Profile` entity entry to [src/Service.Tests/dab-config.MsSql.json](../../src/Service.Tests/dab-config.MsSql.json) (source object `dbo.profiles`, anonymous read role for tests as per existing fixture patterns), per [data-model.md](./data-model.md) §DAB Entity. Run `dab validate` against the updated config (no [schemas/dab.draft.schema.json](../../schemas/dab.draft.schema.json) change expected per FR-014 / R11).
160+
- [x] T002 Add the `profiles` table and the 5 seed rows defined in [data-model.md](./data-model.md) §Table Definition to [src/Service.Tests/DatabaseSchema-MsSql.sql](../../src/Service.Tests/DatabaseSchema-MsSql.sql). Format per copilot-instructions.md MSSQL rules (poorsql, 4-space indent, trailing commas). **MUST NOT touch** `DatabaseSchema-PostgreSql.sql`, `DatabaseSchema-MySql.sql`, `DatabaseSchema-DwSql.sql`, or any CosmosDB schema file (FR-012, Principle I).
161+
- [x] T003 Add the `Profile` entity entry to [src/Service.Tests/dab-config.MsSql.json](../../src/Service.Tests/dab-config.MsSql.json) (source object `dbo.profiles`, anonymous read role for tests as per existing fixture patterns), per [data-model.md](./data-model.md) §DAB Entity. Run `dab validate` against the updated config (no [schemas/dab.draft.schema.json](../../schemas/dab.draft.schema.json) change expected per FR-014 / R11).
78162

79163
**Checkpoint**: Test fixture exists; integration tests added in Phase 5 will resolve their entity references. No production behavior change yet.
80164

@@ -86,8 +170,8 @@ integration tests have a database table and DAB entity to bind to.
86170
(R1, R2). Single-line dictionary edit; once landed, JSON columns flow
87171
through every read / write path that already supports `string`.
88172

89-
- [ ] T004 Add `[SqlDbType.Json] = typeof(string)` to `TypeHelper._sqlDbTypeToType` in [src/Core/Services/TypeHelper.cs](../../src/Core/Services/TypeHelper.cs) (R1). Position the entry adjacent to the existing `NVarChar` / `Text` entries to keep the dictionary readable.
90-
- [ ] T005 [P] Unit test: extend the `TypeHelper` test suite (under [src/Service.Tests](../../src/Service.Tests)) to assert `GetSystemTypeFromSqlDbType("json")` returns `typeof(string)` and that `GetJsonDataTypeFromSystemType(typeof(string))` continues to return `JsonDataType.String` (regression check for R2).
173+
- [x] T004 Add `[SqlDbType.Json] = typeof(string)` to `TypeHelper._sqlDbTypeToType` in [src/Core/Services/TypeHelper.cs](../../src/Core/Services/TypeHelper.cs) (R1). Position the entry adjacent to the existing `NVarChar` / `Text` entries to keep the dictionary readable.
174+
- [x] T005 [P] Unit test: extend the `TypeHelper` test suite (under [src/Service.Tests](../../src/Service.Tests)) to assert `GetSystemTypeFromSqlDbType("json")` returns `typeof(string)` and that `GetJsonDataTypeFromSystemType(typeof(string))` continues to return `JsonDataType.String` (regression check for R2).
91175

92176
**Checkpoint**: Once T004 is merged, `MsSqlMetadataProvider.SqlToCLRType` resolves JSON columns; `OpenApiDocumentor` and `SchemaConverter` emit `string` / `String` automatically. Phase 5 US1 integration tests transition from red to green.
93177

@@ -101,8 +185,8 @@ SQL errors surface as `400` instead of `500`. Covers BOTH malformed
101185
JSON on write AND filter operators SQL Server cannot evaluate
102186
against a JSON column (FR-007; R4 as elevated 2026-06-09).
103187

104-
- [ ] T006 Extend `MsSqlDbExceptionParser.BadRequestExceptionCodes` in [src/Core/Resolvers/MsSqlDbExceptionParser.cs](../../src/Core/Resolvers/MsSqlDbExceptionParser.cs) to include the SQL Server JSON-validation error numbers (R4 lists 13608–13614 as the starting set). PRUNE the speculative set to the actually-triggered numbers using T013 / T014 / T017 / T018 during implementation. Verify that the response body includes the SQL Server error number (R4 notes the existing error envelope serializer may need a small adjustment — verify and amend in this same task or call out a follow-up).
105-
- [ ] T007 [P] Unit test: add a `MsSqlDbExceptionParserTests` case (under [src/Service.Tests](../../src/Service.Tests)) asserting that a `SqlException` carrying each of the appended error numbers maps to `HttpStatusCode.BadRequest` via `GetHttpStatusCodeForException`, while a still-unknown number maps to its prior status (regression). Uses the existing `MakeSqlException`-style helper if present, else mocks `SqlException` via reflection per existing test patterns.
188+
- [x] T006 Extend `MsSqlDbExceptionParser.BadRequestExceptionCodes` in [src/Core/Resolvers/MsSqlDbExceptionParser.cs](../../src/Core/Resolvers/MsSqlDbExceptionParser.cs) to include the SQL Server JSON-validation error numbers (R4 lists 13608–13614 as the starting set). PRUNE the speculative set to the actually-triggered numbers using T013 / T014 / T017 / T018 during implementation. Verify that the response body includes the SQL Server error number (R4 notes the existing error envelope serializer may need a small adjustment — verify and amend in this same task or call out a follow-up).
189+
- [x] T007 [P] Unit test: add a `MsSqlDbExceptionParserTests` case (under [src/Service.Tests](../../src/Service.Tests)) asserting that a `SqlException` carrying each of the appended error numbers maps to `HttpStatusCode.BadRequest` via `GetHttpStatusCodeForException`, while a still-unknown number maps to its prior status (regression). Uses the existing `MakeSqlException`-style helper if present, else mocks `SqlException` via reflection per existing test patterns.
106190

107191
**Checkpoint**: Once T006 lands, every SQL Server JSON error surfaces as 400 / `BAD_REQUEST` with the SQL error number in the response body. T013 / T014 (malformed write) and T017 / T018 (unsupported filter operator) go green.
108192

src/Core/Models/SqlTypeConstants.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ public static class SqlTypeConstants
4747
{ "time", true }, // SqlDbType.Time
4848
{ "datetime2", true }, // SqlDbType.DateTime2
4949
{ "datetimeoffset", true }, // SqlDbType.DateTimeOffset
50+
{ "json", true }, // SqlDbType.Json (SQL Server 2025+) treated as string
5051
{ "", false }, // SqlDbType.Udt and SqlDbType.Structured provided by SQL as empty strings (unsupported)
5152
{ "numeric", true} // Not present in SqlDbType, however can be returned by sql functions like LAG and should map to decimal.
5253
};

src/Core/Resolvers/MsSqlDbExceptionParser.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ public MsSqlDbExceptionParser(RuntimeConfigProvider configProvider) : base(confi
3030
"4435", "4436", "4437", "4438", "4439", "4440", "4441",
3131
"4442", "4443", "4444", "4445", "4446", "4447", "4448",
3232
"4450", "4451", "4452", "4453", "4454", "4455", "4456",
33-
"4457", "4933", "4934", "4936", "4988", "8102"
33+
"4457", "4933", "4934", "4936", "4988", "8102",
34+
// JSON data type validation errors (SQL Server 2025+). Invalid JSON
35+
// supplied for a json column is a client error, mapped to HTTP 400.
36+
"13608", "13609", "13610", "13611", "13612", "13613", "13614"
3437
});
3538

3639
TransientExceptionCodes.UnionWith(new List<string>

src/Core/Services/TypeHelper.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ public static class TypeHelper
9797
[SqlDbType.Float] = typeof(double),
9898
[SqlDbType.Image] = typeof(byte[]),
9999
[SqlDbType.Int] = typeof(int),
100+
[SqlDbType.Json] = typeof(string),
100101
[SqlDbType.Money] = typeof(decimal),
101102
[SqlDbType.NChar] = typeof(char),
102103
[SqlDbType.NText] = typeof(string),

src/Service.Tests/DatabaseSchema-MsSql.sql

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ DROP TABLE IF EXISTS mappedbookmarks;
5757
DROP TABLE IF EXISTS fte_data;
5858
DROP TABLE IF EXISTS intern_data;
5959
DROP TABLE IF EXISTS books_sold;
60+
DROP TABLE IF EXISTS profiles;
6061
DROP TABLE IF EXISTS default_with_function_table;
6162
DROP TABLE IF EXISTS [DimAccount]
6263
DROP TABLE IF EXISTS date_only_table;
@@ -826,3 +827,17 @@ INSERT INTO date_only_table( event_date, event_time, event_timestamp)
826827
VALUES ('2023-01-01', '08:30:00', '2023-01-01 08:30:00'),
827828
('2023-02-15', '12:45:00', '2023-02-15 12:45:00'),
828829
('2023-03-30', '17:15:00', '2023-03-30 17:15:00');
830+
831+
CREATE TABLE profiles (
832+
id int IDENTITY(1, 1) PRIMARY KEY,
833+
metadata json NULL
834+
);
835+
836+
SET IDENTITY_INSERT profiles ON
837+
INSERT INTO profiles (id, metadata)
838+
VALUES (1, N'{"role":"admin","tier":3}'),
839+
(2, N'{"tags":["a","b","c"]}'),
840+
(3, N'{"nested":{"key":{"deep":true}}}'),
841+
(4, N'{"unicode":"éü😀"}'),
842+
(5, NULL);
843+
SET IDENTITY_INSERT profiles OFF

src/Service.Tests/OpenApiDocumentor/CLRtoJsonValueTypeUnitTests.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,17 @@ public void ResolveUnderlyingTypeForNullableValueType(Type nullableType)
111111
Assert.AreNotEqual(notExpected: JsonDataType.Undefined, actual: TypeHelper.GetJsonDataTypeFromSystemType(nullableType));
112112
Assert.IsNotNull(TypeHelper.GetDbTypeFromSystemType(nullableType));
113113
}
114+
115+
/// <summary>
116+
/// Validates that the SQL Server 2025+ 'json' data type literal resolves to the
117+
/// CLR string type and maps to JsonDataType.String, i.e. DAB treats a JSON column
118+
/// just like a string column.
119+
/// </summary>
120+
[TestMethod]
121+
public void JsonSqlDbTypeResolvesToString()
122+
{
123+
Type resolvedType = TypeHelper.GetSystemTypeFromSqlDbType("json");
124+
Assert.AreEqual(typeof(string), resolvedType);
125+
Assert.AreEqual(JsonDataType.String, TypeHelper.GetJsonDataTypeFromSystemType(resolvedType));
126+
}
114127
}

src/Service.Tests/UnitTests/DbExceptionParserUnitTests.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,5 +94,42 @@ public void TestIsTransientExceptionMethod(bool expected, int number)
9494

9595
Assert.AreEqual(expected, dbExceptionParser.IsTransientException(SqlTestHelper.CreateSqlException(number)));
9696
}
97+
98+
/// <summary>
99+
/// Validates that JSON data type validation error codes raised by SQL Server 2025+
100+
/// when invalid JSON is supplied for a json column are mapped to HTTP 400 Bad Request.
101+
/// </summary>
102+
/// <param name="number">SQL error code populated in SqlException.Number.</param>
103+
[DataTestMethod]
104+
[DataRow(13608, DisplayName = "JSON validation error code 13608 maps to 400")]
105+
[DataRow(13609, DisplayName = "JSON validation error code 13609 maps to 400")]
106+
[DataRow(13610, DisplayName = "JSON validation error code 13610 maps to 400")]
107+
[DataRow(13611, DisplayName = "JSON validation error code 13611 maps to 400")]
108+
[DataRow(13612, DisplayName = "JSON validation error code 13612 maps to 400")]
109+
[DataRow(13613, DisplayName = "JSON validation error code 13613 maps to 400")]
110+
[DataRow(13614, DisplayName = "JSON validation error code 13614 maps to 400")]
111+
public void TestJsonValidationErrorsMapToBadRequest(int number)
112+
{
113+
RuntimeConfig mockConfig = new(
114+
Schema: "",
115+
DataSource: new(DatabaseType.MSSQL, "", new()),
116+
Runtime: new(
117+
Rest: new(),
118+
GraphQL: new(),
119+
Mcp: new(),
120+
Host: new(null, null, HostMode.Development)
121+
),
122+
Entities: new(new Dictionary<string, Entity>())
123+
);
124+
MockFileSystem fileSystem = new();
125+
fileSystem.AddFile(FileSystemRuntimeConfigLoader.DEFAULT_CONFIG_FILE_NAME, new MockFileData(mockConfig.ToJson()));
126+
FileSystemRuntimeConfigLoader loader = new(fileSystem);
127+
RuntimeConfigProvider provider = new(loader);
128+
DbExceptionParser dbExceptionParser = new MsSqlDbExceptionParser(provider);
129+
130+
Assert.AreEqual(
131+
System.Net.HttpStatusCode.BadRequest,
132+
dbExceptionParser.GetHttpStatusCodeForException(SqlTestHelper.CreateSqlException(number)));
133+
}
97134
}
98135
}

src/Service.Tests/dab-config.MsSql.json

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3991,6 +3991,43 @@
39913991
}
39923992
]
39933993
},
3994+
"Profile": {
3995+
"source": {
3996+
"object": "profiles",
3997+
"type": "table",
3998+
"key-fields": [
3999+
"id"
4000+
]
4001+
},
4002+
"graphql": {
4003+
"enabled": true,
4004+
"type": {
4005+
"singular": "Profile",
4006+
"plural": "Profiles"
4007+
}
4008+
},
4009+
"rest": {
4010+
"enabled": true
4011+
},
4012+
"permissions": [
4013+
{
4014+
"role": "anonymous",
4015+
"actions": [
4016+
{
4017+
"action": "*"
4018+
}
4019+
]
4020+
},
4021+
{
4022+
"role": "authenticated",
4023+
"actions": [
4024+
{
4025+
"action": "*"
4026+
}
4027+
]
4028+
}
4029+
]
4030+
},
39944031
"GetBooksAuth": {
39954032
"source": {
39964033
"object": "get_books",

0 commit comments

Comments
 (0)