Skip to content

Commit 22676d8

Browse files
committed
SSMS Object Explorer's Databases node now populates: the root cause was the TDS RPC parser rejecting ntext (0x63) parameters — SqlClient sends sp_executesql's @Statement as ntext once a query exceeds nvarchar(4000), so SMO's large user-database enumeration was rejected at parameter-parse and never executed. Decode ntext (0x63) and text (0x23) RPC params via the legacy 4-byte-length wire form. Model the catalog surface that HADR-aware enumeration reaches once it runs: sys.database_mirroring, the empty AlwaysOn DMVs sys.availability_replicas/_groups and sys.dm_hadr_database_replica_states, sys.master_files, msdb.dbo.syspolicy_configuration + fn_syspolicy_is_automation_enabled() for the enum connection's server-object-model setup, and the COLLATIONPROPERTY built-in; xp_qv reports 2 (AlwaysOn available, matching the Enterprise EngineEdition and the reference, distinct from IsHadrEnabled=0).
1 parent c6de980 commit 22676d8

19 files changed

Lines changed: 977 additions & 19 deletions

SqlServerSimulator.Tests.SqlClient/NetworkListenerTests.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,20 @@ public async Task ListenAsync_PortZero_AssignsEphemeralPort()
3535
IsGreaterThan(0, listener.Port);
3636
AreNotEqual(1433, listener.Port);
3737
}
38+
39+
// SqlConnection.ServerVersion is parsed from the LOGINACK token's
40+
// ProgVersion (major.minor.build). The simulator deliberately reports a
41+
// 0 build ("17.00.0000") as an honest "not a real SQL Server build"
42+
// marker — probed harmless to SMO's Object Explorer (the Databases node
43+
// populates regardless; the enumeration gate was ntext RPC-param support,
44+
// not the reported version).
45+
[TestMethod]
46+
public async Task LoginAck_ReportsSimulatedZeroBuild()
47+
{
48+
var simulation = new Simulation();
49+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
50+
await using var connection = new SqlConnection(ConnectionString(listener));
51+
await connection.OpenAsync(TestContext.CancellationToken);
52+
AreEqual("17.00.0000", connection.ServerVersion);
53+
}
3854
}

SqlServerSimulator.Tests.SqlClient/RpcParameterTypeTests.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,4 +182,41 @@ public async Task Null_TypedParameters_RoundTripAsDbNull()
182182
_ = IsInstanceOfType<DBNull>(wireValue);
183183
}
184184
}
185+
186+
// A statement > nvarchar(4000) forces SqlClient to send the sp_executesql
187+
// @statement parameter as ntext (0x63) with the legacy 4-byte-length value
188+
// form (LONGLEN max + collation + LONGLEN data + UTF-16 bytes) — NOT PLP.
189+
// SMO's Object-Explorer user-database enumeration is exactly such a large
190+
// parameterized query; rejecting ntext RPC params meant it never executed,
191+
// so the Databases node stayed empty. Spans multiple TDS packets at the
192+
// 8000-byte default. Probe-confirmed wire shape (2026-07-15).
193+
[TestMethod]
194+
public async Task LargeStatement_SentAsNtextRpcParam_Executes()
195+
{
196+
var simulation = new Simulation();
197+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
198+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
199+
// ~12 KB of statement text → over nvarchar(4000) and multi-packet.
200+
var pad = "/* " + new string('x', 6000) + " */";
201+
await using var command = new SqlCommand($"{pad} SELECT @p AS v", connection);
202+
_ = command.Parameters.Add(new SqlParameter("@p", SqlDbType.Int) { Value = 42 });
203+
AreEqual(42, await command.ExecuteScalarAsync(TestContext.CancellationToken));
204+
}
205+
206+
// The @statement itself carrying > 4000 chars of real SQL (not padding) —
207+
// ntext value must decode to the exact query the server then runs.
208+
[TestMethod]
209+
public async Task LargeStatement_NtextValue_DecodesExactly()
210+
{
211+
var simulation = new Simulation();
212+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
213+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
214+
var terms = string.Join(" + ", Enumerable.Repeat("1", 3000));
215+
await using var command = new SqlCommand($"SELECT ({terms}) AS total, @p AS p", connection);
216+
_ = command.Parameters.Add(new SqlParameter("@p", SqlDbType.NVarChar, 20) { Value = "ok" });
217+
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
218+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
219+
AreEqual(3000, reader.GetInt32(0));
220+
AreEqual("ok", reader.GetString(1));
221+
}
185222
}

