Skip to content

Commit 7149e68

Browse files
committed
Extended-property values ride sql_variant with per-value base-type fidelity, CREATE TABLE gains ROWGUIDCOL and IDENTITY NOT FOR REPLICATION, all three surfaced through sys.columns.is_rowguidcol / sys.identity_columns.is_not_for_replication / COLUMNPROPERTY.
1 parent 20255ed commit 7149e68

18 files changed

Lines changed: 273 additions & 54 deletions

SqlServerSimulator.Tests/Bacpac/BacpacLoaderTests.cs

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,10 +1314,11 @@ public void Role_WithAuthorizationOwner_LandsAsCreateRoleAuthorization()
13141314
}
13151315

13161316
[TestMethod]
1317-
public void RowGuidColColumn_AddsWarningAndLoadsWithoutClause()
1317+
public void RowGuidColColumn_LoadsWithClause_SetsIsRowGuidCol()
13181318
{
1319-
// ROWGUIDCOL is metadata-only — the loader drops the clause and
1320-
// records a Warning. Exercises the ROWGUIDCOL warning emission path.
1319+
// ROWGUIDCOL round-trips: the loader emits the clause so
1320+
// sys.columns.is_rowguidcol reports it (DacFx re-emits
1321+
// IsRowGuidColumn=True on export). No Skipped, no Warning.
13211322
using var bacpac = BacpacBuilder.Create()
13221323
.Table("dbo", "Tagged", t => t
13231324
.Column("Id", "int")
@@ -1327,9 +1328,28 @@ public void RowGuidColColumn_AddsWarningAndLoadsWithoutClause()
13271328
var sim = new Simulation();
13281329
sim.ImportBacpac(bacpac, out var diag);
13291330
IsEmpty(diag.Skipped);
1330-
IsNotEmpty(diag.Warnings.Where(w => w.Contains("ROWGUIDCOL", StringComparison.Ordinal)).ToList());
1331-
// Column still loads, just without the metadata annotation.
1332-
AreEqual(1, sim.ExecuteScalar("SELECT COUNT(*) FROM sys.columns WHERE name = 'RowId';"));
1331+
IsEmpty(diag.Warnings.Where(w => w.Contains("ROWGUIDCOL", StringComparison.Ordinal)).ToList());
1332+
IsTrue((bool)sim.ExecuteScalar(
1333+
"SELECT is_rowguidcol FROM sys.columns WHERE object_id = object_id('dbo.Tagged') AND name = 'RowId';")!);
1334+
}
1335+
1336+
[TestMethod]
1337+
public void IdentityNotForReplicationColumn_LoadsWithClause_SetsFlag()
1338+
{
1339+
// IdentityIsNotForReplication round-trips through
1340+
// sys.identity_columns.is_not_for_replication so DacFx re-emits the
1341+
// property on export.
1342+
using var bacpac = BacpacBuilder.Create()
1343+
.Table("dbo", "Seeded", t => t
1344+
.Column("Id", "int", identity: true, identityNotForReplication: true)
1345+
.Column("Val", "int", nullable: true))
1346+
.Build();
1347+
1348+
var sim = new Simulation();
1349+
sim.ImportBacpac(bacpac, out var diag);
1350+
IsEmpty(diag.Skipped);
1351+
IsTrue((bool)sim.ExecuteScalar(
1352+
"SELECT is_not_for_replication FROM sys.identity_columns WHERE object_id = object_id('dbo.Seeded');")!);
13331353
}
13341354

13351355
[TestMethod]

SqlServerSimulator.Tests/Bacpac/TableBuilder.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ internal TableBuilder(string schemaName, string tableName)
4646
/// IsNullable=True default, but for test setup NOT NULL is more
4747
/// useful so explicit assertions on NULL-handling become opt-in.
4848
/// </summary>
49-
public TableBuilder Column(string name, string sqlType, bool nullable = false, bool identity = false, int identitySeed = 1, int identityIncrement = 1, PeriodColumnKind periodKind = PeriodColumnKind.None, string? collation = null, bool rowGuidCol = false, string? xmlSchemaCollection = null)
49+
public TableBuilder Column(string name, string sqlType, bool nullable = false, bool identity = false, int identitySeed = 1, int identityIncrement = 1, PeriodColumnKind periodKind = PeriodColumnKind.None, string? collation = null, bool rowGuidCol = false, string? xmlSchemaCollection = null, bool identityNotForReplication = false)
5050
{
51-
_columns.Add(new ColumnDef(name, sqlType, nullable, identity, identitySeed, identityIncrement, periodKind, collation, rowGuidCol, xmlSchemaCollection));
51+
_columns.Add(new ColumnDef(name, sqlType, nullable, identity, identitySeed, identityIncrement, periodKind, collation, rowGuidCol, xmlSchemaCollection, identityNotForReplication));
5252
_order.Add((Computed: false, _columns.Count - 1));
5353
return this;
5454
}
@@ -452,6 +452,8 @@ private XElement ColumnElement(XNamespace ns, ColumnDef column)
452452
element.Add(PropertyElement(ns, "IsIdentity", "True"));
453453
element.Add(PropertyElement(ns, "IdentitySeed", column.IdentitySeed.ToString(System.Globalization.CultureInfo.InvariantCulture)));
454454
element.Add(PropertyElement(ns, "IdentityIncrement", column.IdentityIncrement.ToString(System.Globalization.CultureInfo.InvariantCulture)));
455+
if (column.IdentityNotForReplication)
456+
element.Add(PropertyElement(ns, "IdentityIsNotForReplication", "True"));
455457
}
456458
if (column.PeriodKind == PeriodColumnKind.Start)
457459
element.Add(PropertyElement(ns, "GeneratedAlwaysType", "1"));
@@ -574,7 +576,7 @@ public enum PeriodColumnKind
574576
}
575577

