From 7eb9f4220826e3e2b575a97281c13790a583b29c Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Tue, 16 Jun 2026 02:09:31 +0800 Subject: [PATCH] fix(formula): register mixed double/int arithmetic overloads (#1928) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 / int`) and the engine silently returned null — build-green, empty at runtime. Register the missing `double int` / `int 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) --- .changeset/cel-mixed-numeric-overloads.md | 17 ++++++ .../app-crm/src/objects/opportunity.object.ts | 6 +- packages/formula/src/cel-engine.test.ts | 58 +++++++++++++++++++ packages/formula/src/cel-engine.ts | 4 +- packages/formula/src/stdlib.ts | 32 ++++++++++ 5 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 .changeset/cel-mixed-numeric-overloads.md diff --git a/.changeset/cel-mixed-numeric-overloads.md b/.changeset/cel-mixed-numeric-overloads.md new file mode 100644 index 0000000000..ed9f892ffe --- /dev/null +++ b/.changeset/cel-mixed-numeric-overloads.md @@ -0,0 +1,17 @@ +--- +"@objectstack/formula": patch +--- + +fix(formula): register mixed `double int` arithmetic overloads so number-field formulas compute + +cel-js types a record field number as `double` and a bare integer literal as +`int`, and ships overloads only for matching numeric pairs. So an everyday +formula like `record.amount / 100` or `record.price * 2` faulted at runtime +(`no such overload: dyn / int`); the engine caught the fault and the +formula silently evaluated to `null` — passing build, empty at runtime (#1928). + +The CEL engine now registers the missing `double int` / `int double` +overloads for `+ - * / %`, computing the result as a `double` (CEL's mixed-numeric +promotion). Pure `int op int` is untouched, so integer division (`7 / 2 == 3`) +keeps its semantics — the overloads fire only when the operands are genuinely a +`double` and an `int`. Authors no longer need the `/ 100.0` float-literal workaround. diff --git a/examples/app-crm/src/objects/opportunity.object.ts b/examples/app-crm/src/objects/opportunity.object.ts index 9241242323..19af0541b0 100644 --- a/examples/app-crm/src/objects/opportunity.object.ts +++ b/examples/app-crm/src/objects/opportunity.object.ts @@ -45,11 +45,7 @@ export const Opportunity = ObjectSchema.create({ }), expected_revenue: Field.formula({ label: 'Expected Revenue', - // NOTE: the divisor is the float literal `100.0`, not `100`. cel-js has no - // `double int` arithmetic overload, so ` / 100` - // faults at runtime and the formula silently evaluates to null. Using a - // float literal keeps both operands `double`. See objectstack-formula skill. - expression: cel`(record.amount == null ? 0.0 : record.amount) * (record.probability == null ? 0.0 : record.probability) / 100.0`, + expression: cel`(record.amount == null ? 0 : record.amount) * (record.probability == null ? 0 : record.probability) / 100`, }), close_date: Field.date({ label: 'Close Date', diff --git a/packages/formula/src/cel-engine.test.ts b/packages/formula/src/cel-engine.test.ts index d1f1b52479..97b5feced1 100644 --- a/packages/formula/src/cel-engine.test.ts +++ b/packages/formula/src/cel-engine.test.ts @@ -164,6 +164,64 @@ describe('celEngine', () => { }); }); + // #1928 — cel-js ships no `double int` arithmetic overload, so a field + // number (double) combined with a bare integer literal faulted `no such + // overload` and the formula silently evaluated to null. registerNumericCoercions + // closes the gap; these are the everyday formula shapes that were broken. + describe('mixed double/int arithmetic overloads (#1928)', () => { + it('divides a currency field by an int literal (expected_revenue shape)', () => { + const r = celEngine.evaluate( + cel('record.amount * record.probability / 100'), + { record: { amount: 120000, probability: 70 } }, + ); + expect(r).toEqual({ ok: true, value: 84000 }); + }); + + it('divides a field by an int literal', () => { + const r = celEngine.evaluate(cel('record.amount / 100'), { + record: { amount: 120000 }, + }); + expect(r).toEqual({ ok: true, value: 1200 }); + }); + + it('handles *, +, -, % between a field and an int literal', () => { + expect(celEngine.evaluate(cel('record.x * 2'), { record: { x: 5.5 } })) + .toEqual({ ok: true, value: 11 }); + expect(celEngine.evaluate(cel('record.x + 1'), { record: { x: 2.5 } })) + .toEqual({ ok: true, value: 3.5 }); + expect(celEngine.evaluate(cel('record.x - 100'), { record: { x: 250 } })) + .toEqual({ ok: true, value: 150 }); + expect(celEngine.evaluate(cel('record.x % 7'), { record: { x: 20 } })) + .toEqual({ ok: true, value: 6 }); + }); + + it('handles the int-literal on the left (int op double)', () => { + const r = celEngine.evaluate(cel('100 - record.x'), { + record: { x: 40 }, + }); + expect(r).toEqual({ ok: true, value: 60 }); + }); + + it('leaves pure int/int arithmetic as integer division (7 / 2 == 3)', () => { + const r = celEngine.evaluate(cel('7 / 2'), {}); + expect(r).toEqual({ ok: true, value: 3 }); + }); + + it('still evaluates double/double field arithmetic', () => { + const r = celEngine.evaluate(cel('record.a / record.b'), { + record: { a: 10, b: 4 }, + }); + expect(r).toEqual({ ok: true, value: 2.5 }); + }); + + it('composes with string-field hydration (currency string + int literal)', () => { + const r = celEngine.evaluate(cel('record.amount + 1'), { + record: { amount: '120000.00' }, + }); + expect(r).toEqual({ ok: true, value: 120001 }); + }); + }); + // ADR-0032 §1c — string-serialized date/datetime fields (#1530). Field.date // serializes to "YYYY-MM-DD" and Field.datetime to a full ISO string; cel-js // compares those raw strings against the google.protobuf.Timestamp returned by diff --git a/packages/formula/src/cel-engine.ts b/packages/formula/src/cel-engine.ts index 4e852aa5e8..59b759fcf9 100644 --- a/packages/formula/src/cel-engine.ts +++ b/packages/formula/src/cel-engine.ts @@ -16,7 +16,7 @@ import { Environment } from '@marcbachmann/cel-js'; import type { Expression } from '@objectstack/spec'; -import { buildScope, registerStdLib } from './stdlib'; +import { buildScope, registerNumericCoercions, registerStdLib } from './stdlib'; import type { DialectEngine, EvalContext, EvalResult } from './types'; /** @@ -38,7 +38,7 @@ function buildEnv(now: () => Date): Environment { enableOptionalTypes: true, limits: DEFAULT_LIMITS, }); - return registerStdLib(env, now); + return registerNumericCoercions(registerStdLib(env, now)); } /** Coerce cel-js's BigInt-flavored return into spec-friendly JS values. */ diff --git a/packages/formula/src/stdlib.ts b/packages/formula/src/stdlib.ts index ffb9df2253..fff0ddccf8 100644 --- a/packages/formula/src/stdlib.ts +++ b/packages/formula/src/stdlib.ts @@ -104,6 +104,38 @@ export function registerStdLib( ); } +/** + * Register mixed `double int` / `int double` arithmetic overloads. + * + * cel-js types a record field number as `double` and a bare integer literal as + * `int`, and ships overloads only for matching pairs (`double op double`, + * `int op int`). So a formula as ordinary as `record.amount / 100` or + * `record.price * 2` faults at runtime (`no such overload: dyn / int`); + * the engine catches the fault and the formula silently evaluates to `null` + * (#1928). Authors then have to know the cel-js quirk and write `/ 100.0`. + * + * We close the gap by registering the missing mixed overloads. The result is + * always computed as a JS `double`, matching CEL's promotion rule for mixed + * numeric arithmetic. Pure `int op int` is untouched, so integer division + * (`7 / 2 == 3`) keeps its semantics — these overloads only fire when the two + * operands are genuinely a `double` and an `int`. + */ +export function registerNumericCoercions(env: Environment): Environment { + const ops: Record number> = { + '+': (a, b) => a + b, + '-': (a, b) => a - b, + '*': (a, b) => a * b, + '/': (a, b) => a / b, + '%': (a, b) => a % b, + }; + for (const [op, fn] of Object.entries(ops)) { + const impl = (a: unknown, b: unknown) => fn(Number(a), Number(b)); + env.registerOperator(`double ${op} int`, impl); + env.registerOperator(`int ${op} double`, impl); + } + return env; +} + /** * Build the variable scope for a single evaluation. Absent fields are simply * not bound — CEL macros (`has(record.foo)`) handle missing-key safely.