Skip to content

Commit 7789a10

Browse files
committed
Database DDL triggers now populate sys.trigger_events; sys.database_principals.owning_principal_id reports 1 (dbo) for roles; the bacpac loader propagates computed-column PERSISTED [NOT NULL] through both emission paths and recomputes persisted values at data load so is_persisted/is_nullable round-trip; sys.database_query_store_options reports capture mode AUTO.
1 parent b665352 commit 7789a10

15 files changed

Lines changed: 816 additions & 59 deletions

SqlServerSimulator.Tests/Bacpac/BacpacLoaderTests.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,58 @@ public void ComputedColumn_LandsAs_is_computed_AndEvaluatesOnRead()
923923
AreEqual(42, sim.ExecuteScalar("SELECT Area FROM Rectangle WHERE Id = 2;"));
924924
}
925925

926+
[TestMethod]
927+
public void ComputedColumn_Persisted_LandsAs_is_persisted_AndComputesOnLoad()
928+
{
929+
// A PERSISTED computed column round-trips its IsPersisted flag to
930+
// sys.computed_columns (so DacFx re-export carries it), and its value
931+
// is computed at BCP-load time — the column has a storage slot but the
932+
// BCP wire carries no bytes for it. IsPersistedNullable=False maps to
933+
// PERSISTED NOT NULL so is_nullable also matches the source model.
934+
using var bacpac = BacpacBuilder.Create()
935+
.Table("dbo", "People", t => t
936+
.Column("PersonID", "int")
937+
.Column("FullName", "nvarchar(50)")
938+
.Column("PreferredName", "nvarchar(50)")
939+
.ComputedColumn("SearchName", "(concat([PreferredName],N' ',[FullName]))", persisted: true, persistedNullable: false)
940+
.Row(1, "Kayla Woodcock", "Kayla")
941+
.Row(2, "Hudson Onslow", "Hudson"))
942+
.Build();
943+
944+
var sim = new Simulation();
945+
sim.ImportBacpac(bacpac, out var diag);
946+
if (diag.Skipped.Count > 0)
947+
Fail("Unexpected Skipped: " + string.Join("; ", diag.Skipped.Select(s => $"{s.ElementType}/{s.ElementName}: {s.Reason}")));
948+
IsTrue((bool)sim.ExecuteScalar("SELECT is_persisted FROM sys.computed_columns WHERE object_id = OBJECT_ID('dbo.People') AND name = 'SearchName';")!);
949+
IsFalse((bool)sim.ExecuteScalar("SELECT is_nullable FROM sys.computed_columns WHERE object_id = OBJECT_ID('dbo.People') AND name = 'SearchName';")!);
950+
AreEqual("Kayla Kayla Woodcock", sim.ExecuteScalar("SELECT SearchName FROM dbo.People WHERE PersonID = 1;"));
951+
AreEqual("Hudson Hudson Onslow", sim.ExecuteScalar("SELECT SearchName FROM dbo.People WHERE PersonID = 2;"));
952+
}
953+
954+
[TestMethod]
955+
public void ComputedColumn_PersistedNullable_LandsAs_is_persisted_Nullable()
956+
{
957+
// A nullable PERSISTED computed column (IsPersistedNullable=True) maps
958+
// to a bare PERSISTED marker — is_persisted = 1, is_nullable = 1.
959+
using var bacpac = BacpacBuilder.Create()
960+
.Table("dbo", "Txn", t => t
961+
.Column("Id", "int")
962+
.Column("FinalizedOn", "date", nullable: true)
963+
.ComputedColumn("IsFinalized", "(case when [FinalizedOn] is null then CONVERT([bit],(0)) else CONVERT([bit],(1)) end)", persisted: true)
964+
.Row(1, null)
965+
.Row(2, new DateOnly(2025, 1, 1)))
966+
.Build();
967+
968+
var sim = new Simulation();
969+
sim.ImportBacpac(bacpac, out var diag);
970+
if (diag.Skipped.Count > 0)
971+
Fail("Unexpected Skipped: " + string.Join("; ", diag.Skipped.Select(s => $"{s.ElementType}/{s.ElementName}: {s.Reason}")));
972+
IsTrue((bool)sim.ExecuteScalar("SELECT is_persisted FROM sys.computed_columns WHERE object_id = OBJECT_ID('dbo.Txn') AND name = 'IsFinalized';")!);
973+
IsTrue((bool)sim.ExecuteScalar("SELECT is_nullable FROM sys.computed_columns WHERE object_id = OBJECT_ID('dbo.Txn') AND name = 'IsFinalized';")!);
974+
IsFalse((bool)sim.ExecuteScalar("SELECT IsFinalized FROM dbo.Txn WHERE Id = 1;")!);
975+
IsTrue((bool)sim.ExecuteScalar("SELECT IsFinalized FROM dbo.Txn WHERE Id = 2;")!);
976+
}
977+
926978
[TestMethod]
927979
public void ComputedColumn_MidTable_LandsAtModelOrdinal()
928980
{

SqlServerSimulator.Tests/Bacpac/TableBuilder.cs

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,16 +70,18 @@ public TableBuilder SystemVersioned(string historySchema, string historyTable)
7070
}
7171

