Skip to content

Commit 6fd3f63

Browse files
committed
Decimal arithmetic per-operator scale: SqlType.PromoteForArithmetic encodes SQL Server's per-operator precision/scale rules for + - * / %, single source of truth for both static GetSqlType and runtime DecimalArithmetic, fixes EF Core's percent-of-total LINQ shape and a latent multiplication-overflow scale bug.
1 parent b2ec9c7 commit 6fd3f63

5 files changed

Lines changed: 236 additions & 65 deletions

File tree

CLAUDE.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,22 @@ Top-level OFFSET/FETCH (post-set-op chain) attaches alongside the top-level ORDE
182182
### Aggregates
183183
`COUNT(*)` / `COUNT(expr)` / `COUNT(DISTINCT expr)` / `COUNT_BIG`, `SUM` / `AVG` (integer-truncating; `decimal(38, max(s, 6))` widening for AVG over decimals), `MAX` / `MIN`, statistical (`STDEV` / `STDEVP` / `VAR` / `VARP`), `STRING_AGG`, `CHECKSUM_AGG`, `APPROX_COUNT_DISTINCT`. Standalone and inside `GROUP BY` / `HAVING`.
184184

185+
### Decimal arithmetic precision / scale
186+
SQL Server has per-operator scale rules for decimal that differ from the joint-envelope rule used for comparison / COALESCE / set-op type unification (probe-confirmed against SQL Server 2025, 2026-05-08):
187+
188+
- `+` / `-`: `p = max(p1-s1, p2-s2) + max(s1, s2) + 1`, `s = max(s1, s2)`
189+
- `*`: `p = p1 + p2 + 1`, `s = s1 + s2`
190+
- `/`: `s = max(6, s1 + p2 + 1)`, `p = p1 - s1 + s2 + s`
191+
- `%`: `p = min(p1-s1, p2-s2) + max(s1, s2)`, `s = max(s1, s2)`
192+
193+
When the computed precision exceeds 38, scale reduces by the excess down to a floor of `min(originalScale, 6)` and precision clips to 38. The 6-floor is what makes division stable: division always produces `s ≥ 6`, so the floor effectively becomes 6; for `+ - * %` the floor binds only when the original scale was already ≤ 6 and preserves it. `decimal(38,2) * decimal(38,2)``decimal(38,4)` (small scale preserved); `decimal(38,30) / decimal(38,30)``decimal(38,6)` (floor binds); EF Core's `decimal(10,2) * 100.0 / decimal(38,2)``decimal(38,24)`.
194+
195+
Integer / money operands canonicalize to their decimal equivalent before the formulas apply (bit→(1,0) … bigint→(19,0); money→(19,4); smallmoney→(10,4)). Pure integer-pair, pure money-pair, and float-involving arithmetic skip the decimal path entirely — those produce wider integer / money / float results that match the joint-envelope `Promote`.
196+
197+
`SqlType.PromoteForArithmetic(a, b, op)` is the single source of truth: `TwoSidedExpression.GetSqlType` calls it for the static schema, and `DecimalArithmetic` calls it for the runtime result type. The static / runtime parity is required because the row encoder rejects values whose runtime type doesn't match the schema's declared type.
198+
199+
`SqlType.Promote` (joint-envelope, `scale = max(s1, s2); precision = min(38, max(p1-s1, p2-s2) + scale)`) is still the right rule for non-arithmetic uses (CASE, COALESCE, set ops, comparison common-type) and stays unchanged.
200+
185201
### Window functions
186202
Two shapes are modeled:
187203
- `ROW_NUMBER() OVER([PARTITION BY <expr-list>] ORDER BY <expr-list-with-direction>)` — for EF Core 10's top-N / Skip+Take per group. Result type `bigint`. ORDER BY is required inside OVER (parse fails otherwise — SQL Server raises Msg 4112).

