Skip to content

Commit de30725

Browse files
committed
Added SQL-text BEGIN / COMMIT / ROLLBACK + TRANCOUNT-based nesting + @@TRANCOUNT (Bundle 3 of the transactions arc), wrapping up the three-bundle transaction feature.
1 parent 5f61533 commit de30725

9 files changed

Lines changed: 358 additions & 49 deletions

File tree

CLAUDE.md

Lines changed: 15 additions & 15 deletions
Large diffs are not rendered by default.
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Tests for SQL-text transaction control: <c>BEGIN TRANSACTION</c>,
8+
/// <c>COMMIT</c>, <c>ROLLBACK</c> (no savepoint), and <c>@@TRANCOUNT</c>.
9+
/// EF Core never emits these — it uses SqlClient's API path — so this is
10+
/// hand-written-SQL fidelity. Probe-confirmed against SQL Server 2025
11+
/// (2026-05-08): nesting via TRANCOUNT (BEGIN increments; COMMIT decrements;
12+
/// ROLLBACK without a savepoint name zeroes regardless of depth);
13+
/// COMMIT / ROLLBACK without an active tx raise Msg 3902 / 3903 verbatim.
14+
/// </summary>
15+
[TestClass]
16+
public sealed class SqlTransactionStatementTests
17+
{
18+
private static int CountRows(DbConnection conn, string table) =>
19+
(int)conn.CreateCommand($"select count(*) from {table}").ExecuteScalar()!;
20+
21+
private static DbConnection NewSeededConnection()
22+
{
23+
var conn = new Simulation().CreateOpenConnection();
24+
_ = conn.CreateCommand("create table t (id int primary key, val int)").ExecuteNonQuery();
25+
return conn;
26+
}
27+
28+
[TestMethod]
29+
public void BeginCommit_PersistsWrites()
30+
{
31+
using var conn = NewSeededConnection();
32+
_ = conn.CreateCommand("begin transaction").ExecuteNonQuery();
33+
_ = conn.CreateCommand("insert into t values (1, 10)").ExecuteNonQuery();
34+
_ = conn.CreateCommand("commit").ExecuteNonQuery();
35+
AreEqual(1, CountRows(conn, "t"));
36+
}
37+
38+
[TestMethod]
39+
public void BeginRollback_UndoesWrites()
40+
{
41+
using var conn = NewSeededConnection();
42+
_ = conn.CreateCommand("begin transaction").ExecuteNonQuery();
43+
_ = conn.CreateCommand("insert into t values (1, 10)").ExecuteNonQuery();
44+
_ = conn.CreateCommand("rollback").ExecuteNonQuery();
45+
AreEqual(0, CountRows(conn, "t"));
46+
}
47+
48+
[TestMethod]
49+
public void TranCount_TracksNestingDepth()
50+
{
51+
using var conn = NewSeededConnection();
52+
AreEqual(0, conn.CreateCommand("select @@trancount").ExecuteScalar());
53+
54+
_ = conn.CreateCommand("begin transaction").ExecuteNonQuery();
55+
AreEqual(1, conn.CreateCommand("select @@trancount").ExecuteScalar());
56+
57+
_ = conn.CreateCommand("begin tran").ExecuteNonQuery();
58+
AreEqual(2, conn.CreateCommand("select @@trancount").ExecuteScalar());
59+
60+
_ = conn.CreateCommand("commit").ExecuteNonQuery();
61+
AreEqual(1, conn.CreateCommand("select @@trancount").ExecuteScalar());
62+
63+
_ = conn.CreateCommand("commit transaction").ExecuteNonQuery();
64+
AreEqual(0, conn.CreateCommand("select @@trancount").ExecuteScalar());
65+
}
66+
67+
[TestMethod]
68+
public void NestedBeginCommit_InnerCommitDoesNotPersist()
69+
{
70+
// Probe-confirmed: only the outermost COMMIT actually commits.
71+
// Inner COMMITs just decrement TRANCOUNT — the writes survive an
72+
// outer ROLLBACK only if the OUTER level commits too. Here we
73+
// inner-COMMIT then outer-ROLLBACK; everything must rollback.
74+
using var conn = NewSeededConnection();
75+
_ = conn.CreateCommand("begin transaction").ExecuteNonQuery();
76+
_ = conn.CreateCommand("insert into t values (1, 10)").ExecuteNonQuery();
77+
_ = conn.CreateCommand("begin transaction").ExecuteNonQuery();
78+
_ = conn.CreateCommand("insert into t values (2, 20)").ExecuteNonQuery();
79+
_ = conn.CreateCommand("commit").ExecuteNonQuery();
80+
AreEqual(1, conn.CreateCommand("select @@trancount").ExecuteScalar());
81+
_ = conn.CreateCommand("rollback").ExecuteNonQuery();
82+
AreEqual(0, CountRows(conn, "t"));
83+
}
84+
85+
[TestMethod]
86+
public void RollbackWithoutSavepoint_WipesAllNestingLevels()
87+
{
88+
using var conn = NewSeededConnection();
89+
_ = conn.CreateCommand("begin transaction").ExecuteNonQuery();
90+
_ = conn.CreateCommand("insert into t values (1, 10)").ExecuteNonQuery();
91+
_ = conn.CreateCommand("begin transaction").ExecuteNonQuery();
92+
_ = conn.CreateCommand("insert into t values (2, 20)").ExecuteNonQuery();
93+
_ = conn.CreateCommand("rollback").ExecuteNonQuery();
94+
95+
AreEqual(0, CountRows(conn, "t"));
96+
AreEqual(0, conn.CreateCommand("select @@trancount").ExecuteScalar());
97+
}
98+
99+
[TestMethod]
100+
public void BeginTransaction_AcceptsName()
101+
{
102+
// BEGIN TRANSACTION my_tx — name is cosmetic; matches SQL Server.
103+
using var conn = NewSeededConnection();
104+
_ = conn.CreateCommand("begin transaction my_tx").ExecuteNonQuery();
105+
AreEqual(1, conn.CreateCommand("select @@trancount").ExecuteScalar());
106+
_ = conn.CreateCommand("commit transaction my_tx").ExecuteNonQuery();
107+
AreEqual(0, conn.CreateCommand("select @@trancount").ExecuteScalar());
108+
}
109+
110+
[TestMethod]
111+
public void CommitWork_RollbackWork_AcceptedAsAnsiVariants()
112+
{
113+
using var conn = NewSeededConnection();
114+
_ = conn.CreateCommand("begin transaction").ExecuteNonQuery();
115+
_ = conn.CreateCommand("commit work").ExecuteNonQuery();
116+
AreEqual(0, conn.CreateCommand("select @@trancount").ExecuteScalar());
117+
118+
_ = conn.CreateCommand("begin transaction").ExecuteNonQuery();
119+
_ = conn.CreateCommand("rollback work").ExecuteNonQuery();
120+
AreEqual(0, conn.CreateCommand("select @@trancount").ExecuteScalar());
121+
}
122+
123+
[TestMethod]
124+
public void Commit_WithoutActiveTx_RaisesMsg3902()
125+
{
126+
using var conn = NewSeededConnection();
127+
var ex = Throws<DbException>(() => _ = conn.CreateCommand("commit").ExecuteNonQuery());
128+
AreEqual("3902", ex.Data["HelpLink.EvtID"]);
129+
}
130+
131+
[TestMethod]
132+
public void Rollback_WithoutActiveTx_RaisesMsg3903()
133+
{
134+
using var conn = NewSeededConnection();
135+
var ex = Throws<DbException>(() => _ = conn.CreateCommand("rollback").ExecuteNonQuery());
136+
AreEqual("3903", ex.Data["HelpLink.EvtID"]);
137+
}
138+
139+
[TestMethod]
140+
public void SqlClientApi_AndSqlText_ShareTheSameTransaction()
141+
{
142+
// SqlClient API BeginTransaction → TRANCOUNT 1.
143+
// SQL BEGIN inside → TRANCOUNT 2.
144+
// SQL COMMIT → TRANCOUNT 1.
145+
// API tx.Commit() → TRANCOUNT 0, persists writes.
146+
using var conn = NewSeededConnection();
147+
using var tx = conn.BeginTransaction();
148+
AreEqual(1, conn.CreateCommand("select @@trancount").ExecuteScalar());
149+
150+
using var cmd = conn.CreateCommand();
151+
cmd.Transaction = tx;
152+
cmd.CommandText = "begin transaction";
153+
_ = cmd.ExecuteNonQuery();
154+
AreEqual(2, conn.CreateCommand("select @@trancount").ExecuteScalar());
155+
156+
cmd.CommandText = "insert into t values (1, 10)";
157+
_ = cmd.ExecuteNonQuery();
158+
159+
cmd.CommandText = "commit";
160+
_ = cmd.ExecuteNonQuery();
161+
AreEqual(1, conn.CreateCommand("select @@trancount").ExecuteScalar());
162+
163+
tx.Commit();
164+
AreEqual(0, conn.CreateCommand("select @@trancount").ExecuteScalar());
165+
AreEqual(1, CountRows(conn, "t"));
166+
}
167+
}

