Skip to content

Commit ac1183c

Browse files
committed
String concatenation: CONCAT, CONCAT_WS, and string + operator. Variadic NULL-skipping CONCAT/CONCAT_WS via Msg 189; string + via NULL-propagating Add.Run branch with text/ntext rejected as Msg 402; PromoteForArithmetic preserves char(N)+char(M)→char(N+M) and nchar/char-mix→nchar fidelity, drops to length-less varchar/nvarchar for variable-pair where the simulator doesn't track length on the SqlType.
1 parent 8590f59 commit ac1183c

8 files changed

Lines changed: 536 additions & 1 deletion

File tree

CLAUDE.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,33 @@ Result types (probe-confirmed 2026-05-09 via `SELECT INTO` + `tempdb.information
231231

232232
`CurrentTimeFunction` (`Parser/Expressions/`) is a single class with a `CurrentTimeKind` discriminator; result-type rules and the SqlValue construction live in one place. The class holds a `Simulation` reference like `LastIdentityExpression` does, and reads `CurrentStatementUtcNow` per `Run` call.
233233

234+
### Variadic string concatenation: `CONCAT` / `CONCAT_WS`
235+
Both functions stringify each argument via the standard CAST-to-varchar/nvarchar path, **skip NULL arguments** (rather than propagating), and **never return NULL** — an all-NULL input returns `''`, NOT NULL. Result type is `nvarchar` if any argument has a national-string type (`nvarchar` / `nchar` / `ntext`); otherwise `varchar`. Probe-confirmed 2026-05-09.
236+
237+
Argument-count rules raise **Msg 189** with lowercase function name and per-function minimum: `CONCAT` requires 2-254 args (`"The concat function requires 2 to 254 arguments."`); `CONCAT_WS` requires 3-254 args — separator + at least two values (`"The concat_ws function requires 3 to 254 arguments."`).
238+
239+
`CONCAT_WS` quirks worth knowing:
240+
- **NULL separator silently degrades to empty string**`concat_ws(NULL, 'a', 'b')` returns `'ab'`, not NULL. Probe-confirmed; doesn't match common documentation that asserts NULL propagation.
241+
- **NULL values are skipped entirely** — no double separator next to a missing value: `concat_ws(',', 'a', NULL, 'b')``'a,b'`, not `'a,,b'`.
242+
- **Single-value form errors**`concat_ws(',', 'a')` raises Msg 189; the function refuses to act as a no-op stringifier.
243+
244+
The result type is computed from runtime argument types in `Run` (not just from `GetSqlType`'s static cache) because the function is reachable when its outer wrapper's `GetSqlType` doesn't cascade — e.g. `select datalength(concat(N'a', N'b'))` runs `Concat.Run` without `Concat.GetSqlType` being called, and the inner function still needs to settle on nvarchar based on the actual operand types.
245+
246+
**EF Core 10 doesn't emit `CONCAT` from `string.Concat`**`string.Concat(a, b, c)` translates to `[a] + N'-' + [b] + N'-' + [c]` (the `+` operator), which is NULL-propagating, distinct from CONCAT's NULL-skipping semantics. The simulator's CONCAT/CONCAT_WS support is reachable from raw SQL (`FromSqlInterpolated` / direct command text); the LINQ-side `string.Concat` path goes through string `+` (covered immediately below).
247+
248+
`StringConcat` (`Parser/Expressions/`) is a single class with a `StringConcatKind` discriminator (`Concat` / `ConcatWs`); both share the same per-arg stringify + NULL-skip + nvarchar-promotion path, with the only branch in `Run` being whether the first argument is consumed as a separator.
249+
250+
### String `+` operator (concatenation)
251+
String concatenation via `+` is **NULL-propagating** (matches default `CONCAT_NULL_YIELDS_NULL ON`; the simulator doesn't model the OFF setting). Result type is `nvarchar` when either operand is a national-string type (`nvarchar` / `nchar` / `ntext`), otherwise `varchar`. EF Core 10 emits this from `string.Concat(a, b, c)` server-side, from `+`-chains in LINQ, and as the dominant string-concat path for application code.
252+
253+
`text` / `ntext` / `image` / `varbinary` operands raise **Msg 402** (`"The data types {a} and {b} are incompatible in the add operator."`) matching SQL Server's restriction on LOB-string and binary types in arithmetic operators. Trailing-space preservation falls out for free: fixed-length `char(N)` storage carries its padding, so `cast('a' as char(5)) + cast('b' as char(5))``'a b '`.
254+
255+
**Bare-NULL handling has a minor divergence**: simulator's untyped `NULL` literal carries `SqlType.Int32` (the simulator has no truly untyped NULL sentinel), so `'a' + NULL` and `'a' + cast(NULL as int)` are indistinguishable at runtime. The simulator treats both as string concatenation (returning NULL of the result string type), which matches real SQL Server's behavior on bare NULL but diverges from `cast(NULL as int) + 'a'` (real raises Msg 245 from a string-to-int parse). The bare-NULL case dominates in practice; the typed-null-int edge case is a rare hand-written-SQL shape EF Core never emits.
256+
257+
**Result-type fidelity**: pure char/nchar pairs preserve fixed-length-ness with combined lengths capped at the type's max — `char(5) + char(5)``char(10)`, `nchar(5) + nchar(5)``nchar(10)`, `char(5) + nchar(5)``nchar(10)`, `char(8000) + char(100)``char(8000)`. Mixed fixed/variable string pairs (`char + varchar`, `nchar + nvarchar`, etc.) drop to length-less `varchar` / `nvarchar` because the simulator stores variable-length string length at the column level rather than on the SqlType — so length metadata for `varchar(10) + varchar(20)` (real: `varchar(30)`) isn't tracked. `PromoteForArithmetic` short-circuits the joint-envelope `Promote` rule for string-`+` so the static schema and the runtime value type agree.
258+
259+
`Subtract` / `Multiply` / etc. on string operands still raise `NotSupportedException` (real SQL Server: Msg 402 for `-`, Msg 8117 for `*` / `/` / `%`). Not a fidelity priority — apps don't use those shapes intentionally.
260+
234261
### Constraints
235262
- `CHECK`: inline single-column and table-level forms; Msg 547 per row on definitely-false predicate.
236263
- `PRIMARY KEY` / `UNIQUE`: linear scan (O(N) per insert); no B-tree.

SqlServerSimulator.Tests.EFCore/EFCoreStrings.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,4 +240,16 @@ public void StringFunction_IndexOf()
240240
using var fresh = new TestDbContext(context.Simulation);
241241
Assert.AreEqual(6, fresh.People.Select(p => p.Name.IndexOf("world", StringComparison.Ordinal)).Single());
242242
}
243+
244+
[TestMethod]
245+
public void StringFunction_ConcatViaPlus()
246+
{
247+
// EF Core 10 emits `[Name] + N'-' + [Code]` for `string.Concat(p.Name, "-", p.Code)`
248+
// — the `+` operator, not the CONCAT function. Both sides non-null.
249+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation()).WithSaved(new Person { Id = 1, Name = "Alice", Code = "A1" });
250+
251+
using var fresh = new TestDbContext(context.Simulation);
252+
Assert.AreEqual("Alice-A1", fresh.People.Select(p => string.Concat(p.Name, "-", p.Code)).Single());
253+
}
254+
243255
}
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
using static SqlServerSimulator.TestHelpers;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// <c>CONCAT</c> and <c>CONCAT_WS</c>: variadic string-concatenation scalars
8+
/// that skip NULL inputs (rather than propagating). Probe-anchored against
9+
/// SQL Server 2025 (2026-05-09).
10+
/// </summary>
11+
[TestClass]
12+
public sealed class ConcatTests
13+
{
14+
[TestMethod]
15+
[DataRow("concat('a', 'b')", "ab")]
16+
[DataRow("concat('a', 'b', 'c')", "abc")]
17+
[DataRow("concat('a', '', 'c')", "ac")]
18+
[DataRow("concat(1, 2)", "12")]
19+
[DataRow("concat('a', 1)", "a1")]
20+
[DataRow("concat(1, 2, 3, 4, 5)", "12345")]
21+
[DataRow("concat('x=', cast(3.14 as decimal(10,2)))", "x=3.14")]
22+
[DataRow("concat('today: ', cast('2026-05-09' as date))", "today: 2026-05-09")]
23+
[DataRow("concat('t=', cast('2026-05-09T12:34:56' as datetime2(7)))", "t=2026-05-09 12:34:56.0000000")]
24+
[DataRow("concat('z=', cast('2026-05-09T12:34:56+05:00' as datetimeoffset(7)))", "z=2026-05-09 12:34:56.0000000 +05:00")]
25+
[DataRow("concat('b=', cast(1 as bit))", "b=1")]
26+
[DataRow("concat('m=', cast(123.45 as money))", "m=123.45")]
27+
public void Concat_Basic(string expression, string expected) =>
28+
AreEqual(expected, ExecuteScalar($"select {expression}"));
29+
30+
[TestMethod]
31+
[DataRow("concat('a', null, 'b')", "ab")]
32+
[DataRow("concat(null, 'a', null, 'b', null)", "ab")]
33+
[DataRow("concat(null, null)", "")]
34+
[DataRow("concat(null, null, null)", "")]
35+
[DataRow("concat('a', null)", "a")]
36+
[DataRow("concat(null, 'a')", "a")]
37+
public void Concat_SkipsNulls(string expression, string expected) =>
38+
AreEqual(expected, ExecuteScalar($"select {expression}"));
39+
40+
[TestMethod]
41+
[DataRow("concat()")]
42+
[DataRow("concat('a')")]
43+
public void Concat_TooFewArguments_RaisesMsg189(string expression) =>
44+
AssertSqlError($"select {expression}", 189, "The concat function requires 2 to 254 arguments.");
45+
46+
[TestMethod]
47+
[DataRow("concat_ws(',', 'a', 'b', 'c')", "a,b,c")]
48+
[DataRow("concat_ws('-', 'a', 'b')", "a-b")]
49+
[DataRow("concat_ws('', 'a', 'b')", "ab")]
50+
[DataRow("concat_ws('-', 1, 2, 3)", "1-2-3")]
51+
[DataRow("concat_ws('-', 'a', 1, cast('2026-05-09' as date))", "a-1-2026-05-09")]
52+
public void ConcatWs_Basic(string expression, string expected) =>
53+
AreEqual(expected, ExecuteScalar($"select {expression}"));
54+
55+
[TestMethod]
56+
[DataRow("concat_ws(',', 'a', null, 'b')", "a,b")]
57+
[DataRow("concat_ws(',', null, 'a', 'b')", "a,b")]
58+
[DataRow("concat_ws(',', 'a', 'b', null)", "a,b")]
59+
[DataRow("concat_ws(',', null, null, null)", "")]
60+
[DataRow("concat_ws(',', 'a', null, null, 'b')", "a,b")]
61+
public void ConcatWs_SkipsNullValues(string expression, string expected) =>
62+
AreEqual(expected, ExecuteScalar($"select {expression}"));
63+
64+
[TestMethod]
65+
[DataRow("concat_ws(null, 'a', 'b')", "ab")]
66+
[DataRow("concat_ws(null, 'a', 'b', 'c')", "abc")]
67+
[DataRow("concat_ws(null, null, null)", "")]
68+
public void ConcatWs_NullSeparator_DegradesToEmpty(string expression, string expected) =>
69+
AreEqual(expected, ExecuteScalar($"select {expression}"));
70+
71+
[TestMethod]
72+
[DataRow("concat_ws()")]
73+
[DataRow("concat_ws(',')")]
74+
[DataRow("concat_ws(',', 'a')")]
75+
[DataRow("concat_ws(',', null)")]
76+
public void ConcatWs_TooFewArguments_RaisesMsg189(string expression) =>
77+
AssertSqlError($"select {expression}", 189, "The concat_ws function requires 3 to 254 arguments.");
78+
79+
[TestMethod]
80+
public void Concat_ResultIsVarcharByDefault()
81+
{
82+
// All-ASCII string args → varchar; round-trip through DataLength
83+
// (varchar = 1 byte/char) confirms no nvarchar promotion.
84+
AreEqual(3, ExecuteScalar("select datalength(concat('a', 'b', 'c'))"));
85+
}
86+
87+
[TestMethod]
88+
public void Concat_AnyNVarcharArg_PromotesToNVarchar()
89+
{
90+
// nvarchar = 2 bytes/char in CP1252 / UTF-16; presence of N'...'
91+
// on any arg promotes the result.
92+
AreEqual(6, ExecuteScalar("select datalength(concat('a', 'b', N'c'))"));
93+
}
94+
95+
[TestMethod]
96+
public void Concat_NullsOnly_ReturnsEmptyString_NotNull()
97+
{
98+
// Probe-confirmed SQL Server quirk: typed metadata says NOT NULL
99+
// even when every input is NULL; runtime returns ''.
100+
AreEqual("", ExecuteScalar("select concat(null, null)"));
101+
}
102+
103+
[TestMethod]
104+
public void ConcatWs_NullsOnly_ReturnsEmptyString_NotNull() =>
105+
AreEqual("", ExecuteScalar("select concat_ws(',', null, null, null)"));
106+
107+
[TestMethod]
108+
public void Concat_OfColumns_FromTable() =>
109+
AreEqual("foobar", new Simulation().ExecuteScalar("""
110+
create table t (a varchar(10), b varchar(10), c varchar(10));
111+
insert t values ('foo', null, 'bar');
112+
select concat(a, b, c) from t
113+
"""));
114+
115+
[TestMethod]
116+
public void ConcatWs_OfColumns_FromTable() =>
117+
AreEqual("foo|bar", new Simulation().ExecuteScalar("""
118+
create table t (a varchar(10), b varchar(10), c varchar(10));
119+
insert t values ('foo', null, 'bar');
120+
select concat_ws('|', a, b, c) from t
121+
"""));
122+
}
123+
124+
/// <summary>
125+
/// String <c>+</c> operator: NULL-propagating concatenation distinct from
126+
/// CONCAT's NULL-skipping. EF Core 10 emits this for <c>string.Concat</c> and
127+
/// <c>+</c>-chains over server-evaluated string operands. Probe-anchored
128+
/// against SQL Server 2025 (2026-05-09).
129+
/// </summary>
130+
[TestClass]
131+
public sealed class StringPlusOperatorTests
132+
{
133+
[TestMethod]
134+
[DataRow("'a' + 'b'", "ab")]
135+
[DataRow("'' + 'a'", "a")]
136+
[DataRow("'a' + ''", "a")]
137+
[DataRow("'a' + 'b' + 'c'", "abc")]
138+
[DataRow("'a' + 'b' + 'c' + 'd'", "abcd")]
139+
[DataRow("N'a' + N'b'", "ab")]
140+
[DataRow("'a' + N'b'", "ab")]
141+
[DataRow("N'a' + 'b'", "ab")]
142+
public void Plus_StringConcat(string expression, string expected) =>
143+
AreEqual(expected, ExecuteScalar($"select {expression}"));
144+
145+
[TestMethod]
146+
[DataRow("'a' + null")]
147+
[DataRow("null + 'a'")]
148+
[DataRow("'a' + null + 'b'")]
149+
[DataRow("cast(null as varchar(10)) + 'a'")]
150+
[DataRow("'a' + cast(null as nvarchar(10))")]
151+
public void Plus_PropagatesNull(string expression) =>
152+
IsInstanceOfType<DBNull>(ExecuteScalar($"select {expression}"));
153+
154+
[TestMethod]
155+
public void Plus_VarcharVarchar_StaysVarchar() =>
156+
AreEqual(2, ExecuteScalar("select datalength('a' + 'b')"));
157+
158+
[TestMethod]
159+
[DataRow("'a' + N'b'")]
160+
[DataRow("N'a' + 'b'")]
161+
public void Plus_NVarcharOnEitherSide_PromotesToNVarchar(string expression) =>
162+
AreEqual(4, ExecuteScalar($"select datalength({expression})"));
163+
164+
/// <summary>
165+
/// <c>char(5) + char(5)</c> → <c>char(10)</c> (probe-confirmed). Trailing-space
166+
/// padding on each operand survives the concat as a side-effect of the storage rep.
167+
/// </summary>
168+
[TestMethod]
169+
public void Plus_CharPair_PreservesFixedLengthAndCombinesLengths() =>
170+
AreEqual("a b ", new Simulation().ExecuteScalar("""
171+
create table t (a char(5), b char(5));
172+
insert t values ('a', 'b');
173+
select a + b from t
174+
"""));
175+
176+
/// <summary><c>char(5)+char(3)</c> → <c>char(8)</c>, one byte per char.</summary>
177+
[TestMethod]
178+
public void Plus_CharPair_DataLength_MatchesCombinedLength() =>
179+
AreEqual(8, new Simulation().ExecuteScalar("""
180+
create table t (a char(5), b char(3));
181+
insert t values ('x', 'y');
182+
select datalength(a + b) from t
183+
"""));
184+
185+
/// <summary><c>nchar(5)+nchar(5)</c> → <c>nchar(10)</c>, two bytes per char → datalength = 20.</summary>
186+
[TestMethod]
187+
public void Plus_NCharPair_PromotesToNCharWithCombinedLength() =>
188+
AreEqual(20, new Simulation().ExecuteScalar("""
189+
create table t (a nchar(5), b nchar(5));
190+
insert t values (N'x', N'y');
191+
select datalength(a + b) from t
192+
"""));
193+
194+
/// <summary><c>char(5) + nchar(5)</c> → <c>nchar(10)</c>, datalength 20.</summary>
195+
[TestMethod]
196+
public void Plus_CharNCharMix_PromotesToNCharWithCombinedLength() =>
197+
AreEqual(20, new Simulation().ExecuteScalar("""
198+
create table t (a char(5), b nchar(5));
199+
insert t values ('x', N'y');
200+
select datalength(a + b) from t
201+
"""));
202+
203+
[TestMethod]
204+
public void Plus_TextOperand_RaisesMsg402() =>
205+
AssertSqlError("select cast('a' as text) + 'b'", 402,
206+
"The data types text and varchar are incompatible in the add operator.");
207+
208+
[TestMethod]
209+
public void Plus_NTextOperand_RaisesMsg402() =>
210+
AssertSqlError("select 'a' + cast(N'b' as ntext)", 402,
211+
"The data types varchar and ntext are incompatible in the add operator.");
212+
213+
/// <summary>
214+
/// <c>'5'</c> parses to int via existing integer↔string promotion; result is int.
215+
/// </summary>
216+
[TestMethod]
217+
[DataRow("'5' + 1", 6)]
218+
[DataRow("1 + '5'", 6)]
219+
public void Plus_StringPlusInteger_RoutesThroughIntegerArithmetic(string expression, int expected) =>
220+
AreEqual(expected, ExecuteScalar($"select {expression}"));
221+
222+
[TestMethod]
223+
public void Plus_OfColumns_FromTable() =>
224+
AreEqual("foobar", new Simulation().ExecuteScalar("""
225+
create table t (a varchar(10), b varchar(10));
226+
insert t values ('foo', 'bar');
227+
select a + b from t
228+
"""));
229+
230+
[TestMethod]
231+
public void Plus_ColumnAndNull_PropagatesNull() =>
232+
IsInstanceOfType<DBNull>(new Simulation().ExecuteScalar("""
233+
create table t (a varchar(10), b varchar(10));
234+
insert t values ('foo', null);
235+
select a + b from t
236+
"""));
237+
}

SqlServerSimulator/Errors/SimulatedSqlException.SyntaxErrors.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ internal static SimulatedSqlException UnclosedStringLiteral() =>
2222

2323
internal static SimulatedSqlException SyntaxErrorNear(char c) => new($"Incorrect syntax near '{c}'.", 102, 15, 1);
2424

25+
/// <summary>
26+
/// Mimics SQL Server error 189: a built-in function received the wrong
27+
/// number of arguments. Wording uses the lowercase function name and the
28+
/// per-function minimum (e.g. <c>"The concat function requires 2 to 254
29+
/// arguments."</c>). Probe-confirmed against SQL Server 2025 (2026-05-09)
30+
/// for <c>CONCAT</c> (min 2) and <c>CONCAT_WS</c> (min 3).
31+
/// </summary>
32+
internal static SimulatedSqlException FunctionArgumentCount(string lowercaseFunctionName, int min) =>
33+
new($"The {lowercaseFunctionName} function requires {min} to 254 arguments.", 189, 15, 1);
34+
2535
/// <summary>
2636
/// Mimics SQL Server error 3902: a <c>COMMIT</c> was issued with no
2737
/// active transaction. Probe-confirmed against SQL Server 2025

SqlServerSimulator/Parser/Expression.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
359359
},
360360
6 => uppercaseName switch
361361
{
362+
"CONCAT" => new StringConcat(context, StringConcatKind.Concat),
362363
"ISNULL" => new IsNullExpression(context),
363364
"NULLIF" => new NullIf(context),
364365
"STDEVP" => AggregateExpression.Parse(context, AggregateKind.StdevP),
@@ -384,6 +385,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
384385
9 => uppercaseName switch
385386
{
386387
"CHARINDEX" => new CharIndex(context),
388+
"CONCAT_WS" => new StringConcat(context, StringConcatKind.ConcatWs),
387389
"COUNT_BIG" => AggregateExpression.Parse(context, AggregateKind.CountBig),
388390
"SUBSTRING" => new Substring(context),
389391
_ => null

0 commit comments

Comments
 (0)