SqlServerSimulator.Tests.EFCore/EFCoreSubquery.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,31 @@ public void Where_ScalarComparisonAgainstSubquery_FiltersByMaxAmount()
122122
CollectionAssert.AreEqual(new[] { 2 }, customerIds);
123123
}
124124

125+
[TestMethod]
126+
public void Projection_PercentOfTotal_DecimalDivisionScalePreserved()
127+
{
128+
// EF Core emits `s.Amount * 100m / (correlated SUM(...))` for the
129+
// percent-of-total LINQ shape. The chained multiply+divide produces
130+
// a wide-scale decimal at runtime (d(38,24)) that the static
131+
// schema must agree with — otherwise the row encoder rejects on
132+
// schema mismatch.
133+
using var context = SeededContext();
134+
var rows = context.CustomerOrders
135+
.OrderBy(o => o.Id)
136+
.Select(o => new
137+
{
138+
o.Id,
139+
Pct = o.Amount * 100m / context.CustomerOrders.Where(x => x.CustomerId == o.CustomerId).Sum(x => x.Amount),
140+
})
141+
.ToArray();
142+
Assert.HasCount(3, rows);
143+
// Customer 1: orders 10 + 20 = 30. Pct for amount=10 → 33.33...; for amount=20 → 66.66...
144+
// Customer 2: order 30 → 100%.
145+
Assert.AreEqual(decimal.Round(10m * 100m / 30m, 6), decimal.Round(rows[0].Pct, 6));
146+
Assert.AreEqual(decimal.Round(20m * 100m / 30m, 6), decimal.Round(rows[1].Pct, 6));
147+
Assert.AreEqual(100m, rows[2].Pct);
148+
}
149+
125150
[TestMethod]
126151
public void Projection_DistinctCorrelatedCount_EmitsCorrelatedDerivedTable()
127152
{

SqlServerSimulator.Tests/DecimalTests.cs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,70 @@ public void DecimalArithmetic_OverflowOnMultiplicationRaisesMsg8115()
175175
AreEqual("Arithmetic overflow error converting expression to data type numeric.", ex.Message);
176176
}
177177

