Skip to content

Commit 11aef40

Browse files
committed
Fixed OPENJSON ... WITH ... AS JSON on nvarchar(max) gap.
1 parent dd05b8b commit 11aef40

8 files changed

Lines changed: 175 additions & 35 deletions

File tree

SqlServerSimulator.Tests/OpenJsonTests.cs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,88 @@ public void OpenJson_AsJsonOnNonNVarcharMax_RaisesMsg13618()
184184
13618,
185185
"AS JSON option can be specified only for column of nvarchar(max) type in WITH clause.");
186186

187+
[TestMethod]
188+
public void OpenJson_AsJson_ObjectSubtree_PreservesVerbatimText()
189+
=> AreEqual("{ \"b\" : 2 , \"a\" : 1 }", new Simulation().ExecuteScalar(
190+
"select x from openjson('{ \"o\" : { \"b\" : 2 , \"a\" : 1 } }') with (x nvarchar(max) '$.o' as json)"));
191+
192+
[TestMethod]
193+
public void OpenJson_AsJson_ArraySubtree()
194+
=> AreEqual("[1,2,3]", new Simulation().ExecuteScalar(
195+
"select x from openjson('{\"tags\":[1,2,3]}') with (x nvarchar(max) '$.tags' as json)"));
196+
197+
[TestMethod]
198+
public void OpenJson_AsJson_ScalarUnderLax_Null()
199+
{
200+
using var reader = new Simulation().ExecuteReader(
201+
"select x from openjson('{\"scalar\":42}') with (x nvarchar(max) '$.scalar' as json)");
202+
IsTrue(reader.Read());
203+
IsTrue(reader.IsDBNull(0));
204+
IsFalse(reader.Read());
205+
}
206+
207+
[TestMethod]
208+
public void OpenJson_AsJson_ScalarUnderStrict_RaisesMsg13624()
209+
=> new Simulation().AssertSqlError(
210+
"select x from openjson('{\"scalar\":42}') with (x nvarchar(max) 'strict $.scalar' as json)",
211+
13624,
212+
"Object or array cannot be found in the specified JSON path.");
213+
214+
[TestMethod]
215+
public void OpenJson_AsJson_MissingUnderStrict_RaisesMsg13608_State6()
216+
{
217+
var ex = new Simulation().AssertSqlError(
218+
"select x from openjson('{\"a\":1}') with (x nvarchar(max) 'strict $.missing' as json)",
219+
13608);
220+
AreEqual("Property cannot be found on the specified JSON path.", ex.Message);
221+
AreEqual((byte)6, ex.State);
222+
}
223+
224+
[TestMethod]
225+
public void OpenJson_AsJson_JsonNullUnderStrict_ReturnsNull()
226+
{
227+
using var reader = new Simulation().ExecuteReader(
228+
"select x from openjson('{\"n\":null}') with (x nvarchar(max) 'strict $.n' as json)");
229+
IsTrue(reader.Read());
230+
IsTrue(reader.IsDBNull(0));
231+
IsFalse(reader.Read());
232+
}
233+
234+
[TestMethod]
235+
public void OpenJson_AsJson_MixedScalarAndSubtreeColumns()
236+
{
237+
using var reader = new Simulation().ExecuteReader("""
238+
select name, addr, city from openjson('{"name":"Alice","address":{"city":"NYC"}}')
239+
with (name nvarchar(100) '$.name', addr nvarchar(max) '$.address' as json, city nvarchar(50) '$.address.city')
240+
""");
241+
IsTrue(reader.Read());
242+
AreEqual("Alice", reader.GetString(0));
243+
AreEqual("{\"city\":\"NYC\"}", reader.GetString(1));
244+
AreEqual("NYC", reader.GetString(2));
245+
}
246+
247+
[TestMethod]
248+
public void OpenJson_AsJson_ArraySource_ExtractsSubtreePerElement()
249+
{
250+
using var reader = new Simulation().ExecuteReader("""
251+
select id, meta from openjson('[{"id":1,"meta":{"x":10}},{"id":2,"meta":{"y":20}}]')
252+
with (id int '$.id', meta nvarchar(max) '$.meta' as json)
253+
""");
254+
var rows = new List<(int id, string meta)>();
255+
while (reader.Read())
256+
rows.Add((reader.GetInt32(0), reader.GetString(1)));
257+
CollectionAssert.AreEqual(new[] { (1, "{\"x\":10}"), (2, "{\"y\":20}") }, rows);
258+
}
259+
260+
// JSON_QUERY shares the AS JSON subtree-extraction rule: a strict-mode
261+
// scalar match raises Msg 13624 (lax returns NULL).
262+
[TestMethod]
263+
public void JsonQuery_StrictScalar_RaisesMsg13624()
264+
=> new Simulation().AssertSqlError(
265+
"select json_query('{\"a\":1}', 'strict $.a')",
266+
13624,
267+
"Object or array cannot be found in the specified JSON path.");
268+
187269
// EF Core 10's primitive-collection .Any() shape: EXISTS over a
188270
// typed-OPENJSON subquery with a WHERE filter.
189271
[TestMethod]

