Skip to content

Commit 1b67a7d

Browse files
committed
Indexed views ship with live enforcement: CREATE INDEX on a schema-bound view, the unique clustered index enforces at INSERT/UPDATE time, View gains IsSchemaBound/Indexes with output-ordinal keys surfaced through sys.indexes/index_columns/stats and is_schema_bound/OBJECTPROPERTY, and the bacpac loader dispatches SqlIndex-on-view.
1 parent f521bcb commit 1b67a7d

25 files changed

Lines changed: 944 additions & 135 deletions

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
147147
- **`FOREIGN KEY` + referential actions, `sys.foreign_keys`**[`foreign-keys.md`](docs/claude/foreign-keys.md).
148148
- **`PERIOD FOR SYSTEM_TIME`, `FOR SYSTEM_TIME ALL/AS OF`, history sibling, `temporal_type`**[`temporal-tables.md`](docs/claude/temporal-tables.md).
149149
- **`ALTER TABLE` ADD/DROP/ALTER COLUMN + CONSTRAINT (incl. trust toggling)**[`alter-table.md`](docs/claude/alter-table.md).
150-
- **`CREATE INDEX` (UNIQUE / CLUSTERED / INCLUDE / WHERE filter), `sys.indexes`, `DBCC SHOW_STATISTICS … WITH HISTOGRAM`**[`indexes.md`](docs/claude/indexes.md).
150+
- **`CREATE INDEX` (UNIQUE / CLUSTERED / INCLUDE / WHERE filter), indexed views (`CREATE INDEX ON <schema-bound view>`, Msg 1939/1940/1941 gates, live Msg 2601 uniqueness enforcement, NOEXPAND), `sys.indexes`, `DBCC SHOW_STATISTICS … WITH HISTOGRAM`**[`indexes.md`](docs/claude/indexes.md).
151151
- **Table hints (`WITH (NOLOCK …)`) + statement `OPTION (…)` hints**[`query-hints.md`](docs/claude/query-hints.md).
152152
- **Heap page lifecycle** (reclamation/reuse, tail-only shrink, `DBCC SHRINKDATABASE`/`SHRINKFILE`) → [`heap-storage.md`](docs/claude/heap-storage.md).
153153
- **Per-`Simulation` plan cache** (single-SELECT `Selection` reuse keyed by text + db + param-type sig + QUOTED_IDENTIFIER setting, `SchemaVersion`-stamped invalidation, inline-in-SELECT-arm promotion since the iterator's post-yield code is unreachable on a non-draining `ExecuteReader`; `VariableReference` Run-time slot lookup is the co-fix) → [`plan-cache.md`](docs/claude/plan-cache.md).

SqlServerSimulator.Tests/Bacpac/BacpacBuilder.cs

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -245,15 +245,16 @@ public BacpacBuilder Grant(string permission, string grantee)
245245
}
246246

247247
/// <summary>
248-
/// Emits an <c>SqlIndex</c> element whose IndexedObject points at a
249-
/// view. The loader detects view-targeted indexes via a pre-scan of
250-
/// SqlView Names and records them on Skipped with a clear reason
251-
/// (indexed views need SCHEMABINDING — not modeled). Use this to
252-
/// exercise that deferral path.
248+
/// Emits an <c>SqlIndex</c> element whose IndexedObject points at a view
249+
/// — an indexed view. The referenced view must be created (via
250+
/// <see cref="View"/>) <c>WITH SCHEMABINDING</c>. Defaults to
251+
/// <c>IsUnique=True IsClustered=True</c> (the required first index on a
252+
/// view); the loader emits <c>CREATE UNIQUE CLUSTERED INDEX … ON
253+
/// &lt;view&gt;</c>.
253254
/// </summary>
254-
public BacpacBuilder IndexOnView(string viewSchema, string viewName, string indexName, string[] columns)
255+
public BacpacBuilder IndexOnView(string viewSchema, string viewName, string indexName, string[] columns, bool isUnique = true, bool isClustered = true)
255256
{
256-
_viewIndexes.Add(new ViewIndexDef(viewSchema, viewName, indexName, columns));
257+
_viewIndexes.Add(new ViewIndexDef(viewSchema, viewName, indexName, columns, isUnique, isClustered));
257258
return this;
258259
}
259260

