Skip to content

Commit 36a49a1

Browse files
committed
Add SESSION_CONTEXT / sp_set_session_context, CONTEXT_INFO / SET CONTEXT_INFO, and the CONNECTIONPROPERTY / CURRENT_TRANSACTION_ID / CURRENT_REQUEST_ID connection scalars as real per-session state.
1 parent bf8cee7 commit 36a49a1

14 files changed

Lines changed: 385 additions & 7 deletions

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ Full `DbDataReader` contract. Typed accessors read `SqlValue` directly via the c
133133

134134
Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger: read the linked file on demand when working in the matching area.
135135

136-
- **Built-in scalars** (math, date incl. DATETRUNC / DATE_BUCKET / SWITCHOFFSET / TODATETIMEOFFSET, current-time, `*FROMPARTS`, AT TIME ZONE, CONCAT, char-code, SOUNDEX / STR / TRANSLATE / STRING_ESCAPE / DIFFERENCE, CHOOSE / IIF, bit manipulation (BIT_COUNT / GET_BIT / SET_BIT / LEFT_SHIFT / RIGHT_SHIFT), CHECKSUM / BINARY_CHECKSUM, FORMAT, RAND, STRING_SPLIT, GENERATE_SERIES, COMPRESS/DECOMPRESS, session/server `@@`-constants + HOST_NAME / APP_NAME / GETANSINULL / ORIGINAL_DB_NAME) → [`scalars.md`](docs/claude/scalars.md).
136+
- **Built-in scalars** (math, date incl. DATETRUNC / DATE_BUCKET / SWITCHOFFSET / TODATETIMEOFFSET, current-time, `*FROMPARTS`, AT TIME ZONE, CONCAT, char-code, SOUNDEX / STR / TRANSLATE / STRING_ESCAPE / DIFFERENCE, CHOOSE / IIF, bit manipulation (BIT_COUNT / GET_BIT / SET_BIT / LEFT_SHIFT / RIGHT_SHIFT), CHECKSUM / BINARY_CHECKSUM, FORMAT, RAND, STRING_SPLIT, GENERATE_SERIES, COMPRESS/DECOMPRESS, session/server `@@`-constants + HOST_NAME / APP_NAME / GETANSINULL / ORIGINAL_DB_NAME, session-state store (SESSION_CONTEXT / sp_set_session_context / CONTEXT_INFO / CONNECTIONPROPERTY / CURRENT_TRANSACTION_ID / CURRENT_REQUEST_ID)) → [`scalars.md`](docs/claude/scalars.md).
137137
- **`SqlType.Promote` / `PromoteForArithmetic` / decimal precision-scale / int↔string promotion**[`arithmetic.md`](docs/claude/arithmetic.md).
138138
- **`Cast` / coercion error paths** (CAST/CONVERT narrow targets, TRY_CAST/TRY_CONVERT swallow set, PARSE/TRY_PARSE culture-aware parsing) → [`casting.md`](docs/claude/casting.md).
139139
- **`Selection`, aggregates, window functions, set ops, CASE, OFFSET/FETCH**[`query.md`](docs/claude/query.md).
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
using static SqlServerSimulator.TestHelpers;
3+
4+
namespace SqlServerSimulator;
5+
6+
[TestClass]
7+
public sealed class SessionContextTests
8+
{
9+
[TestMethod]
10+
public void SetAndRead_Named()
11+
=> AreEqual("42", ExecuteScalar(
12+
"exec sp_set_session_context @key = N'TenantId', @value = 42; select session_context(N'TenantId')"));
13+
14+
[TestMethod]
15+
public void SetAndRead_Positional()
16+
=> AreEqual("hello", ExecuteScalar(
17+
"exec sp_set_session_context N'StrKey', N'hello'; select session_context(N'StrKey')"));
18+
19+
[TestMethod]
20+
public void MissingKey_ReturnsNull()
21+
=> IsInstanceOfType<DBNull>(ExecuteScalar("select session_context(N'nope')"));
22+
23+
[TestMethod]
24+
public void Key_IsCaseSensitive()
25+
=> IsInstanceOfType<DBNull>(ExecuteScalar(
26+
"exec sp_set_session_context N'TenantId', 42; select session_context(N'tenantid')"));
27+
28+
[TestMethod]
29+
public void ReadOnlyKey_RejectsOverwrite_Msg15664()
30+
{
31+
var ex = new Simulation().AssertSqlError(
32+
"exec sp_set_session_context @key = N'Locked', @value = 1, @read_only = 1;"
33+
+ " exec sp_set_session_context @key = N'Locked', @value = 2",
34+
15664);
35+
Assert.Contains("read_only", ex.Message);
36+
}
37+
38+
[TestMethod]
39+
public void NullKey_RaisesMsg225()
40+
=> new Simulation().AssertSqlError("exec sp_set_session_context @key = NULL, @value = 1", 225);
41+
42+
[TestMethod]
43+
public void NullKeyArgument_RaisesMsg8116()
44+
=> new Simulation().AssertSqlError("select session_context(NULL)", 8116);
45+
46+
[TestMethod]
47+
public void UsableInWherePredicate()
48+
=> AreEqual(1, ExecuteScalar("""
49+
create table t (id int not null primary key, tenant int not null);
50+
insert t values (1, 42), (2, 99);
51+
exec sp_set_session_context N'T', 42;
52+
select id from t where tenant = session_context(N'T')
53+
"""));
54+
55+
[TestMethod]
56+
public void ContextInfo_NullBeforeSet()
57+
=> IsInstanceOfType<DBNull>(ExecuteScalar("select context_info()"));
58+
59+
[TestMethod]
60+
public void ContextInfo_PaddedTo128AfterSet()
61+
=> AreEqual(128, ExecuteScalar("set context_info 0x4869; select datalength(context_info())"));
62+
63+
[TestMethod]
64+
public void ConnectionProperty_KnownAndUnknown()
65+
{
66+
AreEqual("TCP", ExecuteScalar("select connectionproperty('net_transport')"));
67+
AreEqual("TSQL", ExecuteScalar("select connectionproperty('protocol_type')"));
68+
_ = IsInstanceOfType<DBNull>(ExecuteScalar("select connectionproperty('bogus')"));
69+
}
70+
71+
[TestMethod]
72+
public void CurrentTransactionId_IsBigint()
73+
=> IsInstanceOfType<long>(ExecuteScalar("select current_transaction_id()"));
74+
75+
[TestMethod]
76+
public void CurrentRequestId_IsZero()
77+
=> AreEqual(0, ExecuteScalar("select current_request_id()"));
78+
}

