Skip to content

Commit c1748c7

Browse files
committed
Math scalar functions: ROUND (2- and 3-arg, half-away-from-zero plus truncate mode for Math.Truncate), FLOOR, CEILING, POWER, SQRT, SIGN, LOG (1- and 2-arg), EXP, LOG10 added; ABS extended from integer-only to the full numeric family (decimal/numeric/money/smallmoney/float/real/bit). Shared MathScalars helper centralizes the probe-confirmed widening rule — tinyint/smallint→int, smallmoney→money, real→float, bit→float (sic) — and the AsLong / AsDouble / AsDecimalOrMoney accessors plus matching writers that bridge decimal's boxed storage and money's scaled-int64 storage. Result-type rules: FLOOR/CEILING/ROUND/SIGN/POWER preserve (post-widen) input type; SQRT/LOG/EXP/LOG10 always return float; POWER follows its first arg. Errors: Msg 3623 (invalid float op) for sqrt-neg / log-non-positive / power-neg-fractional / log-base-1; Msg 8134 for POWER(0, neg); Msg 232 for POWER int overflow; Msg 8115 for EXP overflow and ABS(int.MinValue) / ABS(bigint.MinValue). EF Core 10 emits all of these from natural Math.X LINQ; Math.Truncate translates to ROUND(x, 0, 1).
1 parent bf6cc3a commit c1748c7

17 files changed

Lines changed: 1252 additions & 21 deletions

File tree

CLAUDE.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,32 @@ Two shapes are modeled:
232232

233233
EF Core 10 always wraps ROW_NUMBER in a derived-table subquery: `SELECT ... FROM (SELECT cols, ROW_NUMBER() OVER(...) AS row FROM T) AS sub WHERE sub.row <= N` (Take) or `WHERE 1 < sub.row AND sub.row <= K` (Skip+Take). The simulator's plain-derived-table-doesn't-see-outer-scope limitation doesn't bite here because the ROW_NUMBER subquery has no outer correlation — its OVER refers only to the inner FROM.
234234

