Skip to content

Commit 769c006

Browse files
committed
Filled out varchar/nvarchar/varbinary support: string and binary literals ('foo', N'foo', 0xAB), case-insensitive equality and ordering with ANSI trailing-space padding, twelve common string functions (LEN, LEFT/RIGHT, SUBSTRING, UPPER/LOWER, LTRIM/RTRIM/TRIM, CHARINDEX, REPLACE, REVERSE), and CAST for integer-family targets.
1 parent 73a57a6 commit 769c006

26 files changed

Lines changed: 1213 additions & 12 deletions

SqlServerSimulator.Tests.EFCore/EFCoreStrings.cs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,91 @@ public void Insert_NullableAvatarAcceptsNull()
159159
Assert.IsNull(context.People.Select(p => p.Avatar).FirstOrDefault());
160160
}
161161

162+
[TestMethod]
163+
public void StringFunction_Length()
164+
{
165+
// EF Core translates string.Length to CAST(LEN(x) AS int) — exercises
166+
// LEN's trailing-space exclusion and CAST in one query.
167+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
168+
_ = context.People.Add(new Person { Id = 1, Name = "Alice " });
169+
_ = context.SaveChanges();
170+
171+
var len = context.People.Select(p => p.Name.Length).FirstOrDefault();
172+
Assert.AreEqual(5, len);
173+
}
174+
175+
[TestMethod]
176+
public void StringFunction_ToUpper()
177+
{
178+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
179+
_ = context.People.Add(new Person { Id = 1, Name = "alice" });
180+
_ = context.SaveChanges();
181+
182+
Assert.AreEqual("ALICE", context.People.Select(p => p.Name.ToUpper()).FirstOrDefault());
183+
}
184+
185+
[TestMethod]
186+
public void StringFunction_ToLower()
187+
{
188+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
189+
_ = context.People.Add(new Person { Id = 1, Name = "ALICE" });
190+
_ = context.SaveChanges();
191+
192+
Assert.AreEqual("alice", context.People.Select(p => p.Name.ToLower()).FirstOrDefault());
193+
}
194+
195+
[TestMethod]
196+
public void StringFunction_Trim()
197+
{
198+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
199+
_ = context.People.Add(new Person { Id = 1, Name = " bob " });
200+
_ = context.SaveChanges();
201+
202+
Assert.AreEqual("bob", context.People.Select(p => p.Name.Trim()).FirstOrDefault());
203+
}
204+
205+
[TestMethod]
206+
public void StringFunction_TrimStart()
207+
{
208+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
209+
_ = context.People.Add(new Person { Id = 1, Name = " bob" });
210+
_ = context.SaveChanges();
211+
212+
Assert.AreEqual("bob", context.People.Select(p => p.Name.TrimStart()).FirstOrDefault());
213+
}
214+
215+
[TestMethod]
216+
public void StringFunction_TrimEnd()
217+
{
218+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
219+
_ = context.People.Add(new Person { Id = 1, Name = "bob " });
220+
_ = context.SaveChanges();
221+
222+
Assert.AreEqual("bob", context.People.Select(p => p.Name.TrimEnd()).FirstOrDefault());
223+
}
224+
225+
[TestMethod]
226+
public void StringFunction_Substring()
227+
{
228+
// C# Substring(start, length) is 0-indexed; EF Core's translation to
229+
// T-SQL SUBSTRING (1-indexed) handles the off-by-one.
230+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
231+
_ = context.People.Add(new Person { Id = 1, Name = "alphabet" });
232+
_ = context.SaveChanges();
233+
234+
Assert.AreEqual("lpha", context.People.Select(p => p.Name.Substring(1, 4)).FirstOrDefault());
235+
}
236+
237+
[TestMethod]
238+
public void StringFunction_Replace()
239+
{
240+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
241+
_ = context.People.Add(new Person { Id = 1, Name = "hello" });
242+
_ = context.SaveChanges();
243+
244+
Assert.AreEqual("heLLo", context.People.Select(p => p.Name.Replace("l", "L")).FirstOrDefault());
245+
}
246+
162247
[TestMethod]
163248
public void Insert_MultipleRows_RoundTripsBothColumns()
164249
{

SqlServerSimulator.Tests/BuiltInFunctionTests.cs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,133 @@ public void BuiltInFunction(string function, string input, object output)
3535
AreEqual(output, ExecuteScalar($"select {function}({input})"));
3636
AreEqual(output, ExecuteScalar($"select {function.ToUpperInvariant()}({input})"));
3737
}
38+
39+
[TestMethod]
40+
[DataRow("len('abc')", 3)]
41+
[DataRow("len('abc ')", 3)] // trailing spaces excluded
42+
[DataRow("len(' ')", 0)] // all-trailing-space → 0
43+
[DataRow("len('')", 0)]
44+
[DataRow("len(' abc')", 5)] // leading spaces preserved
45+
[DataRow("datalength('abc')", 3)] // for varchar: 1 byte/char in CP1252
46+
[DataRow("datalength('café')", 4)] // 'café' is 4 CP1252 bytes
47+
[DataRow("datalength(N'café')", 8)] // nvarchar: 2 bytes/char
48+
[DataRow("upper('AbC')", "ABC")]
49+
[DataRow("upper('café')", "CAFÉ")]
50+
[DataRow("lower('AbC')", "abc")]
51+
[DataRow("lower('CAFÉ')", "café")]
52+
[DataRow("ltrim(' abc')", "abc")]
53+
[DataRow("ltrim(' abc ')", "abc ")] // only leading
54+
[DataRow("rtrim('abc ')", "abc")]
55+
[DataRow("rtrim(' abc ')", " abc")] // only trailing
56+
[DataRow("trim(' abc ')", "abc")]
57+
[DataRow("trim('abc')", "abc")]
58+
[DataRow("reverse('abc')", "cba")]
59+
[DataRow("reverse('')", "")]
60+
[DataRow("left('hello', 3)", "hel")]
61+
[DataRow("left('hi', 10)", "hi")] // count past length clamps
62+
[DataRow("left('abc', 0)", "")]
63+
[DataRow("right('hello', 3)", "llo")]
64+
[DataRow("right('hi', 10)", "hi")]
65+
[DataRow("right('abc', 0)", "")]
66+
[DataRow("substring('hello', 2, 3)", "ell")] // 1-indexed
67+
[DataRow("substring('hello', 1, 5)", "hello")]
68+
[DataRow("substring('hello', 6, 5)", "")] // start past end → empty
69+
[DataRow("substring('hello', 0, 3)", "he")] // start <= 0 truncates window
70+
[DataRow("charindex('lo', 'hello')", 4)]
71+
[DataRow("charindex('xx', 'hello')", 0)] // not found
72+
[DataRow("charindex('LO', 'hello')", 4)] // case-insensitive
73+
[DataRow("charindex('l', 'hello', 4)", 4)] // start at l-position
74+
[DataRow("charindex('l', 'hello', 5)", 0)] // start past last l
75+
[DataRow("replace('hello', 'l', 'L')", "heLLo")]
76+
[DataRow("replace('hello', 'L', 'X')", "heXXo")] // case-insensitive match
77+
[DataRow("replace('hello', 'xyz', '!')", "hello")]
78+
public void StringFunction(string expression, object expected) =>
79+
AreEqual(expected, ExecuteScalar($"select {expression}"));
80+
81+
[TestMethod]
82+
[DataRow("len")]
83+
[DataRow("upper")]
84+
[DataRow("lower")]
85+
[DataRow("ltrim")]
86+
[DataRow("rtrim")]
87+
[DataRow("trim")]
88+
[DataRow("reverse")]
89+
public void StringFunction_NullPassThrough(string function) =>
90+
IsInstanceOfType<DBNull>(ExecuteScalar($"select {function}(null)"));
91+
92+
[TestMethod]
93+
[DataRow("left(null, 3)")]
94+
[DataRow("left('abc', null)")]
95+
[DataRow("right(null, 3)")]
96+
[DataRow("substring(null, 1, 3)")]
97+
[DataRow("substring('abc', null, 3)")]
98+
[DataRow("substring('abc', 1, null)")]
99+
[DataRow("charindex(null, 'abc')")]
100+
[DataRow("charindex('a', null)")]
101+
[DataRow("replace(null, 'a', 'b')")]
102+
[DataRow("replace('abc', null, 'b')")]
103+
[DataRow("replace('abc', 'a', null)")]
104+
public void MultiArgStringFunction_NullPropagates(string expression) =>
105+
IsInstanceOfType<DBNull>(ExecuteScalar($"select {expression}"));
106+
107+
[TestMethod]
108+
[DataRow("left('abc', -1)")]
109+
[DataRow("right('abc', -1)")]
110+
[DataRow("substring('abc', 1, -1)")]
111+
public void NegativeLength_RaisesMsg537(string expression)
112+
{
113+
var ex = Throws<DbException>(() => ExecuteScalar($"select {expression}"));
114+
StringAssert.Contains(ex.Message, "Invalid length parameter");
115+
}
116+
117+
[TestMethod]
118+
public void FunctionOfColumn_FromTable()
119+
{
120+
// Regression: Expression.Parse used to advance past the function's
121+
// closing ')' AND let the surrounding while loop advance again,
122+
// skipping a trailing FROM. The `select abs(n) from t` shape never had
123+
// a test before this arc, so the bug surfaced only via EF Core.
124+
using var connection = new Simulation().CreateOpenConnection();
125+
_ = connection.CreateCommand("create table t ( v int )").ExecuteNonQuery();
126+
_ = connection.CreateCommand("insert t values ( 5 )").ExecuteNonQuery();
127+
AreEqual(5, connection.CreateCommand("select abs(v) from t").ExecuteScalar());
128+
}
129+
130+
[TestMethod]
131+
public void StringFunctionOfColumn_FromTable()
132+
{
133+
using var connection = new Simulation().CreateOpenConnection();
134+
_ = connection.CreateCommand("create table t ( name varchar(20) )").ExecuteNonQuery();
135+
_ = connection.CreateCommand("insert t values ( 'alice' )").ExecuteNonQuery();
136+
AreEqual("ALICE", connection.CreateCommand("select upper(name) from t").ExecuteScalar());
137+
}
138+
139+
[TestMethod]
140+
[DataRow("cast(1 as int)", 1)]
141+
[DataRow("cast(1 as bigint)", 1L)]
142+
[DataRow("cast(1 as smallint)", (short)1)]
143+
[DataRow("cast(255 as tinyint)", (byte)255)]
144+
[DataRow("cast(1 as bit)", true)]
145+
[DataRow("cast(0 as bit)", false)]
146+
public void Cast(string expression, object expected) =>
147+
AreEqual(expected, ExecuteScalar($"select {expression}"));
148+
149+
[TestMethod]
150+
public void Cast_NullPassesThroughWithRetargetedType() =>
151+
IsInstanceOfType<DBNull>(ExecuteScalar("select cast(null as int)"));
152+
153+
[TestMethod]
154+
public void Cast_NarrowingOverflow_RaisesArithmeticOverflow()
155+
{
156+
var ex = Throws<DbException>(() => ExecuteScalar("select cast(300 as tinyint)"));
157+
StringAssert.Contains(ex.Message, "Arithmetic overflow");
158+
}
159+
160+
[TestMethod]
161+
public void Cast_CrossCategory_NotSupported()
162+
{
163+
// String ↔ numeric coercion isn't modeled yet; CAST surfaces the same
164+
// NotSupportedException SqlValue.CoerceTo throws.
165+
_ = Throws<NotSupportedException>(() => ExecuteScalar("select cast('42' as int)"));
166+
}
38167
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
using System.Data;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// End-to-end coverage of the tokenized literal forms ('foo', N'foo',
8+
/// 0xHEX) and the SQL Server semantics they exercise: ANSI trailing-space
9+
/// padding for varchar/nvarchar equality, case-insensitive default collation,
10+
/// accent-sensitive comparison, escape handling for embedded apostrophes, and
11+
/// no-padding equality for varbinary.
12+
/// </summary>
13+
[TestClass]
14+
public class StringLiteralTests
15+
{
16+
[TestMethod]
17+
[DataRow("'a' = 'a'", 1)]
18+
[DataRow("'a' = 'A'", 1)] // case-insensitive default collation
19+
[DataRow("'a' = 'b'", 0)]
20+
[DataRow("'a' = 'a '", 1)] // ANSI trailing-space padding
21+
[DataRow("'a ' = 'a '", 1)]
22+
[DataRow("'' = ' '", 1)] // empty equals all-spaces under padding
23+
[DataRow("'a' = ' a'", 0)] // leading spaces are significant
24+
[DataRow("'café' = 'cafe'", 0)] // accent-sensitive
25+
[DataRow("'café' = 'CAFÉ'", 1)] // accents preserved across case fold
26+
public void VarcharEquality(string condition, int expectedRows) =>
27+
AreEqual(expectedRows, new Simulation().ExecuteReader($"select 1 where {condition}").EnumerateRecords().Count());
28+
29+
[TestMethod]
30+
[DataRow("N'a' = N'A'", 1)]
31+
[DataRow("N'Bjørn' = N'BJØRN'", 1)]
32+
[DataRow("N'Bjørn ' = N'BJØRN'", 1)]
33+
[DataRow("N'a' = N'b'", 0)]
34+
public void NVarcharEquality(string condition, int expectedRows) =>
35+
AreEqual(expectedRows, new Simulation().ExecuteReader($"select 1 where {condition}").EnumerateRecords().Count());
36+
37+
[TestMethod]
38+
[DataRow("'a' < 'b'", 1)]
39+
[DataRow("'a' < 'a'", 0)]
40+
[DataRow("'b' > 'a'", 1)]
41+
[DataRow("'a' <= 'A '", 1)] // case fold + ANSI padding produce equality, so <= is true
42+
[DataRow("'a' >= 'A '", 1)]
43+
[DataRow("'a' < 'A '", 0)] // ... but strict less-than is false
44+
[DataRow("'apple' < 'banana'", 1)]
45+
[DataRow("'banana' > 'apple'", 1)]
46+
public void VarcharOrdering(string condition, int expectedRows) =>
47+
AreEqual(expectedRows, new Simulation().ExecuteReader($"select 1 where {condition}").EnumerateRecords().Count());
48+
49+
[TestMethod]
50+
[DataRow("0x0102 < 0x0103", 1)]
51+
[DataRow("0x0103 > 0x0102", 1)]
52+
[DataRow("0x01 < 0x0100", 1)] // shorter is less when prefix matches (no padding)
53+
[DataRow("0x0100 > 0x01", 1)]
54+
[DataRow("0x01 = 0x01", 1)]
55+
public void VarbinaryOrdering(string condition, int expectedRows) =>
56+
AreEqual(expectedRows, new Simulation().ExecuteReader($"select 1 where {condition}").EnumerateRecords().Count());
57+
58+
[TestMethod]
59+
[DataRow("'a' = null", 0)] // any comparison with NULL is UNKNOWN, no row
60+
[DataRow("null = 'a'", 0)]
61+
[DataRow("null = null", 0)] // even NULL = NULL is UNKNOWN
62+
[DataRow("'' = null", 0)]
63+
public void NullVsString_AlwaysUnknown(string condition, int expectedRows) =>
64+
AreEqual(expectedRows, new Simulation().ExecuteReader($"select 1 where {condition}").EnumerateRecords().Count());
65+
66+
[TestMethod]
67+
[DataRow("0xDEAD = 0xDEAD", 1)]
68+
[DataRow("0xDEAD = 0xBEEF", 0)]
69+
[DataRow("0xdead = 0xDEAD", 1)] // hex digits are case-insensitive in the literal
70+
[DataRow("0x01 = 0x0100", 0)] // no padding for varbinary
71+
[DataRow("0x = 0x", 0)] // 0x without digits is a syntax error — verified separately below
72+
public void VarbinaryEquality(string condition, int expectedRows)
73+
{
74+
if (condition.Contains("0x =", System.StringComparison.Ordinal))
75+
{
76+
// The "0x =" rows are filtered out below by the syntax-error test;
77+
// skip them here to keep the data-row table compact.
78+
return;
79+
}
80+
AreEqual(expectedRows, new Simulation().ExecuteReader($"select 1 where {condition}").EnumerateRecords().Count());
81+
}
82+
83+
[TestMethod]
84+
public void HexLiteral_OddLengthZeroPadsHighNibble()
85+
{
86+
// 0xABC → 0x0ABC (high nibble defaulted to 0, matching SQL Server).
87+
AreEqual(1, new Simulation().ExecuteReader("select 1 where 0xABC = 0x0ABC").EnumerateRecords().Count());
88+
}
89+
90+
[TestMethod]
91+
public void HexLiteral_BarePrefix_RaisesSyntaxError()
92+
{
93+
var ex = Throws<System.Data.Common.DbException>(() => new Simulation().ExecuteReader("select 1 where 0x = 0x").EnumerateRecords().ToArray());
94+
StringAssert.Contains(ex.Message, "syntax");
95+
}
96+
97+
[TestMethod]
98+
public void StringLiteral_EmbeddedApostropheEscape()
99+
{
100+
// 'foo''bar' parses as foo'bar.
101+
using var connection = new Simulation().CreateOpenConnection();
102+
_ = connection.CreateCommand("create table t ( v varchar(20) )").ExecuteNonQuery();
103+
_ = connection.CreateCommand("insert t values ( 'foo''bar' )").ExecuteNonQuery();
104+
AreEqual("foo'bar", connection.CreateCommand("select v from t").ExecuteScalar());
105+
}
106+
107+
[TestMethod]
108+
public void StringLiteral_Unclosed_RaisesError()
109+
{
110+
var ex = Throws<System.Data.Common.DbException>(() => new Simulation().ExecuteReader("select 1 where 'unclosed = 1").EnumerateRecords().ToArray());
111+
StringAssert.Contains(ex.Message, "Unclosed quotation mark");
112+
}
113+
114+
[TestMethod]
115+
public void Insert_VarcharLiteral_StoresAndReadsBack()
116+
{
117+
using var connection = new Simulation().CreateOpenConnection();
118+
_ = connection.CreateCommand("create table t ( v varchar(20) )").ExecuteNonQuery();
119+
_ = connection.CreateCommand("insert t values ( 'hello' )").ExecuteNonQuery();
120+
AreEqual("hello", connection.CreateCommand("select v from t").ExecuteScalar());
121+
}
122+
123+
[TestMethod]
124+
public void Insert_NVarcharLiteral_StoresAndReadsBack()
125+
{
126+
using var connection = new Simulation().CreateOpenConnection();
127+
_ = connection.CreateCommand("create table t ( v nvarchar(20) )").ExecuteNonQuery();
128+
_ = connection.CreateCommand("insert t values ( N'café' )").ExecuteNonQuery();
129+
AreEqual("café", connection.CreateCommand("select v from t").ExecuteScalar());
130+
}
131+
132+
[TestMethod]
133+
public void Insert_HexLiteral_StoresAndReadsBack()
134+
{
135+
using var connection = new Simulation().CreateOpenConnection();
136+
_ = connection.CreateCommand("create table t ( v varbinary(8) )").ExecuteNonQuery();
137+
_ = connection.CreateCommand("insert t values ( 0xDEADBEEF )").ExecuteNonQuery();
138+
var read = (byte[]?)connection.CreateCommand("select v from t").ExecuteScalar();
139+
CollectionAssert.AreEqual(new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }, read);
140+
}
141+
142+
[TestMethod]
143+
public void FromTableWhere_FiltersByVarcharLiteral()
144+
{
145+
using var connection = new Simulation().CreateOpenConnection();
146+
_ = connection.CreateCommand("create table t ( name varchar(20) )").ExecuteNonQuery();
147+
_ = connection.CreateCommand("insert t values ( 'Alice' ), ( 'Bob' ), ( 'Carol' )").ExecuteNonQuery();
148+
149+
// Case-insensitive + ANSI padding: 'BOB ' matches 'Bob'.
150+
using var reader = connection.CreateCommand("select name from t where name = 'BOB '").ExecuteReader();
151+
IsTrue(reader.Read());
152+
AreEqual("Bob", reader[0]);
153+
IsFalse(reader.Read());
154+
}
155+
156+
[TestMethod]
157+
public void FromTableWhere_FiltersByVarbinaryLiteral()
158+
{
159+
using var connection = new Simulation().CreateOpenConnection();
160+
_ = connection.CreateCommand("create table t ( id int, payload varbinary(4) )").ExecuteNonQuery();
161+
_ = connection.CreateCommand("insert t values ( 1, 0xDEAD ), ( 2, 0xBEEF )").ExecuteNonQuery();
162+
163+
using var reader = connection.CreateCommand("select id from t where payload = 0xBEEF").ExecuteReader();
164+
IsTrue(reader.Read());
165+
AreEqual(2, reader[0]);
166+
IsFalse(reader.Read());
167+
}
168+
}

0 commit comments

Comments
 (0)