Skip to content

Commit 73f58c1

Browse files
committed
Cross-category integer ↔ string Promote: returns the integer's specific subtype (tinyint+'3' → tinyint, bigint+'3' → bigint), with the string parsing through the integer's CAST path; bit + string raises Msg 402 (+/-/%) or Msg 8117 (*/) per SQL Server 2025. Coalesce now caches its Promote result type, BuildSynthesizedSqlRow runs Run-then-GetSqlType so mixed-type CASE / Coalesce in FROM-less SELECTs land at the joint type, and FromSqlInterpolated covers the EF Core integration end-to-end.
1 parent 5bb56ca commit 73f58c1

7 files changed

Lines changed: 337 additions & 13 deletions

File tree

CLAUDE.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,15 @@ Top-level OFFSET/FETCH (post-set-op chain) attaches alongside the top-level ORDE
185185
### Aggregates
186186
`COUNT(*)` / `COUNT(expr)` / `COUNT(DISTINCT expr)` / `COUNT_BIG`, `SUM` / `AVG` (integer-truncating; `decimal(38, max(s, 6))` widening for AVG over decimals), `MAX` / `MIN`, statistical (`STDEV` / `STDEVP` / `VAR` / `VARP`), `STRING_AGG`, `CHECKSUM_AGG`, `APPROX_COUNT_DISTINCT`. Standalone and inside `GROUP BY` / `HAVING`.
187187

188+
### Integer ↔ string promotion
189+
Cross-category integer ↔ string promotion lands the integer's specific subtype (`tinyint + '3'` stays tinyint, `bigint + '3'` stays bigint — probe-confirmed against SQL Server 2025, 2026-05-08). The string side parses through the integer's existing CAST path at runtime — empty / whitespace-only → 0, `+`/`-` sign prefixes accepted, leading/trailing whitespace trimmed. **Decimal-shaped strings (`'5.5'`, `'5.0'`) raise Msg 245** rather than routing through decimal — SQL Server's int-target string parse rejects any non-integer literal. Hex notation (`'0x05'`) likewise rejected; the `0x` literal form is a different parse path.
190+
191+
`PromoteFromInteger`'s `String => a` arm and `PromoteFromString`'s `Integer => b` arm are the static rule; `IntegerArithmetic` in `TwoSidedExpression` normalizes the string operand by `CoerceTo(integerType)` before dispatching to `PureIntegerArithmetic`. The bit asymmetry: `bit ↔ string` **comparison** works through string→bit CAST (with `'true'` / `'false'` / empty all accepted), but `bit + str` (and other bit-arithmetic with strings) is rejected — `+` / `-` / `%` raise **Msg 402** (`"The data types bit and varchar are incompatible in the {add|subtract|modulo} operator."`); `*` / `/` raise **Msg 8117** with the LEFT operand's type only (`"Operand data type bit is invalid for {multiply|divide} operator."`). The asymmetry mirrors SQL Server's same bit-arithmetic restriction with another bit, which is also rejected.
192+
193+
WHERE on a varchar column compared against an int (or vice versa) halts the whole query on the first unparseable row — the failure isn't isolated as per-row UNKNOWN. SQL Server's lazy-IN quirk (where an unparseable value in an IN list is suppressed when any other value matches) isn't modeled — strict semantics: any conversion error in an IN-list propagates immediately.
194+
195+
`BuildSynthesizedSqlRow` (the FROM-less SELECT path) runs each expression first to surface runtime-only errors with their operator-name wording, then calls `GetSqlType` to populate the projection schema and bridges any runtime/schema type mismatch via `CoerceTo` — required for mixed-type CASE / Coalesce expressions (`case when 1=0 then 5 else '99' end` lands as int 99 even without a FROM clause).
196+
188197
### Decimal arithmetic precision / scale
189198
SQL Server has per-operator scale rules for decimal that differ from the joint-envelope rule used for comparison / COALESCE / set-op type unification (probe-confirmed against SQL Server 2025, 2026-05-08):
190199

