Skip to content

Commit 1c3657a

Browse files
committed
Enforce Msg 8141 at CREATE TABLE — reject inline column-level CHECK predicates that reference any column other than the owning column. Structural walk via Expression.VisitColumnReferences (Reference / Parenthesized / TwoSidedExpression / Cast / Length covered) + BooleanExpression.VisitOperandExpressions (every subclass). Coverage gap documented: peer refs buried in less-common containers (DATEPART, SUBSTRING, nested CASE, JSON, etc.) still escape and surface at INSERT.
1 parent fde4588 commit 1c3657a

11 files changed

Lines changed: 211 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ NOT swallowed: Msg 529 (explicit-cast disallowed pair like `int → date`), Msg
237237
String-source truncation isn't a "conversion failure" path either way — `TRY_CAST('hello' AS varchar(3))``'hel'`. EF doesn't emit TRY_CAST/TRY_CONVERT from idiomatic LINQ (raw SQL only).
238238

239239
### Constraints
240-
- `CHECK`: inline single-column and table-level forms; Msg 547 per row on definitely-false predicate.
240+
- `CHECK`: inline single-column and table-level forms; Msg 547 per row on definitely-false predicate. Inline column-level CHECK predicates may only reference their owning column — peer references raise **Msg 8141** at CREATE TABLE (probe-confirmed verbatim wording). The walker is structural via `Expression.VisitColumnReferences` + `BooleanExpression.VisitOperandExpressions`; coverage is currently limited to common container subclasses (`Reference`, `Parenthesized`, `TwoSidedExpression`, `Cast`, `Length`) — peer refs buried in less-common containers (`DATEPART`, `SUBSTRING`, nested `CASE`, etc.) silently escape the CREATE-TABLE check and surface at INSERT instead. Table-level CHECK has no peer restriction.
241241
- `PRIMARY KEY` / `UNIQUE`: linear scan (O(N) per insert); no B-tree.
242242

243243
### Transactions
@@ -420,7 +420,6 @@ Full `DbDataReader` contract. Typed accessors read `SqlValue` directly via the c
420420
- `LEN(ntext)` raising Msg 8116; legacy `READTEXT` / `WRITETEXT` / `UPDATETEXT`.
421421
- `OUTPUT INTO @table_var`, `OUTPUT DELETED.*` / `INSERTED.*` star expansion.
422422
- MERGE source subqueries; MERGE target column refs in `ON`; `WHEN MATCHED` UPDATE/DELETE branches; `$action`.
423-
- Msg 8141 (inline CHECK referencing a peer column — SQL Server rejects at CREATE TABLE; simulator allows).
424423
- `PRIMARY KEY` / `UNIQUE` on a computed column (`NotSupportedException`).
425424
- Heap allocation tracking (flat page list, no IAM/PFS).
426425
- Compound assignment (`SET @v += expr` / `-=` / `*=` etc.) — rewrite as `SET @v = @v + expr`. The arithmetic-operator runtime is locked behind `protected` instance methods on `TwoSidedExpression`; exposing them as static helpers is the prerequisite refactor.