576578
/// <summary>Column metadata captured by <see cref="TableBuilder.Column"/>.</summary>
577-
internal readonly record struct ColumnDef(string Name, string SqlType, bool Nullable, bool Identity = false, int IdentitySeed = 1, int IdentityIncrement = 1, PeriodColumnKind PeriodKind = PeriodColumnKind.None, string? Collation = null, bool RowGuidCol = false, string? XmlSchemaCollectionRef = null);
579+
internal readonly record struct ColumnDef(string Name, string SqlType, bool Nullable, bool Identity = false, int IdentitySeed = 1, int IdentityIncrement = 1, PeriodColumnKind PeriodKind = PeriodColumnKind.None, string? Collation = null, bool RowGuidCol = false, string? XmlSchemaCollectionRef = null, bool IdentityNotForReplication = false);
578580

579581
/// <summary>Base for constraint declarations accumulated on a table.</summary>
580582
internal abstract record ConstraintDef(string Name);

SqlServerSimulator.Tests/ExtendedPropertyTests.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,34 @@ public void Update_ChangesValue()
139139
"SELECT CAST(value AS nvarchar(MAX)) FROM sys.extended_properties WHERE name = 'X'"));
140140
}
141141

142+
[TestMethod]
143+
public void Value_NvarcharInput_SurvivesAsNvarcharBaseType()
144+
{
145+
// DacFx reads sys.extended_properties.value as sql_variant and scripts
146+
// the value with an N-prefix iff its base type is nvarchar. An N'…'
147+
// input must round-trip as nvarchar (probe-confirmed on SQL Server 2025:
148+
// @value=N'…' stores base type nvarchar).
149+
var sim = new Simulation();
150+
_ = sim.ExecuteNonQuery(
151+
"EXEC sp_addextendedproperty @name=N'X', @value=N'ünïcode', @level0type=N'SCHEMA', @level0name=N'dbo'");
152+
AreEqual("nvarchar", sim.ExecuteScalar(
153+
"SELECT SQL_VARIANT_PROPERTY(value, 'BaseType') FROM sys.extended_properties WHERE name = 'X'"));
154+
AreEqual("ünïcode", sim.ExecuteScalar(
155+
"SELECT CAST(value AS nvarchar(MAX)) FROM sys.extended_properties WHERE name = 'X'"));
156+
}
157+
158+
/// <summary>
159+
/// A plain (non-N) literal stores varchar base type — probe-confirmed on
160+
/// SQL Server 2025 (@value='plain' → varchar). The value column keeps
161+
/// per-cell base types like the real sql_variant.
162+
/// </summary>
163+
[TestMethod]
164+
public void Value_VarcharInput_SurvivesAsVarcharBaseType()
165+
=> AreEqual("varchar", new Simulation().ExecuteScalar("""
166+
EXEC sp_addextendedproperty @name=N'X', @value='plain', @level0type=N'SCHEMA', @level0name=N'dbo';
167+
SELECT SQL_VARIANT_PROPERTY(value, 'BaseType') FROM sys.extended_properties WHERE name = 'X'
168+
"""));
169+
142170
[TestMethod]
143171
public void Update_OnMissing_RaisesMsg15217()
144172
{

SqlServerSimulator.Tests/IdentityTests.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,4 +279,31 @@ insert t (x) values (1),(2)
279279
_ = s2.ExecuteNonQuery("create table t (id int identity(1,1), x int)");
280280
AreEqual(1m, s2.ExecuteScalar("select IDENT_CURRENT('t')"));
281281
}
282+
283+
[TestMethod]
284+
public void NotForReplication_SetsIdentityColumnFlag()
285+
{
286+
// NOT FOR REPLICATION has no runtime effect (replication isn't modeled);
287+
// it round-trips through sys.identity_columns + COLUMNPROPERTY for BACPAC
288+
// parity. DacFx reads is_not_for_replication to emit
289+
// IdentityIsNotForReplication=True.
290+
var sim = new Simulation();
291+
_ = sim.ExecuteNonQuery("create table t (id int identity(1,1) not for replication, x int)");
292+
IsTrue((bool)sim.ExecuteScalar(
293+
"select is_not_for_replication from sys.identity_columns where object_id = object_id('t')")!);
294+
AreEqual(1, sim.ExecuteScalar("select COLUMNPROPERTY(object_id('t'), 'id', 'IsIdNotForRepl')"));
295+
// Auto-generation still works normally.
296+
_ = sim.ExecuteNonQuery("insert t (x) values (10),(20)");
297+
AreEqual(2, sim.ExecuteScalar("select id from t where x = 20"));
298+
}
299+
300+
[TestMethod]
301+
public void IdentityWithoutNotForReplication_FlagIsZero()
302+
{
303+
var sim = new Simulation();
304+
_ = sim.ExecuteNonQuery("create table t (id int identity(1,1), x int)");
305+
IsFalse((bool)sim.ExecuteScalar(
306+
"select is_not_for_replication from sys.identity_columns where object_id = object_id('t')")!);
307+
AreEqual(0, sim.ExecuteScalar("select COLUMNPROPERTY(object_id('t'), 'id', 'IsIdNotForRepl')"));
308+
}
282309
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Exercises the <c>ROWGUIDCOL</c> column marker: a uniqueidentifier-only,
7+
/// one-per-table metadata annotation with no runtime effect (the simulator
8+
/// doesn't model the <c>$ROWGUID</c> pseudo-column). It round-trips through
9+
/// <c>sys.columns.is_rowguidcol</c> / <c>COLUMNPROPERTY(…, 'IsRowGuidCol')</c>
10+
/// for BACPAC parity. Errors probed against SQL Server 2025 (2026-07-17):
11+
/// Msg 2761 (non-uniqueidentifier) and Msg 8196 (duplicate) — both compile-time.
12+
/// </summary>
13+
[TestClass]
14+
public sealed class RowGuidColTests
15+
{
16+
[TestMethod]
17+
public void RowGuidCol_SetsSysColumnsFlag()
18+
=> IsTrue((bool)new Simulation().ExecuteScalar("""
19+
create table t (id uniqueidentifier rowguidcol default newid(), x int);
20+
select is_rowguidcol from sys.columns where object_id = object_id('t') and name = 'id'
21+
""")!);
22+
23+
[TestMethod]
24+
public void RowGuidCol_ColumnProperty_ReportsOne()
25+
=> AreEqual(1, new Simulation().ExecuteScalar("""
26+
create table t (id uniqueidentifier rowguidcol, x int);
27+
select COLUMNPROPERTY(object_id('t'), 'id', 'IsRowGuidCol')
28+
"""));
29+
30+
[TestMethod]
31+
public void NonRowGuidColumn_FlagIsZero()
32+
=> IsFalse((bool)new Simulation().ExecuteScalar("""
33+
create table t (id uniqueidentifier, x int);
34+
select is_rowguidcol from sys.columns where object_id = object_id('t') and name = 'id'
35+
""")!);
36+
37+
[TestMethod]
38+
public void RowGuidCol_OnNonUniqueIdentifier_RaisesMsg2761()
39+
{
40+
var ex = new Simulation().AssertSqlError(
41+
"create table t (id int rowguidcol)", 2761);
42+
Assert.Contains("uniqueidentifier data type", ex.Message);
43+
}
44+
45+
[TestMethod]
46+
public void DuplicateRowGuidCol_RaisesMsg8196()
47+
{
48+
var ex = new Simulation().AssertSqlError(
49+
"create table t (a uniqueidentifier rowguidcol, b uniqueidentifier rowguidcol)", 8196);
50+
Assert.Contains("Duplicate column specified as ROWGUIDCOL", ex.Message);
51+
}
52+
}

