Skip to content

Commit 9664935

Browse files
committed
Read-only views: CREATE VIEW schema.name [(col_list)] [WITH SCHEMABINDING|ENCRYPTION|VIEW_METADATA] AS <SELECT> [WITH CHECK OPTION] + DROP VIEW [IF EXISTS], stored in Schema.Views alongside HeapTables/Functions, body re-parsed per FROM-reference via Selection.ForView through the shared 32-level nesting cap, CREATE-time validation (Msg 4511 unnamed / 4506 duplicate / 8158-8159 rename count / 1033 ORDER BY without TOP), routing (view-in-expression → Msg 4104), catalog surface (sys.objects 'V', sys.views, sys.columns view output, INFORMATION_SCHEMA.VIEWS with body text + IS_UPDATABLE hardcoded 'NO', INFORMATION_SCHEMA.TABLES VIEW rows, OBJECT_ID 'V'), EF Core ToView() validated end-to-end; INSERT/UPDATE/DELETE through views deferred with explicit NotSupportedException.
1 parent 0cf2e6e commit 9664935

20 files changed

Lines changed: 1121 additions & 38 deletions

CLAUDE.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ 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), `Functions` (UDFs — abstract `UserDefinedFunction` keyed by name, runtime-typed as either `ScalarFunction` or `InlineTableValuedFunction`). Future views / procs / sequences land here too. Schema-qualified references (`SELECT * FROM audit.t`, `SELECT audit.fn(x)`, `FROM audit.tvf(x)`) route through `Database.Schemas["audit"]`; unqualified references fall back to `Database.DefaultSchemaName` (`"dbo"`).
65+
- **`Schema`** (internal) = one namespace inside a database. `HeapTables` (per-schema table dict), `Functions` (UDFs — abstract `UserDefinedFunction` keyed by name, runtime-typed as either `ScalarFunction` or `InlineTableValuedFunction`), `Views` (per-schema view dict). Future procs / sequences / triggers land here too. Schema-qualified references (`SELECT * FROM audit.t`, `SELECT audit.fn(x)`, `FROM audit.tvf(x)`, `FROM audit.view1`) route through `Database.Schemas["audit"]`; unqualified references fall back to `Database.DefaultSchemaName` (`"dbo"`).
6666
- **`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`).
6767
- **`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.
@@ -519,6 +519,31 @@ Per-connection `Dictionary<string, HeapTable> TempTables` on `SimulatedDbConnect
519519
- **No Msg 111 batch-first enforcement** (same as scalar UDFs).
520520
- **Multi-statement TVFs** (`RETURNS @t TABLE ... BEGIN ... END`, `type='TF'`) not modeled.
521521