7272
/// <summary>
73-
/// Adds a computed column. The loader emits the column in phase 8
74-
/// (after UDFs have landed) via <c>ALTER TABLE … ADD col AS (expr)</c>.
75-
/// PERSISTED is intentionally dropped by the loader regardless of the
76-
/// builder's hint — BCP files don't carry data for computed columns,
77-
/// so persisted-computed columns would have no stored bytes; the
78-
/// simulator recomputes on every read instead.
73+
/// Adds a computed column. The loader emits it inline in CREATE TABLE at
74+
/// its model ordinal (or in phase 8 via <c>ALTER TABLE … ADD col AS (expr)</c>
75+
/// when the expression forward-references a not-yet-created UDF). When
76+
/// <paramref name="persisted"/> is set the model carries
77+
/// <c>IsPersisted=True</c> (and <c>IsPersistedNullable</c>), which the loader
78+
/// translates to a <c>PERSISTED</c> / <c>PERSISTED NOT NULL</c> marker — the
79+
/// column then has a storage slot and its value is computed at BCP-load
80+
/// time (the BCP wire carries no bytes for computed columns).
7981
/// </summary>
80-
public TableBuilder ComputedColumn(string name, string expression, bool persisted = false)
82+
public TableBuilder ComputedColumn(string name, string expression, bool persisted = false, bool persistedNullable = true)
8183
{
82-
_computedColumns.Add(new ComputedColumnDef(name, expression, persisted));
84+
_computedColumns.Add(new ComputedColumnDef(name, expression, persisted, persistedNullable));
8385
_order.Add((Computed: true, _computedColumns.Count - 1));
8486
return this;
8587
}
@@ -224,6 +226,9 @@ private XElement ComputedColumnElement(XNamespace ns, ComputedColumnDef computed
224226
element.Add(new XElement(ns + "Property",
225227
new XAttribute("Name", "IsPersisted"),
226228
new XAttribute("Value", "True")));
229+
element.Add(new XElement(ns + "Property",
230+
new XAttribute("Name", "IsPersistedNullable"),
231+
new XAttribute("Value", computed.PersistedNullable ? "True" : "False")));
227232
}
228233
return element;
229234
}
@@ -567,4 +572,4 @@ internal sealed record ForeignKeyDef(
567572
internal readonly record struct IndexDef(string Name, string[] KeyColumns, string[] IncludedColumns, bool Unique, bool Clustered);
568573

569574
/// <summary>Computed column declaration accumulated via <see cref="TableBuilder.ComputedColumn"/>.</summary>
570-
internal readonly record struct ComputedColumnDef(string Name, string Expression, bool Persisted);
575+
internal readonly record struct ComputedColumnDef(string Name, string Expression, bool Persisted, bool PersistedNullable);

SqlServerSimulator.Tests/DacFxExportCatalogTests.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,32 @@ public void UnmodeledFeatureViews_ResolveEmpty()
6868
}
6969
}
7070