SqlServerSimulator/Parser/AtAtKeyword.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,6 @@ enum AtAtKeyword
1717
ServiceName,
1818
SpId,
1919
TextSize,
20+
TranCount,
2021
Version
2122
}

SqlServerSimulator/Parser/Expression.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,12 @@ public static Expression Parse(ParserContext context)
6262
Numeric number => new Value(number.Value),
6363
Literal literal => new Value(literal.Value),
6464
AtPrefixedString atPrefixed => new Value(atPrefixed, context),
65-
DoubleAtPrefixedString doubleAtPrefixedString => doubleAtPrefixedString.Parse() == AtAtKeyword.Identity
66-
? new LastIdentityExpression(context.Simulation)
67-
: new Value(doubleAtPrefixedString),
65+
DoubleAtPrefixedString doubleAtPrefixedString => doubleAtPrefixedString.Parse() switch
66+
{
67+
AtAtKeyword.Identity => new LastIdentityExpression(context.Simulation),
68+
AtAtKeyword.TranCount => new TranCountExpression(context),
69+
_ => new Value(doubleAtPrefixedString),
70+
},
6871
ReservedKeyword { Keyword: Keyword.Null } => new Value(),
6972
ReservedKeyword { Keyword: Keyword.Case } => CaseExpression.ParseCase(context),
7073
// LEFT, RIGHT, CONVERT, and TRY_CONVERT are reserved keywords
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using SqlServerSimulator.Storage;
2+
3+
namespace SqlServerSimulator.Parser.Expressions;
4+
5+
/// <summary>
6+
/// Backs <c>@@TRANCOUNT</c>: returns the connection's current transaction
7+
/// nesting depth as <see cref="SqlType.Int32"/>. Zero when no transaction
8+
/// is active; one after a single <c>BEGIN TRANSACTION</c> (or SqlClient
9+
/// <c>BeginTransaction()</c>); higher when nested SQL-text <c>BEGIN</c>s
10+
/// have been issued without matching <c>COMMIT</c>s. Probe-confirmed
11+
/// behavior against SQL Server 2025 (2026-05-08).
12+
/// </summary>
13+
internal sealed class TranCountExpression(ParserContext context) : Expression
14+
{
15+
public override SqlValue Run(Func<MultiPartName, SqlValue> getColumnValue) =>
16+
SqlValue.FromInt32(context.Connection.CurrentTransaction?.TranCount ?? 0);
17+
18+
public override SqlType GetSqlType(Func<MultiPartName, SqlType> resolveColumnType) => SqlType.Int32;
19+
20+
internal override string DebugDisplay() => "@@TRANCOUNT";
21+
}