235+
### Math scalar functions
236+
`ABS`, `ROUND` (2- and 3-arg), `FLOOR`, `CEILING`, `POWER`, `SQRT`, `SIGN`, `LOG` (1- and 2-arg), `EXP`, `LOG10`. EF Core 10 emits all from natural `Math.X` LINQ — `Math.Round(x, n)``ROUND(x, n)`, `Math.Truncate(x)``ROUND(x, 0, 1)` (the truncate-mode third-arg form), `Math.Pow(a, b)``POWER(a, b)`, `Math.Log10(x)``LOG10(x)`, `Math.Abs(x)``ABS(x)`, etc. Probe-confirmed against SQL Server 2025 (2026-05-09).
237+
238+
**Type-widening rule** (shared across `ABS` / `FLOOR` / `CEILING` / `ROUND` / `SIGN` / `POWER`'s first-arg type — probe-confirmed via `SELECT INTO #t` + `tempdb.information_schema.columns`):
239+
- `tinyint` / `smallint``int`
240+
- `smallmoney``money`
241+
- `real` / `bit``float` (sic — `bit` widens to float, not int, despite being in the integer category)
242+
- `int` / `bigint` / `decimal(p,s)` / `money` / `float` → preserve input type
243+
244+
**Other type rules**:
245+
- `POWER` returns the (post-widen) type of the *first* argument regardless of exponent type — `POWER(int, float) → int` with truncation toward zero.
246+
- `SQRT` / `LOG` / `EXP` / `LOG10` always return `float` regardless of input.
247+
- The narrow-integer asymmetric range gets absorbed by the widening: `ABS(smallint.MinValue) = 32768` succeeds because the int result type holds it; `ABS(int.MinValue)` and `ABS(bigint.MinValue)` raise Msg 8115.
248+
249+
**Error semantics** (probe-confirmed 2026-05-09):
250+
- `SQRT(negative)`, `LOG(<= 0)`, `LOG10(<= 0)`, `LOG(x, base = 1)`, `POWER(negative, fractional)`**Msg 3623** `"An invalid floating point operation occurred."`.
251+
- `POWER(0, negative)`**Msg 8134** `"Divide by zero error encountered."`.
252+
- `EXP(huge)` (overflow to inf) → **Msg 8115** `"Arithmetic overflow error converting expression to data type float."`.
253+
- `ABS(int.MinValue)` / `ABS(bigint.MinValue)`**Msg 8115** with the result type's family name in the data-type slot.
254+
- `POWER` int-result overflow → **Msg 232** `"Arithmetic overflow error for type int, value = X.XXXXXX."` (six-digit fractional in the value slot).
255+
- `ROUND` non-integer length / function argument → **Msg 8116** (existing factory) `"Argument data type X is invalid for argument N of round function."`.
256+
257+
NULL on any operand → typed NULL of the (post-widen) result type. `MathScalars` (sibling helper in `Parser/Expressions/`) centralizes the widening rule and the `AsLong` / `AsDouble` / `AsDecimalOrMoney` accessors plus the matching `PromoteInteger` / `FromDecimalOrMoney` writers — each function file stays small and dispatches on `resultType.Category`. The `AsDecimal` / `FromDecimal` pair is decimal-only (rejects money); `AsDecimalOrMoney` / `FromDecimalOrMoney` bridge the two storage shapes (decimal stores boxed `decimal`, money stores scaled `int64`).
258+
259+
**`Math.Sign(decimal)` through EF Core 10 doesn't work end-to-end against either real SQL Server or the simulator** (probe-confirmed 2026-05-09): the LINQ method's CLR signature returns `int` but EF emits `SELECT SIGN([col])` and SQL Server's `SIGN(decimal)` returns decimal, so the reader-side cast throws — real SqlClient surfaces `InvalidCastException("Unable to cast object of type 'System.Decimal' to type 'System.Int32'.")`, the simulator surfaces `InvalidOperationException("Value is decimal(p,s), not int.")` from its stricter `GetInt32`. Same failure mode, different exception subtype; not a fidelity bug. Apps using `Math.Sign` over an int column work fine.
260+
235261
### Date scalar functions: `DATEPART` / `DATEADD` / `DATEDIFF` / `DATEDIFF_BIG`
236262
All four take a bare datepart keyword as the first argument (parse-time `Name` token, not an expression). Canonical keywords + common aliases: `year`/`yy`/`yyyy`, `quarter`/`qq`/`q`, `month`/`mm`/`m`, `dayofyear`/`dy`/`y`, `day`/`dd`/`d`, `week`/`wk`/`ww`, `iso_week`/`isowk`/`isoww`, `weekday`/`dw`, `hour`/`hh`, `minute`/`mi`/`n`, `second`/`ss`/`s`, `millisecond`/`ms`, `microsecond`/`mcs`, `nanosecond`/`ns`, `tzoffset`/`tz`. Result types: `DATEPART``int`; `DATEADD` preserves the input's SQL type (`date` stays `date`, `time(N)` stays `time(N)`, etc.); `DATEDIFF``int`; `DATEDIFF_BIG``bigint`.
237263

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
namespace SqlServerSimulator;
2+
3+
/// <summary>
4+
/// End-to-end coverage for EF Core 10's translation of <c>Math.X</c> LINQ
5+
/// methods. Probe-confirmed (2026-05-09): EF emits the SQL function
6+
/// directly for <c>Math.Round</c> / <c>Floor</c> / <c>Ceiling</c> /
7+
/// <c>Pow</c> / <c>Sqrt</c> / <c>Sign</c> / <c>Log</c> / <c>Exp</c> /
8+
/// <c>Log10</c>, and emits <c>ROUND(x, 0, 1)</c> for <c>Math.Truncate</c>
9+
/// (the truncate-mode third-arg form). <c>Math.Abs</c> already routes
10+
/// through the existing <c>AbsoluteValue</c> path.
11+
/// </summary>
12+
[TestClass]
13+
public class EFCoreMath
14+
{
15+
public TestContext TestContext { get; set; } = null!;
16+
17+
private static TestDbContext SeededProductsContext()
18+
{
19+
var context = new TestDbContext(TestDbContext.CreateProductsSimulation());
20+
context.Products.AddRange(
21+
new Product { Id = 1, Price = 12.345m },
22+
new Product { Id = 2, Price = 0.5m },
23+
new Product { Id = 3, Price = -5.5m });
24+
_ = context.SaveChanges();
25+
return context;
26+
}
27+
28+
[TestMethod]
29+
public void Round_DecimalProperty()
30+
{
31+
// Math.Round(p.Price, 2) → ROUND([p].[Price], 2) — half-away-from-zero.
32+
using var context = SeededProductsContext();
33+
var rounded = context.Products
34+
.OrderBy(p => p.Id)
35+
.Select(p => Math.Round(p.Price, 2))
36+
.ToArray();
37+
CollectionAssert.AreEqual(new[] { 12.35m, 0.50m, -5.50m }, rounded);
38+
}
39+
40+
[TestMethod]
41+
public void RoundToInteger()
42+
{
43+
// Math.Round(p.Price) → ROUND([p].[Price], 0) — half-away-from-zero.
44+
using var context = SeededProductsContext();
45+
var rounded = context.Products
46+
.OrderBy(p => p.Id)
47+
.Select(p => Math.Round(p.Price))
48+
.ToArray();
49+
CollectionAssert.AreEqual(new[] { 12m, 1m, -6m }, rounded);
50+
}
51+
52+
[TestMethod]
53+
public void Floor_DecimalProperty()
54+
{
55+
// Math.Floor(p.Price) → FLOOR([p].[Price]).
56+
using var context = SeededProductsContext();
57+
var floors = context.Products
58+
.OrderBy(p => p.Id)
59+
.Select(p => Math.Floor(p.Price))
60+
.ToArray();
61+
CollectionAssert.AreEqual(new[] { 12m, 0m, -6m }, floors);
62+
}
63+
64+
[TestMethod]
65+
public void Ceiling_DecimalProperty()
66+
{
67+
using var context = SeededProductsContext();
68+
var ceilings = context.Products
69+
.OrderBy(p => p.Id)
70+
.Select(p => Math.Ceiling(p.Price))
71+
.ToArray();
72+
CollectionAssert.AreEqual(new[] { 13m, 1m, -5m }, ceilings);
73+
}
74+
75+
[TestMethod]
76+
public void Sign_IntFromOrderBy()
77+
{
78+
// EF Core emits SIGN([t].[Id]) for Math.Sign(int); the result is
79+
// an int column. Math.Sign(decimal) → int has a server-side type
80+
// mismatch (SQL Server's SIGN(decimal) returns decimal, but EF
81+
// reads the column as int) — that route isn't exercised here.
82+
using var context = SeededProductsContext();
83+
var signs = context.Products
84+
.OrderBy(p => p.Id)
85+
.Select(p => Math.Sign(p.Id))
86+
.ToArray();
87+
CollectionAssert.AreEqual(new[] { 1, 1, 1 }, signs);
88+
}
89+
90+
[TestMethod]
91+
public void Abs_DecimalProperty()
92+
{
93+
// Math.Abs(p.Price) → ABS([p].[Price]) — preserves decimal(p,s).
94+
using var context = SeededProductsContext();
95+
var values = context.Products
96+
.OrderBy(p => p.Id)
97+
.Select(p => Math.Abs(p.Price))
98+
.ToArray();
99+
CollectionAssert.AreEqual(new[] { 12.35m, 0.50m, 5.50m }, values);
100+
}
101+
102+
[TestMethod]
103+
public void Truncate_DecimalProperty_EmitsRoundWithTruncateFlag()
104+
{
105+
// Math.Truncate(p.Price) → ROUND([p].[Price], 0, 1) — truncate mode.
106+
using var context = SeededProductsContext();
107+
var truncated = context.Products
108+
.OrderBy(p => p.Id)
109+
.Select(p => Math.Truncate(p.Price))
110+
.ToArray();
111+
CollectionAssert.AreEqual(new[] { 12m, 0m, -5m }, truncated);
112+
}
113+
114+
[TestMethod]
115+
public void Power_AndSqrt_OverFloatProjection()
116+
{
117+
// Math.Pow / Math.Sqrt require float operands at the LINQ side
118+
// (EF Core's translator doesn't widen decimal to double
119+
// automatically), so route through a CAST to double.
120+
using var context = SeededProductsContext();
121+
var values = context.Products
122+
.Where(p => p.Price > 0)
123+
.OrderBy(p => p.Id)
124+
.Select(p => new { Pow = Math.Pow((double)p.Price, 2), Root = Math.Sqrt((double)p.Price) })
125+
.ToArray();
126+
// Price is decimal(10,2) so 12.345 stores as 12.35 → 12.35^2 = 152.5225.
127+
AreClose(152.5225m, (decimal)values[0].Pow, 0.001m);
128+
AreClose(3.5142m, (decimal)values[0].Root, 0.001m);
129+
AreClose(0.25m, (decimal)values[1].Pow, 0.001m);
130+
}
131+
132+
[TestMethod]
133+
public void LogAndExp_FloatProjection()
134+
{
135+
using var context = SeededProductsContext();
136+
var values = context.Products
137+
.Where(p => p.Price > 0)
138+
.OrderBy(p => p.Id)
139+
.Select(p => new { Log = Math.Log((double)p.Price), Exp = Math.Exp(1.0) })
140+
.ToArray();
141+
Assert.HasCount(2, values);
142+
AreClose(2.5133m, (decimal)values[0].Log, 0.001m); // log(12.345)
143+
AreClose(2.7183m, (decimal)values[0].Exp, 0.001m); // e
144+
}
145+
146+
[TestMethod]
147+
public void Log10_FloatProjection()
148+
{
149+
using var context = SeededProductsContext();
150+
var values = context.Products
151+
.Where(p => p.Price > 0)
152+
.OrderBy(p => p.Id)
153+
.Select(p => Math.Log10((double)p.Price))
154+
.ToArray();
155+
AreClose(1.0915m, (decimal)values[0], 0.001m); // log10(12.345)
156+
}
157+
158+
private static void AreClose(decimal expected, decimal actual, decimal tolerance)
159+
=> Assert.IsLessThanOrEqualTo(tolerance, Math.Abs(expected - actual), $"Expected {expected} ± {tolerance}, got {actual}");
160+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
using static SqlServerSimulator.TestHelpers;
4+
5+
namespace SqlServerSimulator;
6+
7+
[TestClass]
8+
public sealed class MathScalarAbsTests
9+
{
10+
[TestMethod]
11+
public void Abs_Int()
12+
{
13+
AreEqual(5, ExecuteScalar<int>("select abs(-5)"));
14+
AreEqual(5, ExecuteScalar<int>("select abs(5)"));
15+
AreEqual(0, ExecuteScalar<int>("select abs(0)"));
16+
}
17+
18+
[TestMethod]
19+
public void Abs_BigInt_PreservesType()
20+
{
21+
AreEqual(5L, ExecuteScalar("select abs(cast(-5 as bigint))"));
22+
}
23+
24+
[TestMethod]
25+
public void Abs_SmallInt_WidensToInt()
26+
{
27+
// Server-side type: SELECT INTO #t reports `int`. SqlClient's
28+
// ExecuteScalar surfaces the value as int regardless.
29+
AreEqual(5, ExecuteScalar<int>("select abs(cast(-5 as smallint))"));
30+
// smallint.MinValue widens through int and survives.
31+
AreEqual(32768, ExecuteScalar<int>("select abs(cast(-32768 as smallint))"));
32+
}
33+
34+
[TestMethod]
35+
public void Abs_TinyInt_WidensToInt()
36+
{
37+
AreEqual(5, ExecuteScalar<int>("select abs(cast(5 as tinyint))"));
38+
}
39+
40+
[TestMethod]
41+
public void Abs_Decimal_PreservesPrecisionScale()
42+
{
43+
AreEqual(5.50m, ExecuteScalar("select abs(cast(-5.5 as decimal(10,2)))"));
44+
}
45+
46+
[TestMethod]
47+
public void Abs_Money_PreservesType()
48+
{
49+
AreEqual(5.5000m, ExecuteScalar("select abs(cast(-5.5 as money))"));
50+
}
51+
52+
[TestMethod]
53+
public void Abs_SmallMoney_WidensToMoney()
54+
{
55+
// Server-side type: smallmoney input → money result.
56+
AreEqual(5.5000m, ExecuteScalar("select abs(cast(-5.5 as smallmoney))"));
57+
}
58+
59+
[TestMethod]
60+
public void Abs_Float()
61+
{
62+
AreEqual(5.5, ExecuteScalar("select abs(cast(-5.5 as float))"));
63+
}
64+
65+
[TestMethod]
66+
public void Abs_Real_WidensToFloat()
67+
{
68+
// Server-side type: real input → float result.
69+
AreEqual(5.5, ExecuteScalar("select abs(cast(-5.5 as real))"));
70+
}
71+
72+
[TestMethod]
73+
public void Abs_Bit_WidensToFloat()
74+
{
75+
// SQL Server's quirky widening: ABS(bit) → float (probe-confirmed).
76+
AreEqual(0.0, ExecuteScalar("select abs(cast(0 as bit))"));
77+
AreEqual(1.0, ExecuteScalar("select abs(cast(1 as bit))"));
78+
}
79+
80+
[TestMethod]
81+
public void Abs_IntMinValue_RaisesMsg8115()
82+
{
83+
var ex = Throws<DbException>(() => ExecuteScalar("select abs(cast(-2147483648 as int))"));
84+
StartsWith("Arithmetic overflow error converting expression to data type int", ex.Message);
85+
AreEqual("8115", ex.Data["HelpLink.EvtID"]);
86+
}
87+
88+
[TestMethod]
89+
public void Abs_BigIntMinValue_RaisesMsg8115()
90+
{
91+
var ex = Throws<DbException>(() => ExecuteScalar("select abs(cast(-9223372036854775808 as bigint))"));
92+
StartsWith("Arithmetic overflow error converting expression to data type bigint", ex.Message);
93+
AreEqual("8115", ex.Data["HelpLink.EvtID"]);
94+
}
95+
96+
[TestMethod]
97+
public void Abs_Null_TypedNullOfWidenedType()
98+
{
99+
AreEqual(DBNull.Value, ExecuteScalar("select abs(cast(null as int))"));
100+
AreEqual(DBNull.Value, ExecuteScalar("select abs(cast(null as decimal(10,2)))"));
101+
AreEqual(DBNull.Value, ExecuteScalar("select abs(cast(null as smallmoney))"));
102+
}
103+
104+
// The widening rule is shared across ABS / FLOOR / CEILING / ROUND /
105+
// SIGN; verify the previously-shipped functions also widen smallmoney
106+
// / real / bit per the probe.
107+
108+
[TestMethod]
109+
public void Floor_SmallMoney_WidensToMoney()
110+
{
111+
AreEqual(1.0000m, ExecuteScalar("select floor(cast(1.7 as smallmoney))"));
112+
}
113+
114+
[TestMethod]
115+
public void Floor_Real_WidensToFloat()
116+
{
117+
AreEqual(1.0, ExecuteScalar("select floor(cast(1.7 as real))"));
118+
}
119+
120+
[TestMethod]
121+
public void Floor_Bit_WidensToFloat()
122+
{
123+
AreEqual(1.0, ExecuteScalar("select floor(cast(1 as bit))"));
124+
AreEqual(0.0, ExecuteScalar("select floor(cast(0 as bit))"));
125+
}
126+
127+
[TestMethod]
128+
public void Ceiling_SmallMoney_WidensToMoney()
129+
{
130+
AreEqual(2.0000m, ExecuteScalar("select ceiling(cast(1.3 as smallmoney))"));
131+
}
132+
133+
[TestMethod]
134+
public void Sign_SmallMoney_WidensToMoney()
135+
{
136+
AreEqual(-1.0000m, ExecuteScalar("select sign(cast(-1.5 as smallmoney))"));
137+
}
138+
139+
[TestMethod]
140+
public void Sign_Real_WidensToFloat()
141+
{
142+
AreEqual(-1.0, ExecuteScalar("select sign(cast(-1.5 as real))"));
143+
}
144+
145+
[TestMethod]
146+
public void Round_SmallMoney_WidensToMoney()
147+
{
148+
AreEqual(2.0000m, ExecuteScalar("select round(cast(1.5 as smallmoney), 0)"));
149+
}
150+
151+
[TestMethod]
152+
public void Round_Real_WidensToFloat()
153+
{
154+
AreEqual(2.0, ExecuteScalar("select round(cast(1.5 as real), 0)"));
155+
}
156+
}

0 commit comments

Comments
 (0)