Skip to content

Commit 3e040fa

Browse files
committed
String and binary literals type at exact value width: the tokenizer emits varchar-style literal types, Promote unifies string pairs by max-of-widths instead of picking one operand whole, concat sums cap at 8000/4000 without jumping to MAX, ISNULL keeps the first argument's width, and LEFT/RIGHT/SUBSTRING/REPLICATE/STUFF/SPACE const-fold per-function derivations while REPLACE/TRANSLATE stay container.
1 parent dc659a6 commit 3e040fa

19 files changed

Lines changed: 566 additions & 52 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ Comparison (Msg 402), ORDER BY/DISTINCT (Msg 306), and aggregates (Msg 8117 from
121121
Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger: read the linked file on demand when working in the matching area.
122122

123123
- **Built-in scalars** — math; date (DATETRUNC / DATE_BUCKET / SWITCHOFFSET / TODATETIMEOFFSET / `*FROMPARTS` / AT TIME ZONE / current-time); string (CONCAT / SOUNDEX / TRANSLATE / STRING_ESCAPE / DIFFERENCE); CHOOSE / IIF; bit (BIT_COUNT / GET_BIT / SET_BIT / shifts); CHECKSUM / BINARY_CHECKSUM; FORMAT / FORMATMESSAGE; RAND; STRING_SPLIT / GENERATE_SERIES; COMPRESS / DECOMPRESS; PWDENCRYPT / PWDCOMPARE / LOGINPROPERTY; sys.fn_varbintohexsubstring / sys.fn_varbintohexstr (varbinary→hex, sys-qualified system functions); `@@`-constants + HOST_NAME / APP_NAME / GETANSINULL / ORIGINAL_DB_NAME; session-state (SESSION_CONTEXT / sp_set_session_context / CONTEXT_INFO / CONNECTIONPROPERTY / SESSIONPROPERTY); SQL_VARIANT_PROPERTY → [`scalars.md`](docs/claude/scalars.md).
124-
- **`SqlType.Promote` / `PromoteForArithmetic` / decimal precision-scale / int↔string promotion**[`arithmetic.md`](docs/claude/arithmetic.md).
124+
- **`SqlType.Promote` / `PromoteForArithmetic` / decimal precision-scale / int↔string promotion / string+binary literal value-width typing + width algebra (concat sum-cap, CASE/COALESCE/set-op max, per-function widths)**[`arithmetic.md`](docs/claude/arithmetic.md).
125125
- **`Cast` / coercion error paths** (CAST/CONVERT narrow targets, TRY_CAST/TRY_CONVERT swallow set, PARSE/TRY_PARSE culture-aware parsing) → [`casting.md`](docs/claude/casting.md).
126126
- **`SimulatedDbDataReader` client surface** (typed accessors, `datetime` client-millisecond rounding, `GetOrdinal` precedence, GetBytes/GetChars materialization divergence) → [`data-reader.md`](docs/claude/data-reader.md).
127127
- **`Selection`, aggregates, window functions, set ops, CASE, OFFSET/FETCH**[`query.md`](docs/claude/query.md).
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
using Microsoft.Data.SqlClient;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Result-set width metadata (COLMETADATA) as a real SqlClient reader observes
8+
/// it via <c>GetColumnSchema().ColumnSize</c>. String / binary literals type at
9+
/// their exact value width (probe-confirmed against SQL Server 2025 — a bare
10+
/// <c>'abc'</c> advertises <c>varchar(3)</c>, not the <c>varchar(8000)</c>
11+
/// container it once did), and the width algebra that combines them (concat
12+
/// sum-capped, CASE / COALESCE / set-op max, ISNULL first-arg, per-function
13+
/// derivations) flows through to the wire. SqlClient reports <c>ColumnSize</c>
14+
/// in characters for the string families and <c>int.MaxValue</c> for a MAX
15+
/// column.
16+
/// </summary>
17+
[TestClass]
18+
public sealed class StringLiteralWidthWireTests
19+
{
20+
public TestContext TestContext { get; set; } = null!;
21+
22+
private async Task<int> ColumnSizeAsync(string sql)
23+
{
24+
var simulation = new Simulation();
25+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
26+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
27+
await using var command = new SqlCommand(sql, connection);
28+
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
29+
return reader.GetColumnSchema()[0].ColumnSize ?? -1;
30+
}
31+
32+
// Bare literals: exact value width, empty floors to 1, over-cap widens to MAX.
33+
[TestMethod]
34+
[DataRow("select 'abc' as x", 3)]
35+
[DataRow("select '' as x", 1)]
36+
[DataRow("select 'ab ' as x", 4)] // trailing spaces counted
37+
[DataRow("select N'abc' as x", 3)] // chars, not bytes
38+
[DataRow("select N'' as x", 1)]
39+
[DataRow("select 0xAABB as x", 2)]
40+
[DataRow("select 0x as x", 1)] // empty binary floors to 1
41+
public async Task BareLiterals_TypeAtExactWidth(string sql, int expected)
42+
=> AreEqual(expected, await ColumnSizeAsync(sql));
43+
44+
[TestMethod]
45+
[DataRow("select cast('x' as varchar(max)) + 'abc' as x")] // MAX operand propagates
46+
public async Task MaxOperand_WidensToMax(string sql)
47+
=> AreEqual(int.MaxValue, await ColumnSizeAsync(sql));
48+
49+
// A string literal longer than the family bound widens to the MAX form.
50+
[TestMethod]
51+
public async Task OverBoundLiteral_WidensToMax()
52+
{
53+
AreEqual(int.MaxValue, await ColumnSizeAsync($"select '{new string('a', 8001)}' as x"));
54+
AreEqual(int.MaxValue, await ColumnSizeAsync($"select N'{new string('a', 4001)}' as x"));
55+
}
56+
57+
// Concatenation: sum of widths, capped at the family maximum (not MAX).
58+
[TestMethod]
59+
[DataRow("select 'ab' + 'cde' as x", 5)]
60+
[DataRow("select N'ab' + N'cde' as x", 5)]
61+
[DataRow("select 'ab' + N'cde' as x", 5)] // mixed family → nvarchar, char-count sum
62+
[DataRow("select cast('x' as varchar(10)) + 'abc' as x", 13)]
63+
[DataRow("select replicate(cast('a' as varchar(5000)), 1) + replicate(cast('b' as varchar(5000)), 1) as x", 8000)]
64+
public async Task Concatenation_SumsCappedAtFamilyMax(string sql, int expected)
65+
=> AreEqual(expected, await ColumnSizeAsync(sql));
66+
67+
// CASE / COALESCE / IIF / NULLIF / set ops: maximum of arm widths.
68+
[TestMethod]
69+
[DataRow("select case when 1=1 then 'ab' else 'wxyz' end as x", 4)]
70+
[DataRow("select case when 1=1 then 'ab' else null end as x", 2)]
71+
[DataRow("select case when 1=1 then 'ab' else N'wxyz' end as x", 4)] // national family, char-count max
72+
[DataRow("select coalesce('ab', 'wxyz') as x", 4)]
73+
[DataRow("select iif(1=1, 'ab', 'wxyz') as x", 4)]
74+
[DataRow("select nullif('abcd', 'x') as x", 4)]
75+
[DataRow("select 'ab' as x union all select 'wxyz'", 4)]
76+
[DataRow("select 'ab' as x union select 'wxyz'", 4)]
77+
public async Task Unification_TakesMaxWidth(string sql, int expected)
78+
=> AreEqual(expected, await ColumnSizeAsync(sql));
79+
80+
// ISNULL fixes the result to the FIRST argument's width (unlike COALESCE).
81+
[TestMethod]
82+
[DataRow("select isnull('ab', 'wxyz') as x", 2)]
83+
[DataRow("select isnull('wxyz', 'ab') as x", 4)]
84+
public async Task IsNull_TakesFirstArgumentWidth(string sql, int expected)
85+
=> AreEqual(expected, await ColumnSizeAsync(sql));
86+
87+
// Width-deriving / preserving functions consume the literal width.
88+
[TestMethod]
89+
[DataRow("select upper('abc') as x", 3)] // preserve input width
90+
[DataRow("select ltrim(' abc') as x", 5)]
91+
[DataRow("select left('abcdef', 2) as x", 2)] // min(inputWidth, n)
92+
[DataRow("select left('abcdef', 20) as x", 6)]
93+
[DataRow("select right('abcdef', 2) as x", 2)]
94+
[DataRow("select substring('abcdef', 2, 3) as x", 3)]
95+
[DataRow("select substring('abcdef', 5, 20) as x", 6)]
96+
[DataRow("select replicate('ab', 3) as x", 6)] // inputWidth × count
97+
[DataRow("select space(5) as x", 5)]
98+
[DataRow("select space(0) as x", 1)] // floors to 1
99+
[DataRow("select stuff('abcdef', 2, 1, 'XY') as x", 7)] // inputWidth - delete + replacement
100+
public async Task LengthDerivingFunctions_ComputeWidth(string sql, int expected)
101+
=> AreEqual(expected, await ColumnSizeAsync(sql));
102+
103+
// REPLACE can grow the input, so it stays the family container (8000).
104+
[TestMethod]
105+
public async Task Replace_StaysContainerWidth()
106+
=> AreEqual(8000, await ColumnSizeAsync("select replace('aaa', 'a', 'XY') as x"));
107+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Runtime-side coverage for the value-width literal typing: the projected
7+
/// column widths (asserted over the wire in <c>StringLiteralWidthWireTests</c>)
8+
/// must never fall below the value a growable string scalar actually produces —
9+
/// static / runtime parity. Also covers the binary length-variance comparison
10+
/// that two exact-width binary literals now exercise (<c>0x01</c> is
11+
/// <c>varbinary(1)</c>, <c>0x0100</c> is <c>varbinary(2)</c>).
12+
/// </summary>
13+
[TestClass]
14+
public sealed class StringWidthAlgebraTests
15+
{
16+
// REPLACE / STUFF / REPLICATE / SPACE grow past the operand width; the
17+
// runtime value must materialize in full despite the tighter projected type.
18+
[TestMethod]
19+
[DataRow("select len(replace('aaa', 'a', 'XY'))", 6)]
20+
[DataRow("select len(stuff('abcdef', 2, 1, 'XYZW'))", 9)]
21+
[DataRow("select len(replicate('ab', 5))", 10)]
22+
[DataRow("select datalength(space(7))", 7)] // LEN strips trailing spaces; DATALENGTH counts them
23+
[DataRow("select len(left('abcdef', 20))", 6)]
24+
[DataRow("select len(substring('abcdef', 2, 3))", 3)]
25+
public void GrowableScalars_MaterializeFullValue(string sql, int expected)
26+
=> AreEqual(expected, new Simulation().ExecuteScalar(sql));
27+
28+
// Concatenation of two exact-width literals produces the full joined value.
29+
[TestMethod]
30+
public void Concatenation_ProducesFullValue()
31+
=> AreEqual("abcde", new Simulation().ExecuteScalar("select 'ab' + 'cde'"));
32+
33+
// A CASE that could yield either arm materializes the wider arm intact.
34+
[TestMethod]
35+
public void CaseUnification_MaterializesWiderArm()
36+
=> AreEqual("wxyz", new Simulation().ExecuteScalar("select case when 1=0 then 'ab' else 'wxyz' end"));
37+
38+
// Binary literals of differing exact widths compare by byte span, not by
39+
// declared-length identity.
40+
[TestMethod]
41+
[DataRow("0x01 = 0x01", 1)]
42+
[DataRow("0x0001 = 0x0001", 1)]
43+
[DataRow("0x01 = 0x0001", 0)] // different bytes → not equal
44+
[DataRow("0x0001 = cast(0x0001 as varbinary(8))", 1)]
45+
[DataRow("0x01 < 0x0100", 1)]
46+
[DataRow("0x0100 > 0x01", 1)]
47+
public void BinaryLengthVariance_ComparesByBytes(string condition, int expectedRows)
48+
=> AreEqual(expectedRows, new Simulation().ExecuteReader($"select 1 where {condition}").EnumerateRecords().Count());
49+
}

SqlServerSimulator/Errors/SimulatedSqlException.TypeErrors.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -374,15 +374,15 @@ internal static SimulatedSqlException ConversionFailedFromString(SqlType sourceT
374374
/// used for <c>bigint</c> overflows.
375375
/// </summary>
376376
internal static SimulatedSqlException OverflowConvertingNarrowInt(SqlType sourceType, string sourceValue, string targetTypeAlias) =>
377-
new($"The conversion of the {sourceType} value '{sourceValue}' overflowed an {targetTypeAlias} column. Use a larger integer column.", 244, 16, 1);
377+
new($"The conversion of the {sourceType.SqlServerName} value '{sourceValue}' overflowed an {targetTypeAlias} column. Use a larger integer column.", 244, 16, 1);
378378

379379
/// <summary>
380380
/// Mimics SQL Server error 248: the int-target counterpart of Msg 244.
381381
/// Note the lowercase "int" wording and the missing "Use a larger
382382
/// integer column" sentence — both verified against real SQL Server.
383383
/// </summary>
384384
internal static SimulatedSqlException OverflowConvertingToInt(SqlType sourceType, string sourceValue) =>
385-
new($"The conversion of the {sourceType} value '{sourceValue}' overflowed an int column.", 248, 16, 1);
385+
new($"The conversion of the {sourceType.SqlServerName} value '{sourceValue}' overflowed an int column.", 248, 16, 1);
386386

387387
/// <summary>
388388
/// Mimics SQL Server error 242, state 3: a date/time conversion
@@ -460,7 +460,7 @@ internal static SimulatedSqlException InvalidHierarchyIdInput(string detail) =>
460460
/// lazily). Same Msg + same wording; only the firing point differs.
461461
/// </summary>
462462
internal static SimulatedSqlException CollateClauseRequiresString(SqlType operandType) =>
463-
new($"Expression type {operandType} is invalid for COLLATE clause.", 447, 16, 1);
463+
new($"Expression type {FamilyRootName(operandType)} is invalid for COLLATE clause.", 447, 16, 1);
464464

465465
/// <summary>
466466
/// Mimics SQL Server error 448: an explicit <c>COLLATE</c> clause names

SqlServerSimulator/Parser/Expressions/Convert.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,14 @@ public override SqlValue Run(RuntimeContext runtime)
7575
// (3.14-style) routes through the same overflow surface as
7676
// any other narrowing-to-int.
7777
if (styleValue.Type.Category is SqlTypeCategory.String or SqlTypeCategory.UniqueIdentifier or SqlTypeCategory.DateTime)
78-
throw SimulatedSqlException.InvalidArgumentDataType(styleValue.Type.ToString()!, 3, this.tryMode ? "try_convert" : "convert");
78+
throw SimulatedSqlException.InvalidArgumentDataType(styleValue.Type.SqlServerName, 3, this.tryMode ? "try_convert" : "convert");
7979
try
8080
{
8181
styleCode = styleValue.CoerceTo(SqlType.Int32).AsInt32;
8282
}
8383
catch (OverflowException)
8484
{
85-
throw SimulatedSqlException.ArithmeticOverflow(SqlType.Int32.ToString()!);
85+
throw SimulatedSqlException.ArithmeticOverflow(SqlType.Int32.SqlServerName);
8686
}
8787
}
8888

SqlServerSimulator/Parser/Expressions/Left.cs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ public override SqlValue Run(RuntimeContext runtime)
2525
var rawSource = source.Run(runtime);
2626
var n = count.Run(runtime);
2727
if (rawSource.IsNull || n.IsNull)
28-
return SqlValue.Null(StringScalars.ResolveResultType(rawSource.Type, runtime.Batch));
28+
return SqlValue.Null(ResolveResultType(rawSource.Type, runtime.Batch));
2929
var s = StringScalars.CoerceToVarchar(rawSource, runtime.Batch, "left");
30+
var resultType = ResolveResultType(s.Type, runtime.Batch);
3031

3132
var len = StringScalars.CoerceLengthArgument(n);
3233
if (len < 0)
@@ -36,11 +37,29 @@ public override SqlValue Run(RuntimeContext runtime)
3637
var result = s.Type.Collation?.IsSupplementaryCharacterAware == true
3738
? SupplementaryCharacters.LeftByCodepoints(input, len)
3839
: len >= input.Length ? input : input[..len];
39-
return SqlValue.FromString(s.Type, result);
40+
return SqlValue.FromString(resultType, result);
4041
}
4142

4243
public override SqlType GetSqlType(BatchContext batch, Func<MultiPartName, SqlType> resolveColumnType) =>
43-
StringScalars.ResolveResultType(source.GetSqlType(batch, resolveColumnType), batch);
44+
ResolveResultType(source.GetSqlType(batch, resolveColumnType), batch);
45+
46+
/// <summary>
47+
/// LEFT preserves the input's string family; a constant count tightens the
48+
/// projected width to <c>min(inputWidth, count)</c> (probe-confirmed:
49+
/// <c>LEFT(varchar(10), 2)</c> → <c>varchar(2)</c>, <c>LEFT(varchar(10),
50+
/// 20)</c> → <c>varchar(10)</c>). A MAX / LOB input, a non-constant count,
51+
/// or an unspecified input width leaves the width at the input's.
52+
/// </summary>
53+
private SqlType ResolveResultType(SqlType sourceType, BatchContext batch)
54+
{
55+
var stringType = StringScalars.ResolveResultType(sourceType, batch);
56+
if (StringScalars.IsMaxForm(stringType))
57+
return stringType;
58+
var inputWidth = StringScalars.DeclaredWidth(stringType);
59+
return inputWidth > 0 && StringScalars.TryConstantCount(count, out var n)
60+
? StringScalars.SizedResultType(stringType, Math.Min(inputWidth, n), batch)
61+
: stringType;
62+
}
4463

4564
internal override string DebugDisplay() => $"LEFT({source.DebugDisplay()}, {count.DebugDisplay()})";
4665
}