SqlServerSimulator/Errors/SimulatedSqlException.SchemaErrors.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,22 @@ internal static SimulatedSqlException InvalidCompatibilityLevel() =>
3737
internal static SimulatedSqlException DatabaseDoesNotExist(string databaseName) =>
3838
new($"Database '{databaseName}' does not exist. Make sure that the name is entered correctly.", 911, 16, 1);
3939

40+
/// <summary>
41+
/// Mimics SQL Server error 15664: a <c>sp_set_session_context</c> call
42+
/// targeted a key previously set with <c>@read_only = 1</c> in this
43+
/// session. Wording probe-confirmed against SQL Server 2025. Class 16 State 1.
44+
/// </summary>
45+
internal static SimulatedSqlException SessionContextKeyIsReadOnly(string key) =>
46+
new($"Cannot set key '{key}' in the session context. The key has been set as read_only for this session.", 15664, 16, 1);
47+
48+
/// <summary>
49+
/// Mimics SQL Server error 225: the parameters supplied to a system
50+
/// procedure are not valid — raised here for a NULL / missing <c>@key</c>
51+
/// to <c>sp_set_session_context</c>. Class 16 State 1.
52+
/// </summary>
53+
internal static SimulatedSqlException InvalidProcedureParameters(string procedureName) =>
54+
new($"The parameters supplied for the procedure \"{procedureName}\" are not valid.", 225, 16, 1);
55+
4056
/// <summary>
4157
/// Mimics SQL Server error 2520: <c>DBCC SHRINKDATABASE(&lt;name&gt;)</c>
4258
/// names a database not present in this <see cref="Simulation"/>. Distinct