SqlServerSimulator/Errors/SimulatedSqlException.JsonErrors.cs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,22 @@ internal static SimulatedSqlException JsonInvalidPath(string path) =>
1515
/// Msg 13608: SQL Server raises this when a <c>strict</c>-mode JSON
1616
/// path resolves through a missing property / out-of-bounds index /
1717
/// non-object-or-array intermediate. Lax mode silently returns NULL
18-
/// instead.
18+
/// instead. The State byte is context-dependent: JSON_VALUE reports
19+
/// state 2, an OPENJSON … WITH column reports state 6, and the
20+
/// JSON_QUERY / JSON_MODIFY default is state 1.
1921
/// </summary>
20-
internal static SimulatedSqlException JsonStrictPathNotFound() =>
21-
new("Property cannot be found on the specified JSON path.", 13608, 16, 1);
22+
internal static SimulatedSqlException JsonStrictPathNotFound(byte state = 1) =>
23+
new("Property cannot be found on the specified JSON path.", 13608, 16, state);
24+
25+
/// <summary>
26+
/// Msg 13624: a <c>strict</c>-mode JSON path under JSON_QUERY (or an
27+
/// <c>OPENJSON … WITH (col … AS JSON)</c> column) resolved to a value
28+
/// that is present but is neither an object nor an array. Lax mode
29+
/// returns NULL instead; a JSON <c>null</c> value returns NULL in
30+
/// both modes.
31+
/// </summary>
32+
internal static SimulatedSqlException JsonObjectOrArrayNotFound() =>
33+
new("Object or array cannot be found in the specified JSON path.", 13624, 16, 1);
2234

2335
/// <summary>
2436
/// Msg 13609: invalid JSON text passed to JSON_VALUE / JSON_QUERY /

SqlServerSimulator/Parser/Expressions/JsonQuery.cs

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -55,19 +55,8 @@ public override SqlValue Run(RuntimeContext runtime)
5555
if (match is null)
5656
return SqlValue.Null(SqlType.NVarchar);
5757

58-
var element = match.Value;
59-
return element.ValueKind switch
60-
{
61-
// Object / Array — JSON_QUERY returns the raw JSON text of
62-
// the subtree (re-serialized via GetRawText, which preserves
63-
// the input's whitespace shape — matching SQL Server's
64-
// observable behavior on round-trip).
65-
JsonValueKind.Object or JsonValueKind.Array => SqlValue.FromNVarchar(element.GetRawText()),
66-
// Scalar match (String / Number / True / False / Null) —
67-
// JSON_QUERY returns NULL in lax mode (Msg 13624 in strict,
68-
// which EF / DACFx never depend on).
69-
_ => SqlValue.Null(SqlType.NVarchar),
70-
};
58+
var subtree = JsonSubtree.Extract(match.Value, path.Mode);
59+
return subtree is null ? SqlValue.Null(SqlType.NVarchar) : SqlValue.FromNVarchar(subtree);
7160
}
7261
}
7362

