Skip to content

Commit 57ad9c7

Browse files
committed
EF.Functions string + type-check + rowset bundle: PATINDEX (Msg 8116 on NULL subject, int / bigint result by MAX-ness, ESCAPE rejected via grammar), STUFF (NULL on out-of-range start / negative length, nvarchar promotion via SqlType.Promote), QUOTENAME (7 delimiter pairs incl. quote / paren / angle / brace, closing-char doubling, 128-char input cap → NULL, multi-char delim picks first), REPLICATE (parse-time MAX-form capture via Expression.GetSqlType + outer-type resolver — runtime SqlValue is length-agnostic so this is the only signal; falls back to truncating for unresolvable column refs), SPACE (varchar not nvarchar, 8000-byte cap), FORMAT (.NET IFormattable passthrough, en-US default w/ silent culture-not-found fallback, FormatException → NULL, format string echoed on token miss; Msg 8116 for strings / bit / binary / uniqueidentifier / rowversion), ISNUMERIC (hand-rolled state machine matching probed quirks: bare sign / decimal point / comma / currency all return 1, exponent requires leading + trailing digit, internal whitespace breaks, bit returns 0, NULL → 0), ISDATE (1753-9999 gate atop SqlValue.TryParseLegacyDateTime + empty-string short-circuit; Msg 8116 on modern date / time / datetimeoffset; integer stringified then re-parsed), RAND (per-instance cached for the run-once-per-query rule, XOR-folded 64-bit-double seed mix avoids int-cast collapse on small integer seeds), and STRING_SPLIT as a Selection factory (FromStringSplit) dispatched in ParseSingleFromSource alongside OPENJSON and reachable through CROSS / OUTER APPLY via a chained-resolver entry in ParseLateralFromSource that wires left-side sources for the right-side column reach. New LikePatternBuilder hoists the LIKE wildcard compiler (BuildAnchored for LIKE's ^…[ ]*\z; BuildForPatIndex strips leading / trailing % so the returned position points at non-wildcard content not at .* consumption start); LikeExpression now delegates. SqlValue.TryParseLegacyDateTime promoted to internal for ISDATE reuse. New QueryErrors factories: StringSplitSeparatorMustBeSingleChar (Msg 214) and StringSplitInvalidEnableOrdinal (Msg 4199) with verbatim probed wording. STRING_SPLIT third arg enforced parse-time-constant (column / parameter refs raise NotSupportedException, matching SQL Server's compile-time shape-fix). EF Core LINQ surface (EF.Functions.IsNumeric / IsDate / Random) regression-covered; PATINDEX / STUFF / QUOTENAME / REPLICATE / SPACE / FORMAT / STRING_SPLIT covered by raw-SQL tests since EF.Functions doesn't expose them. CLAUDE.md notes two pre-existing-but-now-observable divergences: REPLICATE of a column-typed varchar(MAX) truncates to 8000 because the parse-time outer-type resolver doesn't reach FROM-source columns; DATALENGTH returns int (not bigint) for MAX inputs.
1 parent efa5ac9 commit 57ad9c7

26 files changed

Lines changed: 2141 additions & 131 deletions

CLAUDE.md

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

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

