Skip to content

Commit 3a5302a

Browse files
committed
Strengthened XML features: value, nodes, query, exist, modify.
1 parent c2cfdb8 commit 3a5302a

7 files changed

Lines changed: 567 additions & 63 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
162162
- **`hierarchyid` data type** (incl. deferred byte-identical CAST research notes) → [`hierarchyid.md`](docs/claude/hierarchyid.md).
163163
- **`GRANT` / `REVOKE` / `DENY`, principal DDL, fixed-principal seed, principal scalars (`USER_ID` / `SUSER_ID` / `DATABASE_PRINCIPAL_ID` / `USER_NAME` / `SUSER_NAME` / `SUSER_SNAME` / `CURRENT_USER` / `SESSION_USER` / `SYSTEM_USER` / `ORIGINAL_LOGIN` / `HAS_PERMS_BY_NAME` / `IS_MEMBER` / `IS_ROLEMEMBER` / `IS_SRVROLEMEMBER`)**[`permissions.md`](docs/claude/permissions.md).
164164
- **`CREATE FULLTEXT CATALOG`/`INDEX`, `CONTAINS`/`FREETEXT` rejection**[`full-text.md`](docs/claude/full-text.md).
165-
- **`xml` data type, XML schema collections, XML method dispatch, XML indexes**[`xml.md`](docs/claude/xml.md).
165+
- **`xml` data type, XML schema collections, XML methods (`.value()` / `.nodes()` / `.query()` / `.exist()` execute via an XQuery-subset evaluator; `.modify()` XML-DML skip-with-diagnostic), XML indexes**[`xml.md`](docs/claude/xml.md).
166166
- **`geography` / `geometry` types, spatial methods, spatial indexes**[`spatial.md`](docs/claude/spatial.md).
167167
- **`ALTER DATABASE SET <option>` accept-list + database-level `COLLATE` clause**[`database-options.md`](docs/claude/database-options.md).
168168
- **Per-column / per-expression collation, coercibility precedence, Msg 468 / 457 cross-collation enforcement, recognized catalog, `#temp` collation inheritance**[`collations.md`](docs/claude/collations.md).

SqlServerSimulator.Tests/XmlTests.cs

Lines changed: 62 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -164,50 +164,91 @@ public void CreateXmlIndex_DuplicateName_Raises2714()
164164
}
165165

166166
[TestMethod]
167-
public void XmlValue_Method_RaisesNotSupportedAtExecute()
167+
public void XmlValue_Method_ExtractsScalar()
168+
=> AreEqual("hi", new Simulation().ExecuteScalar("""
169+
create table dbo.doc (id int, body xml);
170+
insert into dbo.doc values (1, N'<r><c>hi</c></r>');
171+
select body.value('(/r/c)[1]', 'nvarchar(50)') from dbo.doc
172+
"""));
173+
174+
[TestMethod]
175+
public void XmlQuery_Method_SerializesMatchedNodes()
176+
=> AreEqual("<c>a</c><c>b</c>", new Simulation().ExecuteScalar("""
177+
create table dbo.doc (id int, body xml);
178+
insert into dbo.doc values (1, N'<r><c>a</c><c>b</c></r>');
179+
select cast(body.query('/r/c') as nvarchar(max)) from dbo.doc
180+
"""));
181+
182+
[TestMethod]
183+
public void XmlExist_Method_ReturnsBit()
168184
{
169185
var sim = new Simulation();
170186
_ = sim.ExecuteNonQuery("create table dbo.doc (id int, body xml)");
171-
_ = sim.ExecuteNonQuery("insert into dbo.doc values (1, N'<r><c>hi</c></r>')");
172-
var ex = ThrowsExactly<NotSupportedException>(() =>
173-
sim.ExecuteScalar("select body.value('(/r/c)[1]', 'nvarchar(50)') from dbo.doc"));
174-
Contains(".value()", ex.Message);
187+
_ = sim.ExecuteNonQuery("insert into dbo.doc values (1, N'<r><c>x</c></r>')");
188+
IsTrue((bool)sim.ExecuteScalar("select body.exist('/r/c') from dbo.doc")!);
189+
IsFalse((bool)sim.ExecuteScalar("select body.exist('/r/missing') from dbo.doc")!);
175190
}
176191

