Skip to content

Commit 31f20c6

Browse files
committed
BETWEEN / NOT BETWEEN predicate: new BetweenExpression : BooleanExpression desugars to value >= lower AND value <= upper with the result negated for the NOT form, value evaluated once per row regardless of the surface desugar. Hooks into BooleanExpression.ParseComparison's keyword switch (Between case + NOT Between under the existing NOT case); ParseBetween reads lower via Expression.Parse, consumes the required AND keyword (Msg 102 if missing), reads upper — Expression.Parse naturally stops at the trailing AND because AND is a higher-precedence boolean combinator, so a chained predicate falls back to the surrounding ParseAnd loop. Probe-confirmed semantics: inclusive on both ends (1 between 1 and 10 and 10 between 1 and 10 both true), reversed bounds (low > high) collapse to definite false, NULL in any operand position propagates per three-valued AND/NOT (even definitely-out-of-range with a NULL bound stays false because UNKNOWN AND FALSE = FALSE). 27 new BetweenTests via DataRow cover at/inside/outside bounds, NOT BETWEEN, reversed bounds, every NULL operand position, string / decimal / date type promotion, BETWEEN-vs-AND and BETWEEN-vs-OR precedence, arithmetic in bounds, multi-row WHERE with NULL filtering, CHECK constraint usage, and missing-AND syntax error. Pre-existing gap surfaced by the probe — Msg 4145 fall-through was the previous behavior because BETWEEN had no dispatch site outside of WindowExpression's frame-spec parser. docs/claude/query.md Boolean section extended with the desugar / precedence / once-per-row note.
1 parent ced7d54 commit 31f20c6

3 files changed

Lines changed: 222 additions & 1 deletion