@@ -1181,7 +1182,7 @@ internal sealed record TableTypeDef(string SchemaName, string TypeName, TableBui
11811182

11821183
internal sealed record PermissionDef(string Action, string Permission, string Grantee);
11831184

1184-
internal sealed record ViewIndexDef(string ViewSchema, string ViewName, string IndexName, string[] KeyColumns);
1185+
internal sealed record ViewIndexDef(string ViewSchema, string ViewName, string IndexName, string[] KeyColumns, bool IsUnique = true, bool IsClustered = true);
11851186

11861187
internal sealed record XmlIndexDef(string SchemaName, string TableName, string IndexName, string Column, bool IsPrimary, string? UsingPrimaryIndexName, int? PrimaryXmlIndexUsage);
11871188

@@ -1362,6 +1363,11 @@ private static XElement BuildViewIndexElement(XNamespace ns, ViewIndexDef vi)
13621363
new XElement(ns + "References",
13631364
new XAttribute("Name", $"[{vi.ViewSchema}].[{vi.ViewName}]")))));
13641365

1366+
if (vi.IsUnique)
1367+
element.Add(new XElement(ns + "Property", new XAttribute("Name", "IsUnique"), new XAttribute("Value", "True")));
1368+
if (vi.IsClustered)
1369+
element.Add(new XElement(ns + "Property", new XAttribute("Name", "IsClustered"), new XAttribute("Value", "True")));
1370+
13651371
var columnSpecs = new XElement(ns + "Relationship",
13661372
new XAttribute("Name", "ColumnSpecifications"));
13671373
foreach (var col in vi.KeyColumns)

SqlServerSimulator.Tests/Bacpac/BacpacLoaderTests.cs

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,24 +1134,27 @@ FROM sys.extended_properties ep
11341134
}
11351135

11361136
[TestMethod]
1137-
public void IndexOnView_LandsOn_Skipped_WithSchemabindingReason()
1137+
public void IndexOnView_LoadsAsIndexedView()
11381138
{
1139-
// Indexed views need SCHEMABINDING machinery the simulator doesn't
1140-
// model; the loader pre-scans SqlView Names and routes any
1141-
// view-targeted SqlIndex to Skipped with a clear reason. Exercise
1142-
// that deferral path with a view + a matching SqlIndex.
1139+
// An indexed (materialized) view: the SqlView is created WITH
1140+
// SCHEMABINDING, then its unique clustered SqlIndex (phase 8, after
1141+
// views land in phase 6) dispatches as CREATE UNIQUE CLUSTERED INDEX
1142+
// ON the view. No Skipped entry; the index surfaces in sys.indexes at
1143+
// index_id 1 / CLUSTERED and enforces uniqueness on base DML.
11431144
using var bacpac = BacpacBuilder.Create()
1144-
.Table("dbo", "Item", t => t.Column("Id", "int"))
1145-
.View("dbo", "ItemView", "CREATE VIEW dbo.ItemView AS SELECT Id FROM dbo.Item;")
1145+
.Table("dbo", "Item", t => t.Column("Id", "int").Column("Grp", "int"))
1146+
.View("dbo", "ItemView", "CREATE VIEW dbo.ItemView WITH SCHEMABINDING AS SELECT Id, Grp FROM dbo.Item;")
11461147
.IndexOnView("dbo", "ItemView", "IX_ItemView_Id", ["Id"])
11471148
.Build();
11481149

11491150
var sim = new Simulation();
11501151
sim.ImportBacpac(bacpac, out var diag);
1151-
AreEqual(0, sim.ExecuteScalar("SELECT COUNT(*) FROM sys.indexes WHERE name = 'IX_ItemView_Id';"));
1152-
var skippedIndexes = diag.Skipped.Where(s => s.ElementType == "SqlIndex").ToList();
1153-
HasCount(1, skippedIndexes);
1154-
Contains("on view", skippedIndexes[0].Reason);
1152+
IsEmpty(diag.Skipped.Where(s => s.ElementType == "SqlIndex"));
1153+
AreEqual(1, sim.ExecuteScalar("SELECT COUNT(*) FROM sys.indexes WHERE name = 'IX_ItemView_Id' AND type_desc = 'CLUSTERED' AND is_unique = 1;"));
1154+
// The unique clustered view index enforces uniqueness on base DML: two
1155+
// base rows projecting the same view key raise Msg 2601.
1156+
_ = sim.ExecuteNonQuery("INSERT dbo.Item VALUES (1, 10)");
1157+
_ = sim.AssertSqlError("INSERT dbo.Item VALUES (1, 20)", 2601);
11551158
}
11561159