SqlServerSimulator.Tests/CheckConstraintTests.cs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,4 +177,78 @@ insert t values ('A'), ('B')
177177
var ex = Assert.Throws<DbException>(() => simulation.ExecuteNonQuery("insert t values ('C')"));
178178
Assert.AreEqual("547", ex.Data["HelpLink.EvtID"]);
179179
}
180+
181+
// Msg 8141: an inline column-level CHECK constraint may only reference
182+
// its owning column. Table-level CHECK has no such restriction. Probed
183+
// against SQL Server 2025 (2026-05-11).
184+
185+
[TestMethod]
186+
public void InlineCheck_ReferencesPeerColumn_Msg8141()
187+
=> new Simulation().AssertSqlError(
188+
"create table t (a int check (b > 0), b int)",
189+
8141,
190+
"Column CHECK constraint for column 'a' references another column, table 't'.");
191+
192+
[TestMethod]
193+
public void InlineCheck_OwningAndPeer_StillMsg8141()
194+
=> new Simulation().AssertSqlError(
195+
"create table t (a int check (a > 0 and b > 0), b int)",
196+
8141);
197+
198+
[TestMethod]
199+
public void InlineCheck_PeerOnSecondColumn_Msg8141()
200+
=> new Simulation().AssertSqlError(
201+
"create table t (a int, b int check (a > 0))",
202+
8141,
203+
"Column CHECK constraint for column 'b' references another column, table 't'.");
204+
205+
[TestMethod]
206+
public void InlineCheck_PeerInsideFunctionCall_Msg8141()
207+
=> new Simulation().AssertSqlError(
208+
"create table t (a int, b nvarchar(10) check (len(a) > 0))",
209+
8141);
210+
211+
[TestMethod]
212+
public void InlineCheck_PeerInsideInList_Msg8141()
213+
=> new Simulation().AssertSqlError(
214+
"create table t (a int check (a in (b, 1, 2)), b int)",
215+
8141);
216+
217+
[TestMethod]
218+
public void InlineCheck_NamedConstraint_PeerColumn_Msg8141()
219+
=> new Simulation().AssertSqlError(
220+
"create table t (a int constraint ck_peer check (b > 0), b int)",
221+
8141);
222+
223+
[TestMethod]
224+
public void InlineCheck_OnlyOwningColumn_Works()
225+
=> _ = new Simulation().ExecuteNonQuery("""
226+
create table t (a int check (a > 0), b int);
227+
insert t values (1, 100)
228+
""");
229+
230+
/// <summary>
231+
/// Predicate has no column references at all — should not trip Msg 8141.
232+
/// </summary>
233+
[TestMethod]
234+
public void InlineCheck_NoColumnRef_Works()
235+
=> _ = new Simulation().ExecuteNonQuery("""
236+
create table t (a int check (1 = 1), b int);
237+
insert t values (1, 2)
238+
""");
239+
240+
[TestMethod]
241+
public void TableLevelCheck_ReferencesMultipleColumns_Works()
242+
{
243+
// Table-level CHECK (no owning column) is allowed to reference any
244+
// columns — the Msg 8141 rule is inline-only.
245+
var simulation = new Simulation();
246+
_ = simulation.ExecuteNonQuery("""
247+
create table t (a int, b int, check (a < b));
248+
insert t values (1, 2)
249+
""");
250+
251+
var ex = Assert.Throws<DbException>(() => simulation.ExecuteNonQuery("insert t values (5, 3)"));
252+
Assert.AreEqual("547", ex.Data["HelpLink.EvtID"]);
253+
}
180254
}

SqlServerSimulator/Errors/SimulatedSqlException.QueryErrors.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,4 +348,18 @@ internal static SimulatedSqlException IncorrectWaitForTimeSyntax(string value) =
348348
/// </summary>
349349
internal static SimulatedSqlException WaitForCannotBeTimeType() =>
350350
new("Waitfor delay and waitfor time cannot be of type time.", 9815, 16, 0);
351+
352+
/// <summary>
353+
/// Mimics SQL Server's Msg 8141 — an inline column-level CHECK constraint
354+
/// (i.e. one written next to a column definition rather than at the
355+
/// table level) references a column other than its owning column. Real
356+
/// SQL Server enforces a "one-column scope" rule on inline CHECKs:
357+
/// they may only reference the column they're attached to. Probe-confirmed
358+
/// against SQL Server 2025 (2026-05-11): Class 16, State 0, first-line
359+
/// wording verbatim (real SQL Server appends a second "Could not create
360+
/// constraint or index. See previous errors." sentence which the simulator
361+
/// doesn't model; apps that string-match the error read the first line).
362+
/// </summary>
363+
internal static SimulatedSqlException InlineCheckReferencesAnotherColumn(string owningColumn, string tableName) =>
364+
new($"Column CHECK constraint for column '{owningColumn}' references another column, table '{tableName}'.", 8141, 16, 0);
351365
}

SqlServerSimulator/Parser/BooleanExpression.cs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,19 @@ private static BooleanExpression ParseInList(Expression left, ParserContext cont
255255
/// </summary>
256256
internal abstract string DebugDisplay();
257257

258+
/// <summary>
259+
/// Visits every top-level <see cref="Expression"/> operand carried by
260+
/// this predicate, recursing into nested <see cref="BooleanExpression"/>
261+
/// children (e.g. <c>AND</c> / <c>OR</c> / <c>NOT</c>) so the visitor
262+
/// only ever sees Expression nodes. Used by CREATE TABLE's inline-CHECK
263+
/// validator to enumerate column references via the standard
264+
/// <see cref="Expression.GetSqlType"/> resolver — the Expression-side
265+
/// walk is unchanged because every <c>Reference</c> already feeds the
266+
/// resolver, so callers only need to drive the BooleanExpression-side
267+
/// traversal from here.
268+
/// </summary>
269+
internal abstract void VisitOperandExpressions(Action<Expression> visitor);
270+
258271
/// <summary>
259272
/// Three-valued <c>AND</c>: <c>false AND x = false</c> regardless of
260273
/// <c>x</c>; <c>true AND x = x</c>; <c>NULL AND NULL = NULL</c>. Short-
@@ -276,6 +289,12 @@ private sealed class AndExpression(BooleanExpression left, BooleanExpression rig
276289
}
277290

278291
internal override string DebugDisplay() => $"{left.DebugDisplay()} AND {right.DebugDisplay()}";
292+
293+
internal override void VisitOperandExpressions(Action<Expression> visitor)
294+
{
295+
left.VisitOperandExpressions(visitor);
296+
right.VisitOperandExpressions(visitor);
297+
}
279298
}
280299

