Skip to content

Commit 81d5593

Browse files
committed
Introduced contextual keywords to simplify parsing.
1 parent 9474065 commit 81d5593

4 files changed

Lines changed: 104 additions & 27 deletions

File tree

SqlServerSimulator.Tests/CreateTableTests.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,4 +352,31 @@ public void CreateTableDateTimeOffset_PrecisionOutOfRange(int precision)
352352
var x = Assert.Throws<DbException>(() => command.ExecuteNonQuery());
353353
Assert.AreEqual($"Line 1: Specified scale {precision} is invalid.", x.Message);
354354
}
355+
356+
/// <summary>
357+
/// Identifiers that match contextual keywords (the parser-side enum
358+
/// covering OUTPUT, USING, MATCHED, MAX, etc.) must still work as column
359+
/// names — the architecture classifies contextual keywords at parse time
360+
/// in keyword-expecting positions only, so identifier positions are
361+
/// unaffected. <c>Output</c>, <c>Using</c>, <c>Matched</c>, and <c>Max</c>
362+
/// are the most likely real-world collisions.
363+
/// </summary>
364+
[TestMethod]
365+
[DataRow("Output")]
366+
[DataRow("Using")]
367+
[DataRow("Matched")]
368+
[DataRow("Max")]
369+
[DataRow("Configuration")]
370+
public void ContextualKeywordsAsColumnNames_RoundTrip(string columnName)
371+
{
372+
var simulation = new Simulation();
373+
_ = simulation.ExecuteNonQuery($"create table t ( {columnName} int )");
374+
_ = simulation.ExecuteNonQuery($"insert into t ({columnName}) values (42)");
375+
376+
using var reader = simulation
377+
.CreateCommand($"select {columnName} from t where {columnName} = 42")
378+
.ExecuteReader();
379+
Assert.IsTrue(reader.Read());
380+
Assert.AreEqual(42, reader.GetInt32(0));
381+
}
355382
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
namespace SqlServerSimulator.Parser;
2+
3+
/// <summary>
4+
/// Identifiers the parser treats as keywords in specific positions but that
5+
/// aren't on SQL Server's official reserved-keyword list (see
6+
/// <see cref="Keyword"/>, sourced verbatim from MS docs). Kept in a separate
7+
/// enum so the reserved set stays tied to documentation while parser sites
8+
/// still get typed dispatch — the alternative was scattered case-insensitive
9+
/// span comparisons against string literals at every call site.
10+
/// </summary>
11+
/// <remarks>
12+
/// Recognition happens at parse time, not tokenization time: tokenizer keeps
13+
/// 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.
19+
/// </remarks>
20+
enum ContextualKeyword
21+
{
22+
_ = 0, // Default — current token isn't a contextual keyword.
23+
Compatibility_Level,
24+
Configuration,
25+
Matched,
26+
Max,
27+
Output,
28+
Scoped,
29+
TraceOff,
30+
TraceOn,
31+
Using,
32+
Verbose_Truncation_Warnings,
33+
}

SqlServerSimulator/Parser/ParserContext.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,30 @@ public ParserContext MoveNextRequiredReturnSelf()
132132
return this;
133133
}
134134

135+
/// <summary>
136+
/// Classifies the current <see cref="Token"/> as a
137+
/// <see cref="ContextualKeyword"/>: returns the matching value if
138+
/// <see cref="Token"/> is an unquoted identifier whose text matches one
139+
/// of the contextual-keyword names case-insensitively, otherwise
140+
/// returns <see cref="ContextualKeyword._"/> (zero / default). Useful
141+
/// for switch-style dispatch on positions that accept several optional
142+
/// keywords (e.g. DBCC <c>TRACEON</c>/<c>TRACEOFF</c>).
143+
/// </summary>
144+
public ContextualKeyword AsContextual() =>
145+
this.Token is UnquotedString u
146+
&& Enum.TryParse<ContextualKeyword>(u.Span, ignoreCase: true, out var keyword)
147+
&& keyword != ContextualKeyword._
148+
? keyword
149+
: ContextualKeyword._;
150+
151+
/// <summary>
152+
/// Returns true when <see cref="Token"/> is an unquoted identifier that
153+
/// matches the given <paramref name="expected"/> contextual keyword
154+
/// (case-insensitive). Use for required-keyword guards in parser flows;
155+
/// for switch-on-classification, see <see cref="AsContextual"/>.
156+
/// </summary>
157+
public bool MatchContextual(ContextualKeyword expected) => AsContextual() == expected;
158+
135159
/// <summary>
136160
/// Advances <see cref="Token"/> to the next token in the enumeration, throwing an exception if the end was reached instead.
137161
/// </summary>