178+
[TestMethod]
179+
public void DecimalArithmetic_StaticSchemaMatchesRuntimeForDivision()
180+
{
181+
// The EF percent-of-total shape: (s.Amount * 100.0) / (sum). Before
182+
// the per-operator-arithmetic fix, the static schema computed via
183+
// joint-envelope Promote diverged from the per-operator runtime
184+
// type, causing the RowEncoder to reject the value with a
185+
// "decimal(38,N) vs decimal(38,M)" mismatch. The probed result is
186+
// d(38,24); confirming both schema and value land at scale 24.
187+
var simulation = new Simulation();
188+
_ = simulation.ExecuteNonQuery("create table sales (region varchar(10), amount decimal(10,2))");
189+
_ = simulation.ExecuteNonQuery(
190+
"insert into sales values ('east', 100), ('east', 200), ('east', 150), ('west', 300), ('west', 500), ('west', 50)");
191+
using var connection = simulation.CreateOpenConnection();
192+
using var reader = connection.CreateCommand(
193+
"select s.region, s.amount, s.amount * 100.0 / (select sum(s0.amount) from sales as s0 where s0.region = s.region) from sales as s")
194+
.ExecuteReader();
195+
AreEqual("decimal", reader.GetDataTypeName(2));
196+
var pcts = new List<(string Region, decimal Amount, decimal Pct)>();
197+
while (reader.Read())
198+
pcts.Add((reader.GetString(0), reader.GetDecimal(1), reader.GetDecimal(2)));
199+
// east totals 450; west totals 850. Verify a couple per region.
200+
var (_, _, east100Pct) = pcts.Single(p => p.Region == "east" && p.Amount == 100m);
201+
var (_, _, west500Pct) = pcts.Single(p => p.Region == "west" && p.Amount == 500m);
202+
AreEqual(decimal.Round(100m * 100m / 450m, 6), decimal.Round(east100Pct, 6));
203+
AreEqual(decimal.Round(500m * 100m / 850m, 6), decimal.Round(west500Pct, 6));
204+
}
205+
206+
[TestMethod]
207+
public void DecimalArithmetic_38CapPreservesSmallScaleForMultiplication()
208+
{
209+
// d(38,2) * d(38,2): formula gives p=77, s=4. The 38-cap reduces
210+
// precision but the scale floor is min(originalScale, 6) = 4 (not
211+
// 0 — that was a latent bug in the old non-division path). SQL
212+
// Server returns d(38,4); verify the runtime value carries scale 4.
213+
var v = ExecuteScalar("select cast(123 as decimal(38,2)) * cast(456 as decimal(38,2))");
214+
AreEqual(56088.0000m, v);
215+
}
216+
217+
[TestMethod]
218+
public void DecimalArithmetic_38CapForDivisionFloorsAtSix()
219+
{
220+
// d(38,30) / d(38,30): formula gives p=107, s=69. The 38-cap with
221+
// floor 6 lands at d(38,6).
222+
AreEqual(1.000000m, ExecuteScalar("select cast(1 as decimal(38,30)) / cast(1 as decimal(38,30))"));
223+
}
224+
225+
[TestMethod]
226+
public void DecimalArithmetic_ChainedExpressionPropagatesScale()
227+
{
228+
// (d(10,2) * d(10,2)) * d(10,2) — first step yields d(21,4); next
229+
// step yields d(32,6) per the multiplication formula. Ensures the
230+
// schema/runtime parity holds through chaining.
231+
AreEqual(8.000000m, ExecuteScalar("select cast(2 as decimal(10,2)) * cast(2 as decimal(10,2)) * cast(2 as decimal(10,2))"));
232+
}
233+
234+
[TestMethod]
235+
public void DecimalArithmetic_ModuloPreservesMaxScale()
236+
{
237+
// d(10,2) % d(5,2) → d(5,2): p = min(p1-s1, p2-s2) + max(s1,s2)
238+
// = min(8, 3) + 2 = 5; s = max(s1, s2) = 2.
239+
AreEqual(1.50m, ExecuteScalar("select cast(7.5 as decimal(10,2)) % cast(3.0 as decimal(5,2))"));
240+
}
241+
178242
[TestMethod]
179243
public void Promote_DecimalAndStringInComparison()
180244
{

SqlServerSimulator/Parser/Expressions/TwoSidedExpression.cs

Lines changed: 10 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public sealed override SqlValue Run(Func<MultiPartName, SqlValue> getColumnValue
2121
protected abstract SqlValue Run(SqlValue left, SqlValue right);
2222

2323
public sealed override SqlType GetSqlType(Func<MultiPartName, SqlType> resolveColumnType) =>
24-
SqlType.Promote(left.GetSqlType(resolveColumnType), right.GetSqlType(resolveColumnType));
24+
SqlType.PromoteForArithmetic(left.GetSqlType(resolveColumnType), right.GetSqlType(resolveColumnType), this.Operator);
2525

2626
/// <summary>
2727
/// Numeric binary-operator dispatcher. Despite the name, this is the
@@ -84,68 +84,19 @@ private static NotSupportedException UnsupportedNumericPair(SqlValue left, SqlVa
8484
new($"Operator '{op}' currently supports only integer operands; got {left.Type} and {right.Type}.");
8585

8686
/// <summary>
87-
/// Decimal arithmetic with SQL Server's per-operator precision/scale
88-
/// formulas (verified against SQL Server 2025):
89-
/// <list type="bullet">
90-
/// <item><c>+</c>/<c>-</c>: <c>p = max(p1-s1, p2-s2) + max(s1,s2) + 1</c>,
91-
/// <c>s = max(s1, s2)</c></item>
92-
/// <item><c>*</c>: <c>p = p1+p2+1</c>, <c>s = s1+s2</c></item>
93-
/// <item><c>/</c>: <c>s = max(6, s1+p2+1)</c>,
94-
/// <c>p = p1-s1+s2+s</c></item>
95-
/// <item><c>%</c>: <c>p = min(p1-s1, p2-s2) + max(s1,s2)</c>,
96-
/// <c>s = max(s1, s2)</c></item>
97-
/// </list>
98-
/// Computed precision is capped at 38; excess scale is dropped, with a
99-
/// floor of 6 for division. Integer operands canonicalize to their
100-
/// equivalent decimal form (bit→(1,0), tinyint→(3,0), smallint→(5,0),
101-
/// int→(10,0), bigint→(19,0)) before the formulas apply.
87+
/// Decimal arithmetic. The result-type computation is delegated to
88+
/// <see cref="SqlType.PromoteForArithmetic"/> (which encodes the same
89+
/// per-operator scale formulas verified against SQL Server 2025), so
90+
/// the static <see cref="GetSqlType"/> path and this runtime path
91+
/// always agree on the schema. Integer / money operands canonicalize
92+
/// to their decimal equivalent before the .NET-decimal compute step.
10293
/// </summary>
10394
private protected static SqlValue DecimalArithmetic(SqlValue left, SqlValue right, char op)
10495
{
105-
var (p1, s1) = AsDecimalPrecisionScale(left.Type);
106-
var (p2, s2) = AsDecimalPrecisionScale(right.Type);
107-
108-
int resultPrecision, resultScale;
109-
switch (op)
110-
{
111-
case '+' or '-':
112-
resultPrecision = Math.Max(p1 - s1, p2 - s2) + Math.Max(s1, s2) + 1;
113-
resultScale = Math.Max(s1, s2);
114-
break;
115-
case '*':
116-
resultPrecision = p1 + p2 + 1;
117-
resultScale = s1 + s2;
118-
break;
119-
case '/':
120-
resultScale = Math.Max(6, s1 + p2 + 1);
121-
resultPrecision = p1 - s1 + s2 + resultScale;
122-
break;
123-
case '%':
124-
resultPrecision = Math.Min(p1 - s1, p2 - s2) + Math.Max(s1, s2);
125-
resultScale = Math.Max(s1, s2);
126-
break;
127-
default:
128-
throw new NotSupportedException($"Operator '{op}' on decimal operands isn't implemented.");
129-
}
96+
var resultType = (DecimalSqlType)SqlType.PromoteForArithmetic(left.Type, right.Type, op);
97+
var resultPrecision = (int)resultType.precision;
98+
var resultScale = (int)resultType.scale;
13099

131-
// 38-cap with scale-truncation. Division enforces a min scale of 6
132-
// (SQL Server's documented division behavior — verified
133-
// <c>d(38,30)/d(38,30) → d(38,6)</c>). Other operators allow scale
134-
// to drop to 0; precision is always capped first.
135-
if (resultPrecision > 38)
136-
{
137-
var minScale = op == '/' ? 6 : 0;
138-
var excess = resultPrecision - 38;
139-
resultScale = Math.Max(minScale, resultScale - excess);
140-
resultPrecision = 38;
141-
}
142-
// Defensive clamp: scale shouldn't exceed precision (theoretically the
143-
// formulas keep this invariant, but if the 38-cap dropped scale below
144-
// zero, normalize back).
145-
if (resultScale < 0)
146-
resultScale = 0;
147-
148-
var resultType = SqlType.GetDecimal(resultPrecision, resultScale);
149100
if (left.IsNull || right.IsNull)
150101
return SqlValue.Null(resultType);
151102

@@ -190,11 +141,6 @@ private protected static SqlValue DecimalArithmetic(SqlValue left, SqlValue righ
190141
: SqlValue.FromDecimal(resultType, rounded);
191142
}
192143

193-
private static (int Precision, int Scale) AsDecimalPrecisionScale(SqlType type) =>
194-
type is DecimalSqlType d ? (d.precision, d.scale)
195-
: SqlType.IsMoneyCategory(type) ? SqlType.MoneyAsDecimal(type)
196-
: SqlType.IntegerAsDecimal(type);
197-
198144
private static decimal ToDecimal(SqlValue v) =>
199145
v.Type is DecimalSqlType ? v.AsDecimal
200146
: SqlType.IsMoneyCategory(v.Type) ? v.AsMoney

SqlServerSimulator/Storage/SqlType.cs

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,8 @@ private static SqlType PromoteFromRowVersion(SqlType other) =>
308308
/// <summary>
309309
/// Joint-envelope decimal promotion used by comparison / COALESCE-style
310310
/// common-type decisions. Per-operator arithmetic precision/scale rules
311-
/// (multiplication, division, etc.) live in the binary-expression layer.
311+
/// (multiplication, division, etc.) live in
312+
/// <see cref="PromoteForArithmetic"/>.
312313
/// </summary>
313314
private static SqlType PromoteDecimalPair(DecimalSqlType a, DecimalSqlType b)
314315
{
@@ -317,6 +318,125 @@ private static SqlType PromoteDecimalPair(DecimalSqlType a, DecimalSqlType b)
317318
return GetDecimal(Math.Min(38, integerPart + scale), scale);
318319
}
319320

321+
/// <summary>
322+
/// Per-operator type promotion for binary arithmetic — the rules SQL
323+
/// Server applies to <c>+</c> / <c>-</c> / <c>*</c> / <c>/</c> / <c>%</c>
324+
/// when computing the result type. Differs from
325+
/// <see cref="Promote"/> only in the decimal-involving cases: each
326+
/// operator has its own precision / scale formula (probed against SQL
327+
/// Server 2025), and the 38-precision cap reduces scale by the excess
328+
/// down to <c>min(originalScale, 6)</c> — effectively "never below 6
329+
/// for division (which always produces scale ≥ 6 anyway)" and "never
330+
/// below the original scale for the other operators when the original
331+
/// was already ≤ 6". Non-decimal categories (integer × integer, money
332+
/// × money, float × *, etc.) reuse <see cref="Promote"/> since their
333+
/// arithmetic result type matches the joint-envelope rule.
334+
/// </summary>
335+
/// <remarks>
336+
/// Decimal scale formulas (verified against SQL Server 2025, 2026-05-08):
337+
/// <list type="bullet">
338+
/// <item><c>+</c>/<c>-</c>: <c>p = max(p1-s1, p2-s2) + max(s1,s2) + 1</c>,
339+
/// <c>s = max(s1, s2)</c></item>
340+
/// <item><c>*</c>: <c>p = p1+p2+1</c>, <c>s = s1+s2</c></item>
341+
/// <item><c>/</c>: <c>s = max(6, s1+p2+1)</c>,
342+
/// <c>p = p1-s1+s2+s</c></item>
343+
/// <item><c>%</c>: <c>p = min(p1-s1, p2-s2) + max(s1,s2)</c>,
344+
/// <c>s = max(s1, s2)</c></item>
345+
/// </list>
346+
/// Integer / money operands canonicalize to their decimal equivalent
347+
/// (bit→(1,0), tinyint→(3,0), smallint→(5,0), int→(10,0), bigint→(19,0),
348+
/// money→(19,4), smallmoney→(10,4)) before the formulas apply. Pure
349+
/// integer-pair and money-pair arithmetic skips the decimal path entirely
350+
/// — those produce wider integer / money results that match
351+
/// <see cref="Promote"/>.
352+
/// </remarks>
353+
public static SqlType PromoteForArithmetic(SqlType a, SqlType b, char op)
354+
{
355+
// Bitwise operators (&, |, ^) don't have per-operator scale rules
356+
// and don't accept decimal operands anyway — fall through to the
357+
// joint-envelope rule for type unification (decimal × bitwise will
358+
// raise the unsupported-numeric-pair error at runtime instead).
359+
if (op is '&' or '|' or '^')
360+
return Promote(a, b);
361+
362+
// Float / real win over everything else, same as the joint-envelope
363+
// path; no decimal-style scale dance needed.
364+
if (a.Category == SqlTypeCategory.Approximate || b.Category == SqlTypeCategory.Approximate)
365+
return Promote(a, b);
366+
367+
// Decimal-involving cases: the per-operator formula applies. Money
368+
// and integer canonicalize to their decimal equivalent so the
369+
// formula sees a uniform (precision, scale) pair on both sides.
370+
var aIsDecimal = a is DecimalSqlType;
371+
var bIsDecimal = b is DecimalSqlType;
372+
if (aIsDecimal || bIsDecimal)
373+
{
374+
var (p1, s1) = AsDecimalPrecisionScale(a);
375+
var (p2, s2) = AsDecimalPrecisionScale(b);
376+
return ComputeDecimalArithmeticResultType(p1, s1, p2, s2, op);
377+
}
378+
379+
// Pure integer / money / date / string pairs: arithmetic result type
380+
// matches the joint-envelope rule (e.g., int + bigint → bigint;
381+
// money + money → money; int + int → int).
382+
return Promote(a, b);
383+
}
384+
385+
/// <summary>
386+
/// Computes a decimal arithmetic result type from raw (p1, s1, p2, s2)
387+
/// quadruples. Public to <see cref="Storage"/> via
388+
/// <see cref="PromoteForArithmetic"/>; the binary-expression layer
389+
/// reuses this directly so the static (GetSqlType) and runtime
390+
/// (DecimalArithmetic) paths share one formula.
391+
/// </summary>
392+
internal static DecimalSqlType ComputeDecimalArithmeticResultType(int p1, int s1, int p2, int s2, char op)
393+
{
394+
int p, s;
395+
switch (op)
396+
{
397+
case '+' or '-':
398+
p = Math.Max(p1 - s1, p2 - s2) + Math.Max(s1, s2) + 1;
399+
s = Math.Max(s1, s2);
400+
break;
401+
case '*':
402+
p = p1 + p2 + 1;
403+
s = s1 + s2;
404+
break;
405+
case '/':
406+
s = Math.Max(6, s1 + p2 + 1);
407+
p = p1 - s1 + s2 + s;
408+
break;
409+
case '%':
410+
p = Math.Min(p1 - s1, p2 - s2) + Math.Max(s1, s2);
411+
s = Math.Max(s1, s2);
412+
break;
413+
default:
414+
throw new NotSupportedException($"Decimal arithmetic operator '{op}' isn't supported.");
415+
}
416+
417+
// 38-precision cap: scale reduces by the excess, but never below
418+
// min(originalScale, 6). For division s is always ≥ 6 so the floor
419+
// effectively becomes 6; for the other operators the floor binds
420+
// only when the original scale was already ≤ 6, preserving it.
421+
if (p > 38)
422+
{
423+
s = Math.Max(s - (p - 38), Math.Min(s, 6));
424+
p = 38;
425+
}
426+
if (s < 0) s = 0;
427+
return (DecimalSqlType)GetDecimal(p, s);
428+
}
429+
430+
/// <summary>
431+
/// Canonicalizes any decimal-arithmetic-eligible operand type to its
432+
/// (precision, scale) pair. Decimals return their declared p/s; money
433+
/// and integers map to documented equivalents.
434+
/// </summary>
435+
private static (int Precision, int Scale) AsDecimalPrecisionScale(SqlType type) =>
436+
type is DecimalSqlType d ? (d.precision, d.scale)
437+
: IsMoneyCategory(type) ? MoneyAsDecimal(type)
438+
: IntegerAsDecimal(type);
439+
320440
private static DecimalSqlType IntegerAsDecimalType(SqlType integer)
321441
{
322442
var (p, s) = IntegerAsDecimal(integer);

0 commit comments

Comments
 (0)