71+
/// <summary>
72+
/// Database roles must carry a non-NULL <c>owning_principal_id</c> (dbo,
73+
/// principal_id 1 — probe-confirmed on WWI's custom roles). DacFx's role
74+
/// reverse-engineering filters <c>USER_NAME(owning_principal_id) != N'cdc'</c>;
75+
/// a NULL owner makes that predicate UNKNOWN and silently drops every
76+
/// SqlRole from the bacpac export. This reproduces the exact populator
77+
/// query and asserts the role survives.
78+
/// </summary>
79+
[TestMethod]
80+
public void DatabaseRole_OwnedByDbo_SurvivesDacFxRoleFilter()
81+
{
82+
var sim = new Simulation();
83+
_ = sim.ExecuteNonQuery("create role [External Sales]");
84+
AreEqual(1, sim.ExecuteScalar(
85+
"select owning_principal_id from sys.database_principals where name = 'External Sales'"));
86+
AreEqual("dbo", sim.ExecuteScalar(
87+
"select USER_NAME(owning_principal_id) from sys.database_principals where name = 'External Sales'"));
88+
// The DacFx SqlRole populator query, verbatim in shape.
89+
AreEqual(1, sim.ExecuteScalar("""
90+
select count(*) from sys.database_principals dp
91+
where dp.type = N'R'
92+
and dp.name = N'External Sales'
93+
and USER_NAME(dp.owning_principal_id) != N'cdc'
94+
"""));
95+
}
96+
7197
/// <summary>
7298
/// A representative column-shape check: selecting specific columns from the
7399
/// empty views resolves (no Msg 207) and returns an empty set — including

