From 9537b32a8169d0725290984a4f4327d05fcf91fe Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 24 Jun 2026 10:15:56 +0800 Subject: [PATCH 1/2] feat(formula): infer a value/formula expression's coarse return type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/formula-infer-value-type.md | 5 +++ packages/formula/src/cel-engine.ts | 36 +++++++++++++++++++++ packages/formula/src/index.ts | 4 +-- packages/formula/src/validate.test.ts | 44 ++++++++++++++++++++++++- packages/formula/src/validate.ts | 45 +++++++++++++++++++++++++- 5 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 .changeset/formula-infer-value-type.md diff --git a/.changeset/formula-infer-value-type.md b/.changeset/formula-infer-value-type.md new file mode 100644 index 0000000000..ae84403ca6 --- /dev/null +++ b/.changeset/formula-infer-value-type.md @@ -0,0 +1,5 @@ +--- +"@objectstack/formula": minor +--- + +Add `inferExpressionType()` (and the lower-level `inferCelType()`): infer the coarse return type (`number | text | boolean | date | unknown`) of a CEL value/formula expression by surfacing the cel-js type-checker result. Conservative — two `dyn` operands stay `unknown`, while typed literals/stdlib returns pin a concrete type. Enables numeric-formula measure-eligibility in downstream dataset derivation. diff --git a/packages/formula/src/cel-engine.ts b/packages/formula/src/cel-engine.ts index 56b0fcbac6..27e8653c38 100644 --- a/packages/formula/src/cel-engine.ts +++ b/packages/formula/src/cel-engine.ts @@ -126,6 +126,42 @@ export function firstUndeclaredReference( return null; } +/** + * The result type cel-js's type-checker infers for a `value`/`predicate` + * expression — its raw CEL type name (`'int'`, `'double'`, `'string'`, `'bool'`, + * `'google.protobuf.Timestamp'`, `'dyn'`, …) — or `null` when the expression does + * not type-check. Reuses the SAME record-scoped, stdlib-registered env as + * {@link firstUndeclaredReference}: namespace roots (`record`, `previous`, …) are + * declared `map` and `knownFields` are declared `dyn`, so both `record.` + * and bare `` references resolve while every stdlib call carries its + * declared return type. + * + * Deliberately conservative. A member access (`record.amount`) or a bare field is + * `dyn`, and an operator over two `dyn` operands stays `dyn` (cel-js cannot prove + * it numeric), so `record.a + record.b` — which could be string concatenation — + * infers `dyn`, not a number. A typed literal or a stdlib return DOES pin the + * type, so the common computed-number formulas resolve concretely: + * `daysBetween(start_date, end_date) + 1` → `int`, `amount * 0.1` → `double`. A + * caller keying off a concrete numeric type therefore never mis-classifies an + * ambiguous formula. + */ +export function inferCelType(source: string, knownFields: readonly string[] = []): string | null { + if (typeof source !== 'string' || !source.trim()) return null; + try { + const env = knownFields.length === 0 + ? (recordScopeEnv ??= buildScopedEnv([])) + : buildScopedEnv(knownFields); + const result = env.parse(source).check?.() as + | { valid?: boolean; type?: unknown } + | undefined; + if (!result || result.valid === false) return null; + return typeof result.type === 'string' ? result.type : null; + } catch { + // Parse/other faults mean we cannot prove a type — the conservative `null`. + return null; + } +} + /** @deprecated use {@link firstUndeclaredReference} with no fields. */ export function detectBareReference(source: string): string | null { return firstUndeclaredReference(source); diff --git a/packages/formula/src/index.ts b/packages/formula/src/index.ts index 5395009283..8b0e85ed9f 100644 --- a/packages/formula/src/index.ts +++ b/packages/formula/src/index.ts @@ -24,7 +24,7 @@ export type { CelFilterCompileResult, CelFilterCompileOptions, CelFilterFailReas export { matchesFilterCondition } from './matches-filter'; // ADR-0032 — shared validator + introspection (one validator for build, // registration, and the agent-callable validate_expression tool). -export { validateExpression, introspectScope, expectedDialect, CEL_STDLIB_FUNCTIONS } from './validate'; -export type { FieldRole, ExprSchemaHint, ExprValidationError, ExprValidationResult } from './validate'; +export { validateExpression, introspectScope, expectedDialect, inferExpressionType, CEL_STDLIB_FUNCTIONS } from './validate'; +export type { FieldRole, ExprInput, ExprSchemaHint, ExprValidationError, ExprValidationResult, InferredValueType } from './validate'; export type { SeedValue, SeedPrimitive } from './seed-eval'; export type { DialectEngine, EvalContext, EvalResult, EvalError } from './types'; diff --git a/packages/formula/src/validate.test.ts b/packages/formula/src/validate.test.ts index e09fb1c85e..951332f98b 100644 --- a/packages/formula/src/validate.test.ts +++ b/packages/formula/src/validate.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { validateExpression, introspectScope, expectedDialect } from './validate'; +import { validateExpression, introspectScope, expectedDialect, inferExpressionType } from './validate'; describe('validateExpression (ADR-0032)', () => { describe('predicates (CEL)', () => { @@ -165,3 +165,45 @@ describe('validateExpression (ADR-0032)', () => { }); }); }); + +describe('inferExpressionType — coarse value-type of a formula', () => { + // The host object's fields, so a bare `` reference resolves the same as + // `record.` (a stored formula may be written either way). + const fields = ['start_date', 'end_date', 'amount', 'rate', 'first', 'last', 'name', 'items']; + + it('infers number for a computed-number formula (the leave_days repro)', () => { + // daysBetween(...): int, int + 1 → int → number. The exact case a "total + // leave days" dashboard card needs a SUM measure derived for. + expect(inferExpressionType('daysBetween(start_date, end_date) + 1', { fields })).toBe('number'); + expect(inferExpressionType('daysBetween(record.start_date, record.end_date) + 1')).toBe('number'); + expect(inferExpressionType('amount * 0.1', { fields })).toBe('number'); // dyn * double → double + expect(inferExpressionType('round(amount)', { fields })).toBe('number'); + expect(inferExpressionType('len(items)', { fields })).toBe('number'); + }); + + it('accepts the canonical Expression envelope as input', () => { + expect(inferExpressionType({ dialect: 'cel', source: 'amount * 0.1' }, { fields })).toBe('number'); + }); + + it('infers text / boolean / date for non-numeric formulas', () => { + expect(inferExpressionType('upper(name)', { fields })).toBe('text'); + expect(inferExpressionType('rate >= 0.5', { fields })).toBe('boolean'); + expect(inferExpressionType('today()')).toBe('date'); + }); + + it('is conservative — an ambiguous (dyn) result is unknown, never number', () => { + // `first + last` could be string concatenation OR numeric addition; with two + // untyped operands cel-js yields `dyn`, so we must NOT call it a number (else + // a dataset would SUM a text formula). This is the safety property. + expect(inferExpressionType('first + last', { fields })).toBe('unknown'); + expect(inferExpressionType('amount + rate', { fields })).toBe('unknown'); + }); + + it('returns unknown for empty, absent, or un-type-checkable expressions', () => { + expect(inferExpressionType('')).toBe('unknown'); + expect(inferExpressionType(null)).toBe('unknown'); + expect(inferExpressionType(undefined)).toBe('unknown'); + expect(inferExpressionType('no_such_fn(amount)', { fields })).toBe('unknown'); // no overload + expect(inferExpressionType('undeclared_field + 1')).toBe('unknown'); // bare ref, no fields given + }); +}); diff --git a/packages/formula/src/validate.ts b/packages/formula/src/validate.ts index 1781685849..c5aac84d08 100644 --- a/packages/formula/src/validate.ts +++ b/packages/formula/src/validate.ts @@ -17,7 +17,7 @@ * This validator detects that specific mistake and returns the exact fix. */ -import { celEngine, firstUndeclaredReference } from './cel-engine'; +import { celEngine, firstUndeclaredReference, inferCelType } from './cel-engine'; import { templateEngine } from './template-engine'; export type FieldRole = 'predicate' | 'value' | 'template'; @@ -256,6 +256,49 @@ export function introspectScope(role: FieldRole, schema?: ExprSchemaHint): { }; } +/** + * Coarse value categories a `value`/formula expression can compute. `'unknown'` + * means cel-js could not prove a concrete type — either a `dyn` result (an + * ambiguous expression over untyped operands) or one that does not type-check. + */ +export type InferredValueType = 'number' | 'text' | 'boolean' | 'date' | 'unknown'; + +/** Map a cel-js type-checker type name onto an ObjectStack field value category. */ +function celTypeToValueType(celType: string | null): InferredValueType { + switch (celType) { + case 'int': + case 'uint': + case 'double': + return 'number'; + case 'string': + return 'text'; + case 'bool': + return 'boolean'; + case 'google.protobuf.Timestamp': + return 'date'; + default: + // `dyn`, `google.protobuf.Duration`, list/map, null, or un-type-checkable. + return 'unknown'; + } +} + +/** + * Infer the coarse value type a `value`/formula expression computes — `'number'`, + * `'text'`, `'boolean'`, `'date'`, or `'unknown'` when cel-js cannot prove a + * concrete type. `schema.fields` (the host object's field names) are declared so + * a bare `` reference resolves the same as `record.`. + * + * The motivating use is measure-eligibility: a dataset derives a SUM measure for + * a `formula` field ONLY when this returns `'number'`, so an ambiguous or + * non-numeric formula never yields an incoherent measure. Conservative by + * construction — see {@link inferCelType}. + */ +export function inferExpressionType(input: ExprInput, schema?: ExprSchemaHint): InferredValueType { + const { source } = toSource(input); + if (!source.trim()) return 'unknown'; + return celTypeToValueType(inferCelType(source, schema?.fields)); +} + /** * Public catalog of CEL functions available in expressions — what `introspectScope` * advertises to authors (incl. AI). Every entry MUST actually resolve at runtime: From 1edb778976521d576cb1ef23b7c478bc3ddbdd61 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 24 Jun 2026 10:54:47 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(spec):=20FieldSchema.returnType=20?= =?UTF-8?q?=E2=80=94=20a=20formula=20field's=20declared=20value=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/formula-infer-value-type.md | 6 +++++- packages/spec/liveness/field.json | 4 ++++ packages/spec/src/data/field.zod.ts | 9 +++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.changeset/formula-infer-value-type.md b/.changeset/formula-infer-value-type.md index ae84403ca6..76e4bad288 100644 --- a/.changeset/formula-infer-value-type.md +++ b/.changeset/formula-infer-value-type.md @@ -1,5 +1,9 @@ --- "@objectstack/formula": minor +"@objectstack/spec": minor --- -Add `inferExpressionType()` (and the lower-level `inferCelType()`): infer the coarse return type (`number | text | boolean | date | unknown`) of a CEL value/formula expression by surfacing the cel-js type-checker result. Conservative — two `dyn` operands stay `unknown`, while typed literals/stdlib returns pin a concrete type. Enables numeric-formula measure-eligibility in downstream dataset derivation. +Formula field typing: `inferExpressionType()` + a declared `returnType`. + +- `@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. +- `@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. diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index f48bf6ff2a..9e9c8f5d38 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -58,6 +58,10 @@ "evidence": "packages/objectql/src/engine.ts", "note": "formula." }, + "returnType": { + "status": "live", + "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." + }, "summaryOperations": { "status": "live", "evidence": "packages/objectql/src/engine.ts", diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 18c4b36894..3228a8ed75 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -477,6 +477,15 @@ export const FieldSchema = lazySchema(() => z.object({ /** Calculation — CEL formula. Plain string accepted for back-compat; build emits canonical envelope. */ expression: ExpressionInputSchema.optional().describe('Formula expression (CEL). e.g. F`record.amount * 0.1`'), + /** + * The value type a `formula` field computes, declared at authoring (the way + * Salesforce/Airtable carry a formula's result type). Lets consumers — dataset + * measures, display formatting, validation — read a declared type instead of + * re-parsing the expression. Authoring stamps it from the inferred CEL type; + * absent when the type can't be proven (an ambiguous/`dyn` expression). + */ + returnType: z.enum(['number', 'text', 'boolean', 'date']).optional() + .describe('Inferred value type of a formula field (number/text/boolean/date)'), summaryOperations: z.object({ object: z.string().describe('Source child object name for roll-up'), field: z.string().describe('Field on child object to aggregate (ignored for count)'),