Skip to content

Commit a58e72a

Browse files
committed
Implemented sys.all_columns, sys.data_spaces, the six sys.tables table-flavor flags (is_memory_optimized / is_filetable / is_external / is_node / is_edge / ledger_type, accepted-but-constant since none of those table kinds are modeled), and a QUOTENAME fidelity fix.
1 parent 3b71004 commit a58e72a

6 files changed

Lines changed: 250 additions & 14 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Tests for the catalog surface SSMS Object Explorer's Tables node reads via
7+
/// SMO: <c>sys.all_columns</c> (user-object parity with <c>sys.columns</c> plus
8+
/// <c>is_sparse</c>), <c>sys.data_spaces</c> (single PRIMARY filegroup), the
9+
/// table-flavor flags on <c>sys.tables</c> (<c>is_memory_optimized</c> /
10+
/// <c>is_filetable</c> / <c>is_external</c> / <c>is_node</c> / <c>is_edge</c> /
11+
/// <c>ledger_type</c>), and the QUOTENAME collation-propagation fix the Urn
12+
/// concatenation exposed. Closes with a trimmed representative of SMO's Tables
13+
/// node query. Shapes/values probed against SQL Server 2025 (2026-07-15).
14+
/// </summary>
15+
[TestClass]
16+
public sealed class SsmsTablesNodeCatalogTests
17+
{
18+
[TestMethod]
19+
public void AllColumns_ExposesIsSparse_ForUserColumn()
20+
=> AreEqual(0, new Simulation().ExecuteScalar<int>("""
21+
create table t (id int not null primary key, x int null);
22+
select cast(is_sparse as int) from sys.all_columns
23+
where object_id = object_id('t') and name = 'x'
24+
"""));
25+
26+
[TestMethod]
27+
public void AllColumns_MatchesColumnsRowSet_ForUserTable()
28+
{
29+
var sim = new Simulation();
30+
_ = sim.ExecuteNonQuery("create table t (a int not null primary key, b nvarchar(20) null, c decimal(10, 2) null)");
31+
AreEqual(
32+
sim.ExecuteScalar<int>("select count(*) from sys.columns where object_id = object_id('t')"),
33+
sim.ExecuteScalar<int>("select count(*) from sys.all_columns where object_id = object_id('t')"));
34+
}
35+
36+
[TestMethod]
37+
public void DataSpaces_ReturnsPrimaryFilegroup()
38+
{
39+
using var reader = new Simulation().ExecuteReader(
40+
"select name, data_space_id, type, type_desc, is_default, is_system from sys.data_spaces");
41+
42+
IsTrue(reader.Read());
43+
AreEqual("PRIMARY", reader.GetString(0));
44+
AreEqual(1, reader.GetInt32(1));
45+
AreEqual("FG", reader.GetString(2).TrimEnd());
46+
AreEqual("ROWS_FILEGROUP", reader.GetString(3));
47+
IsTrue(reader.GetBoolean(4));
48+
IsFalse(reader.GetBoolean(5));
49+
IsFalse(reader.Read());
50+
}
51+
52+
/// <summary>
53+
/// SMO's IsPartitioned probe joins sys.indexes.data_space_id to
54+
/// sys.data_spaces and compares type to 'PS'. The single modeled row is a
55+
/// row-filegroup ('FG'), so a PK's index always resolves to a non-partition
56+
/// data space.
57+
/// </summary>
58+
[TestMethod]
59+
public void DataSpaces_JoinFromIndex_ResolvesToFilegroupNotPartitionScheme()
60+
=> AreEqual(0, new Simulation().ExecuteScalar<int>("""
61+
create table t (id int not null primary key);
62+
select cast(case when 'PS' = dsidx.type then 1 else 0 end as int)
63+
from sys.tables tbl
64+
inner join sys.indexes idx on idx.object_id = tbl.object_id and idx.index_id < 2
65+
left outer join sys.data_spaces dsidx on dsidx.data_space_id = idx.data_space_id
66+
where tbl.object_id = object_id('t')
67+
"""));
68+
69+
[TestMethod]
70+
public void Tables_TableFlavorFlags_AllZeroForUserTable()
71+
{
72+
using var reader = new Simulation().ExecuteReader("""
73+
create table t (id int not null primary key);
74+
select cast(is_memory_optimized as int), is_filetable, is_external, is_node, is_edge, ledger_type
75+
from sys.tables where object_id = object_id('t')
76+
""");
77+
78+
IsTrue(reader.Read());
79+
AreEqual(0, reader.GetInt32(0));
80+
IsFalse(reader.GetBoolean(1));
81+
IsFalse(reader.GetBoolean(2));
82+
IsFalse(reader.GetBoolean(3));
83+
IsFalse(reader.GetBoolean(4));
84+
AreEqual((byte)0, reader.GetByte(5));
85+
IsFalse(reader.Read());
86+
}
87+
88+
/// <summary>
89+
/// Regression for the QUOTENAME collation fix. Under a non-baseline
90+
/// database collation a plain string literal carries the database collation
91+
/// while the sysname catalog column carries the baseline; before the fix
92+
/// QUOTENAME dropped the input's Implicit coercibility and the Urn's
93+
/// literal + QUOTENAME(...) concatenation raised Msg 468.
94+
/// </summary>
95+
[TestMethod]
96+
public void QuoteName_UnderNonBaselineCollation_ConcatWithLiteralSucceeds()
97+
{
98+
var sim = new Simulation { ServerCollationName = "Latin1_General_100_CI_AS" };
99+
_ = sim.ExecuteNonQuery("create table t (id int not null primary key)");
100+
AreEqual("x[t]", sim.ExecuteScalar("select 'x' + quotename(name) from sys.tables where name = 't'"));
101+
}
102+
103+
/// <summary>
104+
/// Trimmed representative of SMO's Tables node query: the correlated
105+
/// nonclustered-index probe, the is_ms_shipped + extended-property CASE
106+
/// filter, and the sys.all_columns → sys.types join for XML detection. A
107+
/// table with a clustered PK, a nonclustered index, and an xml column
108+
/// projects HasClusteredIndex / HasPrimaryClusteredIndex / HasNonClustered /
109+
/// HasXmlData = 1 and passes the exclusion filter.
110+
/// </summary>
111+
[TestMethod]
112+
public void SmoTablesNodeShape_ProjectsExpectedFlags()
113+
{
114+
var sim = new Simulation();
115+
_ = sim.ExecuteNonQuery("""
116+
create table t (id int not null primary key, payload xml null, note nvarchar(50) null);
117+
create index ix_note on t (note);
118+
""");
119+
120+
using var reader = sim.ExecuteReader("""
121+
select
122+
tbl.name,
123+
cast(case idx.index_id when 1 then 1 else 0 end as int) as HasClusteredIndex,
124+
cast(case idx.index_id when 1 then case when (idx.is_primary_key + 2 * idx.is_unique_constraint = 1) then 1 else 0 end else 0 end as int) as HasPrimaryClusteredIndex,
125+
cast(isnull((select top 1 1 from sys.indexes ind where ind.object_id = tbl.object_id and ind.type > 1 and ind.is_hypothetical = 0), 0) as int) as HasNonClusteredIndex,
126+
cast(isnull((select top 1 1 from sys.all_columns clmns join sys.types usrt on usrt.user_type_id = clmns.user_type_id where clmns.object_id = tbl.object_id and usrt.name = N'xml'), 0) as int) as HasXmlData,
127+
cast(isnull((select distinct 1 from sys.all_columns where object_id = tbl.object_id and is_sparse = 1), 0) as int) as HasSparse
128+
from sys.tables tbl
129+
inner join sys.indexes idx on idx.object_id = tbl.object_id and idx.index_id < 2
130+
where tbl.object_id = object_id('t')
131+
and (cast(case
132+
when tbl.is_ms_shipped = 1 then 1
133+
when exists (select 1 from sys.extended_properties
134+
where major_id = tbl.object_id and minor_id = 0 and class = 1
135+
and name = N'microsoft_database_tools_support') then 1
136+
else 0 end as bit) = 0
137+
and tbl.is_filetable = 0 and tbl.temporal_type = 0 and cast(tbl.is_node as bit) = 0)
138+
""");
139+
140+
IsTrue(reader.Read());
141+
AreEqual("t", reader.GetString(0));
142+
AreEqual(1, reader.GetInt32(1));
143+
AreEqual(1, reader.GetInt32(2));
144+
AreEqual(1, reader.GetInt32(3));
145+
AreEqual(1, reader.GetInt32(4));
146+
AreEqual(0, reader.GetInt32(5));
147+
IsFalse(reader.Read());
148+
}
149+
}