SqlServerSimulator/BuiltInResources.ColumnFamily.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,7 @@ private static IEnumerable<SqlValue[]> EnumerateIdentityColumns(Parser.BatchCont
387387
SqlValue.FromInt64(identity.Seed),
388388
SqlValue.FromInt64(identity.Increment),
389389
identity.Snapshot() is { } last ? SqlValue.FromInt64(last) : nullLast,
390-
falseBit,
390+
SqlValue.FromBoolean(identity.NotForReplication),
391391
];
392392
}
393393
}

SqlServerSimulator/BuiltInResources.CoreObjects.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ SqlValue CollationFor(HeapColumn c) =>
430430
falseBit,
431431
SqlValue.FromBoolean(col.IsHidden),
432432
falseBit,
433-
falseBit,
433+
SqlValue.FromBoolean(col.IsRowGuidCol),
434434
zeroInt,
435435
];
436436
}
@@ -477,7 +477,7 @@ SqlValue CollationFor(HeapColumn c) =>
477477
falseBit,
478478
SqlValue.FromBoolean(col.IsHidden),
479479
falseBit,
480-
falseBit,
480+
SqlValue.FromBoolean(col.IsRowGuidCol),
481481
zeroInt,
482482
];
483483
}
@@ -524,7 +524,7 @@ SqlValue CollationFor(HeapColumn c) =>
524524
falseBit,
525525
SqlValue.FromBoolean(col.IsHidden),
526526
falseBit,
527-
falseBit,
527+
SqlValue.FromBoolean(col.IsRowGuidCol),
528528
zeroInt,
529529
];
530530
}
@@ -571,7 +571,7 @@ SqlValue CollationFor(HeapColumn c) =>
571571
falseBit,
572572
SqlValue.FromBoolean(col.IsHidden),
573573
falseBit,
574-
falseBit,
574+
SqlValue.FromBoolean(col.IsRowGuidCol),
575575
zeroInt,
576576
];
577577
}

