Skip to content

Commit e298638

Browse files
committed
ContextualKeyword: cache classification on UnquotedString, retire ParserContext.AsContextual/MatchContextual, fold TRY/CATCH/THROW/WORK/DELAY/ATOMIC contextual-text matches into the enum.
1 parent a035c48 commit e298638

21 files changed

Lines changed: 101 additions & 98 deletions

SqlServerSimulator.Tests.Internal/Parser/ReservedKeywordsTests.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ namespace SqlServerSimulator.Parser;
1111
/// to this enum is a bug because the tokenizer relies on the enum to decide
1212
/// what surfaces as <c>ReservedKeyword</c> vs. <c>UnquotedString</c>, and a
1313
/// non-reserved word being treated as reserved breaks valid SQL (e.g.
14-
/// blocking <c>select 1 as throw</c>).
14+
/// blocking <c>select 1 as throw</c>). Contextual keywords belong in
15+
/// <see cref="ContextualKeyword"/> instead — parser sites then read them via
16+
/// <see cref="Tokens.UnquotedString.ContextualKeyword"/>.
1517
/// </summary>
1618
/// <remarks>
1719
/// The test cross-checks both directions: every entry in <see cref="Keyword"/>
@@ -95,7 +97,7 @@ public void Keyword_Enum_MatchesCanonicalReservedList()
9597

9698
IsEmpty(
9799
unexpectedExtras,
98-
$"Keyword enum has entries not in the canonical reserved list at {SourceUrl}: {string.Join(", ", unexpectedExtras)}. The reserved-keyword list is frozen — remove the entry from the enum (and route any contextual-keyword usage through UnquotedString, matching the TRY / CATCH / THROW pattern).");
100+
$"Keyword enum has entries not in the canonical reserved list at {SourceUrl}: {string.Join(", ", unexpectedExtras)}. The reserved-keyword list is frozen — remove the entry from Keyword. If the parser needs to recognize the identifier in specific positions, add it to ContextualKeyword instead; call sites read it via UnquotedString.ContextualKeyword (see the TRY / CATCH / THROW pattern in Simulation.TryCatch.cs / Simulation.Throw.cs).");
99101