SqlServerSimulator/BuiltInResources.cs

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ void Iso(string name, HeapColumn[] columns, Func<Parser.BatchContext, Database,
171171
var temporalDescNone = SqlValue.FromNVarchar("NON_TEMPORAL_TABLE");
172172
var temporalDescHistory = SqlValue.FromNVarchar("HISTORY_TABLE");
173173
var temporalDescBase = SqlValue.FromNVarchar("SYSTEM_VERSIONED_TEMPORAL_TABLE");
174+
var falseTableFlag = SqlValue.FromBoolean(false);
175+
var ledgerTypeNone = SqlValue.FromByte(0);
174176
Sys("tables",
175177
[
176178
new("object_id", SqlType.Int32, null, false),
@@ -184,6 +186,17 @@ void Iso(string name, HeapColumn[] columns, Func<Parser.BatchContext, Database,
184186
new("temporal_type", SqlType.TinyInt, null, true),
185187
new("temporal_type_desc", nvarchar60Catalog, 60, true),
186188
new("history_table_id", SqlType.Int32, null, true),
189+
// Table-flavor flags SMO's Object-Explorer Tables node filters on.
190+
// None of these table kinds are modeled (memory-optimized,
191+
// filetable, external/PolyBase, graph node/edge, ledger), so each
192+
// ships as a constant 0. ledger_type is tinyint (0 = NON_LEDGER_TABLE,
193+
// probe-confirmed non-null on SQL Server 2025). See docs/claude/catalog-views.md.
194+
new("is_memory_optimized", SqlType.Bit, null, false),
195+
new("is_filetable", SqlType.Bit, null, false),
196+
new("is_external", SqlType.Bit, null, false),
197+
new("is_node", SqlType.Bit, null, false),
198+
new("is_edge", SqlType.Bit, null, false),
199+
new("ledger_type", SqlType.TinyInt, null, false),
187200
], (batch, database) =>
188201
database.Schemas.Values
189202
.SelectMany(s => s.HeapTables.Values)
@@ -209,6 +222,12 @@ void Iso(string name, HeapColumn[] columns, Func<Parser.BatchContext, Database,
209222
tt,
210223
ttd,
211224
htid,
225+
falseTableFlag,
226+
falseTableFlag,
227+
falseTableFlag,
228+
falseTableFlag,
229+
falseTableFlag,
230+
ledgerTypeNone,
212231
};
213232
}));
214233