137-
- **Adding or modifying a built-in scalar** (math, date, current-time, `*FROMPARTS`/EOMONTH, AT TIME ZONE, CONCAT/CONCAT_WS, string `+`) → [`docs/claude/scalars.md`](docs/claude/scalars.md).
137+
- **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).
138138
- **Touching `SqlType.Promote` / `PromoteForArithmetic` / decimal precision-scale formulas / int↔string promotion**[`docs/claude/arithmetic.md`](docs/claude/arithmetic.md).
139139
- **Touching `Cast` or coercion error paths** (CAST/CONVERT narrow targets, TRY_CAST/TRY_CONVERT swallow set) → [`docs/claude/casting.md`](docs/claude/casting.md).
140140
- **Changing `Selection`, aggregates, window functions, set ops, CASE, OFFSET/FETCH**[`docs/claude/query.md`](docs/claude/query.md).
@@ -197,3 +197,5 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
197197
- **Un-taken IF branches resolve names eagerly**: real SQL Server defers name resolution for un-taken branches, so `IF 1=0 SELECT bad_col FROM bad_table` runs silently. The simulator's parsers do name resolution inline with parsing, so un-taken branches that reference non-existent tables/columns still raise `Msg 208` / `Msg 207`. The common idioms (`IF NOT EXISTS (…) CREATE TABLE foo (…)`, `IF OBJECT_ID('foo','U') IS NOT NULL DROP TABLE foo`, `IF cond INSERT t VALUES (…)` against pre-existing `t`) work end-to-end because referenced names exist when the branch is skipped. State mutations inside the un-taken branch are correctly suppressed (skip-mode gate); the gap is name resolution only.
198198
- **`IF` cond divide-by-zero**: real SQL Server surfaces `IF 1/0 = 0 …` as Msg 8134; the simulator surfaces the raw `DivideByZeroException` from .NET decimal arithmetic. Same pre-existing gap as documented for `TRY_CAST(1/0 AS INT)`.
199199
- **`IF (1) select` paren-wrapped non-boolean cond — slight positional gap**: simulator raises Msg 4145 near `')'`; real SQL Server reports `'select'` (the post-paren token). Wording is correct (Msg 4145, non-boolean type), only the "near 'X'" suffix differs. Same gap applies to any `IF (value-expr) …` shape.
200+
- **`REPLICATE` of a column-typed `varchar(MAX)` / `nvarchar(MAX)` truncates to 8000 bytes**: the simulator's runtime `SqlValue` doesn't carry the varchar / nvarchar declared length — both bounded `varchar(N)` and `varchar(MAX)` collapse to the length-agnostic singleton at the value level. `Replicate` captures the MAX-vs-bounded distinction at parse time via `Expression.GetSqlType`, which works for literal-only or CAST-target inputs (the common shape) but falls back to "treat as bounded" for column references because the parse-time outer-type resolver doesn't reach FROM-source column types. Threading the projection-time resolver through would lift this; EF's REPLICATE emissions don't hit the affected path.
201+
- **`DATALENGTH` returns `int` for MAX-typed inputs**: real SQL Server returns `bigint` for `varchar(MAX)` / `nvarchar(MAX)` and the legacy LOB family. Pre-existing simulator divergence; the result still fits in int for any value the simulator can produce, but the declared column type doesn't widen.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
using Microsoft.EntityFrameworkCore;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Coverage for the <see cref="EF.Functions"/> members added in the EF.Functions
7+
/// scalar bundle: <c>IsNumeric</c>, <c>IsDate</c>, and <c>Random</c>. Each
8+
/// emits the corresponding SQL Server built-in via the SqlServer provider,
9+
/// so the LINQ→SQL pipeline is the actual coverage target — raw SQL paths
10+
/// are validated in the *.Tests project.
11+
/// </summary>
12+
[TestClass]
13+
public sealed class EFCoreFunctionsBundle
14+
{
15+
public TestContext TestContext { get; set; } = null!;
16+
17+
private static TestDbContext SeededContext()
18+
{
19+
var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
20+
context.People.AddRange(
21+
new Person { Id = 1, Name = "Alice", Code = "100" },
22+
new Person { Id = 2, Name = "Bob", Code = "abc" },
23+
new Person { Id = 3, Name = "Charlie", Code = "-7.5" },
24+
new Person { Id = 4, Name = "Dave", Code = null });
25+
_ = context.SaveChanges();
26+
return context;
27+
}
28+
29+
[TestMethod]
30+
public void EFFunctions_IsNumeric_FiltersNumericCodes()
31+
{
32+
using var context = SeededContext();
33+
var ids = context.People
34+
.Where(p => EF.Functions.IsNumeric(p.Code!))
35+
.OrderBy(p => p.Id)
36+
.Select(p => p.Id)
37+
.ToArray();
38+
// '100' and '-7.5' are numeric per SQL Server's loose rules; 'abc'
39+
// and NULL are not. NULL through IsNumeric returns 0 (probe-confirmed
40+
// in the *.Tests suite). EF.Functions.IsNumeric surfaces this as
41+
// bool via comparison-to-1.
42+
CollectionAssert.AreEqual(new[] { 1, 3 }, ids);
43+
}
44+
45+
[TestMethod]
46+
public void EFFunctions_IsDate_FiltersDateStrings()
47+
{
48+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
49+
context.People.AddRange(
50+
new Person { Id = 1, Name = "X", Code = "2026-05-12" },
51+
new Person { Id = 2, Name = "Y", Code = "not-a-date" },
52+
new Person { Id = 3, Name = "Z", Code = "20260512" });
53+
_ = context.SaveChanges();
54+
var ids = context.People
55+
.Where(p => EF.Functions.IsDate(p.Code!))
56+
.OrderBy(p => p.Id)
57+
.Select(p => p.Id)
58+
.ToArray();
59+
CollectionAssert.AreEqual(new[] { 1, 3 }, ids);
60+
}
61+
62+
/// <summary>
63+
/// <see cref="DbFunctionsExtensions.Random"/> emits <c>RAND()</c> (no
64+
/// seed). The defining behavior is that the value is unique per query
65+
/// invocation but reused across rows — assertion here just verifies
66+
/// the function executes and returns a value in [0, 1).
67+
/// </summary>
68+
[TestMethod]
69+
public void EFFunctions_Random_EvaluatesInRange()
70+
{
71+
using var context = SeededContext();
72+
var values = context.People.Select(p => new { p.Id, Roll = EF.Functions.Random() }).ToArray();
73+
Assert.HasCount(4, values);
74+
foreach (var v in values)
75+
Assert.IsTrue(v.Roll is >= 0.0 and < 1.0, $"Random produced {v.Roll}");
76+
}
77+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
using static SqlServerSimulator.TestHelpers;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Behavioral tests for <c>FORMAT(value, format [, culture])</c>. The
8+
/// implementation routes through .NET's <see cref="IFormattable"/> on the
9+
/// underlying CLR value — so .NET's format-token rules (and quirks)
10+
/// determine whether the result is the format string echoed, a properly
11+
/// formatted value, or NULL via <see cref="FormatException"/>.
12+
/// </summary>
13+
[TestClass]
14+
public sealed class FormatTests
15+
{
16+
[TestMethod]
17+
public void IntN0_ThousandsSeparated()
18+
=> AreEqual("1,234,567", ExecuteScalar("select FORMAT(1234567, 'N0')"));
19+
20+
[TestMethod]
21+
public void IntN2_TwoDecimalPlaces()
22+
=> AreEqual("1,234,567.00", ExecuteScalar("select FORMAT(1234567, 'N2')"));
23+
24+
[TestMethod]
25+
public void DecimalCurrency_EnUS()
26+
=> AreEqual("$1,234.56", ExecuteScalar("select FORMAT(cast(1234.56 as decimal(10,2)), 'C', 'en-US')"));
27+
28+
[TestMethod]
29+
public void DateCustomFormat()
30+
=> AreEqual("2026-05-12", ExecuteScalar("select FORMAT(cast('2026-05-12' as date), 'yyyy-MM-dd')"));
31+
32+
[TestMethod]
33+
public void NumberDeDE_DecimalCommaThousandsDot()
34+
=> AreEqual("1.234,50", ExecuteScalar("select FORMAT(cast(1234.5 as decimal(10,2)), 'N2', 'de-DE')"));
35+
36+
[TestMethod]
37+
public void NullValue_ReturnsNull()
38+
=> IsInstanceOfType<DBNull>(ExecuteScalar("select FORMAT(cast(NULL as int), 'N0')"));
39+
40+
/// <summary>Probe-confirmed: NULL format raises Msg 8116 even with a NULL value side.</summary>
41+
[TestMethod]
42+
public void NullFormat_RaisesMsg8116()
43+
=> AssertSqlError("select FORMAT(1234, NULL)", 8116);
44+
45+
[TestMethod]
46+
public void StringInput_RaisesMsg8116()
47+
=> AssertSqlError("select FORMAT('abc', 'N0')", 8116);
48+
49+
[TestMethod]
50+
public void BitInput_RaisesMsg8116()
51+
=> AssertSqlError("select FORMAT(cast(1 as bit), 'N0')", 8116);
52+
53+
/// <summary>
54+
/// .NET's int.ToString accepts unrecognized custom-format strings by
55+
/// echoing them — matching SQL Server's documented passthrough.
56+
/// </summary>
57+
[TestMethod]
58+
public void UnrecognizedFormat_OnInt_Passthrough()
59+
=> AreEqual("qq qq", ExecuteScalar("select FORMAT(1234, 'qq qq')"));
60+
61+
/// <summary>
62+
/// .NET's decimal.ToString throws FormatException for the D specifier
63+
/// (decimals don't support D). SQL Server catches and returns NULL.
64+
/// </summary>
65+
[TestMethod]
66+
public void IncompatibleFormat_OnDecimal_ReturnsNull()
67+
=> IsInstanceOfType<DBNull>(ExecuteScalar("select FORMAT(cast(42 as decimal(10,0)), 'D5')"));
68+
69+
[TestMethod]
70+
public void InvalidCulture_FallsBackToEnUS()
71+
=> AreEqual("1,234", ExecuteScalar("select FORMAT(1234, 'N0', 'qq-QQ')"));
72+
73+
[TestMethod]
74+
public void DateTimeCustomFormat()
75+
=> AreEqual("12/05/2026 13:45", ExecuteScalar("select FORMAT(cast('2026-05-12T13:45:00' as datetime2), 'dd/MM/yyyy HH:mm')"));
76+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
using static SqlServerSimulator.TestHelpers;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Behavioral tests for <c>ISDATE(expression)</c>. Validates against the
8+
/// legacy <c>datetime</c> range (1753-9999); modern <c>date</c> / <c>time</c>
9+
/// / <c>datetimeoffset</c> raise Msg 8116; integer input gets implicitly
10+
/// stringified before parse.
11+
/// </summary>
12+
[TestClass]
13+
public sealed class IsDateTests
14+
{
15+
[TestMethod] public void IsoDate_Accepted() => AreEqual(1, ExecuteScalar<int>("select ISDATE('2026-05-12')"));
16+
[TestMethod] public void YyyyMmDd_Accepted() => AreEqual(1, ExecuteScalar<int>("select ISDATE('20260512')"));
17+
[TestMethod] public void DateAndTime_Accepted() => AreEqual(1, ExecuteScalar<int>("select ISDATE('2026-05-12 13:45')"));
18+
[TestMethod] public void TimeOnly_Accepted() => AreEqual(1, ExecuteScalar<int>("select ISDATE('12:34:56')"));
19+
20+
[TestMethod] public void InvalidMonth_Rejected() => AreEqual(0, ExecuteScalar<int>("select ISDATE('2026-13-01')"));
21+
[TestMethod] public void InvalidDay_Rejected() => AreEqual(0, ExecuteScalar<int>("select ISDATE('2026-02-30')"));
22+
[TestMethod] public void NonDate_Rejected() => AreEqual(0, ExecuteScalar<int>("select ISDATE('not a date')"));
23+
[TestMethod] public void Empty_Rejected() => AreEqual(0, ExecuteScalar<int>("select ISDATE('')"));
24+
25+
/// <summary>Pre-1753 dates are outside the legacy datetime range; ISDATE rejects.</summary>
26+
[TestMethod] public void PreLegacyMinYear_Rejected() => AreEqual(0, ExecuteScalar<int>("select ISDATE('1700-01-01')"));
27+
[TestMethod] public void LegacyMinYear_Accepted() => AreEqual(1, ExecuteScalar<int>("select ISDATE('1753-01-01')"));
28+
[TestMethod] public void LegacyMaxYear_Accepted() => AreEqual(1, ExecuteScalar<int>("select ISDATE('9999-12-31')"));
29+
30+
[TestMethod] public void Null_ReturnsZero() => AreEqual(0, ExecuteScalar<int>("select ISDATE(NULL)"));
31+
32+
[TestMethod] public void IntegerYyyyMmDd_AcceptedViaStringCoercion() => AreEqual(1, ExecuteScalar<int>("select ISDATE(20260512)"));
33+
[TestMethod] public void IntegerOutOfBounds_Rejected() => AreEqual(0, ExecuteScalar<int>("select ISDATE(99999999)"));
34+
[TestMethod] public void IntegerZero_Rejected() => AreEqual(0, ExecuteScalar<int>("select ISDATE(0)"));
35+
[TestMethod] public void IntegerOne_RejectedBelowYearFloor() => AreEqual(0, ExecuteScalar<int>("select ISDATE(1)"));
36+
37+
[TestMethod] public void DateTimeInput_Accepted() => AreEqual(1, ExecuteScalar<int>("select ISDATE(getdate())"));
38+
39+
/// <summary>
40+
/// Probe-confirmed: modern date/time/datetimeoffset are explicitly rejected
41+
/// with Msg 8116 — ISDATE intentionally lives in the legacy datetime domain.
42+
/// </summary>
43+
[TestMethod]
44+
public void DateInput_RaisesMsg8116()
45+
=> AssertSqlError("select ISDATE(cast('2026-05-12' as date))", 8116);
46+
47+
[TestMethod]
48+
public void TimeInput_RaisesMsg8116()
49+
=> AssertSqlError("select ISDATE(cast('12:34:56' as time))", 8116);
50+
51+
[TestMethod]
52+
public void DateTimeOffsetInput_RaisesMsg8116()
53+
=> AssertSqlError("select ISDATE(SYSDATETIMEOFFSET())", 8116);
54+
55+
[TestMethod] public void FloatInput_ReturnsZero() => AreEqual(0, ExecuteScalar<int>("select ISDATE(cast(1.5 as float))"));
56+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
using static SqlServerSimulator.TestHelpers;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Behavioral tests for <c>ISNUMERIC(expression)</c>. Famously lossy on real
8+
/// SQL Server — bare <c>-</c> / <c>.</c> / <c>,</c> / <c>$</c> all return 1,
9+
/// hex strings return 0, and internal whitespace breaks the match. These
10+
/// cases are all probe-confirmed against SQL Server 2025.
11+
/// </summary>
12+
[TestClass]
13+
public sealed class IsNumericTests
14+
{
15+
[TestMethod] public void Integer() => AreEqual(1, ExecuteScalar<int>("select ISNUMERIC('123')"));
16+
[TestMethod] public void NegativeInteger() => AreEqual(1, ExecuteScalar<int>("select ISNUMERIC('-123')"));
17+
[TestMethod] public void PositiveInteger() => AreEqual(1, ExecuteScalar<int>("select ISNUMERIC('+123')"));
18+
[TestMethod] public void Decimal() => AreEqual(1, ExecuteScalar<int>("select ISNUMERIC('1.5')"));
19+
[TestMethod] public void ScientificNotation_e() => AreEqual(1, ExecuteScalar<int>("select ISNUMERIC('1e10')"));
20+
[TestMethod] public void ScientificNotation_e_LargeExp() => AreEqual(1, ExecuteScalar<int>("select ISNUMERIC('1e308')"));
21+
[TestMethod] public void ScientificNotation_d() => AreEqual(1, ExecuteScalar<int>("select ISNUMERIC('1d10')"));
22+
[TestMethod] public void ScientificNotation_SignedExponent() => AreEqual(1, ExecuteScalar<int>("select ISNUMERIC('1e+10')"));
23+
[TestMethod] public void Currency() => AreEqual(1, ExecuteScalar<int>("select ISNUMERIC('$1')"));
24+
[TestMethod] public void CurrencyDecimal() => AreEqual(1, ExecuteScalar<int>("select ISNUMERIC('$1.50')"));
25+
[TestMethod] public void NonDollarCurrency() => AreEqual(1, ExecuteScalar<int>("select ISNUMERIC(N'£1')"));
26+
[TestMethod] public void SignThenCurrency() => AreEqual(1, ExecuteScalar<int>("select ISNUMERIC('-$1')"));
27+
[TestMethod] public void CurrencyThenSign() => AreEqual(1, ExecuteScalar<int>("select ISNUMERIC('$-1')"));
28+
29+
/// <summary>Quirky probe-confirmed acceptance: a bare sign returns 1.</summary>
30+
[TestMethod] public void SignAlone() => AreEqual(1, ExecuteScalar<int>("select ISNUMERIC('-')"));
31+
32+
/// <summary>Quirky probe-confirmed acceptance: a bare decimal point returns 1.</summary>
33+
[TestMethod] public void DecimalPointAlone() => AreEqual(1, ExecuteScalar<int>("select ISNUMERIC('.')"));
34+
35+
[TestMethod] public void CommaAlone() => AreEqual(1, ExecuteScalar<int>("select ISNUMERIC(',')"));
36+
[TestMethod] public void CurrencyAlone() => AreEqual(1, ExecuteScalar<int>("select ISNUMERIC('$')"));
37+
38+
[TestMethod] public void SpaceAlone_ReturnsZero() => AreEqual(0, ExecuteScalar<int>("select ISNUMERIC(' ')"));
39+
[TestMethod] public void EmptyString_ReturnsZero() => AreEqual(0, ExecuteScalar<int>("select ISNUMERIC('')"));
40+
[TestMethod] public void Null_ReturnsZero() => AreEqual(0, ExecuteScalar<int>("select ISNUMERIC(NULL)"));
41+
42+
[TestMethod] public void ExponentWithoutLeadingDigit_ReturnsZero() => AreEqual(0, ExecuteScalar<int>("select ISNUMERIC('e10')"));
43+
[TestMethod] public void ExponentWithoutTrailingDigit_ReturnsZero() => AreEqual(0, ExecuteScalar<int>("select ISNUMERIC('1e')"));
44+
[TestMethod] public void HexPrefix_ReturnsZero() => AreEqual(0, ExecuteScalar<int>("select ISNUMERIC('0x10')"));
45+
[TestMethod] public void InternalWhitespace_ReturnsZero() => AreEqual(0, ExecuteScalar<int>("select ISNUMERIC(' 1 2 ')"));
46+
[TestMethod] public void TrailingNonDigit_ReturnsZero() => AreEqual(0, ExecuteScalar<int>("select ISNUMERIC('1L')"));
47+
[TestMethod] public void FExponentRejected() => AreEqual(0, ExecuteScalar<int>("select ISNUMERIC('1f10')"));
48+
49+
[TestMethod] public void IntegerInput_ReturnsOne() => AreEqual(1, ExecuteScalar<int>("select ISNUMERIC(123)"));
50+
[TestMethod] public void FloatInput_ReturnsOne() => AreEqual(1, ExecuteScalar<int>("select ISNUMERIC(cast(1.5 as float))"));
51+
52+
/// <summary>Probe-confirmed: bit is the one numeric-category type that returns 0.</summary>
53+
[TestMethod] public void BitInput_ReturnsZero() => AreEqual(0, ExecuteScalar<int>("select ISNUMERIC(cast(1 as bit))"));
54+
55+
[TestMethod] public void LeadingAndTrailingWhitespace_TrimmedAccepted() => AreEqual(1, ExecuteScalar<int>("select ISNUMERIC(' 123 ')"));
56+
[TestMethod] public void ThousandsSeparated_Accepted() => AreEqual(1, ExecuteScalar<int>("select ISNUMERIC('1,000')"));
57+
}

0 commit comments

Comments
 (0)