Skip to content

Commit 87b64db

Browse files
committed
BuiltInToken introduced to provide a clear pattern for SQL query elements that don't use database collation but have more relaxed parsing rules than keywords.
1 parent 8e5c926 commit 87b64db

9 files changed

Lines changed: 131 additions & 65 deletions

SqlServerSimulator/BuiltInToken.cs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
using System.Globalization;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// String-matcher for T-SQL "built-in token" values — spec-defined fixed
7+
/// strings that aren't subject to <c>ALTER DATABASE COLLATE</c>. Examples:
8+
/// the <c>INSERTED</c> / <c>DELETED</c> trigger pseudo-table names; the
9+
/// <c>OBJECT_ID(..., 'U')</c> type-filter codes (<c>U</c> / <c>FN</c> /
10+
/// <c>IF</c> / <c>TF</c> / <c>V</c> / <c>P</c>); the
11+
/// <c>sp_addextendedproperty</c> arg names (<c>@name</c>, <c>@value</c>,
12+
/// <c>@level0type</c>, ...); and the spec-defined arg values for level
13+
/// types (<c>SCHEMA</c>, <c>TABLE</c>, <c>VIEW</c>, <c>PROCEDURE</c>,
14+
/// <c>FUNCTION</c>, <c>TYPE</c>, <c>COLUMN</c>, <c>CONSTRAINT</c>,
15+
/// <c>INDEX</c>).
16+
/// </summary>
17+
/// <remarks>
18+
/// <para>
19+
/// Properties: CI + IgnoreKanaType + IgnoreWidth. Behaviorally a subset of
20+
/// today's <see cref="Collation.Default"/> at the compare level, but
21+
/// without any of the Collation-class machinery
22+
/// (<see cref="Collation.Name"/> / <see cref="Collation.Description"/> /
23+
/// <see cref="Collation.StorageEncoding"/> / coercibility-precedence /
24+
/// supplementary-character toggle / <c>LIKE</c> case-sensitivity flag) —
25+
/// none of which apply at fixed-token sites.
26+
/// </para>
27+
/// <para>
28+
/// Probe-confirmed against SQL Server 2025 (2026-05-21) on a CS database
29+
/// (<c>SQL_Latin1_General_CP1_CS_AS</c>): the sites above continue to
30+
/// match canonical and wrong-case forms (<c>'u'</c>, <c>inserted</c>,
31+
/// <c>'schema'</c>, <c>'dbo'</c>) even when the database itself is
32+
/// case-sensitive. By contrast, user identifiers (table / column names,
33+
/// schema names, catalog views, system proc names,
34+
/// <c>hierarchyid::</c> / <c>geography::</c> / <c>geometry::</c>
35+
/// type-prefix dispatch, the <c>dbo</c>/<c>sys</c>/<c>INFORMATION_SCHEMA</c>
36+
/// reserved-name check) <em>do</em> flip under CS — those route through the
37+
/// database's identifier collation, not this matcher. See
38+
/// <c>docs/claude/collations.md</c> for the regime breakdown.
39+
/// </para>
40+
/// </remarks>
41+
internal static class BuiltInToken
42+
{
43+
private static readonly CompareInfo compareInfo = CultureInfo.InvariantCulture.CompareInfo;
44+
45+
private const CompareOptions Options =
46+
CompareOptions.IgnoreCase | CompareOptions.IgnoreKanaType | CompareOptions.IgnoreWidth;
47+
48+
/// <summary>
49+
/// Returns true when <paramref name="x"/> and <paramref name="y"/>
50+
/// match under the built-in-token compare options (CI + width- /
51+
/// kanatype-insensitive). Both arms accept <see langword="null"/>:
52+
/// two nulls are equal; one null + one non-null is not.
53+
/// </summary>
54+
public static bool Equals(string? x, string? y) =>
55+
x is null
56+
? y is null
57+
: y is not null && compareInfo.Compare(x, y, Options) == 0;
58+
59+
/// <summary>
60+
/// Returns true when <paramref name="value"/> matches any of
61+
/// <paramref name="options"/> under the built-in-token compare options.
62+
/// A <see langword="null"/> <paramref name="value"/> returns false —
63+
/// the caller is asking "is this one of X/Y/Z?", and a null can't
64+
/// match any of those.
65+
/// </summary>
66+
public static bool EqualsAny(string? value, params ReadOnlySpan<string> options)
67+
{
68+
if (value is null)
69+
return false;
70+
foreach (var option in options)
71+
{
72+
if (compareInfo.Compare(value, option, Options) == 0)
73+
return true;
74+
}
75+
return false;
76+
}
77+
}

