Skip to content

Commit 5fda360

Browse files
committed
SCHEMA_NAME / OBJECT_NAME / OBJECT_SCHEMA_NAME scalars — the int→name inverses of SCHEMA_ID / OBJECT_ID, paired with a CLAUDE.md cleanup of a misleading "Not modeled" entry. SCHEMA_NAME([id]) walks Database.Schemas.Values by SchemaId; no-arg form returns Database.DefaultSchemaName ("dbo") matching real SQL Server's "default schema for current user" semantic (single-principal simulator); NULL / missing / negative id → NULL; result sysname (nvarchar(128)). OBJECT_NAME(object_id [, database_id]) walks every Schema.SchemaObjects() (heap tables / views / functions / procedures / sequences / triggers — the shared object namespace) plus Schema.TableTypes for the matching ObjectId; returns the object's leaf Name. OBJECT_SCHEMA_NAME(object_id [, database_id]) is the same lookup walk projecting the owning Schema.Name instead. Both optional database_id args are parsed-and-ignored (single-database simulator; real SQL Server uses the arg to scope across attached DBs). All three functions dispatch through Expression.ResolveBuiltIn's name-length switch: SCHEMA_NAME / OBJECT_NAME in the 11-char bucket, OBJECT_SCHEMA_NAME in the 18-char bucket. 20 new SchemaNameTests cover SCHEMA_NAME against the three built-in schemas (dbo / sys / INFORMATION_SCHEMA), user-schema round-trip through SCHEMA_ID, no-arg / NULL / missing / negative; OBJECT_NAME against tables / views / table-types (via type_table_object_id from sys.table_types since sys.objects doesn't yet surface TT-rows); OBJECT_SCHEMA_NAME against default-schema and named-schema tables; both with the ignored db_id arg form. Bonus cleanup: removed "Row-constructor IN ((1,2), (3,4))" from CLAUDE.md "Not modeled" — probe-confirmed real SQL Server 2025 rejects that syntax with Msg 4145 ("An expression of non-boolean type … near ','."), and the simulator already raises the same Msg with identical wording (it was never a missing feature, just a misleading doc entry). New RowConstructorIn_RejectedWithMatchingMsg4145 test pins the rejection wording. docs/claude/schemas.md gains an "Id → name scalars" section documenting all three functions plus the sys.objects-doesn't-surface-TableTypes gap (OBJECT_NAME / OBJECT_SCHEMA_NAME resolve TableTypes internally via Schema.TableTypes, so sys.table_types lookup works; sys.objects WHERE type = 'TT' returns empty — orthogonal BuiltInResources change).
1 parent d968deb commit 5fda360

7 files changed

Lines changed: 284 additions & 6 deletions

File tree