SqlServerSimulator/Parser/Expression.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
735735
12 => uppercaseName switch
736736
{
737737
"CHECKSUM_AGG" => AggregateExpression.Parse(context, AggregateKind.ChecksumAgg),
738+
"CONTEXT_INFO" => new ContextInfoFunction(context),
738739
"DATEDIFF_BIG" => new DateDiff.Big(context),
739740
"ERROR_NUMBER" => new ErrorNumberFunction(context),
740741
"PERCENT_RANK" => WindowExpression.ParsePercentRank(context),
@@ -775,6 +776,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
775776
"NEWSEQUENTIALID" => new NewSequentialId(context),
776777
"PERCENTILE_CONT" => WindowExpression.ParsePercentile(context, WindowKind.PercentileCont),
777778
"PERCENTILE_DISC" => WindowExpression.ParsePercentile(context, WindowKind.PercentileDisc),
779+
"SESSION_CONTEXT" => new SessionContext(context),
778780
_ => null
779781
},
780782
16 => uppercaseName switch
@@ -798,6 +800,8 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
798800
},
799801
18 => uppercaseName switch
800802
{
803+
"CONNECTIONPROPERTY" => new ConnectionProperty(context),
804+
"CURRENT_REQUEST_ID" => new CurrentRequestId(context),
801805
"DATABASEPROPERTYEX" => new DatabasePropertyEx(context),
802806
"DATETIME2FROMPARTS" => new DatePartsBuilder(context, DatePartsBuilderKind.DateTime2FromParts),
803807
"OBJECT_SCHEMA_NAME" => new ObjectSchemaName(context),
@@ -812,6 +816,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
812816
},
813817
22 => uppercaseName switch
814818
{
819+
"CURRENT_TRANSACTION_ID" => new CurrentTransactionId(context),
815820
"SMALLDATETIMEFROMPARTS" => new DatePartsBuilder(context, DatePartsBuilderKind.SmallDateTimeFromParts),
816821
_ => null
817822
},
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
using SqlServerSimulator.Storage;
2+
3+
namespace SqlServerSimulator.Parser.Expressions;
4+
5+
/// <summary>
6+
/// SQL <c>SESSION_CONTEXT(N'key')</c>: reads a value previously stored by
7+
/// <c>sp_set_session_context</c> on this session. Real SQL Server returns
8+
/// <c>sql_variant</c> (type-preserving); the simulator has no
9+
/// <c>sql_variant</c>, so the stored value surfaces as
10+
/// <see cref="SqlType.NVarchar"/> — the same proxy <see cref="ServerProperty"/>
11+
/// uses. A missing key returns NULL; a NULL key raises Msg 8116
12+
/// (probe-confirmed). Keys are case-sensitive — see
13+
/// <see cref="SimulatedDbConnection.SessionContext"/>.
14+
/// </summary>
15+
internal sealed class SessionContext : Expression
16+
{
17+
private readonly Expression keyArg;
18+
19+
public SessionContext(ParserContext context)
20+
{
21+
this.keyArg = Parse(context);
22+
if (context.Token is not Tokens.Operator { Character: ')' })
23+
throw SimulatedSqlException.SyntaxErrorNear(context);
24+
}
25+
26+
public override SqlValue Run(RuntimeContext runtime)
27+
{
28+
var k = this.keyArg.Run(runtime);
29+
if (k.IsNull)
30+
throw SimulatedSqlException.InvalidArgumentDataType("NULL", argumentIndex: 1, "session_context");
31+
var key = k.CoerceTo(SqlType.NVarchar).AsString;
32+
return runtime.Batch.Connection.SessionContext.TryGetValue(key, out var entry)
33+
? entry.Value.CoerceTo(SqlType.NVarchar)
34+
: SqlValue.Null(SqlType.NVarchar);
35+
}
36+
37+
public override SqlType GetSqlType(BatchContext batch, Func<MultiPartName, SqlType> resolveColumnType) => SqlType.NVarchar;
38+
39+
internal override string DebugDisplay() => $"SESSION_CONTEXT({this.keyArg.DebugDisplay()})";
40+
}
41+
42+
/// <summary>
43+
/// SQL <c>CONTEXT_INFO()</c>: returns the session's 128-byte context-info
44+
/// buffer, or NULL when <c>SET CONTEXT_INFO</c> hasn't run. The buffer is
45+
/// always exactly 128 bytes once set (SQL Server right-pads / truncates),
46+
/// so <c>DATALENGTH(CONTEXT_INFO())</c> is 128. Result type is
47+
/// <see cref="SqlType.Varbinary"/>.
48+
/// </summary>
49+
internal sealed class ContextInfoFunction : Expression
50+
{
51+
public ContextInfoFunction(ParserContext context)
52+
{
53+
if (context.Token is not Tokens.Operator { Character: ')' })
54+
throw SimulatedSqlException.FunctionRequiresNArguments("context_info", 0);
55+
}
56+
57+
public override SqlValue Run(RuntimeContext runtime) =>
58+
runtime.Batch.Connection.ContextInfo is { } bytes
59+
? SqlValue.FromVarbinary(bytes)
60+
: SqlValue.Null(SqlType.Varbinary);
61+
62+
public override SqlType GetSqlType(BatchContext batch, Func<MultiPartName, SqlType> resolveColumnType) => SqlType.Varbinary;
63+
64+
internal override string DebugDisplay() => "CONTEXT_INFO()";
65+
}
66+
67+
/// <summary>
68+
/// SQL <c>CONNECTIONPROPERTY('property')</c>: returns connection-level
69+
/// attributes. Real SQL Server projects <c>sql_variant</c>; the simulator
70+
/// surfaces values as <see cref="SqlType.NVarchar"/> (same proxy as
71+
/// <see cref="ServerProperty"/>). The in-process connection has no real
72+
/// network identity, so transport-shaped properties report fixed
73+
/// placeholder constants (probe-confirmed <c>net_transport = 'TCP'</c>,
74+
/// <c>protocol_type = 'TSQL'</c>); unknown property → NULL.
75+
/// </summary>
76+
internal sealed class ConnectionProperty : Expression
77+
{
78+
private static readonly Dictionary<string, string?> Properties = new(StringComparer.OrdinalIgnoreCase)
79+
{
80+
["net_transport"] = "TCP",
81+
["protocol_type"] = "TSQL",
82+
["auth_scheme"] = "SQL",
83+
["physical_net_transport"] = "TCP",
84+
["local_net_address"] = null,
85+
["local_tcp_port"] = null,
86+
["client_net_address"] = null,
87+
["sni_consumer_node"] = null,
88+
};
89+
90+
private readonly Expression nameArg;
91+
92+
public ConnectionProperty(ParserContext context)
93+
{
94+
this.nameArg = Parse(context);
95+
if (context.Token is not Tokens.Operator { Character: ')' })
96+
throw SimulatedSqlException.SyntaxErrorNear(context);
97+
}
98+
99+
public override SqlValue Run(RuntimeContext runtime)
100+
{
101+
var n = this.nameArg.Run(runtime);
102+
if (n.IsNull)
103+
return SqlValue.Null(SqlType.NVarchar);
104+
var name = n.CoerceTo(SqlType.NVarchar).AsString;
105+
return Properties.TryGetValue(name, out var value) && value is not null
106+
? SqlValue.FromNVarchar(value)
107+
: SqlValue.Null(SqlType.NVarchar);
108+
}
109+
110+
public override SqlType GetSqlType(BatchContext batch, Func<MultiPartName, SqlType> resolveColumnType) => SqlType.NVarchar;
111+
112+
internal override string DebugDisplay() => $"CONNECTIONPROPERTY({this.nameArg.DebugDisplay()})";
113+
}
114+
115+
/// <summary>
116+
/// SQL <c>CURRENT_TRANSACTION_ID()</c>: returns a <c>bigint</c> transaction
117+
/// identifier. Apps use it for logging / correlation, not correctness; the
118+
/// simulator approximates it with the database's monotonic commit counter
119+
/// (<see cref="Database.CurrentTransactionCommitId"/>) — a plausible,
120+
/// increasing value rather than a stable per-transaction id.
121+
/// </summary>
122+
internal sealed class CurrentTransactionId : Expression
123+
{
124+
public CurrentTransactionId(ParserContext context)
125+
{
126+
if (context.Token is not Tokens.Operator { Character: ')' })
127+
throw SimulatedSqlException.FunctionRequiresNArguments("current_transaction_id", 0);
128+
}
129+
130+
public override SqlValue Run(RuntimeContext runtime) =>
131+
SqlValue.FromInt64(runtime.Batch.CurrentDatabase.CurrentTransactionCommitId);
132+
133+
public override SqlType GetSqlType(BatchContext batch, Func<MultiPartName, SqlType> resolveColumnType) => SqlType.BigInt;
134+
135+
internal override string DebugDisplay() => "CURRENT_TRANSACTION_ID()";
136+
}
137+
138+
/// <summary>
139+
/// SQL <c>CURRENT_REQUEST_ID()</c>: returns the <c>int</c> request id within
140+
/// the session. The simulator doesn't multiplex requests per session, so it
141+
/// reports <c>0</c> (probe-confirmed value for a single-request session).
142+
/// </summary>
143+
internal sealed class CurrentRequestId : Expression
144+
{
145+
public CurrentRequestId(ParserContext context)
146+
{
147+
if (context.Token is not Tokens.Operator { Character: ')' })
148+
throw SimulatedSqlException.FunctionRequiresNArguments("current_request_id", 0);
149+
}
150+
151+
public override SqlValue Run(RuntimeContext runtime) => SqlValue.FromInt32(0);
152+
153+
public override SqlType GetSqlType(BatchContext batch, Func<MultiPartName, SqlType> resolveColumnType) => SqlType.Int32;
154+
155+
internal override string DebugDisplay() => "CURRENT_REQUEST_ID()";
156+
}