177192
[TestMethod]
178-
public void XmlQuery_Method_RaisesNotSupportedAtExecute()
193+
public void XmlExist_NullInstance_ReturnsNull()
179194
{
180195
var sim = new Simulation();
181196
_ = sim.ExecuteNonQuery("create table dbo.doc (id int, body xml)");
182-
_ = sim.ExecuteNonQuery("insert into dbo.doc values (1, N'<r/>')");
183-
var ex = ThrowsExactly<NotSupportedException>(() =>
184-
sim.ExecuteScalar("select body.query('/r') from dbo.doc"));
185-
Contains(".query()", ex.Message);
197+
_ = sim.ExecuteNonQuery("insert into dbo.doc values (1, NULL)");
198+
AreEqual(DBNull.Value, sim.ExecuteScalar("select body.exist('/r') from dbo.doc"));
186199
}
187200

188201
[TestMethod]
189-
public void XmlExist_Method_RaisesNotSupportedAtExecute()
202+
public void XmlModify_Method_RaisesNotSupportedAtExecute()
190203
{
191204
var sim = new Simulation();
192205
_ = sim.ExecuteNonQuery("create table dbo.doc (id int, body xml)");
193206
_ = sim.ExecuteNonQuery("insert into dbo.doc values (1, N'<r/>')");
194207
var ex = ThrowsExactly<NotSupportedException>(() =>
195-
sim.ExecuteScalar("select body.exist('/r') from dbo.doc"));
196-
Contains(".exist()", ex.Message);
208+
sim.ExecuteScalar("select body.modify('insert <c/> into (/r)[1]') from dbo.doc"));
209+
Contains(".modify()", ex.Message);
197210
}
198211

199212
[TestMethod]
200-
public void CreateViewWithXmlMethod_Succeeds_FailsAtExecute()
213+
public void CreateViewWithXmlValue_ProjectsExtractedScalar()
201214
{
202-
// CREATE VIEW body parses cleanly (proc/view bodies parse for name
203-
// resolution but XML methods defer their NotSupportedException to
204-
// run-time). The query against the view fails on first row.
205215
var sim = new Simulation();
206216
_ = sim.ExecuteNonQuery("create table dbo.doc (id int, body xml)");
207-
_ = sim.ExecuteNonQuery("insert into dbo.doc values (1, N'<r/>')");
208-
_ = sim.ExecuteNonQuery("create view dbo.v_doc as select body.value('(/r)[1]', 'nvarchar(50)') as v from dbo.doc");
209-
_ = ThrowsExactly<NotSupportedException>(() =>
210-
sim.ExecuteScalar("select v from dbo.v_doc"));
217+
_ = sim.ExecuteNonQuery("insert into dbo.doc values (1, N'<r><c>hi</c></r>')");
218+
_ = sim.ExecuteNonQuery("create view dbo.v_doc as select body.value('(/r/c)[1]', 'nvarchar(50)') as v from dbo.doc");
219+
AreEqual("hi", sim.ExecuteScalar("select v from dbo.v_doc"));
220+
}
221+
222+
[TestMethod]
223+
public void XmlNodes_CrossApply_ShredsRows()
224+
{
225+
// .nodes() as a CROSS APPLY rowset source, with relative .value()
226+
// against each shredded node — the AdventureWorks vJobCandidate* shape.
227+
var sim = new Simulation();
228+
_ = sim.ExecuteNonQuery("create table dbo.doc (id int, body xml)");
229+
_ = sim.ExecuteNonQuery("insert into dbo.doc values (1, N'<r><c>a</c><c>b</c><c>c</c></r>')");
230+
AreEqual(3, sim.ExecuteScalar("""
231+
select count(*) from dbo.doc
232+
cross apply body.nodes('/r/c') as n(ref)
233+
"""));
234+
AreEqual("a|b|c", sim.ExecuteScalar("""
235+
select string_agg(n.ref.value('(.)[1]', 'nvarchar(10)'), '|') from dbo.doc
236+
cross apply body.nodes('/r/c') as n(ref)
237+
"""));
238+
}
239+
240+
[TestMethod]
241+
public void XmlNodes_OuterApply_NullXmlYieldsNoRows()
242+
{
243+
var sim = new Simulation();
244+
_ = sim.ExecuteNonQuery("create table dbo.doc (id int, body xml)");
245+
_ = sim.ExecuteNonQuery("insert into dbo.doc values (1, NULL)");
246+
// OUTER APPLY null-fills the right side when the lateral plan is empty,
247+
// so the single left row survives with a NULL shredded value.
248+
AreEqual(1, sim.ExecuteScalar("""
249+
select count(*) from dbo.doc
250+
outer apply body.nodes('/r/c') as n(ref)
251+
"""));
211252
}
212253

