Skip to content

Commit 3efc41e

Browse files
committed
Char-code scalars: ASCII / UNICODE / CHAR / NCHAR — basic conversions between a character and its code point that virtually every other string-manipulation built-in presupposes. ASCII / UNICODE return int; CHAR returns char(1); NCHAR returns nchar(1) (probe-confirmed via sql_variant_property basetype against SQL Server 2025 — CHAR's result is char not varchar, mirroring the docs but contrary to the Space.cs precedent). All four implementations live as one-file-per-scalar partials under Parser/Expressions/ (Ascii.cs / UnicodeCodepoint.cs / CharFromCode.cs / NCharFromCode.cs) — same pattern as existing simple scalars like Lower / Space. Wired into Expression.cs's length-keyed ResolveBuiltIn dispatch: CHAR at length-4, ASCII + NCHAR at length-5, UNICODE at length-7. ASCII implementation routes through CharSqlType.Cp1252Encoder for the .NET-string-to-CP1252-byte conversion, so probe-confirmed quirks fall out for free: ASCII(N'€') = 128 (the CP1252 byte for €), ASCII(N'😀') = 63 (encoder's ?' fallback for non-CP1252 chars), ASCII('é') = 233. NULL inputs return NULL; empty-string inputs return NULL for both ASCII and UNICODE (probe-confirmed — Microsoft's docs got it right; the "empty -> 0" claims floating around were wrong). Non-string inputs implicitly stringify before the first-char read, so ASCII(65) returns 54 (the byte for '6', the first char of "65") rather than 65 — implemented via SqlValue.CoerceTo(SqlType.Varchar) when the runtime type isn't in the string category. UNICODE returns the first .NET char's UTF-16 code unit value; supplementary code points like N'😀' return the high surrogate (55357 for 😀) under the non-SC default collation, matching probe + the simulator's "default collation only" stance — an SC-aware variant returning 128512 would need explicit collation modeling. CHAR(0..255) maps through Cp1252Encoder.GetString for the byte-to-char direction; CHAR(0) returns the NUL character (DATALENGTH = 1, ASCII round-trips back to 0) — not NULL, common idiom for embedding NUL bytes. CHAR / NCHAR out-of-range inputs (negative / > 255 for CHAR, > 65535 for NCHAR) return NULL; non-integer inputs (decimal / float / numeric string) implicitly truncate-to-int through CoerceTo(Int32). NCHAR > 65535 (including supplementary code points like 128512 for emoji) returns NULL under non-SC default collation matching probe — an SC-aware variant returning a 2-char surrogate-pair nvarchar(2) is deferred. 32 new CharScalarTests cover every probe-confirmed case verbatim including the PRINT-bundle-motivating CHAR(13)+CHAR(10) embedded-CRLF idiom, plus DATALENGTH parity for both CHAR(N) and NCHAR(N) result widths. docs/claude/scalars.md gains a dedicated "Char-code scalars" section above the EF.Functions-driven string scalars; CLAUDE.md Feature reference trigger entry adds the four function names to the scalar bundle. No new error factories (every edge case is silent NULL-or-coerce, matching probed behavior).
1 parent efb6b43 commit 3efc41e

8 files changed

Lines changed: 337 additions & 1 deletion

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ Full `DbDataReader` contract. Typed accessors read `SqlValue` directly via the c
133133

134134
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.
135135

136-
- **Adding or modifying a built-in scalar** (math, date, current-time, `*FROMPARTS`/EOMONTH, AT TIME ZONE, CONCAT/CONCAT_WS, string `+`, PATINDEX/STUFF/QUOTENAME/REPLICATE/SPACE/FORMAT, ISNUMERIC/ISDATE/RAND, STRING_SPLIT) → [`docs/claude/scalars.md`](docs/claude/scalars.md).
136+
- **Adding or modifying a built-in scalar** (math, date, current-time, `*FROMPARTS`/EOMONTH, AT TIME ZONE, CONCAT/CONCAT_WS, string `+`, ASCII/UNICODE/CHAR/NCHAR char-code, PATINDEX/STUFF/QUOTENAME/REPLICATE/SPACE/FORMAT, ISNUMERIC/ISDATE/RAND, STRING_SPLIT) → [`docs/claude/scalars.md`](docs/claude/scalars.md).
137137
- **Touching `SqlType.Promote` / `PromoteForArithmetic` / decimal precision-scale formulas / int↔string promotion**[`docs/claude/arithmetic.md`](docs/claude/arithmetic.md).
138138
- **Touching `Cast` or coercion error paths** (CAST/CONVERT narrow targets, TRY_CAST/TRY_CONVERT swallow set) → [`docs/claude/casting.md`](docs/claude/casting.md).
139139
- **Changing `Selection`, aggregates, window functions, set ops, CASE, OFFSET/FETCH**[`docs/claude/query.md`](docs/claude/query.md).
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// <c>ASCII</c> / <c>UNICODE</c> / <c>CHAR</c> / <c>NCHAR</c> char-code
7+
/// scalars. Probe-confirmed verbatim against SQL Server 2025 (2026-05-14);
8+
/// every test below corresponds to a probe entry.
9+
/// </summary>
10+
[TestClass]
11+
public sealed class CharScalarTests
12+
{
13+
[TestMethod]
14+
public void Ascii_Letter_ReturnsCode()
15+
=> AreEqual(65, new Simulation().ExecuteScalar("select ASCII('A')"));
16+
17+
[TestMethod]
18+
public void Ascii_MultiChar_ReturnsFirstByte()
19+
=> AreEqual(65, new Simulation().ExecuteScalar("select ASCII('Abc')"));
20+
21+
[TestMethod]
22+
public void Ascii_EmptyString_ReturnsNull()
23+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select ASCII('')"));
24+
25+
[TestMethod]
26+
public void Ascii_Null_ReturnsNull()
27+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select ASCII(NULL)"));
28+
29+
[TestMethod]
30+
public void Ascii_Space_Returns32()
31+
=> AreEqual(32, new Simulation().ExecuteScalar("select ASCII(' ')"));
32+
33+
/// <summary>
34+
/// N'€' is U+20AC, representable in CP1252 as 0x80 = 128.
35+
/// </summary>
36+
[TestMethod]
37+
public void Ascii_Cp1252Representable_Returns128()
38+
=> AreEqual(128, new Simulation().ExecuteScalar("select ASCII(N'€')"));
39+
40+
/// <summary>
41+
/// 'é' is U+00E9 = 233 in CP1252.
42+
/// </summary>
43+
[TestMethod]
44+
public void Ascii_LatinE_Returns233()
45+
=> AreEqual(233, new Simulation().ExecuteScalar("select ASCII('é')"));
46+
47+
/// <summary>
48+
/// ASCII(65) stringifies 65 to "65" first, then returns ASCII of '6' = 54.
49+
/// </summary>
50+
[TestMethod]
51+
public void Ascii_IntInput_ImplicitStringifies()
52+
=> AreEqual(54, new Simulation().ExecuteScalar("select ASCII(65)"));
53+
54+
[TestMethod]
55+
public void Unicode_Letter_Returns65()
56+
=> AreEqual(65, new Simulation().ExecuteScalar("select UNICODE(N'A')"));
57+
58+
[TestMethod]
59+
public void Unicode_EmptyString_ReturnsNull()
60+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select UNICODE(N'')"));
61+
62+
[TestMethod]
63+
public void Unicode_Null_ReturnsNull()
64+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select UNICODE(NULL)"));
65+
66+
[TestMethod]
67+
public void Unicode_EuroSign_Returns8364()
68+
=> AreEqual(8364, new Simulation().ExecuteScalar("select UNICODE(N'€')"));
69+
70+
/// <summary>
71+
/// 😀 (U+1F600) is the high surrogate 0xD83D = 55357 + low surrogate 0xDE00.
72+
/// Non-SC default collation returns the high surrogate, not the full code point.
73+
/// </summary>
74+
[TestMethod]
75+
public void Unicode_Supplementary_ReturnsHighSurrogate()
76+
=> AreEqual(55357, new Simulation().ExecuteScalar("select UNICODE(N'😀')"));
77+
78+
[TestMethod]
79+
public void Unicode_IntInput_ImplicitStringifies()
80+
=> AreEqual(54, new Simulation().ExecuteScalar("select UNICODE(65)"));
81+
82+
[TestMethod]
83+
public void Char_65_ReturnsA()
84+
=> AreEqual("A", new Simulation().ExecuteScalar("select CHAR(65)"));
85+
86+
[TestMethod]
87+
public void Char_0_ReturnsNulByte()
88+
{
89+
// CHAR(0) is a valid character — the NUL byte — not NULL. DATALENGTH
90+
// confirms a single byte; ASCII round-trips back to 0.
91+
var sim = new Simulation();
92+
AreEqual(1, sim.ExecuteScalar("select datalength(CHAR(0))"));
93+
AreEqual(0, sim.ExecuteScalar("select ASCII(CHAR(0))"));
94+
}
95+
96+
[TestMethod]
97+
public void Char_256_ReturnsNull()
98+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select CHAR(256)"));
99+
100+
[TestMethod]
101+
public void Char_Negative_ReturnsNull()
102+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select CHAR(-1)"));
103+
104+
[TestMethod]
105+
public void Char_Null_ReturnsNull()
106+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select CHAR(NULL)"));
107+
108+
[TestMethod]
109+
public void Char_DecimalInput_TruncatesToInt()
110+
=> AreEqual("A", new Simulation().ExecuteScalar("select CHAR(65.7)"));
111+
112+
[TestMethod]
113+
public void Char_StringInput_ParsesAsInt()
114+
=> AreEqual("A", new Simulation().ExecuteScalar("select CHAR('65')"));
115+
116+
/// <summary>
117+
/// CHAR(128) maps to € under CP1252.
118+
/// </summary>
119+
[TestMethod]
120+
public void Char_Cp1252EuroByte_Returns()
121+
=> AreEqual("€", new Simulation().ExecuteScalar("select CHAR(128)"));
122+
123+
[TestMethod]
124+
public void NChar_65_ReturnsA()
125+
=> AreEqual("A", new Simulation().ExecuteScalar("select NCHAR(65)"));
126+
127+
[TestMethod]
128+
public void NChar_EuroCodepoint_ReturnsEuro()
129+
=> AreEqual("€", new Simulation().ExecuteScalar("select NCHAR(8364)"));
130+
131+
/// <summary>
132+
/// Default non-SC collation: NCHAR > 65535 returns NULL rather than
133+
/// emitting a surrogate pair. Documented in CLAUDE.md as a deliberate
134+
/// alignment with the simulator's default-collation-only stance.
135+
/// </summary>
136+
[TestMethod]
137+
public void NChar_Supplementary_ReturnsNull()
138+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select NCHAR(65536)"));
139+
140+
[TestMethod]
141+
public void NChar_Emoji_ReturnsNull()
142+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select NCHAR(128512)"));
143+
144+
[TestMethod]
145+
public void NChar_Negative_ReturnsNull()
146+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select NCHAR(-1)"));
147+
148+
[TestMethod]
149+
public void NChar_Null_ReturnsNull()
150+
=> AreEqual(DBNull.Value, new Simulation().ExecuteScalar("select NCHAR(NULL)"));
151+
152+
[TestMethod]
153+
public void NChar_StringInput_ParsesAsInt()
154+
=> AreEqual("A", new Simulation().ExecuteScalar("select NCHAR('65')"));
155+
156+
[TestMethod]
157+
public void Char_Width_IsOneByte()
158+
=> AreEqual(1, new Simulation().ExecuteScalar("select datalength(CHAR(65))"));
159+
160+
[TestMethod]
161+
public void NChar_Width_IsTwoBytes()
162+
=> AreEqual(2, new Simulation().ExecuteScalar("select datalength(NCHAR(65))"));
163+
164+
[TestMethod]
165+
public void Char13Plus10_EmbedsCRLF()
166+
{
167+
// The PRINT-bundle observation that drove this feature: CHAR(13) +
168+
// CHAR(10) is the idiomatic way to embed CR+LF in T-SQL string
169+
// literals.
170+
var sim = new Simulation();
171+
AreEqual(2, sim.ExecuteScalar("select len(CHAR(13)+CHAR(10))"));
172+
AreEqual("a\nb", sim.ExecuteScalar("select 'a'+CHAR(10)+'b'"));
173+
}
174+
}

SqlServerSimulator/Parser/Expression.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
444444
"ATAN" => new TrigFunction(context, TrigKind.Atan),
445445
"ATN2" => new Atn2(context),
446446
"CAST" => new Cast(context),
447+
"CHAR" => new CharFromCode(context),
447448
"LEAD" => WindowExpression.ParseLead(context),
448449
"LEFT" => new Left(context),
449450
"RAND" => new Rand(context),
@@ -456,11 +457,13 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
456457
},
457458
5 => uppercaseName switch
458459
{
460+
"ASCII" => new Ascii(context),
459461
"COUNT" => AggregateExpression.Parse(context, AggregateKind.Count),
460462
"FLOOR" => new Floor(context),
461463
"LOG10" => new Log10(context),
462464
"LOWER" => new Lower(context),
463465
"LTRIM" => new LeftTrim(context),
466+
"NCHAR" => new NCharFromCode(context),
464467
"NEWID" => new NewId(context),
465468
"NTILE" => WindowExpression.ParseNTile(context),
466469
"POWER" => new Power(context),
@@ -496,6 +499,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
496499
"REPLACE" => new Replace(context),
497500
"REVERSE" => new Reverse(context),
498501
"TYPE_ID" => new TypeId(context),
502+
"UNICODE" => new UnicodeCodepoint(context),
499503
_ => null
500504
},
501505
8 => uppercaseName switch
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using SqlServerSimulator.Storage;
2+
3+
namespace SqlServerSimulator.Parser.Expressions;
4+
5+
/// <summary>
6+
/// SQL <c>ASCII(character_expression)</c>: returns the CP1252 byte value
7+
/// of the first character of the input.
8+
/// </summary>
9+
/// <remarks>
10+
/// Probe-confirmed against SQL Server 2025 (2026-05-14):
11+
/// <list type="bullet">
12+
/// <item>NULL input → NULL.</item>
13+
/// <item>Empty string → NULL.</item>
14+
/// <item>Unicode input (<c>N'…'</c>) is converted to CP1252 before the byte is read; representable characters like <c>N'€'</c> return their CP1252 byte (128 for €); unrepresentable Unicode (emoji etc.) returns 63 via CP1252's <c>'?'</c> replacement fallback (matches the simulator's existing CP1252 collation quirk).</item>
15+
/// <item>Non-string inputs (int / decimal / etc.) are implicitly converted to <c>varchar</c> first, so <c>ASCII(65)</c> returns 54 (the ASCII byte for <c>'6'</c>, the first char of the string <c>"65"</c>) — not 65.</item>
16+
/// </list>
17+
/// </remarks>
18+
internal sealed class Ascii(ParserContext context) : Expression
19+
{
20+
private readonly Expression source = Parse(context);
21+
22+
public override SqlValue Run(RuntimeContext runtime)
23+
{
24+
var v = source.Run(runtime);
25+
if (v.IsNull)
26+
return SqlValue.Null(SqlType.Int32);
27+
var s = SqlType.IsStringCategory(v.Type) ? v.AsString : v.CoerceTo(SqlType.Varchar).AsString;
28+
if (s.Length == 0)
29+
return SqlValue.Null(SqlType.Int32);
30+
Span<byte> firstByte = stackalloc byte[1];
31+
_ = CharSqlType.Cp1252Encoder.GetBytes(s.AsSpan(0, 1), firstByte);
32+
return SqlValue.FromInt32(firstByte[0]);
33+
}
34+
35+
public override SqlType GetSqlType(Func<MultiPartName, SqlType> resolveColumnType) => SqlType.Int32;
36+
37+
internal override string DebugDisplay() => $"ASCII({this.source.DebugDisplay()})";
38+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using SqlServerSimulator.Storage;
2+
3+
namespace SqlServerSimulator.Parser.Expressions;
4+
5+
/// <summary>
6+
/// SQL <c>CHAR(integer_expression)</c>: returns the single CP1252 character
7+
/// corresponding to the input byte code (0–255), as <c>char(1)</c>.
8+
/// </summary>
9+
/// <remarks>
10+
/// Probe-confirmed against SQL Server 2025 (2026-05-14):
11+
/// <list type="bullet">
12+
/// <item>Result type is <c>char(1)</c>, not <c>varchar(1)</c> — <c>sql_variant_property(CHAR(65), 'basetype')</c> returns <c>'char'</c>.</item>
13+
/// <item>NULL input → NULL.</item>
14+
/// <item>Out-of-range input (negative or &gt; 255) → NULL.</item>
15+
/// <item>Non-integer inputs (decimal / float / string of digits) are implicitly converted to int with truncation, so <c>CHAR(65.7)</c> and <c>CHAR('65')</c> both return <c>'A'</c>.</item>
16+
/// <item><c>CHAR(0)</c> returns a single <c>0x00</c> byte (the NUL character), not NULL — common idiom for embedding null bytes.</item>
17+
/// </list>
18+
/// </remarks>
19+
internal sealed class CharFromCode(ParserContext context) : Expression
20+
{
21+
private static readonly CharSqlType Char1 = CharSqlType.Get(1);
22+
private readonly Expression code = Parse(context);
23+
24+
public override SqlValue Run(RuntimeContext runtime)
25+
{
26+
var v = code.Run(runtime);
27+
if (v.IsNull)
28+
return SqlValue.Null(Char1);
29+
var n = v.CoerceTo(SqlType.Int32).AsInt32;
30+
if (n is < 0 or > 255)
31+
return SqlValue.Null(Char1);
32+
ReadOnlySpan<byte> oneByte = [(byte)n];
33+
return SqlValue.FromChar(Char1, CharSqlType.Cp1252Encoder.GetString(oneByte));
34+
}
35+
36+
public override SqlType GetSqlType(Func<MultiPartName, SqlType> resolveColumnType) => Char1;
37+
38+
internal override string DebugDisplay() => $"CHAR({this.code.DebugDisplay()})";
39+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using SqlServerSimulator.Storage;
2+
3+
namespace SqlServerSimulator.Parser.Expressions;
4+
5+
/// <summary>
6+
/// SQL <c>NCHAR(integer_expression)</c>: returns the single UTF-16 character
7+
/// corresponding to the input code unit (0–65535), as <c>nchar(1)</c>.
8+
/// Mirror of <see cref="CharFromCode"/>'s range / coercion / NULL rules,
9+
/// scaled to BMP code units.
10+
/// </summary>
11+
/// <remarks>
12+
/// Probe-confirmed against SQL Server 2025 (2026-05-14):
13+
/// <list type="bullet">
14+
/// <item>Result type is <c>nchar(1)</c> — <c>sql_variant_property</c> returns <c>'nchar'</c>; <c>datalength</c> is 2 (one UTF-16 code unit).</item>
15+
/// <item>Out-of-range inputs (negative, or above 65535) return NULL under the default non-SC collation. Supplementary code points like <c>NCHAR(128512)</c> (😀) return NULL rather than the surrogate pair — matches the simulator's "default collation only" stance; an SC-collation-aware variant returning <c>nvarchar(2)</c> would need explicit collation modeling.</item>
16+
/// <item>Non-integer inputs implicitly truncate-to-int (<c>NCHAR('65')</c> → 'A').</item>
17+
/// </list>
18+
/// </remarks>
19+
internal sealed class NCharFromCode(ParserContext context) : Expression
20+
{
21+
private static readonly NCharSqlType NChar1 = NCharSqlType.Get(1);
22+
private readonly Expression code = Parse(context);
23+
24+
public override SqlValue Run(RuntimeContext runtime)
25+
{
26+
var v = code.Run(runtime);
27+
if (v.IsNull)
28+
return SqlValue.Null(NChar1);
29+
var n = v.CoerceTo(SqlType.Int32).AsInt32;
30+
return n is < 0 or > 65535
31+
? SqlValue.Null(NChar1)
32+
: SqlValue.FromNChar(NChar1, ((char)n).ToString());
33+
}
34+
35+
public override SqlType GetSqlType(Func<MultiPartName, SqlType> resolveColumnType) => NChar1;
36+
37+
internal override string DebugDisplay() => $"NCHAR({this.code.DebugDisplay()})";
38+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using SqlServerSimulator.Storage;
2+
3+
namespace SqlServerSimulator.Parser.Expressions;
4+
5+
/// <summary>
6+
/// SQL <c>UNICODE(ncharacter_expression)</c>: returns the UTF-16 code-unit
7+
/// value of the first character of the input. Mirror of <see cref="Ascii"/>
8+
/// in input-handling shape; the only divergence is the byte vs code-unit
9+
/// extraction step.
10+
/// </summary>
11+
/// <remarks>
12+
/// Probe-confirmed against SQL Server 2025 (2026-05-14):
13+
/// <list type="bullet">
14+
/// <item>NULL → NULL; empty string → NULL.</item>
15+
/// <item>Non-string inputs implicitly stringify, so <c>UNICODE(65)</c> returns 54 (<c>'6'</c>) not 65.</item>
16+
/// <item>Supplementary code points (above U+FFFF, e.g. <c>N'😀'</c>) under the default non-SC collation return the high surrogate value (55357 for 😀), not the full Unicode code point — matches the simulator's "default collation only" stance documented in CLAUDE.md. An SC-collation-aware variant returning 128512 would need explicit collation modeling.</item>
17+
/// </list>
18+
/// </remarks>
19+
internal sealed class UnicodeCodepoint(ParserContext context) : Expression
20+
{
21+
private readonly Expression source = Parse(context);
22+
23+
public override SqlValue Run(RuntimeContext runtime)
24+
{
25+
var v = source.Run(runtime);
26+
if (v.IsNull)
27+
return SqlValue.Null(SqlType.Int32);
28+
var s = SqlType.IsStringCategory(v.Type) ? v.AsString : v.CoerceTo(SqlType.Varchar).AsString;
29+
return s.Length == 0 ? SqlValue.Null(SqlType.Int32) : SqlValue.FromInt32(s[0]);
30+
}
31+
32+
public override SqlType GetSqlType(Func<MultiPartName, SqlType> resolveColumnType) => SqlType.Int32;
33+
34+
internal override string DebugDisplay() => $"UNICODE({this.source.DebugDisplay()})";
35+
}

docs/claude/scalars.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ Zone-name resolution via `TimeZoneInfo.FindSystemTimeZoneById` (accepts both Win
5959

6060
**Precedence**: `AT TIME ZONE` binds tighter than `+`. The zone-name slot parses as a primary expression only — literals, `@variables`, single-segment column refs, or parenthesized full expressions. Multi-part dotted refs and binary chains in the zone slot aren't modeled; wrap in parens. `AT`/`TIME`/`ZONE` are contextual keywords (still valid identifiers).
6161

62+
## Char-code scalars: `ASCII` / `UNICODE` / `CHAR` / `NCHAR`
63+
Basic one-arg conversions between a character and its code point.
64+
65+
- **`ASCII(input)`** returns `int`. Reads the first character of `input` and returns its CP1252 byte value. NULL → NULL; empty string → NULL. Unicode input is CP1252-encoded first, so `ASCII(N'€')` returns 128 (CP1252's ``); unrepresentable Unicode (emoji etc.) returns 63 via the encoder's `'?'` replacement fallback. Non-string inputs implicitly stringify *before* the first-char read, so `ASCII(65)` is 54 (the byte for `'6'`, the first char of `"65"`), not 65.
66+
- **`UNICODE(input)`** returns `int`. Same input-handling shape as `ASCII`, but reads the .NET `char` directly rather than CP1252-encoding it. Supplementary code points (above U+FFFF, e.g. `N'😀'`) return the high surrogate value (55357 for 😀) under the non-SC default collation — not the full Unicode code point. An SC-aware variant returning 128512 would need explicit collation modeling; matches the simulator's "default collation only" stance.
67+
- **`CHAR(code)`** returns `char(1)` (not `varchar(1)` — probe-confirmed via `sql_variant_property(CHAR(65), 'basetype')`). NULL → NULL; out-of-range (negative / > 255) → NULL. Non-integer inputs truncate-to-int (`CHAR(65.7)``'A'`, `CHAR('65')``'A'`). `CHAR(0)` is a valid NUL character with `DATALENGTH = 1`, not NULL.
68+
- **`NCHAR(code)`** returns `nchar(1)`. NULL / out-of-range (negative, > 65535) → NULL. Supplementary code points like `NCHAR(128512)` (😀) return NULL rather than emitting a surrogate pair — non-SC collation behavior.
69+
6270
## EF.Functions-driven string scalars: `PATINDEX` / `STUFF` / `QUOTENAME` / `REPLICATE` / `SPACE` / `FORMAT`
6371
Bundle that fills out the raw-SQL string surface that EF's `FromSqlInterpolated` and `DefaultValueSql` workloads commonly reach. None of these are exposed as `EF.Functions.X` LINQ extensions; coverage targets raw-SQL paths.
6472

0 commit comments

Comments
 (0)