Skip to content

Commit 972577c

Browse files
committed
Greatly increased supported scenarios for implicit type coercion.
1 parent 0b48e71 commit 972577c

38 files changed

Lines changed: 582 additions & 118 deletions

SqlServerSimulator.Tests/CastTests.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,58 @@ public void Cast_NarrowVarbinary_SilentlyTruncates()
393393
CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, result);
394394
}
395395

396+
/// <summary>
397+
/// String → varbinary CAST encodes through CP1252 for varchar/char and
398+
/// UTF-16 LE for nvarchar/nchar, then the CAST-level path truncates to
399+
/// the declared length. <c>varbinary(N)</c> never pads. Probe-confirmed
400+
/// against SQL Server 2025 (2026-05-22).
401+
/// </summary>
402+
[TestMethod]
403+
public void Cast_VarcharToVarbinary_EncodesCp1252Bytes()
404+
{
405+
var result = ExecuteScalar("select cast('abc' as varbinary(10))") as byte[];
406+
IsNotNull(result);
407+
CollectionAssert.AreEqual("abc"u8.ToArray(), result);
408+
}
409+
410+
[TestMethod]
411+
public void Cast_NarrowVarbinary_StringSource_Truncates()
412+
{
413+
var result = ExecuteScalar("select cast('abcdefghijklmn' as varbinary(5))") as byte[];
414+
IsNotNull(result);
415+
CollectionAssert.AreEqual("abcde"u8.ToArray(), result);
416+
}
417+
418+
[TestMethod]
419+
public void Cast_NvarcharToVarbinary_EncodesUtf16LeBytes()
420+
{
421+
var result = ExecuteScalar("select cast(N'abc' as varbinary(10))") as byte[];
422+
IsNotNull(result);
423+
CollectionAssert.AreEqual(new byte[] { 0x61, 0x00, 0x62, 0x00, 0x63, 0x00 }, result);
424+
}
425+
426+
/// <summary>
427+
/// <c>binary(N)</c> right-pads with zero bytes when the source encoding
428+
/// is shorter than N (verified <c>CAST('abc' AS BINARY(10)) →
429+
/// 0x61626300000000000000</c>). FromBinary applies the
430+
/// pad-or-truncate normalization inside the CoerceTo path.
431+
/// </summary>
432+
[TestMethod]
433+
public void Cast_VarcharToFixedBinary_PadsWithZeros()
434+
{
435+
var result = ExecuteScalar("select cast('abc' as binary(10))") as byte[];
436+
IsNotNull(result);
437+
CollectionAssert.AreEqual(new byte[] { 0x61, 0x62, 0x63, 0, 0, 0, 0, 0, 0, 0 }, result);
438+
}
439+
440+
[TestMethod]
441+
public void Cast_VarcharToFixedBinary_LongerSourceTruncates()
442+
{
443+
var result = ExecuteScalar("select cast('abcdefghijklmn' as binary(5))") as byte[];
444+
IsNotNull(result);
445+
CollectionAssert.AreEqual("abcde"u8.ToArray(), result);
446+
}
447+
396448
[TestMethod]
397449
[DataRow("cast(123456 as varchar(3))", "*")]
398450
[DataRow("cast(123456 as varchar(5))", "*")]
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
using static SqlServerSimulator.TestHelpers;
4+
5+
namespace SqlServerSimulator;
6+
7+
/// <summary>
8+
/// SQL Server implicit-casts non-string operands of the string scalars
9+
/// (LEN / LOWER / UPPER / LTRIM / RTRIM / REVERSE / LEFT / RIGHT / REPLACE)
10+
/// to <c>varchar</c>, and string operands of the math scalars
11+
/// (ABS / CEILING / FLOOR / SIGN / SQRT / DEGREES / RADIANS) to
12+
/// <c>float</c>. DATEPART / DATEADD / DATEDIFF accept string operands
13+
/// (parsed as <c>datetime2(7)</c>) and integer operands (interpreted as
14+
/// days-since-1900-01-01, i.e. legacy <c>datetime</c>) for the date
15+
/// argument. Every assertion below was probe-confirmed against SQL
16+
/// Server 2025 on 2026-05-22.
17+
/// </summary>
18+
[TestClass]
19+
public sealed class ImplicitCoercionTests
20+
{
21+
[TestMethod]
22+
[DataRow("len(12345)", 5)]
23+
[DataRow("len(cast(12345 as bigint))", 5)]
24+
[DataRow("len(cast(123.45 as decimal(5,2)))", 6)]
25+
[DataRow("len(cast('2024-01-15' as date))", 10)]
26+
[DataRow("len(cast(12.5 as float))", 4)]
27+
public void Len_NonString_ImplicitCoerce(string expression, int expected) =>
28+
AreEqual(expected, ExecuteScalar($"select {expression}"));
29+
30+
[TestMethod]
31+
[DataRow("lower(12345)", "12345")]
32+
[DataRow("lower(cast(12345 as bigint))", "12345")]
33+
[DataRow("lower(cast(123.45 as decimal(5,2)))", "123.45")]
34+
[DataRow("lower(cast('2024-01-15' as date))", "2024-01-15")]
35+
[DataRow("upper(12345)", "12345")]
36+
[DataRow("upper(cast('2024-01-15' as date))", "2024-01-15")]
37+
[DataRow("ltrim(12345)", "12345")]
38+
[DataRow("rtrim(12345)", "12345")]
39+
[DataRow("reverse(12345)", "54321")]
40+
[DataRow("reverse(cast('2024-01-15' as date))", "51-10-4202")]
41+
[DataRow("left(12345, 3)", "123")]
42+
[DataRow("left(cast('2024-01-15' as date), 4)", "2024")]
43+
[DataRow("right(12345, 3)", "345")]
44+
[DataRow("right(cast('2024-01-15' as date), 2)", "15")]
45+
[DataRow("replace(12345, 2, 9)", "19345")]
46+
[DataRow("replace(cast('2024-01-15' as date), '-', '/')", "2024/01/15")]
47+
public void StringScalar_NonString_ImplicitCoerce(string expression, string expected) =>
48+
AreEqual(expected, ExecuteScalar($"select {expression}"));
49+
50+
[TestMethod]
51+
[DataRow("abs('-5')", 5.0)]
52+
[DataRow("abs('-5.5')", 5.5)]
53+
[DataRow("abs(N'-5')", 5.0)]
54+
[DataRow("abs(' -5 ')", 5.0)]
55+
[DataRow("ceiling('5.5')", 6.0)]
56+
[DataRow("ceiling('-5.5')", -5.0)]
57+
[DataRow("floor('5.5')", 5.0)]
58+
[DataRow("floor('-5.5')", -6.0)]
59+
[DataRow("sign('-5')", -1.0)]
60+
[DataRow("sign('5')", 1.0)]
61+
[DataRow("sign('0')", 0.0)]
62+
[DataRow("sqrt('16')", 4.0)]
63+
[DataRow("radians('180')", 3.141592653589793)]
64+
public void MathScalar_String_ImplicitCoerce(string expression, double expected) =>
65+
AreEqual(expected, ExecuteScalar($"select {expression}"));
66+
67+
[TestMethod]
68+
public void Degrees_String_ImplicitCoerce() =>
69+
AreEqual(57.29577951308232, ExecuteScalar("select degrees('1')"));
70+
71+
[TestMethod]
72+
[DataRow("log('10')", 2.302585092994046)]
73+
[DataRow("log10('100')", 2.0)]
74+
[DataRow("exp('1')", 2.718281828459045)]
75+
[DataRow("square('3')", 9.0)]
76+
[DataRow("sin('0')", 0.0)]
77+
[DataRow("cos('0')", 1.0)]
78+
[DataRow("tan('0')", 0.0)]
79+
[DataRow("asin('0')", 0.0)]
80+
[DataRow("acos('1')", 0.0)]
81+
[DataRow("atan('0')", 0.0)]
82+
[DataRow("atn2('0', '1')", 0.0)]
83+
[DataRow("cot('1')", 0.6420926159343306)]
84+
// Choose a value that doesn't sit on the float-representation boundary
85+
// (5.55 → 5.5500000000000007 in IEEE 754, so ROUND-half-away-from-zero
86+
// diverges between engines depending on the multiply-by-10 strategy).
87+
[DataRow("round('5.4', 0)", 5.0)]
88+
public void MathSibling_String_ImplicitCoerce(string expression, double expected) =>
89+
AreEqual(expected, ExecuteScalar($"select {expression}"));
90+
91+
/// <summary>
92+
/// log(value, base) with both args string — real coerces both to float.
93+
/// </summary>
94+
[TestMethod]
95+
public void Log_StringBase_ImplicitCoerce() =>
96+
AreEqual(3.0, ExecuteScalar("select log('8', '2')"));
97+
98+
/// <summary>
99+
/// POWER's first arg drives the result type: string base widens to float.
100+
/// </summary>
101+
[TestMethod]
102+
public void Power_StringBase_ProjectsAsFloat() =>
103+
AreEqual(8.0, ExecuteScalar("select power('2', 3)"));
104+
105+
/// <summary>
106+
/// POWER(int, string) preserves int result type (truncates toward zero).
107+
/// </summary>
108+
[TestMethod]
109+
public void Power_StringExponent_PreservesIntBase() =>
110+
AreEqual(8, ExecuteScalar("select power(2, '3')"));
111+
112+
[TestMethod]
113+
public void Round_NonIntegerLength_RaisesMsg8116()
114+
{
115+
// ROUND's length arg stays strict-int — Msg 8116 on string.
116+
var ex = Throws<DbException>(() => ExecuteScalar("select round(5.55, '1')"));
117+
Assert.Contains("Argument data type", ex.Message);
118+
Assert.Contains("argument 2 of round", ex.Message);
119+
}
120+
121+
[TestMethod]
122+
public void MathScalar_BadString_RaisesConversionError()
123+
{
124+
// ABS('abc') / CEILING('abc') route through the string-to-float
125+
// parser; bad text produces Msg 8114 ("Error converting data type
126+
// varchar to float.") — same code real SQL Server raises.
127+
var ex = Throws<DbException>(() => ExecuteScalar("select abs('abc')"));
128+
Assert.Contains("Error converting data type", ex.Message);
129+
}
130+
131+
[TestMethod]
132+
[DataRow("datepart(year, '2024-01-15')", 2024)]
133+
[DataRow("datepart(year, N'2024-01-15')", 2024)]
134+
[DataRow("datepart(year, 0)", 1900)]
135+
[DataRow("datepart(year, 100)", 1900)]
136+
[DataRow("datepart(year, cast(45000 as bigint))", 2023)]
137+
[DataRow("datediff(day, '2024-01-01', '2024-01-31')", 30)]
138+
[DataRow("datediff(day, 0, '2024-01-31')", 45320)]
139+
[DataRow("datediff(day, '2024-01-01', 100)", -45190)]
140+
public void DateFunction_StringAndIntegerOperands_ImplicitCoerce(string expression, int expected) =>
141+
AreEqual(expected, ExecuteScalar($"select {expression}"));
142+
143+
[TestMethod]
144+
public void DateAdd_String_ImplicitCoerce() =>
145+
AreEqual(new DateTime(2024, 1, 16), ExecuteScalar("select dateadd(day, 1, '2024-01-15')"));
146+
147+
[TestMethod]
148+
public void DateAdd_Integer_ImplicitCoerce() =>
149+
AreEqual(new DateTime(1900, 1, 2), ExecuteScalar("select dateadd(day, 1, 0)"));
150+
151+
[TestMethod]
152+
public void DateAdd_LargeInteger_ImplicitCoerce() =>
153+
AreEqual(new DateTime(1900, 4, 12), ExecuteScalar("select dateadd(day, 1, 100)"));
154+
155+
[TestMethod]
156+
[DataRow("charindex('2', 12345)", 2)]
157+
[DataRow("charindex('5', cast(12345 as bigint))", 5)]
158+
[DataRow("charindex('-', cast('2024-01-15' as date))", 5)]
159+
public void CharIndex_Haystack_ImplicitCoerce(string expression, int expected) =>
160+
AreEqual(expected, ExecuteScalar($"select {expression}"));
161+
162+
[TestMethod]
163+
public void CharIndex_NonStringNeedle_StaysStrict()
164+
{
165+
// Real SQL Server rejects non-string needle (arg 1); simulator matches.
166+
var ex = Throws<DbException>(() => ExecuteScalar("select charindex(2, 12345)"));
167+
Assert.Contains("argument 1 of charindex", ex.Message);
168+
}
169+
170+
[TestMethod]
171+
[DataRow("stuff('abcde', 2, 1, 99)", "a99cde")]
172+
[DataRow("stuff(99, 2, 1, 99)", "999")]
173+
[DataRow("stuff('abcde', 2, 1, cast('2024-01-15' as date))", "a2024-01-15cde")]
174+
public void Stuff_NonString_ImplicitCoerce(string expression, string expected) =>
175+
AreEqual(expected, ExecuteScalar($"select {expression}"));
176+
177+
[TestMethod]
178+
[DataRow("len(0x4142202020)", 2)] // CP1252-space bytes trim
179+
[DataRow("len(0x00)", 1)] // null byte isn't a space
180+
[DataRow("len(0x20)", 0)] // single space byte trims away
181+
[DataRow("len(0x4100)", 2)] // 'A' + trailing null stays at 2
182+
[DataRow("len(cast(0x4142202020 as varbinary(10)))", 2)]
183+
[DataRow("len(cast(0x4142202020 as binary(10)))", 10)] // binary zero-padding isn't trimmed
184+
[DataRow("len(cast('abc' as varbinary(10)))", 3)] // string→varbinary via the new CoerceTo path
185+
public void Len_Binary_ImplicitCoerce(string expression, int expected) =>
186+
AreEqual(expected, ExecuteScalar($"select {expression}"));
187+
188+
[TestMethod]
189+
[DataRow("lower(0x414243)", "abc")] // 'ABC' bytes → 'abc'
190+
[DataRow("upper(0x616263)", "ABC")] // 'abc' bytes → 'ABC'
191+
[DataRow("ltrim(0x202041)", "A")]
192+
[DataRow("rtrim(0x412020)", "A")]
193+
[DataRow("reverse(0x414243)", "CBA")]
194+
public void StringScalar_Binary_ImplicitCoerce(string expression, string expected) =>
195+
AreEqual(expected, ExecuteScalar($"select {expression}"));
196+
197+
[TestMethod]
198+
public void Len_Image_StaysRejected()
199+
{
200+
// Image (legacy LOB form of varbinary) stays rejected — real SQL
201+
// Server raises Msg 8116 on the implicit-coerce path; the
202+
// simulator's StringScalars.IsCoerceableToVarchar deliberately
203+
// excludes image to match.
204+
var ex = Throws<DbException>(() => ExecuteScalar("select len(cast(0x010203 as image))"));
205+
Assert.Contains("argument 1 of len", ex.Message);
206+
}
207+
208+
[TestMethod]
209+
[DataRow("replicate(12345, 2)", "1234512345")]
210+
[DataRow("replicate(cast(12 as bigint), 2)", "1212")]
211+
[DataRow("replicate(cast('2024-01-15' as date), 2)", "2024-01-152024-01-15")]
212+
public void Replicate_NonString_ImplicitCoerce(string expression, string expected) =>
213+
AreEqual(expected, ExecuteScalar($"select {expression}"));
214+
215+
[TestMethod]
216+
public void StringScalar_NullThroughNonStringSource()
217+
{
218+
// NULL of a non-string type still passes through the implicit-cast
219+
// path — the function returns NULL of the post-coerce type without
220+
// raising on the unsupported input.
221+
_ = IsInstanceOfType<DBNull>(ExecuteScalar("select len(cast(null as int))"));
222+
_ = IsInstanceOfType<DBNull>(ExecuteScalar("select lower(cast(null as date))"));
223+
_ = IsInstanceOfType<DBNull>(ExecuteScalar("select abs(cast(null as varchar(10)))"));
224+
_ = IsInstanceOfType<DBNull>(ExecuteScalar("select datepart(year, cast(null as varchar(20)))"));
225+
_ = IsInstanceOfType<DBNull>(ExecuteScalar("select dateadd(day, 1, cast(null as int))"));
226+
}
227+
}