213254
[TestMethod]

SqlServerSimulator/Parser/Expressions/XmlMethodCall.cs

Lines changed: 156 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,55 @@ namespace SqlServerSimulator.Parser.Expressions;
66
/// <summary>
77
/// Instance-method call on an <c>xml</c> value: <c>expr.value(…)</c>,
88
/// <c>expr.nodes(…)</c>, <c>expr.query(…)</c>, <c>expr.exist(…)</c>, or
9-
/// <c>expr.modify(…)</c>. Parses cleanly (so CREATE VIEW / CREATE
10-
/// PROCEDURE bodies that reference XML methods can be stored verbatim);
11-
/// raises <see cref="NotSupportedException"/> at <see cref="Run"/> time
12-
/// with a wording naming the method, matching the skip-with-diagnostic
13-
/// stance documented in <c>docs/claude/xml.md</c>.
9+
/// <c>expr.modify(…)</c>.
1410
/// </summary>
1511
/// <remarks>
16-
/// The closed accept-list (<c>value</c>, <c>nodes</c>, <c>query</c>,
17-
/// <c>exist</c>, <c>modify</c>) is checked before falling through to the
18-
/// existing multipart-Reference path so a column literally named (e.g.)
19-
/// <c>value</c> followed by <c>.MethodName(...)</c> won't collide.
12+
/// <para>
13+
/// <c>value</c> evaluates its XQuery path against the target xml through
14+
/// <see cref="XmlQueryEngine"/> and casts the selected node's string value to
15+
/// the requested SQL type (its second argument, a string literal). <c>nodes</c>
16+
/// produces a rowset and is only valid in a FROM / APPLY source position; the
17+
/// parser (<see cref="Selection"/>) intercepts the parsed <see cref="XmlMethodCall"/>
18+
/// there via <see cref="IsNodes"/> / <see cref="Target"/> / <see cref="XQuery"/>
19+
/// and builds a correlated source — reaching <see cref="Run"/> for <c>nodes</c>
20+
/// means it appeared in scalar position, which is unsupported.
21+
/// </para>
22+
/// <para>
23+
/// <c>query</c> / <c>exist</c> / <c>modify</c> parse cleanly (so CREATE VIEW /
24+
/// CREATE PROCEDURE bodies referencing them store verbatim) but raise
25+
/// <see cref="NotSupportedException"/> at <see cref="Run"/> time, the
26+
/// skip-with-diagnostic stance documented in <c>docs/claude/xml.md</c>.
27+
/// </para>
2028
/// </remarks>
2129
internal sealed class XmlMethodCall : Expression
2230
{
23-
private readonly Expression target;
31+
/// <summary>The xml-valued expression the method is invoked on.</summary>
32+
public readonly Expression Target;
33+
2434
private readonly string methodName;
35+
private readonly string? xquery;
36+
private readonly SqlType valueType;
37+
private readonly int? valueMaxLength;
2538

26-
private XmlMethodCall(Expression target, string methodName)
39+
private XmlMethodCall(Expression target, string methodName, string? xquery, SqlType valueType, int? valueMaxLength)
2740
{
28-
this.target = target;
41+
this.Target = target;
2942
this.methodName = methodName;
43+
this.xquery = xquery;
44+
this.valueType = valueType;
45+
this.valueMaxLength = valueMaxLength;
3046
}
3147

48+
/// <summary>True when this is a <c>.nodes()</c> call (rowset-producing).</summary>
49+
public bool IsNodes => this.methodName.Equals("nodes", StringComparison.Ordinal);
50+
51+
/// <summary>The XQuery path argument (prolog + body), captured at parse time.</summary>
52+
public string XQuery => this.xquery ?? throw new InvalidOperationException("XML method has no captured XQuery argument.");
53+
3254
/// <summary>
3355
/// Returns true if <paramref name="name"/> matches one of the five XML
3456
/// instance method names. Used by the expression parser to take the
35-
/// throws-at-execute path instead of multipart-reference dispatch.
57+
/// method-call path instead of multipart-reference dispatch.
3658
/// </summary>
3759
public static bool IsKnownMethodName(string name) =>
3860
name.Equals("value", StringComparison.Ordinal)
@@ -43,47 +65,146 @@ public static bool IsKnownMethodName(string name) =>
4365

4466
/// <summary>
4567
/// Parses <c>expr.MethodName(args)</c>. Cursor enters on <c>(</c>; on
46-
/// return cursor sits on the closing <c>)</c>. Arguments parse fully
47-
/// (so name resolution surfaces eagerly per the simulator's idiom)
48-
/// but they're discarded — runtime evaluation throws.
68+
/// return cursor sits on the closing <c>)</c>. The first argument (XQuery
69+
/// path) and, for <c>value</c>, the second (target SQL type) are captured
70+
/// as compile-time string literals; a non-literal argument raises
71+
/// <see cref="NotSupportedException"/> (dynamic XQuery isn't modeled).
4972
/// </summary>
5073
public static XmlMethodCall Parse(Expression target, string methodName, ParserContext context)
5174
{
75+
var isValue = methodName.Equals("value", StringComparison.Ordinal);
76+
var isNodesOrValueOrQueryOrExist = isValue
77+
|| methodName.Equals("nodes", StringComparison.Ordinal)
78+
|| methodName.Equals("query", StringComparison.Ordinal)
79+
|| methodName.Equals("exist", StringComparison.Ordinal);
80+
5281
context.MoveNextRequired();
82+
string? xquery = null;
83+
SqlType valueType = SqlType.Xml;
84+
int? valueMaxLength = null;
5385
if (context.Token is not Operator { Character: ')' })
5486
{
55-
_ = Expression.Parse(context);
87+
var firstArg = Expression.Parse(context);
88+
if (isNodesOrValueOrQueryOrExist)
89+
xquery = ConstantString(firstArg, context, "XML method path");
90+
5691
while (context.Token is Operator { Character: ',' })
5792
{
5893
context.MoveNextRequired();
59-
_ = Expression.Parse(context);
94+
var nextArg = Expression.Parse(context);
95+
if (isValue)
96+
(valueType, valueMaxLength) = ResolveValueType(ConstantString(nextArg, context, "value() target type"), context.Batch);
6097
}
6198
if (context.Token is not Operator { Character: ')' })
6299
throw SimulatedSqlException.SyntaxErrorNear(context);
63100
}
64-
return new XmlMethodCall(target, methodName);
101+
return new XmlMethodCall(target, methodName, xquery, valueType, valueMaxLength);
65102
}
66103

67-
public override SqlValue Run(RuntimeContext runtime) =>
68-
throw new NotSupportedException(
69-
$"XML instance method '.{this.methodName}()' is not modeled.");
104+
public override SqlValue Run(RuntimeContext runtime)
105+
{
106+
// .nodes() is rowset-producing (handled in FROM/APPLY parse, never
107+
// here) and .modify() is XML-DML, neither is reachable as a scalar.
108+
if (this.methodName.Equals("nodes", StringComparison.Ordinal) || this.methodName.Equals("modify", StringComparison.Ordinal))
109+
throw new NotSupportedException($"XML instance method '.{this.methodName}()' is not modeled.");
110+
111+
var input = this.Target.Run(runtime);
112+
switch (this.methodName)
113+
{
114+
case "exist":
115+
return input.IsNull ? SqlValue.Null(SqlType.Bit) : SqlValue.FromBoolean(XmlQueryEngine.EvaluateExists(input.AsString, this.xquery!));
116+
case "query":
117+
return input.IsNull ? SqlValue.Null(SqlType.Xml) : SqlValue.FromXml(XmlQueryEngine.EvaluateQuery(input.AsString, this.xquery!));
118+
default:
119+
if (input.IsNull)
120+
return SqlValue.Null(this.valueType);
121+
var selected = XmlQueryEngine.EvaluateScalar(input.AsString, this.xquery!);
122+
return selected is null
123+
? SqlValue.Null(this.valueType)
124+
: Cast.ApplyCoercion(SqlValue.FromString(SqlType.NVarchar, selected), this.valueType, this.valueMaxLength);
125+
}
126+
}
70127

71128
/// <summary>
72-
/// Static result type, used by projection schema inference. Returns
73-
/// <c>xml</c> for the methods that produce xml (<c>query</c>,
74-
/// <c>nodes</c>) and <c>bit</c> for <c>exist</c>; <c>value</c> returns
75-
/// the requested target type but the simulator stubs it as
76-
/// nvarchar(MAX) since we never actually evaluate it. <c>modify</c>
77-
/// is statement-level in real SQL Server and has no result type;
78-
/// surfaces as xml here for static-typing safety since it can't be
79-
/// reached at execute anyway.
129+
/// Static result type, used by projection schema inference: <c>value</c>
130+
/// returns its requested target type; <c>exist</c> returns <c>bit</c>;
131+
/// <c>nodes</c> / <c>query</c> / <c>modify</c> surface as <c>xml</c>.
80132
/// </summary>
81133
public override SqlType GetSqlType(BatchContext batch, Func<MultiPartName, SqlType> resolveColumnType) =>
82-
this.methodName.Equals("exist", StringComparison.Ordinal)
83-
? SqlType.Bit
84-
: this.methodName.Equals("value", StringComparison.Ordinal)
85-
? NVarcharSqlType.Get(-1, batch.CurrentDatabase.Collation, Coercibility.CoercibleDefault)
134+
this.methodName.Equals("value", StringComparison.Ordinal)
135+
? this.valueType
136+
: this.methodName.Equals("exist", StringComparison.Ordinal)
137+
? SqlType.Bit
86138
: SqlType.Xml;
87139

88-
internal override string DebugDisplay() => $"({this.target.DebugDisplay()}).{this.methodName}(…)";
140+
internal override string DebugDisplay() => $"({this.Target.DebugDisplay()}).{this.methodName}(…)";
141+
142+
/// <summary>
143+
/// Evaluates <paramref name="argument"/> against an empty resolver to pull
144+
/// out its compile-time string value; a column / variable / runtime
145+
/// reference surfaces as <see cref="NotSupportedException"/>.
146+
/// </summary>
147+
private static string ConstantString(Expression argument, ParserContext context, string role)
148+
{
149+
try
150+
{
151+
var value = argument.Run(new RuntimeContext(_ => throw new InvalidOperationException(), context.Batch));
152+
if (!value.IsNull && SqlType.IsStringCategory(value.Type))
153+
return value.AsString;
154+
}
155+
catch (InvalidOperationException)
156+
{
157+
// Falls through to the unsupported-shape throw below.
158+
}
159+
throw new NotSupportedException($"A non-literal {role} argument to an XML method is not modeled.");
160+
}
161+
162+
/// <summary>
163+
/// Resolves a <c>value()</c> target-type literal (e.g. <c>nvarchar(30)</c>,
164+
/// <c>money</c>, <c>decimal(9, 4)</c>, <c>integer</c>) into a
165+
/// <see cref="SqlType"/> + max-length by re-tokenizing the literal and
166+
/// reusing <see cref="SqlType.GetByName"/>. <c>integer</c> is mapped to
167+
/// <c>int</c> (an XQuery type synonym <see cref="SqlType.GetByName"/>
168+
/// doesn't itself accept).
169+
/// </summary>
170+
private static (SqlType Type, int? MaxLength) ResolveValueType(string spec, BatchContext batch)
171+
{
172+
var collation = batch.CurrentDatabase.Collation;
173+
var index = 0;
174+
Token? NextToken()
175+
{
176+
Token? token;
177+
do
178+
{
179+
token = Tokenizer.NextToken(spec, ref index, collation);
180+
}
181+
while (token is Whitespace);
182+
return token;
183+
}
184+
185+
if (NextToken() is not Name typeName)
186+
throw new NotSupportedException($"Unrecognized XML value() target type '{spec}'.");
187+
if (typeName.Span.Equals("integer", StringComparison.OrdinalIgnoreCase))
188+
return (SqlType.Int32, null);
189+
190+
int? declaredMaxLength = null;
191+
int? declaredScale = null;
192+
if (NextToken() is Operator { Character: '(' })
193+
{
194+
declaredMaxLength = NextToken() switch
195+
{
196+
Numeric { Value: { IsNull: false } length } => length.AsInt32,
197+
UnquotedString { ContextualKeyword: ContextualKeyword.Max } => SqlType.MaxLengthSentinel,
198+
_ => throw new NotSupportedException($"Unrecognized XML value() target type '{spec}'."),
199+
};
200+
if (NextToken() is Operator { Character: ',' })
201+
{
202+
if (NextToken() is not Numeric { Value: { IsNull: false } scale })
203+
throw new NotSupportedException($"Unrecognized XML value() target type '{spec}'.");
204+
declaredScale = scale.AsInt32;
205+
_ = NextToken();
206+
}
207+
}
208+
return SqlType.GetByName(typeName, declaredMaxLength, declaredScale, 1, columnName: null);
209+
}
89210
}

0 commit comments

Comments
 (0)