100102
IsEmpty(
101103
unexpectedOmissions,

SqlServerSimulator/Parser/ContextualKeyword.cs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,23 @@ namespace SqlServerSimulator.Parser;
1111
/// <remarks>
1212
/// Recognition happens at parse time, not tokenization time: tokenizer keeps
1313
/// emitting <see cref="Tokens.UnquotedString"/> for any unquoted identifier,
14-
/// and <see cref="ParserContext.MatchContextual"/> /
15-
/// <see cref="ParserContext.AsContextual"/> classify on demand. This keeps
16-
/// column references whose names happen to match a contextual keyword
17-
/// (e.g. <c>create table t (Output int)</c>) working without special casing
18-
/// — identifier positions never invoke the contextual lookup.
14+
/// and <see cref="Tokens.UnquotedString.ContextualKeyword"/> classifies on
15+
/// demand with per-token caching. This keeps column references whose names
16+
/// happen to match a contextual keyword (e.g. <c>create table t (Output int)</c>)
17+
/// working without special casing — identifier positions never invoke the
18+
/// contextual lookup.
1919
/// </remarks>
2020
enum ContextualKeyword
2121
{
22-
_ = 0, // Default — current token isn't a contextual keyword.
22+
NotChecked = 0, // Default field value — token hasn't been classified yet.
23+
_, // Classified: not a contextual keyword.
2324
Apply,
2425
At,
26+
Atomic,
27+
Catch,
2528
Compatibility_Level,
2629
Configuration,
30+
Delay,
2731
First,
2832
Matched,
2933
Max,
@@ -38,11 +42,14 @@ enum ContextualKeyword
3842
Row,
3943
Rows,
4044
Scoped,
45+
Throw,
4146
Time,
4247
TraceOff,
4348
TraceOn,
49+
Try,
4450
Using,
4551
Verbose_Truncation_Warnings,
4652
Within,
53+
Work,
4754
Zone,
4855
}

SqlServerSimulator/Parser/Expression.cs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,9 @@ public static Expression Parse(ParserContext context)
176176
// ordered-set aggregate postfix. STRING_AGG accepts it; every
177177
// other aggregate kind raises Msg 10757. WITHIN is contextual
178178
// (SQL Server doesn't reserve the identifier).
179-
case UnquotedString unquoted when expression is AggregateExpression aggregateForOrderBy
180-
&& context.AsContextual() == ContextualKeyword.Within:
179+
case UnquotedString { ContextualKeyword: ContextualKeyword.Within }
180+
when expression is AggregateExpression aggregateForOrderBy:
181181
{
182-
_ = unquoted;
183182
ParseWithinGroupOrderBy(aggregateForOrderBy, context);
184183
continue;
185184
}
@@ -188,9 +187,8 @@ public static Expression Parse(ParserContext context)
188187
// reserve any of them); the runtime check rejects date/time
189188
// LHS with Msg 8116. Binds tighter than `+` so the zone-name
190189
// slot is a primary expression — full expressions need parens.
191-
case UnquotedString atToken when context.AsContextual() == ContextualKeyword.At:
190+
case UnquotedString { ContextualKeyword: ContextualKeyword.At }:
192191
{
193-
_ = atToken;
194192
expression = AtTimeZone.ParsePostfix(expression, context);
195193
continue;
196194
}

SqlServerSimulator/Parser/Expressions/AtTimeZone.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,13 +153,13 @@ private static TimeZoneInfo ResolveZone(string name)
153153
public static AtTimeZone ParsePostfix(Expression source, ParserContext context)
154154
{
155155
// Cursor is on AT.
156-
if (!context.MatchContextual(ContextualKeyword.At))
156+
if (context.Token is not UnquotedString { ContextualKeyword: ContextualKeyword.At })
157157
throw SimulatedSqlException.SyntaxErrorNear(context);
158158
context.MoveNextRequired();
159-
if (!context.MatchContextual(ContextualKeyword.Time))
159+
if (context.Token is not UnquotedString { ContextualKeyword: ContextualKeyword.Time })
160160
throw SimulatedSqlException.SyntaxErrorNear(context);
161161
context.MoveNextRequired();
162-
if (!context.MatchContextual(ContextualKeyword.Zone))
162+
if (context.Token is not UnquotedString { ContextualKeyword: ContextualKeyword.Zone })
163163
throw SimulatedSqlException.SyntaxErrorNear(context);
164164
context.MoveNextRequired();
165165

SqlServerSimulator/Parser/Expressions/Cast.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ internal static (SqlType targetType, int? targetMaxLength) ParseTargetTypeSpec(P
8585
var lengthToken = context.GetNextRequired();
8686
declaredMaxLength = lengthToken is Numeric { Value: { IsNull: false } numericValue }
8787
? numericValue.AsInt32
88-
: context.MatchContextual(ContextualKeyword.Max)
88+
: context.Token is UnquotedString { ContextualKeyword: ContextualKeyword.Max }
8989
? SqlType.MaxLengthSentinel
9090
: throw SimulatedSqlException.SyntaxErrorNear(context);
9191
switch (context.GetNextRequired())

SqlServerSimulator/Parser/Expressions/WindowExpression.cs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ public static WindowExpression WrapAggregate(AggregateExpression aggregate, Pars
191191
/// <c>ORDER</c>, <c>ROWS</c>, <c>RANGE</c>, or the closing <c>)</c>).
192192
/// </summary>
193193
private static Expression[] ParseOptionalPartitionBy(ParserContext context) =>
194-
!context.MatchContextual(ContextualKeyword.Partition)
194+
context.Token is not UnquotedString { ContextualKeyword: ContextualKeyword.Partition }
195195
? []
196196
: context.GetNextRequired() is not ReservedKeyword { Keyword: Keyword.By }
197197
? throw SimulatedSqlException.SyntaxErrorNear(context)
@@ -207,10 +207,13 @@ private static Expression[] ParseOptionalPartitionBy(ParserContext context) =>
207207
/// </summary>
208208
private static void RejectFrameSpec(ParserContext context)
209209
{
210-
if (context.MatchContextual(ContextualKeyword.Rows))
211-
throw new NotSupportedException("Explicit window frame ROWS BETWEEN inside OVER isn't modeled; only the implicit default frame is supported.");
212-
if (context.MatchContextual(ContextualKeyword.Range))
213-
throw new NotSupportedException("Explicit window frame RANGE BETWEEN inside OVER isn't modeled; only the implicit default frame is supported.");
210+
switch (context.Token)
211+
{
212+
case UnquotedString { ContextualKeyword: ContextualKeyword.Rows }:
213+
throw new NotSupportedException("Explicit window frame ROWS BETWEEN inside OVER isn't modeled; only the implicit default frame is supported.");
214+
case UnquotedString { ContextualKeyword: ContextualKeyword.Range }:
215+
throw new NotSupportedException("Explicit window frame RANGE BETWEEN inside OVER isn't modeled; only the implicit default frame is supported.");
216+
}
214217
}
215218

216219
/// <summary>

SqlServerSimulator/Parser/ParserContext.cs

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -215,30 +215,6 @@ public ParserContext MoveNextRequiredReturnSelf()
215215
return this;
216216
}
217217

218-
/// <summary>
219-
/// Classifies the current <see cref="Token"/> as a
220-
/// <see cref="ContextualKeyword"/>: returns the matching value if
221-
/// <see cref="Token"/> is an unquoted identifier whose text matches one
222-
/// of the contextual-keyword names case-insensitively, otherwise
223-
/// returns <see cref="ContextualKeyword._"/> (zero / default). Useful
224-
/// for switch-style dispatch on positions that accept several optional
225-
/// keywords (e.g. DBCC <c>TRACEON</c>/<c>TRACEOFF</c>).
226-
/// </summary>
227-
public ContextualKeyword AsContextual() =>
228-
this.Token is UnquotedString u
229-
&& Enum.TryParse<ContextualKeyword>(u.Span, ignoreCase: true, out var keyword)
230-
&& keyword != ContextualKeyword._
231-
? keyword
232-
: ContextualKeyword._;
233-
234-
/// <summary>
235-
/// Returns true when <see cref="Token"/> is an unquoted identifier that
236-
/// matches the given <paramref name="expected"/> contextual keyword
237-
/// (case-insensitive). Use for required-keyword guards in parser flows;
238-
/// for switch-on-classification, see <see cref="AsContextual"/>.
239-
/// </summary>
240-
public bool MatchContextual(ContextualKeyword expected) => AsContextual() == expected;
241-
242218
/// <summary>
243219
/// Advances <see cref="Token"/> to the next token in the enumeration, throwing an exception if the end was reached instead.
244220
/// </summary>

SqlServerSimulator/Parser/Selection.OpenJson.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ private static OpenJsonColumn[] ParseOpenJsonWithColumns(ParserContext context,
281281
var lengthToken = context.GetNextRequired();
282282
declaredMaxLength = lengthToken is Numeric { Value: { IsNull: false } numericValue }
283283
? numericValue.AsInt32
284-
: context.MatchContextual(ContextualKeyword.Max)
284+
: context.Token is UnquotedString { ContextualKeyword: ContextualKeyword.Max }
285285
? SqlType.MaxLengthSentinel
286286
: throw SimulatedSqlException.SyntaxErrorNear(context);
287287

SqlServerSimulator/Parser/Selection.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ private static void ParseOptionClause(ParserContext context)
227227
while (true)
228228
{
229229
context.MoveNextRequired();
230-
if (!context.MatchContextual(ContextualKeyword.MaxRecursion))
230+
if (context.Token is not UnquotedString { ContextualKeyword: ContextualKeyword.MaxRecursion })
231231
throw new NotSupportedException("Only OPTION (MAXRECURSION N) is modeled in the OPTION clause.");
232232

233233
if (context.GetNextRequired() is not Numeric { Value: { IsNull: false } limitValue })
@@ -1014,7 +1014,7 @@ private static bool TryParseJoinKeyword(ParserContext context, out JoinKind kind
10141014
kind = JoinKind.Cross;
10151015
return true;
10161016
}
1017-
if (context.MatchContextual(ContextualKeyword.Apply))
1017+
if (context.Token is UnquotedString { ContextualKeyword: ContextualKeyword.Apply })
10181018
{
10191019
kind = JoinKind.CrossApply;
10201020
return true;
@@ -1026,7 +1026,7 @@ private static bool TryParseJoinKeyword(ParserContext context, out JoinKind kind
10261026
// cases above). The cursor is on OUTER; advance and require APPLY.
10271027
case Keyword.Outer:
10281028
context.MoveNextRequired();
1029-
if (!context.MatchContextual(ContextualKeyword.Apply))
1029+
if (context.Token is not UnquotedString { ContextualKeyword: ContextualKeyword.Apply })
10301030
throw SimulatedSqlException.SyntaxErrorNear(context);
10311031
kind = JoinKind.OuterApply;
10321032
return true;
@@ -1193,7 +1193,7 @@ private static void ConsumeOffsetFetch(ParserContext context, FromClause fromCla
11931193
if (context.Token is ReservedKeyword { Keyword: Keyword.Fetch })
11941194
throw SimulatedSqlException.FetchInvalidUsageWithoutOffset();
11951195

1196-
if (!context.MatchContextual(ContextualKeyword.Offset))
1196+
if (context.Token is not UnquotedString { ContextualKeyword: ContextualKeyword.Offset })
11971197
return;
11981198

11991199
context.MoveNextRequired();
@@ -1207,15 +1207,15 @@ private static void ConsumeOffsetFetch(ParserContext context, FromClause fromCla
12071207
throw SimulatedSqlException.OffsetMustNotBeNegative();
12081208
fromClause.OffsetCount = offsetCount;
12091209

1210-
if (!context.MatchContextual(ContextualKeyword.Row) && !context.MatchContextual(ContextualKeyword.Rows))
1210+
if (context.Token is not UnquotedString { ContextualKeyword: ContextualKeyword.Row or ContextualKeyword.Rows })
12111211
throw SimulatedSqlException.SyntaxErrorNear(context);
12121212
context.MoveNextOptional();
12131213

12141214
if (context.Token is not ReservedKeyword { Keyword: Keyword.Fetch })
12151215
return;
12161216

12171217
context.MoveNextRequired();
1218-
if (!context.MatchContextual(ContextualKeyword.Next) && !context.MatchContextual(ContextualKeyword.First))
1218+
if (context.Token is not UnquotedString { ContextualKeyword: ContextualKeyword.Next or ContextualKeyword.First })
12191219
throw SimulatedSqlException.SyntaxErrorNear(context);
12201220
context.MoveNextRequired();
12211221

@@ -1229,11 +1229,11 @@ private static void ConsumeOffsetFetch(ParserContext context, FromClause fromCla
12291229
throw SimulatedSqlException.FetchMustBeGreaterThanZero();
12301230
fromClause.FetchCount = fetchCount;
12311231

1232-
if (!context.MatchContextual(ContextualKeyword.Row) && !context.MatchContextual(ContextualKeyword.Rows))
1232+
if (context.Token is not UnquotedString { ContextualKeyword: ContextualKeyword.Row or ContextualKeyword.Rows })
12331233
throw SimulatedSqlException.SyntaxErrorNear(context);
12341234
context.MoveNextRequired();
12351235

1236-
if (!context.MatchContextual(ContextualKeyword.Only))
1236+
if (context.Token is not UnquotedString { ContextualKeyword: ContextualKeyword.Only })
12371237
throw SimulatedSqlException.SyntaxErrorNear(context);
12381238
context.MoveNextOptional();
12391239
}

SqlServerSimulator/Parser/Tokens/UnquotedString.cs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
namespace SqlServerSimulator.Parser.Tokens;
1+
namespace SqlServerSimulator.Parser.Tokens;
22

33
sealed class UnquotedString : Name
44
{
@@ -9,6 +9,29 @@ private UnquotedString(string command, int index, int length)
99

1010
public override ReadOnlySpan<char> Span => Source;
1111

12+
/// <summary>
13+
/// Lazily classifies this token against <see cref="Parser.ContextualKeyword"/>.
14+
/// First access parses <see cref="Span"/> via <see cref="Enum.TryParse{TEnum}(ReadOnlySpan{char}, bool, out TEnum)"/>
15+
/// (case-insensitive); the result is cached on the field so repeat reads at
16+
/// the same token are constant-time. A miss — or a pathological identifier
17+
/// matching either sentinel name (<c>NotChecked</c> / <c>_</c>) — collapses
18+
/// to <see cref="ContextualKeyword._"/>.
19+
/// </summary>
20+
public ContextualKeyword ContextualKeyword
21+
{
22+
get
23+
{
24+
if (field == ContextualKeyword.NotChecked)
25+
{
26+
field = Enum.TryParse<ContextualKeyword>(this.Span, ignoreCase: true, out var keyword)
27+
&& keyword is not (ContextualKeyword.NotChecked or ContextualKeyword._)
28+
? keyword
29+
: ContextualKeyword._;
30+
}
31+
return field;
32+
}
33+
}
34+
1235
/// <summary>
1336
/// Returns either an <see cref="UnquotedString"/> or <see cref="ReservedKeyword"/> depending on input.
1437
/// </summary>

0 commit comments

Comments
 (0)