522+
### Views
523+
`CREATE VIEW schema.name [(col_list)] [WITH SCHEMABINDING | ENCRYPTION | VIEW_METADATA] AS <SELECT> [WITH CHECK OPTION]`, referenced from FROM as `FROM schema.view [alias]` (or unqualified `FROM view`). Stored as `View` in `Schema.Views`. Body re-parsed per call inside a child `BatchContext`, returned as `Selection.ForView` wrapped in a `FromSource.LateralPlan`. Same 32-level recursion cap (Msg 217) as scalar UDFs / inline TVFs. Probed against SQL Server 2025 (2026-05-12).
524+
525+
- **Body grammar**: a single SELECT (CTE-prefixed bodies via `WITH cte AS (...) SELECT ...` work — the body parse runs at depth 0). ORDER BY without TOP / OFFSET / FETCH → **Msg 1033** (same factory CTE bodies use).
526+
- **Column-rename list**: `CREATE VIEW v(a, b) AS SELECT ...` renames the projection. Count mismatch → **Msg 8158** (too few listed) / **Msg 8159** (too many) — shared factories with CTE rename lists.
527+
- **CREATE-time validation**: body parses once to derive `OutputColumns`. Unnamed projection → **Msg 4511** (distinct from inline TVF's Msg 4514 and SELECT INTO's Msg 1038 — different wording too: `"Create View or Function failed because no column name was specified for column N."`). Duplicate column name → **Msg 4506** (shared with inline TVFs).
528+
- **WITH-clause options**: `SCHEMABINDING` / `ENCRYPTION` / `VIEW_METADATA` parse-and-ignore. **`WITH CHECK OPTION`** (trailing the body) parses and records on `View.WithCheckOption` but isn't enforced (only matters for DML, which v1 doesn't model).
529+
- **Routing in expression position**: a view name used as a scalar value raises **Msg 4104** (`"The multi-part identifier '...' could not be bound."`), NOT Msg 4121 — views look like tables to the expression parser.
530+
- **Unqualified names work**: `FROM v1` falls back to `dbo.v1` (probe-confirmed real SQL Server accepts both).
531+
- **Catalog surface**:
532+
- `sys.objects` `type='V '` (char(2) padded) / `type_desc='VIEW'`.
533+
- `sys.views` (load-bearing subset): `object_id`, `name`, `schema_id`, `with_check_option`, `is_date_correlation_view` (always False).
534+
- `sys.columns` emits one row per output column (`is_identity=0`, `is_computed=0`; `is_nullable` always True — same fidelity gap as inline TVFs).
535+
- `INFORMATION_SCHEMA.VIEWS` (full ISO 6-col shape): `VIEW_DEFINITION` surfaces the stored body text; `CHECK_OPTION` is `'CASCADE'` / `'NONE'`; **`IS_UPDATABLE` always `'NO'`** — probe-confirmed real SQL Server hardcodes this regardless of actual updatability.
536+
- `INFORMATION_SCHEMA.TABLES` includes views with `TABLE_TYPE='VIEW'`.
537+
- `OBJECT_ID(name, 'V')` resolves views only; no-filter form falls through both functions and views before tables.
538+
- **DROP VIEW [IF EXISTS] schema.name[, ...]**: same shape as DROP TABLE / DROP FUNCTION; missing target → **Msg 3701** with `view` wording variant.
539+
- **EF Core integration**: `ToView()` mapping works end-to-end. Keyless entities (`HasNoKey().ToView("name")`) project rows from CREATE VIEW-produced views; the simulator's per-call body re-parse handles correlated LINQ-emitted WHERE clauses against the view's projection.
540+
541+
**Fidelity gaps**:
542+
- **No updatable views** — INSERT / UPDATE / DELETE on a view raises `NotSupportedException` regardless of view shape. Real SQL Server supports DML against single-source views (with column-name rebinding to the backing table and Msg 4406 rejection for aggregate / derived-field views). Apps target the underlying table directly. Deferred to a follow-up bundle.
543+
- **No SCHEMABINDING enforcement** — DROP TABLE on a view-referenced table succeeds (real SQL Server raises Msg 3729); the view later fails at call time when re-parsing against the missing name.
544+
- **`VIEW_DEFINITION` always surfaces body text** even for WITH ENCRYPTION views (real SQL Server returns NULL for ENCRYPTION views).
545+
- **`is_nullable` always True** in `sys.columns` for view output — same gap as inline TVFs.
546+
522547
### `text` / `ntext` / `image` restrictions
523548
Comparison (Msg 402), ORDER BY/DISTINCT (Msg 306), and aggregates (Msg 8117 from MAX/MIN) all enforced.
524549