SqlServerSimulator.Tests/CatalogViewTests.cs

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,4 +523,167 @@ order by name
523523
AreEqual((byte)0, reader.GetByte(8));
524524
IsFalse(reader.Read());
525525
}
526+
527+
// === sys.database_mirroring: one non-mirrored row per database ===
528+
529+
[TestMethod]
530+
public void SysDatabaseMirroring_Projects21Columns()
531+
{
532+
using var reader = new Simulation().ExecuteReader(
533+
"select * from sys.database_mirroring where database_id = 1");
534+
AreEqual(21, reader.FieldCount);
535+
}
536+
537+
[TestMethod]
538+
public void SysDatabaseMirroring_NonMirroredRow_OnlyDatabaseIdPopulated()
539+
{
540+
using var reader = new Simulation().ExecuteReader("""
541+
select database_id, mirroring_guid, mirroring_state, mirroring_role,
542+
mirroring_role_desc, mirroring_failover_lsn
543+
from sys.database_mirroring where database_id = 5
544+
""");
545+
AreEqual(typeof(int), reader.GetFieldType(0));
546+
AreEqual(typeof(byte), reader.GetFieldType(2));
547+
AreEqual(typeof(decimal), reader.GetFieldType(5));
548+
IsTrue(reader.Read());
549+
AreEqual(5, reader.GetInt32(0));
550+
IsTrue(reader.IsDBNull(1));
551+
IsTrue(reader.IsDBNull(2));
552+
IsTrue(reader.IsDBNull(3));
553+
IsTrue(reader.IsDBNull(4));
554+
IsTrue(reader.IsDBNull(5));
555+
IsFalse(reader.Read());
556+
}
557+
558+
// One row per database, joining 1:1 to sys.databases on database_id.
559+
[TestMethod]
560+
public void SysDatabaseMirroring_OneRowPerDatabase_JoinsToDatabases()
561+
=> AreEqual(0, new Simulation().ExecuteScalar("""
562+
select count(*)
563+
from master.sys.databases dtb
564+
full join sys.database_mirroring dmi on dmi.database_id = dtb.database_id
565+
where dtb.database_id is null or dmi.database_id is null
566+
"""));
567+
568+
// The core of SSMS's Object-Explorer "Databases" enumeration: sys.databases
569+
// LEFT JOIN sys.database_mirroring, reading ISNULL(mirroring_role, 0) /
570+
// ISNULL(mirroring_state + 1, 0), filtered to user databases. Msg 208 on the
571+
// mirroring view would blank the folder.
572+
[TestMethod]
573+
public void SysDatabaseMirroring_SmoEnumeration_ReturnsUserDatabase()
574+
{
575+
using var reader = new Simulation().ExecuteReader("""
576+
select dtb.name, isnull(dmi.mirroring_role, 0), isnull(dmi.mirroring_state + 1, 0)
577+
from master.sys.databases dtb
578+
left join sys.database_mirroring dmi on dmi.database_id = dtb.database_id
579+
where dtb.name not in ('master', 'model', 'msdb', 'tempdb')
580+
""");
581+
IsTrue(reader.Read());
582+
AreEqual("simulated", reader.GetString(0));
583+
// ISNULL(mirroring_role, 0) inherits mirroring_role's tinyint type;
584+
// ISNULL(mirroring_state + 1, 0) is int (tinyint + int promotes).
585+
AreEqual((byte)0, reader.GetByte(1));
586+
AreEqual(0, reader.GetInt32(2));
587+
IsFalse(reader.Read());
588+
}
589+
590+
// === AlwaysOn Availability-Group views: empty, server-scope ===
591+
592+
[TestMethod]
593+
public void SysAvailabilityReplicas_Projects22Columns_ZeroRows()
594+
{
595+
using var reader = new Simulation().ExecuteReader(
596+
"select * from sys.availability_replicas");
597+
AreEqual(22, reader.FieldCount);
598+
AreEqual(typeof(Guid), reader.GetFieldType(0));
599+
IsFalse(reader.Read());
600+
}
601+
602+
[TestMethod]
603+
public void SysAvailabilityGroups_Projects19Columns_ZeroRows()
604+
{
605+
using var reader = new Simulation().ExecuteReader(
606+
"select * from sys.availability_groups");
607+
AreEqual(19, reader.FieldCount);
608+
AreEqual(typeof(Guid), reader.GetFieldType(0));
609+
AreEqual(typeof(string), reader.GetFieldType(1));
610+
IsFalse(reader.Read());
611+
}
612+
613+
[TestMethod]
614+
public void SysDmHadrDatabaseReplicaStates_Projects39Columns_ZeroRows()
615+
{
616+
using var reader = new Simulation().ExecuteReader(
617+
"select * from sys.dm_hadr_database_replica_states");
618+
AreEqual(39, reader.FieldCount);
619+
IsFalse(reader.Read());
620+
}
621+
622+
// SSMS's enumeration seeds a #temp from the empty replica DMV; the
623+
// insert-from-empty-catalog-view path must resolve and add zero rows.
624+
[TestMethod]
625+
public void SysAvailabilityReplicas_InsertFromEmptyView_AddsNoRows()
626+
=> AreEqual(0, new Simulation().ExecuteScalar("""
627+
create table #r (a uniqueidentifier, b uniqueidentifier, c sysname);
628+
insert #r select replica_id, group_id, replica_server_name
629+
from master.sys.availability_replicas;
630+
select count(*) from #r
631+
"""));
632+
633+
// === sys.master_files: data + log file per database, no type-2 files ===
634+
635+
[TestMethod]
636+
public void SysMasterFiles_Projects32Columns()
637+
{
638+
using var reader = new Simulation().ExecuteReader(
639+
"select * from sys.master_files where database_id = 1");
640+
AreEqual(32, reader.FieldCount);
641+
}
642+
643+
[TestMethod]
644+
public void SysMasterFiles_DataAndLogFilePerDatabase()
645+
{
646+
using var reader = new Simulation().ExecuteReader("""
647+
select file_id, type, type_desc, name, physical_name
648+
from sys.master_files where database_id = 5 order by file_id
649+
""");
650+
AreEqual(typeof(int), reader.GetFieldType(0));
651+
AreEqual(typeof(byte), reader.GetFieldType(1));
652+
IsTrue(reader.Read());
653+
AreEqual(1, reader.GetInt32(0));
654+
AreEqual((byte)0, reader.GetByte(1));
655+
AreEqual("ROWS", reader.GetString(2));
656+
AreEqual("simulated_Data", reader.GetString(3));
657+
IsTrue(reader.Read());
658+
AreEqual(2, reader.GetInt32(0));
659+
AreEqual((byte)1, reader.GetByte(1));
660+
AreEqual("LOG", reader.GetString(2));
661+
AreEqual("simulated_Log", reader.GetString(3));
662+
IsFalse(reader.Read());
663+
}
664+
665+
// Two files (data + log) for each of the five hosted databases.
666+
[TestMethod]
667+
public void SysMasterFiles_TwoFilesPerDatabase()
668+
=> AreEqual(10, new Simulation().ExecuteScalar(
669+
"select count(*) from master.sys.master_files"));
670+
671+
// SSMS's in-memory-OLTP filegroup probe: no type-2 file exists, so the
672+
// bracket-escaped [type] filter must parse and return zero.
673+
[TestMethod]
674+
public void SysMasterFiles_NoType2Files()
675+
=> AreEqual(0, new Simulation().ExecuteScalar(
676+
"select count(*) from master.sys.master_files where [type] = 2"));
677+
678+
[TestMethod]
679+
public void SysMasterFiles_TypeTwoJoinToDatabases_ReturnsNothing()
680+
{
681+
using var reader = new Simulation().ExecuteReader("""
682+
select db.name
683+
from master.sys.master_files mf
684+
join master.sys.databases db on mf.database_id = db.database_id
685+
where mf.[type] = 2
686+
""");
687+
IsFalse(reader.Read());
688+
}
526689
}