SqlServerSimulator/Parser/Expressions/Replace.cs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public override SqlValue Run(RuntimeContext runtime)
3232
var rawOld = oldValue.Run(runtime);
3333
var rawNew = newValue.Run(runtime);
3434
if (rawInput.IsNull || rawOld.IsNull || rawNew.IsNull)
35-
return SqlValue.Null(StringScalars.ResolveResultType(rawInput.Type, runtime.Batch));
35+
return SqlValue.Null(StringScalars.ContainerResultType(rawInput.Type, runtime.Batch));
3636
var i = StringScalars.CoerceToVarchar(rawInput, runtime.Batch, "replace", argumentIndex: 1);
3737
var o = StringScalars.CoerceToVarchar(rawOld, runtime.Batch, "replace", argumentIndex: 2);
3838
var n = StringScalars.CoerceToVarchar(rawNew, runtime.Batch, "replace", argumentIndex: 3);
@@ -42,11 +42,15 @@ public override SqlValue Run(RuntimeContext runtime)
4242
var replaced = oldString.Length == 0
4343
? i.AsString
4444
: i.AsString.Replace(oldString, n.AsString, StringComparison.InvariantCultureIgnoreCase);
45-
return SqlValue.FromString(i.Type, replaced);
45+
// REPLACE can grow the input (a longer replacement per match), so the
46+
// result type is the family container (varchar(8000) / nvarchar(4000))
47+
// regardless of the input's declared width — probe-confirmed against
48+
// SQL Server 2025 (REPLACE(varchar(3), 'a', 'XY') → varchar(8000)).
49+
return SqlValue.FromString(StringScalars.ContainerResultType(i.Type, runtime.Batch), replaced);
4650
}
4751

