Skip to content

Commit 32680cc

Browse files
committed
JSON_VALUE / JSON_MODIFY / OPENJSON: read + partial-update for OwnsOne(...).ToJson() owned types and primitive collections (List<int> / List<string>). Three dispatch points — JSON_VALUE / JSON_MODIFY drop into ResolveBuiltIn (length 10 / 11); OPENJSON wins at ParseSingleFromSource's case-Name head (case-insensitive name match, parallel to SQL Server's reservation). OPENJSON implemented via Selection.FromOpenJson factory so the existing alias / qualifier / lateral-re-execution machinery transparently covers it. Shared JsonPath parser (lax/strict $[.foo|[N]]+) backs runtime walks via JsonElement (read) and JsonNode (modify) plus parse-time per-column path resolution in OPENJSON's WITH clause. System.Text.Json (runtime-shipped) for parse + mutation. Lax-default semantics; strict honored only in JSON_MODIFY's missing-leaf path (Msg 13608). OPENJSON WITH-clause types: int / bigint / decimal / float / bit / nvarchar / varchar / date / datetime2 / datetimeoffset / uniqueidentifier; AS JSON modifier raises NotSupportedException.
1 parent 67d13f8 commit 32680cc

12 files changed

Lines changed: 1395 additions & 0 deletions

File tree

.editorconfig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ dotnet_diagnostic.IDE0040.severity = none # Add accessibility modifiers
1919
dotnet_diagnostic.IDE0061.severity = none # Use block body for local function
2020
dotnet_diagnostic.IDE0072.severity = none # Add missing cases
2121

22+
dotnet_diagnostic.JSON002.severity = none # Probable JSON string detected
23+
2224
dotnet_diagnostic.MSTEST0017.severity = error # Assertion arguments should be passed in the correct order (expected then actual)
2325
dotnet_diagnostic.MSTEST0024.severity = error # Do not store TestContext in a static or readonly field
2426
dotnet_diagnostic.MSTEST0025.severity = error # Use 'Assert.Fail' instead of always-failing constructs