File tree

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Tests for <c>value [NOT] BETWEEN lower AND upper</c>. Semantically
8+
/// equivalent to <c>value &gt;= lower AND value &lt;= upper</c> (inclusive
9+
/// on both ends); reversed bounds produce a definite false; any NULL operand
10+
/// propagates per three-valued logic. Probed against SQL Server 2025
11+
/// (2026-05-13).
12+
/// </summary>
13+
[TestClass]
14+
public sealed class BetweenTests
15+
{
16+
[TestMethod]
17+
[DataRow("5 between 1 and 10", 1)]
18+
[DataRow("1 between 1 and 10", 1)]
19+
[DataRow("10 between 1 and 10", 1)]
20+
[DataRow("0 between 1 and 10", 0)]
21+
[DataRow("11 between 1 and 10", 0)]
22+
[DataRow("5 not between 1 and 10", 0)]
23+
[DataRow("11 not between 1 and 10", 1)]
24+
[DataRow("5 between 10 and 1", 0)]
25+
[DataRow("5 not between 10 and 1", 1)]
26+
public void Basic(string predicate, int expectedCount) =>
27+
AreEqual(expectedCount,
28+
new Simulation().ExecuteReader($"select 1 where {predicate}").EnumerateRecords().Count());
29+
30+
[TestMethod]
31+
[DataRow("cast(null as int) between 1 and 10")]
32+
[DataRow("cast(null as int) not between 1 and 10")]
33+
[DataRow("5 between cast(null as int) and 10")]
34+
[DataRow("5 between 1 and cast(null as int)")]
35+
[DataRow("5 between cast(null as int) and cast(null as int)")]
36+
[DataRow("5 not between cast(null as int) and 10")]
37+
[DataRow("5 not between 1 and cast(null as int)")]
38+
public void NullOperand_ExcludesFromWhere(string predicate) =>
39+
AreEqual(0,
40+
new Simulation().ExecuteReader($"select 1 where {predicate}").EnumerateRecords().Count());
41+
42+
[TestMethod]
43+
public void DefinitelyOutOfRange_WithNullBound_StillFalse()
44+
{
45+
// Probe-confirmed: even though the NULL-side comparison is UNKNOWN,
46+
// three-valued AND with the definite-false side collapses to FALSE,
47+
// so the row is excluded.
48+
AreEqual(0,
49+
new Simulation().ExecuteReader("select 1 where 100 between cast(null as int) and 10").EnumerateRecords().Count());
50+
AreEqual(0,
51+
new Simulation().ExecuteReader("select 1 where -5 between 1 and cast(null as int)").EnumerateRecords().Count());
52+
}
53+
54+
[TestMethod]
55+
public void StringBetween()
56+
{
57+
// 'mango' falls between 'apple' and 'pear' in ANSI string order.
58+
AreEqual(1,
59+
new Simulation().ExecuteReader("select 1 where 'mango' between 'apple' and 'pear'").EnumerateRecords().Count());
60+
AreEqual(0,
61+
new Simulation().ExecuteReader("select 1 where 'mango' not between 'apple' and 'pear'").EnumerateRecords().Count());
62+
}
63+
64+
[TestMethod]
65+
public void DecimalBetween_IntBounds_TypePromotion()
66+
{
67+
AreEqual(1,
68+
new Simulation().ExecuteReader("select 1 where cast(2.5 as decimal(5,2)) between 1 and 5").EnumerateRecords().Count());
69+
}
70+
71+
[TestMethod]
72+
public void DateBetween_StringBounds_CoercionWorks()
73+
{
74+
AreEqual(1,
75+
new Simulation().ExecuteReader(
76+
"select 1 where cast('2026-05-15' as date) between '2026-01-01' and '2026-12-31'").EnumerateRecords().Count());
77+
}
78+
79+
[TestMethod]
80+
public void Precedence_BetweenBindsTighterThanAnd()
81+
{
82+
// `5 between 1 and 10 and 1 = 1` parses as
83+
// `(5 between 1 and 10) AND (1 = 1)` — both true → row passes.
84+
AreEqual(1,
85+
new Simulation().ExecuteReader("select 1 where 5 between 1 and 10 and 1 = 1").EnumerateRecords().Count());
86+
}
87+
88+
[TestMethod]
89+
public void Precedence_BetweenInsideOr()
90+
{
91+
AreEqual(1,
92+
new Simulation().ExecuteReader("select 1 where 1 = 0 or 5 between 1 and 10").EnumerateRecords().Count());
93+
}
94+
95+
[TestMethod]
96+
public void ArithmeticInBounds()
97+
{
98+
// Bounds accept arithmetic expressions — Expression.Parse stops at
99+
// the AND keyword (boolean combinator) without consuming it.
100+
AreEqual(1,
101+
new Simulation().ExecuteReader("select 1 where 5 between 1 - 1 and 10 + 1").EnumerateRecords().Count());
102+
}
103+
104+
[TestMethod]
105+
public void MultiRowWhereBetween_FiltersNulls()
106+
{
107+
using var conn = new Simulation().CreateOpenConnection();
108+
_ = conn.CreateCommand("""
109+
create table bv (id int, v int);
110+
insert bv values (1, 5), (2, 10), (3, 15), (4, null)
111+
""").ExecuteNonQuery();
112+
using var reader = conn.CreateCommand("select id from bv where v between 5 and 12 order by id").ExecuteReader();
113+
var ids = new List<int>();
114+
while (reader.Read())
115+
ids.Add(reader.GetInt32(0));
116+
CollectionAssert.AreEqual(new int[] { 1, 2 }, ids);
117+
}
118+
119+
[TestMethod]
120+
public void MultiRowWhereNotBetween_ExcludesNulls()
121+
{
122+
// Probe-confirmed: id=4 (v=NULL) excluded by three-valued NOT (UNKNOWN
123+
// → UNKNOWN → excluded), id=3 (v=15) is the only out-of-range row.
124+
using var conn = new Simulation().CreateOpenConnection();
125+
_ = conn.CreateCommand("""
126+
create table bv (id int, v int);
127+
insert bv values (1, 5), (2, 10), (3, 15), (4, null)
128+
""").ExecuteNonQuery();
129+
using var reader = conn.CreateCommand("select id from bv where v not between 5 and 12 order by id").ExecuteReader();
130+
var ids = new List<int>();
131+
while (reader.Read())
132+
ids.Add(reader.GetInt32(0));
133+
CollectionAssert.AreEqual(new int[] { 3 }, ids);
134+
}
135+
136+
[TestMethod]
137+
public void CheckConstraint_BetweenRejectsOutOfRange()
138+
{
139+
// CHECK constraint passes UNKNOWN — a definitively-false BETWEEN
140+
// raises Msg 547. Inline single-column CHECK referencing only the
141+
// owning column.
142+
using var conn = new Simulation().CreateOpenConnection();
143+
_ = conn.CreateCommand("create table bt (v int check (v between 1 and 10))").ExecuteNonQuery();
144+
_ = conn.CreateCommand("insert bt values (5)").ExecuteNonQuery();
145+
using var cmd = conn.CreateCommand("insert bt values (11)");
146+
var ex = Throws<DbException>(() => cmd.ExecuteNonQuery());
147+
AreEqual("547", ex.Data["HelpLink.EvtID"]);
148+
}
149+
150+
[TestMethod]
151+
public void MissingAndKeyword_SyntaxError()
152+
{
153+
// `value BETWEEN lower upper` (no AND) → syntax error near upper.
154+
var ex = Throws<DbException>(() => new Simulation().ExecuteScalar("select 1 where 5 between 1 10"));
155+
AreEqual("102", ex.Data["HelpLink.EvtID"]);
156+
}
157+
}