11571160
[TestMethod]
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Behavioral tests for indexed (materialized) views: <c>CREATE UNIQUE
7+
/// CLUSTERED INDEX ON &lt;view&gt;</c>, the schema-binding / unique-clustered
8+
/// gates (Msg 1939 / 1940 / 1941), create-time duplicate rejection (Msg 1505),
9+
/// live base-DML uniqueness enforcement (Msg 2601), the <c>sys.indexes</c> /
10+
/// <c>sys.index_columns</c> / <c>sys.stats</c> catalog surface, and
11+
/// <c>is_schema_bound</c>. Probe-confirmed against SQL Server 2025
12+
/// (2026-07-17).
13+
/// </summary>
14+
[TestClass]
15+
public sealed class IndexedViewTests
16+
{
17+
/// <summary>
18+
/// Base table + a schema-bound projection view + a unique clustered index
19+
/// on the view's <c>Id</c> column, with three seed rows (Ids 1/2/3).
20+
/// </summary>
21+
private static Simulation SeedIndexedView()
22+
{
23+
var sim = new Simulation();
24+
sim.ExecuteBatches(
25+
"create table dbo.b (id int not null, grp int not null, val int not null); insert dbo.b values (1,10,100),(2,20,200),(3,30,300)",
26+
"create view dbo.v with schemabinding as select id, grp, val from dbo.b",
27+
"create unique clustered index ix_v on dbo.v(id)");
28+
return sim;
29+
}
30+
31+
// --- Gates ---
32+
33+
[TestMethod]
34+
public void CreateIndex_OnNonSchemaBoundView_Msg1939()
35+
{
36+
var sim = new Simulation();
37+
sim.ExecuteBatches(
38+
"create table dbo.b (id int not null, grp int not null)",
39+
"create view dbo.v as select id, grp from dbo.b");
40+
var ex = sim.AssertSqlError("create unique clustered index ix on dbo.v(id)", 1939);
41+
AreEqual("Cannot create index on view 'v' because the view is not schema bound.", ex.Message);
42+
}
43+
44+
[TestMethod]
45+
public void CreateIndex_NonUniqueClusteredOnView_Msg1941()
46+
{
47+
var sim = new Simulation();
48+
sim.ExecuteBatches(
49+
"create table dbo.b (id int not null, grp int not null)",
50+
"create view dbo.v with schemabinding as select id, grp from dbo.b");
51+
var ex = sim.AssertSqlError("create clustered index ix on dbo.v(id)", 1941);
52+
Contains("only unique clustered indexes are allowed", ex.Message);
53+
}
54+
55+
[TestMethod]
56+
public void CreateIndex_NonClusteredFirstOnView_Msg1940()
57+
{
58+
var sim = new Simulation();
59+
sim.ExecuteBatches(
60+
"create table dbo.b (id int not null, grp int not null)",
61+
"create view dbo.v with schemabinding as select id, grp from dbo.b");
62+
var ex = sim.AssertSqlError("create nonclustered index ix on dbo.v(id)", 1940);
63+
AreEqual("Cannot create index on view 'dbo.v'. It does not have a unique clustered index.", ex.Message);
64+
}
65+
66+
[TestMethod]
67+
public void CreateIndex_UniqueNonClusteredFirstOnView_Msg1940()
68+
{
69+
var sim = new Simulation();
70+
sim.ExecuteBatches(
71+
"create table dbo.b (id int not null, grp int not null)",
72+
"create view dbo.v with schemabinding as select id, grp from dbo.b");
73+
_ = sim.AssertSqlError("create unique nonclustered index ix on dbo.v(id)", 1940);
74+
}
75+
76+
[TestMethod]
77+
public void CreateIndex_MissingViewColumn_Msg1911()
78+
{
79+
var sim = new Simulation();
80+
sim.ExecuteBatches(
81+
"create table dbo.b (id int not null, grp int not null)",
82+
"create view dbo.v with schemabinding as select id, grp from dbo.b");
83+
_ = sim.AssertSqlError("create unique clustered index ix on dbo.v(nope)", 1911);
84+
}
85+
86+
// --- Create-time validation ---
87+
88+
[TestMethod]
89+
public void CreateUniqueClusteredIndex_OverDuplicateViewRows_Msg1505()
90+
{
91+
var sim = new Simulation();
92+
sim.ExecuteBatches(
93+
"create table dbo.b (id int not null, grp int not null); insert dbo.b values (1,10),(2,10),(3,20)",
94+
"create view dbo.v with schemabinding as select grp from dbo.b");
95+
var ex = sim.AssertSqlError("create unique clustered index ix on dbo.v(grp)", 1505);
96+
Contains("'dbo.v'", ex.Message);
97+
Contains("(10)", ex.Message);
98+
}
99+
100+
[TestMethod]
101+
public void CreateUniqueClusteredIndex_Succeeds()
102+
=> AreEqual(3L, SeedIndexedView().ExecuteScalar("select count_big(*) from dbo.v"));
103+
104+
// --- Enforcement ---
105+
106+
[TestMethod]
107+
public void Insert_ProducingDuplicateViewKey_Msg2601_NamesViewAndIndex()
108+
{
109+
var ex = SeedIndexedView().AssertSqlError("insert dbo.b values (1, 99, 999)", 2601);
110+
AreEqual("Cannot insert duplicate key row in object 'dbo.v' with unique index 'ix_v'. The duplicate key value is (1).", ex.Message);
111+
}
112+
113+
[TestMethod]
114+
public void Insert_ProducingDuplicateViewKey_RollsBackStatement()
115+
{
116+
var sim = SeedIndexedView();
117+
_ = sim.AssertSqlError("insert dbo.b values (1, 99, 999)", 2601);
118+
AreEqual(3, sim.ExecuteScalar("select count(*) from dbo.b"));
119+
}
120+
121+
[TestMethod]
122+
public void Update_ProducingDuplicateViewKey_Msg2601()
123+
{
124+
var sim = SeedIndexedView();
125+
var ex = sim.AssertSqlError("update dbo.b set id = 2 where id = 3", 2601);
126+
Contains("'dbo.v'", ex.Message);
127+
AreEqual(3, sim.ExecuteScalar("select count(*) from dbo.b where id in (1,2,3)"));
128+
}
129+
130+
[TestMethod]
131+
public void Insert_NonDuplicate_Succeeds()
132+
{
133+
var sim = SeedIndexedView();
134+
_ = sim.ExecuteNonQuery("insert dbo.b values (4, 40, 400)");
135+
AreEqual(4, sim.ExecuteScalar("select count(*) from dbo.b"));
136+
}
137+
138+
[TestMethod]
139+
public void Delete_NeverViolatesViewUniqueness()
140+
{
141+
var sim = SeedIndexedView();
142+
_ = sim.ExecuteNonQuery("delete dbo.b where id = 3");
143+
AreEqual(2, sim.ExecuteScalar("select count(*) from dbo.b"));
144+
}
145+
146+
// --- is_schema_bound ---
147+
148+
[TestMethod]
149+
public void SchemaBinding_SurfacesThroughSqlModulesAndObjectProperty()
150+
{
151+
var sim = SeedIndexedView();
152+
IsTrue((bool)sim.ExecuteScalar("select is_schema_bound from sys.sql_modules where object_id = object_id('dbo.v')")!);
153+
AreEqual(1, sim.ExecuteScalar("select objectproperty(object_id('dbo.v'), 'IsSchemaBound')"));
154+
}
155+
156+
[TestMethod]
157+
public void NonSchemaBoundView_ReportsIsSchemaBoundFalse()
158+
{
159+
var sim = new Simulation();
160+
sim.ExecuteBatches(
161+
"create table dbo.b (id int not null)",
162+
"create view dbo.v as select id from dbo.b");
163+
IsFalse((bool)sim.ExecuteScalar("select is_schema_bound from sys.sql_modules where object_id = object_id('dbo.v')")!);
164+
}
165+
166+
// --- Catalog surface ---
167+
168+
[TestMethod]
169+
public void ViewIndex_SurfacesInSysIndexes_AsClusteredUnique()
170+
{
171+
var sim = SeedIndexedView();
172+
AreEqual(1, sim.ExecuteScalar(
173+
"select index_id from sys.indexes where object_id = object_id('dbo.v') and name = 'ix_v' and type_desc = 'CLUSTERED' and is_unique = 1 and is_primary_key = 0 and is_unique_constraint = 0"));
174+
// No HEAP row for a view (no index_id 0).
175+
AreEqual(0, sim.ExecuteScalar("select count(*) from sys.indexes where object_id = object_id('dbo.v') and index_id = 0"));
176+
}
177+
178+
[TestMethod]
179+
public void PlainView_HasNoSysIndexesRows()
180+
{
181+
var sim = new Simulation();
182+
sim.ExecuteBatches(
183+
"create table dbo.b (id int not null)",
184+
"create view dbo.v as select id from dbo.b");
185+
AreEqual(0, sim.ExecuteScalar("select count(*) from sys.indexes where object_id = object_id('dbo.v')"));
186+
}
187+
188+
[TestMethod]
189+
public void ViewIndex_SurfacesInSysIndexColumns_WithViewColumnId()
190+
{
191+
// Two key columns: id (column_id 1) and grp (column_id 2).
192+
var sim = new Simulation();
193+
sim.ExecuteBatches(
194+
"create table dbo.b (id int not null, grp int not null, val int not null); insert dbo.b values (1,10,100)",
195+
"create view dbo.v with schemabinding as select id, grp, val from dbo.b",
196+
"create unique clustered index ix_v on dbo.v(id, grp)");
197+
AreEqual(2, sim.ExecuteScalar("select count(*) from sys.index_columns where object_id = object_id('dbo.v') and is_included_column = 0"));
198+
AreEqual(1, sim.ExecuteScalar("select column_id from sys.index_columns where object_id = object_id('dbo.v') and key_ordinal = 1"));
199+
AreEqual(2, sim.ExecuteScalar("select column_id from sys.index_columns where object_id = object_id('dbo.v') and key_ordinal = 2"));
200+
}
201+
202+
[TestMethod]
203+
public void ViewIndex_SurfacesInSysStats()
204+
{
205+
var sim = SeedIndexedView();
206+
AreEqual(1, sim.ExecuteScalar("select stats_id from sys.stats where object_id = object_id('dbo.v') and name = 'ix_v'"));
207+
AreEqual(1, sim.ExecuteScalar("select count(*) from sys.stats_columns where object_id = object_id('dbo.v') and stats_id = 1"));
208+
}
209+
210+
// --- NOEXPAND ---
211+
212+
[TestMethod]
213+
public void Select_FromIndexedView_WithNoExpand_Accepted()
214+
=> AreEqual(3L, SeedIndexedView().ExecuteScalar("select count_big(*) from dbo.v with (noexpand)"));
215+
}

SqlServerSimulator/BuiltInResources.ColumnFamily.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,7 @@ SqlValue[] Row(SchemaObject obj) =>
442442
obj.DefinitionText is null ? SqlValue.Null(SqlType.NVarchar) : SqlValue.FromNVarchar(obj.DefinitionText),
443443
on, // uses_ansi_nulls
444444
on, // uses_quoted_identifier
445-
off, // is_schema_bound
445+
obj is View { IsSchemaBound: true } ? on : off, // is_schema_bound
446446
off, // uses_database_collation
447447
off, // is_recompiled
448448
obj is ScalarFunction { ReturnsNullOnNullInput: true } ? on : off, // null_on_null_input

0 commit comments

Comments
 (0)