Skip to content

Commit 0e9adec

Browse files
committed
Compound assignment (SET @v op= expr and UPDATE t SET col op= expr) via parse-time desugar: 8 arith subclasses gained an internal X(Expression, Expression) ctor alongside the existing (Expression, ParserContext) parsing form, TwoSidedExpression exposes FromCompoundOp(char, left, right) static factory dispatching across + - * / % & | ^. New TryConsumeAssignmentOperator helper shared between Simulation.Set.cs and Simulation.Update.cs detects plain = or compound <arith>= with an adjacency check on the two operator tokens (probe-confirmed: SET @v + = 5 with whitespace raises Msg 102). UPDATE SET-list rewritten flatter to support qualified compound LHS (t.col += rhs) and mixed plain/compound in multi-column SET. NULL propagation, string += concat, decimal/money widening, divide-by-zero (Msg 8134 on decimal/float/money, raw DivideByZeroException on integer) all inherited from the existing arithmetic dispatch — no new runtime code. 22 new CompoundAssignmentTests covering variable + UPDATE × all 8 ops + edge cases. CLAUDE.md "Not modeled" entry removed; docs/claude/control-flow.md rewritten from "isn't modeled" stub to feature description.
1 parent 57ad9c7 commit 0e9adec

14 files changed

Lines changed: 358 additions & 38 deletions

File tree

