Skip to content

Commit 18b2195

Browse files
committed
Properly implemented AND in WHERE clauses.
1 parent eef4c5a commit 18b2195

6 files changed

Lines changed: 154 additions & 45 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ Heavy-hitters someone might assume work but don't. Source and `git log` are the
7575
- `text` / `ntext` / `image` operation restrictions: comparison (Msg 402) and ORDER BY / DISTINCT (Msg 306) are enforced; function-level restrictions (e.g. `LEN(ntext)` raising Msg 8116) and the legacy `READTEXT`/`WRITETEXT`/`UPDATETEXT` family aren't modeled.
7676
- `decimal` / `numeric` values are backed by .NET `decimal`, so values requiring more than 28 significant digits aren't modeled (the type declarations up through `decimal(38, *)` are accepted so storage byte-width still matches SQL Server). `float` text formatting uses .NET's `G15`/`G7` conventions rather than SQL Server's exact `1e+015`-style scientific layout.
7777
- `CONVERT` / `TRY_CONVERT` style-code coverage. Only styles `0`, `120`, and `121` are wired up for date-like sources targeting a character string (the EF Core code-generation defaults). Other style numbers raise Msg 281; money / float / binary style codes and `CONVERT(date, str, 103)`-style date-parsing styles aren't modeled.
78+
- Boolean combinators in WHERE / MERGE-ON predicates: `AND` chains work, but `OR` / `NOT` (at the predicate-combinator level — `NOT LIKE` is fine inside a single predicate) and parenthesized predicate groupings aren't parsed yet. `OR` raises `NotSupportedException`.
7879
- `LIKE` `COLLATE` override. The default collation (case-insensitive, Latin1_General-shaped) is what every `LIKE` runs under; explicit `COLLATE` clauses on the predicate aren't parsed yet.
7980
- Cross-category `Promote` for integer ↔ string. Only CAST works for that pair.
8081
- EF Core compatibility: the `SqlServerSimulator.EFCore` adapter (`UseSqlServerSimulator(...)`) covers the seven `SqlParameter`-downcast pairs — `DateOnly → date`, `DateTime → date`, `DateTime → smalldatetime`, `TimeOnly → time(N)`, `TimeSpan → time(N)`, `decimal → money`, `decimal → smallmoney`. Without the adapter (plain `UseSqlServer`), those mappings still throw at SaveChanges as before. The MAX-string family (default-mapped `string``nvarchar(max)`, `[Column(TypeName = "varchar(max)")]`, `[Column(TypeName = "varbinary(max)")]`) flows through plain `UseSqlServer` without needing the adapter. Other type pairs the SqlServer provider supports but aren't modeled here (e.g. `hierarchyid`, `geography`, `geometry`, `rowversion`) are not bridged.

SqlServerSimulator.Tests/KeyConstraintTests.cs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,10 @@ public void PrimaryKey_DuplicateInsert_StringValue_RendersWithoutQuotes()
9999
[TestMethod]
100100
public void PrimaryKey_TableLevel_Composite_Works()
101101
{
102-
// Each (a, b) pair distinct → all three insert successfully. Read-back
103-
// uses single-predicate WHERE (composite-key equality projection
104-
// already exercised by the duplicate-violation test).
105102
var simulation = new Simulation();
106103
_ = simulation.ExecuteNonQuery("create table t (a int not null, b int not null, c int, constraint pk_t primary key (a, b))");
107104
_ = simulation.ExecuteNonQuery("insert into t values (1, 2, 100), (1, 3, 200), (2, 2, 300)");
108-
Assert.AreEqual(200, simulation.ExecuteScalar("select c from t where b = 3"));
105+
Assert.AreEqual(200, simulation.ExecuteScalar("select c from t where a = 1 and b = 3"));
109106
}
110107

111108
[TestMethod]

SqlServerSimulator.Tests/WhereTests.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,61 @@ public void FromTableWhere_CrossPrecisionDateTimeOffsetComparison()
332332
IsFalse(reader.Read());
333333
}
334334