SqlServerSimulator.Tests/CollationMetadataTests.cs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,4 +304,80 @@ public void DatabasePropertyEx_MissingComma_RaisesMsg174()
304304
public void DatabasePropertyEx_MissingCloseParen_RaisesSyntaxError()
305305
=> _ = Throws<DbException>(() => new Simulation().ExecuteScalar(
306306
"SELECT DATABASEPROPERTYEX('simulated', 'Status' extra"));
307+
308+
// === COLLATIONPROPERTY(collation_name, property) ===
309+
// Probe-confirmed against SQL Server 2025 (2026-07-14). Real SQL Server
310+
// projects sql_variant; the simulator surfaces the bare base type
311+
// (int for CodePage/LCID/ComparisonStyle/Version, nvarchar for Name).
312+
313+
[TestMethod]
314+
public void CollationProperty_CodePage_ReturnsAnsiCodePage()
315+
=> AreEqual(1252, new Simulation().ExecuteScalar(
316+
"SELECT COLLATIONPROPERTY('SQL_Latin1_General_CP1_CI_AS', 'CodePage')"));
317+
318+
[TestMethod]
319+
public void CollationProperty_Lcid_ReturnsLocaleId()
320+
=> AreEqual(1033, new Simulation().ExecuteScalar(
321+
"SELECT COLLATIONPROPERTY('SQL_Latin1_General_CP1_CI_AS', 'LCID')"));
322+
323+
[TestMethod]
324+
public void CollationProperty_ComparisonStyle_ReturnsBitmask()
325+
=> AreEqual(196609, new Simulation().ExecuteScalar(
326+
"SELECT COLLATIONPROPERTY('SQL_Latin1_General_CP1_CI_AS', 'ComparisonStyle')"));
327+
328+
[TestMethod]
329+
public void CollationProperty_Version_SqlCollation_IsZero()
330+
=> AreEqual(0, new Simulation().ExecuteScalar(
331+
"SELECT COLLATIONPROPERTY('SQL_Latin1_General_CP1_CI_AS', 'Version')"));
332+
333+
[TestMethod]
334+
public void CollationProperty_Version_HundredSeriesCollation_IsTwo()
335+
=> AreEqual(2, new Simulation().ExecuteScalar(
336+
"SELECT COLLATIONPROPERTY('Latin1_General_100_CI_AS', 'Version')"));
337+
338+
[TestMethod]
339+
public void CollationProperty_Name_ReturnsCollationName()
340+
=> AreEqual("SQL_Latin1_General_CP1_CI_AS", new Simulation().ExecuteScalar(
341+
"SELECT COLLATIONPROPERTY('SQL_Latin1_General_CP1_CI_AS', 'Name')"));
342+
343+
// ComparisonStyle tracks the suffix flags — accent-insensitive adds bit 2,
344+
// case-sensitive clears bit 1, KS/WS clear the ignore-kana/width bits, and
345+
// binary collations report 0. Probe-confirmed values.
346+
[TestMethod]
347+
public void CollationProperty_ComparisonStyle_TracksSuffixFlags()
348+
{
349+
var sim = new Simulation();
350+
AreEqual(196611, sim.ExecuteScalar("SELECT COLLATIONPROPERTY('Latin1_General_100_CI_AI', 'ComparisonStyle')"));
351+
AreEqual(196608, sim.ExecuteScalar("SELECT COLLATIONPROPERTY('Latin1_General_100_CS_AS', 'ComparisonStyle')"));
352+
AreEqual(1, sim.ExecuteScalar("SELECT COLLATIONPROPERTY('Latin1_General_100_CI_AS_KS_WS', 'ComparisonStyle')"));
353+
AreEqual(0, sim.ExecuteScalar("SELECT COLLATIONPROPERTY('Latin1_General_100_BIN2', 'ComparisonStyle')"));
354+
}
355+
356+
// CodePage derives from the collation model, so a UTF-8 collation reports
357+
// 65001 and a Japanese collation reports 932.
358+
[TestMethod]
359+
public void CollationProperty_CodePage_DerivesFromCollationModel()
360+
{
361+
var sim = new Simulation();
362+
AreEqual(65001, sim.ExecuteScalar("SELECT COLLATIONPROPERTY('Latin1_General_100_CI_AS_SC_UTF8', 'CodePage')"));
363+
AreEqual(932, sim.ExecuteScalar("SELECT COLLATIONPROPERTY('Japanese_CI_AS', 'CodePage')"));
364+
}
365+
366+
// SSMS's per-database follow-up passes a scalar subquery as the collation
367+
// argument with a constant property; the constant property still drives the
368+
// static int type and the subquery resolves at runtime.
369+
[TestMethod]
370+
public void CollationProperty_SubqueryCollationArg_Resolves()
371+
=> AreEqual(1252, new Simulation().ExecuteScalar(
372+
"SELECT COLLATIONPROPERTY((select collation_name from sys.databases where name = 'simulated'), 'CodePage')"));
373+
374+
[TestMethod]
375+
public void CollationProperty_UnknownProperty_ReturnsNull()
376+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
377+
"SELECT COLLATIONPROPERTY('SQL_Latin1_General_CP1_CI_AS', 'Bogus')"));
378+
379+
[TestMethod]
380+
public void CollationProperty_UnrecognizedCollation_ReturnsNull()
381+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
382+
"SELECT COLLATIONPROPERTY('Not_A_Collation', 'CodePage')"));
307383
}