CLAUDE.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,6 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
167167
- MERGE source subqueries; MERGE target column refs in `ON`; `WHEN MATCHED` UPDATE/DELETE branches; `$action`.
168168
- `PRIMARY KEY` / `UNIQUE` on a computed column (`NotSupportedException`).
169169
- Heap allocation tracking (flat page list, no IAM/PFS).
170-
- Compound assignment (`SET @v += expr` / `-=` / `*=` etc.) — rewrite as `SET @v = @v + expr`. The arithmetic-operator runtime is locked behind `protected` instance methods on `TwoSidedExpression`; exposing them as static helpers is the prerequisite refactor.
171170
- **Table-variable named constraints / foreign keys**`DECLARE @t TABLE (...)` shares its column-list parser with CREATE TABLE (see `docs/claude/table-variables.md`); column features (IDENTITY / UNIQUE / inline + table-level CHECK / computed columns / rowversion) all ship, alongside per-statement-atomic mutations and `OUTPUT … INTO <target>` for both `@t` and regular tables. Named constraints (`CONSTRAINT pk1 PRIMARY KEY`) and `FOREIGN KEY` raise Msg 102 (matches probe — real SQL Server's grammar disallows both shapes inside `DECLARE @t TABLE`). Multi-variable DECLARE with a table variable (`DECLARE @t1 TABLE (...), @t2 TABLE (...)`) and mixed scalar+table DECLARE also raise Msg 102/156. `SET IDENTITY_INSERT @t ON` likewise raises Msg 102 (probe-confirmed: there's no way to force a specific value into a table-variable identity column).
172171
- **Global temp tables (`##foo`)**`NotSupportedException` at parse. Local `#foo` works; the lifecycle for global temps (drops when creator session closes, visible across sessions) is the deferred scope.
173172
- **`ALTER TABLE #foo`**, **`OBJECT_ID('tempdb..#foo')`** — none modeled (none of those exist for regular tables either yet). The common `IF OBJECT_ID(...) IS NOT NULL DROP TABLE` cleanup pattern works via `DROP TABLE IF EXISTS #foo` instead.
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
using static SqlServerSimulator.TestHelpers;
4+
5+
namespace SqlServerSimulator;
6+
7+
/// <summary>
8+
/// Behavioral tests for compound assignment (<c>+=</c> / <c>-=</c> / <c>*=</c>
9+
/// / <c>/=</c> / <c>%=</c> / <c>&amp;=</c> / <c>|=</c> / <c>^=</c>) at both
10+
/// surfaces it appears in T-SQL: <c>SET @v op= expr</c> for variables and
11+
/// <c>UPDATE t SET col op= expr</c> for table columns. Compound forms are
12+
/// implemented as a parse-time desugar — <c>@v += rhs</c> is rewritten as
13+
/// <c>FromCompoundOp('+', VariableReference(@v), rhs)</c> and the existing
14+
/// arithmetic / string-concat dispatch handles the runtime semantics
15+
/// (NULL propagation, string concat, decimal widening, divide-by-zero).
16+
/// All assertions probe-confirmed against SQL Server 2025.
17+
/// </summary>
18+
[TestClass]
19+
public sealed class CompoundAssignmentTests
20+
{
21+
[TestMethod]
22+
public void Variable_PlusEquals_Int()
23+
=> AreEqual(15, ExecuteScalar<int>("declare @v int = 10; set @v += 5; select @v"));
24+
25+
[TestMethod]
26+
public void Variable_MinusEquals_Int()
27+
=> AreEqual(7, ExecuteScalar<int>("declare @v int = 10; set @v -= 3; select @v"));
28+
29+
[TestMethod]
30+
public void Variable_StarEquals_Int()
31+
=> AreEqual(20, ExecuteScalar<int>("declare @v int = 10; set @v *= 2; select @v"));
32+
33+
[TestMethod]
34+
public void Variable_SlashEquals_IntTruncates()
35+
=> AreEqual(3, ExecuteScalar<int>("declare @v int = 10; set @v /= 3; select @v"));
36+
37+
[TestMethod]
38+
public void Variable_PercentEquals_Int()
39+
=> AreEqual(1, ExecuteScalar<int>("declare @v int = 10; set @v %= 3; select @v"));
40+
41+
[TestMethod]
42+
public void Variable_AndEquals_Int()
43+
=> AreEqual(2, ExecuteScalar<int>("declare @v int = 10; set @v &= 6; select @v"));
44+
45+
[TestMethod]
46+
public void Variable_OrEquals_Int()
47+
=> AreEqual(15, ExecuteScalar<int>("declare @v int = 10; set @v |= 5; select @v"));
48+
49+
[TestMethod]
50+
public void Variable_XorEquals_Int()
51+
=> AreEqual(12, ExecuteScalar<int>("declare @v int = 10; set @v ^= 6; select @v"));
52+
53+
/// <summary>
54+
/// NULL propagates through compound arithmetic — an uninitialized
55+
/// variable is NULL, and NULL <c>+</c> int → NULL.
56+
/// </summary>
57+
[TestMethod]
58+
public void Variable_NullPlusEquals_StaysNull()
59+
=> AreEqual(DBNull.Value, ExecuteScalar("declare @v int; set @v += 5; select @v"));
60+
61+
[TestMethod]
62+
public void Variable_StringPlusEquals_Concatenates()
63+
=> AreEqual("hi there", ExecuteScalar("declare @s varchar(50) = 'hi'; set @s += ' there'; select @s"));
64+
65+
[TestMethod]
66+
public void Variable_StringPlusEquals_NullRhs_Propagates()
67+
=> AreEqual(DBNull.Value, ExecuteScalar("declare @s varchar(50) = 'hi'; set @s += cast(null as varchar(5)); select @s"));
68+
69+
/// <summary>
70+
/// Decimal participation widens the same way it does for plain <c>*</c>:
71+
/// <c>decimal(10,2) * int</c> stays decimal with scale preserved.
72+
/// </summary>
73+
[TestMethod]
74+
public void Variable_DecimalStarEquals_PreservesScale()
75+
=> AreEqual(7.00m, ExecuteScalar<decimal>("declare @v decimal(10,2) = 3.50; set @v *= 2; select @v"));
76+
77+
/// <summary>
78+
/// Decimal compound divide-by-zero raises Msg 8134, matching the plain
79+
/// <c>cast(10 as decimal(10,2)) / 0</c> path. Integer compound
80+
/// divide-by-zero surfaces a raw <see cref="DivideByZeroException"/>
81+
/// instead — pre-existing simulator divergence (see CLAUDE.md and
82+
/// IfBlockTests's <c>Integer_DivideByZero_*</c>).
83+
/// </summary>
84+
[TestMethod]
85+
public void Variable_Decimal_DivideByZero_RaisesMsg8134()
86+
=> AssertSqlError("declare @v decimal(10,2) = 10; set @v /= 0; select @v", 8134,
87+
"Divide by zero error encountered.");
88+
89+
/// <summary>
90+
/// Probe-confirmed: SQL Server tokenizes <c>+ =</c> with a space as two
91+
/// separate operators and raises Msg 102 near <c>'+'</c>. The simulator's
92+
/// adjacency check enforces no whitespace between the arith char and
93+
/// trailing <c>=</c>; the "near" token text may differ slightly from real
94+
/// SQL Server (pre-existing simulator pattern, see CLAUDE.md).
95+
/// </summary>
96+
[TestMethod]
97+
public void Variable_PlusSpaceEquals_RaisesMsg102()
98+
{
99+
var ex = Throws<DbException>(() => ExecuteScalar("declare @v int = 10; set @v + = 5; select @v"));
100+
AreEqual("102", ex.Data["HelpLink.EvtID"]);
101+
}
102+
103+
[TestMethod]
104+
public void Variable_PlusEquals_ChainedAcrossStatements()
105+
{
106+
var simulation = new Simulation();
107+
AreEqual(6, simulation.ExecuteScalar<int>("declare @v int = 0; set @v += 1; set @v += 2; set @v += 3; select @v"));
108+
}
109+
110+
[TestMethod]
111+
public void Update_PlusEquals_AllRows()
112+
{
113+
var simulation = new Simulation();
114+
_ = simulation.ExecuteNonQuery("""
115+
create table t (id int, v int);
116+
insert t values (1, 10), (2, 20)
117+
""");
118+
AreEqual(2, simulation.ExecuteNonQuery("update t set v += 5"));
119+
using var reader = simulation.CreateCommand("select v from t order by id").ExecuteReader();
120+
IsTrue(reader.Read()); AreEqual(15, reader.GetInt32(0));
121+
IsTrue(reader.Read()); AreEqual(25, reader.GetInt32(0));
122+
IsFalse(reader.Read());
123+
}
124+
125+
[TestMethod]
126+
public void Update_PlusEquals_WhereClause_OnlySelectedRows()
127+
{
128+
var simulation = new Simulation();
129+
_ = simulation.ExecuteNonQuery("""
130+
create table t (id int, v int);
131+
insert t values (1, 10), (2, 20), (3, 30)
132+
""");
133+
AreEqual(1, simulation.ExecuteNonQuery("update t set v += 100 where id = 2"));
134+
using var reader = simulation.CreateCommand("select v from t order by id").ExecuteReader();
135+
IsTrue(reader.Read()); AreEqual(10, reader.GetInt32(0));
136+
IsTrue(reader.Read()); AreEqual(120, reader.GetInt32(0));
137+
IsTrue(reader.Read()); AreEqual(30, reader.GetInt32(0));
138+
}
139+
140+
/// <summary>
141+
/// Multi-column SET with mixed plain / compound. The pre-update snapshot
142+
/// is shared across all assignments in a single SET (matches existing
143+
/// multi-column UPDATE semantics), so <c>a *= 2, b = a</c> sees the
144+
/// original <c>a</c>, not the doubled one.
145+
/// </summary>
146+
[TestMethod]
147+
public void Update_MultiColumnSet_MixedPlainAndCompound()
148+
{
149+
var simulation = new Simulation();
150+
_ = simulation.ExecuteNonQuery("""
151+
create table t (id int, a int, b int);
152+
insert t values (1, 3, 0)
153+
""");
154+
AreEqual(1, simulation.ExecuteNonQuery("update t set a *= 2, b = a where id = 1"));
155+
using var reader = simulation.CreateCommand("select a, b from t").ExecuteReader();
156+
IsTrue(reader.Read());
157+
AreEqual(6, reader.GetInt32(0));
158+
AreEqual(3, reader.GetInt32(1));
159+
}
160+
161+
[TestMethod]
162+
public void Update_QualifiedColumn_PlusEquals()
163+
{
164+
var simulation = new Simulation();
165+
_ = simulation.ExecuteNonQuery("""
166+
create table t (id int, v int);
167+
insert t values (1, 10)
168+
""");
169+
AreEqual(1, simulation.ExecuteNonQuery("update t set t.v += 5 where id = 1"));
170+
AreEqual(15, simulation.ExecuteScalar<int>("select v from t"));
171+
}
172+
173+
[TestMethod]
174+
public void Update_StringPlusEquals_Concatenates()
175+
{
176+
var simulation = new Simulation();
177+
_ = simulation.ExecuteNonQuery("""
178+
create table t (id int, s varchar(20));
179+
insert t values (1, 'hi'), (2, 'there')
180+
""");
181+
AreEqual(1, simulation.ExecuteNonQuery("update t set s += '!' where id = 1"));
182+
using var reader = simulation.CreateCommand("select s from t order by id").ExecuteReader();
183+
IsTrue(reader.Read()); AreEqual("hi!", reader.GetString(0));
184+
IsTrue(reader.Read()); AreEqual("there", reader.GetString(0));
185+
}
186+
187+
/// <summary>
188+
/// NULL column value + int → NULL: the compound operator participates in
189+
/// three-valued logic the same way plain <c>+</c> does.
190+
/// </summary>
191+
[TestMethod]
192+
public void Update_NullColumnPlusEquals_StaysNull()
193+
{
194+
var simulation = new Simulation();
195+
_ = simulation.ExecuteNonQuery("""
196+
create table t (id int, v int null);
197+
insert t values (1, null), (2, 5)
198+
""");
199+
AreEqual(2, simulation.ExecuteNonQuery("update t set v += 10"));
200+
using var reader = simulation.CreateCommand("select v from t order by id").ExecuteReader();
201+
IsTrue(reader.Read()); IsTrue(reader.IsDBNull(0));
202+
IsTrue(reader.Read()); AreEqual(15, reader.GetInt32(0));
203+
}
204+
205+
[TestMethod]
206+
public void Update_FromJoinSyntax_CompoundWithAlias()
207+
{
208+
var simulation = new Simulation();
209+
_ = simulation.ExecuteNonQuery("""
210+
create table t (id int, v int);
211+
create table u (id int, bonus int);
212+
insert t values (1, 10), (2, 20);
213+
insert u values (1, 5), (2, 50)
214+
""");
215+
AreEqual(2, simulation.ExecuteNonQuery("update t set v += u.bonus from t join u on t.id = u.id"));
216+
using var reader = simulation.CreateCommand("select v from t order by id").ExecuteReader();
217+
IsTrue(reader.Read()); AreEqual(15, reader.GetInt32(0));
218+
IsTrue(reader.Read()); AreEqual(70, reader.GetInt32(0));
219+
}
220+
}