SqlServerSimulator.Tests/DdlTriggerTests.cs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,97 @@ public void SysTriggers_DdlTrigger_ReportsTrAndSqlTriggerType()
104104
AreEqual("SQL_TRIGGER", sim.ExecuteScalar(
105105
"select type_desc from sys.triggers where name = 'trg_ddl'"));
106106
}
107+
108+
[TestMethod]
109+
public void TriggerEvents_DdlDatabaseLevelEvents_ExpandsTo158LeafRows()
110+
{
111+
// A DDL trigger created FOR DDL_DATABASE_LEVEL_EVENTS surfaces one
112+
// sys.trigger_events row per leaf event in the group's transitive
113+
// closure — 158 rows, each tagged with the group id (10016) and desc,
114+
// probe-confirmed against SQL Server 2025's AdventureWorks2025.
115+
var sim = new Simulation();
116+
_ = sim.ExecuteNonQuery(CreateAwTrigger);
117+
AreEqual(158, sim.ExecuteScalar("""
118+
select count(*) from sys.trigger_events e
119+
join sys.triggers t on t.object_id = e.object_id
120+
where t.parent_class = 0
121+
"""));
122+
AreEqual(1, sim.ExecuteScalar("""
123+
select count(distinct event_group_type) from sys.trigger_events e
124+
join sys.triggers t on t.object_id = e.object_id
125+
where t.parent_class = 0
126+
"""));
127+
AreEqual(10016, sim.ExecuteScalar("""
128+
select top 1 event_group_type from sys.trigger_events e
129+
join sys.triggers t on t.object_id = e.object_id
130+
where t.parent_class = 0
131+
"""));
132+
AreEqual("DDL_DATABASE_LEVEL_EVENTS", sim.ExecuteScalar("""
133+
select top 1 event_group_type_desc from sys.trigger_events e
134+
join sys.triggers t on t.object_id = e.object_id
135+
where t.parent_class = 0
136+
"""));
137+
}
138+
139+
[TestMethod]
140+
public void TriggerEvents_DdlDatabaseLevelEvents_LeafRowShape()
141+
{
142+
// Sample leaf rows probe-confirmed on the reference: RENAME (241),
143+
// CREATE_COLUMN_MASTER_KEY (315), ALTER_DATABASE_SCOPED_CONFIGURATION
144+
// (320). is_first / is_last are 0, is_trigger_event is 1.
145+
var sim = new Simulation();
146+
_ = sim.ExecuteNonQuery(CreateAwTrigger);
147+
AreEqual("RENAME", sim.ExecuteScalar(
148+
"select e.type_desc from sys.trigger_events e join sys.triggers t on t.object_id = e.object_id where t.parent_class = 0 and e.type = 241"));
149+
AreEqual("CREATE_COLUMN_MASTER_KEY", sim.ExecuteScalar(
150+
"select e.type_desc from sys.trigger_events e join sys.triggers t on t.object_id = e.object_id where t.parent_class = 0 and e.type = 315"));
151+
AreEqual("ALTER_DATABASE_SCOPED_CONFIGURATION", sim.ExecuteScalar(
152+
"select e.type_desc from sys.trigger_events e join sys.triggers t on t.object_id = e.object_id where t.parent_class = 0 and e.type = 320"));
153+
IsFalse((bool)sim.ExecuteScalar(
154+
"select is_first from sys.trigger_events e join sys.triggers t on t.object_id = e.object_id where t.parent_class = 0 and e.type = 241")!);
155+
IsFalse((bool)sim.ExecuteScalar(
156+
"select is_last from sys.trigger_events e join sys.triggers t on t.object_id = e.object_id where t.parent_class = 0 and e.type = 241")!);
157+
IsTrue((bool)sim.ExecuteScalar(
158+
"select is_trigger_event from sys.trigger_events e join sys.triggers t on t.object_id = e.object_id where t.parent_class = 0 and e.type = 241")!);
159+
}
160+
161+
[TestMethod]
162+
public void TriggerEvents_IndividualEvent_HasNullGroup()
163+
{
164+
// A DDL trigger created FOR a single event (not a group) surfaces one
165+
// row with a NULL event_group_type.
166+
var sim = new Simulation();
167+
_ = sim.ExecuteNonQuery("""
168+
create trigger [oneEvent]
169+
on database
170+
for create_table as
171+
begin set nocount on; end
172+
""");
173+
AreEqual(1, sim.ExecuteScalar(
174+
"select count(*) from sys.trigger_events e join sys.triggers t on t.object_id = e.object_id where t.name = 'oneEvent'"));
175+
AreEqual(21, sim.ExecuteScalar(
176+
"select e.type from sys.trigger_events e join sys.triggers t on t.object_id = e.object_id where t.name = 'oneEvent'"));
177+
AreEqual("CREATE_TABLE", sim.ExecuteScalar(
178+
"select e.type_desc from sys.trigger_events e join sys.triggers t on t.object_id = e.object_id where t.name = 'oneEvent'"));
179+
AreEqual(0, sim.ExecuteScalar(
180+
"select count(event_group_type) from sys.trigger_events e join sys.triggers t on t.object_id = e.object_id where t.name = 'oneEvent'"));
181+
}
182+
183+
[TestMethod]
184+
public void SysTriggerEventTypes_ExposesStaticCatalog()
185+
{
186+
// The static sys.trigger_event_types catalog (312 rows, probe-confirmed
187+
// shape). DDL_DATABASE_LEVEL_EVENTS (10016) parents to DDL_EVENTS
188+
// (10001); CREATE_TABLE (21) parents to DDL_TABLE_EVENTS (10018).
189+
var sim = new Simulation();
190+
AreEqual(312, sim.ExecuteScalar("select count(*) from sys.trigger_event_types"));
191+
AreEqual("DDL_DATABASE_LEVEL_EVENTS", sim.ExecuteScalar(
192+
"select type_name from sys.trigger_event_types where type = 10016"));
193+
AreEqual(10001, sim.ExecuteScalar(
194+
"select parent_type from sys.trigger_event_types where type = 10016"));
195+
AreEqual(10018, sim.ExecuteScalar(
196+
"select parent_type from sys.trigger_event_types where type = 21"));
197+
AreEqual(0, sim.ExecuteScalar(
198+
"select count(parent_type) from sys.trigger_event_types where type = 10001"));
199+
}
107200
}

