Skip to content

Commit c3bb055

Browse files
committed
Scalar UDFs: CREATE/DROP FUNCTION schema.name(@p [=default]) RETURNS <type> [WITH RETURNS NULL ON NULL INPUT] AS BEGIN ... END, multi-statement body via per-call child BatchContext + UdfFrame (value-form RETURN), 2-part-name call site (Msg 195/4121), DEFAULT keyword required for omitted args (Msg 313/8144), recursion capped at 32 (Msg 217 via SimulatedDbConnection.NestingLevel), sys.objects FN rows + new sys.parameters view + OBJECT_ID 'FN' filter.
1 parent e298638 commit c3bb055

20 files changed

Lines changed: 1270 additions & 36 deletions

CLAUDE.md

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,9 @@ Five scopes, one home each. **Add new state to whichever class matches its true
6262

6363
- **`Simulation`** = server / instance. Process-shared system tables (`SystemHeapTables`), `NEWSEQUENTIALID` anchor + counter, the `Databases` dictionary. Public surface (`Simulation` ctor + `CreateDbConnection()`) stays on this class.
6464
- **`Database`** (internal) = one database hosted by the server instance. `Schemas` (named-schema dict, pre-seeded with `dbo`), `CompatibilityLevel`, `VerboseTruncationWarnings`, `rowVersionCounter` (per-DB `@@DBTS`). Every `Simulation` ships with one entry named `Simulation.DefaultDatabaseName` (`"simulated"`); a future `USE <db>` adds entries to the dictionary.
65-
- **`Schema`** (internal) = one namespace inside a database. `HeapTables` (per-schema table dict). Future views / procs / sequences land here too. Schema-qualified references (`SELECT * FROM audit.t`) route through `Database.Schemas["audit"].HeapTables`; unqualified references fall back to `Database.DefaultSchemaName` (`"dbo"`).
66-
- **`SimulatedDbConnection`** = session. `CurrentDatabase` pointer, `CurrentTransaction`, `LastIdentity` (`SCOPE_IDENTITY()` / `@@IDENTITY`), `LastStatementRowCount` (`@@ROWCOUNT`), `IdentityInsertTable`, `TraceFlags`, `IsVerboseTruncationActive()`, `TempTables` (per-session `#foo` dictionary, cleared on `Dispose`).
67-
- **`BatchContext`** (internal, in `Parser/`) = one command execution. Owns the `ParserContext` (parse-time-only scratch — `Token`, `AggregateCollector`, `WindowCollector`, `OuterTypeResolver`, `CteBindings`, `InDefaultClause`, `AllowsWindowExpressions`) and holds batch-lifetime runtime state: `Variables`, `CurrentUndoLog`, plus the per-statement frame `CurrentStatement`. Exposes `TryResolveTable(MultiPartName)` — the routing rule that dispatches `#foo` leaves to `Connection.TempTables` regardless of qualifier; everything else routes through the named schema (or `dbo` for an unqualified reference), with `SystemHeapTables` reachable only as a flat 1-part fallback. `TryResolveSchema(MultiPartName)` exposes the dict-bearing schema for CREATE / DROP / TRUNCATE / SELECT INTO. `ParseObjectName(ParserContext)` parses the 1–4-segment dotted form, leaves cursor on the last name segment (standard parser contract), and compresses empty middle segments (so `tempdb..#foo` returns a 2-part name). Threaded explicitly into every `Expression.Run(RuntimeContext runtime)` call via `runtime.Batch`.
65+
- **`Schema`** (internal) = one namespace inside a database. `HeapTables` (per-schema table dict), `Functions` (scalar UDFs). Future views / procs / sequences land here too. Schema-qualified references (`SELECT * FROM audit.t`, `SELECT audit.fn(x)`) route through `Database.Schemas["audit"]`; unqualified references fall back to `Database.DefaultSchemaName` (`"dbo"`).
66+
- **`SimulatedDbConnection`** = session. `CurrentDatabase` pointer, `CurrentTransaction`, `LastIdentity` (`SCOPE_IDENTITY()` / `@@IDENTITY`), `LastStatementRowCount` (`@@ROWCOUNT`), `LastErrorNumber` (`@@ERROR`), `NestingLevel` (UDF / proc / trigger / view recursion depth, capped at 32), `IdentityInsertTable`, `TraceFlags`, `IsVerboseTruncationActive()`, `TempTables` (per-session `#foo` dictionary, cleared on `Dispose`).
67+
- **`BatchContext`** (internal, in `Parser/`) = one command execution. Owns the `ParserContext` (parse-time-only scratch — `Token`, `AggregateCollector`, `WindowCollector`, `OuterTypeResolver`, `CteBindings`, `InDefaultClause`, `AllowsWindowExpressions`) and holds batch-lifetime runtime state: `Variables`, `CurrentUndoLog`, `UdfFrame` (non-null when this batch is a scalar-UDF body being dispatched — gates value-form `RETURN` and lands the return value for the caller), plus the per-statement frame `CurrentStatement`. Exposes `TryResolveTable(MultiPartName)` — the routing rule that dispatches `#foo` leaves to `Connection.TempTables` regardless of qualifier; everything else routes through the named schema (or `dbo` for an unqualified reference), with `SystemHeapTables` reachable only as a flat 1-part fallback. `TryResolveFunction(MultiPartName)` resolves 2-/3-part dotted names against the named schema's `Functions` dict; 1-part names return false (real SQL Server rejects bare UDF calls with Msg 195). `TryResolveSchema(MultiPartName)` exposes the dict-bearing schema for CREATE / DROP / TRUNCATE / SELECT INTO. `ParseObjectName(ParserContext)` parses the 1–4-segment dotted form, leaves cursor on the last name segment (standard parser contract), and compresses empty middle segments (so `tempdb..#foo` returns a 2-part name). Threaded explicitly into every `Expression.Run(RuntimeContext runtime)` call via `runtime.Batch`. Scalar-UDF invocation allocates a child `BatchContext` via the UDF-body constructor: parameters pre-seed `Variables`, `UdfFrame` is set, the body source text (captured at CREATE FUNCTION time) is re-tokenized through a synthesized `SimulatedDbCommand`, and the same dispatch loop runs the body.
6868
- **`StatementContext`** (internal, in `Parser/`) = the dispatch loop's per-statement frame. Allocated once per batch and overwritten in place at the top of each iteration; holds `UtcNow` (the per-statement-freeze the time scalars read). Stored-proc EXEC / TRY-CATCH frames slot in here when added.
6969

