Skip to content

Commit 7eb9f42

Browse files
os-zhuangclaude
andcommitted
fix(formula): register mixed double/int arithmetic overloads (#1928)
cel-js types a record field number as `double` and a bare integer literal as `int`, with overloads only for matching pairs. An everyday formula like `record.amount / 100` or `record.price * 2` therefore faulted at runtime (`no such overload: dyn<double> / int`) and the engine silently returned null — build-green, empty at runtime. Register the missing `double <op> int` / `int <op> double` overloads for `+ - * / %` (result computed as double, per CEL mixed-numeric promotion). Pure int/int is untouched, so integer division (`7 / 2 == 3`) is preserved; overloads fire only on a genuine double×int pair. Composes with the existing string-field hydration retry (`"120000.00" + 1 == 120001`). Reverts the `/ 100.0` float-literal workaround in example-crm's `expected_revenue` back to plain `/ 100` now that it computes natively. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1104c24 commit 7eb9f42

5 files changed

Lines changed: 110 additions & 7 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/formula": patch
3+
---
4+
5+
fix(formula): register mixed `double <op> int` arithmetic overloads so number-field formulas compute
6+
7+
cel-js types a record field number as `double` and a bare integer literal as
8+
`int`, and ships overloads only for matching numeric pairs. So an everyday
9+
formula like `record.amount / 100` or `record.price * 2` faulted at runtime
10+
(`no such overload: dyn<double> / int`); the engine caught the fault and the
11+
formula silently evaluated to `null` — passing build, empty at runtime (#1928).
12+
13+
The CEL engine now registers the missing `double <op> int` / `int <op> double`
14+
overloads for `+ - * / %`, computing the result as a `double` (CEL's mixed-numeric
15+
promotion). Pure `int op int` is untouched, so integer division (`7 / 2 == 3`)
16+
keeps its semantics — the overloads fire only when the operands are genuinely a
17+
`double` and an `int`. Authors no longer need the `/ 100.0` float-literal workaround.

examples/app-crm/src/objects/opportunity.object.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,7 @@ export const Opportunity = ObjectSchema.create({
4545
}),
4646
expected_revenue: Field.formula({
4747
label: 'Expected Revenue',
48-
// NOTE: the divisor is the float literal `100.0`, not `100`. cel-js has no
49-
// `double <op> int` arithmetic overload, so `<currency/number field> / 100`
50-
// faults at runtime and the formula silently evaluates to null. Using a
51-
// float literal keeps both operands `double`. See objectstack-formula skill.
52-
expression: cel`(record.amount == null ? 0.0 : record.amount) * (record.probability == null ? 0.0 : record.probability) / 100.0`,
48+
expression: cel`(record.amount == null ? 0 : record.amount) * (record.probability == null ? 0 : record.probability) / 100`,
5349
}),
5450
close_date: Field.date({
5551
label: 'Close Date',

packages/formula/src/cel-engine.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,64 @@ describe('celEngine', () => {
164164
});
165165
});
166166

167+
// #1928 — cel-js ships no `double <op> int` arithmetic overload, so a field
168+
// number (double) combined with a bare integer literal faulted `no such
169+
// overload` and the formula silently evaluated to null. registerNumericCoercions
170+
// closes the gap; these are the everyday formula shapes that were broken.
171+
describe('mixed double/int arithmetic overloads (#1928)', () => {
172+
it('divides a currency field by an int literal (expected_revenue shape)', () => {
173+
const r = celEngine.evaluate(
174+
cel('record.amount * record.probability / 100'),
175+
{ record: { amount: 120000, probability: 70 } },
176+
);
177+
expect(r).toEqual({ ok: true, value: 84000 });
178+
});
179+
180+
it('divides a field by an int literal', () => {
181+
const r = celEngine.evaluate(cel('record.amount / 100'), {
182+
record: { amount: 120000 },
183+
});
184+
expect(r).toEqual({ ok: true, value: 1200 });
185+
});
186+
187+
it('handles *, +, -, % between a field and an int literal', () => {
188+
expect(celEngine.evaluate(cel('record.x * 2'), { record: { x: 5.5 } }))
189+
.toEqual({ ok: true, value: 11 });
190+
expect(celEngine.evaluate(cel('record.x + 1'), { record: { x: 2.5 } }))
191+
.toEqual({ ok: true, value: 3.5 });
192+
expect(celEngine.evaluate(cel('record.x - 100'), { record: { x: 250 } }))
193+
.toEqual({ ok: true, value: 150 });
194+
expect(celEngine.evaluate(cel('record.x % 7'), { record: { x: 20 } }))
195+
.toEqual({ ok: true, value: 6 });
196+
});
197+
198+
it('handles the int-literal on the left (int op double)', () => {
199+
const r = celEngine.evaluate(cel('100 - record.x'), {
200+
record: { x: 40 },
201+
});
202+
expect(r).toEqual({ ok: true, value: 60 });
203+
});
204+
205+
it('leaves pure int/int arithmetic as integer division (7 / 2 == 3)', () => {
206+
const r = celEngine.evaluate(cel('7 / 2'), {});
207+
expect(r).toEqual({ ok: true, value: 3 });
208+
});
209+
210+
it('still evaluates double/double field arithmetic', () => {
211+
const r = celEngine.evaluate(cel('record.a / record.b'), {
212+
record: { a: 10, b: 4 },
213+
});
214+
expect(r).toEqual({ ok: true, value: 2.5 });
215+
});
216+
217+
it('composes with string-field hydration (currency string + int literal)', () => {
218+
const r = celEngine.evaluate(cel('record.amount + 1'), {
219+
record: { amount: '120000.00' },
220+
});
221+
expect(r).toEqual({ ok: true, value: 120001 });
222+
});
223+
});
224+
167225
// ADR-0032 §1c — string-serialized date/datetime fields (#1530). Field.date
168226
// serializes to "YYYY-MM-DD" and Field.datetime to a full ISO string; cel-js
169227
// compares those raw strings against the google.protobuf.Timestamp returned by

packages/formula/src/cel-engine.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import { Environment } from '@marcbachmann/cel-js';
1717
import type { Expression } from '@objectstack/spec';
1818

19-
import { buildScope, registerStdLib } from './stdlib';
19+
import { buildScope, registerNumericCoercions, registerStdLib } from './stdlib';
2020
import type { DialectEngine, EvalContext, EvalResult } from './types';
2121

2222
/**
@@ -38,7 +38,7 @@ function buildEnv(now: () => Date): Environment {
3838
enableOptionalTypes: true,
3939
limits: DEFAULT_LIMITS,
4040
});
41-
return registerStdLib(env, now);
41+
return registerNumericCoercions(registerStdLib(env, now));
4242
}
4343

4444
/** Coerce cel-js's BigInt-flavored return into spec-friendly JS values. */

packages/formula/src/stdlib.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,38 @@ export function registerStdLib(
104104
);
105105
}
106106

107+
/**
108+
* Register mixed `double <op> int` / `int <op> double` arithmetic overloads.
109+
*
110+
* cel-js types a record field number as `double` and a bare integer literal as
111+
* `int`, and ships overloads only for matching pairs (`double op double`,
112+
* `int op int`). So a formula as ordinary as `record.amount / 100` or
113+
* `record.price * 2` faults at runtime (`no such overload: dyn<double> / int`);
114+
* the engine catches the fault and the formula silently evaluates to `null`
115+
* (#1928). Authors then have to know the cel-js quirk and write `/ 100.0`.
116+
*
117+
* We close the gap by registering the missing mixed overloads. The result is
118+
* always computed as a JS `double`, matching CEL's promotion rule for mixed
119+
* numeric arithmetic. Pure `int op int` is untouched, so integer division
120+
* (`7 / 2 == 3`) keeps its semantics — these overloads only fire when the two
121+
* operands are genuinely a `double` and an `int`.
122+
*/
123+
export function registerNumericCoercions(env: Environment): Environment {
124+
const ops: Record<string, (a: number, b: number) => number> = {
125+
'+': (a, b) => a + b,
126+
'-': (a, b) => a - b,
127+
'*': (a, b) => a * b,
128+
'/': (a, b) => a / b,
129+
'%': (a, b) => a % b,
130+
};
131+
for (const [op, fn] of Object.entries(ops)) {
132+
const impl = (a: unknown, b: unknown) => fn(Number(a), Number(b));
133+
env.registerOperator(`double ${op} int`, impl);
134+
env.registerOperator(`int ${op} double`, impl);
135+
}
136+
return env;
137+
}
138+
107139
/**
108140
* Build the variable scope for a single evaluation. Absent fields are simply
109141
* not bound — CEL macros (`has(record.foo)`) handle missing-key safely.

0 commit comments

Comments
 (0)