281300
/// <summary>
@@ -297,6 +316,12 @@ private sealed class OrExpression(BooleanExpression left, BooleanExpression righ
297316
}
298317

299318
internal override string DebugDisplay() => $"{left.DebugDisplay()} OR {right.DebugDisplay()}";
319+
320+
internal override void VisitOperandExpressions(Action<Expression> visitor)
321+
{
322+
left.VisitOperandExpressions(visitor);
323+
right.VisitOperandExpressions(visitor);
324+
}
300325
}
301326

302327
/// <summary>
@@ -313,6 +338,8 @@ private sealed class IsNullExpression(Expression source, bool negated) : Boolean
313338
source.Run(runtime).IsNull ^ negated;
314339

315340
internal override string DebugDisplay() => $"{source.DebugDisplay()} IS {(negated ? "NOT NULL" : "NULL")}";
341+
342+
internal override void VisitOperandExpressions(Action<Expression> visitor) => visitor(source);
316343
}
317344

318345
/// <summary>
@@ -351,6 +378,13 @@ internal override string DebugDisplay()
351378
var keyword = negated ? "NOT IN" : "IN";
352379
return $"{source.DebugDisplay()} {keyword} ({string.Join(", ", candidates.Select(c => c.DebugDisplay()))})";
353380
}
381+
382+
internal override void VisitOperandExpressions(Action<Expression> visitor)
383+
{
384+
visitor(source);
385+
foreach (var candidate in candidates)
386+
visitor(candidate);
387+
}
354388
}
355389

356390
/// <summary>
@@ -367,6 +401,11 @@ private sealed class ExistsExpression(Selection inner) : BooleanExpression
367401
inner.Execute(runtime.Batch, runtime.ResolveColumn).RowBytes.Any();
368402

369403
internal override string DebugDisplay() => "EXISTS (...)";
404+
405+
// No top-level Expression operands — the subquery's references are
406+
// unreachable from this validator (and a subquery in inline CHECK
407+
// raises Msg 1046 in real SQL Server anyway).
408+
internal override void VisitOperandExpressions(Action<Expression> visitor) { }
370409
}
371410

372411
/// <summary>
@@ -405,6 +444,10 @@ private sealed class InSubqueryExpression(Expression source, Selection inner, bo
405444
}
406445

407446
internal override string DebugDisplay() => $"{source.DebugDisplay()} {(negated ? "NOT IN" : "IN")} (...)";
447+
448+
// Only the LHS source is a reachable Expression operand; the subquery
449+
// side is a Selection (handled by its own machinery).
450+
internal override void VisitOperandExpressions(Action<Expression> visitor) => visitor(source);
408451
}
409452

410453
/// <summary>
@@ -423,6 +466,8 @@ private sealed class NotExpression(BooleanExpression inner) : BooleanExpression
423466
};
424467

425468
internal override string DebugDisplay() => $"NOT {inner.DebugDisplay()}";
469+
470+
internal override void VisitOperandExpressions(Action<Expression> visitor) => inner.VisitOperandExpressions(visitor);
426471
}
427472

428473
/// <summary>
@@ -462,6 +507,12 @@ private protected CompareExpression(Expression left, ParserContext context)
462507
/// </summary>
463508
protected static bool? ComparePromoted(Expression left, Expression right, RuntimeContext runtime, string operatorName, Func<SqlValue, SqlValue, bool> compare) =>
464509
CompareValuesPromoted(left.Run(runtime), right.Run(runtime), operatorName, compare);
510+
511+
internal override void VisitOperandExpressions(Action<Expression> visitor)
512+
{
513+
visitor(this.left);
514+
visitor(this.right);
515+
}
465516
}
466517

467518
/// <summary>
@@ -701,5 +752,13 @@ private static int TranslateClass(string pattern, int start, StringBuilder sb)
701752
internal override string DebugDisplay() => this.escape is null
702753
? $"{left.DebugDisplay()} {(this.negated ? "NOT LIKE" : "LIKE")} {right.DebugDisplay()}"
703754
: $"{left.DebugDisplay()} {(this.negated ? "NOT LIKE" : "LIKE")} {right.DebugDisplay()} ESCAPE {this.escape.DebugDisplay()}";
755+
756+
internal override void VisitOperandExpressions(Action<Expression> visitor)
757+
{
758+
visitor(this.left);
759+
visitor(this.right);
760+
if (this.escape is not null)
761+
visitor(this.escape);
762+
}
704763
}
705764
}