4852
public override SqlType GetSqlType(BatchContext batch, Func<MultiPartName, SqlType> resolveColumnType) =>
49-
StringScalars.ResolveResultType(input.GetSqlType(batch, resolveColumnType), batch);
53+
StringScalars.ContainerResultType(input.GetSqlType(batch, resolveColumnType), batch);
5054

5155
internal override string DebugDisplay() => $"REPLACE({input.DebugDisplay()}, {oldValue.DebugDisplay()}, {newValue.DebugDisplay()})";
5256
}

SqlServerSimulator/Parser/Expressions/Replicate.cs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,25 @@ public override SqlType GetSqlType(BatchContext batch, Func<MultiPartName, SqlTy
7373
ResolveResultType(this.input.GetSqlType(batch, resolveColumnType), batch);
7474

7575
/// <summary>
76-
/// Non-string input projects as <c>varchar</c> in the active database's
77-
/// collation (matches the runtime coerce + real-server probe). String
78-
/// input preserves its declared family.
76+
/// Result width mirrors SQL Server's probed rule: a <c>varchar(MAX)</c> /
77+
/// <c>nvarchar(MAX)</c> input carries MAX through (unbounded); a bounded
78+
/// input with a constant count projects as the family-capped product
79+
/// <c>min(8000/4000, inputWidth × count)</c> (<c>REPLICATE(varchar(5), 3)</c>
80+
/// → <c>varchar(15)</c>); a non-constant count — or an input whose width is
81+
/// unspecified — falls back to the family container (<c>varchar(8000)</c> /
82+
/// <c>nvarchar(4000)</c>), matching real's non-constant behavior. Non-string
83+
/// input projects as <c>varchar</c> in the active database collation.
7984
/// </summary>
80-
private static SqlType ResolveResultType(SqlType inputType, BatchContext batch) =>
81-
StringScalars.ResolveResultType(inputType, batch);
85+
private SqlType ResolveResultType(SqlType inputType, BatchContext batch)
86+
{
87+
var stringType = StringScalars.ResolveResultType(inputType, batch);
88+
if (IsMaxForm(stringType))
89+
return stringType;
90+
var inputWidth = StringScalars.DeclaredWidth(stringType);
91+
return inputWidth > 0 && StringScalars.TryConstantCount(this.count, out var times)
92+
? StringScalars.SizedResultType(stringType, (int)Math.Min((long)inputWidth * times, StringScalars.FamilyCap(stringType)), batch)
93+
: StringScalars.ContainerResultType(stringType, batch);
94+
}
8295

8396
/// <summary>
8497
/// A string value is MAX-form when its declared type is a LOB

0 commit comments

Comments
 (0)