SqlServerSimulator/Parser/BooleanExpression.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,14 @@ private static BooleanExpression ParseComparison(Expression left, ParserContext
137137
return ParseIsNullSuffix(left, context);
138138
case ReservedKeyword { Keyword: Keyword.In }:
139139
return ParseInList(left, context, negated: false);
140+
case ReservedKeyword { Keyword: Keyword.Between }:
141+
return ParseBetween(left, context, negated: false);
140142
case ReservedKeyword { Keyword: Keyword.Not }:
141143
return context.GetNextRequired() switch
142144
{
143145
ReservedKeyword { Keyword: Keyword.Like } => ParseLike(left, context, negated: true),
144146
ReservedKeyword { Keyword: Keyword.In } => ParseInList(left, context, negated: true),
147+
ReservedKeyword { Keyword: Keyword.Between } => ParseBetween(left, context, negated: true),
145148
_ => throw SimulatedSqlException.SyntaxErrorNear(context),
146149
};
147150
}
@@ -341,6 +344,28 @@ private static BooleanExpression ParseInList(Expression left, ParserContext cont
341344
return new InExpression(left, [.. candidates], negated);
342345
}
343346

347+
/// <summary>
348+
/// Parses the <c>[NOT] BETWEEN lower AND upper</c> suffix after an
349+
/// expression. Entered with <see cref="ParserContext.Token"/> on the
350+
/// <c>BETWEEN</c> keyword; consumes <c>BETWEEN</c>, the lower expression,
351+
/// the required <c>AND</c>, and the upper expression. Both bounds are
352+
/// plain value expressions (not boolean predicates) — <see cref="Expression.Parse"/>
353+
/// naturally stops at the trailing <c>AND</c> keyword because <c>AND</c>
354+
/// is a boolean combinator at higher precedence, so a trailing <c>AND
355+
/// other-predicate</c> falls back to the surrounding
356+
/// <see cref="ParseAnd"/> loop.
357+
/// </summary>
358+
private static BetweenExpression ParseBetween(Expression left, ParserContext context, bool negated)
359+
{
360+
context.MoveNextRequired();
361+
var lower = Expression.Parse(context);
362+
if (context.Token is not ReservedKeyword { Keyword: Keyword.And })
363+
throw SimulatedSqlException.SyntaxErrorNear(context);
364+
context.MoveNextRequired();
365+
var upper = Expression.Parse(context);
366+
return new BetweenExpression(left, lower, upper, negated);
367+
}
368+
344369
/// <summary>
345370
/// Evaluates the predicate to SQL Server's three-valued logic:
346371
/// <c>true</c>, <c>false</c>, or <c>null</c> (UNKNOWN). NULL operands in a
@@ -513,6 +538,44 @@ private sealed class ExistsExpression(Selection inner) : BooleanExpression
513538
internal override void VisitOperandExpressions(Action<Expression> visitor) { }
514539
}
515540

541+
/// <summary>
542+
/// <c>value [NOT] BETWEEN lower AND upper</c>: equivalent to
543+
/// <c>value &gt;= lower AND value &lt;= upper</c> (with the result negated
544+
/// for the NOT form). Both bounds inclusive (probe-confirmed against
545+
/// SQL Server 2025 — <c>5 between 1 and 5</c> is true at both endpoints).
546+
/// Reversed bounds (low &gt; high) produce a definite false; NULL in any
547+
/// operand position propagates per the standard three-valued AND/NOT
548+
/// truth tables. <c>value</c> is evaluated once per row even though the
549+
/// desugaring would suggest two evaluations.
550+
/// </summary>
551+
private sealed class BetweenExpression(Expression value, Expression lower, Expression upper, bool negated) : BooleanExpression
552+
{
553+
public override bool? Run(RuntimeContext runtime)
554+
{
555+
var v = value.Run(runtime);
556+
var lo = lower.Run(runtime);
557+
var hi = upper.Run(runtime);
558+
var ge = CompareValuesPromoted(v, lo, "greater than or equal to", static (l, r) => l.CompareTo(r) >= 0);
559+
var le = CompareValuesPromoted(v, hi, "less than or equal to", static (l, r) => l.CompareTo(r) <= 0);
560+
var inRange = ge == false || le == false ? false
561+
: ge == true && le == true ? true
562+
: (bool?)null;
563+
return negated
564+
? inRange switch { true => false, false => true, _ => null }
565+
: inRange;
566+
}
567+
568+
internal override string DebugDisplay() =>
569+
$"{value.DebugDisplay()} {(negated ? "NOT BETWEEN" : "BETWEEN")} {lower.DebugDisplay()} AND {upper.DebugDisplay()}";
570+
571+
internal override void VisitOperandExpressions(Action<Expression> visitor)
572+
{
573+
visitor(value);
574+
visitor(lower);
575+
visitor(upper);
576+
}
577+
}
578+
516579
/// <summary>
517580
/// <c>expr [NOT] IN (SELECT ...)</c>: equivalent to a chain of
518581
/// promote-and-equal comparisons against each row of the single-column