@@ -252,7 +271,14 @@ void Iso(string name, HeapColumn[] columns, Func<Parser.BatchContext, Database,
252271
var systemTypeId = SqlType.TinyInt;
253272
var defaultCollation = SqlValue.FromSystemName("SQL_Latin1_General_CP1_CI_AS");
254273
var nullCollation = SqlValue.Null(SqlType.SystemName);
255-
Sys("columns",
274+
// sys.columns / sys.all_columns share one shape. is_sparse ships as a
275+
// constant 0 (the simulator has no sparse-column storage); SMO's
276+
// Object-Explorer HasSparseColumn probe reads it off sys.all_columns.
277+
// sys.all_columns is user-object-parity with sys.columns here: real
278+
// SQL Server also surfaces system objects' negative-object_id columns,
279+
// but SMO correlates only on user tables' object_ids so the identical
280+
// user-column row set suffices. See docs/claude/catalog-views.md.
281+
HeapColumn[] ColumnsShape() =>
256282
[
257283
new("object_id", SqlType.Int32, null, false),
258284
new("name", SqlType.SystemName, 128, false),
@@ -266,8 +292,12 @@ void Iso(string name, HeapColumn[] columns, Func<Parser.BatchContext, Database,
266292
new("is_identity", SqlType.Bit, null, false),
267293
new("is_computed", SqlType.Bit, null, false),
268294
new("collation_name", SqlType.SystemName, 128, true),
269-
], (batch, database) =>
270-
EnumerateColumns(batch, database, defaultCollation, nullCollation));
295+
new("is_sparse", SqlType.Bit, null, true),
296+
];
297+
IEnumerable<SqlValue[]> ColumnRows(Parser.BatchContext batch, Database database) =>
298+
EnumerateColumns(batch, database, defaultCollation, nullCollation);
299+
Sys("columns", ColumnsShape(), ColumnRows);
300+
Sys("all_columns", ColumnsShape(), ColumnRows);
271301

272302
// sys.sql_modules: one row per programmable module (procedure / view /
273303
// DML + DDL trigger / scalar / inline / multi-statement function),
@@ -738,6 +768,40 @@ void Iso(string name, HeapColumn[] columns, Func<Parser.BatchContext, Database,
738768
new("statistics_incremental", SqlType.Bit, null, true),
739769
], EnumerateSysIndexes);
740770

