Skip to content

Commit a727a2c

Browse files
committed
Multi-statement TVFs (RETURNS @r TABLE (cols) AS BEGIN ... END): new MultiStatementTableValuedFunction parallel to InlineTableValuedFunction under the UserDefinedFunction base, captures parsed output columns + key/check constraints once at CREATE time (column-list parsing factored out of ParseDeclareTableVariable into shared TryParseTableVariableColumnsAndConstraints); Simulation.InvokeMultiStatementTvf allocates a child BatchContext via a new frameless body constructor (no UdfFrame / ProcFrame, so value-form RETURN N naturally surfaces Msg 178 through the existing return-statement parser), pre-seeds the @r return-table variable as a fresh per-call HeapTable in TableVariables, dispatches the body via the standard statement loop, then yields accumulated rows to the FROM-source driver. Bare RETURN; exits via the existing ReturnSignaled flag; CROSS APPLY correlation, nested TVF calls, and constraint enforcement on the return table (PK / UNIQUE / CHECK / IDENTITY) all reuse existing machinery. sys.objects.type = 'TF' and OBJECT_ID(name, 'TF') recognize the new kind. 12 new MultiStatementTvfTests + 2 EFCore tests validating EF Core 10's HasDbFunction end-to-end LINQ→SQL surface (the primary application integration point — IQueryable<T>-returning DbContext methods emit SELECT ... FROM dbo.fn(@p) which dispatches the body and projects rows back through LINQ composition). docs/claude/programmable.md gains a new section; CLAUDE.md trigger phrase extended and the deferred bullet moved to shipped.
1 parent d277b20 commit a727a2c

12 files changed