SqlServerSimulator/Parser/Expression.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,31 @@ private static void ParseWithinGroupOrderBy(AggregateExpression aggregate, Parse
340340
_ => false,
341341
};
342342

343+
/// <summary>
344+
/// Visits every <see cref="Reference"/> node in this expression's tree,
345+
/// calling <paramref name="visit"/> with each reference's
346+
/// <see cref="MultiPartName"/>. Used by CREATE TABLE's inline-CHECK
347+
/// validator (Msg 8141) to enumerate column references statically —
348+
/// distinct from <see cref="GetSqlType"/>'s walk because the latter is
349+
/// optimized for type inference and several function-call subclasses
350+
/// shortcut to a fixed result type without visiting their child
351+
/// expressions. Default implementation is empty; container Expression
352+
/// subclasses override to recurse into their child Expressions.
353+
/// </summary>
354+
/// <remarks>
355+
/// Coverage gap: only the most common container subclasses
356+
/// (<see cref="Reference"/>, <see cref="Parenthesized"/>, the binary
357+
/// arithmetic / bitwise via <see cref="TwoSidedExpression"/>,
358+
/// <see cref="Cast"/>, <see cref="Length"/>) currently override this.
359+
/// Less-common containers (date-arithmetic functions, JSON functions,
360+
/// nested CASE, etc.) fall through to the empty default, so peer
361+
/// references buried inside them silently escape Msg 8141 detection at
362+
/// CREATE TABLE. Real SQL Server catches these; the simulator surfaces
363+
/// the runtime error at INSERT instead. New overrides can be added as
364+
/// applications surface the gap.
365+
/// </remarks>
366+
internal virtual void VisitColumnReferences(Action<MultiPartName> visit) { }
367+
343368
/// <summary>
344369
/// Parses a grouped expression starting at the opening <c>(</c>. Two
345370
/// shapes share the leading paren: a parenthesized expression

SqlServerSimulator/Parser/Expressions/Cast.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ public override SqlValue Run(RuntimeContext runtime)
6565
internal override string DebugDisplay() =>
6666
$"{(this.tryMode ? "TRY_CAST" : "CAST")}({source.DebugDisplay()} AS {targetType})";
6767

68+
internal override void VisitColumnReferences(Action<MultiPartName> visit) => this.source.VisitColumnReferences(visit);
69+
6870
/// <summary>
6971
/// Parses the optional <c>(length)</c> or <c>(precision, scale)</c> spec
7072
/// after a CAST/CONVERT target type name and resolves the type. The caller

SqlServerSimulator/Parser/Expressions/Length.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,6 @@ public override SqlValue Run(RuntimeContext runtime)
3434
public override SqlType GetSqlType(Func<MultiPartName, SqlType> resolveColumnType) => SqlType.Int32;
3535

3636
internal override string DebugDisplay() => $"LEN({source.DebugDisplay()})";
37+
38+
internal override void VisitColumnReferences(Action<MultiPartName> visit) => source.VisitColumnReferences(visit);
3739
}

SqlServerSimulator/Parser/Expressions/Parenthesized.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,6 @@ internal sealed class Parenthesized(Expression wrapped) : Expression
1717
public override Storage.SqlType GetSqlType(Func<MultiPartName, Storage.SqlType> resolveColumnType) => this.Wrapped.GetSqlType(resolveColumnType);
1818

1919
internal override string DebugDisplay() => $"( {this.Wrapped.DebugDisplay()} )";
20+
21+
internal override void VisitColumnReferences(Action<MultiPartName> visit) => this.Wrapped.VisitColumnReferences(visit);
2022
}

SqlServerSimulator/Parser/Expressions/Reference.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,6 @@ public Reference(string qualifier, string column)
5353
internal override string DebugDisplay() => this.ReferencedName.ToString();
5454

5555
internal override bool ResultIsNullable(Func<MultiPartName, bool> resolveColumnNullable) => resolveColumnNullable(this.ReferencedName);
56+
57+
internal override void VisitColumnReferences(Action<MultiPartName> visit) => visit(this.ReferencedName);
5658
}

SqlServerSimulator/Parser/Expressions/TwoSidedExpression.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,4 +364,10 @@ private static long TicksFromBase(SqlValue v) =>
364364
protected abstract char Operator { get; }
365365

366366
internal sealed override string DebugDisplay() => $"{left.DebugDisplay()} {Operator} {right.DebugDisplay()}";
367+
368+
internal sealed override void VisitColumnReferences(Action<MultiPartName> visit)
369+
{
370+
this.left.VisitColumnReferences(visit);
371+
this.right.VisitColumnReferences(visit);
372+
}
367373
}

0 commit comments

Comments
 (0)