docs/claude/query.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# Query semantics — set ops, CASE, pagination, aggregates, windows
22

33
## Boolean / set ops / projection / CASE
4-
- Boolean combinators (WHERE / MERGE-ON / CHECK): `AND` / `OR` / `NOT`, parens, `IS [NOT] NULL`, `[NOT] IN (literal,...)`, `[NOT] IN (SELECT ...)`, `EXISTS (SELECT ...)`, `expr <op> {ANY|SOME|ALL} (SELECT col FROM ...)` quantified comparison. Tri-valued.
4+
- Boolean combinators (WHERE / MERGE-ON / CHECK): `AND` / `OR` / `NOT`, parens, `IS [NOT] NULL`, `[NOT] IN (literal,...)`, `[NOT] IN (SELECT ...)`, `EXISTS (SELECT ...)`, `expr <op> {ANY|SOME|ALL} (SELECT col FROM ...)` quantified comparison, `value [NOT] BETWEEN lower AND upper`. Tri-valued.
5+
- `[NOT] BETWEEN` desugars to `value >= lower AND value <= upper` (inclusive on both ends, probe-confirmed). Reversed bounds (low > high) collapse to a definite false; NULL in any operand position propagates through three-valued AND. `value` is evaluated once per row by `BetweenExpression.Run` (the desugaring is per-row semantics, not duplicated evaluation). BETWEEN binds tighter than the surrounding AND/OR, so `value between a and b and other` parses as `(value between a and b) AND (other)`; bounds may be arbitrary value expressions (Expression.Parse stops at the trailing AND keyword).
56
- Quantified comparison (`Parser/BooleanExpression.cs` — `QuantifiedComparisonExpression`): all six operators (`=`, `<>`, `<`, `<=`, `>`, `>=`) plus T-SQL synonyms (`!=` → `<>`, `!<` → `>=`, `!>` → `<=`) fold at parse time into a `ComparisonOp` enum. `SOME` is a pure alias of `ANY`. Inner SELECT must project exactly one column (Msg 116, shared factory with IN-subquery). **Predicate-only**: probe-confirmed (2026-05-13) that real SQL Server raises Msg 102 when the form appears in a SELECT-list expression slot — the simulator inherits the same restriction because parsing lives in `BooleanExpression.ParseComparison`, reachable only from the boolean-atom path. Empty inner: `ALL` is vacuously true, `ANY` vacuously false (LHS NULL irrelevant — the inner is consumed first). Non-empty inner runs three-valued fold: any comparison evaluating to `true` short-circuits `ANY` to `true`; any `false` short-circuits `ALL` to `false`; otherwise `null`-tainted comparisons turn the overall result UNKNOWN, fall-through is `false` for `ANY` / `true` for `ALL`. `<op> ANY` cannot be negated directly (`NOT <op> ANY` isn't grammar — apps must flip the operator: `NOT (x > ALL y)` ≡ `x <= ANY y`).
67
- Set ops (UNION / UNION ALL / INTERSECT / EXCEPT): standard precedence (INTERSECT > UNION/EXCEPT). **NULLs are equal during set-op dedup/matching** (opposite of `=`'s tri-state). Per-branch ORDER BY in non-final branch → Msg 156. Top-level ORDER BY references first-branch column names only.
78
- `SELECT *`: bare and qualified `<source>.*`. Multi-source `*` keeps duplicate names. Unbound `<qualifier>.*` → Msg 4104.

0 commit comments

Comments
 (0)