335+
[TestMethod]
336+
public void Where_AndChain_TwoPredicates_BothMustHold()
337+
{
338+
// Regression for the silent-wrong-rows bug: pre-fix, `where a=X and
339+
// b=Y` parsed only `a=X` and dropped `and b=Y`, returning the first
340+
// row matching just the left predicate. With composite-PK row
341+
// (1,3,200) present, the right answer is 200, not the (1,2,100) row
342+
// that matches only `a=1`.
343+
using var connection = new Simulation().CreateOpenConnection();
344+
_ = connection.CreateCommand("create table t (a int not null, b int not null, c int)").ExecuteNonQuery();
345+
_ = connection.CreateCommand("insert into t values (1, 2, 100), (1, 3, 200), (2, 2, 300)").ExecuteNonQuery();
346+
347+
AreEqual(200, connection.CreateCommand("select c from t where a = 1 and b = 3").ExecuteScalar());
348+
AreEqual(100, connection.CreateCommand("select c from t where a = 1 and b = 2").ExecuteScalar());
349+
AreEqual(300, connection.CreateCommand("select c from t where a = 2 and b = 2").ExecuteScalar());
350+
}
351+
352+
[TestMethod]
353+
public void Where_AndChain_ThreePredicates_AllMustHold()
354+
{
355+
using var connection = new Simulation().CreateOpenConnection();
356+
_ = connection.CreateCommand("create table t (a int, b int, c int)").ExecuteNonQuery();
357+
_ = connection.CreateCommand("insert into t values (1, 2, 100), (1, 2, 200)").ExecuteNonQuery();
358+
359+
AreEqual(100, connection.CreateCommand("select c from t where a = 1 and b = 2 and c = 100").ExecuteScalar());
360+
IsNull(connection.CreateCommand("select c from t where a = 1 and b = 2 and c = 999").ExecuteScalar());
361+
}
362+
363+
[TestMethod]
364+
public void Where_AndChain_NullOperand_ExcludesRow()
365+
{
366+
// SQL Server WHERE: NULL on either side of AND treats the row as
367+
// excluded (the whole predicate evaluates to false/UNKNOWN).
368+
using var connection = new Simulation().CreateOpenConnection();
369+
_ = connection.CreateCommand("create table t (a int, b int)").ExecuteNonQuery();
370+
_ = connection.CreateCommand("insert into t values (1, null), (1, 2)").ExecuteNonQuery();
371+
372+
// Only (1, 2) passes both predicates; (1, NULL) fails the b=2 side.
373+
using var reader = connection.CreateCommand("select b from t where a = 1 and b = 2").ExecuteReader();
374+
IsTrue(reader.Read());
375+
AreEqual(2, reader[0]);
376+
IsFalse(reader.Read());
377+
}
378+
379+
[TestMethod]
380+
public void Where_OrInPredicate_RaisesNotSupported()
381+
{
382+
// OR / NOT at the boolean-combinator level aren't modeled yet; the
383+
// parser surfaces the gap so the predicate doesn't silently drop.
384+
using var connection = new Simulation().CreateOpenConnection();
385+
_ = connection.CreateCommand("create table t (a int, b int)").ExecuteNonQuery();
386+
var ex = Throws<NotSupportedException>(() => connection.CreateCommand("select a from t where a = 1 or b = 2").ExecuteScalar());
387+
Contains("OR", ex.Message);
388+
}
389+
335390
private static void AddTypedParameter(System.Data.Common.DbCommand command, string name, DbType dbType, object value)
336391
{
337392
var parameter = command.CreateParameter();

SqlServerSimulator/Parser/BooleanExpression.cs

Lines changed: 95 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,42 @@ namespace SqlServerSimulator.Parser;
1212
[DebuggerDisplay("{DebugDisplay(),nq}")]
1313
internal abstract class BooleanExpression
1414
{
15-
protected readonly Expression left, right;
16-
17-
private protected BooleanExpression(Expression left, Expression right)
15+
private protected BooleanExpression()
1816
{
19-
this.left = left;
20-
this.right = right;
2117
}
2218

23-
private protected BooleanExpression(Expression left, ParserContext context)
24-
: this(left, Expression.Parse(context.MoveNextRequiredReturnSelf()))
19+
/// <summary>
20+
/// Parses a full boolean predicate from the current position: a single
21+
/// comparison (the <see cref="CompareExpression"/> shapes), optionally
22+
/// chained with one or more <c>AND</c> clauses producing an
23+
/// <see cref="AndExpression"/> tree. Each chain element is itself a
24+
/// comparison; nested parens around predicates and <c>OR</c>/<c>NOT</c>
25+
/// at the boolean-combinator level aren't supported yet (they fall
26+
/// through to the comparison parser, which surfaces them as syntax
27+
/// errors). Follows the lookahead contract on <see cref="ParserContext"/>:
28+
/// on return, <see cref="ParserContext.Token"/> is the first token not
29+
/// consumed by the predicate.
30+
/// </summary>
31+
public static BooleanExpression Parse(ParserContext context)
2532
{
33+
var result = ParseSingle(Expression.Parse(context), context);
34+
while (context.Token is ReservedKeyword { Keyword: Keyword.And })
35+
{
36+
context.MoveNextRequired();
37+
var rhs = ParseSingle(Expression.Parse(context), context);
38+
result = new AndExpression(result, rhs);
39+
}
40+
return context.Token is ReservedKeyword { Keyword: Keyword.Or }
41+
? throw new NotSupportedException("OR in boolean expressions.")
42+
: result;
2643
}
2744

2845
/// <summary>
29-
/// Parses a comparison operator and its right-hand expression. Follows
30-
/// the lookahead contract documented on <see cref="ParserContext"/>: on
31-
/// return, <see cref="ParserContext.Token"/> is the first token not
32-
/// consumed by the comparison.
46+
/// Parses a single comparison/predicate (no AND/OR chaining): equality,
47+
/// inequality, ordered comparison, or LIKE/NOT LIKE. Caller must have
48+
/// already parsed the left side.
3349
/// </summary>
34-
public static BooleanExpression Parse(Expression left, ParserContext context) => context.Token switch
50+
private static BooleanExpression ParseSingle(Expression left, ParserContext context) => context.Token switch
3551
{
3652
Operator { Character: '=' } => new EqualityExpression(left, context),
3753
Operator { Character: '>' } => context.GetNextRequired() switch
@@ -85,71 +101,111 @@ private static LikeExpression ParseLike(Expression left, ParserContext context,
85101
internal abstract string DebugDisplay();
86102

87103
/// <summary>
88-
/// Evaluates both sides, applies SQL Server type promotion to a common
89-
/// type, and invokes the comparator. Cross-category type pairs surface as
90-
/// <see cref="NotSupportedException"/> via <see cref="SqlType.Promote"/>.
91-
/// LOB-typed operands (<c>text</c>, <c>ntext</c>, <c>image</c>) raise
92-
/// Msg 402 rather than being routed through promotion — SQL Server rejects
93-
/// them in any comparison/equality slot, and the operator name is woven
94-
/// into the message via the caller-supplied <paramref name="operatorName"/>.
104+
/// Combines two boolean predicates with a logical AND. Short-circuits on
105+
/// the left side: if <c>left.Run</c> returns <c>false</c>, the right side
106+
/// isn't evaluated. NULL-as-false propagates naturally — both sides must
107+
/// run to true for the row to pass, matching SQL Server's WHERE-clause
108+
/// "NULL excludes the row" semantics.
95109
/// </summary>
96-
private static bool ComparePromoted(Expression left, Expression right, Func<List<string>, SqlValue> getColumnValue, string operatorName, Func<SqlValue, SqlValue, bool> compare)
110+
private sealed class AndExpression(BooleanExpression left, BooleanExpression right) : BooleanExpression
97111
{
98-
var l = left.Run(getColumnValue);
99-
var r = right.Run(getColumnValue);
100-
if (l.Type.IsLob || r.Type.IsLob)
101-
throw SimulatedSqlException.IncompatibleDataTypesInOperator(l.Type, r.Type, operatorName);
102-
if (l.IsNull || r.IsNull)
103-
return false;
104-
105-
if (l.Type == r.Type)
106-
return compare(l, r);
107-
108-
var common = SqlType.Promote(l.Type, r.Type);
109-
return compare(l.CoerceTo(common), r.CoerceTo(common));
112+
public override bool Run(Func<List<string>, SqlValue> getColumnValue) =>
113+
left.Run(getColumnValue) && right.Run(getColumnValue);
114+
115+
internal override string DebugDisplay() => $"{left.DebugDisplay()} AND {right.DebugDisplay()}";
116+
}
117+
118+
/// <summary>
119+
/// Common base for the binary-comparison subclasses (=, &lt;&gt;, &lt;,
120+
/// &gt;, &lt;=, &gt;=, LIKE). Holds the parsed left/right
121+
/// <see cref="Expression"/>s and the shared promote-and-compare helper;
122+
/// non-comparison <see cref="BooleanExpression"/>s (currently
123+
/// <see cref="AndExpression"/> and <see cref="LikeExpression"/>) bring
124+
/// their own field shapes.
125+
/// </summary>
126+
private abstract class CompareExpression : BooleanExpression
127+
{
128+
protected readonly Expression left, right;
129+
130+
private protected CompareExpression(Expression left, Expression right)
131+
{
132+
this.left = left;
133+
this.right = right;
134+
}
135+
136+
private protected CompareExpression(Expression left, ParserContext context)
137+
: this(left, Expression.Parse(context.MoveNextRequiredReturnSelf()))
138+
{
139+
}
140+
141+
/// <summary>
142+
/// Evaluates both sides, applies SQL Server type promotion to a common
143+
/// type, and invokes the comparator. Cross-category type pairs surface
144+
/// as <see cref="NotSupportedException"/> via
145+
/// <see cref="SqlType.Promote"/>. LOB-typed operands (<c>text</c>,
146+
/// <c>ntext</c>, <c>image</c>) raise Msg 402 rather than being routed
147+
/// through promotion — SQL Server rejects them in any comparison /
148+
/// equality slot, and the operator name is woven into the message via
149+
/// the caller-supplied <paramref name="operatorName"/>.
150+
/// </summary>
151+
protected static bool ComparePromoted(Expression left, Expression right, Func<List<string>, SqlValue> getColumnValue, string operatorName, Func<SqlValue, SqlValue, bool> compare)
152+
{
153+
var l = left.Run(getColumnValue);
154+
var r = right.Run(getColumnValue);
155+
if (l.Type.IsLob || r.Type.IsLob)
156+
throw SimulatedSqlException.IncompatibleDataTypesInOperator(l.Type, r.Type, operatorName);
157+
if (l.IsNull || r.IsNull)
158+
return false;
159+
160+
if (l.Type == r.Type)
161+
return compare(l, r);
162+
163+
var common = SqlType.Promote(l.Type, r.Type);
164+
return compare(l.CoerceTo(common), r.CoerceTo(common));
165+
}
110166
}
111167

112-
private sealed class EqualityExpression(Expression left, ParserContext context) : BooleanExpression(left, context)
168+
private sealed class EqualityExpression(Expression left, ParserContext context) : CompareExpression(left, context)
113169
{
114170
public override bool Run(Func<List<string>, SqlValue> getColumnValue) =>
115171
ComparePromoted(left, right, getColumnValue, "equal to", static (l, r) => l.Equals(r));
116172

117173
internal override string DebugDisplay() => $"{left.DebugDisplay()} = {right.DebugDisplay()}";
118174
}
119175

120-
private sealed class InequalityExpression(Expression left, ParserContext context) : BooleanExpression(left, context)
176+
private sealed class InequalityExpression(Expression left, ParserContext context) : CompareExpression(left, context)
121177
{
122178
public override bool Run(Func<List<string>, SqlValue> getColumnValue) =>
123179
ComparePromoted(left, right, getColumnValue, "not equal to", static (l, r) => !l.Equals(r));
124180

125181
internal override string DebugDisplay() => $"{left.DebugDisplay()} <> {right.DebugDisplay()}";
126182
}
127183

128-
private sealed class GreaterThanExpression(Expression left, Expression right) : BooleanExpression(left, right)
184+
private sealed class GreaterThanExpression(Expression left, Expression right) : CompareExpression(left, right)
129185
{
130186
public override bool Run(Func<List<string>, SqlValue> getColumnValue) =>
131187
ComparePromoted(left, right, getColumnValue, "greater than", static (l, r) => l.CompareTo(r) > 0);
132188

133189
internal override string DebugDisplay() => $"{left.DebugDisplay()} > {right.DebugDisplay()}";
134190
}
135191

136-
private sealed class GreaterThanOrEqualExpression(Expression left, ParserContext context) : BooleanExpression(left, context)
192+
private sealed class GreaterThanOrEqualExpression(Expression left, ParserContext context) : CompareExpression(left, context)
137193
{
138194
public override bool Run(Func<List<string>, SqlValue> getColumnValue) =>
139195
ComparePromoted(left, right, getColumnValue, "greater than or equal to", static (l, r) => l.CompareTo(r) >= 0);
140196

141197
internal override string DebugDisplay() => $"{left.DebugDisplay()} >= {right.DebugDisplay()}";
142198
}
143199

144-
private sealed class LessThanExpression(Expression left, Expression right) : BooleanExpression(left, right)
200+
private sealed class LessThanExpression(Expression left, Expression right) : CompareExpression(left, right)
145201
{
146202
public override bool Run(Func<List<string>, SqlValue> getColumnValue) =>
147203
ComparePromoted(left, right, getColumnValue, "less than", static (l, r) => l.CompareTo(r) < 0);
148204

149205
internal override string DebugDisplay() => $"{left.DebugDisplay()} < {right.DebugDisplay()}";
150206
}
151207

152-
private sealed class LessThanOrEqualExpression(Expression left, ParserContext context) : BooleanExpression(left, context)
208+
private sealed class LessThanOrEqualExpression(Expression left, ParserContext context) : CompareExpression(left, context)
153209
{
154210
public override bool Run(Func<List<string>, SqlValue> getColumnValue) =>
155211
ComparePromoted(left, right, getColumnValue, "less than or equal to", static (l, r) => l.CompareTo(r) <= 0);
@@ -167,7 +223,7 @@ public override bool Run(Func<List<string>, SqlValue> getColumnValue) =>
167223
/// ranges (<c>[c-a]</c>) and unterminated <c>[</c> all produce never-match
168224
/// translations to mirror SQL Server's silent failure.
169225
/// </summary>
170-
private sealed class LikeExpression(Expression left, Expression right, Expression? escape, bool negated) : BooleanExpression(left, right)
226+
private sealed class LikeExpression(Expression left, Expression right, Expression? escape, bool negated) : CompareExpression(left, right)
171227
{
172228
private readonly Expression? escape = escape;
173229
private readonly bool negated = negated;

SqlServerSimulator/Parser/Selection.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ private static void ConsumeWhereAndOrderBy(ParserContext context, List<BooleanEx
204204
{
205205
while (context.Token is ReservedKeyword { Keyword: Keyword.Where })
206206
{
207-
excluders.Add(BooleanExpression.Parse(Expression.Parse(context.MoveNextRequiredReturnSelf()), context));
207+
excluders.Add(BooleanExpression.Parse(context.MoveNextRequiredReturnSelf()));
208208
}
209209

210210
if (context.Token is ReservedKeyword { Keyword: Keyword.Order })

SqlServerSimulator/Simulation.Merge.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ private static SimulatedStatementOutcome ParseMerge(ParserContext context)
7979
throw SimulatedSqlException.SyntaxErrorNear(context);
8080

8181
context.MoveNextRequired();
82-
var onPredicate = BooleanExpression.Parse(Expression.Parse(context), context);
82+
var onPredicate = BooleanExpression.Parse(context);
8383

8484
// Compute source schema by static type-of'ing the first tuple's
8585
// expressions. Source tuples can't reference any columns yet (they're

0 commit comments

Comments
 (0)