SqlServerSimulator/SimulatedDbConnection.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,28 @@ private static Database ResolveInitialDatabase(Simulation simulation)
290290
/// </summary>
291291
internal string? IdentityInsertTable;
292292

293+
/// <summary>
294+
/// Per-session key/value store backing <c>SESSION_CONTEXT(key)</c> and
295+
/// <c>sp_set_session_context</c>. Keys are case-sensitive (<see cref="StringComparer.Ordinal"/>),
296+
/// matching SQL Server's binary key comparison regardless of database
297+
/// collation (probe-confirmed: a key set as <c>TenantId</c> isn't readable
298+
/// as <c>tenantid</c>). Each entry carries the stored value (type-preserved,
299+
/// though <c>SESSION_CONTEXT</c> surfaces it as nvarchar since the simulator
300+
/// has no <c>sql_variant</c>) and whether it was set <c>@read_only = 1</c>
301+
/// — a read-only key rejects further writes with Msg 15664. Session-scoped:
302+
/// lives for the connection's lifetime, persisting across batches.
303+
/// </summary>
304+
internal readonly Dictionary<string, (SqlValue Value, bool ReadOnly)> SessionContext = new(StringComparer.Ordinal);
305+
306+
/// <summary>
307+
/// Backs <c>CONTEXT_INFO()</c> / <c>SET CONTEXT_INFO</c>. Null until the
308+
/// first <c>SET CONTEXT_INFO</c>; once set, a 128-byte buffer (SQL Server
309+
/// right-pads or truncates the supplied binary to exactly 128 bytes, so
310+
/// <c>DATALENGTH(CONTEXT_INFO())</c> is always 128 after a set).
311+
/// Session-scoped.
312+
/// </summary>
313+
internal byte[]? ContextInfo;
314+
293315
/// <summary>
294316
/// Active session-scoped trace flags toggled via <c>DBCC TRACEON(N)</c>
295317
/// / <c>DBCC TRACEOFF(N)</c>. The simulator doesn't model the separate