SqlServerSimulator/BuiltInResources.Security.cs

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -88,17 +88,19 @@ void Sys(string name, HeapColumn[] columns, Func<Parser.BatchContext, Database,
8888
// attached to schemas / tables / columns / etc. via the
8989
// sp_addextendedproperty / sp_updateextendedproperty /
9090
// sp_dropextendedproperty trio. Real SQL Server's `value` column is
91-
// typed `sql_variant` — the simulator surfaces it as `nvarchar(MAX)`
92-
// since sql_variant isn't modeled; AW's 538 properties are all
93-
// nvarchar values so functional fidelity is preserved.
91+
// typed `sql_variant`, carrying the base type of the value the sproc
92+
// stored (nvarchar for an N'…' literal, varchar for a plain literal).
93+
// The simulator surfaces it as sql_variant too so DacFx reads the
94+
// base type off the wire and re-scripts the value with the correct
95+
// N-prefix on export — a BACPAC round-trip preserves nvarchar.
9496
Sys("extended_properties",
9597
[
9698
new("class", SqlType.TinyInt, null, false),
9799
new("class_desc", SqlType.SystemName, 60, true),
98100
new("major_id", SqlType.Int32, null, false),
99101
new("minor_id", SqlType.Int32, null, false),
100102
new("name", SqlType.SystemName, 128, false),
101-
new("value", NVarcharSqlType.Get(-1, Collation.Baseline, Coercibility.CoercibleDefault), SqlType.MaxLengthSentinel, true),
103+
new("value", SqlType.SqlVariant, null, true),
102104
], EnumerateSysExtendedProperties);
103105

104106
// sys.database_principals: probe-confirmed shipped subset of columns
@@ -471,10 +473,10 @@ void Sys(string name, HeapColumn[] columns, Func<Parser.BatchContext, Database,
471473
/// derived from the class number per real SQL Server's enum (0 =
472474
/// DATABASE, 1 = OBJECT_OR_COLUMN, 3 = SCHEMA — the only classes the
473475
/// simulator currently emits; others fall through as the string form
474-
/// of the class number for forward compat). Value is coerced to
475-
/// <c>nvarchar(MAX)</c> since the simulator doesn't model
476-
/// <c>sql_variant</c>; for AW's all-nvarchar workload, this is a
477-
/// lossless surfacing.
476+
/// of the class number for forward compat). Value is surfaced as a
477+
/// <c>sql_variant</c> wrapping the stored value's own type, so the value's
478+
/// base type (nvarchar vs varchar) survives to <c>SQL_VARIANT_PROPERTY</c>
479+
/// and the TDS wire — DacFx reads it to re-script the correct N-prefix.
478480
/// </summary>
479481
private static IEnumerable<SqlValue[]> EnumerateSysExtendedProperties(Parser.BatchContext batch, Database database)
480482
{
@@ -495,7 +497,7 @@ private static IEnumerable<SqlValue[]> EnumerateSysExtendedProperties(Parser.Bat
495497
SqlValue.FromInt32(key.MajorId),
496498
SqlValue.FromInt32(key.MinorId),
497499
SqlValue.FromSystemName(key.Name),
498-
kvp.Value.IsNull ? SqlValue.Null(NVarcharSqlType.Get(-1, Collation.Baseline, Coercibility.CoercibleDefault)) : kvp.Value.CoerceTo(NVarcharSqlType.Get(-1, Collation.Baseline, Coercibility.CoercibleDefault)),
500+
kvp.Value.IsNull ? SqlValue.Null(SqlType.SqlVariant) : SqlValue.FromVariant(kvp.Value),
499501
];
500502
}
501503
}

SqlServerSimulator/Errors/SimulatedSqlException.SchemaErrors.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,22 @@ internal static SimulatedSqlException MultipleIdentityColumns(string tableName)
510510
internal static SimulatedSqlException IdentityInvalidIncrement(string columnName) =>
511511
new($"Identity column '{columnName}' contains invalid INCREMENT.", 2753, 16, 2);
512512

513+
/// <summary>
514+
/// Mimics SQL Server error 2761: the <c>ROWGUIDCOL</c> property was declared
515+
/// on a column whose type isn't <c>uniqueidentifier</c>. Probe-confirmed
516+
/// wording / number / state (SQL Server 2025, 2026-07-17).
517+
/// </summary>
518+
internal static SimulatedSqlException RowGuidColRequiresUniqueIdentifier() =>
519+
new("The ROWGUIDCOL property can only be specified on the uniqueidentifier data type.", 2761, 16, 2);
520+
521+
/// <summary>
522+
/// Mimics SQL Server error 8196: more than one column in a table was
523+
/// declared with the <c>ROWGUIDCOL</c> property. Probe-confirmed wording /
524+
/// number / state (SQL Server 2025, 2026-07-17).
525+
/// </summary>
526+
internal static SimulatedSqlException MultipleRowGuidColumns() =>
527+
new("Duplicate column specified as ROWGUIDCOL.", 8196, 16, 1);
528+
513529
/// <summary>
514530
/// Mimics SQL Server error 8115's IDENTITY-specific wording: the next
515531
/// identity value would overflow the column's underlying integer type.

SqlServerSimulator/Parser/Expressions/ColumnProperty.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ namespace SqlServerSimulator.Parser.Expressions;
1616
/// <item><description><c>AllowsNull</c> — 1 / 0 from <see cref="HeapColumn.Nullable"/>.</description></item>
1717
/// <item><description><c>IsIdentity</c> — 1 / 0 from <see cref="HeapColumn.Identity"/>.</description></item>
1818
/// <item><description><c>IsComputed</c> — 1 / 0 from <see cref="HeapColumn.Computed"/>.</description></item>
19-
/// <item><description><c>IsRowGuidCol</c> — always 0 (ROWGUIDCOL not modeled).</description></item>
19+
/// <item><description><c>IsRowGuidCol</c> — 1 / 0 from <see cref="HeapColumn.IsRowGuidCol"/>.</description></item>
20+
/// <item><description><c>IsIdNotForRepl</c> — 1 when the column is an IDENTITY
21+
/// declared NOT FOR REPLICATION, else 0 (0 on non-identity columns, matching
22+
/// real, probe-confirmed 2026-07-17).</description></item>
2023
/// <item><description><c>Precision</c> — type-dependent: integer family yields
2124
/// (decimal-equivalent precision), <c>varchar(N)</c> / <c>nvarchar(N)</c> yield
2225
/// <c>N</c>, money family yields 19 / 10.</description></item>
@@ -114,10 +117,15 @@ private static (HeapColumn? Column, int Ordinal) FindColumn(HeapTable table, str
114117
},
115118
12 => upper switch
116119
{
117-
"ISROWGUIDCOL" => 0,
120+
"ISROWGUIDCOL" => column.IsRowGuidCol ? 1 : 0,
118121
"USESANSITRIM" => SqlType.IsStringCategory(column.Type) ? 1 : 0,
119122
_ => null,
120123
},
124+
14 => upper switch
125+
{
126+
"ISIDNOTFORREPL" => column.Identity is { NotForReplication: true } ? 1 : 0,
127+
_ => null,
128+
},
121129
_ => null,
122130
};
123131
}

0 commit comments

Comments
 (0)