771+
// sys.data_spaces: the simulator models a single PRIMARY row-filegroup
772+
// (data_space_id = 1) — the same id every sys.indexes row reports for
773+
// data_space_id, so SMO's LEFT JOIN idx.data_space_id → dsidx resolves.
774+
// Probe-confirmed shape (SQL Server 2025): name / data_space_id / type
775+
// char(2) / type_desc / is_default / is_system. 'FG' = ROWS_FILEGROUP;
776+
// partition schemes ('PS' — what SMO's IsPartitioned probe compares
777+
// against) aren't modeled, so type is always 'FG'. See
778+
// docs/claude/catalog-views.md.
779+
var filegroupType = SqlValue.FromChar(charTwo, "FG");
780+
var filegroupTypeDesc = SqlValue.FromNVarchar("ROWS_FILEGROUP");
781+
Sys("data_spaces",
782+
[
783+
new("name", SqlType.SystemName, 128, false),
784+
new("data_space_id", SqlType.Int32, null, false),
785+
new("type", charTwo, 2, false),
786+
new("type_desc", nvarchar60Catalog, 60, true),
787+
new("is_default", SqlType.Bit, null, true),
788+
new("is_system", SqlType.Bit, null, true),
789+
], (batch, database) =>
790+
{
791+
_ = (batch, database);
792+
return
793+
[
794+
[
795+
SqlValue.FromSystemName("PRIMARY"),
796+
SqlValue.FromInt32(1),
797+
filegroupType,
798+
filegroupTypeDesc,
799+
SqlValue.FromBoolean(true),
800+
SqlValue.FromBoolean(false),
801+
],
802+
];
803+
});
804+
741805
// sys.index_columns: probe-confirmed 10-column shape. One row per
742806
// (index, column) pair — KEY columns get key_ordinal = 1..N and
743807
// index_column_id = 1..N; INCLUDE columns get key_ordinal = 0 and
@@ -3892,6 +3956,7 @@ SqlValue CollationFor(HeapColumn c) =>
38923956
SqlValue.FromBoolean(col.Identity is not null),
38933957
SqlValue.FromBoolean(col.Computed is not null),
38943958
CollationFor(col),
3959+
falseBit,
38953960
];
38963961
}
38973962
}
@@ -3918,6 +3983,7 @@ SqlValue CollationFor(HeapColumn c) =>
39183983
falseBit,
39193984
falseBit,
39203985
CollationFor(col),
3986+
falseBit,
39213987
];
39223988
}
39233989
}
@@ -3944,6 +4010,7 @@ SqlValue CollationFor(HeapColumn c) =>
39444010
falseBit,
39454011
falseBit,
39464012
CollationFor(col),
4013+
falseBit,
39474014
];
39484015
}
39494016
}
@@ -3970,6 +4037,7 @@ SqlValue CollationFor(HeapColumn c) =>
39704037
SqlValue.FromBoolean(col.Identity is not null),
39714038
SqlValue.FromBoolean(col.Computed is not null),
39724039
CollationFor(col),
4040+
falseBit,
39734041
];
39744042
}
39754043
}