@@ -551,7 +576,7 @@ Full `DbDataReader` contract. Typed accessors read `SqlValue` directly via the c
551576
- **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`.
552577
- **`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.
553578
- T-SQL `GOTO` / labels — `IF` / `BEGIN…END` / `WHILE` / `BREAK` / `CONTINUE` / `RETURN` (bare + UDF-body value form) ship; unconditional jumps don't.
554-
- `RAISERROR`, stored procedures, multi-statement table-valued functions (`RETURNS @t TABLE (...) AS BEGIN ... END`), CLR functions, triggers, views, sequences. Scalar UDFs and inline TVFs ship — see the "Scalar user-defined functions" and "Inline table-valued functions" sections. `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.
579+
- `RAISERROR`, stored procedures, multi-statement table-valued functions (`RETURNS @t TABLE (...) AS BEGIN ... END`), CLR functions, triggers, sequences. Scalar UDFs, inline TVFs, and views ship — see the matching sections above. **DML through views** (INSERT / UPDATE / DELETE) isn't modeled; the parser raises `NotSupportedException`, deferred to a follow-up bundle. `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.
555580
- **`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.
556581
- `hierarchyid`, `geography`, `geometry`.
557582

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using System.ComponentModel.DataAnnotations.Schema;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Exercises EF Core's <c>ToView()</c> mapping against the simulator's
8+
/// view machinery — the canonical EF-side use case for read-only views.
9+
/// Maps a keyless entity to a CREATE VIEW-produced view and verifies
10+
/// LINQ queries flow through the simulator's view-resolution path
11+
/// (re-parses + executes the body per call).
12+
/// </summary>
13+
[TestClass]
14+
public sealed class EFCoreViews
15+
{
16+
private static ViewDbContext WithOrderSummary()
17+
{
18+
var simulation = new Simulation();
19+
_ = simulation.ExecuteNonQuery("""
20+
create table Orders (Id int primary key, Customer varchar(50), Amount decimal(10,2));
21+
insert Orders values (1, 'Alice', 100.00), (2, 'Bob', 250.00), (3, 'Alice', 50.00);
22+
create view OrderSummary as select Customer, sum(Amount) as TotalAmount, count(*) as OrderCount from Orders group by Customer
23+
""");
24+
return new ViewDbContext(simulation);
25+
}
26+
27+
[TestMethod]
28+
public void ToView_BasicQuery_ReturnsAggregatedRows()
29+
{
30+
using var context = WithOrderSummary();
31+
var summaries = context.OrderSummaries.OrderBy(s => s.Customer).ToList();
32+
Assert.HasCount(2, summaries);
33+
Assert.AreEqual(("Alice", 150.00m, 2), (summaries[0].Customer, summaries[0].TotalAmount, summaries[0].OrderCount));
34+
Assert.AreEqual(("Bob", 250.00m, 1), (summaries[1].Customer, summaries[1].TotalAmount, summaries[1].OrderCount));
35+
}
36+
37+
[TestMethod]
38+
public void ToView_WithFilter_PushesPredicateThroughView()
39+
{
40+
using var context = WithOrderSummary();
41+
var bigSpenders = context.OrderSummaries
42+
.Where(s => s.TotalAmount > 100m)
43+
.OrderBy(s => s.Customer)
44+
.Select(s => s.Customer)
45+
.ToList();
46+
CollectionAssert.AreEqual(new[] { "Alice", "Bob" }, bigSpenders);
47+
}
48+
}
49+
50+
/// <summary>
51+
/// Keyless entity mapped to the <c>OrderSummary</c> view via
52+
/// <c>ToView</c>. EF Core treats <c>HasNoKey</c> + <c>ToView</c> as a
53+
/// read-only projection — SaveChanges won't try to write back, matching
54+
/// the simulator's read-only-views-only stance.
55+
/// </summary>
56+
internal class OrderSummary
57+
{
58+
[Column(TypeName = "varchar(50)")]
59+
public string Customer { get; set; } = "";
60+
61+
[Column(TypeName = "decimal(10,2)")]
62+
public decimal TotalAmount { get; set; }
63+
64+
public int OrderCount { get; set; }
65+
}
66+
67+
internal class ViewDbContext(Simulation simulation) : DbContext
68+
{
69+
public Simulation Simulation { get; set; } = simulation;
70+
71+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
72+
{
73+
_ = optionsBuilder.UseSqlServer(this.Simulation.CreateDbConnection());
74+
}
75+
76+
protected override void OnModelCreating(ModelBuilder modelBuilder)
77+
{
78+
_ = modelBuilder.Entity<OrderSummary>()
79+
.HasNoKey()
80+
.ToView("OrderSummary");
81+
}
82+
83+
public DbSet<OrderSummary> OrderSummaries => Set<OrderSummary>();
84+
}

0 commit comments

Comments
 (0)