SqlServerSimulator/Simulation/Simulation.Dbcc.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ private static bool TryParseShrink(ParserContext context, BatchContext batch, ou
109109

110110
/// <summary>
111111
/// Resolves the <c>DBCC SHRINKDATABASE</c> first argument to a database: a
112-
/// bare / bracketed name routes through <see cref="Simulation.Databases"/>
112+
/// bare / bracketed name routes through <see cref="Databases"/>
113113
/// (Msg 2520 on miss), a numeric database-id through the same 1-based
114114
/// name-ordered id convention <see cref="DbId"/> uses.
115115
/// </summary>

SqlServerSimulator/Simulation/Simulation.Exec.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ private IEnumerable<SimulatedStatementOutcome> ParseExec(BatchContext batch)
130130
yield return outcome;
131131
yield break;
132132
}
133+
if (dbCollation.Equals(procName.Leaf, "sp_set_session_context"))
134+
{
135+
foreach (var outcome in InvokeSpSetSessionContext(batch))
136+
yield return outcome;
137+
yield break;
138+
}
133139
if (dbCollation.Equals(procName.Leaf, "sp_addlinkedsrvlogin")
134140
|| dbCollation.Equals(procName.Leaf, "sp_droplinkedsrvlogin")
135141
|| dbCollation.Equals(procName.Leaf, "sp_serveroption"))

0 commit comments

Comments
 (0)