SqlServerSimulator/Parser/Expressions/QuoteName.cs

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,15 @@ public QuoteName(ParserContext context)
3636
public override SqlValue Run(RuntimeContext runtime)
3737
{
3838
var nameValue = this.name.Run(runtime);
39+
// The result carries the input argument's collation and coercibility
40+
// (real SQL Server propagates the operand's collation through string
41+
// functions). Pinning to the neutral SqlType.NVarchar instead would
42+
// give a coercible-default Baseline value that collides with a
43+
// database-default-collation literal under Msg 468 when the two are
44+
// concatenated (SMO's Urn: 'text' + QUOTENAME(sys-catalog sysname)).
45+
var resultType = ResultType(nameValue.Type);
3946
if (nameValue.IsNull)
40-
return SqlValue.Null(SqlType.NVarchar);
47+
return SqlValue.Null(resultType);
4148

4249
char open, close;
4350
if (this.delimiter is null)
@@ -48,34 +55,44 @@ public override SqlValue Run(RuntimeContext runtime)
4855
{
4956
var delimValue = this.delimiter.Run(runtime);
5057
if (delimValue.IsNull)
51-
return SqlValue.Null(SqlType.NVarchar);
58+
return SqlValue.Null(resultType);
5259

5360
// Multi-char delimiter argument: SQL Server picks the first character
5461
// (probe-confirmed: '<<' selects '<' which pairs with '>').
5562
var delimString = SqlType.IsStringCategory(delimValue.Type)
5663
? delimValue.AsString
5764
: delimValue.CoerceTo(SqlType.NVarchar).AsString;
5865
if (delimString.Length == 0)
59-
return SqlValue.Null(SqlType.NVarchar);
66+
return SqlValue.Null(resultType);
6067

6168
if (!TryResolveDelimiterPair(delimString[0], out open, out close))
62-
return SqlValue.Null(SqlType.NVarchar);
69+
return SqlValue.Null(resultType);
6370
}
6471

6572
var nameString = nameValue.AsString;
6673
if (nameString.Length > MaxInputLength)
67-
return SqlValue.Null(SqlType.NVarchar);
74+
return SqlValue.Null(resultType);
6875

6976
// Closing-character doubling: only matters when open != close; when
7077
// they're identical (e.g. " or '), the same character gets doubled
7178
// anyway, so the same code path is correct without branching.
7279
var doubled = nameString.Contains(close, StringComparison.Ordinal)
7380
? nameString.Replace(close.ToString(), $"{close}{close}", StringComparison.Ordinal)
7481
: nameString;
75-
return SqlValue.FromNVarchar($"{open}{doubled}{close}");
82+
return SqlValue.FromNVarchar(resultType, $"{open}{doubled}{close}");
7683
}
7784

78-
public override SqlType GetSqlType(BatchContext batch, Func<MultiPartName, SqlType> resolveColumnType) => SqlType.NVarchar;
85+
public override SqlType GetSqlType(BatchContext batch, Func<MultiPartName, SqlType> resolveColumnType) =>
86+
ResultType(this.name.GetSqlType(batch, resolveColumnType));
87+
88+
/// <summary>
89+
/// <c>nvarchar(258)</c> carrying the input argument's collation and
90+
/// coercibility (Baseline / coercible-default for non-string inputs).
91+
/// </summary>
92+
private static NVarcharSqlType ResultType(SqlType inputType) =>
93+
NVarcharSqlType.Get(ResultLength, inputType.Collation ?? Collation.Baseline, inputType.Coercibility);
94+
95+
private const int ResultLength = 258;
7996

8097
/// <summary>
8198
/// Maps the user-supplied delimiter character to the <c>(open, close)</c>

0 commit comments

Comments
 (0)