Skip to content

Commit 56da297

Browse files
committed
OBJECT_ID(name [, type]) scalar + stable per-database object identifiers. Every HeapTable carries an int ObjectId allocated at CREATE from a Database-scoped Interlocked counter (seeded at 100, bypasses rollback to match identity-counter behavior, fresh ID on drop-then-recreate — probe-confirmed). The function's name argument is a runtime string with bracket-aware multi-part parsing and routes through TryResolveTable; 'U' type filter matches user tables (case-insensitive, whitespace-sensitive per real SQL Server); NULL anywhere propagates; 4-part names return NULL. Fixed a latent bug in TryResolveSchema that would have let server.<currentdb>.dbo.t resolve by silently ignoring the server segment.
1 parent cee4327 commit 56da297

10 files changed

Lines changed: 497 additions & 9 deletions

File tree

CLAUDE.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,14 +399,24 @@ Probe-confirmed semantics (2026-05-11) the simulator handles correctly because e
399399

400400
- **Duplicate `CREATE SCHEMA`** (case-insensitive) → **Msg 2714** (`"There is already an object named '<n>' in the database."` — same factory as duplicate CREATE TABLE; SQL Server shares the namespace).
401401
- **Reserved schema names** (`dbo`, `sys`, `INFORMATION_SCHEMA`) → **Msg 2760** (`"The specified schema name \"<n>\" either does not exist or you do not have permission to use it."`). Wording is quirky for a CREATE (says "does not exist"), but probe-confirmed verbatim — real SQL Server resolves the principal first and these schemas tie to system principals.
402-
- **Three-part `db.schema.t`** validates the db segment against `CurrentDatabase.Name` (case-insensitive); mismatch → Msg 208. Empty middle segment (`tempdb..#foo`) is silently compressed by `ParseObjectName`, so a 2-part name pops out — preserves the existing DROP TABLE behavior for temp-table qualifiers.
402+
- **Three-part `db.schema.t`** validates the db segment against `CurrentDatabase.Name` (case-insensitive); mismatch → Msg 208. **Four-part `server.db.schema.t`** always returns false from `TryResolveSchema` (linked-server names aren't modeled — surfaces as Msg 208 / Msg 3701 / NULL from OBJECT_ID per callsite). Empty middle segment (`tempdb..#foo`) is silently compressed by `ParseObjectName`, so a 2-part name pops out — preserves the existing DROP TABLE behavior for temp-table qualifiers.
403403
- **`CREATE TABLE schema.t`** where `schema` doesn't exist → **Msg 2760** (target schema for the create must already exist). Distinct from FROM / INSERT / UPDATE / DELETE / MERGE / DROP / TRUNCATE access which use 208 / 3701 / 4701 respectively.
404404
- **`AUTHORIZATION owner`** and the embedded `<schema_element>` list (CREATE TABLE / VIEW / GRANT nested inside CREATE SCHEMA) aren't modeled — `AUTHORIZATION` raises `NotSupportedException`; trailing statement-starting tokens (CREATE / SELECT / INSERT / etc.) parse as their own statements in the same batch (deviates from real SQL Server's strict greedy-consume but reaches the same end state for the common idiom).
405405
- **No "first in batch" enforcement** — real SQL Server raises Msg 111 if CREATE SCHEMA isn't the first statement, tied to its greedy schema_element grammar. The simulator's dispatch already treats each statement as independent, so CREATE SCHEMA in any position works.
406406
- **`sys` schema isn't modeled**: `sys.schemas` / `sys.tables` etc. all raise Msg 208. System tables (`systypes` etc.) remain reachable only as bare 1-part names through `SystemHeapTables`.
407407
- **Error wording**: Msg 208 wraps the qualified name in single quotes (`Invalid object name 'badschema.t'.`); Msg 3701 (DROP) does the same; Msg 4701 (TRUNCATE) carries only the leaf (probe-confirmed asymmetric — distinct error path).
408408
- **DROP SCHEMA, ALTER SCHEMA TRANSFER, sys.schemas / INFORMATION_SCHEMA.SCHEMATA**: not modeled.
409409

