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
11 changes: 11 additions & 0 deletions .changeset/console-3b2e4d98d904.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/console": minor
---

Console (objectui) refreshed to `3b2e4d98d904`. Frontend changes in this range:

- fix(list): route remaining system-field groupings through shared classifier (#2706)
- feat(console): user-import wizard defaults to the `auto` password policy (tracks framework#3236) (#2701)
- feat(flow-designer): schema-driven keyValue + numberList mapping (#3304) (#2708)

objectui range: `0318118e02fd...3b2e4d98d904`
15 changes: 15 additions & 0 deletions .changeset/formula-null-guard-floor-date-arith-3306.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@objectstack/formula": minor
---

**Stored `Field.formula` fields that compute dates/durations no longer silently evaluate to `null` (#3306).** Three independent CEL gaps made shipped template formulas (e.g. `hr_employee.tenure_years`, `hr_time_off_request.days`) return `null` with no parse/build/runtime error:

1. **The null-guard idiom `cond ? <value> : null` now compiles and evaluates.** cel-js's ternary type-unifier rejects a concrete `int`/`double`/`string` branch against `null` — so even `true ? 5 : null` faulted *"Ternary branches must have the same type"* and the whole formula nulled. A `Field.formula` is inherently nullable and the catalog blesses both ternary and `== null`, so this is the canonical "compute value, else blank" shape. An AST pre-pass (mirroring the #3183 temporal-equality rewrite) wraps the non-null branch in `dyn(...)` — value-preserving, null-branch-only, idempotent — so it type-checks and runs. Applied in `compile()`, `evaluate()`, and the build soundness check alike.

2. **`floor(x)` / `ceil(x)` are now registered** (parallel to `round`/`abs`) and advertised in the catalog. They round toward −∞ / +∞, so `floor(-1.2) == -2` — NOT interchangeable with integer division's round-toward-zero. Previously `floor(...)` faulted `found no matching overload` and the formula nulled.

3. **Date arithmetic is now a build-time ERROR instead of a silent runtime `null`.** `record.end_date - record.start_date + 1`, `today() + 30`, `record.date + n` type-check clean (operands are `dyn`) but always fault at runtime and never recover (a date string is not numeric, so hydration can't rescue it). The build soundness check now types `date`/`datetime` fields as `google.protobuf.Timestamp` and flags date/duration **arithmetic against a number** with a corrective message pointing at `daysBetween(a, b)` / `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)`. Sound by construction — ordering (`date < today()`, `date < "2026-01-01"` string-lex), equality (#3183), and string concatenation (`"Due: " + date`) are all runtime-tolerated and never flagged; only arithmetic against a number is. A `!= null` guard on a date field no longer masks the inner fault (`== null` no-op overloads registered in the check-only env).

> **Heads-up for downstream:** (3) adds a NEW build-time error. A stored formula or predicate doing arithmetic on a `date`/`datetime` field (`end - start + 1`, `today() + 30`) that previously built (and nulled at runtime) will now fail `objectstack build` / `validateStackExpressions` with a message telling you to use `daysBetween` / `daysFromNow` / `addDays`. This only fires for genuinely-broken expressions that already returned `null`.

Fixes #3306.
2 changes: 1 addition & 1 deletion .objectui-sha
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0318118e02fd0ef377492f3b09b687a84cccc908
3b2e4d98d904d695a8372c394d46b81673011270
53 changes: 52 additions & 1 deletion packages/formula/src/cel-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,9 +465,17 @@ describe('celEngine', () => {
expect(r).toEqual({ ok: true, value: true });
});

it('abs / round / min / max', () => {
it('abs / round / floor / ceil / min / max', () => {
expect(celEngine.evaluate(cel('abs(record.x)'), { record: { x: -3.5 } })).toEqual({ ok: true, value: 3.5 });
expect(celEngine.evaluate(cel('round(2.6)'), {})).toEqual({ ok: true, value: 3 });
expect(celEngine.evaluate(cel('floor(6.7)'), {})).toEqual({ ok: true, value: 6 });
expect(celEngine.evaluate(cel('ceil(6.1)'), {})).toEqual({ ok: true, value: 7 });
// floor/ceil round toward −∞/+∞, NOT toward zero — the whole reason they are
// not interchangeable with integer division (#3306).
expect(celEngine.evaluate(cel('floor(-1.2)'), {})).toEqual({ ok: true, value: -2 });
expect(celEngine.evaluate(cel('ceil(-1.2)'), {})).toEqual({ ok: true, value: -1 });
// A record number field arrives as a cel-js `double`; `floor` must accept it.
expect(celEngine.evaluate(cel('floor(record.x / 365.0)'), { record: { x: 2318 } })).toEqual({ ok: true, value: 6 });
expect(celEngine.evaluate(cel('min(record.a, record.b)'), { record: { a: 3, b: 7 } })).toEqual({ ok: true, value: 3 });
expect(celEngine.evaluate(cel('max(record.a, record.b)'), { record: { a: 3, b: 7 } })).toEqual({ ok: true, value: 7 });
});
Expand Down Expand Up @@ -495,6 +503,7 @@ describe('celEngine', () => {
daysBetween: 'daysBetween(today(), daysFromNow(7))', date: 'date("2026-03-15")',
addDays: 'addDays(today(), 7)', addMonths: 'addMonths(today(), 3)',
datetime: 'datetime("2026-03-15T08:00:00Z")', abs: 'abs(-3.5)', round: 'round(2.6)',
floor: 'floor(6.7)', ceil: 'ceil(6.1)',
min: 'min(1, 2)', max: 'max(1, 2)', upper: 'upper("hi")', lower: 'lower("HI")',
trim: 'trim(" x ")', contains: 'contains("hello", "ell")', startsWith: 'startsWith("hi", "h")',
endsWith: 'endsWith("hi", "i")', matches: 'matches("a1", "a.")', joinNonEmpty: 'joinNonEmpty(["a", "b"], "-")',
Expand Down Expand Up @@ -588,4 +597,46 @@ describe('celEngine', () => {
.toEqual({ ok: true, value: false });
});
});

// #3306 — the blessed null-guard idiom `cond ? <value> : null`. cel-js's ternary
// unifier rejects a concrete branch against `null`; the engine's AST rewrite
// wraps the non-null branch in `dyn(...)` so it compiles AND evaluates, and the
// null branch still yields null.
describe('null-guarded formula idiom (#3306)', () => {
const now = new Date('2026-07-20T00:00:00Z');

it('`cond ? <number> : null` evaluates instead of silently nulling', () => {
expect(celEngine.evaluate(cel('true ? 5 : null'), {})).toEqual({ ok: true, value: 5 });
expect(celEngine.evaluate(cel('false ? 5 : null'), {})).toEqual({ ok: true, value: null });
// null-first order (either branch may be the null literal).
expect(celEngine.evaluate(cel('true ? null : 5'), {})).toEqual({ ok: true, value: null });
});

it('compiles the idiom that used to fault type-checking', () => {
// Was: ERR[type] "Ternary branches must have the same type, got 'int' and 'null'".
expect(celEngine.compile('true ? 5 : null').ok).toBe(true);
expect(celEngine.compile('cond ? daysBetween(a, b) + 1 : null').ok).toBe(true);
});

it('the shipped hr templates now compute (tenure_years, time_off.days)', () => {
// tenure_years — daysBetween/365 integer division, null when hire_date unset.
const tenure = cel('record.hire_date != null ? daysBetween(record.hire_date, today()) / 365 : null');
expect(celEngine.evaluate(tenure, { now, record: { hire_date: '2020-03-15' } }))
.toEqual({ ok: true, value: 6 });
expect(celEngine.evaluate(tenure, { now, record: { hire_date: null } }))
.toEqual({ ok: true, value: null });
// time_off.days — inclusive calendar-day span.
const days = cel('record.start_date != null && record.end_date != null ? daysBetween(record.start_date, record.end_date) + 1 : null');
expect(celEngine.evaluate(days, { now, record: { start_date: '2026-06-20', end_date: '2026-06-24' } }))
.toEqual({ ok: true, value: 5 });
});

it('leaves a genuine type mismatch and same-typed / non-null ternaries alone', () => {
// Not a null-guard → still an honest type error (we only relax `… : null`).
expect(celEngine.compile('true ? "a" : 5').ok).toBe(false);
// Same-typed branches never needed the rewrite and are unchanged.
expect(celEngine.evaluate(cel('true ? "a" : "b"'), {})).toEqual({ ok: true, value: 'a' });
expect(celEngine.evaluate(cel('false ? 1 : 2'), {})).toEqual({ ok: true, value: 2 });
});
});
});
Loading
Loading