Skip to content

Commit 86374e1

Browse files
committed
Implemented INDEX_COL, INDEXKEY_PROPERTY, STATS_DATE.
1 parent b48d79e commit 86374e1

10 files changed

Lines changed: 632 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
149149
- **CTE shapes / recursive-CTE error handling**[`ctes.md`](docs/claude/ctes.md).
150150
- **JSON_VALUE / JSON_QUERY / JSON_MODIFY / JSON_OBJECT / JSON_ARRAY / JSON_PATH_EXISTS / ISJSON / OPENJSON**[`json.md`](docs/claude/json.md).
151151
- **Name resolution, schema lookup, CREATE / DROP / ALTER SCHEMA TRANSFER, `OBJECT_ID` / `OBJECT_NAME` / `OBJECT_SCHEMA_NAME` / `SCHEMA_ID` / `SCHEMA_NAME` / `DB_ID` / `DB_NAME`, cross-DB read routing**[`schemas.md`](docs/claude/schemas.md).
152-
- **System metadata surfaces** (sys.* / INFORMATION_SCHEMA.*, function-form lookups: `OBJECTPROPERTY` / `OBJECTPROPERTYEX` / `COLUMNPROPERTY` / `INDEXPROPERTY` / `TYPEPROPERTY` / `SERVERPROPERTY` / `COL_LENGTH` / `COL_NAME` / `TYPE_NAME` / `TYPE_ID` / `PARSENAME`) → [`catalog-views.md`](docs/claude/catalog-views.md).
152+
- **System metadata surfaces** (sys.* / INFORMATION_SCHEMA.*, function-form lookups: `OBJECTPROPERTY` / `OBJECTPROPERTYEX` / `COLUMNPROPERTY` / `INDEXPROPERTY` / `INDEX_COL` / `INDEXKEY_PROPERTY` / `STATS_DATE` / `TYPEPROPERTY` / `SERVERPROPERTY` / `COL_LENGTH` / `COL_NAME` / `TYPE_NAME` / `TYPE_ID` / `PARSENAME`) → [`catalog-views.md`](docs/claude/catalog-views.md).
153153
- **Scalar UDFs / TVFs / views / stored procs / dynamic SQL / `@@NESTLEVEL` / `@@PROCID`**[`programmable.md`](docs/claude/programmable.md).
154154
- **`#foo` / `##foo` routing, DROP TABLE, TRUNCATE TABLE**[`temp-tables.md`](docs/claude/temp-tables.md).
155155
- **`DECLARE @t TABLE`, table-variable DML, `OUTPUT … INTO`**[`table-variables.md`](docs/claude/table-variables.md).
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Behavioral tests for <c>INDEX_COL</c> / <c>INDEXKEY_PROPERTY</c> /
7+
/// <c>STATS_DATE</c>. Index_id resolution follows the same emission order
8+
/// as <c>sys.indexes</c> (PK=1, then UQ/named indexes sorted by ObjectId).
9+
/// Behaviors are probe-confirmed against SQL Server 2025 (2026-05-23);
10+
/// <c>STATS_DATE</c> intentionally diverges from real (which returns NULL
11+
/// when no auto-stats has run) — the simulator returns the table's
12+
/// <c>CreateDate</c> as a fake-but-realistic placeholder.
13+
/// </summary>
14+
[TestClass]
15+
public sealed class IndexIntrospectionTests
16+
{
17+
// === INDEX_COL ===
18+
19+
[TestMethod]
20+
public void IndexCol_PK_FirstKeyColumn()
21+
=> AreEqual("id", new Simulation().ExecuteScalar(
22+
"create table t (id int primary key, name varchar(50)); " +
23+
"select index_col('t', 1, 1)"));
24+
25+
[TestMethod]
26+
public void IndexCol_PK_OutOfRangeKeyId_Null()
27+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
28+
"create table t (id int primary key); " +
29+
"select index_col('t', 1, 2)"));
30+
31+
[TestMethod]
32+
public void IndexCol_NamedIndex_FirstColumn()
33+
=> AreEqual("name", new Simulation().ExecuteScalar(
34+
"create table t (id int primary key, name varchar(50)); " +
35+
"create index ix_name on t(name); " +
36+
"select index_col('t', 2, 1)"));
37+
38+
[TestMethod]
39+
public void IndexCol_CompositeIndex_BothColumns()
40+
{
41+
var sim = new Simulation();
42+
_ = sim.ExecuteNonQuery(
43+
"create table t (id int primary key, a int, b int); " +
44+
"create index ix_ab on t(a, b)");
45+
AreEqual("a", sim.ExecuteScalar("select index_col('t', 2, 1)"));
46+
AreEqual("b", sim.ExecuteScalar("select index_col('t', 2, 2)"));
47+
}
48+
49+
[TestMethod]
50+
public void IndexCol_IncludeColumn_NotReachable()
51+
{
52+
// INCLUDE columns appear in sys.index_columns but INDEX_COL returns
53+
// NULL for them (key positions only). Probe-confirmed.
54+
var sim = new Simulation();
55+
_ = sim.ExecuteNonQuery(
56+
"create table t (id int primary key, a int, b int, c int); " +
57+
"create index ix_a on t(a) include (b, c)");
58+
AreEqual("a", sim.ExecuteScalar("select index_col('t', 2, 1)"));
59+
AreEqual(DBNull.Value, sim.ExecuteScalar("select index_col('t', 2, 2)"));
60+
}
61+
62+
[TestMethod]
63+
public void IndexCol_UnknownIndexId_Null()
64+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
65+
"create table t (id int); " +
66+
"select index_col('t', 99, 1)"));
67+
68+
[TestMethod]
69+
public void IndexCol_UnknownTable_Null()
70+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
71+
"select index_col('no_such_table', 1, 1)"));
72+
73+
[TestMethod]
74+
public void IndexCol_NullTable_Null()
75+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select index_col(null, 1, 1)"));
76+
77+
[TestMethod]
78+
public void IndexCol_NullIndexId_Null()
79+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
80+
"create table t (id int primary key); " +
81+
"select index_col('t', null, 1)"));
82+
83+
[TestMethod]
84+
public void IndexCol_NullKeyId_Null()
85+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
86+
"create table t (id int primary key); " +
87+
"select index_col('t', 1, null)"));
88+
89+
[TestMethod]
90+
public void IndexCol_KeyIdZero_Null()
91+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
92+
"create table t (id int primary key); " +
93+
"select index_col('t', 1, 0)"));
94+
95+
[TestMethod]
96+
public void IndexCol_KeyIdNegative_Null()
97+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
98+
"create table t (id int primary key); " +
99+
"select index_col('t', 1, -1)"));
100+
101+
[TestMethod]
102+
public void IndexCol_BracketedTableName()
103+
=> AreEqual("id", new Simulation().ExecuteScalar(
104+
"create table t (id int primary key); " +
105+
"select index_col('[dbo].[t]', 1, 1)"));
106+
107+
[TestMethod]
108+
public void IndexCol_SchemaQualified()
109+
=> AreEqual("id", new Simulation().ExecuteScalar(
110+
"create table t (id int primary key); " +
111+
"select index_col('dbo.t', 1, 1)"));
112+
113+
[TestMethod]
114+
public void IndexCol_DescendingKey_StillReturnsName()
115+
{
116+
// The DESC flag doesn't affect the returned column name.
117+
var sim = new Simulation();
118+
_ = sim.ExecuteNonQuery(
119+
"create table t (id int primary key, b varchar(20)); " +
120+
"create unique index ix_b on t(b desc)");
121+
AreEqual("b", sim.ExecuteScalar("select index_col('t', 2, 1)"));
122+
}
123+
124+
// === INDEXKEY_PROPERTY ===
125+
126+
[TestMethod]
127+
public void IndexKeyProperty_ColumnId_PK()
128+
=> AreEqual(1, new Simulation().ExecuteScalar(
129+
"create table t (id int primary key); " +
130+
"select indexkey_property(object_id('t'), 1, 1, 'ColumnId')"));
131+
132+
[TestMethod]
133+
public void IndexKeyProperty_ColumnId_SecondaryIndex()
134+
=> AreEqual(2, new Simulation().ExecuteScalar(
135+
"create table t (id int primary key, name varchar(50)); " +
136+
"create index ix on t(name); " +
137+
"select indexkey_property(object_id('t'), 2, 1, 'ColumnId')"));
138+
139+
[TestMethod]
140+
public void IndexKeyProperty_IsDescending_Ascending_Returns0()
141+
=> AreEqual(0, new Simulation().ExecuteScalar(
142+
"create table t (id int primary key, name varchar(50)); " +
143+
"create index ix on t(name); " +
144+
"select indexkey_property(object_id('t'), 2, 1, 'IsDescending')"));
145+
146+
[TestMethod]
147+
public void IndexKeyProperty_IsDescending_Descending_Returns1()
148+
=> AreEqual(1, new Simulation().ExecuteScalar(
149+
"create table t (id int primary key, name varchar(50)); " +
150+
"create index ix on t(name desc); " +
151+
"select indexkey_property(object_id('t'), 2, 1, 'IsDescending')"));
152+
153+
[TestMethod]
154+
public void IndexKeyProperty_IsDescending_PK_AlwaysZero()
155+
=> AreEqual(0, new Simulation().ExecuteScalar(
156+
"create table t (id int primary key); " +
157+
"select indexkey_property(object_id('t'), 1, 1, 'IsDescending')"));
158+
159+
[TestMethod]
160+
public void IndexKeyProperty_CompositeIndex_SecondKeyDescending()
161+
=> AreEqual(1, new Simulation().ExecuteScalar(
162+
"create table t (id int primary key, a int, b int); " +
163+
"create index ix on t(a, b desc); " +
164+
"select indexkey_property(object_id('t'), 2, 2, 'IsDescending')"));
165+
166+
[TestMethod]
167+
public void IndexKeyProperty_CompositeIndex_FirstKeyAscending()
168+
=> AreEqual(0, new Simulation().ExecuteScalar(
169+
"create table t (id int primary key, a int, b int); " +
170+
"create index ix on t(a, b desc); " +
171+
"select indexkey_property(object_id('t'), 2, 1, 'IsDescending')"));
172+
173+
[TestMethod]
174+
public void IndexKeyProperty_OutOfRangeKeyId_Null()
175+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
176+
"create table t (id int primary key, name varchar(50)); " +
177+
"create index ix on t(name); " +
178+
"select indexkey_property(object_id('t'), 2, 99, 'ColumnId')"));
179+
180+
[TestMethod]
181+
public void IndexKeyProperty_UnknownIndexId_Null()
182+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
183+
"create table t (id int); " +
184+
"select indexkey_property(object_id('t'), 99, 1, 'ColumnId')"));
185+
186+
[TestMethod]
187+
public void IndexKeyProperty_UnknownObjectId_Null()
188+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
189+
"select indexkey_property(99999, 1, 1, 'ColumnId')"));
190+
191+
[TestMethod]
192+
public void IndexKeyProperty_UnknownProperty_Null()
193+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
194+
"create table t (id int primary key); " +
195+
"select indexkey_property(object_id('t'), 1, 1, 'NotAProperty')"));
196+
197+
[TestMethod]
198+
public void IndexKeyProperty_NullObjectId_Null()
199+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
200+
"select indexkey_property(null, 1, 1, 'ColumnId')"));
201+
202+
[TestMethod]
203+
public void IndexKeyProperty_NullIndexId_Null()
204+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
205+
"create table t (id int primary key); " +
206+
"select indexkey_property(object_id('t'), null, 1, 'ColumnId')"));
207+
208+
[TestMethod]
209+
public void IndexKeyProperty_NullKeyId_Null()
210+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
211+
"create table t (id int primary key); " +
212+
"select indexkey_property(object_id('t'), 1, null, 'ColumnId')"));
213+
214+
[TestMethod]
215+
public void IndexKeyProperty_NullProperty_Null()
216+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
217+
"create table t (id int primary key); " +
218+
"select indexkey_property(object_id('t'), 1, 1, null)"));
219+
220+
[TestMethod]
221+
public void IndexKeyProperty_CaseInsensitiveProperty()
222+
=> AreEqual(1, new Simulation().ExecuteScalar(
223+
"create table t (id int primary key); " +
224+
"select indexkey_property(object_id('t'), 1, 1, 'columnid')"));
225+
226+
[TestMethod]
227+
public void IndexKeyProperty_IncludeColumn_NotReachable()
228+
{
229+
// INCLUDE columns appear in sys.index_columns but
230+
// INDEXKEY_PROPERTY returns NULL for key positions beyond the
231+
// actual key column count.
232+
var sim = new Simulation();
233+
_ = sim.ExecuteNonQuery(
234+
"create table t (id int primary key, a int, b int); " +
235+
"create index ix on t(a) include (b)");
236+
AreEqual(DBNull.Value, sim.ExecuteScalar(
237+
"select indexkey_property(object_id('t'), 2, 2, 'ColumnId')"));
238+
}
239+
240+
// === STATS_DATE ===
241+
242+
[TestMethod]
243+
public void StatsDate_PK_ReturnsNonNullDateTime()
244+
{
245+
var sim = new Simulation();
246+
_ = sim.ExecuteNonQuery("create table t (id int primary key)");
247+
var v = sim.ExecuteScalar("select stats_date(object_id('t'), 1)");
248+
_ = IsInstanceOfType<DateTime>(v);
249+
IsGreaterThan(new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Unspecified), (DateTime)v!);
250+
}
251+
252+
[TestMethod]
253+
public void StatsDate_NamedIndex_MatchesTableCreateDate()
254+
{
255+
var sim = new Simulation();
256+
_ = sim.ExecuteNonQuery(
257+
"create table t (id int primary key, a int); " +
258+
"create index ix on t(a)");
259+
var pk = (DateTime)sim.ExecuteScalar("select stats_date(object_id('t'), 1)")!;
260+
var ix = (DateTime)sim.ExecuteScalar("select stats_date(object_id('t'), 2)")!;
261+
AreEqual(pk, ix);
262+
}
263+
264+
[TestMethod]
265+
public void StatsDate_UnknownIndexId_Null()
266+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
267+
"create table t (id int); " +
268+
"select stats_date(object_id('t'), 99)"));
269+
270+
[TestMethod]
271+
public void StatsDate_UnknownObjectId_Null()
272+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
273+
"select stats_date(99999, 1)"));
274+
275+
[TestMethod]
276+
public void StatsDate_NullObjectId_Null()
277+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select stats_date(null, 1)"));
278+
279+
[TestMethod]
280+
public void StatsDate_NullStatsId_Null()
281+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
282+
"create table t (id int primary key); " +
283+
"select stats_date(object_id('t'), null)"));
284+
285+
[TestMethod]
286+
public void StatsDate_HeapTable_NoIndex_Returns_Null()
287+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar(
288+
"create table t (id int); " + // no PK = heap, index_id 0 is HEAP
289+
"select stats_date(object_id('t'), 1)"));
290+
}