410+
### Object identifiers + `OBJECT_ID()`
411+
Every `HeapTable` carries a stable per-database `int ObjectId` assigned at CREATE time from `Database.AllocateObjectId()` (a `Database`-scoped `Interlocked.Increment` counter seeded at 100). DROP-then-recreate yields a fresh id, matching real SQL Server (probe-confirmed 2026-05-11 — counter never reuses values). The counter bypasses transaction rollback: a rolled-back CREATE TABLE still consumed an id, matching the identity-counter rule. System tables (`SystemHeapTables`) carry a sentinel `ObjectId = -1` — they're process-shared, sit outside per-DB id space, and aren't reachable through `OBJECT_ID()` anyway. Backs `OBJECT_ID()` today; pre-positioned for the upcoming `sys.objects.object_id` catalog column.
412+
413+
**`OBJECT_ID(name [, type])`** scalar (`Parser/Expressions/ObjectId.cs`): returns the `int` ObjectId of the named object, or NULL when not found / wrong type / malformed name. The name is a runtime string parsed as a 1–3-part dotted identifier with bracket-quoting (`'[dbo].[foo]'`, `'dbo.foo'`, `'simulated.dbo.foo'` all resolve identically); 4-segment names return NULL (linked-server form unmodeled). The type filter is case-insensitive but whitespace-sensitive — `'U'` and `'u'` match user tables; `' U '`, `'XX'`, `''` all → NULL; other documented codes (`V`/`P`/`F`/`FN`/...) → NULL until those features land. A NULL on any argument propagates NULL.
414+
415+
- **Runtime-evaluated arguments**: `DECLARE @n nvarchar(100) = 'foo'; SELECT OBJECT_ID(@n)` works — both args are full `Expression`s.
416+
- **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).
417+
- **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.
418+
- **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.
419+
410420
### Local temp tables (`#foo`)
411421
Per-connection `Dictionary<string, HeapTable> TempTables` on `SimulatedDbConnection`; routed by `BatchContext.TryResolveTable` (`#`-prefix leaf → connection dict, ignoring any qualifier; else the named schema's heap-table dict + flat `SystemHeapTables`). Auto-cleared on `Dispose`, matching real SQL Server's session-close drop. Lifecycle, cross-conn isolation, and Msg 208 from other sessions all probe-confirmed against SQL Server 2025.
412422

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Tests for the <c>OBJECT_ID(name [, type])</c> scalar function. Returns the
8+
/// table's stable per-database int id (assigned at CREATE, fresh on
9+
/// DROP-then-recreate) or NULL when not found / wrong type / malformed name.
10+
/// Probed against SQL Server 2025 (2026-05-11).
11+
/// </summary>
12+
[TestClass]
13+
public sealed class ObjectIdTests
14+
{
15+
[TestMethod]
16+
public void ObjectId_ExistingTable_ReturnsInt()
17+
{
18+
using var reader = new Simulation().ExecuteReader("""
19+
create table foo (id int);
20+
select object_id('foo') as id
21+
""");
22+
IsTrue(reader.Read());
23+
AreEqual(typeof(int), reader.GetFieldType(0));
24+
IsFalse(reader.IsDBNull(0));
25+
}
26+
27+
[TestMethod]
28+
public void ObjectId_MissingTable_ReturnsNull()
29+
{
30+
using var reader = new Simulation().ExecuteReader("select object_id('nope') as id");
31+
IsTrue(reader.Read());
32+
IsTrue(reader.IsDBNull(0));
33+
}
34+
35+
[TestMethod]
36+
public void ObjectId_NullName_ReturnsNull()
37+
{
38+
using var reader = new Simulation().ExecuteReader("select object_id(null) as id");
39+
IsTrue(reader.Read());
40+
IsTrue(reader.IsDBNull(0));
41+
}
42+
43+
[TestMethod]
44+
public void ObjectId_NullType_ReturnsNull()
45+
{
46+
// Probe-confirmed: a NULL type filter propagates NULL even when the
47+
// table exists — real SQL Server treats it as "no match".
48+
using var reader = new Simulation().ExecuteReader("""
49+
create table foo (id int);
50+
select object_id('foo', null) as id
51+
""");
52+
IsTrue(reader.Read());
53+
IsTrue(reader.IsDBNull(0));
54+
}
55+
56+
[TestMethod]
57+
public void ObjectId_UserType_Matches()
58+
=> IsFalseDbNull(new Simulation().ExecuteReader("""
59+
create table foo (id int);
60+
select object_id('foo', 'U') as id
61+
"""));
62+
63+
[TestMethod]
64+
public void ObjectId_UserTypeLowercase_Matches()
65+
=> IsFalseDbNull(new Simulation().ExecuteReader("""
66+
create table foo (id int);
67+
select object_id('foo', 'u') as id
68+
"""));
69+
70+
[TestMethod]
71+
public void ObjectId_TypeWithWhitespace_ReturnsNull()
72+
{
73+
// Probe-confirmed: real SQL Server is whitespace-sensitive on the
74+
// type filter; ' U ' returns NULL.
75+
using var reader = new Simulation().ExecuteReader("""
76+
create table foo (id int);
77+
select object_id('foo', ' U ') as id
78+
""");
79+
IsTrue(reader.Read());
80+
IsTrue(reader.IsDBNull(0));
81+
}
82+
83+
[TestMethod]
84+
public void ObjectId_WrongType_ReturnsNull()
85+
{
86+
using var reader = new Simulation().ExecuteReader("""
87+
create table foo (id int);
88+
select object_id('foo', 'V') as id
89+
""");
90+
IsTrue(reader.Read());
91+
IsTrue(reader.IsDBNull(0));
92+
}
93+
94+
[TestMethod]
95+
public void ObjectId_InvalidType_ReturnsNull()
96+
{
97+
using var reader = new Simulation().ExecuteReader("""
98+
create table foo (id int);
99+
select object_id('foo', 'XX') as id
100+
""");
101+
IsTrue(reader.Read());
102+
IsTrue(reader.IsDBNull(0));
103+
}
104+
105+
[TestMethod]
106+
public void ObjectId_TwoPartName_Works()
107+
=> IsFalseDbNull(new Simulation().ExecuteReader("""
108+
create schema audit;
109+
create table audit.bar (id int);
110+
select object_id('audit.bar', 'U') as id
111+
"""));
112+
113+
[TestMethod]
114+
public void ObjectId_UnqualifiedResolvesToDbo()
115+
=> AreEqual(new Simulation().ExecuteScalar("""
116+
create table dbo.foo (id int);
117+
select object_id('dbo.foo') as id
118+
"""), new Simulation().ExecuteScalar("""
119+
create table dbo.foo (id int);
120+
select object_id('foo') as id
121+
"""));
122+
123+
[TestMethod]
124+
public void ObjectId_BracketedName_Works()
125+
{
126+
var bareId = (int)new Simulation().ExecuteScalar("""
127+
create table foo (id int);
128+
select object_id('foo') as id
129+
""")!;
130+
var bracketedId = (int)new Simulation().ExecuteScalar("""
131+
create table foo (id int);
132+
select object_id('[dbo].[foo]') as id
133+
""")!;
134+
// Same Simulation would be more direct but our infra creates two —
135+
// assert both are non-null ints. The cross-Simulation values differ.
136+
AreNotEqual(0, bareId);
137+
AreNotEqual(0, bracketedId);
138+
}
139+
140+
[TestMethod]
141+
public void ObjectId_BracketsAndDots_SingleSimulation()
142+
{
143+
using var reader = new Simulation().ExecuteReader("""
144+
create table foo (id int);
145+
select object_id('foo') as a, object_id('[dbo].[foo]') as b, object_id('dbo.foo') as c
146+
""");
147+
IsTrue(reader.Read());
148+
var a = reader.GetInt32(0);
149+
AreEqual(a, reader.GetInt32(1));
150+
AreEqual(a, reader.GetInt32(2));
151+
}
152+
153+
[TestMethod]
154+
public void ObjectId_ThreePartCorrectDb_Works()
155+
=> IsFalseDbNull(new Simulation().ExecuteReader("""
156+
create table foo (id int);
157+
select object_id('simulated.dbo.foo', 'U') as id
158+
"""));
159+
160+
[TestMethod]
161+
public void ObjectId_ThreePartWrongDb_ReturnsNull()
162+
{
163+
using var reader = new Simulation().ExecuteReader("""
164+
create table foo (id int);
165+
select object_id('baddb.dbo.foo', 'U') as id
166+
""");
167+
IsTrue(reader.Read());
168+
IsTrue(reader.IsDBNull(0));
169+
}
170+
171+
[TestMethod]
172+
public void ObjectId_FourPartName_ReturnsNull()
173+
{
174+
// Linked-server names aren't modeled — real SQL Server also returns
175+
// NULL for a 4-part name pointing at a non-existent linked server.
176+
using var reader = new Simulation().ExecuteReader("""
177+
create table foo (id int);
178+
select object_id('linked.simulated.dbo.foo', 'U') as id
179+
""");
180+
IsTrue(reader.Read());
181+
IsTrue(reader.IsDBNull(0));
182+
}
183+
184+
[TestMethod]
185+
public void ObjectId_StableAcrossCalls()
186+
{
187+
using var reader = new Simulation().ExecuteReader("""
188+
create table foo (id int);
189+
select object_id('foo') as a, object_id('foo') as b
190+
""");
191+
IsTrue(reader.Read());
192+
AreEqual(reader.GetInt32(0), reader.GetInt32(1));
193+
}
194+
195+
[TestMethod]
196+
public void ObjectId_DistinctAcrossTables()
197+
{
198+
using var reader = new Simulation().ExecuteReader("""
199+
create table foo (id int);
200+
create table bar (id int);
201+
select object_id('foo') as foo, object_id('bar') as bar
202+
""");
203+
IsTrue(reader.Read());
204+
AreNotEqual(reader.GetInt32(0), reader.GetInt32(1));
205+
}
206+
207+
[TestMethod]
208+
public void ObjectId_FreshAfterDropAndRecreate()
209+
{
210+
// Probe-confirmed against SQL Server 2025: drop-then-recreate yields
211+
// a fresh int id, the prior value is gone.
212+
using var reader = new Simulation().ExecuteReader("""
213+
create table foo (id int);
214+
declare @before int = object_id('foo');
215+
drop table foo;
216+
create table foo (id int);
217+
select @before as before_id, object_id('foo') as after_id
218+
""");
219+
IsTrue(reader.Read());
220+
AreNotEqual(reader.GetInt32(0), reader.GetInt32(1));
221+
}
222+
223+
[TestMethod]
224+
public void ObjectId_VariableArgument_RuntimeEvaluated()
225+
{
226+
using var reader = new Simulation().ExecuteReader("""
227+
create table foo (id int);
228+
declare @n nvarchar(100) = 'foo';
229+
select object_id(@n) as id
230+
""");
231+
IsTrue(reader.Read());
232+
IsFalse(reader.IsDBNull(0));
233+
}
234+
235+
[TestMethod]
236+
public void ObjectId_AfterDrop_IsNull()
237+
{
238+
using var reader = new Simulation().ExecuteReader("""
239+
create table foo (id int);
240+
drop table foo;
241+
select object_id('foo') as id
242+
""");
243+
IsTrue(reader.Read());
244+
IsTrue(reader.IsDBNull(0));
245+
}
246+
247+
[TestMethod]
248+
public void ObjectId_TempTable_Resolves()
249+
{
250+
// Divergence from real SQL Server (documented quirk): the simulator
251+
// routes # leaves to the connection's temp dict regardless of the
252+
// current db, so OBJECT_ID('#foo') finds the session's temp table
253+
// directly rather than requiring tempdb..#foo.
254+
using var reader = new Simulation().ExecuteReader("""
255+
create table #foo (id int);
256+
select object_id('#foo') as id
257+
""");
258+
IsTrue(reader.Read());
259+
IsFalse(reader.IsDBNull(0));
260+
}
261+
262+
[TestMethod]
263+
public void ObjectId_IfExistsIdiom_Works()
264+
{
265+
// The dominant real-world use of OBJECT_ID is the safe-drop pattern.
266+
// Confirm it threads end-to-end against an existing table.
267+
_ = new Simulation().ExecuteNonQuery("""
268+
create table foo (id int);
269+
if object_id('dbo.foo', 'U') is not null drop table foo;
270+
create table foo (id int)
271+
""");
272+
}
273+
274+
[TestMethod]
275+
public void ObjectId_IfExistsIdiom_NoExistingTable()
276+
=> _ = new Simulation().ExecuteNonQuery("""
277+
if object_id('dbo.foo', 'U') is not null drop table foo;
278+
create table foo (id int)
279+
""");
280+
281+
[TestMethod]
282+
public void ObjectId_EmptyString_ReturnsNull()
283+
{
284+
using var reader = new Simulation().ExecuteReader("select object_id('') as id");
285+
IsTrue(reader.Read());
286+
IsTrue(reader.IsDBNull(0));
287+
}
288+
289+
[TestMethod]
290+
public void ObjectId_TempdbDotDotHash_Resolves()
291+
=> IsFalseDbNull(new Simulation().ExecuteReader("""
292+
create table #foo (id int);
293+
select object_id('tempdb..#foo') as id
294+
"""));
295+
296+
[TestMethod]
297+
public void ObjectId_ResultTypeIsInt32()
298+
{
299+
using var reader = new Simulation().ExecuteReader("""
300+
create table foo (id int);
301+
select object_id('foo') as id
302+
""");
303+
IsTrue(reader.Read());
304+
AreEqual(typeof(int), reader.GetFieldType(0));
305+
}
306+
307+
[TestMethod]
308+
public void ObjectId_TooManyArgs_RaisesMsg174()
309+
=> new Simulation().AssertSqlError("select object_id('foo', 'U', 'extra')", 174);
310+
311+
private static void IsFalseDbNull(DbDataReader reader)
312+
{
313+
using (reader)
314+
{
315+
IsTrue(reader.Read());
316+
IsFalse(reader.IsDBNull(0));
317+
}
318+
}
319+
}

SqlServerSimulator/BuiltInResources.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,11 @@ private static Dictionary<string, HeapTable> BuildSystemHeapTables()
7070
new("scale", SqlType.TinyInt, null, true),
7171
new("collation", SqlType.SystemName, 128, true),
7272
];
73-
var systypes = new HeapTable("systypes", systypesColumns);
73+
// System tables live outside any user database's id space — they're
74+
// process-shared and the simulator doesn't expose them via OBJECT_ID
75+
// (which routes through per-DB schema resolution). A small negative
76+
// id keeps them distinguishable in debug output.
77+
var systypes = new HeapTable("systypes", systypesColumns, objectId: -1);
7478

7579
foreach (var row in SystypesRowData)
7680
{

SqlServerSimulator/Database.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,17 @@ public Database(string name)
7070
/// path.
7171
/// </summary>
7272
public long AllocateRowVersion() => Interlocked.Increment(ref this.rowVersionCounter);
73+
74+
private int nextObjectId = 100;
75+
76+
/// <summary>
77+
/// Allocates the next per-object identifier. Each user table gets one at
78+
/// CREATE; the value is stable through INSERT / UPDATE / DELETE / TRUNCATE
79+
/// (DROP-then-recreate yields a fresh ID, matching real SQL Server —
80+
/// probe-confirmed 2026-05-11). The counter never reuses a value and
81+
/// bypasses transaction rollback (matches the identity-counter rule for
82+
/// INSERT — rolling back doesn't return IDs to the pool). Backs
83+
/// <c>OBJECT_ID()</c> and the upcoming <c>sys.objects</c> catalog view.
84+
/// </summary>
85+
public int AllocateObjectId() => Interlocked.Increment(ref this.nextObjectId);
7386
}

0 commit comments

Comments
 (0)