SqlServerSimulator/Parser/BatchContext.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,12 +1204,12 @@ public bool TryResolveTable(MultiPartName name, [System.Diagnostics.CodeAnalysis
12041204
// (no DDL can target them).
12051205
if (this.TriggerFrame is { } triggerFrame && name.Count == 1)
12061206
{
1207-
if (Collation.Default.Equals(name.Leaf, "inserted") && triggerFrame.Inserted is { } ins)
1207+
if (BuiltInToken.Equals(name.Leaf, "inserted") && triggerFrame.Inserted is { } ins)
12081208
{
12091209
table = ins;
12101210
return true;
12111211
}
1212-
if (Collation.Default.Equals(name.Leaf, "deleted") && triggerFrame.Deleted is { } del)
1212+
if (BuiltInToken.Equals(name.Leaf, "deleted") && triggerFrame.Deleted is { } del)
12131213
{
12141214
table = del;
12151215
return true;

SqlServerSimulator/Parser/Expressions/ObjectId.cs

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,8 @@ public override SqlValue Run(RuntimeContext runtime)
6969
// 'IF' (inline table-valued function), 'V' (view), 'P' (stored
7070
// procedure). Other documented codes (TF / TR / ...) return
7171
// NULL pending those features.
72-
if (!Collation.Default.Equals(typeFilter, "U")
73-
&& !Collation.Default.Equals(typeFilter, "FN")
74-
&& !Collation.Default.Equals(typeFilter, "IF")
75-
&& !Collation.Default.Equals(typeFilter, "V")
76-
&& !Collation.Default.Equals(typeFilter, "P"))
77-
{
72+
if (!BuiltInToken.EqualsAny(typeFilter, "U", "FN", "IF", "V", "P"))
7873
return SqlValue.Null(SqlType.Int32);
79-
}
8074
}
8175

8276
var nameStr = nameValue.CoerceTo(SqlType.NVarchar).AsString;
@@ -87,19 +81,16 @@ public override SqlValue Run(RuntimeContext runtime)
8781
// specific filter the function must match that kind (scalar vs.
8882
// inline TVF vs. multi-statement TVF); without a filter, any kind
8983
// matches.
90-
if (typeFilter is null
91-
|| Collation.Default.Equals(typeFilter, "FN")
92-
|| Collation.Default.Equals(typeFilter, "IF")
93-
|| Collation.Default.Equals(typeFilter, "TF"))
84+
if (typeFilter is null || BuiltInToken.EqualsAny(typeFilter, "FN", "IF", "TF"))
9485
{
9586
if (runtime.Batch.TryResolveFunction(parsed, out var function))
9687
{
9788
var kindMatches = typeFilter switch
9889
{
9990
null => true,
100-
_ when Collation.Default.Equals(typeFilter, "FN") => function is ScalarFunction,
101-
_ when Collation.Default.Equals(typeFilter, "IF") => function is InlineTableValuedFunction,
102-
_ when Collation.Default.Equals(typeFilter, "TF") => function is MultiStatementTableValuedFunction,
91+
_ when BuiltInToken.Equals(typeFilter, "FN") => function is ScalarFunction,
92+
_ when BuiltInToken.Equals(typeFilter, "IF") => function is InlineTableValuedFunction,
93+
_ when BuiltInToken.Equals(typeFilter, "TF") => function is MultiStatementTableValuedFunction,
10394
_ => false,
10495
};
10596
if (kindMatches)
@@ -111,7 +102,7 @@ _ when Collation.Default.Equals(typeFilter, "TF") => function is MultiStatementT
111102

112103
// 'V' / no filter: try view resolution before falling through to
113104
// tables. With a specific 'V' filter, a table miss returns NULL.
114-
if (typeFilter is null || Collation.Default.Equals(typeFilter, "V"))
105+
if (typeFilter is null || BuiltInToken.Equals(typeFilter, "V"))
115106
{
116107
if (runtime.Batch.TryResolveView(parsed, out var view))
117108
return SqlValue.FromInt32(view.ObjectId);
@@ -122,7 +113,7 @@ _ when Collation.Default.Equals(typeFilter, "TF") => function is MultiStatementT
122113
// 'P' / no filter: try procedure resolution. Procs share the
123114
// object-name namespace with tables / views / functions, so the
124115
// no-filter form falls through here too.
125-
if (typeFilter is null || Collation.Default.Equals(typeFilter, "P"))
116+
if (typeFilter is null || BuiltInToken.Equals(typeFilter, "P"))
126117
{
127118
if (runtime.Batch.TryResolveProcedure(parsed, out var procedure))
128119
return SqlValue.FromInt32(procedure.ObjectId);

SqlServerSimulator/Parser/MultiPartName.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ public MultiPartName WithAddedPart(string next)
103103
/// <c>schema.table.col</c>, the table in <c>db.schema.table.col</c> —
104104
/// or <see langword="null"/> when the reference is unqualified
105105
/// (<see cref="Count"/> == 1). Use with
106-
/// <c>Collation.Default.Equals(name.ImmediateQualifier, "INSERTED")</c>
106+
/// <c>BuiltInToken.Equals(name.ImmediateQualifier, "INSERTED")</c>
107107
/// shape: the equality check folds the null-or-unqualified case into a
108108
/// <c>false</c> result without a separate guard.
109109
/// </summary>

SqlServerSimulator/Parser/Selection.ListExtendedProperty.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -152,12 +152,12 @@ private static IEnumerable<byte[]> EnumerateListExtendedPropertyRows(
152152
f.ClassFilter = 0;
153153
return f;
154154
}
155-
if (!Collation.Default.Equals(l0Type, "SCHEMA"))
155+
if (!BuiltInToken.Equals(l0Type, "SCHEMA"))
156156
throw new NotSupportedException($"fn_listextendedproperty level0type '{l0Type}' isn't modeled (only SCHEMA / NULL).");
157157
if (l0Name is null)
158158
return null;
159159
if (!batch.CurrentDatabase.Schemas.TryGetValue(l0Name, out var schema)
160-
&& !Collation.Default.Equals(l0Name, "default"))
160+
&& !BuiltInToken.Equals(l0Name, "default"))
161161
{
162162
return null;
163163
}
@@ -176,7 +176,7 @@ private static IEnumerable<byte[]> EnumerateListExtendedPropertyRows(
176176
// 'default' wildcard fans out across every object of that kind in
177177
// the parent schema.
178178
SchemaObject? l1Obj = null;
179-
if (schema is not null && !Collation.Default.Equals(l1Name, "default"))
179+
if (schema is not null && !BuiltInToken.Equals(l1Name, "default"))
180180
{
181181
Span<char> l1Kind = stackalloc char[l1Type.Length];
182182
_ = l1Type.ToUpperInvariant(l1Kind);
@@ -203,14 +203,14 @@ private static IEnumerable<byte[]> EnumerateListExtendedPropertyRows(
203203
}
204204
if (l2Name is null)
205205
return null;
206-
if (!Collation.Default.Equals(l2Type, "COLUMN"))
206+
if (!BuiltInToken.Equals(l2Type, "COLUMN"))
207207
throw new NotSupportedException($"fn_listextendedproperty level2type '{l2Type}' isn't modeled (only COLUMN).");
208208

209209
f.ClassFilter = 1;
210210
if (l1Obj is HeapTable table)
211211
{
212212
f.MajorIdFilter = table.ObjectId;
213-
if (!Collation.Default.Equals(l2Name, "default"))
213+
if (!BuiltInToken.Equals(l2Name, "default"))
214214
{
215215
for (var i = 0; i < table.Columns.Length; i++)
216216
{

SqlServerSimulator/Simulation/Simulation.ExtendedProperties.cs

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -65,29 +65,29 @@ private static IEnumerable<SimulatedStatementOutcome> InvokeSpExtendedProperty(B
6565
throw SimulatedSqlException.InvalidExtendedPropertyParameter(procLabel);
6666
switch (arg.Name)
6767
{
68-
case var n when Collation.Default.Equals(n, "name"):
68+
case var n when BuiltInToken.Equals(n, "name"):
6969
name = ExpectStringArg(arg.Value);
7070
break;
71-
case var n when Collation.Default.Equals(n, "value"):
71+
case var n when BuiltInToken.Equals(n, "value"):
7272
value = arg.Value;
7373
hasValueArg = true;
7474
break;
75-
case var n when Collation.Default.Equals(n, "level0type"):
75+
case var n when BuiltInToken.Equals(n, "level0type"):
7676
level0Type = ExpectStringArgOrNull(arg.Value);
7777
break;
78-
case var n when Collation.Default.Equals(n, "level0name"):
78+
case var n when BuiltInToken.Equals(n, "level0name"):
7979
level0Name = ExpectStringArgOrNull(arg.Value);
8080
break;
81-
case var n when Collation.Default.Equals(n, "level1type"):
81+
case var n when BuiltInToken.Equals(n, "level1type"):
8282
level1Type = ExpectStringArgOrNull(arg.Value);
8383
break;
84-
case var n when Collation.Default.Equals(n, "level1name"):
84+
case var n when BuiltInToken.Equals(n, "level1name"):
8585
level1Name = ExpectStringArgOrNull(arg.Value);
8686
break;
87-
case var n when Collation.Default.Equals(n, "level2type"):
87+
case var n when BuiltInToken.Equals(n, "level2type"):
8888
level2Type = ExpectStringArgOrNull(arg.Value);
8989
break;
90-
case var n when Collation.Default.Equals(n, "level2name"):
90+
case var n when BuiltInToken.Equals(n, "level2name"):
9191
level2Name = ExpectStringArgOrNull(arg.Value);
9292
break;
9393
default:
@@ -161,7 +161,7 @@ private static (ExtendedPropertyKey Key, string TargetLabel) ResolveExtendedProp
161161
// call with all 6 level args absent).
162162
return (new ExtendedPropertyKey(0, 0, 0, propertyName), "object specified");
163163
}
164-
if (!Collation.Default.Equals(level0Type, "SCHEMA"))
164+
if (!BuiltInToken.Equals(level0Type, "SCHEMA"))
165165
throw SimulatedSqlException.InvalidExtendedPropertyParameter(procLabel);
166166
if (level0Name is null)
167167
throw SimulatedSqlException.InvalidExtendedPropertyParameter(procLabel);
@@ -179,23 +179,23 @@ private static (ExtendedPropertyKey Key, string TargetLabel) ResolveExtendedProp
179179
// level1 kinds — anything else raises Msg 15600 (real SQL Server's
180180
// grammar accepts more but AW only uses these five).
181181
SchemaObject? obj = null;
182-
if (Collation.Default.Equals(level1Type, "TABLE"))
182+
if (BuiltInToken.Equals(level1Type, "TABLE"))
183183
{
184184
if (schema.HeapTables.TryGetValue(level1Name, out var t)) obj = t;
185185
}
186-
else if (Collation.Default.Equals(level1Type, "VIEW"))
186+
else if (BuiltInToken.Equals(level1Type, "VIEW"))
187187
{
188188
if (schema.Views.TryGetValue(level1Name, out var v)) obj = v;
189189
}
190-
else if (Collation.Default.Equals(level1Type, "PROCEDURE"))
190+
else if (BuiltInToken.Equals(level1Type, "PROCEDURE"))
191191
{
192192
if (schema.Procedures.TryGetValue(level1Name, out var p)) obj = p;
193193
}
194-
else if (Collation.Default.Equals(level1Type, "FUNCTION"))
194+
else if (BuiltInToken.Equals(level1Type, "FUNCTION"))
195195
{
196196
if (schema.Functions.TryGetValue(level1Name, out var f)) obj = f;
197197
}
198-
else if (Collation.Default.Equals(level1Type, "TYPE"))
198+
else if (BuiltInToken.Equals(level1Type, "TYPE"))
199199
{
200200
// TYPE-level extended properties target alias or table types.
201201
// The object_id is the table-type's, alias types don't carry one.
@@ -219,7 +219,7 @@ private static (ExtendedPropertyKey Key, string TargetLabel) ResolveExtendedProp
219219
if (obj is not HeapTable table)
220220
throw SimulatedSqlException.ExtendedPropertyTargetMissing($"{schema.Name}.{obj.Name}.{level2Name}");
221221

222-
if (Collation.Default.Equals(level2Type, "COLUMN"))
222+
if (BuiltInToken.Equals(level2Type, "COLUMN"))
223223
{
224224
// 1-based column ordinal (real SQL Server's minor_id convention).
225225
for (var i = 0; i < table.Columns.Length; i++)
@@ -230,7 +230,7 @@ private static (ExtendedPropertyKey Key, string TargetLabel) ResolveExtendedProp
230230
throw SimulatedSqlException.ExtendedPropertyTargetMissing($"{schema.Name}.{obj.Name}.{level2Name}");
231231
}
232232

233-
if (Collation.Default.Equals(level2Type, "CONSTRAINT"))
233+
if (BuiltInToken.Equals(level2Type, "CONSTRAINT"))
234234
{
235235
// Constraints (PK / UQ / FK / CHECK / DEFAULT) all carry their own
236236
// object_id and reuse class=1 (OBJECT_OR_COLUMN) like every other
@@ -258,7 +258,7 @@ private static (ExtendedPropertyKey Key, string TargetLabel) ResolveExtendedProp
258258
throw SimulatedSqlException.ExtendedPropertyTargetMissing($"{schema.Name}.{obj.Name}.{level2Name}");
259259
}
260260

261-
if (Collation.Default.Equals(level2Type, "INDEX"))
261+
if (BuiltInToken.Equals(level2Type, "INDEX"))
262262
{
263263
// INDEX-level uses class=7. major_id=table.object_id, minor_id=
264264
// index_id (matches real SQL Server's sys.extended_properties).

SqlServerSimulator/Simulation/Simulation.LinkedServers.cs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,21 +47,21 @@ private static IEnumerable<SimulatedStatementOutcome> InvokeSpAddLinkedServer(Ba
4747
positionalIndex++;
4848
switch (name)
4949
{
50-
case var n when Collation.Default.Equals(n, "server"):
50+
case var n when BuiltInToken.Equals(n, "server"):
5151
server = arg.Value.IsNull ? null : arg.Value.CoerceTo(SqlType.SystemName).AsString;
5252
break;
53-
case var n when Collation.Default.Equals(n, "srvproduct"):
53+
case var n when BuiltInToken.Equals(n, "srvproduct"):
5454
srvProduct = arg.Value.IsNull ? string.Empty : arg.Value.CoerceTo(SqlType.SystemName).AsString;
5555
break;
56-
case var n when Collation.Default.Equals(n, "provider"):
56+
case var n when BuiltInToken.Equals(n, "provider"):
5757
provider = arg.Value.IsNull ? "SQLNCLI" : arg.Value.CoerceTo(SqlType.SystemName).AsString;
5858
break;
59-
case var n when Collation.Default.Equals(n, "datasrc"):
59+
case var n when BuiltInToken.Equals(n, "datasrc"):
6060
dataSource = arg.Value.IsNull ? null : arg.Value.CoerceTo(SqlType.SystemName).AsString;
6161
break;
62-
case var n when Collation.Default.Equals(n, "location"):
63-
case var n2 when Collation.Default.Equals(n2, "provider_string"):
64-
case var n3 when Collation.Default.Equals(n3, "catalog"):
62+
case var n when BuiltInToken.Equals(n, "location"):
63+
case var n2 when BuiltInToken.Equals(n2, "provider_string"):
64+
case var n3 when BuiltInToken.Equals(n3, "catalog"):
6565
break;
6666
default:
6767
throw SimulatedSqlException.InvalidLinkedServerParameter("sp_addlinkedserver");
@@ -106,10 +106,10 @@ private static IEnumerable<SimulatedStatementOutcome> InvokeSpDropServer(BatchCo
106106
positionalIndex++;
107107
switch (name)
108108
{
109-
case var n when Collation.Default.Equals(n, "server"):
109+
case var n when BuiltInToken.Equals(n, "server"):
110110
server = arg.Value.IsNull ? null : arg.Value.CoerceTo(SqlType.SystemName).AsString;
111111
break;
112-
case var n when Collation.Default.Equals(n, "droplogins"):
112+
case var n when BuiltInToken.Equals(n, "droplogins"):
113113
break;
114114
default:
115115
throw SimulatedSqlException.InvalidLinkedServerParameter("sp_dropserver");

0 commit comments

Comments
 (0)