SqlServerSimulator.Tests/ScalarFunctionTypingTests.cs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,12 +103,14 @@ public void Charindex_IntegerNeedle_RaisesMsg8116()
103103
8116,
104104
"Argument data type int is invalid for argument 1 of charindex function.");
105105

106+
/// <summary>
107+
/// Integer haystack implicit-coerces to varchar (probe-confirmed
108+
/// against real 2026-05-22: CHARINDEX('2', 12345) = 2). Needle stays
109+
/// strict — see Charindex_IntegerNeedle_RaisesMsg8116 above.
110+
/// </summary>
106111
[TestMethod]
107-
public void Charindex_IntegerHaystack_RaisesMsg8116OnArg2()
108-
=> new Simulation().AssertSqlError(
109-
"select charindex('a', cast(1234 as int))",
110-
8116,
111-
"Argument data type int is invalid for argument 2 of charindex function.");
112+
public void Charindex_IntegerHaystack_ImplicitCoercesToVarchar() =>
113+
AreEqual(2, ExecuteScalar("select charindex('2', cast(12345 as int))"));
112114

113115
// Note: LEN(text) / LEN(ntext) Msg 8116 — the existing CLAUDE.md "Not modeled"
114116
// entry covers this. The simulator's IsStringCategory treats text/ntext as

