Skip to content

Commit 9474065

Browse files
committed
Implemented LIKE.
1 parent cfae73b commit 9474065

6 files changed

Lines changed: 566 additions & 1 deletion

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ Heavy-hitters someone might assume work but don't. Source and `git log` are the
7373
- Row-overflow / LOB pages and the `varchar(MAX)` / `nvarchar(MAX)` / `varbinary(MAX)` types they enable.
7474
- `decimal` / `numeric` values are backed by .NET `decimal`, so values requiring more than 28 significant digits aren't modeled (the type declarations up through `decimal(38, *)` are accepted so storage byte-width still matches SQL Server). `float` text formatting uses .NET's `G15`/`G7` conventions rather than SQL Server's exact `1e+015`-style scientific layout. `money` / `smallmoney` are wired in raw SQL but unreachable through EF Core for the same `SqlParameter`-downcast reason as the date-only / time-only mappings — adding a money column to an EF entity breaks that entity's whole save path.
7575
- `NEWSEQUENTIALID()` (deferred until `DEFAULT`-clause support exists in `CREATE TABLE`; the function is only valid in that context).
76-
- Pattern matching (`LIKE`) and `CONVERT`.
76+
- `CONVERT` (cousin to `CAST`, with a separate style-code surface).
77+
- `LIKE` `COLLATE` override. The default collation (case-insensitive, Latin1_General-shaped) is what every `LIKE` runs under; explicit `COLLATE` clauses on the predicate aren't parsed yet.
7778
- Cross-category `Promote` for integer ↔ string. Only CAST works for that pair.
7879
- EF Core compatibility: the SqlServer provider downcasts `DbParameter` to `SqlParameter` for some mappings — `DateTime → date`, `DateTime → smalldatetime`, `DateOnly`, `TimeOnly`, `TimeSpan`, and `decimal → money` / `decimal → smallmoney` (via `SqlServerDecimalTypeMapping`) all break at SaveChanges. See `SimulatedDbParameter` for the matrix; a `SqlServerSimulator.EFCore` adapter package is planned to close the gap.
7980
- `OUTPUT` and `MERGE` are scoped to the EF Core SaveChanges shape: `INSERT ... OUTPUT INSERTED.<col>` (single-row) and `MERGE INTO target USING (VALUES ...) AS alias (cols) ON predicate WHEN NOT MATCHED THEN INSERT ... [OUTPUT ...]` (multi-row batch). `OUTPUT INTO @table_var`, `OUTPUT DELETED.*`, OUTPUT on UPDATE/DELETE, `INSERTED.*` star expansion, MERGE source subqueries, MERGE target with column refs in `ON`, the `WHEN MATCHED` UPDATE/DELETE branches, and `$action` aren't supported. The `WHEN MATCHED` branch parses syntactically but throws `NotSupportedException` if the per-row predicate ever evaluates true.
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
using Microsoft.EntityFrameworkCore;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Exercises the simulator's <c>LIKE</c> support through EF Core's idiomatic
7+
/// surfaces: <see cref="string.StartsWith(string)"/>, <see cref="string.EndsWith(string)"/>,
8+
/// <see cref="string.Contains(string)"/>, and <see cref="EF.Functions"/>'s
9+
/// <c>Like</c> family. Different LINQ shapes hit different SQL emit paths in
10+
/// the SqlServer provider; coverage here pins which shapes round-trip
11+
/// end-to-end.
12+
/// </summary>
13+
[TestClass]
14+
public class EFCoreLike
15+
{
16+
public TestContext TestContext { get; set; } = null!;
17+
18+
private static TestDbContext SeededContext()
19+
{
20+
var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
21+
context.People.AddRange(
22+
new Person { Id = 1, Name = "Alice" },
23+
new Person { Id = 2, Name = "Bob" },
24+
new Person { Id = 3, Name = "Alicia" },
25+
new Person { Id = 4, Name = "Charlie" });
26+
_ = context.SaveChanges();
27+
return context;
28+
}
29+
30+
[TestMethod]
31+
public void StartsWith_FiltersByPrefix()
32+
{
33+
using var context = SeededContext();
34+
var ids = context.People.Where(p => p.Name.StartsWith("Ali")).OrderBy(p => p.Id).Select(p => p.Id).ToArray();
35+
CollectionAssert.AreEqual(new[] { 1, 3 }, ids);
36+
}
37+
38+
[TestMethod]
39+
public void EndsWith_FiltersBySuffix()
40+
{
41+
using var context = SeededContext();
42+
var ids = context.People.Where(p => p.Name.EndsWith("ie")).Select(p => p.Id).ToArray();
43+
CollectionAssert.AreEqual(new[] { 4 }, ids);
44+
}
45+
46+
[TestMethod]
47+
public void Contains_FiltersBySubstring()
48+
{
49+
// EF Core's SqlServer provider emits CHARINDEX for parameterized
50+
// Contains rather than LIKE, but the result must agree either way.
51+
using var context = SeededContext();
52+
var ids = context.People.Where(p => p.Name.Contains("li")).OrderBy(p => p.Id).Select(p => p.Id).ToArray();
53+
CollectionAssert.AreEqual(new[] { 1, 3, 4 }, ids);
54+
}
55+
56+
[TestMethod]
57+
public void EFFunctionsLike_ConstantPattern()
58+
{
59+
using var context = SeededContext();
60+
var ids = context.People.Where(p => EF.Functions.Like(p.Name, "Ali%")).OrderBy(p => p.Id).Select(p => p.Id).ToArray();
61+
CollectionAssert.AreEqual(new[] { 1, 3 }, ids);
62+
}
63+
64+
[TestMethod]
65+
public void EFFunctionsLike_ParameterizedPattern()
66+
{
67+
using var context = SeededContext();
68+
var pattern = "%li%";
69+
var ids = context.People.Where(p => EF.Functions.Like(p.Name, pattern)).OrderBy(p => p.Id).Select(p => p.Id).ToArray();
70+
CollectionAssert.AreEqual(new[] { 1, 3, 4 }, ids);
71+
}
72+
73+
[TestMethod]
74+
public void EFFunctionsLike_WildcardSingleChar()
75+
{
76+
// "_" in EF's LIKE binding is the SQL one-char wildcard, not the C#
77+
// identifier convention.
78+
using var context = SeededContext();
79+
var ids = context.People.Where(p => EF.Functions.Like(p.Name, "_ob")).Select(p => p.Id).ToArray();
80+
CollectionAssert.AreEqual(new[] { 2 }, ids);
81+
}
82+
83+
[TestMethod]
84+
public void EFFunctionsLike_BracketCharacterClass()
85+
{
86+
using var context = SeededContext();
87+
var ids = context.People.Where(p => EF.Functions.Like(p.Name, "[ABC]%")).OrderBy(p => p.Id).Select(p => p.Id).ToArray();
88+
CollectionAssert.AreEqual(new[] { 1, 2, 3, 4 }, ids);
89+
}
90+
91+
[TestMethod]
92+
public void EFFunctionsLike_WithEscape()
93+
{
94+
// The 3-arg EF.Functions.Like overload threads an ESCAPE clause through
95+
// to the simulator. Insert a row whose name contains a literal '%' and
96+
// verify the escaped pattern matches it without treating '%' as wild.
97+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
98+
context.People.AddRange(
99+
new Person { Id = 1, Name = "100% Pure" },
100+
new Person { Id = 2, Name = "Mostly Pure" });
101+
_ = context.SaveChanges();
102+
103+
var ids = context.People.Where(p => EF.Functions.Like(p.Name, "%!%%", "!")).Select(p => p.Id).ToArray();
104+
CollectionAssert.AreEqual(new[] { 1 }, ids);
105+
}
106+
107+
[TestMethod]
108+
public void StartsWith_OnVarcharColumn_AlsoWorks()
109+
{
110+
// Code is varchar(10) — exercises the CP1252 path rather than nvarchar.
111+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
112+
context.People.AddRange(
113+
new Person { Id = 1, Name = "x", Code = "ABC-1" },
114+
new Person { Id = 2, Name = "y", Code = "ABC-2" },
115+
new Person { Id = 3, Name = "z", Code = "XYZ-1" });
116+
_ = context.SaveChanges();
117+
118+
var ids = context.People.Where(p => p.Code!.StartsWith("ABC")).OrderBy(p => p.Id).Select(p => p.Id).ToArray();
119+
CollectionAssert.AreEqual(new[] { 1, 2 }, ids);
120+
}
121+
122+
[TestMethod]
123+
public void NotStartsWith_NegatesPredicate()
124+
{
125+
using var context = SeededContext();
126+
var ids = context.People.Where(p => !p.Name.StartsWith("Ali")).OrderBy(p => p.Id).Select(p => p.Id).ToArray();
127+
CollectionAssert.AreEqual(new[] { 2, 4 }, ids);
128+
}
129+
}
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
using System.Data.Common;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Direct-SQL coverage for the <c>LIKE</c> / <c>NOT LIKE</c> predicate with
7+
/// optional <c>ESCAPE</c> clause. Behavior was probed against real SQL Server
8+
/// 2025 before encoding; the trailing-space rule (subject U+0020 leftovers
9+
/// accepted, pattern's must match), bracket-class corner cases, and ESCAPE
10+
/// validation all reflect real-server findings.
11+
/// </summary>
12+
[TestClass]
13+
public class LikeTests
14+
{
15+
[TestMethod]
16+
[DataRow("'abc' like 'abc'", 1)]
17+
[DataRow("'abc' like 'a_c'", 1)]
18+
[DataRow("'abc' like 'a%c'", 1)]
19+
[DataRow("'abc' like 'a%'", 1)]
20+
[DataRow("'abc' like '%c'", 1)]
21+
[DataRow("'abc' like '%b%'", 1)]
22+
[DataRow("'aaa' like 'a'", 0)]
23+
[DataRow("'aaa' like 'a%'", 1)]
24+
[DataRow("'aaa' like '%a'", 1)]
25+
[DataRow("'a' like '_'", 1)]
26+
[DataRow("'' like '_'", 0)]
27+
[DataRow("'' like '%'", 1)]
28+
[DataRow("'' like ''", 1)]
29+
[DataRow("'a' like ''", 0)]
30+
public void Basic(string condition, int expectedRows) =>
31+
AssertRowCount(condition, expectedRows);
32+
33+
/// <summary>
34+
/// LIKE does not ANSI-pad like <c>=</c>: the pattern's trailing spaces
35+
/// are taken literally, but the subject's trailing U+0020 leftovers are
36+
/// silently accepted after the pattern is exhausted.
37+
/// </summary>
38+
[TestMethod]
39+
[DataRow("'abc' like 'abc '", 0)] // pattern requires trailing space
40+
[DataRow("'abc ' like 'abc'", 1)] // subject's trailing space ignored
41+
[DataRow("'abc ' like 'abc '", 1)] // both have trailing space
42+
[DataRow("'abc' like 'abc%'", 1)] // % swallows everything
43+
[DataRow("'abc ' like 'abc%'", 1)]
44+
[DataRow("' ' like ''", 1)] // all-space subject vs empty pattern
45+
[DataRow("'' like ' '", 0)] // empty subject can't satisfy 3-space pattern
46+
[DataRow("' ' like ' '", 1)] // 1-space pattern + leftover spaces
47+
public void TrailingSpaces(string condition, int expectedRows) =>
48+
AssertRowCount(condition, expectedRows);
49+
50+
/// <summary>
51+
/// Only literal U+0020 counts as a trailing blank; tabs, CR, LF, NUL do not.
52+
/// </summary>
53+
[TestMethod]
54+
[DataRow("abc\t", "abc", 0)]
55+
[DataRow("abc\n", "abc", 0)]
56+
[DataRow("abc\r", "abc", 0)]
57+
[DataRow("abc\0", "abc", 0)]
58+
[DataRow("abc\t", "abc%", 1)] // % swallows tab
59+
public void TrailingWhitespace_OnlySpaceCounts(string subject, string pattern, int expectedRows) =>
60+
AssertParameterizedRowCount(subject, pattern, expectedRows);
61+
62+
/// <summary>
63+
/// <c>_</c> and <c>%</c> both cross newlines; <c>_</c> matches a single
64+
/// newline character.
65+
/// </summary>
66+
[TestMethod]
67+
[DataRow("a\nb", "a_b", 1)]
68+
[DataRow("a\rb", "a_b", 1)]
69+
[DataRow("a\nb", "a%b", 1)]
70+
[DataRow("\n", "_", 1)]
71+
[DataRow("\n", "%", 1)]
72+
public void NewlineCrossing(string subject, string pattern, int expectedRows) =>
73+
AssertParameterizedRowCount(subject, pattern, expectedRows);
74+
75+
[TestMethod]
76+
[DataRow("'a' like '[abc]'", 1)]
77+
[DataRow("'d' like '[abc]'", 0)]
78+
[DataRow("'a' like '[^abc]'", 0)]
79+
[DataRow("'d' like '[^abc]'", 1)]
80+
[DataRow("'5' like '[0-9]'", 1)]
81+
[DataRow("'a' like '[a-c]'", 1)]
82+
[DataRow("'B' like '[a-c]'", 1)] // case-insensitive default
83+
public void CharacterClasses(string condition, int expectedRows) =>
84+
AssertRowCount(condition, expectedRows);
85+
86+
/// <summary>
87+
/// Bracket parsing edge cases verified against real SQL Server 2025:
88+
/// leading/trailing hyphens are literal; <c>[]</c> is empty (never
89+
/// matches); reversed ranges (<c>[c-a]</c>) never match; unterminated
90+
/// <c>[</c> never matches; <c>[^]</c> matches any single char.
91+
/// </summary>
92+
[TestMethod]
93+
[DataRow("'-' like '[-a]'", 1)]
94+
[DataRow("'a' like '[a-]'", 1)]
95+
[DataRow("'-' like '[a-]'", 1)]
96+
[DataRow("'[' like '[[]'", 1)]
97+
[DataRow("'a' like '[]'", 0)]
98+
[DataRow("'a' like '[]a'", 0)]
99+
[DataRow("']' like ']'", 1)]
100+
[DataRow("'[' like '['", 0)]
101+
[DataRow("'a' like '[abc'", 0)]
102+
[DataRow("'b' like '[c-a]'", 0)]
103+
[DataRow("'a' like '[^]'", 1)]
104+
[DataRow("'^' like '[^]'", 1)]
105+
public void BracketEdgeCases(string condition, int expectedRows) =>
106+
AssertRowCount(condition, expectedRows);
107+
108+
/// <summary>
109+
/// Wildcards inside a class are taken literally — the documented way to
110+
/// match a literal <c>%</c> or <c>_</c> without using ESCAPE.
111+
/// </summary>
112+
[TestMethod]
113+
[DataRow("'a%' like 'a[%]'", 1)]
114+
[DataRow("'aX' like 'a[%]'", 0)]
115+
[DataRow("'a_' like 'a[_]'", 1)]
116+
public void WildcardsAsLiteralsInClass(string condition, int expectedRows) =>
117+
AssertRowCount(condition, expectedRows);
118+
119+
[TestMethod]
120+
[DataRow("'A' like 'a'", 1)]
121+
[DataRow("'AbC' like 'aBc'", 1)]
122+
public void DefaultCollationIsCaseInsensitive(string condition, int expectedRows) =>
123+
AssertRowCount(condition, expectedRows);
124+
125+
[TestMethod]
126+
[DataRow("'a' not like 'b'", 1)]
127+
[DataRow("'a' not like 'a'", 0)]
128+
[DataRow("'abc' not like 'a%'", 0)]
129+
public void NotLike(string condition, int expectedRows) =>
130+
AssertRowCount(condition, expectedRows);
131+
132+
/// <summary>
133+
/// Any NULL operand makes LIKE / NOT LIKE evaluate to UNKNOWN, which
134+
/// drops the row from a WHERE filter.
135+
/// </summary>
136+
[TestMethod]
137+
[DataRow("null like 'a'", 0)]
138+
[DataRow("'a' like null", 0)]
139+
[DataRow("null like null", 0)]
140+
[DataRow("null not like 'a'", 0)]
141+
public void NullOperand_IsUnknown(string condition, int expectedRows) =>
142+
AssertRowCount(condition, expectedRows);
143+
144+
[TestMethod]
145+
[DataRow("'a%b' like 'a!%b' escape '!'", 1)]
146+
[DataRow("'aXb' like 'a!%b' escape '!'", 0)]
147+
[DataRow("'a_b' like 'a!_b' escape '!'", 1)]
148+
[DataRow("'a[b' like 'a![b' escape '!'", 1)]
149+
[DataRow("'abc' like 'abc' escape '!'", 1)] // unused escape
150+
[DataRow("'aXb' like 'a!Xb' escape '!'", 1)] // escape before non-special: takes char as literal
151+
[DataRow("'a' like 'a!' escape '!'", 0)] // trailing escape: literal '!' after 'a' makes 2-char pattern
152+
public void EscapeClause(string condition, int expectedRows) =>
153+
AssertRowCount(condition, expectedRows);
154+
155+
[TestMethod]
156+
public void EscapeClause_MultiCharRaisesMsg506()
157+
{
158+
var simulation = new Simulation();
159+
var ex = Assert.Throws<DbException>(() =>
160+
simulation.ExecuteScalar("select 1 where 'a' like 'a' escape 'xy'"));
161+
StringAssert.Contains(ex.Message, "invalid escape character");
162+
StringAssert.Contains(ex.Message, "\"xy\"");
163+
}
164+
165+
[TestMethod]
166+
public void EscapeClause_EmptyRaisesMsg506()
167+
{
168+
var simulation = new Simulation();
169+
var ex = Assert.Throws<DbException>(() =>
170+
simulation.ExecuteScalar("select 1 where 'a' like 'a' escape ''"));
171+
StringAssert.Contains(ex.Message, "invalid escape character");
172+
}
173+
174+
[TestMethod]
175+
public void NonStringSubject_RaisesOperandTypeClash()
176+
{
177+
// The simulator's general gap — int↔string implicit conversion isn't
178+
// implemented. Real SQL Server auto-converts; here a CAST is required.
179+
var simulation = new Simulation();
180+
var ex = Assert.Throws<DbException>(() =>
181+
simulation.ExecuteScalar("select 1 where 123 like '1%'"));
182+
StringAssert.Contains(ex.Message, "Operand type clash");
183+
}
184+
185+
[TestMethod]
186+
public void LikeAgainstColumn_FiltersRows()
187+
{
188+
// Real-world use: LIKE against a varchar column from a table.
189+
var simulation = new Simulation();
190+
_ = simulation.ExecuteNonQuery("create table t ( id int, name nvarchar(40) )");
191+
_ = simulation.ExecuteNonQuery("insert into t values (1, 'apple'), (2, 'banana'), (3, 'apricot'), (4, 'cherry')");
192+
193+
using var reader = simulation
194+
.CreateCommand("select id from t where name like 'a%' order by id")
195+
.ExecuteReader();
196+
var ids = new List<int>();
197+
while (reader.Read())
198+
ids.Add(reader.GetInt32(0));
199+
CollectionAssert.AreEqual(new[] { 1, 3 }, ids);
200+
}
201+
202+
[TestMethod]
203+
public void LikePatternFromParameter_Works()
204+
{
205+
// EF Core parameterizes the pattern; verify the pattern can come from
206+
// a SqlParameter rather than a literal.
207+
var simulation = new Simulation();
208+
_ = simulation.ExecuteNonQuery("create table t ( id int, name nvarchar(40) )");
209+
_ = simulation.ExecuteNonQuery("insert into t values (1, 'apple'), (2, 'banana')");
210+
211+
using var connection = simulation.CreateOpenConnection();
212+
using var command = connection.CreateCommand("select id from t where name like @p", ("@p", "ban%"));
213+
using var reader = command.ExecuteReader();
214+
Assert.IsTrue(reader.Read());
215+
Assert.AreEqual(2, reader.GetInt32(0));
216+
Assert.IsFalse(reader.Read());
217+
}
218+
219+
private static void AssertRowCount(string condition, int expectedRows) =>
220+
Assert.AreEqual(
221+
expectedRows,
222+
new Simulation().ExecuteReader($"select 1 where {condition}").EnumerateRecords().Count());
223+
224+
/// <summary>
225+
/// Subject and pattern travel as parameters so non-printable / control
226+
/// characters (tab, LF, CR, NUL) reach the predicate intact rather than
227+
/// requiring the unimplemented <c>CHAR(N)</c> built-in.
228+
/// </summary>
229+
private static void AssertParameterizedRowCount(string subject, string pattern, int expectedRows)
230+
{
231+
using var connection = new Simulation().CreateOpenConnection();
232+
using var command = connection.CreateCommand("select 1 where @s like @p", ("@s", subject), ("@p", pattern));
233+
using var reader = command.ExecuteReader();
234+
Assert.AreEqual(expectedRows, reader.EnumerateRecords().Count());
235+
}
236+
}

0 commit comments

Comments
 (0)