Skip to content

Commit aaa56ab

Browse files
committed
Populate sys.indexes.filter_definition (byte-exact canonical) and sys.check_constraints.definition / sys.default_constraints.definition (original source syntax via ParserContext.SourceTextFrom).
1 parent 72935a1 commit aaa56ab

16 files changed

Lines changed: 330 additions & 34 deletions

SqlServerSimulator.Tests/CheckConstraintTests.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,4 +296,28 @@ insert t values (null, null, null)
296296
"""));
297297
Assert.AreEqual("547", ex.Data["HelpLink.EvtID"]);
298298
}
299+
300+
// sys.check_constraints.definition holds the predicate's original syntax,
301+
// wrapped in one paren pair (the simulator doesn't re-normalize to SQL
302+
// Server's canonical [col]=(1)-style form). Covers inline column-level,
303+
// table-level, and the broad grammar (OR / IN / functions) that the
304+
// filtered-index renderer deliberately doesn't touch.
305+
[TestMethod]
306+
[DataRow("a int check (a > 0)", "(a > 0)")]
307+
[DataRow("a int, b int, check (a > 0 and b < 10)", "(a > 0 and b < 10)")]
308+
[DataRow("s varchar(2) check (s in ('A', 'B'))", "(s in ('A', 'B'))")]
309+
[DataRow("n varchar(9) check (len(n) > 0 or n is null)", "(len(n) > 0 or n is null)")]
310+
public void CheckConstraint_Definition_HoldsOriginalSyntax(string columns, string expected)
311+
=> Assert.AreEqual(expected, new Simulation().ExecuteScalar($"""
312+
create table t (id int not null primary key, {columns});
313+
select definition from sys.check_constraints
314+
"""));
315+
316+
[TestMethod]
317+
public void CheckConstraint_AlterAddCheck_Definition_HoldsOriginalSyntax()
318+
=> Assert.AreEqual("(b >= a)", new Simulation().ExecuteScalar("""
319+
create table t (id int not null primary key, a int, b int);
320+
alter table t add constraint ck check (b >= a);
321+
select definition from sys.check_constraints where name = 'ck'
322+
"""));
299323
}

SqlServerSimulator.Tests/CreateIndexTests.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,46 @@ public void CreateIndex_WithFilter_HasFilterFlagSet()
114114
select has_filter from sys.indexes where name = 'ix_filter'
115115
""")!);
116116

