Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/cel-mixed-numeric-overloads.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
---
"@objectstack/formula": patch
---

fix(formula): register mixed `double <op> 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<double> / 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 <op> int` / `int <op> 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.
6 changes: 1 addition & 5 deletions examples/app-crm/src/objects/opportunity.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <op> int` arithmetic overload, so `<currency/number field> / 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',
Expand Down
58 changes: 58 additions & 0 deletions packages/formula/src/cel-engine.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -164,6 +164,64 @@ describe('celEngine', () => {
});
});

// #1928 — cel-js ships no `double <op> 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
Expand Down
4 changes: 2 additions & 2 deletions packages/formula/src/cel-engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

/**
Expand All@@ -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. */
Expand Down
32 changes: 32 additions & 0 deletions packages/formula/src/stdlib.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -104,6 +104,38 @@ export function registerStdLib(
);
}

/**
* Register mixed `double <op> int` / `int <op> 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<double> / 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<string, (a: number, b: number) => 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.
Expand Down
Loading