SqlServerSimulator/BuiltInResources.ConstraintsAndTriggers.cs

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,20 @@ void Iso(string name, HeapColumn[] columns, Func<Parser.BatchContext, Database,
5757
new("is_trigger_event", SqlType.Bit, null, true),
5858
], EnumerateSysTriggerEvents);
5959

60+
// sys.trigger_event_types: SQL Server's static event-type catalog
61+
// (probe-confirmed 3-column shape, SQL Server 2025). Version-stable
62+
// reference data backing the DDL-trigger expansion in
63+
// sys.trigger_events; surfaced as a queryable view for parity. type is
64+
// NOT NULL; type_name is nvarchar(64); parent_type is NULL for the two
65+
// roots (DDL_EVENTS, ALTER_SERVER_CONFIGURATION).
66+
var eventTypeNameCol = NVarcharSqlType.Get(64, Collation.Catalog, Coercibility.Implicit);
67+
Sys("trigger_event_types",
68+
[
69+
new("type", SqlType.Int32, null, false),
70+
new("type_name", eventTypeNameCol, 64, true),
71+
new("parent_type", SqlType.Int32, null, true),
72+
], (batch, database) => EnumerateSysTriggerEventTypes(eventTypeNameCol));
73+
6074
// sys.assembly_modules: CLR (SQLCLR) modules aren't modeled, so this is
6175
// an empty view with the documented SQL Server 2025 shape. SMO's
6276
// CREATE-scripting trigger query LEFT JOINs it to detect a CLR trigger.
@@ -354,20 +368,28 @@ private static IEnumerable<SqlValue[]> EnumerateSysTriggers(
354368
}
355369