SqlServerSimulator/Parser/Expressions/AbsoluteValue.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ internal sealed class AbsoluteValue(ParserContext context) : Expression
3030

3131
public override SqlValue Run(RuntimeContext runtime)
3232
{
33-
var v = this.source.Run(runtime);
33+
var v = MathScalars.CoerceImplicit(this.source.Run(runtime));
3434
var resultType = MathScalars.WidenForResult(v.Type);
3535
return v.IsNull ? SqlValue.Null(resultType) : resultType.Category switch
3636
{

SqlServerSimulator/Parser/Expressions/Atn2.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ public Atn2(ParserContext context)
3434

3535
public override SqlValue Run(RuntimeContext runtime)
3636
{
37-
var y = this.first.Run(runtime);
37+
var y = MathScalars.CoerceImplicit(this.first.Run(runtime));
3838
if (y.IsNull) return SqlValue.Null(SqlType.Float);
39-
var x = this.second.Run(runtime);
39+
var x = MathScalars.CoerceImplicit(this.second.Run(runtime));
4040
if (x.IsNull) return SqlValue.Null(SqlType.Float);
4141
var yd = MathScalars.AsDouble(y);
4242
var xd = MathScalars.AsDouble(x);

SqlServerSimulator/Parser/Expressions/Ceiling.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ internal sealed class Ceiling(ParserContext context) : Expression
1313

1414
public override SqlValue Run(RuntimeContext runtime)
1515
{
16-
var v = this.source.Run(runtime);
16+
var v = MathScalars.CoerceImplicit(this.source.Run(runtime));
1717
var resultType = MathScalars.WidenForResult(v.Type);
1818
return v.IsNull ? SqlValue.Null(resultType) : resultType.Category switch
1919
{

SqlServerSimulator/Parser/Expressions/CharIndex.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,14 @@ public CharIndex(ParserContext context)
2828
public override SqlValue Run(RuntimeContext runtime)
2929
{
3030
var n = needle.Run(runtime);
31-
var h = haystack.Run(runtime);
31+
// CHARINDEX's haystack (arg 2) implicit-coerces to varchar per real
32+
// (probe-confirmed 2026-05-22: CHARINDEX('2', 12345) = 2). Needle
33+
// (arg 1) stays strict — real rejects non-string with Msg 8116.
34+
var h = StringScalars.CoerceToVarchar(haystack.Run(runtime), runtime.Batch, "charindex", argumentIndex: 2);
3235
if (n.IsNull || h.IsNull)
3336
return SqlValue.Null(SqlType.Int32);
3437
if (!SqlType.IsStringCategory(n.Type))
3538
throw SimulatedSqlException.InvalidArgumentDataType(n.Type.SqlServerName, argumentIndex: 1, "charindex");
36-
if (!SqlType.IsStringCategory(h.Type))
37-
throw SimulatedSqlException.InvalidArgumentDataType(h.Type.SqlServerName, argumentIndex: 2, "charindex");
3839

3940
var needleStr = n.AsString;
4041
var haystackStr = h.AsString;

SqlServerSimulator/Parser/Expressions/DateAdd.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public DateAdd(ParserContext context)
3232

3333
public override SqlValue Run(RuntimeContext runtime)
3434
{
35-
var value = source.Run(runtime);
35+
var value = DatePartKinds.CoerceDateArgumentImplicit(source.Run(runtime));
3636
var n = number.Run(runtime);
3737
if (value.IsNull || n.IsNull)
3838
return SqlValue.Null(value.Type);
@@ -42,7 +42,7 @@ public override SqlValue Run(RuntimeContext runtime)
4242
}
4343

4444
public override SqlType GetSqlType(BatchContext batch, Func<MultiPartName, SqlType> resolveColumnType) =>
45-
source.GetSqlType(batch, resolveColumnType);
45+
DatePartKinds.ResolveImplicitDateType(source.GetSqlType(batch, resolveColumnType));
4646

4747
internal override string DebugDisplay() => $"DATEADD({this.keywordText}, {number.DebugDisplay()}, {source.DebugDisplay()})";
4848
}

SqlServerSimulator/Parser/Expressions/DateDiff.cs

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ protected DateDiff(ParserContext context, string functionLowerName, SqlType resu
5050

5151
public override SqlValue Run(RuntimeContext runtime)
5252
{
53-
var startVal = CoerceStringToDateTime2(this.start.Run(runtime));
54-
var endVal = CoerceStringToDateTime2(this.end.Run(runtime));
53+
var startVal = DatePartKinds.CoerceDateArgumentImplicit(this.start.Run(runtime));
54+
var endVal = DatePartKinds.CoerceDateArgumentImplicit(this.end.Run(runtime));
5555
if (startVal.IsNull || endVal.IsNull)
5656
return SqlValue.Null(this.resultType);
5757
DatePartKinds.RequireCompatibleForDiff(this.kind, this.keywordText, this.functionLowerName);
@@ -70,14 +70,6 @@ public override SqlValue Run(RuntimeContext runtime)
7070
internal override string DebugDisplay() =>
7171
$"{this.functionLowerName.ToUpperInvariant()}({this.keywordText}, {this.start.DebugDisplay()}, {this.end.DebugDisplay()})";
7272

73-
/// <summary>
74-
/// Mirrors SQL Server's implicit-cast behavior: a bare string literal
75-
/// (or string-typed expression) passed as a DATEDIFF argument gets
76-
/// parsed as <c>datetime2(7)</c> before the boundary math runs.
77-
/// </summary>
78-
private static SqlValue CoerceStringToDateTime2(SqlValue v) =>
79-
SqlType.IsStringCategory(v.Type) ? v.CoerceTo(SqlType.GetDateTime2(7)) : v;
80-
8173
internal sealed class Standard(ParserContext context) : DateDiff(context, "datediff", SqlType.Int32)
8274
{
8375
protected override SqlValue WrapResult(long diff) =>

0 commit comments

Comments
 (0)