SqlServerSimulator/Parser/JsonPath.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,16 +130,18 @@ private static void SkipWhitespace(string text, ref int i)
130130
/// the matched element or null when a segment misses (lax mode);
131131
/// raises Msg 13608 in strict mode. The "missing" cases are (a) property
132132
/// name not present in an object, (b) array index out of bounds,
133-
/// (c) traversing into a non-object/non-array.
133+
/// (c) traversing into a non-object/non-array. <paramref name="strictNotFoundState"/>
134+
/// carries the caller's context-specific Msg 13608 State byte (OPENJSON
135+
/// columns report 6; the default 1 matches JSON_QUERY).
134136
/// </summary>
135-
public JsonElement? Walk(JsonElement root)
137+
public JsonElement? Walk(JsonElement root, byte strictNotFoundState = 1)
136138
{
137139
var current = root;
138140
foreach (var segment in this.Segments)
139141
{
140142
var next = TryStep(current, segment);
141143
if (next is null)
142-
return this.Mode == JsonPathMode.Strict ? throw SimulatedSqlException.JsonStrictPathNotFound() : null;
144+
return this.Mode == JsonPathMode.Strict ? throw SimulatedSqlException.JsonStrictPathNotFound(strictNotFoundState) : null;
143145
current = next.Value;
144146
}
145147
return current;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using System.Text.Json;
2+
3+
namespace SqlServerSimulator.Parser;
4+
5+
/// <summary>
6+
/// The JSON_QUERY-style subtree-extraction rule shared by <c>JSON_QUERY</c>
7+
/// and the <c>OPENJSON … WITH (col … AS JSON)</c> column modifier: an
8+
/// object/array match yields its verbatim source text (whitespace and
9+
/// key order preserved, via <see cref="JsonElement.GetRawText"/>), a JSON
10+
/// <c>null</c> yields SQL NULL, and any other (non-null) scalar yields SQL
11+
/// NULL under lax mode or Msg 13624 under strict.
12+
/// </summary>
13+
internal static class JsonSubtree
14+
{
15+
/// <summary>
16+
/// Classifies an already-resolved JSON element. Returns the verbatim
17+
/// subtree text for an object/array; <c>null</c> for a JSON-null match
18+
/// or — under lax mode — a non-null scalar. Raises Msg 13624 for a
19+
/// non-null scalar under strict mode.
20+
/// </summary>
21+
public static string? Extract(JsonElement element, JsonPathMode mode) => element.ValueKind switch
22+
{
23+
JsonValueKind.Object or JsonValueKind.Array => element.GetRawText(),
24+
JsonValueKind.Null => null,
25+
_ => mode == JsonPathMode.Strict ? throw SimulatedSqlException.JsonObjectOrArrayNotFound() : null,
26+
};
27+
}

SqlServerSimulator/Parser/Selection.OpenJson.cs

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,17 @@ private static IEnumerable<byte[]> EnumerateOpenJsonRows(
104104
root = match.Value;
105105
}
106106

107-
if (root.ValueKind == JsonValueKind.Array)
107+
// An explicit WITH schema evaluates its column paths relative to
108+
// the root document: an array root yields one row per element
109+
// (paths relative to the element), an object root yields a single
110+
// row (paths relative to the root). The default (key, value, type)
111+
// schema instead unfolds the root — one row per array element or
112+
// per object property.
113+
if (root.ValueKind == JsonValueKind.Object && withColumns is not null)
114+
{
115+
yield return BuildOpenJsonRow(root, withColumns, schema, key: "");
116+
}
117+
else if (root.ValueKind == JsonValueKind.Array)
108118
{
109119
var index = 0;
110120
foreach (var element in root.EnumerateArray())
@@ -153,7 +163,7 @@ private static byte[] BuildOpenJsonRow(JsonElement element, OpenJsonColumn[]? wi
153163
for (var i = 0; i < withColumns.Length; i++)
154164
{
155165
var column = withColumns[i];
156-
var matched = column.Path.Walk(element);
166+
var matched = column.Path.Walk(element, strictNotFoundState: 6);
157167
values[i] = matched is null
158168
? SqlValue.Null(column.Type)
159169
: ExtractColumnValue(matched.Value, column);
@@ -169,6 +179,15 @@ private static byte[] BuildOpenJsonRow(JsonElement element, OpenJsonColumn[]? wi
169179
/// </summary>
170180
private static SqlValue ExtractColumnValue(JsonElement element, OpenJsonColumn column)
171181
{
182+
if (column.AsJson)
183+
{
184+
// AS JSON: return the matched object/array subtree as verbatim
185+
// text. A JSON null or (under lax) a non-null scalar yields NULL;
186+
// a non-null scalar under strict raised Msg 13624 inside Extract.
187+
var subtree = JsonSubtree.Extract(element, column.Path.Mode);
188+
return subtree is null ? SqlValue.Null(column.Type) : SqlValue.FromNVarchar(subtree).CoerceTo(column.Type);
189+
}
190+
172191
if (element.ValueKind == JsonValueKind.Null)
173192
return SqlValue.Null(column.Type);
174193

@@ -252,9 +271,8 @@ public static Selection ParseOpenJson(ParserContext context, Func<MultiPartName,
252271
/// Parses the body of an OPENJSON <c>WITH (col TYPE [path] [AS JSON], ...)</c>
253272
/// clause. Enters with <see cref="ParserContext.Token"/> on the
254273
/// <c>WITH</c> keyword; on return Token sits on the closing <c>)</c>.
255-
/// <c>AS JSON</c> isn't modeled — raises NotSupportedException so users
256-
/// get a diagnostic when they try owned-many-as-JSON-with-AS-JSON
257-
/// shapes (EF Core 10 doesn't emit these).
274+
/// <c>AS JSON</c> is accepted only on <c>nvarchar(max)</c> columns
275+
/// (Msg 13618 otherwise) and flags the column for subtree extraction.
258276
/// </summary>
259277
private static OpenJsonColumn[] ParseOpenJsonWithColumns(ParserContext context, Func<MultiPartName, SqlType>? outerTypeResolver)
260278
{
@@ -320,21 +338,25 @@ private static OpenJsonColumn[] ParseOpenJsonWithColumns(ParserContext context,
320338
path = JsonPath.Parse("$." + columnName);
321339
}
322340

323-
// AS JSON modifier — real SQL Server only accepts it on
341+
// AS JSON modifier — real SQL Server accepts it only on
324342
// nvarchar(max) columns and raises Msg 13618 for any other type
325-
// (probe-confirmed). The simulator emits the matching rejection
326-
// for non-nvarchar(max) types but still raises NotSupportedException
327-
// for the accepted nvarchar(max) shape, which the simulator hasn't
328-
// built the surrounding sub-tree extraction for. EF Core 10 doesn't
329-
// emit the AS JSON modifier.
343+
// (probe-confirmed). The matched object/array subtree is later
344+
// extracted as verbatim JSON text; see ExtractColumnValue.
345+
var asJson = false;
330346
if (context.Token is ReservedKeyword { Keyword: Keyword.As })
331347
{
348+
if (context.GetNextRequired() is not Name { Value: var modifier }
349+
|| !string.Equals(modifier, "JSON", StringComparison.OrdinalIgnoreCase))
350+
{
351+
throw SimulatedSqlException.SyntaxErrorNear(context);
352+
}
332353
if (resolvedType is not NVarcharSqlType { length: SqlType.MaxLengthSentinel })
333354
throw SimulatedSqlException.OpenJsonAsJsonRequiresNVarcharMax();
334-
throw new NotSupportedException("OPENJSON column-level AS JSON modifier on nvarchar(max) isn't modeled.");
355+
asJson = true;
356+
context.MoveNextRequired();
335357
}
336358

337-
columns.Add(new OpenJsonColumn(columnName, resolvedType, path));
359+
columns.Add(new OpenJsonColumn(columnName, resolvedType, path, asJson));
338360

339361
if (context.Token is Operator { Character: ')' })
340362
break;
@@ -351,9 +373,14 @@ private static OpenJsonColumn[] ParseOpenJsonWithColumns(ParserContext context,
351373
/// is parsed once at FROM-source-parse time (not per row) since the WITH
352374
/// clause's path argument is always a string literal.
353375
/// </summary>
354-
internal sealed class OpenJsonColumn(string name, SqlType type, JsonPath path)
376+
internal sealed class OpenJsonColumn(string name, SqlType type, JsonPath path, bool asJson)
355377
{
356378
public readonly string Name = name;
357379
public readonly SqlType Type = type;
358380
public readonly JsonPath Path = path;
381+
382+
/// <summary>The <c>AS JSON</c> modifier was present: the column extracts
383+
/// the matched object/array subtree as verbatim JSON text rather than
384+
/// coercing a scalar leaf to <see cref="Type"/>.</summary>
385+
public readonly bool AsJson = asJson;
359386
}

docs/claude/function-coverage-todo.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,6 @@ Functions that exist only in Azure SQL Database / Fabric surfaces, not in the SQ
144144

145145
Real bugs / limitations against shipped functions — fixes are tickable work, not design decisions.
146146

147-
- [ ] **OPENJSON ... WITH ... AS JSON** on `nvarchar(max)` raises `NotSupportedException` (sub-tree extraction missing); non-`nvarchar(max)` raises Msg 13618.
148147
- [ ] **REPLICATE** of a MAX-typed *column* reference truncates to 8000 bytes (parse-time type resolver doesn't reach FROM-source columns; literal / CAST-target inputs work).
149148
- [ ] **GROUPING / GROUPING_ID** only accept `Reference` arguments — `GROUPING(a+1)` paired with `GROUP BY a+1` always raises Msg 8161 instead of matching structurally.
150149
- [ ] **STRING_SPLIT(..., ..., CAST(@v AS INT))** wrapped-variable accepted; real SQL Server rejects all variable-bearing `enable_ordinal` shapes regardless of wrapping.

docs/claude/json.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ Value formatting matches real SQL Server byte-for-byte except float / real (docu
2121
- other strings → JSON-escaped (`\"` `\\` `\b` `\f` `\n` `\r` `\t` `\uHHHH` for control chars; non-ASCII / `/` / `<` / `>` left literal)
2222
- nested `JSON_OBJECT` / `JSON_ARRAY` / `JSON_QUERY` results — embedded **raw** (not re-quoted), via compile-time `JsonValueRender.ProducesJson(Expression)` detection that unwraps `Parenthesized`. Other strings — including `'{"x":1}'` literals — go through the quote-and-escape path, matching SQL Server's JSON-typed-input detection without needing an `SqlValue`-level marker bit.
2323

24-
`OPENJSON(json [, doc_path]) [WITH (col TYPE [path] [AS JSON], …)]` — rowset-returning, structurally a new FromSource kind. Without WITH: default schema `(key nvarchar, value nvarchar, type int)` — type codes 0=null/1=string/2=number/3=bool/4=array/5=object. With WITH: each column extracts via `$.<col-name>` (default) or explicit `'$path'`; primitive collections use `'$'`. `AS JSON` modifier → `NotSupportedException` on `nvarchar(max)` (sub-tree extraction not modeled, even though real SQL Server accepts the column form there); on a non-`nvarchar(max)` column `AS JSON` raises **Msg 13618**, matching real. NULL/invalid JSON → zero rows under lax.
24+
`OPENJSON(json [, doc_path]) [WITH (col TYPE [path] [AS JSON], …)]` — rowset-returning, structurally a new FromSource kind. Without WITH: default schema `(key nvarchar, value nvarchar, type int)` — type codes 0=null/1=string/2=number/3=bool/4=array/5=object, unfolding the root one row per array element / object property. With WITH: column paths are root-relative — an **array root yields one row per element** (paths relative to the element), an **object root yields a single row** (paths relative to the root). Each column extracts via `$.<col-name>` (default) or explicit `'$path'`; primitive collections use `'$'`. NULL/invalid JSON → zero rows under lax.
25+
26+
`AS JSON` column modifier — accepted only on `nvarchar(max)` (any other declared type raises **Msg 13618** at parse). Extracts the matched subtree via the shared `JsonSubtree.Extract` (the same rule backing `JSON_QUERY`): object/array → verbatim source text (whitespace and key order preserved, via `JsonElement.GetRawText`); JSON `null` → SQL NULL in both modes; any other (non-null) scalar → SQL NULL in lax, **Msg 13624** in strict; a missing path → SQL NULL in lax, **Msg 13608 State 6** in strict (the OPENJSON-context state, threaded through `JsonPath.Walk`'s `strictNotFoundState` — JSON_VALUE reports State 2, JSON_QUERY/JSON_MODIFY State 1).
2527

2628
OPENJSON WITH-clause types: `int`/`bigint`/`decimal(p,s)`/`float`/`bit`/`nvarchar(N|max)`/`varchar(N)`/`date`/`datetime2(N)`/`datetimeoffset(N)`/`uniqueidentifier`. Coercion via `SqlValue.CoerceTo`. Backed by `System.Text.Json`. JSON-path quoted-property escape `""` → literal `"`.
2729

0 commit comments

Comments
 (0)