356370
/// <summary>
357-
/// Rows for <c>sys.trigger_events</c>: one row per (DML trigger, event).
358-
/// The internal <see cref="TriggerActions"/> bit flags (INSERT=1, UPDATE=2,
359-
/// DELETE=4) map to real SQL Server's dense event type codes (INSERT=1,
360-
/// UPDATE=2, DELETE=3). DDL triggers aren't surfaced (their events are DDL
361-
/// event types, which SMO's per-table trigger query never reads).
371+
/// Rows for <c>sys.trigger_events</c>: one row per (trigger, event). For
372+
/// DML triggers the internal <see cref="TriggerActions"/> bit flags
373+
/// (INSERT=1, UPDATE=2, DELETE=4) map to real SQL Server's dense event type
374+
/// codes (INSERT=1, UPDATE=2, DELETE=3) with a NULL <c>event_group_type</c>.
375+
/// For DDL triggers each stored event-type name expands via
376+
/// <see cref="TriggerEventTypes"/>: a group name
377+
/// (<c>DDL_DATABASE_LEVEL_EVENTS</c>) yields one row per <em>leaf</em> event
378+
/// in the group's transitive closure — each carrying the group's id/desc in
379+
/// <c>event_group_type(_desc)</c> — while an individual event name yields a
380+
/// single row with a NULL group. <c>is_first</c> / <c>is_last</c> default 0
381+
/// (no <c>sp_settriggerorder</c> modeled); <c>is_trigger_event</c> is 1.
382+
/// DacFx's SqlDatabaseDdlTrigger reverse-engineering builds the element's
383+
/// EventType relationship from these rows.
362384
/// </summary>
363385
private static IEnumerable<SqlValue[]> EnumerateSysTriggerEvents(Parser.BatchContext batch, Database database)
364386
{
365387
_ = batch;
366388
var falseBit = SqlValue.FromBoolean(false);
367389
var trueBit = SqlValue.FromBoolean(true);
368390
var nullInt = SqlValue.Null(SqlType.Int32);
369-
var nullDesc = SqlValue.Null(NVarcharSqlType.Get(128, Collation.Catalog, Coercibility.Implicit));
370391
var eventTypeName = NVarcharSqlType.Get(128, Collation.Catalog, Coercibility.Implicit);
392+
var nullDesc = SqlValue.Null(eventTypeName);
371393
(int Type, string Desc)[] events =
372394
[
373395
(1, "INSERT"),
@@ -397,6 +419,74 @@ private static IEnumerable<SqlValue[]> EnumerateSysTriggerEvents(Parser.BatchCon
397419
}
398420
}
399421
}
422+
423+
// DDL triggers: each stored event-type name is either an event group
424+
// (expand to the leaf events in its transitive closure, tagging each
425+
// with the group's id/desc) or an individual event (one row, NULL
426+
// group). Duplicate leaves across overlapping group names are
427+
// de-duplicated so a trigger declared FOR two overlapping groups
428+
// doesn't double-emit a shared event.
429+
foreach (var ddl in database.DdlTriggers.Values.OrderBy(t => t.ObjectId))
430+
{
431+
var objectId = SqlValue.FromInt32(ddl.ObjectId);
432+
var emitted = new HashSet<int>();
433+
foreach (var eventName in ddl.EventTypes)
434+
{
435+
if (!TriggerEventTypes.TryResolve(eventName, out var entry))
436+
continue;
437+
if (TriggerEventTypes.IsGroup(entry))
438+
{
439+
var groupType = SqlValue.FromInt32(entry.Type);
440+
var groupDesc = SqlValue.FromString(eventTypeName, entry.TypeName);
441+
foreach (var leaf in TriggerEventTypes.LeafClosure(entry.Type))
442+
{
443+
if (!emitted.Add(leaf.Type))
444+
continue;
445+
yield return [
446+
objectId,
447+
SqlValue.FromInt32(leaf.Type),
448+
SqlValue.FromString(eventTypeName, leaf.TypeName),
449+
falseBit,
450+
falseBit,
451+
groupType,
452+
groupDesc,
453+
trueBit,
454+
];
455+
}
456+
}
457+
else if (emitted.Add(entry.Type))
458+
{
459+
yield return [
460+
objectId,
461+
SqlValue.FromInt32(entry.Type),
462+
SqlValue.FromString(eventTypeName, entry.TypeName),
463+
falseBit,
464+
falseBit,
465+
nullInt,
466+
nullDesc,
467+
trueBit,
468+
];
469+
}
470+
}
471+
}
472+
}
473+
474+
/// <summary>
475+
/// Rows for <c>sys.trigger_event_types</c>: the static event-type catalog
476+
/// (<see cref="TriggerEventTypes.All"/>). Server-scoped — identical across
477+
/// every database.
478+
/// </summary>
479+
private static IEnumerable<SqlValue[]> EnumerateSysTriggerEventTypes(NVarcharSqlType typeName)
480+
{
481+
var nullParent = SqlValue.Null(SqlType.Int32);
482+
foreach (var entry in TriggerEventTypes.All)
483+
{
484+
yield return [
485+
SqlValue.FromInt32(entry.Type),
486+
SqlValue.FromString(typeName, entry.TypeName),
487+
entry.ParentType is int parent ? SqlValue.FromInt32(parent) : nullParent,
488+
];
489+
}
400490
}
401491

402492
/// <summary>

0 commit comments

Comments
 (0)