Lines changed: 881 additions & 43 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
143143
- **Touching JSON_VALUE / JSON_MODIFY / OPENJSON**[`docs/claude/json.md`](docs/claude/json.md).
144144
- **Changing name resolution, schema lookup, CREATE SCHEMA / DROP SCHEMA / ALTER SCHEMA TRANSFER, or OBJECT_ID**[`docs/claude/schemas.md`](docs/claude/schemas.md).
145145
- **Adding or changing system metadata surfaces** (sys.* / INFORMATION_SCHEMA.*) → [`docs/claude/catalog-views.md`](docs/claude/catalog-views.md).
146-
- **Extending scalar UDFs, inline TVFs, views, updatable-view DML routing, stored procedures (CREATE/ALTER/DROP/EXEC), or dynamic SQL (`EXEC (@sql)` / `sp_executesql`)**[`docs/claude/programmable.md`](docs/claude/programmable.md).
146+
- **Extending scalar UDFs, inline TVFs, multi-statement TVFs, views, updatable-view DML routing, stored procedures (CREATE/ALTER/DROP/EXEC), or dynamic SQL (`EXEC (@sql)` / `sp_executesql`)**[`docs/claude/programmable.md`](docs/claude/programmable.md).
147147
- **Touching `#foo` routing, DROP TABLE, TRUNCATE TABLE**[`docs/claude/temp-tables.md`](docs/claude/temp-tables.md).
148148
- **Touching `DECLARE @t TABLE`, table-variable DML routing, `OUTPUT … INTO <target>` (`@t` or regular)**[`docs/claude/table-variables.md`](docs/claude/table-variables.md).
149149
- **Touching `CREATE TYPE … AS TABLE`, `DECLARE @t MyType`, TVP procedure parameters / `READONLY`, or the ADO.NET `DbParameter.TypeName` extension (DataTable / IDataReader as a TVP value source)**[`docs/claude/table-valued-parameters.md`](docs/claude/table-valued-parameters.md).
@@ -172,7 +172,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
172172
- **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`.
173173
- **`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.
174174
- T-SQL `GOTO` / labels — `IF` / `BEGIN…END` / `WHILE` / `BREAK` / `CONTINUE` / `RETURN` (bare + UDF-body and stored-procedure-body value form) ship; unconditional jumps don't.
175-
- Multi-statement table-valued functions (`RETURNS @t TABLE (...) AS BEGIN ... END`), CLR functions, INSTEAD OF UPDATE / DELETE on *non-updatable* views (INSTEAD OF INSERT on any view ships; INSTEAD OF UPDATE / DELETE on updatable single-base views ships). DDL / logon triggers also unmodeled. Scalar UDFs, inline TVFs, views, DML-through-views (single-source updatable shape), stored procedures (including CREATE / ALTER / DROP, EXEC with input/output/default params, `@rc = EXEC` return-code capture, `CommandType.StoredProcedure`, `EXEC (@sql)` and `sp_executesql` dynamic SQL), user-defined table types + table-valued parameters (CREATE TYPE … AS TABLE / DROP TYPE / DECLARE @t MyType / READONLY proc params / EXEC with TVP arg / ADO.NET Structured parameter via the `DbParameter.TypeName` C# 14 extension property + DataTable/IDataReader sources), sequence objects (CREATE / ALTER / DROP SEQUENCE / NEXT VALUE FOR / sys.sequences — supports EF Core HiLo identity strategy end-to-end), and DML triggers (CREATE / ALTER / CREATE OR ALTER / DROP TRIGGER, FOR-as-AFTER synonym, AFTER on heap tables + INSTEAD OF on heap tables / views, multi-action INSERT,UPDATE,DELETE, INSERTED/DELETED pseudo-tables, multiple AFTER triggers per parent / one INSTEAD OF per action per target (Msg 2111), DISABLE/ENABLE TRIGGER, TRIGGER_NESTLEVEL(), direct-recursion suppression matching RECURSIVE_TRIGGERS OFF, sys.triggers + sys.objects integration, trigger-error rolls back firing DML, MERGE per-action routing through INSTEAD OF, DROP TABLE / DROP VIEW cascade-drops attached triggers) ship (see `docs/claude/programmable.md`, `docs/claude/table-valued-parameters.md`, `docs/claude/sequences.md`, and `docs/claude/triggers.md`). JOIN-view single-base-table UPDATE/DELETE, OUTPUT through views, and multi-source alias-form UPDATE/DELETE through views are deferred (Msg 4405 or `NotSupportedException` at the DML site). `BEGIN ATOMIC` / `BEGIN DISTRIBUTED TRANSACTION` raise `NotSupportedException` at dispatch. Value-form `RETURN N` is legal inside a scalar-UDF body and a stored-procedure body; bare batch / dynamic-SQL scope raises Msg 178. TRY/CATCH + THROW + RAISERROR (printf-style `%s %d/%i %u %o %x %X %% %ld %I64d` with width / precision / left-align / zero-pad; severity routing 0-10 informational vs 11-18 catchable; `WITH SETERROR`/`NOWAIT` ship, `WITH LOG` uniformly raises Msg 2778; numeric `msg_id` raises Msg 2732 for 50000 / `< 13000` and Msg 18054 otherwise — `sys.messages` registry / `sp_addmessage` not modeled) + live `@@ERROR` + `ERROR_*()` functions ship (see `docs/claude/control-flow.md`).
175+
- CLR functions, INSTEAD OF UPDATE / DELETE on *non-updatable* views (INSTEAD OF INSERT on any view ships; INSTEAD OF UPDATE / DELETE on updatable single-base views ships). DDL / logon triggers also unmodeled. Scalar UDFs, inline TVFs, multi-statement TVFs (`RETURNS @r TABLE (cols) AS BEGIN ... END` — EF Core `HasDbFunction` end-to-end), views, DML-through-views (single-source updatable shape), stored procedures (including CREATE / ALTER / DROP, EXEC with input/output/default params, `@rc = EXEC` return-code capture, `CommandType.StoredProcedure`, `EXEC (@sql)` and `sp_executesql` dynamic SQL), user-defined table types + table-valued parameters (CREATE TYPE … AS TABLE / DROP TYPE / DECLARE @t MyType / READONLY proc params / EXEC with TVP arg / ADO.NET Structured parameter via the `DbParameter.TypeName` C# 14 extension property + DataTable/IDataReader sources), sequence objects (CREATE / ALTER / DROP SEQUENCE / NEXT VALUE FOR / sys.sequences — supports EF Core HiLo identity strategy end-to-end), and DML triggers (CREATE / ALTER / CREATE OR ALTER / DROP TRIGGER, FOR-as-AFTER synonym, AFTER on heap tables + INSTEAD OF on heap tables / views, multi-action INSERT,UPDATE,DELETE, INSERTED/DELETED pseudo-tables, multiple AFTER triggers per parent / one INSTEAD OF per action per target (Msg 2111), DISABLE/ENABLE TRIGGER, TRIGGER_NESTLEVEL(), direct-recursion suppression matching RECURSIVE_TRIGGERS OFF, sys.triggers + sys.objects integration, trigger-error rolls back firing DML, MERGE per-action routing through INSTEAD OF, DROP TABLE / DROP VIEW cascade-drops attached triggers) ship (see `docs/claude/programmable.md`, `docs/claude/table-valued-parameters.md`, `docs/claude/sequences.md`, and `docs/claude/triggers.md`). JOIN-view single-base-table UPDATE/DELETE, OUTPUT through views, and multi-source alias-form UPDATE/DELETE through views are deferred (Msg 4405 or `NotSupportedException` at the DML site). `BEGIN ATOMIC` / `BEGIN DISTRIBUTED TRANSACTION` raise `NotSupportedException` at dispatch. Value-form `RETURN N` is legal inside a scalar-UDF body and a stored-procedure body; bare batch / dynamic-SQL scope raises Msg 178. TRY/CATCH + THROW + RAISERROR (printf-style `%s %d/%i %u %o %x %X %% %ld %I64d` with width / precision / left-align / zero-pad; severity routing 0-10 informational vs 11-18 catchable; `WITH SETERROR`/`NOWAIT` ship, `WITH LOG` uniformly raises Msg 2778; numeric `msg_id` raises Msg 2732 for 50000 / `< 13000` and Msg 18054 otherwise — `sys.messages` registry / `sp_addmessage` not modeled) + live `@@ERROR` + `ERROR_*()` functions ship (see `docs/claude/control-flow.md`).
176176
- **`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.
177177
- **`ALTER TABLE` grammar beyond `SET (SYSTEM_VERSIONING = OFF)`** — only the SET-versioning-off shape ships (see [`docs/claude/temporal-tables.md`](docs/claude/temporal-tables.md)). ADD / DROP COLUMN, ADD / DROP CONSTRAINT, DROP PERIOD FOR SYSTEM_TIME, REBUILD, SWITCH PARTITION, the SET-versioning-on direction, and every other shape raise `NotSupportedException` at the post-name dispatch point.
178178
- `hierarchyid`, `geography`, `geometry`.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
using Microsoft.EntityFrameworkCore;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// End-to-end test for EF Core's <c>HasDbFunction</c> mapped to a
7+
/// multi-statement TVF. The DbContext registers an
8+
/// <see cref="IQueryable{T}"/>-returning method (<see cref="DocumentContext.OwnedBy"/>);
9+
/// EF Core's SqlServer provider translates the LINQ call to
10+
/// <c>SELECT ... FROM dbo.OwnedBy(@p)</c>, which hits the simulator's
11+
/// MS-TVF dispatch path end-to-end. Validates the primary EF Core
12+
/// integration surface for this feature.
13+
/// </summary>
14+
[TestClass]
15+
public sealed class EFCoreMultiStatementTvf
16+
{
17+
public TestContext TestContext { get; set; } = null!;
18+
19+
private sealed class Doc
20+
{
21+
public int Id { get; set; }
22+
public int OwnerId { get; set; }
23+
public string Title { get; set; } = "";
24+
}
25+
26+
private sealed class DocumentContext(Simulation simulation) : DbContext
27+
{
28+
public Simulation Simulation { get; } = simulation;
29+
30+
public DbSet<Doc> Docs => Set<Doc>();
31+
32+
public IQueryable<Doc> OwnedBy(int ownerId) =>
33+
this.FromExpression(() => OwnedBy(ownerId));
34+
35+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
36+
{
37+
_ = optionsBuilder.UseSqlServer(this.Simulation.CreateDbConnection());
38+
}
39+
40+
protected override void OnModelCreating(ModelBuilder modelBuilder)
41+
{
42+
_ = modelBuilder.Entity<Doc>().ToTable("Docs");
43+
_ = modelBuilder.Entity<Doc>().Property(d => d.Id).ValueGeneratedNever();
44+
_ = modelBuilder.Entity<Doc>().Property(d => d.Title).HasColumnType("nvarchar(50)");
45+
_ = modelBuilder.HasDbFunction(
46+
typeof(DocumentContext).GetMethod(nameof(OwnedBy), [typeof(int)])!)
47+
.HasName("OwnedBy");
48+
}
49+
}
50+
51+
private static Simulation CreateSimulation()
52+
{
53+
var simulation = new Simulation();
54+
var connection = simulation.CreateOpenConnection();
55+
_ = connection.CreateCommand("""
56+
create table Docs (
57+
Id int not null primary key,
58+
OwnerId int not null,
59+
Title nvarchar(50) not null
60+
)
61+
""").ExecuteNonQuery();
62+
_ = connection.CreateCommand("""
63+
create function dbo.OwnedBy(@oid int)
64+
returns @r table (Id int not null, OwnerId int not null, Title nvarchar(50) not null)
65+
as
66+
begin
67+
insert into @r select Id, OwnerId, Title from dbo.Docs where OwnerId = @oid;
68+
return;
69+
end
70+
""").ExecuteNonQuery();
71+
return simulation;
72+
}
73+
74+
[TestMethod]
75+
public void HasDbFunction_MapsToMultiStatementTvf()
76+
{
77+
using var context = new DocumentContext(CreateSimulation());
78+
context.Docs.AddRange(
79+
new Doc { Id = 1, OwnerId = 100, Title = "first" },
80+
new Doc { Id = 2, OwnerId = 100, Title = "second" },
81+
new Doc { Id = 3, OwnerId = 200, Title = "third" });
82+
_ = context.SaveChanges();
83+
84+
var titles = context.OwnedBy(100)
85+
.OrderBy(d => d.Id)
86+
.Select(d => d.Title)
87+
.ToArray();
88+
CollectionAssert.AreEqual(new[] { "first", "second" }, titles);
89+
}
90+
91+
[TestMethod]
92+
public void HasDbFunction_FilterAndOrderTranslate()
93+
{
94+
// EF Core composes WHERE / ORDER BY over the TVF call client-side
95+
// (TVF body returns rows, the simulator's outer SELECT applies the
96+
// filter). This is the practical use case for HasDbFunction.
97+
using var context = new DocumentContext(CreateSimulation());
98+
context.Docs.AddRange(
99+
new Doc { Id = 1, OwnerId = 100, Title = "alpha" },
100+
new Doc { Id = 2, OwnerId = 100, Title = "beta" },
101+
new Doc { Id = 3, OwnerId = 100, Title = "gamma" });
102+
_ = context.SaveChanges();
103+
104+
var afterBeta = context.OwnedBy(100)
105+
.Where(d => d.Title.CompareTo("beta") > 0)
106+
.OrderBy(d => d.Title)
107+
.Select(d => d.Title)
108+
.ToArray();
109+
CollectionAssert.AreEqual(new[] { "gamma" }, afterBeta);
110+
}
111+
}

0 commit comments

Comments
 (0)