Skip to content

Commit ef3ed67

Browse files
os-zhuangclaude
andauthored
feat: formula field typing — inferExpressionType + FieldSchema.returnType (#2255)
* feat(formula): infer a value/formula expression's coarse return type Surfaces the result type cel-js's type-checker already computes (and that `celEngine.compile` discarded): new `inferExpressionType()` maps a CEL value/formula expression onto `number | text | boolean | date | unknown`, plus the lower-level `inferCelType()` on the engine. Conservative by construction — a member access or two `dyn` operands stay `dyn` → `unknown` (e.g. `a + b`, which could be string concat, is NOT called numeric), while a typed literal or stdlib return pins it (`daysBetween(start,end)+1` → number). The motivating consumer is dataset-derive's measure-eligibility: a `formula` field gets a SUM measure ONLY when its expression is provably numeric, so an AI-built "total of a computed number" dashboard card becomes buildable without ever minting an incoherent sum-of-text measure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(spec): FieldSchema.returnType — a formula field's declared value type Lets a formula field carry the value type it computes (number/text/boolean/ date), stamped at authoring from the inferred CEL type. Consumers (dataset measures, display formatting, validation) read the declared type rather than re-parsing the expression. Pairs with @objectstack/formula inferExpressionType. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4d99a5c commit ef3ed67

7 files changed

Lines changed: 147 additions & 4 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/formula": minor
3+
"@objectstack/spec": minor
4+
---
5+
6+
Formula field typing: `inferExpressionType()` + a declared `returnType`.
7+
8+
- `@objectstack/formula`: new `inferExpressionType()` (and lower-level `inferCelType()`) surfaces the cel-js type-checker's result for a CEL value/formula expression, mapped to `number | text | boolean | date | unknown`. Conservative — two `dyn` operands stay `unknown`; typed literals/stdlib returns pin a concrete type.
9+
- `@objectstack/spec`: `FieldSchema` gains an optional `returnType` (`number|text|boolean|date`) so a formula field can carry its declared value type (the way Salesforce/Airtable do), letting consumers (dataset measures, formatting, validation) read a declared type instead of re-parsing the expression.

packages/formula/src/cel-engine.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,42 @@ export function firstUndeclaredReference(
126126
return null;
127127
}
128128

129+
/**
130+
* The result type cel-js's type-checker infers for a `value`/`predicate`
131+
* expression — its raw CEL type name (`'int'`, `'double'`, `'string'`, `'bool'`,
132+
* `'google.protobuf.Timestamp'`, `'dyn'`, …) — or `null` when the expression does
133+
* not type-check. Reuses the SAME record-scoped, stdlib-registered env as
134+
* {@link firstUndeclaredReference}: namespace roots (`record`, `previous`, …) are
135+
* declared `map` and `knownFields` are declared `dyn`, so both `record.<field>`
136+
* and bare `<field>` references resolve while every stdlib call carries its
137+
* declared return type.
138+
*
139+
* Deliberately conservative. A member access (`record.amount`) or a bare field is
140+
* `dyn`, and an operator over two `dyn` operands stays `dyn` (cel-js cannot prove
141+
* it numeric), so `record.a + record.b` — which could be string concatenation —
142+
* infers `dyn`, not a number. A typed literal or a stdlib return DOES pin the
143+
* type, so the common computed-number formulas resolve concretely:
144+
* `daysBetween(start_date, end_date) + 1` → `int`, `amount * 0.1` → `double`. A
145+
* caller keying off a concrete numeric type therefore never mis-classifies an
146+
* ambiguous formula.
147+
*/
148+
export function inferCelType(source: string, knownFields: readonly string[] = []): string | null {
149+
if (typeof source !== 'string' || !source.trim()) return null;
150+
try {
151+
const env = knownFields.length === 0
152+
? (recordScopeEnv ??= buildScopedEnv([]))
153+
: buildScopedEnv(knownFields);
154+
const result = env.parse(source).check?.() as
155+
| { valid?: boolean; type?: unknown }
156+
| undefined;
157+
if (!result || result.valid === false) return null;
158+
return typeof result.type === 'string' ? result.type : null;
159+
} catch {
160+
// Parse/other faults mean we cannot prove a type — the conservative `null`.
161+
return null;
162+
}
163+
}
164+
129165
/** @deprecated use {@link firstUndeclaredReference} with no fields. */
130166
export function detectBareReference(source: string): string | null {
131167
return firstUndeclaredReference(source);

packages/formula/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export type { CelFilterCompileResult, CelFilterCompileOptions, CelFilterFailReas
2424
export { matchesFilterCondition } from './matches-filter';
2525
// ADR-0032 — shared validator + introspection (one validator for build,
2626
// registration, and the agent-callable validate_expression tool).
27-
export { validateExpression, introspectScope, expectedDialect, CEL_STDLIB_FUNCTIONS } from './validate';
28-
export type { FieldRole, ExprSchemaHint, ExprValidationError, ExprValidationResult } from './validate';
27+
export { validateExpression, introspectScope, expectedDialect, inferExpressionType, CEL_STDLIB_FUNCTIONS } from './validate';
28+
export type { FieldRole, ExprInput, ExprSchemaHint, ExprValidationError, ExprValidationResult, InferredValueType } from './validate';
2929
export type { SeedValue, SeedPrimitive } from './seed-eval';
3030
export type { DialectEngine, EvalContext, EvalResult, EvalError } from './types';

packages/formula/src/validate.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { validateExpression, introspectScope, expectedDialect } from './validate';
2+
import { validateExpression, introspectScope, expectedDialect, inferExpressionType } from './validate';
33

44
describe('validateExpression (ADR-0032)', () => {
55
describe('predicates (CEL)', () => {
@@ -165,3 +165,45 @@ describe('validateExpression (ADR-0032)', () => {
165165
});
166166
});
167167
});
168+
169+
describe('inferExpressionType — coarse value-type of a formula', () => {
170+
// The host object's fields, so a bare `<field>` reference resolves the same as
171+
// `record.<field>` (a stored formula may be written either way).
172+
const fields = ['start_date', 'end_date', 'amount', 'rate', 'first', 'last', 'name', 'items'];
173+
174+
it('infers number for a computed-number formula (the leave_days repro)', () => {
175+
// daysBetween(...): int, int + 1 → int → number. The exact case a "total
176+
// leave days" dashboard card needs a SUM measure derived for.
177+
expect(inferExpressionType('daysBetween(start_date, end_date) + 1', { fields })).toBe('number');
178+
expect(inferExpressionType('daysBetween(record.start_date, record.end_date) + 1')).toBe('number');
179+
expect(inferExpressionType('amount * 0.1', { fields })).toBe('number'); // dyn * double → double
180+
expect(inferExpressionType('round(amount)', { fields })).toBe('number');
181+
expect(inferExpressionType('len(items)', { fields })).toBe('number');
182+
});
183+
184+
it('accepts the canonical Expression envelope as input', () => {
185+
expect(inferExpressionType({ dialect: 'cel', source: 'amount * 0.1' }, { fields })).toBe('number');
186+
});
187+
188+
it('infers text / boolean / date for non-numeric formulas', () => {
189+
expect(inferExpressionType('upper(name)', { fields })).toBe('text');
190+
expect(inferExpressionType('rate >= 0.5', { fields })).toBe('boolean');
191+
expect(inferExpressionType('today()')).toBe('date');
192+
});
193+
194+
it('is conservative — an ambiguous (dyn) result is unknown, never number', () => {
195+
// `first + last` could be string concatenation OR numeric addition; with two
196+
// untyped operands cel-js yields `dyn`, so we must NOT call it a number (else
197+
// a dataset would SUM a text formula). This is the safety property.
198+
expect(inferExpressionType('first + last', { fields })).toBe('unknown');
199+
expect(inferExpressionType('amount + rate', { fields })).toBe('unknown');
200+
});
201+
202+
it('returns unknown for empty, absent, or un-type-checkable expressions', () => {
203+
expect(inferExpressionType('')).toBe('unknown');
204+
expect(inferExpressionType(null)).toBe('unknown');
205+
expect(inferExpressionType(undefined)).toBe('unknown');
206+
expect(inferExpressionType('no_such_fn(amount)', { fields })).toBe('unknown'); // no overload
207+
expect(inferExpressionType('undeclared_field + 1')).toBe('unknown'); // bare ref, no fields given
208+
});
209+
});

packages/formula/src/validate.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
* This validator detects that specific mistake and returns the exact fix.
1818
*/
1919

20-
import { celEngine, firstUndeclaredReference } from './cel-engine';
20+
import { celEngine, firstUndeclaredReference, inferCelType } from './cel-engine';
2121
import { templateEngine } from './template-engine';
2222

2323
export type FieldRole = 'predicate' | 'value' | 'template';
@@ -256,6 +256,49 @@ export function introspectScope(role: FieldRole, schema?: ExprSchemaHint): {
256256
};
257257
}
258258

259+
/**
260+
* Coarse value categories a `value`/formula expression can compute. `'unknown'`
261+
* means cel-js could not prove a concrete type — either a `dyn` result (an
262+
* ambiguous expression over untyped operands) or one that does not type-check.
263+
*/
264+
export type InferredValueType = 'number' | 'text' | 'boolean' | 'date' | 'unknown';
265+
266+
/** Map a cel-js type-checker type name onto an ObjectStack field value category. */
267+
function celTypeToValueType(celType: string | null): InferredValueType {
268+
switch (celType) {
269+
case 'int':
270+
case 'uint':
271+
case 'double':
272+
return 'number';
273+
case 'string':
274+
return 'text';
275+
case 'bool':
276+
return 'boolean';
277+
case 'google.protobuf.Timestamp':
278+
return 'date';
279+
default:
280+
// `dyn`, `google.protobuf.Duration`, list/map, null, or un-type-checkable.
281+
return 'unknown';
282+
}
283+
}
284+
285+
/**
286+
* Infer the coarse value type a `value`/formula expression computes — `'number'`,
287+
* `'text'`, `'boolean'`, `'date'`, or `'unknown'` when cel-js cannot prove a
288+
* concrete type. `schema.fields` (the host object's field names) are declared so
289+
* a bare `<field>` reference resolves the same as `record.<field>`.
290+
*
291+
* The motivating use is measure-eligibility: a dataset derives a SUM measure for
292+
* a `formula` field ONLY when this returns `'number'`, so an ambiguous or
293+
* non-numeric formula never yields an incoherent measure. Conservative by
294+
* construction — see {@link inferCelType}.
295+
*/
296+
export function inferExpressionType(input: ExprInput, schema?: ExprSchemaHint): InferredValueType {
297+
const { source } = toSource(input);
298+
if (!source.trim()) return 'unknown';
299+
return celTypeToValueType(inferCelType(source, schema?.fields));
300+
}
301+
259302
/**
260303
* Public catalog of CEL functions available in expressions — what `introspectScope`
261304
* advertises to authors (incl. AI). Every entry MUST actually resolve at runtime:

packages/spec/liveness/field.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@
5858
"evidence": "packages/objectql/src/engine.ts",
5959
"note": "formula."
6060
},
61+
"returnType": {
62+
"status": "live",
63+
"note": "declared value type of a formula field, stamped at authoring from the inferred CEL type; read by cloud service-ai-studio dataset-derive for formula measure-eligibility (and display formatting). Derivable from `expression` but cached so consumers needn't re-parse — the Salesforce/Airtable typed-formula-field pattern."
64+
},
6165
"summaryOperations": {
6266
"status": "live",
6367
"evidence": "packages/objectql/src/engine.ts",

packages/spec/src/data/field.zod.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,15 @@ export const FieldSchema = lazySchema(() => z.object({
477477

478478
/** Calculation — CEL formula. Plain string accepted for back-compat; build emits canonical envelope. */
479479
expression: ExpressionInputSchema.optional().describe('Formula expression (CEL). e.g. F`record.amount * 0.1`'),
480+
/**
481+
* The value type a `formula` field computes, declared at authoring (the way
482+
* Salesforce/Airtable carry a formula's result type). Lets consumers — dataset
483+
* measures, display formatting, validation — read a declared type instead of
484+
* re-parsing the expression. Authoring stamps it from the inferred CEL type;
485+
* absent when the type can't be proven (an ambiguous/`dyn` expression).
486+
*/
487+
returnType: z.enum(['number', 'text', 'boolean', 'date']).optional()
488+
.describe('Inferred value type of a formula field (number/text/boolean/date)'),
480489
summaryOperations: z.object({
481490
object: z.string().describe('Source child object name for roll-up'),
482491
field: z.string().describe('Field on child object to aggregate (ignored for count)'),

0 commit comments

Comments
 (0)