117+
// sys.indexes.filter_definition normalization — every expected string is
118+
// verbatim from SQL Server 2025: columns bracketed, numerics parenthesized
119+
// (literal scale preserved), strings quoted (N-prefixed for nvarchar
120+
// literals), operators space-free, AND / IS [NOT] NULL / IN uppercase-spaced.
121+
[TestMethod]
122+
[DataRow("status = 1", "([status]=(1))")]
123+
[DataRow("status <> 1", "([status]<>(1))")]
124+
[DataRow("status >= 5 and status <= 10", "([status]>=(5) AND [status]<=(10))")]
125+
[DataRow("code is not null", "([code] IS NOT NULL)")]
126+
[DataRow("code is null", "([code] IS NULL)")]
127+
[DataRow("status = 1 and code is not null", "([status]=(1) AND [code] IS NOT NULL)")]
128+
[DataRow("name = 'abc'", "([name]='abc')")]
129+
[DataRow("uname = N'abc'", "([uname]=N'abc')")]
130+
[DataRow("x = -1", "([x]=(-1))")]
131+
[DataRow("status in (1, 2, 3)", "([status] IN ((1), (2), (3)))")]
132+
[DataRow("nm = 0.10", "([nm]=(0.10))")]
133+
public void CreateIndex_FilterDefinition_NormalizedLikeSqlServer(string filter, string expected)
134+
=> AreEqual(expected, new Simulation().ExecuteScalar($"""
135+
create table t (id int not null primary key, status int, code int,
136+
name varchar(50), uname nvarchar(50), x int, nm decimal(10, 2));
137+
create unique index ix on t(id) where {filter};
138+
select filter_definition from sys.indexes where name = 'ix'
139+
"""));
140+
141+
// A predicate outside the renderable filtered grammar (OR — which a real
142+
// server rejects at CREATE, but the simulator's looser parser accepts):
143+
// has_filter stays set, filter_definition degrades to NULL rather than
144+
// emitting a non-canonical rendering.
145+
[TestMethod]
146+
public void CreateIndex_FilterDefinition_UnrenderablePredicate_IsNull()
147+
{
148+
var sim = new Simulation();
149+
_ = sim.ExecuteNonQuery("""
150+
create table t (id int not null primary key, status int);
151+
create unique index ix on t(id) where status = 1 or status = 2
152+
""");
153+
IsTrue((bool)sim.ExecuteScalar("select has_filter from sys.indexes where name = 'ix'")!);
154+
AreEqual(0, sim.ExecuteScalar("select count(filter_definition) from sys.indexes where name = 'ix'"));
155+
}
156+
117157
[TestMethod]
118158
public void CreateIndex_WithOptionsClause_Accepted()
119159
=> AreEqual(1, new Simulation().ExecuteScalar("""

SqlServerSimulator.Tests/DefaultClauseTests.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,4 +169,26 @@ insert t (id) values (1)
169169
Assert.IsTrue(reader.Read());
170170
Assert.AreNotEqual(Guid.Empty, reader.GetGuid(0));
171171
}
172+
173+
// sys.default_constraints.definition holds the default expression's original
174+
// syntax, wrapped in one paren pair (the simulator doesn't re-normalize to
175+
// SQL Server's canonical parenthesization — getdate() happens to coincide).
176+
[TestMethod]
177+
[DataRow("qty int default 0", "(0)")]
178+
[DataRow("created datetime default getdate()", "(getdate())")]
179+
[DataRow("code int default (1 + 2)", "((1 + 2))")]
180+
[DataRow("nm varchar(5) default 'x'", "('x')")]
181+
public void DefaultConstraint_Definition_HoldsOriginalSyntax(string column, string expected)
182+
=> Assert.AreEqual(expected, new Simulation().ExecuteScalar($"""
183+
create table t (id int not null primary key, {column});
184+
select definition from sys.default_constraints
185+
"""));
186+
187+
[TestMethod]
188+
public void DefaultConstraint_AlterAddDefault_Definition_HoldsOriginalSyntax()
189+
=> Assert.AreEqual("(-1)", new Simulation().ExecuteScalar("""
190+
create table t (id int not null primary key, b int);
191+
alter table t add constraint df default (-1) for b;
192+
select definition from sys.default_constraints where name = 'df'
193+
"""));
172194
}

SqlServerSimulator/Parser/BooleanExpression.cs

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
using System.Diagnostics;
22
using System.Diagnostics.CodeAnalysis;
3+
using System.Globalization;
4+
using System.Text;
5+
using SqlServerSimulator.Parser.Expressions;
36
using SqlServerSimulator.Parser.Tokens;
47
using SqlServerSimulator.Storage;
58

@@ -29,6 +32,98 @@ private protected BooleanExpression()
2932
{
3033
}
3134

35+
/// <summary>
36+
/// Renders this predicate into the normalized form SQL Server stores in
37+
/// <c>sys.indexes.filter_definition</c> for a filtered index — columns
38+
/// bracketed, numeric constants parenthesized, strings quoted, operators
39+
/// space-free (<c>[status]=(1)</c>), and <c>AND</c> / <c>IS [NOT] NULL</c> /
40+
/// <c>IN</c> uppercase-spaced — or returns <c>null</c> when the predicate
41+
/// falls outside the renderable filtered-predicate grammar (an <c>AND</c> of
42+
/// comparison / <c>IS [NOT] NULL</c> / <c>IN</c> over <c>column &lt;op&gt;
43+
/// constant</c>). Those excluded shapes — <c>OR</c>, <c>NOT</c>, <c>BETWEEN</c>,
44+
/// function calls — are exactly the ones real SQL Server rejects in a filtered
45+
/// index, so reporting <c>null</c> for them never hides a definition a real
46+
/// server would have stored. The whole predicate is wrapped in one outer pair
47+
/// of parens.
48+
/// </summary>
49+
internal string? RenderFilterDefinition(BatchContext batch)
50+
{
51+
var sb = new StringBuilder("(");
52+
if (!this.TryAppendFilterDefinition(sb, batch))
53+
return null;
54+
_ = sb.Append(')');
55+
return sb.ToString();
56+
}
57+
58+
// Appends this predicate's canonical fragment to `sb`, or returns false when
59+
// the node isn't part of the renderable filtered-predicate grammar. Default:
60+
// not renderable (OR / NOT / DISTINCT FROM / EXISTS / BETWEEN / quantified).
61+
private protected virtual bool TryAppendFilterDefinition(StringBuilder sb, BatchContext batch) => false;
62+
63+
// Renders one comparison / IN operand: a single-part column reference as
64+
// [name], or an otherwise constant-foldable side as its literal. Returns
65+
// false for anything that isn't a bare column or a constant.
66+
private static bool TryAppendFilterOperand(StringBuilder sb, Expression operand, BatchContext batch)
67+
{
68+
while (operand is Parenthesized paren)
69+
operand = paren.Wrapped;
70+
71+
if (operand is Reference reference && reference.ReferencedName.Count == 1)
72+
{
73+
_ = sb.Append('[').Append(reference.ReferencedName.Leaf).Append(']');
74+
return true;
75+
}
76+
77+
SqlValue value;
78+
try
79+
{
80+
value = operand.Run(new RuntimeContext(static _ => throw new NotSupportedException(), batch));
81+
}
82+
catch (Exception ex) when (ex is SimulatedSqlException or NotSupportedException)
83+
{
84+
return false;
85+
}
86+
87+
if (FormatFilterLiteral(value) is not { } literal)
88+
return false;
89+
_ = sb.Append(literal);
90+
return true;
91+
}
92+
93+
// Formats a constant as SQL Server renders it inside filter_definition:
94+
// strings quoted (N-prefixed when the literal is national / Unicode), numerics
95+
// parenthesized in invariant culture preserving the literal's scale. Returns
96+
// null for a NULL literal or a type with no canonical filter rendering
97+
// (dates / binary / guid — rare in a filtered predicate), bailing the whole
98+
// render to a null definition.
99+
private static string? FormatFilterLiteral(SqlValue value)
100+
{
101+
if (value.IsNull)
102+
return null;
103+
104+
var type = value.Type;
105+
if (type.Category == SqlTypeCategory.String)
106+
{
107+
var prefix = type is NVarcharSqlType or NCharSqlType ? "N" : string.Empty;
108+
return $"{prefix}'{value.AsString.Replace("'", "''", StringComparison.Ordinal)}'";
109+
}
110+
111+
var text = type switch
112+
{
113+
_ when type == SqlType.Int32 => value.AsInt32.ToString(CultureInfo.InvariantCulture),
114+
_ when type == SqlType.BigInt => value.AsInt64.ToString(CultureInfo.InvariantCulture),
115+
_ when type == SqlType.SmallInt => value.AsInt16.ToString(CultureInfo.InvariantCulture),
116+
_ when type == SqlType.TinyInt => value.AsByte.ToString(CultureInfo.InvariantCulture),
117+
_ when type == SqlType.Bit => value.AsBoolean ? "1" : "0",
118+
_ when type == SqlType.Money || type == SqlType.SmallMoney => value.AsMoney.ToString("F4", CultureInfo.InvariantCulture),
119+
_ when type == SqlType.Float => value.AsDouble.ToString("G15", CultureInfo.InvariantCulture),
120+
_ when type == SqlType.Real => value.AsSingle.ToString("G7", CultureInfo.InvariantCulture),
121+
DecimalSqlType d => value.AsDecimal.ToString($"F{d.scale}", CultureInfo.InvariantCulture),
122+
_ => null,
123+
};
124+
return text is null ? null : $"({text})";
125+
}
126+
32127
/// <summary>
33128
/// Parses a full boolean predicate using SQL Server's standard
34129
/// precedence: <c>OR</c> binds loosest, then <c>AND</c>, then <c>NOT</c>,
@@ -601,6 +696,14 @@ internal override void CollectConjuncts(List<BooleanExpression> sink)
601696
left.CollectConjuncts(sink);
602697
right.CollectConjuncts(sink);
603698
}
699+
700+
private protected override bool TryAppendFilterDefinition(StringBuilder sb, BatchContext batch)
701+
{
702+
if (!left.TryAppendFilterDefinition(sb, batch))
703+
return false;
704+
_ = sb.Append(" AND ");
705+
return right.TryAppendFilterDefinition(sb, batch);
706+
}
604707
}
605708

606709
/// <summary>
@@ -685,6 +788,14 @@ private sealed class IsNullExpression(Expression source, bool negated) : Boolean
685788
internal override string DebugDisplay() => $"{source.DebugDisplay()} IS {(negated ? "NOT NULL" : "NULL")}";
686789

687790
internal override void VisitOperandExpressions(Action<Expression> visitor) => visitor(source);
791+
792+
private protected override bool TryAppendFilterDefinition(StringBuilder sb, BatchContext batch)
793+
{
794+
if (!TryAppendFilterOperand(sb, source, batch))
795+
return false;
796+
_ = sb.Append(negated ? " IS NOT NULL" : " IS NULL");
797+
return true;
798+
}
688799
}
689800

690801
/// <summary>
@@ -766,6 +877,24 @@ internal override void VisitOperandExpressions(Action<Expression> visitor)
766877
visitor(candidate);
767878
}
768879

880+
private protected override bool TryAppendFilterDefinition(StringBuilder sb, BatchContext batch)
881+
{
882+
// NOT IN isn't part of the filtered-index grammar (real SQL Server
883+
// rejects it), so only the positive form renders.
884+
if (negated || candidates.Length == 0 || !TryAppendFilterOperand(sb, source, batch))
885+
return false;
886+
_ = sb.Append(" IN (");
887+
for (var i = 0; i < candidates.Length; i++)
888+
{
889+
if (i > 0)
890+
_ = sb.Append(", ");
891+
if (!TryAppendFilterOperand(sb, candidates[i], batch))
892+
return false;
893+
}
894+
_ = sb.Append(')');
895+
return true;
896+
}
897+
769898
// `source IN (c1, c2, ...)` is logically `source=c1 OR source=c2 OR ...`,
770899
// so for the equality-seek path it decomposes into one pair per candidate
771900
// against the shared LHS. NOT IN doesn't decompose into positive
@@ -1029,6 +1158,19 @@ internal override void VisitOperandExpressions(Action<Expression> visitor)
10291158
visitor(this.left);
10301159
visitor(this.right);
10311160
}
1161+
1162+
// The space-free operator token this comparison renders into a filtered-
1163+
// index definition (=, <>, >, >=, <, <=), or null for shapes with no
1164+
// canonical filter rendering (LIKE) — those bail the whole render.
1165+
protected virtual string? FilterOperator => null;
1166+
1167+
private protected override bool TryAppendFilterDefinition(StringBuilder sb, BatchContext batch)
1168+
{
1169+
if (this.FilterOperator is not { } op || !TryAppendFilterOperand(sb, this.left, batch))
1170+
return false;
1171+
_ = sb.Append(op);
1172+
return TryAppendFilterOperand(sb, this.right, batch);
1173+
}
10321174
}
10331175

10341176
/// <summary>
@@ -1078,6 +1220,8 @@ private sealed class EqualityExpression(Expression left, Expression right) : Com
10781220

10791221
internal override string DebugDisplay() => $"{left.DebugDisplay()} = {right.DebugDisplay()}";
10801222

1223+
protected override string? FilterOperator => "=";
1224+
10811225
internal override bool TryGetEqualityOperands([NotNullWhen(true)] out Expression? l, [NotNullWhen(true)] out Expression? r)
10821226
{
10831227
l = left;
@@ -1092,6 +1236,8 @@ private sealed class InequalityExpression(Expression left, Expression right) : C
10921236
ComparePromoted(left, right, runtime, "not equal to", static (l, r) => !l.Equals(r));
10931237

10941238
internal override string DebugDisplay() => $"{left.DebugDisplay()} <> {right.DebugDisplay()}";
1239+
1240+
protected override string? FilterOperator => "<>";
10951241
}
10961242

10971243
private sealed class GreaterThanExpression(Expression left, Expression right) : CompareExpression(left, right)
@@ -1101,6 +1247,8 @@ private sealed class GreaterThanExpression(Expression left, Expression right) :
11011247

11021248
internal override string DebugDisplay() => $"{left.DebugDisplay()} > {right.DebugDisplay()}";
11031249

1250+
protected override string? FilterOperator => ">";
1251+
11041252
internal override bool TryGetRangeOperands([NotNullWhen(true)] out Expression? l, out RangeComparison op, [NotNullWhen(true)] out Expression? r)
11051253
{
11061254
(l, op, r) = (left, RangeComparison.Greater, right);
@@ -1115,6 +1263,8 @@ private sealed class GreaterThanOrEqualExpression(Expression left, Expression ri
11151263

11161264
internal override string DebugDisplay() => $"{left.DebugDisplay()} >= {right.DebugDisplay()}";
11171265

1266+
protected override string? FilterOperator => ">=";
1267+
11181268
internal override bool TryGetRangeOperands([NotNullWhen(true)] out Expression? l, out RangeComparison op, [NotNullWhen(true)] out Expression? r)
11191269
{
11201270
(l, op, r) = (left, RangeComparison.GreaterOrEqual, right);
@@ -1129,6 +1279,8 @@ private sealed class LessThanExpression(Expression left, Expression right) : Com
11291279

11301280
internal override string DebugDisplay() => $"{left.DebugDisplay()} < {right.DebugDisplay()}";
11311281

1282+
protected override string? FilterOperator => "<";
1283+
11321284
internal override bool TryGetRangeOperands([NotNullWhen(true)] out Expression? l, out RangeComparison op, [NotNullWhen(true)] out Expression? r)
11331285
{
11341286
(l, op, r) = (left, RangeComparison.Less, right);
@@ -1143,6 +1295,8 @@ private sealed class LessThanOrEqualExpression(Expression left, Expression right
11431295

11441296
internal override string DebugDisplay() => $"{left.DebugDisplay()} <= {right.DebugDisplay()}";
11451297

1298+
protected override string? FilterOperator => "<=";
1299+
11461300
internal override bool TryGetRangeOperands([NotNullWhen(true)] out Expression? l, out RangeComparison op, [NotNullWhen(true)] out Expression? r)
11471301
{
11481302
(l, op, r) = (left, RangeComparison.LessOrEqual, right);

SqlServerSimulator/Parser/ParserContext.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,18 @@ internal sealed class ParserContext(SimulatedDbCommand command, BatchContext bat
177177
/// </summary>
178178
public (int Index, Token? Token) SaveCheckpoint() => (this.index, this.Token);
179179

180+
/// <summary>
181+
/// Raw source text of the command from <paramref name="startIndex"/> up to the
182+
/// current (lookahead) <see cref="Token"/>, trailing whitespace trimmed —
183+
/// capturing an expression's original syntax for catalog <c>definition</c>
184+
/// columns (CHECK / DEFAULT). Pass the <see cref="Token.StartIndex"/> of the
185+
/// expression's first token, snapshotted before parsing; call once the
186+
/// expression parse has positioned <see cref="Token"/> on the following token
187+
/// (or run off the end, where the slice extends to the command's end).
188+
/// </summary>
189+
public string SourceTextFrom(int startIndex) =>
190+
this.commandText[startIndex..(this.Token?.StartIndex ?? this.commandText.Length)].TrimEnd();
191+
180192
/// <summary>
181193
/// Restores a checkpoint captured by <see cref="SaveCheckpoint"/>.
182194
/// </summary>

SqlServerSimulator/Schemas/TableType.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ internal sealed class TableType(
5050
DateTime createDate,
5151
HeapColumn[] columns,
5252
(KeyConstraintKind Kind, string? Name, int[] FullOrdinals)[] pendingKeys,
53-
(string? Name, BooleanExpression Predicate, string? InlineColumn)[] pendingChecks)
53+
(string? Name, BooleanExpression Predicate, string? InlineColumn, string Definition)[] pendingChecks)
5454
: SchemaObject(name, typeTableObjectId, schema.SchemaId, createDate)
5555
{
5656
public Schema Schema = schema;
@@ -86,7 +86,7 @@ internal sealed class TableType(
8686
/// Pending CHECK specs captured at CREATE TYPE time. Resolved per clone
8787
/// (same rationale as <see cref="PendingKeys"/>).
8888
/// </summary>
89-
public readonly (string? Name, BooleanExpression Predicate, string? InlineColumn)[] PendingChecks = pendingChecks;
89+
public readonly (string? Name, BooleanExpression Predicate, string? InlineColumn, string Definition)[] PendingChecks = pendingChecks;
9090

9191
/// <summary>
9292
/// Materializes a fresh <see cref="HeapTable"/> for one <c>DECLARE @t

SqlServerSimulator/Simulation/Simulation.AlterTableColumn.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ private static bool ParseAddColumns(ParserContext context, MultiPartName tableNa
4747
var heapColumns = new List<HeapColumn?>();
4848
var explicitNull = new List<bool>();
4949
var pendingKeys = new List<(KeyConstraintKind Kind, string? Name, int[] FullOrdinals)>();
50-
var pendingChecks = new List<(string? Name, BooleanExpression Predicate, string? InlineColumn)>();
50+
var pendingChecks = new List<(string? Name, BooleanExpression Predicate, string? InlineColumn, string Definition)>();
5151
var pendingComputed = new List<(int Index, string Name, Expression Expression, bool Persisted, bool Nullable)>();
5252
var pendingForeignKeys = new List<PendingForeignKey>();
5353

0 commit comments

Comments
 (0)