SqlServerSimulator/Parser/Expressions/Add.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
namespace SqlServerSimulator.Parser.Expressions;
44

5-
internal sealed class Add(Expression left, ParserContext context) : TwoSidedExpression(left, context)
5+
internal sealed class Add : TwoSidedExpression
66
{
7+
public Add(Expression left, ParserContext context) : base(left, context) { }
8+
internal Add(Expression left, Expression right) : base(left, right) { }
9+
710
public override byte Precedence => 3;
811

912
protected override SqlValue Run(SqlValue left, SqlValue right) =>

SqlServerSimulator/Parser/Expressions/BitwiseAnd.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
namespace SqlServerSimulator.Parser.Expressions;
22

3-
internal sealed class BitwiseAnd(Expression left, ParserContext context) : TwoSidedExpression(left, context)
3+
internal sealed class BitwiseAnd : TwoSidedExpression
44
{
5+
public BitwiseAnd(Expression left, ParserContext context) : base(left, context) { }
6+
internal BitwiseAnd(Expression left, Expression right) : base(left, right) { }
7+
58
public override byte Precedence => 3;
69

710
protected override Storage.SqlValue Run(Storage.SqlValue left, Storage.SqlValue right) => IntegerArithmetic(left, right, '&', static (a, b) => a & b);

SqlServerSimulator/Parser/Expressions/BitwiseExclusiveOr.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
namespace SqlServerSimulator.Parser.Expressions;
22

3-
internal sealed class BitwiseExclusiveOr(Expression left, ParserContext context) : TwoSidedExpression(left, context)
3+
internal sealed class BitwiseExclusiveOr : TwoSidedExpression
44
{
5+
public BitwiseExclusiveOr(Expression left, ParserContext context) : base(left, context) { }
6+
internal BitwiseExclusiveOr(Expression left, Expression right) : base(left, right) { }
7+
58
public override byte Precedence => 3;
69

710
protected override Storage.SqlValue Run(Storage.SqlValue left, Storage.SqlValue right) => IntegerArithmetic(left, right, '^', static (a, b) => a ^ b);

SqlServerSimulator/Parser/Expressions/BitwiseOr.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
namespace SqlServerSimulator.Parser.Expressions;
22

3-
internal sealed class BitwiseOr(Expression left, ParserContext context) : TwoSidedExpression(left, context)
3+
internal sealed class BitwiseOr : TwoSidedExpression
44
{
5+
public BitwiseOr(Expression left, ParserContext context) : base(left, context) { }
6+
internal BitwiseOr(Expression left, Expression right) : base(left, right) { }
7+
58
public override byte Precedence => 3;
69

710
protected override Storage.SqlValue Run(Storage.SqlValue left, Storage.SqlValue right) => IntegerArithmetic(left, right, '|', static (a, b) => a | b);

SqlServerSimulator/Parser/Expressions/Divide.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
namespace SqlServerSimulator.Parser.Expressions;
22

3-
internal sealed class Divide(Expression left, ParserContext context) : TwoSidedExpression(left, context)
3+
internal sealed class Divide : TwoSidedExpression
44
{
5+
public Divide(Expression left, ParserContext context) : base(left, context) { }
6+
internal Divide(Expression left, Expression right) : base(left, right) { }
7+
58
public override byte Precedence => 2;
69

710
protected override Storage.SqlValue Run(Storage.SqlValue left, Storage.SqlValue right) => IntegerArithmetic(left, right, '/', static (a, b) => a / b);

SqlServerSimulator/Parser/Expressions/Modulus.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
namespace SqlServerSimulator.Parser.Expressions;
22

3-
internal sealed class Modulus(Expression left, ParserContext context) : TwoSidedExpression(left, context)
3+
internal sealed class Modulus : TwoSidedExpression
44
{
5+
public Modulus(Expression left, ParserContext context) : base(left, context) { }
6+
internal Modulus(Expression left, Expression right) : base(left, right) { }
7+
58
public override byte Precedence => 2;
69

710
protected override Storage.SqlValue Run(Storage.SqlValue left, Storage.SqlValue right) => IntegerArithmetic(left, right, '%', static (a, b) => a % b);

SqlServerSimulator/Parser/Expressions/Multiply.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
namespace SqlServerSimulator.Parser.Expressions;
22

3-
internal sealed class Multiply(Expression left, ParserContext context) : TwoSidedExpression(left, context)
3+
internal sealed class Multiply : TwoSidedExpression
44
{
5+
public Multiply(Expression left, ParserContext context) : base(left, context) { }
6+
internal Multiply(Expression left, Expression right) : base(left, right) { }
7+
58
public override byte Precedence => 2;
69

710
protected override Storage.SqlValue Run(Storage.SqlValue left, Storage.SqlValue right) => IntegerArithmetic(left, right, '*', static (a, b) => a * b);

SqlServerSimulator/Parser/Expressions/Subtract.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
namespace SqlServerSimulator.Parser.Expressions;
22

3-
internal sealed class Subtract(Expression left, ParserContext context) : TwoSidedExpression(left, context)
3+
internal sealed class Subtract : TwoSidedExpression
44
{
5+
public Subtract(Expression left, ParserContext context) : base(left, context) { }
6+
internal Subtract(Expression left, Expression right) : base(left, right) { }
7+
58
public override byte Precedence => 3;
69

710
protected override Storage.SqlValue Run(Storage.SqlValue left, Storage.SqlValue right) => AdditiveArithmetic(left, right, '-', "subtract", static (a, b) => a - b);

0 commit comments

Comments
 (0)