SqlServerSimulator/Simulation.cs

Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ private bool TryParseCreate(ParserContext context)
188188
{
189189
declaredMaxLength = numericValue.AsInt32;
190190
}
191-
else if (lengthToken is UnquotedString unquoted && unquoted.Span.Equals("MAX", StringComparison.OrdinalIgnoreCase))
191+
else if (context.MatchContextual(ContextualKeyword.Max))
192192
{
193193
throw new NotSupportedException($"{type}(MAX) and other LOB types aren't modeled yet.");
194194
}
@@ -492,7 +492,7 @@ private static SimulatedStatementOutcome ProcessHeapInsert(HeapTable destination
492492
/// <param name="sourceColumnNames">For MERGE only: the source alias's column names. <see langword="null"/> for plain INSERT.</param>
493493
private static OutputProjection? TryParseOutputClause(ParserContext context, HeapTable destinationTable, (string SourceAlias, string[] SourceColumns, SqlType[] SourceTypes)? sourceColumnNames)
494494
{
495-
if (context.Token is not UnquotedString unquoted || !unquoted.Span.Equals("OUTPUT", StringComparison.OrdinalIgnoreCase))
495+
if (!context.MatchContextual(ContextualKeyword.Output))
496496
return null;
497497

498498
var expressions = new List<Expression>();
@@ -628,7 +628,8 @@ private static SimulatedStatementOutcome ParseMerge(ParserContext context)
628628
? table
629629
: throw SimulatedSqlException.InvalidObjectName(destinationTableToken);
630630

631-
if (context.GetNextRequired() is not UnquotedString usingKw || !usingKw.Span.Equals("USING", StringComparison.OrdinalIgnoreCase))
631+
context.MoveNextRequired();
632+
if (!context.MatchContextual(ContextualKeyword.Using))
632633
throw SimulatedSqlException.SyntaxErrorNear(context);
633634

634635
if (context.GetNextRequired() is not Operator { Character: '(' })
@@ -694,7 +695,7 @@ private static SimulatedStatementOutcome ParseMerge(ParserContext context)
694695
if (context.Token is ReservedKeyword { Keyword: Keyword.Not })
695696
{
696697
context.MoveNextRequired();
697-
if (context.Token is not UnquotedString matchedKw || !matchedKw.Span.Equals("MATCHED", StringComparison.OrdinalIgnoreCase))
698+
if (!context.MatchContextual(ContextualKeyword.Matched))
698699
throw SimulatedSqlException.SyntaxErrorNear(context);
699700
if (context.GetNextRequired() is not ReservedKeyword { Keyword: Keyword.Then })
700701
throw SimulatedSqlException.SyntaxErrorNear(context);
@@ -750,7 +751,7 @@ private static SimulatedStatementOutcome ParseMerge(ParserContext context)
750751
{
751752
context.MoveNextOptional();
752753
}
753-
if (context.Token is UnquotedString tail && !tail.Span.Equals("OUTPUT", StringComparison.OrdinalIgnoreCase))
754+
if (context.Token is UnquotedString && !context.MatchContextual(ContextualKeyword.Output))
754755
context.MoveNextOptional();
755756
}
756757
}
@@ -1080,7 +1081,7 @@ private bool TryParseAlter(ParserContext context)
10801081
return false;
10811082

10821083
var afterDatabase = context.GetNextRequired();
1083-
if (afterDatabase is UnquotedString unquoted && unquoted.Span.Equals("SCOPED", StringComparison.OrdinalIgnoreCase))
1084+
if (context.MatchContextual(ContextualKeyword.Scoped))
10841085
return TryParseAlterDatabaseScopedConfiguration(context);
10851086

10861087
// Otherwise a database name (or CURRENT). The simulator has one
@@ -1094,10 +1095,8 @@ private bool TryParseAlterDatabaseSet(ParserContext context)
10941095
if (context.GetNextRequired() is not ReservedKeyword { Keyword: Keyword.Set })
10951096
return false;
10961097

1097-
if (context.GetNextRequired() is not UnquotedString option)
1098-
return false;
1099-
1100-
if (!option.Span.Equals("COMPATIBILITY_LEVEL", StringComparison.OrdinalIgnoreCase))
1098+
context.MoveNextRequired();
1099+
if (!context.MatchContextual(ContextualKeyword.Compatibility_Level))
11011100
return false;
11021101

11031102
if (context.GetNextRequired() is not Operator { Character: '=' })
@@ -1116,19 +1115,15 @@ private bool TryParseAlterDatabaseSet(ParserContext context)
11161115

11171116
private bool TryParseAlterDatabaseScopedConfiguration(ParserContext context)
11181117
{
1119-
if (context.GetNextRequired() is not UnquotedString configToken
1120-
|| !configToken.Span.Equals("CONFIGURATION", StringComparison.OrdinalIgnoreCase))
1121-
{
1118+
context.MoveNextRequired();
1119+
if (!context.MatchContextual(ContextualKeyword.Configuration))
11221120
return false;
1123-
}
11241121

11251122
if (context.GetNextRequired() is not ReservedKeyword { Keyword: Keyword.Set })
11261123
return false;
11271124

1128-
if (context.GetNextRequired() is not UnquotedString option)
1129-
return false;
1130-
1131-
if (!option.Span.Equals("VERBOSE_TRUNCATION_WARNINGS", StringComparison.OrdinalIgnoreCase))
1125+
context.MoveNextRequired();
1126+
if (!context.MatchContextual(ContextualKeyword.Verbose_Truncation_Warnings))
11321127
return false;
11331128

11341129
if (context.GetNextRequired() is not Operator { Character: '=' })
@@ -1149,16 +1144,14 @@ private bool TryParseAlterDatabaseScopedConfiguration(ParserContext context)
11491144
/// </summary>
11501145
private bool TryParseDbcc(ParserContext context)
11511146
{
1152-
if (context.GetNextRequired() is not UnquotedString action)
1153-
return false;
1154-
1147+
context.MoveNextRequired();
11551148
bool turningOn;
1156-
if (action.Span.Equals("TRACEON", StringComparison.OrdinalIgnoreCase))
1157-
turningOn = true;
1158-
else if (action.Span.Equals("TRACEOFF", StringComparison.OrdinalIgnoreCase))
1159-
turningOn = false;
1160-
else
1161-
return false;
1149+
switch (context.AsContextual())
1150+
{
1151+
case ContextualKeyword.TraceOn: turningOn = true; break;
1152+
case ContextualKeyword.TraceOff: turningOn = false; break;
1153+
default: return false;
1154+
}
11621155

11631156
if (context.GetNextRequired() is not Operator { Character: '(' })
11641157
return false;

0 commit comments

Comments
 (0)