SqlServerSimulator.Tests/StatementBoundaryTests.cs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,13 @@ public void SelectFollowedByCte_WithoutSemicolon_RaisesMsg319()
114114

115115
// The exact SSMS Object-Explorer AlwaysOn availability probe: three
116116
// statements, only the middle two separated by a semicolon. xp_qv returns
117-
// status 0 (AlwaysOn not available), so ISNULL(@alwayson, -1) yields 0.
117+
// status 2 (AlwaysOn *available* — the edition-capability answer for the
118+
// simulated Enterprise EngineEdition; distinct from IsHadrEnabled = 0
119+
// meaning not-configured), so ISNULL(@alwayson, -1) yields 2. SMO's
120+
// Databases enumeration is HADR-aware and requires this.
118121
[TestMethod]
119-
public void SsmsAlwaysOnProbe_ReturnsZero()
120-
=> AreEqual(0, new Simulation().ExecuteScalar<int>("""
122+
public void SsmsAlwaysOnProbe_ReturnsTwo()
123+
=> AreEqual(2, new Simulation().ExecuteScalar<int>("""
121124
DECLARE @alwayson INT
122125
EXECUTE @alwayson = master.dbo.xp_qv N'3641190370', @@SERVICENAME;
123126
SELECT ISNULL(@alwayson,-1) AS [AlwaysOn]
@@ -134,8 +137,8 @@ public void XpQv_YieldsNoResultSet(string call)
134137
}
135138

136139
[TestMethod]
137-
public void XpQv_ReturnStatus_IsZero()
138-
=> AreEqual(0, new Simulation().ExecuteScalar<int>("""
140+
public void XpQv_ReturnStatus_IsTwo()
141+
=> AreEqual(2, new Simulation().ExecuteScalar<int>("""
139142
declare @rc int;
140143
exec @rc = xp_qv N'x', N'y';
141144
select @rc

0 commit comments

Comments
 (0)