CLAUDE.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,6 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
157157
- `RIGHT JOIN` / `FULL OUTER JOIN` with a derived-table or lateral right side — base tables / views / system tables on the right ship (see JOINs / APPLY); a derived-table right (always-deferred in this simulator) raises `NotSupportedException` since real SQL Server's Msg 4104 rejection of correlated subqueries on the right of RIGHT/FULL can't be statically distinguished from non-correlated ones.
158158
- Comma-separated FROM (legacy ANSI-89 join syntax).
159159
- `ANY` / `SOME` / `ALL` quantifiers.
160-
- Row-constructor `IN ((1,2), (3,4))`.
161160
- `RANGE BETWEEN <N> PRECEDING` / `<N> FOLLOWING` — real SQL Server gates the numeric-offset RANGE form behind a separately-licensed feature surface and the simulator matches that rejection (Msg 4194). `ROWS` numeric-offset frames ship; both modes support the canonical `UNBOUNDED` / `CURRENT ROW` bounds and the single-bound shorthand (`ROWS UNBOUNDED PRECEDING``ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`). The default frame when ORDER BY is present in OVER is `RANGE UNBOUNDED PRECEDING TO CURRENT ROW` (running-total semantic with peer-tie grouping); without ORDER BY, default frame is whole partition. LAST_VALUE ships with the same semantics as real SQL Server (its default frame returns the current row's value or the peer-tie last under RANGE — the intuitive "partition last" form needs explicit `ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING`).
162161
- Recursive-part feature restrictions (Msg 460 DISTINCT / 461 TOP / 462 OUTER JOIN / 467 aggregate-or-GROUP-BY / 465 ref-in-subquery) — silently accepted with possibly-incorrect semantics rather than raising. Apps that exercise these in real SQL Server hit rejection there too.
163162
- `LIKE` with `COLLATE` override (default collation only).
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Tests for the <c>SCHEMA_NAME([id])</c>, <c>OBJECT_NAME(object_id [, db_id])</c>,
7+
/// and <c>OBJECT_SCHEMA_NAME(object_id [, db_id])</c> scalar functions — the
8+
/// id-to-name inverses of <c>SCHEMA_ID</c> / <c>OBJECT_ID</c>. Probed against
9+
/// SQL Server 2025 (2026-05-13): result type is <c>sysname</c>; NULL argument
10+
/// or unknown id returns NULL; <c>SCHEMA_NAME()</c> no-arg form returns the
11+
/// caller's default schema (<c>dbo</c>); the optional <c>database_id</c>
12+
/// argument on <c>OBJECT_*</c> is accepted-and-ignored (single-database
13+
/// simulator).
14+
/// </summary>
15+
[TestClass]
16+
public sealed class SchemaNameTests
17+
{
18+
[TestMethod]
19+
public void SchemaName_Dbo_ReturnsDbo()
20+
=> AreEqual("dbo", new Simulation().ExecuteScalar("select schema_name(1)"));
21+
22+
[TestMethod]
23+
public void SchemaName_Sys_ReturnsSys()
24+
=> AreEqual("sys", new Simulation().ExecuteScalar("select schema_name(4)"));
25+
26+
[TestMethod]
27+
public void SchemaName_InformationSchema_ReturnsInformationSchema()
28+
=> AreEqual("INFORMATION_SCHEMA", new Simulation().ExecuteScalar("select schema_name(3)"));
29+
30+
[TestMethod]
31+
public void SchemaName_UserSchema_RoundTripsThroughSchemaId()
32+
=> AreEqual("audit", new Simulation().ExecuteScalar("create schema audit; select schema_name(schema_id('audit'))"));
33+
34+
[TestMethod]
35+
public void SchemaName_NoArg_ReturnsDbo()
36+
=> AreEqual("dbo", new Simulation().ExecuteScalar("select schema_name()"));
37+
38+
[TestMethod]
39+
public void SchemaName_NullArg_ReturnsNull()
40+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select schema_name(NULL)"));
41+
42+
[TestMethod]
43+
public void SchemaName_MissingId_ReturnsNull()
44+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select schema_name(99999)"));
45+
46+
[TestMethod]
47+
public void SchemaName_NegativeId_ReturnsNull()
48+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select schema_name(-1)"));
49+
50+
[TestMethod]
51+
public void ObjectName_ExistingTable_ReturnsLeafName()
52+
=> AreEqual("foo", new Simulation().ExecuteScalar("create table foo (id int); select object_name(object_id('foo'))"));
53+
54+
[TestMethod]
55+
public void ObjectName_NullArg_ReturnsNull()
56+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select object_name(NULL)"));
57+
58+
[TestMethod]
59+
public void ObjectName_MissingId_ReturnsNull()
60+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select object_name(99999)"));
61+
62+
[TestMethod]
63+
public void ObjectName_WithDbIdArg_IgnoresArg()
64+
=> AreEqual("foo", new Simulation().ExecuteScalar("create table foo (id int); select object_name(object_id('foo'), 1)"));
65+
66+
[TestMethod]
67+
public void ObjectName_View_Works()
68+
=> AreEqual("v", new Simulation().ExecuteScalar("create table t (id int); create view v as select id from t; select object_name(object_id('v'))"));
69+
70+
[TestMethod]
71+
public void ObjectName_TableType_ResolvableViaTypeTableObjectId()
72+
=> AreEqual("MyType", new Simulation().ExecuteScalar("create type MyType as table (id int); select object_name(type_table_object_id) from sys.table_types where name = 'MyType'"));
73+
74+
[TestMethod]
75+
public void ObjectSchemaName_DefaultSchemaTable_ReturnsDbo()
76+
=> AreEqual("dbo", new Simulation().ExecuteScalar("create table foo (id int); select object_schema_name(object_id('foo'))"));
77+
78+
[TestMethod]
79+
public void ObjectSchemaName_QualifiedSchemaTable_ReturnsSchema()
80+
=> AreEqual("audit", new Simulation().ExecuteScalar("create schema audit; create table audit.t (id int); select object_schema_name(object_id('audit.t'))"));
81+
82+
[TestMethod]
83+
public void ObjectSchemaName_NullArg_ReturnsNull()
84+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select object_schema_name(NULL)"));
85+
86+
[TestMethod]
87+
public void ObjectSchemaName_MissingId_ReturnsNull()
88+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select object_schema_name(99999)"));
89+
90+
[TestMethod]
91+
public void ObjectSchemaName_WithDbIdArg_IgnoresArg()
92+
=> AreEqual("dbo", new Simulation().ExecuteScalar("create table foo (id int); select object_schema_name(object_id('foo'), 1)"));
93+
94+
[TestMethod]
95+
public void RowConstructorIn_RejectedWithMatchingMsg4145()
96+
=> new Simulation().AssertSqlError(
97+
"create table t (a int, b int); select * from t where (a, b) in ((1, 2))",
98+
4145,
99+
"An expression of non-boolean type specified in a context where a condition is expected, near ','.");
100+
}

SqlServerSimulator/Parser/Expression.cs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,8 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
537537
"ERROR_STATE" => new ErrorStateFunction(context),
538538
"FIRST_VALUE" => WindowExpression.ParseFirstValue(context),
539539
"JSON_MODIFY" => new JsonModify(context),
540+
"OBJECT_NAME" => new ObjectName(context),
541+
"SCHEMA_NAME" => new SchemaName(context),
540542
"SYSDATETIME" => new CurrentTimeFunction(context, CurrentTimeKind.SysDateTime),
541543
"TRY_CONVERT" => new ConvertExpression(context, tryMode: true),
542544
_ => null
@@ -579,6 +581,12 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
579581
18 => uppercaseName switch
580582
{
581583
"DATETIME2FROMPARTS" => new DatePartsBuilder(context, DatePartsBuilderKind.DateTime2FromParts),
584+
"OBJECT_SCHEMA_NAME" => new ObjectSchemaName(context),
585+
_ => null
586+
},
587+
21 => uppercaseName switch
588+
{
589+
"APPROX_COUNT_DISTINCT" => AggregateExpression.Parse(context, AggregateKind.ApproxCountDistinct),
582590
_ => null
583591
},
584592
22 => uppercaseName switch
@@ -591,11 +599,6 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
591599
"DATETIMEOFFSETFROMPARTS" => new DatePartsBuilder(context, DatePartsBuilderKind.DateTimeOffsetFromParts),
592600
_ => null
593601
},
594-
21 => uppercaseName switch
595-
{
596-
"APPROX_COUNT_DISTINCT" => AggregateExpression.Parse(context, AggregateKind.ApproxCountDistinct),
597-
_ => null
598-
},
599602
_ => (Expression?)null
600603
} ?? throw SimulatedSqlException.UnrecognizedBuiltInFunction(name);
601604
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using SqlServerSimulator.Storage;
2+
3+
namespace SqlServerSimulator.Parser.Expressions;
4+
5+
/// <summary>
6+
/// SQL <c>OBJECT_NAME(object_id [, database_id])</c>: returns the leaf
7+
/// name of the object with the given <c>object_id</c>, or NULL when not
8+
/// found. The optional database-id argument is accepted and ignored —
9+
/// the simulator hosts one database per <see cref="Simulation"/> instance.
10+
/// Walks <see cref="Database.Schemas"/> → <see cref="Schema.SchemaObjects"/>
11+
/// (the shared object-name namespace: tables / views / functions /
12+
/// procedures / sequences / triggers) plus <see cref="Schema.TableTypes"/>.
13+
/// Probe-confirmed against SQL Server 2025 (2026-05-13): NULL argument
14+
/// returns NULL; missing / negative id returns NULL; result type is
15+
/// <see cref="SqlType.SystemName"/>.
16+
/// </summary>
17+
internal sealed class ObjectName : Expression
18+
{
19+
private readonly Expression idArg;
20+
21+
public ObjectName(ParserContext context)
22+
{
23+
this.idArg = Parse(context);
24+
if (context.Token is Tokens.Operator { Character: ',' })
25+
{
26+
// database_id arg: accepted and ignored (single-DB simulator).
27+
_ = Parse(context.MoveNextRequiredReturnSelf());
28+
if (context.Token is Tokens.Operator { Character: ',' })
29+
throw SimulatedSqlException.FunctionRequiresNArguments("object_name", 2);
30+
}
31+
if (context.Token is not Tokens.Operator { Character: ')' })
32+
throw SimulatedSqlException.SyntaxErrorNear(context);
33+
}
34+
35+
public override SqlValue Run(RuntimeContext runtime)
36+
{
37+
var idValue = this.idArg.Run(runtime);
38+
if (idValue.IsNull)
39+
return SqlValue.Null(SqlType.SystemName);
40+
var id = idValue.CoerceTo(SqlType.Int32).AsInt32;
41+
foreach (var schema in runtime.Batch.CurrentDatabase.Schemas.Values)
42+
{
43+
foreach (var obj in schema.SchemaObjects())
44+
{
45+
if (obj.ObjectId == id)
46+
return SqlValue.FromString(SqlType.SystemName, obj.Name);
47+
}
48+
foreach (var tableType in schema.TableTypes.Values)
49+
{
50+
if (tableType.ObjectId == id)
51+
return SqlValue.FromString(SqlType.SystemName, tableType.Name);
52+
}
53+
}
54+
return SqlValue.Null(SqlType.SystemName);
55+
}
56+
57+
public override SqlType GetSqlType(Func<MultiPartName, SqlType> resolveColumnType) => SqlType.SystemName;
58+
59+
internal override string DebugDisplay() => $"OBJECT_NAME({this.idArg.DebugDisplay()})";
60+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
using SqlServerSimulator.Storage;
2+
3+
namespace SqlServerSimulator.Parser.Expressions;
4+
5+
/// <summary>
6+
/// SQL <c>OBJECT_SCHEMA_NAME(object_id [, database_id])</c>: returns the
7+
/// name of the schema that owns the object with the given <c>object_id</c>,
8+
/// or NULL when not found. The optional database-id argument is accepted
9+
/// and ignored (single-database simulator). Companion to
10+
/// <see cref="ObjectName"/> — same lookup walk over
11+
/// <see cref="Database.Schemas"/> → <see cref="Schema.SchemaObjects"/>
12+
/// plus <see cref="Schema.TableTypes"/>; this variant returns the owning
13+
/// schema's <see cref="Schema.Name"/> instead of the object's leaf.
14+
/// Probe-confirmed against SQL Server 2025 (2026-05-13).
15+
/// </summary>
16+
internal sealed class ObjectSchemaName : Expression
17+
{
18+
private readonly Expression idArg;
19+
20+
public ObjectSchemaName(ParserContext context)
21+
{
22+
this.idArg = Parse(context);
23+
if (context.Token is Tokens.Operator { Character: ',' })
24+
{
25+
// database_id arg: accepted and ignored (single-DB simulator).
26+
_ = Parse(context.MoveNextRequiredReturnSelf());
27+
if (context.Token is Tokens.Operator { Character: ',' })
28+
throw SimulatedSqlException.FunctionRequiresNArguments("object_schema_name", 2);
29+
}
30+
if (context.Token is not Tokens.Operator { Character: ')' })
31+
throw SimulatedSqlException.SyntaxErrorNear(context);
32+
}
33+
34+
public override SqlValue Run(RuntimeContext runtime)
35+
{
36+
var idValue = this.idArg.Run(runtime);
37+
if (idValue.IsNull)
38+
return SqlValue.Null(SqlType.SystemName);
39+
var id = idValue.CoerceTo(SqlType.Int32).AsInt32;
40+
foreach (var schema in runtime.Batch.CurrentDatabase.Schemas.Values)
41+
{
42+
foreach (var obj in schema.SchemaObjects())
43+
{
44+
if (obj.ObjectId == id)
45+
return SqlValue.FromString(SqlType.SystemName, schema.Name);
46+
}
47+
foreach (var tableType in schema.TableTypes.Values)
48+
{
49+
if (tableType.ObjectId == id)
50+
return SqlValue.FromString(SqlType.SystemName, schema.Name);
51+
}
52+
}
53+
return SqlValue.Null(SqlType.SystemName);
54+
}
55+
56+
public override SqlType GetSqlType(Func<MultiPartName, SqlType> resolveColumnType) => SqlType.SystemName;
57+
58+
internal override string DebugDisplay() => $"OBJECT_SCHEMA_NAME({this.idArg.DebugDisplay()})";
59+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using SqlServerSimulator.Storage;
2+
3+
namespace SqlServerSimulator.Parser.Expressions;
4+
5+
/// <summary>
6+
/// SQL <c>SCHEMA_NAME([id])</c>: returns the name of the schema with the
7+
/// given <c>schema_id</c>, or the caller's default schema name (<c>dbo</c>)
8+
/// when called with no argument. Probe-confirmed against SQL Server 2025
9+
/// (2026-05-13): no-arg returns <c>dbo</c> for the user the simulator
10+
/// emulates; a non-existent or negative id returns NULL; a NULL argument
11+
/// returns NULL. Result type is <see cref="SqlType.SystemName"/> (sysname /
12+
/// nvarchar(128)) — mirrors <see cref="SchemaId"/>'s int-result inverse.
13+
/// </summary>
14+
internal sealed class SchemaName : Expression
15+
{
16+
private readonly Expression? idArg;
17+
18+
public SchemaName(ParserContext context)
19+
{
20+
if (context.Token is Tokens.Operator { Character: ')' })
21+
return;
22+
this.idArg = Parse(context);
23+
if (context.Token is not Tokens.Operator { Character: ')' })
24+
throw SimulatedSqlException.SyntaxErrorNear(context);
25+
}
26+
27+
public override SqlValue Run(RuntimeContext runtime)
28+
{
29+
if (this.idArg is null)
30+
return SqlValue.FromString(SqlType.SystemName, Database.DefaultSchemaName);
31+
var idValue = this.idArg.Run(runtime);
32+
if (idValue.IsNull)
33+
return SqlValue.Null(SqlType.SystemName);
34+
var id = idValue.CoerceTo(SqlType.Int32).AsInt32;
35+
foreach (var schema in runtime.Batch.CurrentDatabase.Schemas.Values)
36+
{
37+
if (schema.SchemaId == id)
38+
return SqlValue.FromString(SqlType.SystemName, schema.Name);
39+
}
40+
return SqlValue.Null(SqlType.SystemName);
41+
}
42+
43+
public override SqlType GetSqlType(Func<MultiPartName, SqlType> resolveColumnType) => SqlType.SystemName;
44+
45+
internal override string DebugDisplay() =>
46+
this.idArg is null ? "SCHEMA_NAME()" : $"SCHEMA_NAME({this.idArg.DebugDisplay()})";
47+
}

docs/claude/schemas.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,13 @@ Every `HeapTable` carries a stable per-database `int ObjectId` assigned at CREAT
4040
- **Temp-table divergence**: `OBJECT_ID('#foo')` resolves the session's `#foo` directly because `BatchContext.TryResolveTable` routes `#` leaves to the connection's temp dict regardless of qualifier. Real SQL Server requires the explicit `tempdb..#foo` three-part form (since unqualified resolution targets the current DB, not tempdb). The simulator's existing temp-routing simplification carries through; `OBJECT_ID('tempdb..#foo')` also works (probe-confirmed real behavior).
4141
- **Bracket-handling fidelity gap**: the runtime-string name parser strips bracket pairs at segment level (`'[dbo].[foo]'``dbo`+`foo`) and decodes `]]``]` inside brackets — but bracketed segments containing a literal `.` (`'[a.b].[c]'`, the literal-dot case) don't parse correctly (split on `.` happens before bracket-aware tokenization). Rare in practice; revisit if a real app hits it.
4242
- **Arity**: too-few-args (`OBJECT_ID()`) currently surfaces as Msg 102 (the inner Parse failure path) rather than Msg 174 — same pattern as other built-ins; the simulator doesn't enforce min-args. Too-many-args raises Msg 174 verbatim.
43+
44+
## Id → name scalars
45+
46+
**`SCHEMA_NAME([id])`** (`Parser/Expressions/SchemaName.cs`): the `int → name` inverse of `SCHEMA_ID`. With an int `schema_id` argument, walks `Database.Schemas.Values` for the matching `Schema.SchemaId` and returns its `Name`. No-arg returns `Database.DefaultSchemaName` (`"dbo"`) — matches real SQL Server's "default schema for the current user" behavior (single-principal simulator). NULL arg / missing id / negative id → NULL. Result type: `sysname` (nvarchar(128)).
47+
48+
**`OBJECT_NAME(object_id [, database_id])`** (`Parser/Expressions/ObjectName.cs`): walks every `Schema.SchemaObjects()` (the shared object-name namespace: heap tables / views / functions / procedures / sequences / triggers) plus `Schema.TableTypes` and returns the matching object's leaf `Name`. The optional `database_id` argument is parsed and ignored (single-database simulator — real SQL Server uses it to scope across attached DBs). NULL arg / missing id → NULL. Result type: `sysname`.
49+
50+
**`OBJECT_SCHEMA_NAME(object_id [, database_id])`** (`Parser/Expressions/ObjectSchemaName.cs`): same lookup walk as `OBJECT_NAME`; returns the owning `Schema.Name` instead of the object leaf. Same NULL / ignored-db_id semantics. Result type: `sysname`.
51+
52+
- **TableType / sys.objects gap**: `OBJECT_NAME` and `OBJECT_SCHEMA_NAME` both resolve table-type objects through `Schema.TableTypes` directly, so `SELECT OBJECT_NAME(type_table_object_id) FROM sys.table_types` works. `sys.objects` itself doesn't currently surface TT-rows (separate namespace per the `SchemaObjects()` enumerator's design), so the same lookup via `sys.objects WHERE type = 'TT'` returns empty. Closing the gap is a `BuiltInResources` change, not a scalar change.

0 commit comments

Comments
 (0)