SqlServerSimulator/Parser/Expression.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
663663
"CONCAT_WS" => new StringConcat(context, StringConcatKind.ConcatWs),
664664
"COUNT_BIG" => AggregateExpression.Parse(context, AggregateKind.CountBig),
665665
"HOST_NAME" => new HostName(context),
666+
"INDEX_COL" => new IndexCol(context),
666667
"ISNUMERIC" => new IsNumeric(context),
667668
"OBJECT_ID" => new ObjectId(context),
668669
"PARSENAME" => new ParseName(context),
@@ -693,6 +694,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
693694
"LAST_VALUE" => WindowExpression.ParseLastValue(context),
694695
"LEFT_SHIFT" => new BitShift(context, isLeftShift: true),
695696
"ROW_NUMBER" => WindowExpression.ParseRowNumber(context),
697+
"STATS_DATE" => new StatsDate(context),
696698
"STRING_AGG" => AggregateExpression.Parse(context, AggregateKind.StringAgg),
697699
"SUSER_NAME" => new SUserName(context, isSidVariant: false),
698700
"XACT_STATE" => new XactState(context),
@@ -767,6 +769,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
767769
{
768770
"DATETIMEFROMPARTS" => new DatePartsBuilder(context, DatePartsBuilderKind.DateTimeFromParts),
769771
"HAS_PERMS_BY_NAME" => new HasPermsByName(context),
772+
"INDEXKEY_PROPERTY" => new IndexKeyProperty(context),
770773
"SYSDATETIMEOFFSET" => new CurrentTimeFunction(context, CurrentTimeKind.SysDateTimeOffset),
771774
"TRIGGER_NESTLEVEL" => new TriggerNestLevelFunction(context),
772775
_ => null
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
using SqlServerSimulator.Storage;
2+
3+
namespace SqlServerSimulator.Parser.Expressions;
4+
5+
/// <summary>
6+
/// SQL <c>INDEX_COL('table', index_id, key_id)</c>: returns the
7+
/// <c>sysname</c> name of the key column at the <c>key_id</c>
8+
/// position (1-based) of the index identified by <c>index_id</c>.
9+
/// Returns NULL when the index doesn't exist, <c>key_id</c> is out of
10+
/// range, the <c>key_id</c> refers to an INCLUDE column (only key columns
11+
/// are reachable through this function), or any arg is NULL.
12+
/// Probe-confirmed wording / shape against SQL Server 2025 (2026-05-23).
13+
/// </summary>
14+
/// <remarks>
15+
/// The table-name argument follows the same dotted-string convention as
16+
/// <c>OBJECT_ID</c> — 1- to 4-part with optional bracket quoting,
17+
/// case-insensitive (reuses the same parser as <c>OBJECT_ID</c>).
18+
/// <c>index_id</c> resolution mirrors the
19+
/// <c>sys.indexes</c> emission order via <see cref="IndexLookup.ResolveByIndexId"/>.
20+
/// </remarks>
21+
internal sealed class IndexCol : Expression
22+
{
23+
private readonly Expression tableArg;
24+
private readonly Expression indexIdArg;
25+
private readonly Expression keyIdArg;
26+
27+
public IndexCol(ParserContext context)
28+
{
29+
this.tableArg = Parse(context);
30+
if (context.Token is not Tokens.Operator { Character: ',' })
31+
throw SimulatedSqlException.SyntaxErrorNear(context);
32+
this.indexIdArg = Parse(context.MoveNextRequiredReturnSelf());
33+
if (context.Token is not Tokens.Operator { Character: ',' })
34+
throw SimulatedSqlException.SyntaxErrorNear(context);
35+
this.keyIdArg = Parse(context.MoveNextRequiredReturnSelf());
36+
if (context.Token is not Tokens.Operator { Character: ')' })
37+
throw SimulatedSqlException.SyntaxErrorNear(context);
38+
}
39+
40+
public override SqlValue Run(RuntimeContext runtime)
41+
{
42+
var tableValue = this.tableArg.Run(runtime);
43+
var indexIdValue = this.indexIdArg.Run(runtime);
44+
var keyIdValue = this.keyIdArg.Run(runtime);
45+
if (tableValue.IsNull || indexIdValue.IsNull || keyIdValue.IsNull)
46+
return SqlValue.Null(SqlType.SystemName);
47+
48+
var tableName = tableValue.CoerceTo(SqlType.NVarchar).AsString;
49+
if (!ObjectId.TryParseObjectName(tableName, out var parsed)
50+
|| !runtime.Batch.TryResolveTable(parsed, out var table))
51+
{
52+
return SqlValue.Null(SqlType.SystemName);
53+
}
54+
55+
var indexId = indexIdValue.CoerceTo(SqlType.Int32).AsInt32;
56+
if (IndexLookup.ResolveByIndexId(table, indexId) is not { } resolved)
57+
return SqlValue.Null(SqlType.SystemName);
58+
59+
var keyId = keyIdValue.CoerceTo(SqlType.Int32).AsInt32;
60+
if (IndexLookup.GetKeyColumn(resolved.Constraint, resolved.Index, keyId) is not { } keyCol)
61+
return SqlValue.Null(SqlType.SystemName);
62+
63+
var columnId = IndexLookup.StorageOrdinalToColumnId(table, keyCol.StorageOrdinal);
64+
return columnId < 1 || columnId > table.Columns.Length
65+
? SqlValue.Null(SqlType.SystemName)
66+
: SqlValue.FromSystemName(table.Columns[columnId - 1].Name);
67+
}
68+
69+
public override SqlType GetSqlType(BatchContext batch, Func<MultiPartName, SqlType> resolveColumnType) => SqlType.SystemName;
70+
71+
internal override string DebugDisplay() =>
72+
$"INDEX_COL({this.tableArg.DebugDisplay()}, {this.indexIdArg.DebugDisplay()}, {this.keyIdArg.DebugDisplay()})";
73+
}

0 commit comments

Comments
 (0)