CLAUDE.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,28 @@ Projection-count vs insert-list mismatch fires at parse time via `selection.Sche
365365

366366
EF Core 10 doesn't emit `INSERT … SELECT` from SaveChanges (which uses INSERT…OUTPUT VALUES for single-row and MERGE for batched-multi-row); this is reachable from raw SQL (`FromSqlInterpolated` / direct command text) and from application-side bulk-copy patterns. CTE-prefix INSERTs (`WITH … INSERT t SELECT …`) aren't modeled — orthogonal to this bundle since the simulator has no CTE support.
367367

368+
### JSON support: `JSON_VALUE` / `JSON_MODIFY` / `OPENJSON`
369+
Three pieces unlock EF Core 10's owned-types-as-JSON (`OwnsOne(...).ToJson()`) and primitive-collection (`List<int>` / `List<string>` etc.) emissions. JSON columns are plain `nvarchar(max)` — no special storage type. Probe-confirmed against SQL Server 2025 on 2026-05-10.
370+
371+
`JSON_VALUE(json, path)` — scalar function returning `nvarchar`. Lax mode (default and EF Core's only emitted form): missing path / non-scalar match → SQL NULL. `strict $.foo` raises **Msg 13608** on miss. NULL `json` or NULL path → NULL. JSON booleans render as lowercase `'true'` / `'false'`; JSON numbers return raw text via `JsonElement.GetRawText` (e.g. `'42'`, `'1.5'`). Object / array matches return NULL in lax (the documented scalar-only restriction); strict raises but EF Core never depends on it.
372+
373+
`JSON_MODIFY(json, path, newValue)` — scalar function returning `nvarchar`. Replaces the value at `path` with `newValue`. EF Core 10 emits `'strict $.City'`-shape paths from owned-as-JSON partial updates; the strict prefix is honored (missing leaf → Msg 13608). Bare `'$'` replaces the entire document. Lax-mode existing-key + NULL value removes the key; lax-mode missing key + non-NULL value adds it. Numeric / boolean `newValue` arguments stay JSON-typed (`{"n":42}` not `{"n":"42"}`); System.Text.Json `JsonValue.Create` handles primitive→JSON-text dispatch.
374+
375+
`OPENJSON(json [, doc_path]) [WITH (col TYPE [path] [AS JSON], …)]` — rowset-returning function, structurally a new FromSource kind. Implemented via a `Selection.FromOpenJson(...)` factory (parallel to derived tables / CTEs / VALUES) so the existing alias / qualifier / lateral re-execution machinery transparently covers it. Without WITH: default schema `(key nvarchar, value nvarchar, type int)` — type codes 0=null / 1=string / 2=number / 3=true-or-false / 4=array / 5=object. With WITH: each column extracts via `$.<col-name>` (default) or explicit `'$path'`; primitive collections use the `'$'` self-reference shape. `AS JSON` modifier raises `NotSupportedException` (EF Core 10 doesn't emit it). NULL JSON / invalid JSON → zero rows under lax mode (matches EF's tolerance). For arrays: one row per element with `key` = decimal index. For objects: one row per property with `key` = property name.
376+
377+
Dispatch: `JSON_VALUE` / `JSON_MODIFY` route through `Expression.cs:ResolveBuiltIn`'s length-keyed switch (10 / 11). `OPENJSON` is dispatched at `ParseSingleFromSource`'s `case Name tableName:` head — case-insensitive name match on `"OPENJSON"` wins over CTE / table lookup, parallel to how SQL Server reserves the function name. The path-string parser (`Parser/JsonPath.cs`) is shared between the three: a tiny grammar (`['lax'|'strict']? '$' (segment)*`) backing both runtime walks (via `JsonElement` for read paths, `JsonNode` for modify paths) and parse-time per-column path resolution in OPENJSON's WITH clause. Quoted-property escape `""` → literal `"` matches SQL Server (this is what EF Core's `'{"":"X"}'` + `$.""` parameter-wrap shape relies on).
378+
379+
OPENJSON WITH-clause type subset: `int` / `bigint` / `decimal(p,s)` / `float` / `bit` / `nvarchar(N|max)` / `varchar(N)` / `date` / `datetime2(N)` / `datetimeoffset(N)` / `uniqueidentifier`. Coercion from JSON scalar text routes through `SqlValue.CoerceTo` (the existing CAST machinery). JSON `null` element → SQL NULL of the column's type. Backed by `System.Text.Json` (runtime-shipped, no NuGet dependency added).
380+
381+
EF Core 10 emissions covered:
382+
- `Where(c => c.Address.City == "X")``JSON_VALUE([c].[Address], '$.City')`
383+
- `Where(c => c.Tags.Contains("x"))``OPENJSON(...) WITH ([value] nvarchar(max) '$')` inside `IN(SELECT)`
384+
- `c.Scores.Count``(SELECT COUNT(*) FROM OPENJSON(...))`
385+
- `c.Scores.Any(s => s > 15)``EXISTS (SELECT 1 FROM OPENJSON(...) WITH ([value] int '$') WHERE …)`
386+
- Owned-as-JSON partial UPDATE → `SET [Address] = JSON_MODIFY([Address], 'strict $.City', JSON_VALUE(@p0, '$.""'))`
387+
388+
Not emitted by EF Core 10 (and not modeled): `JSON_QUERY` (object/array extraction; whole-owned-object reads use raw column + client-side deserialization), `ISJSON`, `FOR JSON PATH`/`AUTO`. Reachable from raw SQL only via `FromSqlInterpolated` / direct command text.
389+
368390
### MERGE / OUTPUT (EF Core SaveChanges shape only)
369391
- `INSERT ... OUTPUT INSERTED.<col>` (single-row).
370392
- `MERGE INTO target USING (VALUES ...) AS alias (cols) ON predicate WHEN NOT MATCHED THEN INSERT ... [OUTPUT ...]` (multi-row batch).
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using SqlServerSimulator.EFCore;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// EF Core 10 oracle tests for the JSON pieces: <c>OwnsOne(...).ToJson()</c>
8+
/// owned-types-as-JSON read/update paths (JSON_VALUE / JSON_MODIFY) and
9+
/// primitive-collection read paths (OPENJSON). Each test takes the LINQ
10+
/// shape EF Core would emit naturally and validates the simulator returns
11+
/// the expected results, exercising the LINQ→SQL pipeline end-to-end.
12+
/// </summary>
13+
[TestClass]
14+
public class EFCoreJson
15+
{
16+
public TestContext TestContext { get; set; } = null!;
17+
18+
private static JsonContext NewContext()
19+
{
20+
var simulation = new Simulation();
21+
_ = simulation.ExecuteNonQuery("""
22+
create table Companies (
23+
Id int identity primary key,
24+
Name nvarchar(max) not null,
25+
Address nvarchar(max) null,
26+
Tags nvarchar(max) not null,
27+
Scores nvarchar(max) not null
28+
)
29+
""");
30+
return new JsonContext(simulation);
31+
}
32+
33+
private static JsonContext SeededContext(params (string name, string? city, List<string> tags, List<int> scores)[] companies)
34+
{
35+
var ctx = NewContext();
36+
foreach (var (name, city, tags, scores) in companies)
37+
{
38+
_ = ctx.Companies.Add(new Company
39+
{
40+
Name = name,
41+
Address = city is null ? null : new Address { City = city, Street = "addr" },
42+
Tags = tags,
43+
Scores = scores,
44+
});
45+
}
46+
_ = ctx.SaveChanges();
47+
return ctx;
48+
}
49+
50+
[TestMethod]
51+
public void OwnedAsJson_Insert_StoresJsonText()
52+
{
53+
using var ctx = SeededContext(("Acme", "Springfield", new() { "alpha" }, new() { 10 }));
54+
var company = ctx.Companies.AsNoTracking().Single();
55+
Assert.AreEqual("Springfield", company.Address!.City);
56+
}
57+
58+
[TestMethod]
59+
public void OwnedAsJson_WhereByCity_FiltersWithJsonValue()
60+
{
61+
using var ctx = SeededContext(
62+
("A", "Springfield", new(), new()),
63+
("B", "Shelbyville", new(), new()),
64+
("C", "Springfield", new(), new()));
65+
var matches = ctx.Companies.AsNoTracking().Where(c => c.Address!.City == "Springfield").Count();
66+
Assert.AreEqual(2, matches);
67+
}
68+
69+
[TestMethod]
70+
public void OwnedAsJson_ProjectCity_UsesJsonValue()
71+
{
72+
using var ctx = SeededContext(("A", "Springfield", new(), new()));
73+
var city = ctx.Companies.AsNoTracking().Select(c => c.Address!.City).Single();
74+
Assert.AreEqual("Springfield", city);
75+
}
76+
77+
[TestMethod]
78+
public void OwnedAsJson_PartialUpdate_UsesJsonModify()
79+
{
80+
using var ctx = SeededContext(("A", "Springfield", new(), new()));
81+
var company = ctx.Companies.Single();
82+
company.Address!.City = "Shelbyville";
83+
_ = ctx.SaveChanges();
84+
85+
using var verifyCtx = new JsonContext(ctx.Simulation);
86+
Assert.AreEqual("Shelbyville", verifyCtx.Companies.AsNoTracking().Single().Address!.City);
87+
}
88+
89+
[TestMethod]
90+
public void PrimitiveCollection_Contains_UsesOpenJson()
91+
{
92+
using var ctx = SeededContext(
93+
("A", null, new() { "alpha", "beta" }, new()),
94+
("B", null, new() { "gamma" }, new()),
95+
("C", null, new() { "beta", "delta" }, new()));
96+
var ids = ctx.Companies.AsNoTracking()
97+
.Where(c => c.Tags.Contains("beta"))
98+
.OrderBy(c => c.Id)
99+
.Select(c => c.Name)
100+
.ToList();
101+
CollectionAssert.AreEqual(new[] { "A", "C" }, ids);
102+
}
103+
104+
[TestMethod]
105+
public void PrimitiveCollection_Count_UsesOpenJson()
106+
{
107+
using var ctx = SeededContext(
108+
("A", null, new(), new() { 10, 20, 30 }),
109+
("B", null, new(), new()),
110+
("C", null, new(), new() { 42 }));
111+
var counts = ctx.Companies.AsNoTracking()
112+
.OrderBy(c => c.Id)
113+
.Select(c => new { c.Name, ScoreCount = c.Scores.Count })
114+
.ToList();
115+
Assert.HasCount(3, counts);
116+
Assert.AreEqual(3, counts[0].ScoreCount);
117+
Assert.AreEqual(0, counts[1].ScoreCount);
118+
Assert.AreEqual(1, counts[2].ScoreCount);
119+
}
120+
121+
[TestMethod]
122+
public void PrimitiveCollection_AnyWithPredicate_UsesOpenJson()
123+
{
124+
using var ctx = SeededContext(
125+
("A", null, new(), new() { 10, 20 }),
126+
("B", null, new(), new() { 5, 8 }),
127+
("C", null, new(), new() { 100 }));
128+
var names = ctx.Companies.AsNoTracking()
129+
.Where(c => c.Scores.Any(s => s > 15))
130+
.OrderBy(c => c.Id)
131+
.Select(c => c.Name)
132+
.ToList();
133+
CollectionAssert.AreEqual(new[] { "A", "C" }, names);
134+
}
135+
}
136+
137+
internal sealed class Company
138+
{
139+
public int Id { get; set; }
140+
public string Name { get; set; } = "";
141+
public Address? Address { get; set; }
142+
public List<string> Tags { get; set; } = [];
143+
public List<int> Scores { get; set; } = [];
144+
}
145+
146+
internal sealed class Address
147+
{
148+
public string Street { get; set; } = "";
149+
public string City { get; set; } = "";
150+
}
151+
152+
internal sealed class JsonContext(Simulation simulation) : DbContext
153+
{
154+
public readonly Simulation Simulation = simulation;
155+
156+
public DbSet<Company> Companies => Set<Company>();
157+
158+
protected override void OnConfiguring(DbContextOptionsBuilder options)
159+
{
160+
_ = options.UseSqlServerSimulator(this.Simulation.CreateDbConnection());
161+
}
162+
163+
protected override void OnModelCreating(ModelBuilder mb)
164+
{
165+
_ = mb.Entity<Company>(b =>
166+
{
167+
_ = b.OwnsOne(c => c.Address, a =>
168+
{
169+
_ = a.ToJson();
170+
});
171+
_ = b.PrimitiveCollection(c => c.Tags);
172+
_ = b.PrimitiveCollection(c => c.Scores);
173+
});
174+
}
175+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
using static SqlServerSimulator.TestHelpers;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Behavioral tests for the <c>JSON_VALUE</c> and <c>JSON_MODIFY</c>
8+
/// scalar functions. These cover the EF Core 10 owned-types-as-JSON
9+
/// read + partial-update emissions and a few raw-SQL shapes documented
10+
/// against SQL Server 2025.
11+
/// </summary>
12+
[TestClass]
13+
public sealed class JsonScalarTests
14+
{
15+
[TestMethod]
16+
public void JsonValue_PropertyAccess()
17+
=> AreEqual("Springfield", ExecuteScalar("select json_value('{\"city\":\"Springfield\",\"zip\":\"12345\"}', '$.city')"));
18+
19+
[TestMethod]
20+
public void JsonValue_MissingPath_ReturnsNullLax()
21+
=> IsInstanceOfType<DBNull>(ExecuteScalar("select json_value('{\"city\":\"X\"}', '$.missing')"));
22+
23+
[TestMethod]
24+
public void JsonValue_NullJson_ReturnsNull()
25+
=> IsInstanceOfType<DBNull>(ExecuteScalar("select json_value(null, '$.x')"));
26+
27+
[TestMethod]
28+
public void JsonValue_NullPath_ReturnsNull()
29+
=> IsInstanceOfType<DBNull>(ExecuteScalar("select json_value('{\"x\":1}', null)"));
30+
31+
[TestMethod]
32+
public void JsonValue_BooleanValue_ReturnsLowercaseLiteral()
33+
=> AreEqual("true", ExecuteScalar("select json_value('{\"flag\":true}', '$.flag')"));
34+
35+
[TestMethod]
36+
public void JsonValue_NumberValue_ReturnsRawText()
37+
=> AreEqual("42", ExecuteScalar("select json_value('{\"n\":42}', '$.n')"));
38+
39+
[TestMethod]
40+
public void JsonValue_NullJsonValue_ReturnsNull()
41+
=> IsInstanceOfType<DBNull>(ExecuteScalar("select json_value('{\"n\":null}', '$.n')"));
42+
43+
/// <summary>Lax mode: scalar-only — non-scalar match yields NULL, not the JSON text.</summary>
44+
[TestMethod]
45+
public void JsonValue_ObjectMatch_ReturnsNullLax()
46+
=> IsInstanceOfType<DBNull>(ExecuteScalar("select json_value('{\"obj\":{\"a\":1}}', '$.obj')"));
47+
48+
[TestMethod]
49+
public void JsonValue_ArrayMatch_ReturnsNullLax()
50+
=> IsInstanceOfType<DBNull>(ExecuteScalar("select json_value('{\"arr\":[1,2,3]}', '$.arr')"));
51+
52+
[TestMethod]
53+
public void JsonValue_NestedPropertyAccess()
54+
=> AreEqual("inner", ExecuteScalar("select json_value('{\"a\":{\"b\":{\"c\":\"inner\"}}}', '$.a.b.c')"));
55+
56+
[TestMethod]
57+
public void JsonValue_ArrayIndex()
58+
=> AreEqual("middle", ExecuteScalar("select json_value('{\"items\":[\"first\",\"middle\",\"last\"]}', '$.items[1]')"));
59+
60+
/// <summary>
61+
/// EF Core 10 emits this exact shape when transferring a value through a
62+
/// parameter wrapped as <c>{"":"&lt;value&gt;"}</c> so JSON_VALUE handles
63+
/// parameter type detection without needing per-type SqlParameter typing.
64+
/// </summary>
65+
[TestMethod]
66+
public void JsonValue_QuotedPropertyEmpty_EfWrapShape()
67+
=> AreEqual("Shelbyville", ExecuteScalar("select json_value('{\"\":\"Shelbyville\"}', '$.\"\"')"));
68+
69+
[TestMethod]
70+
public void JsonValue_InvalidJson_ReturnsNullLax()
71+
=> IsInstanceOfType<DBNull>(ExecuteScalar("select json_value('{not valid}', '$.x')"));
72+
73+
[TestMethod]
74+
public void JsonValue_StrictPrefix_OnMissingPathRaises()
75+
=> AssertSqlError("select json_value('{\"a\":1}', 'strict $.missing')", 13608,
76+
"Property cannot be found on the specified JSON path.");
77+
78+
[TestMethod]
79+
public void JsonValue_LaxPrefix_ExplicitParses()
80+
=> AreEqual("v", ExecuteScalar("select json_value('{\"a\":\"v\"}', 'lax $.a')"));
81+
82+
[TestMethod]
83+
public void JsonValue_InvalidPath_RaisesMsg13607()
84+
=> AssertSqlError("select json_value('{}', 'no-leading-dollar')", 13607);
85+
86+
[TestMethod]
87+
public void JsonModify_ReplaceExistingProperty()
88+
=> AreEqual("{\"city\":\"New\"}", ExecuteScalar("select json_modify('{\"city\":\"Old\"}', '$.city', 'New')"));
89+
90+
[TestMethod]
91+
public void JsonModify_ReplaceNestedProperty()
92+
=> AreEqual("{\"a\":{\"b\":\"Y\"}}", ExecuteScalar("select json_modify('{\"a\":{\"b\":\"X\"}}', '$.a.b', 'Y')"));
93+
94+
[TestMethod]
95+
public void JsonModify_AppendToArray()
96+
=> AreEqual("[1,2,3,4]", ExecuteScalar("select json_modify('[1,2,3]', '$[3]', 4)"));
97+
98+
[TestMethod]
99+
public void JsonModify_ReplaceArrayElement()
100+
=> AreEqual("[1,99,3]", ExecuteScalar("select json_modify('[1,2,3]', '$[1]', 99)"));
101+
102+
[TestMethod]
103+
public void JsonModify_LaxMissing_AddsProperty()
104+
=> AreEqual("{\"a\":1,\"b\":2}", ExecuteScalar("select json_modify('{\"a\":1}', '$.b', 2)"));
105+
106+
[TestMethod]
107+
public void JsonModify_LaxNullValue_RemovesExistingProperty()
108+
=> AreEqual("{\"a\":1}", ExecuteScalar("select json_modify('{\"a\":1,\"b\":2}', '$.b', null)"));
109+
110+
[TestMethod]
111+
public void JsonModify_StrictMissing_RaisesMsg13608()
112+
=> AssertSqlError("select json_modify('{\"a\":1}', 'strict $.b', 'x')", 13608,
113+
"Property cannot be found on the specified JSON path.");
114+
115+
[TestMethod]
116+
public void JsonModify_StrictArrayOob_RaisesMsg13608()
117+
=> AssertSqlError("select json_modify('[1,2]', 'strict $[5]', 99)", 13608);
118+
119+
[TestMethod]
120+
public void JsonModify_NullJson_ReturnsNull()
121+
=> IsInstanceOfType<DBNull>(ExecuteScalar("select json_modify(null, '$.x', 1)"));
122+
123+
[TestMethod]
124+
public void JsonModify_NumericValue_StaysJsonNumber()
125+
=> AreEqual("{\"n\":42}", ExecuteScalar("select json_modify('{\"n\":0}', '$.n', 42)"));
126+
127+
[TestMethod]
128+
public void JsonModify_BooleanValue_StaysJsonBoolean()
129+
=> AreEqual("{\"flag\":true}", ExecuteScalar("select json_modify('{\"flag\":false}', '$.flag', cast(1 as bit))"));
130+
131+
// The EF Core 10 SaveChanges shape for partial-update of an owned-as-JSON
132+
// scalar property: JSON_MODIFY with strict path + JSON_VALUE-from-parameter.
133+
[TestMethod]
134+
public void JsonModify_EfPartialUpdateShape()
135+
{
136+
var simulation = new Simulation();
137+
AreEqual(
138+
"{\"City\":\"Shelbyville\",\"Street\":\"1 Main\"}",
139+
simulation.ExecuteScalar("select json_modify('{\"City\":\"Springfield\",\"Street\":\"1 Main\"}', 'strict $.City', json_value('{\"\":\"Shelbyville\"}', '$.\"\"'))"));
140+
}
141+
}

0 commit comments

Comments
 (0)