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
9 changes: 9 additions & 0 deletions .changeset/formula-infer-value-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@objectstack/formula": minor
"@objectstack/spec": minor
---

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.
36 changes: 36 additions & 0 deletions packages/formula/src/cel-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<field>`
* and bare `<field>` 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);
Expand Down
4 changes: 2 additions & 2 deletions packages/formula/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
44 changes: 43 additions & 1 deletion packages/formula/src/validate.test.ts
Original file line number Diff line number Diff line change
@@ -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)', () => {
Expand Down Expand Up @@ -165,3 +165,45 @@ describe('validateExpression (ADR-0032)', () => {
});
});
});

describe('inferExpressionType — coarse value-type of a formula', () => {
// The host object's fields, so a bare `<field>` reference resolves the same as
// `record.<field>` (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
});
});
45 changes: 44 additions & 1 deletion packages/formula/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 `<field>` reference resolves the same as `record.<field>`.
*
* 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:
Expand Down
4 changes: 4 additions & 0 deletions packages/spec/liveness/field.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)'),
Expand Down