SqlServerSimulator/Parser/ParserContext.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,16 @@ private static SqlValue ConvertParameter(object? raw, SqlType type) =>
123123

124124
public Simulation Simulation => Command.simulation;
125125

126+
/// <summary>
127+
/// The connection backing <see cref="Command"/>. Always a
128+
/// <see cref="SimulatedDbConnection"/>: <see cref="SimulatedDbCommand"/>'s
129+
/// constructor takes one and the setter rejects re-assignment, so once
130+
/// the command exists this cast is never wrong and never null. Used by
131+
/// transaction-related parsers and <see cref="Expressions.TranCountExpression"/>
132+
/// to reach the connection's <see cref="SimulatedDbConnection.CurrentTransaction"/>.
133+
/// </summary>
134+
public SimulatedDbConnection Connection => (SimulatedDbConnection)Command.Connection!;
135+
126136
/// <summary>
127137
/// Gets the value of the variable with the provided <paramref name="name"/>.
128138
/// </summary>

SqlServerSimulator/SimulatedDbTransaction.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ sealed class SimulatedDbTransaction(Simulation simulation, SimulatedDbConnection
2020
/// </summary>
2121
internal readonly UndoLog UndoLog = new();
2222

23+
/// <summary>
24+
/// SQL Server's <c>@@TRANCOUNT</c> nesting depth. Starts at 1 when this
25+
/// transaction is created (via either SqlClient API or SQL-text
26+
/// <c>BEGIN TRANSACTION</c>). Each subsequent SQL-text <c>BEGIN</c>
27+
/// increments it; each SQL-text <c>COMMIT</c> decrements; only when it
28+
/// reaches 0 does the transaction actually commit. <c>ROLLBACK</c>
29+
/// (without a savepoint name) zeroes it regardless of depth — matches
30+
/// SQL Server's documented behavior (probe-confirmed 2026-05-08).
31+
/// </summary>
32+
internal int TranCount = 1;
33+
2334
/// <summary>
2435
/// Savepoint name → log position at the time of <c>SAVE TRANSACTION</c>.
2536
/// EF Core 10's <c>RelationalTransaction.CreateSavepoint</c> emits
@@ -48,6 +59,9 @@ public override void Commit()
4859
{
4960
if (this.finished)
5061
throw new InvalidOperationException("This SqlTransaction has completed; it is no longer usable.");
62+
this.TranCount--;
63+
if (this.TranCount > 0)
64+
return;
5165
this.UndoLog.Clear();
5266
this.connection.CurrentTransaction = null;
5367
this.finished = true;
@@ -58,6 +72,7 @@ public override void Rollback()
5872
if (this.finished)
5973
throw new InvalidOperationException("This SqlTransaction has completed; it is no longer usable.");
6074
this.UndoLog.Rollback();
75+
this.TranCount = 0;
6176
this.connection.CurrentTransaction = null;
6277
this.finished = true;
6378
}

SqlServerSimulator/SimulatedSqlException.SyntaxErrors.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,20 @@ internal static SimulatedSqlException UnclosedStringLiteral() =>
2121
internal static SimulatedSqlException SyntaxErrorNear(Token? token) => new($"Incorrect syntax near '{token}'.", 102, 15, 1);
2222

2323
internal static SimulatedSqlException SyntaxErrorNear(char c) => new($"Incorrect syntax near '{c}'.", 102, 15, 1);
24+
25+
/// <summary>
26+
/// Mimics SQL Server error 3902: a <c>COMMIT</c> was issued with no
27+
/// active transaction. Probe-confirmed against SQL Server 2025
28+
/// (2026-05-08): Class 16, State 1, exact wording verbatim.
29+
/// </summary>
30+
internal static SimulatedSqlException NoCorrespondingBeginCommit() =>
31+
new("The COMMIT TRANSACTION request has no corresponding BEGIN TRANSACTION.", 3902, 16, 1);
32+
33+
/// <summary>
34+
/// Mimics SQL Server error 3903: a <c>ROLLBACK</c> was issued with no
35+
/// active transaction. Probe-confirmed against SQL Server 2025
36+
/// (2026-05-08): Class 16, State 1, exact wording verbatim.
37+
/// </summary>
38+
internal static SimulatedSqlException NoCorrespondingBeginRollback() =>
39+
new("The ROLLBACK TRANSACTION request has no corresponding BEGIN TRANSACTION.", 3903, 16, 1);
2440
}

0 commit comments

Comments
 (0)