7070
**Don't stack misfit state into these buckets unthinkingly**: when adding fields, ask which scope it actually belongs to. If none fits, that's the signal to introduce the missing scope rather than squat on a neighbor. Multi-database is structurally supported but exercised only by the default entry; `USE <db>` is the trigger to populate the dictionary properly.
@@ -477,6 +477,29 @@ Per-connection `Dictionary<string, HeapTable> TempTables` on `SimulatedDbConnect
477477
- **Persists across batches** in the same connection; **invisible to other connections** (Msg 208). Two sessions can independently hold a `#foo` of the same name — real SQL Server mangles internally; the simulator achieves the same effect by giving each connection its own dict (user-visible names stay un-mangled).
478478
- **Bare `#`** is a valid temp-table name (one-char `#`). Identity / SCOPE_IDENTITY work identically to regular tables. CTE prefix, JOINs across multiple `#`-tables, all queries against `#foo` flow through the same Selection / Insert / Update / Delete / Merge machinery via `TryResolveTable`.
479479

480+
### Scalar user-defined functions
481+
`CREATE FUNCTION schema.name(@p1 type [= default], ...) RETURNS <type> [WITH RETURNS NULL ON NULL INPUT] AS BEGIN ... END`, called as `SELECT schema.fn(args)`. Probed against SQL Server 2025 (2026-05-11).
482+
483+
- **2-part-name required.** Bare `fn(x)` raises **Msg 195** (`'fn' is not a recognized built-in function name.`) — real SQL Server treats unqualified UDF calls as built-in misses, never falls through to user-function resolution. Schema-qualified miss → **Msg 4121** (`Cannot find either column "dbo" or the user-defined function or aggregate "dbo.x", or the name is ambiguous.`).
484+
- **BEGIN/END required for the body.** Probe-confirmed `as return @x*10` raises Msg 102 (no inline-return form for scalar UDFs). The body source is captured at CREATE time as the raw text between the outer `BEGIN` (exclusive) and matching `END` (exclusive) by token-level nesting (BEGIN TRAN / TRANSACTION / DISTRIBUTED don't open a block).
485+
- **Multi-statement body**: `DECLARE` / `SET` / `SELECT @v = expr` / `IF` / `WHILE` / `BEGIN…END` / `BREAK` / `CONTINUE` / `RETURN <value>`. Variables declared inside the body are function-scoped (per-call child `BatchContext` with its own `Variables` dict).
486+
- **`RETURN <value>` legal only inside a UDF body.** Outside, raises **Msg 178** at parse time (the existing batch-scope rejection). Inside, the value coerces to the declared return type and lands in `BatchContext.UdfFrame.ReturnedValue`; `BatchContext.ReturnSignaled` short-circuits the dispatch.
487+
- **Argument arity errors**: too few → **Msg 313** (`An insufficient number of arguments were supplied for the procedure or function <name>.`); too many → **Msg 8144** (`Procedure or function <name> has too many arguments specified.`).
488+
- **DEFAULT keyword required for parameter defaults.** Probe-confirmed `fn(default)` works but `fn()` raises Msg 313 even when every parameter has a declared default. The default expression is parsed once at CREATE time and re-evaluated per call in the child `BatchContext`. Bare omission is never legal.
489+
- **WITH RETURNS NULL ON NULL INPUT**: when any non-DEFAULT argument is NULL, the body is skipped entirely and the function returns typed NULL. DEFAULT slots materialize from their stored expression and don't trigger the short-circuit on their own.
490+
- **Recursion cap: 32.** Tracked by `SimulatedDbConnection.NestingLevel` (incremented in `Simulation.InvokeScalarFunction`, decremented in `finally`); exceeding raises **Msg 217** verbatim (`Maximum stored procedure, function, trigger, or view nesting level exceeded (limit 32).`). Stored procs / triggers / views will share the same counter when added.
491+
- **Catalog surface**: `sys.objects` emits rows with `type='FN'` / `type_desc='SQL_SCALAR_FUNCTION'`. New `sys.parameters` view emits one row per declared parameter (parameter_id=1+, `name='@<name>'`, `is_output=0`) plus one row with `parameter_id=0` for the return type (empty name, `is_output=1`). **`max_length` is 0** for v1 (computing per-type byte width for the bare SqlType — the existing `GetSysColumnMetadata` helper takes a HeapColumn — is fidelity-gap territory; the common queries read name / parameter_id / system_type_id / is_output / is_nullable).
492+
- **`OBJECT_ID('schema.fn','FN')`** routes to function resolution; `'U'` filter returns NULL for a UDF; no-filter form tries function resolution first, then table. The two new modeled codes (`U` and `FN`) cover the load-bearing object-existence checks; other documented codes (V / P / TF / IF / ...) return NULL pending the corresponding features.
493+
- **DROP FUNCTION [IF EXISTS] schema.name[, ...]**: same comma-list / IF EXISTS shape as DROP TABLE. Missing target → **Msg 3701** with the `function` wording variant (`Cannot drop the function 'dbo.x', because it does not exist or you do not have permission.` — same Msg/Class/State as DROP TABLE's variant, only the noun differs).
494+
495+
**Fidelity gaps** (modeled, document explicitly):
496+
- **No CREATE-time body validation.** Real SQL Server raises **Msg 455** when the body's last statement isn't `RETURN`, **Msg 443** for `PRINT` / `THROW` / DML on permanent tables in the body, and **Msg 444** for result-set `SELECT` inside the body. The simulator defers all of these to call time — a body falling through without RETURN returns typed NULL; side-effecting statements raise their own errors. Apps that rely on CREATE-time rejection diverge.
497+
- **No CREATE-FUNCTION-must-be-first-in-batch (Msg 111).** Real SQL Server requires `CREATE FUNCTION` to be the only/first statement in a batch (also true for `CREATE PROCEDURE` / `CREATE VIEW`). The simulator dispatches it like any other statement; preceding `IF OBJECT_ID('dbo.f','FN') IS NOT NULL DROP FUNCTION dbo.f; CREATE FUNCTION ...` in one batch works in the simulator but raises Msg 111 in real SQL Server. Apps must split the batches (no `GO` support).
498+
- **`WITH SCHEMABINDING` / `WITH ENCRYPTION` / `WITH EXECUTE AS`**: raise `NotSupportedException` (only `WITH RETURNS NULL ON NULL INPUT` is modeled).
499+
- **Inline TVFs (`RETURNS TABLE AS RETURN (SELECT ...)`)** and **multi-statement TVFs (`RETURNS @t TABLE (...) AS BEGIN ... END`)** aren't modeled — `CREATE FUNCTION` only accepts a scalar `RETURNS <type>` header today. Probe-confirmed real SQL Server treats inline TVFs as a separate object type (`'IF'`, `type_desc='SQL_INLINE_TABLE_VALUED_FUNCTION'`) reachable from a `FROM` clause; that path isn't wired.
500+
- **`@@ROWCOUNT` inside a UDF body** isn't isolated — the body shares the connection's `LastStatementRowCount` counter with the caller. Real SQL Server preserves the caller's `@@ROWCOUNT` across UDF calls; the simulator currently lets body statements overwrite it. Workloads that read `@@ROWCOUNT` immediately after a UDF-call-in-SELECT-assignment may diverge.
501+
- **EF Core doesn't emit scalar UDFs** from idiomatic LINQ; they're only reachable from raw SQL (`FromSqlInterpolated` / direct `ExecuteReader`) or via the `[DbFunction]` attribute on a C# method that maps to a UDF (not modeled).
502+
480503
### EF Core adapter coverage
481504
`UseSqlServerSimulator(...)` covers seven SqlParameter-downcast pairs: `DateOnly→date`, `DateTime→date`, `DateTime→smalldatetime`, `TimeOnly→time(N)`, `TimeSpan→time(N)`, `decimal→money`, `decimal→smallmoney`. Without the adapter those mappings throw at SaveChanges. MAX-string family flows through plain `UseSqlServer`.
482505

@@ -511,8 +534,8 @@ Full `DbDataReader` contract. Typed accessors read `SqlValue` directly via the c
511534
- **`CREATE SCHEMA AUTHORIZATION <owner>`**`NotSupportedException` (simulator has no user / principal model).
512535
- **CREATE SCHEMA's `<schema_element>` greedy form** — real SQL Server consumes trailing CREATE TABLE / VIEW / GRANT as part of the same CREATE SCHEMA statement (and requires CREATE SCHEMA to be the first statement in the batch as a result). The simulator instead dispatches the trailing tokens as their own statements — same end state for the common idiom, but mismatched-grammar trailers (e.g. anything that isn't a recognized statement start) raise `NotSupportedException`.
513536
- **`CREATE SCHEMA sys` / `INFORMATION_SCHEMA`** — raises Msg 2760 (matching real SQL Server). The schemas themselves exist as catalog-view hosts (`select * from sys.tables` / `select * from INFORMATION_SCHEMA.COLUMNS` work); legacy bare 1-part system-table access (`select * from systypes`) also still works.
514-
- T-SQL `GOTO` / labels — `IF` / `BEGIN…END` / `WHILE` / `BREAK` / `CONTINUE` / `RETURN` (bare) ship; unconditional jumps don't.
515-
- `RAISERROR`, stored procs / UDFs. `BEGIN ATOMIC` / `BEGIN DISTRIBUTED TRANSACTION` raise `NotSupportedException` at dispatch. Value-form `RETURN N` raises Msg 178 (reserved for the stored-proc / function scope, neither modeled yet). TRY/CATCH + THROW + live `@@ERROR` + `ERROR_*()` functions ship — see the TRY/CATCH section.
537+
- T-SQL `GOTO` / labels — `IF` / `BEGIN…END` / `WHILE` / `BREAK` / `CONTINUE` / `RETURN` (bare + UDF-body value form) ship; unconditional jumps don't.
538+
- `RAISERROR`, stored procedures, **table-valued functions** (inline `RETURNS TABLE AS RETURN (SELECT ...)` and multi-statement `RETURNS @t TABLE (...) AS BEGIN ... END`), CLR functions, triggers, views, sequences. Scalar UDFs ship — see the "Scalar user-defined functions" section. `BEGIN ATOMIC` / `BEGIN DISTRIBUTED TRANSACTION` raise `NotSupportedException` at dispatch. Value-form `RETURN N` is legal inside a scalar-UDF body and raises Msg 178 elsewhere (stored-proc scope where it would also be legal isn't modeled). TRY/CATCH + THROW + live `@@ERROR` + `ERROR_*()` functions ship — see the TRY/CATCH section.
516539
- **`PRINT` message capture** — the statement parses + evaluates the operand (so operand-side errors like Msg 245 surface), but the message is discarded. `DbConnection` has no `InfoMessage` event (that's a `SqlConnection` extension), so adding a public observability surface would mean a new event on `SimulatedDbConnection`. Defer until an application needs it.
517540
- `hierarchyid`, `geography`, `geometry`.
518541

0 commit comments

Comments
 (0)