@@ -300,7 +309,6 @@ Type-metadata accessors (`GetDataTypeName` / `GetFieldType`) read from `Simulate
300309
- `STRING_AGG`'s `WITHIN GROUP (ORDER BY ...)`.
301310
- `LIKE` with `COLLATE` override (default collation only — case-insensitive Latin1_General-shaped).
302311
- `CONVERT` / `TRY_CONVERT` style codes other than `0` / `120` / `121` for date-like → string. Other styles raise Msg 281; money / float / binary style codes and `CONVERT(date, str, 103)`-style date parsing not modeled.
303-
- Cross-category `Promote` for integer ↔ string. Only CAST works that pair.
304312
- `LEN(ntext)` raising Msg 8116 (function-level text/ntext/image restrictions); legacy `READTEXT` / `WRITETEXT` / `UPDATETEXT`.
305313
- `OUTPUT INTO @table_var`, `OUTPUT DELETED.*` / `INSERTED.*` star expansion. Per-column `OUTPUT INSERTED.<col>` / `OUTPUT DELETED.<col>` *is* supported (UPDATE / DELETE both); only the star-expansion form is missing. `OUTPUT INTO` (sending the projection to a table variable rather than the result set) isn't.
306314
- Joined-source UPDATE / DELETE FROM clauses (`UPDATE a SET ... FROM t AS a JOIN u AS b ON ...`). The single-source alias form (`UPDATE a SET ... FROM t AS a [WHERE ...]`, `DELETE FROM a FROM t AS a [WHERE ...]`) IS supported — that's what EF7+ `ExecuteUpdate` / `ExecuteDelete` emit, verified against real SQL Server 2025. Adding sources beyond the single aliased target raises `NotSupportedException` so the gap is visible.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using Microsoft.EntityFrameworkCore;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// EF Core's raw-SQL entry points (<c>FromSqlInterpolated</c> /
7+
/// <c>FromSqlRaw</c>) bypass the LINQ translator and pass user-written SQL
8+
/// straight through to the simulator's parser, while still binding C#
9+
/// interpolated values as typed parameters. This is the realistic path
10+
/// where mixed-type comparisons (string parameter vs int column) actually
11+
/// emerge in app code — direct LINQ over strongly-typed entities never
12+
/// produces that shape.
13+
/// </summary>
14+
[TestClass]
15+
public class EFCoreFromSql
16+
{
17+
public TestContext TestContext { get; set; } = null!;
18+
19+
private static TestDbContext SeededContext()
20+
{
21+
var context = new TestDbContext(TestDbContext.CreateCustomersSimulation());
22+
context.Customers.AddRange(
23+
new Customer { Name = "alpha" },
24+
new Customer { Name = "beta" },
25+
new Customer { Name = "gamma" });
26+
_ = context.SaveChanges();
27+
return context;
28+
}
29+
30+
[TestMethod]
31+
public void FromSqlInterpolated_StringParameterAgainstIntColumn_PromotesAndMatches()
32+
{
33+
// The C# string interpolation hands EF Core a string value, which
34+
// SqlClient binds as nvarchar — so the server-side comparison is
35+
// `int_column = nvarchar_param`. Cross-category int↔string Promote
36+
// closes the loop: the parameter's nvarchar parses through the
37+
// int column's CAST path, the row matches, and entity materialization
38+
// proceeds normally.
39+
using var context = SeededContext();
40+
var idAsString = "2";
41+
var customer = context.Customers
42+
.FromSqlInterpolated($"select Id, Name from Customers where Id = {idAsString}")
43+
.Single();
44+
Assert.AreEqual(2, customer.Id);
45+
Assert.AreEqual("beta", customer.Name);
46+
}
47+
}

SqlServerSimulator.Tests/TypePromotionTests.cs

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,4 +392,215 @@ public void Arithmetic_DateTimeMinusInt_OperatorNameIsSubtract()
392392
var ex = Throws<System.Data.Common.DbException>(() => ExecuteScalar("select cast('2024-01-15' as datetime) - cast('2024-01-15' as date)"));
393393
AreEqual("The data types datetime and date are incompatible in the subtract operator.", ex.Message);
394394
}
395+
396+
// ─── Integer ↔ string Promote (probe-confirmed against SQL Server 2025) ───
397+
398+
[TestMethod]
399+
[DataRow("int", "5", "'5'")]
400+
[DataRow("int", "5", "'+5'")] // sign prefix
401+
[DataRow("int", "-5", "'-5'")]
402+
[DataRow("int", "5", "' 5'")] // leading whitespace
403+
[DataRow("int", "5", "'5 '")] // trailing whitespace
404+
[DataRow("tinyint", "5", "'5'")]
405+
[DataRow("smallint", "5", "'5'")]
406+
[DataRow("bigint", "5", "'5'")]
407+
public void Comparison_IntegerEqualsString_ParsesAndMatches(string columnType, string seed, string literal)
408+
{
409+
// Per probe (SQL Server 2025): integer wins, string parses to the
410+
// integer's specific type. Whitespace trims; sign prefixes work.
411+
AreEqual(1, ExecuteScalar<int>($"select case when cast({seed} as {columnType}) = {literal} then 1 else 0 end"));
412+
}
413+
414+
[TestMethod]
415+
public void Comparison_OperandOrderIndependent()
416+
{
417+
// 'lhs op rhs' must produce the same result as 'rhs op lhs'.
418+
AreEqual(1, ExecuteScalar<int>("select case when '5' = 5 then 1 else 0 end"));
419+
AreEqual(1, ExecuteScalar<int>("select case when 5 = '5' then 1 else 0 end"));
420+
}
421+
422+
[TestMethod]
423+
[DataRow("int", "5", "''")] // empty string parses to 0
424+
[DataRow("int", "0", "''")] // 0 = '' is true (empty → 0)
425+
public void Comparison_EmptyString_ParsesToZero(string columnType, string seed, string literal)
426+
{
427+
// SQL Server's string→int CAST treats empty / whitespace-only as 0.
428+
var expectMatch = seed == "0";
429+
AreEqual(expectMatch ? 1 : 0, ExecuteScalar<int>($"select case when cast({seed} as {columnType}) = {literal} then 1 else 0 end"));
430+
}
431+
432+
[TestMethod]
433+
[DataRow("'abc'")]
434+
[DataRow("'5.5'")] // decimal-shaped: SQL Server does NOT route through decimal
435+
[DataRow("'5.0'")] // decimal-shaped with trailing zero — still rejected
436+
[DataRow("'0x05'")] // hex notation: rejected (only `0x` literal accepts hex)
437+
public void Comparison_UnparseableString_RaisesMsg245(string literal)
438+
{
439+
var ex = Throws<System.Data.Common.DbException>(() => ExecuteScalar($"select case when 5 = {literal} then 1 else 0 end"));
440+
StartsWith("Conversion failed when converting the varchar value", ex.Message);
441+
Contains("to data type int", ex.Message);
442+
}
443+
444+
[TestMethod]
445+
public void Comparison_NullIntegerVsString_IsUnknown()
446+
{
447+
// NULL on either side → UNKNOWN (the WHEN excludes; ELSE arm wins).
448+
AreEqual(-1, ExecuteScalar<int>("select case when cast(null as int) = '5' then 1 when not (cast(null as int) = '5') then 0 else -1 end"));
449+
AreEqual(-1, ExecuteScalar<int>("select case when 5 = cast(null as varchar(10)) then 1 when not (5 = cast(null as varchar(10))) then 0 else -1 end"));
450+
}
451+
452+
[TestMethod]
453+
public void Comparison_BitVsStringForms_AllWorkThroughCastPath()
454+
{
455+
// bit ↔ string COMPARISON works through string→bit CAST: '1', 'true'/'TRUE'
456+
// map to true; '0', 'false', and empty map to false.
457+
AreEqual(1, ExecuteScalar<int>("select case when cast(1 as bit) = '1' then 1 else 0 end"));
458+
AreEqual(1, ExecuteScalar<int>("select case when cast(1 as bit) = 'true' then 1 else 0 end"));
459+
AreEqual(1, ExecuteScalar<int>("select case when cast(1 as bit) = 'TRUE' then 1 else 0 end"));
460+
AreEqual(1, ExecuteScalar<int>("select case when cast(0 as bit) = 'false' then 1 else 0 end"));
461+
AreEqual(1, ExecuteScalar<int>("select case when cast(0 as bit) = '' then 1 else 0 end"));
462+
}
463+
464+
[TestMethod]
465+
[DataRow("+", 8)]
466+
[DataRow("-", 2)]
467+
[DataRow("*", 15)]
468+
[DataRow("/", 1)] // 5 / 3 = 1 (integer division)
469+
[DataRow("%", 2)] // 5 % 3 = 2
470+
public void Arithmetic_IntegerWithString_ProducesIntegerResult(string op, int expected)
471+
{
472+
AreEqual(expected, ExecuteScalar<int>($"select 5 {op} '3'"));
473+
AreEqual(expected, ExecuteScalar<int>($"select '5' {op} 3"));
474+
}
475+
476+
[TestMethod]
477+
public void Arithmetic_TinyintPlusString_StaysTinyint()
478+
{
479+
// The integer's specific type is preserved. Encoding the result as
480+
// a wider int would mismatch the column's tinyint schema and the
481+
// row encoder would reject the write.
482+
using var connection = new Simulation().CreateOpenConnection();
483+
_ = connection.CreateCommand("create table t ( v tinyint )").ExecuteNonQuery();
484+
_ = connection.CreateCommand("insert t values (cast(5 as tinyint) + '3')").ExecuteNonQuery();
485+
using var select = connection.CreateCommand();
486+
select.CommandText = "select v from t";
487+
AreEqual((byte)8, select.ExecuteScalar());
488+
}
489+
490+
[TestMethod]
491+
public void Arithmetic_BigintWithStringRhs_StaysBigint()
492+
{
493+
// Mirror of the tinyint case for the wider integer end: bigint + str
494+
// returns bigint (verified against SQL Server 2025), not widened
495+
// through int. Insert into a bigint column to confirm the runtime
496+
// value's type matches the declared schema.
497+
using var connection = new Simulation().CreateOpenConnection();
498+
_ = connection.CreateCommand("create table t ( v bigint )").ExecuteNonQuery();
499+
_ = connection.CreateCommand("insert t values (cast(5 as bigint) + '3')").ExecuteNonQuery();
500+
using var select = connection.CreateCommand();
501+
select.CommandText = "select v from t";
502+
AreEqual(8L, select.ExecuteScalar());
503+
}
504+
505+
[TestMethod]
506+
[DataRow("+", "add")]
507+
[DataRow("-", "subtract")]
508+
[DataRow("%", "modulo")]
509+
public void Arithmetic_BitWithString_AdditiveAndModulo_RaiseMsg402(string op, string operatorName)
510+
{
511+
// Bit comparison with string works (string→bit CAST), but bit
512+
// arithmetic with string is rejected: + / - / % use Msg 402 with
513+
// both type names and the operator word.
514+
var ex = Throws<System.Data.Common.DbException>(() => ExecuteScalar($"select cast(1 as bit) {op} '1'"));
515+
AreEqual($"The data types bit and varchar are incompatible in the {operatorName} operator.", ex.Message);
516+
}
517+
518+
[TestMethod]
519+
[DataRow("*", "multiply")]
520+
[DataRow("/", "divide")]
521+
public void Arithmetic_BitWithString_MultiplicativeAndDivisive_RaiseMsg8117(string op, string operatorName)
522+
{
523+
// Multiplicative ops use the date-style Msg 8117 instead of Msg 402,
524+
// naming only the LEFT operand's type.
525+
var leftEx = Throws<System.Data.Common.DbException>(() => ExecuteScalar($"select cast(1 as bit) {op} '1'"));
526+
AreEqual($"Operand data type bit is invalid for {operatorName} operator.", leftEx.Message);
527+
528+
// Operand-order matters for Msg 8117: the message names the LEFT side.
529+
var rightEx = Throws<System.Data.Common.DbException>(() => ExecuteScalar($"select '1' {op} cast(1 as bit)"));
530+
AreEqual($"Operand data type varchar is invalid for {operatorName} operator.", rightEx.Message);
531+
}
532+
533+
[TestMethod]
534+
public void Arithmetic_NullPropagation()
535+
{
536+
AreEqual(System.DBNull.Value, ExecuteScalar("select cast(null as int) + '3'"));
537+
AreEqual(System.DBNull.Value, ExecuteScalar("select 5 + cast(null as varchar(10))"));
538+
}
539+
540+
[TestMethod]
541+
public void WhereClause_ColumnEqualsStringParameter_ParsesAndMatches()
542+
{
543+
// The EF Core / SqlClient pattern: bind a string parameter against
544+
// an int column. With Promote landing int, the comparison runs
545+
// through string→int CAST per row.
546+
using var connection = new Simulation().CreateOpenConnection();
547+
_ = connection.CreateCommand("create table t ( id int )").ExecuteNonQuery();
548+
_ = connection.CreateCommand("insert t values (5), (10), (15)").ExecuteNonQuery();
549+
550+
using var select = connection.CreateCommand();
551+
select.CommandText = "select id from t where id = @p";
552+
var p = select.CreateParameter();
553+
p.ParameterName = "@p";
554+
p.DbType = System.Data.DbType.String;
555+
p.Value = "10";
556+
_ = select.Parameters.Add(p);
557+
558+
using var reader = select.ExecuteReader();
559+
IsTrue(reader.Read()); AreEqual(10, reader[0]);
560+
IsFalse(reader.Read());
561+
}
562+
563+
[TestMethod]
564+
public void WhereClause_VarcharColumnComparedToInt_RaisesPerRowOnUnparseable()
565+
{
566+
// Reverse direction: varchar column compared to int literal. Per
567+
// probe (SQL Server 2025): a single unparseable row halts the whole
568+
// query — the failure isn't isolated to one UNKNOWN row.
569+
using var connection = new Simulation().CreateOpenConnection();
570+
_ = connection.CreateCommand("create table t ( s varchar(10) )").ExecuteNonQuery();
571+
_ = connection.CreateCommand("insert t values ('5'), ('abc'), ('15')").ExecuteNonQuery();
572+
573+
using var select = connection.CreateCommand();
574+
select.CommandText = "select s from t where s = 5";
575+
var ex = Throws<System.Data.Common.DbException>(() =>
576+
{
577+
using var reader = select.ExecuteReader();
578+
while (reader.Read()) { }
579+
});
580+
StartsWith("Conversion failed when converting the varchar value 'abc'", ex.Message);
581+
}
582+
583+
[TestMethod]
584+
public void InList_IntegerLhsWithStringValues_Works()
585+
{
586+
AreEqual(1, ExecuteScalar<int>("select case when 5 in ('1','5','9') then 1 else 0 end"));
587+
AreEqual(0, ExecuteScalar<int>("select case when 5 in ('1','9') then 1 else 0 end"));
588+
}
589+
590+
[TestMethod]
591+
public void Coalesce_IntegerAndString_ResultIsInteger()
592+
{
593+
// coalesce(int, varchar) returns int — the first arg's type drives
594+
// when it's non-null; the second is parsed to int if needed.
595+
AreEqual(5, ExecuteScalar<int>("select coalesce(5, '99')"));
596+
AreEqual(99, ExecuteScalar<int>("select coalesce(cast(null as int), '99')"));
597+
}
598+
599+
[TestMethod]
600+
public void Case_ThenIntegerElseString_ResultIsInteger()
601+
{
602+
AreEqual(5, ExecuteScalar<int>("select case when 1=1 then 5 else '99' end"));
603+
AreEqual(99, ExecuteScalar<int>("select case when 1=0 then 5 else '99' end"));
604+
}
605+
395606
}

SqlServerSimulator/Parser/Expressions/Coalesce.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ namespace SqlServerSimulator.Parser.Expressions;
1313
internal sealed class Coalesce : Expression
1414
{
1515
private readonly Expression[] arguments;
16+
private SqlType? cachedResultType;
1617

1718
public Coalesce(ParserContext context)
1819
{
@@ -34,7 +35,7 @@ public override SqlValue Run(Func<MultiPartName, SqlValue> getColumnValue)
3435
{
3536
value = this.arguments[i].Run(getColumnValue);
3637
if (!value.IsNull)
37-
return value;
38+
return this.cachedResultType is { } target && value.Type != target ? value.CoerceTo(target) : value;
3839
}
3940
return value; // all NULL — return the last (typed-NULL) result
4041
}
@@ -44,6 +45,7 @@ public override SqlType GetSqlType(Func<MultiPartName, SqlType> resolveColumnTyp
4445
var t = this.arguments[0].GetSqlType(resolveColumnType);
4546
for (var i = 1; i < this.arguments.Length; i++)
4647
t = SqlType.Promote(t, this.arguments[i].GetSqlType(resolveColumnType));
48+
this.cachedResultType = t;
4749
return t;
4850
}
4951

0 commit comments

Comments
 (0)