Skip to content

Commit 76a1275

Browse files
feat(formula): label(record.<field>) renders a select value in the reader's locale (#26755)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB77M1hT4XaiEVd31sLrD2dU)_ Fixes #26608 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: #26760 ## 维护者速读 - **改了什么**:新增 CEL 函数 `label(record.<field>)`,把 select 字段的存储值渲染为读者语言的选项标签;objectql 在读和写的响应里按请求的 `Accept-Language` 给公式备好标签。`'v' + string(record.version_no) + ' · ' + label(record.kind)` 对 zh-CN 读者是 `v2 · 清稿`,对 en-US 读者是 `v2 · Clean`。 - **为什么改**:select 存的是值,公式 nameField 只能拼出 `v2 · clean`,保存提示和记录标题对所有语言的读者都显示英文值(#26608)。 - **风险与回滚**:只有调用 `label(` 的公式才会构建标签表,其余读路径只多一次源码扫描。没有新的翻译键,也没有第二条翻译路径(复用 `translateObject`,与 `GET /meta/object` 同一函数)。i18n 服务缺失或抛错时退回声明的标签,读不会失败。回滚即 revert 本 PR。 - **先看**:`packages/objectql/src/formula-option-labels.ts`,然后 `packages/formula/src/stdlib.ts` 里的 `bindOptionLabel`。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run src/engine-formula-option-label.test.ts`,以及 `packages/qa/dogfood` 下的 `test/formula-option-label-locale.dogfood.test.ts`。 - **需要你做的**:本 PR 改了 `skills/**`(Tier H),落地需要你的同意。 ## What changes **`@objectstack/formula`** - `label(record.<field>)` joins the CEL stdlib and `CEL_STDLIB_FUNCTIONS`. It is a macro: the type check accepts exactly `record.<field>` and refuses a literal, a path through a lookup, an optional select, `previous.<field>` and any computed argument, with a message that names the one shape. - It reads data only. `EvalContext.optionLabels` (new, type `EvalOptionLabels`: field → stored value → label) is resolved by the caller in the reader's locale before evaluation, so the stdlib holds no translator or resolver. It is not mounted as a CEL variable. - An empty value reads `''`, and a value no option declares reads as itself, as the select control shows it. A field with no options (on every row, empty or not), a list value (multi-select), and a context with no `optionLabels` fail loudly. - The measured catalog counts in `validate.ts` are updated (77 registered names, 42 callable bare, 30 from `registerStdLib`, 38 advertised). The receiver-only count also picks up `hasCapability`, which was already registered on main and missing from the prose. **`@objectstack/objectql`** - New `formula-option-labels.ts` builds the map for one read: the object's fields that declare `options`, with the i18n service's bundle laid over them by `translateObject` from `@objectstack/spec/system`. The bundle and the locale options (`fallbackChain`, `defaultLocale`) are built as the REST metadata route builds them, so the label a formula prints is the label the select control shows the same reader: `objects.<o>.fields.<f>.options.<v>`, then `picklists.<p>.options.<v>`, then the declared label. No key is spelled in this module. - `applyFormulaPlan` passes the map to `ExpressionEngine.evaluate`. The map is built only when some formula in the plan calls `label(`. - Wired readers, each in `ExecutionContext.locale` (the request's `Accept-Language`): `find`, `findOne`, the insert and update responses (`hydrateWriteFormulas`), and `serveFormulaField`. `expandRelatedRecords` already runs a `find` with the read's context, so expanded lookups follow. - Declared labels: stored expressions (`computeStoredExpressions`), the synchronous `evaluateFormulaField`, any read with no locale, and an engine with no i18n service or one whose `getTranslations` throws. - `setI18nService` accepts the optional `getTranslations`, `getFallbackLocale` and `getDefaultLocale` it now reads. `II18nService` already carries all three. **Docs and skill** - `skills/objectstack-formula/SKILL.md` gains an "Options" row for `label(record.f)` (+116 tokens, 5627 of 5997). The row says it answers only in formula fields and faults in a predicate. - `content/docs/data-modeling/formulas.mdx` gains the stdlib row and a cookbook entry, "Record name from an option label". **Skills readings** | Measure | Before | After | |:---|---:|---:| | `skills/objectstack-formula/SKILL.md` lines | 465 | 471 | | `skills/objectstack-formula/SKILL.md` tokens (ceiling 5997) | 5511 | 5627 | | Bundle, all `SKILL.md`, lines | 4600 | 4606 | | Bundle, all `SKILL.md`, tokens (`ceil(bytes / 4)`) | 56017 | 56133 | | `evals/cel-predicates-formulas.json` tokens (ceiling 1046) | 1046 | 1046 | ## Readers (measured) | Reader | Where it gets the name | Locale | |:---|:---|:---| | Save toast (`recordSaveMessage`, app-shell) | the POST/PATCH response | writer's `Accept-Language` | | Record page H1 (`getRecordDisplayName`) | `GET /data/:object/:id` → `findOne` | reader's | | Related list | list read → `find` | reader's | | Lookup display | `expand` → `find` with the same context | reader's | | REST GET / POST / PATCH | the same engine paths | request's | | Search | does not index formula fields (`SEARCH_VIRTUAL_TYPES`) | unchanged | | Stored computed field (`text` + `expression`) | computed once on write | declared labels | | `evaluateFormulaField` (sync, e.g. a hook's record-title accessor) | evaluated with no read context | declared labels (left out) | No `ui/**` file changes: the console renders the name the server serves. The console sends its display language as `Accept-Language` on every call. ## Tests - `packages/formula/src/option-label.test.ts` (new): zh-CN and en-US maps, per-field maps, empty and undeclared values, the run-time faults, the compile-time refusals, and `inferExpressionType` returning `text`. - `packages/formula/src/cel-engine.test.ts`: `label` joins the drift probe. - `packages/objectql/src/engine-formula-option-label.test.ts` (new): the insert response in zh-CN and en-US, the update response, `findOne` and `find` per locale, a base-language tag (`zh`), no locale, a stored expression keeping the declared label under a zh-CN writer, and no i18n service or a throwing one. - `packages/qa/dogfood/test/formula-option-label-locale.dogfood.test.ts` (new, real boot): POST in zh-CN and en-US, GET by id in both, PATCH in zh-CN, the list read in zh-CN, and the name matching the option label `GET /meta/object/:name` serves in the same locale. ## Acceptance notes - `label()` in a validation rule, predicate or flow condition has no option labels and faults loudly instead of comparing against a label; the docs say to compare the stored value there. No publish-time refusal of `label()` outside a formula is built in this PR. - The skill eval is owed, as #26760: `cel-predicates-formulas.json` is at its ceiling (1046 of 1046), and a compact case measures about 130 tokens. - Search indexing is unchanged. ## Verification All runs on `f780ce78`, in a cloud worktree, under `scripts/pm/os-verify-lock.sh`, unless a section names `8ffe3ae6`. <details><summary>Unit tests: <code>@objectstack/formula</code> and <code>@objectstack/objectql</code></summary> - `@objectstack/formula`: `option-label.test.ts`, `cel-engine.test.ts` and `skill-catalog-sync.test.ts` pass (94 tests), and `cel-stdlib-drift.test.ts` passes. The catalog counts in `validate.ts` were measured on the built environment (77 / 42 / 35 / 30 / 38). - `@objectstack/objectql`: `engine-formula-option-label.test.ts` passes 7/7; with the other `engine-formula*` files, 8 files and 135 tests pass. </details> <details><summary>Dogfood on a real boot</summary> - `@objectstack/dogfood`: `formula-option-label-locale.dogfood.test.ts` passes 5/5. </details> <details><summary>Typecheck</summary> - `typecheck` passes for `@objectstack/formula` and `@objectstack/objectql` (including `check:test-typecheck`). - `tsc --noEmit` passes for `@objectstack/dogfood` with `NODE_OPTIONS=--max-old-space-size=8192`; at the default heap it runs out of memory locally, before reporting anything. </details> <details><summary>Derived gates: 102 commands, 101 measured green, 1 not measured</summary> - `node scripts/pm/dispatch-gates.mjs --commands` derives 102 commands; 99 exit 0 on the first run. - `check:objectql-double-limit` found that the new test's `find` double ignored the caller's `limit`. Fixed in `f780ce78`, and it now reports OK. - `spec check:skill-examples` exited 3 because the client SDK was not built. After building `@objectstack/client-react` and its dependencies it passes: 266 examples type-check. - `check:dual-build-cjs-loads` exits 3 because it needs a whole-repo build; nothing was measured. That is left to CI. - `--ran` with exit codes: 102 derived families accounted for, 101 run, 1 NOT-MEASURED (the recorded exit 3). </details> <details><summary>Contract-review should-fixes on <code>8ffe3ae6</code></summary> - `optionLabelOf` checks the field's options before the empty-value answer; `option-label.test.ts` pins a null text field faulting. - `@objectstack/formula`: `option-label`, `cel-engine`, `skill-catalog-sync` and `cel-stdlib-drift` pass (4 files, 98 tests); `typecheck` passes. `@objectstack/objectql`: `engine-formula-option-label.test.ts` passes 7/7 against the rebuilt formula package. - Exit 0: `spec check:skill-docs`, `check:skill-identifier-liveness`, `check:skill-compatibility`, `check:skill-frame-sync`, `check:skill-top-level-keys`, `check:nul-bytes`, `check-skills-token-ratchet`, `spec check:skill-examples`. </details> <details><summary>Skills token ratchet</summary> - `check-skills-token-ratchet` exits 0. The readings are in the Skills readings table above. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzfay61tE4MHpZNiV4t --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e438a55 commit 76a1275

14 files changed

Lines changed: 919 additions & 29 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/formula': minor
3+
'@objectstack/objectql': minor
4+
---
5+
6+
feat(formula): `label(record.<field>)` reads a select field's option label in the reader's locale, so a record name composed in a formula renders `v2 · 清稿` to a zh-CN reader and `v2 · Clean` to an en-US one (#26608)
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: added
10+
11+
A select field stores its option's value, so a formula `nameField` such as `'v' + string(record.version_no) + ' · ' + record.kind` printed `v2 · clean` to every reader. Wrap the field in `label()` and the name prints the label the select control shows that reader.
12+
13+
- **The function (`@objectstack/formula`).** `label(record.<field>)` is a new CEL stdlib function, advertised in `CEL_STDLIB_FUNCTIONS`. It takes exactly `record.<field>`; any other argument is refused when the expression compiles. It reads only data: the new `EvalContext.optionLabels` (field → stored value → label, type `EvalOptionLabels`), which the caller resolves in the reader's locale before evaluating. An empty value reads `''`; a value no option declares reads as itself; a field with no options, a multi-value select, and an evaluation whose context carries no `optionLabels` fail loudly.
14+
- **Where it answers (`@objectstack/objectql`).** The engine pins the map for every read and write it serves formula fields on: `find`, `findOne`, and the insert and update responses. The labels are the object's declared `options` with the i18n service's bundle laid over them by `translateObject`, the function `GET /meta/object/:name` serves the object with, in `ExecutionContext.locale` (the request's `Accept-Language`). The field-level key `objects.<object>.fields.<field>.options.<value>` wins over a shared picklist's `picklists.<picklist>.options.<value>`, and both over the declared label. No new keys and no new bundle. A read with no locale, an engine with no i18n service, and an i18n service that throws all read the declared labels.
15+
- **Stored values.** A stored computed field (a non-formula field with an `expression`) is computed once, on the write, so it stores the declared label whatever the writer's locale. Search never indexes formula fields, so what search indexes is unchanged.
16+
- **`setI18nService`** accepts the optional `getTranslations`, `getFallbackLocale` and `getDefaultLocale` the engine now reads. An `II18nService` already carries all three.

‎content/docs/data-modeling/formulas.mdx‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ All functions are pure given a pinned `now`, which is what makes
196196
| `abs(x)` / `round(x)` | number | Numeric helpers |
197197
| `floor(x)` / `ceil(x)` | number | Round toward −∞ / +∞ (`floor(-1.2) == -2`) — **not** integer-division's round-toward-zero |
198198
| `min(a, b)` / `max(a, b)` | dyn | Smaller / larger operand (numeric comparison) |
199+
| `label(record.f)` | string | A select field's option label in the reader's locale — see [Record name from an option label](#record-name-from-an-option-label) |
199200

200201
Add new helpers in
201202
[`packages/formula/src/stdlib.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/formula/src/stdlib.ts).
@@ -265,6 +266,40 @@ type-soundness check applies.
265266
Building the string by hand with `+` would also work, but CEL throws on
266267
`null + string`, so each nullable operand must be wrapped in `coalesce(..., '')`.
267268

269+
### Record name from an option label
270+
271+
A select field stores its option's **value** (`clean`), not its label. To put
272+
the label a reader reads into a composed name, wrap the field in `label()`:
273+
274+
```ts
275+
{
276+
name: 'display_name', // the object's nameField
277+
type: 'formula',
278+
expression: F`'v' + string(record.version_no) + ' · ' + label(record.kind)`,
279+
}
280+
```
281+
282+
The same record reads `v2 · 清稿` to a reader whose locale is `zh-CN` and
283+
`v2 · Clean` to an `en-US` reader. The label is the one the select control
284+
shows that reader: the field's declared option label, with the object's
285+
translation bundle laid over it (`objects.<object>.fields.<field>.options.<value>`,
286+
then a shared picklist's `picklists.<picklist>.options.<value>`), exactly as
287+
`GET /meta/object/:name` serves it. The locale is the request's
288+
`Accept-Language`, so the write response the save toast reads, the record
289+
page title, related lists and lookups all follow it.
290+
291+
- `label()` takes exactly `record.<field>`, a field that declares `options`.
292+
An empty value reads `''`; a stored value no option declares reads as itself.
293+
A multi-value select is refused.
294+
- A read with no locale (a script, a scheduled job) reads the declared labels.
295+
- A **stored** computed field (a `text` field with an `expression`) is computed
296+
once, on the write, so it stores the declared label whatever the writer's
297+
locale. Search never indexes a formula field, so it is unaffected.
298+
- `label()` answers only where the engine serves formula fields. A validation
299+
rule, predicate or flow condition that calls it fails loudly instead of
300+
comparing against a label; compare the stored value there
301+
(`record.kind == 'clean'`).
302+
268303
### Conditional formula
269304

270305
```ts

‎packages/formula/src/cel-engine.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -511,13 +511,15 @@ describe('celEngine', () => {
511511
isBlank: 'isBlank("")', isEmpty: 'isEmpty([])', coalesce: 'coalesce(null, "x")', len: 'len("ab")',
512512
size: 'size([1, 2])', has: 'has(record.a)', int: 'int("3")', string: 'string(3)',
513513
bool: 'bool("true")', double: 'double("3.5")', timestamp: 'timestamp("2026-01-01T00:00:00Z")',
514-
duration: 'duration("3600s")',
514+
duration: 'duration("3600s")', label: 'label(record.a)',
515515
};
516516
const unresolved: string[] = [];
517517
for (const fn of CEL_STDLIB_FUNCTIONS) {
518518
const src = call[fn];
519519
expect(src, `no probe call defined for advertised function \`${fn}\``).toBeTruthy();
520-
const r = celEngine.evaluate(cel(src), { now, record: { a: 1 } });
520+
// `optionLabels` is the data `label()` answers from (#26608); the other
521+
// probes read nothing from it.
522+
const r = celEngine.evaluate(cel(src), { now, record: { a: 1 }, optionLabels: { a: { 1: 'One' } } });
521523
if (!r.ok) unresolved.push(`${fn}: ${r.error.message.split('\n')[0]}`);
522524
}
523525
expect(unresolved, `advertised functions that fault at runtime:\n${unresolved.join('\n')}`).toEqual([]);

‎packages/formula/src/cel-engine.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ export function buildEnv(
8282
/**
8383
* The one constructor behind {@link buildEnv} and the shared evaluation
8484
* environment: the same options and the same registrations, with the stdlib
85-
* reading its `now`, timezone and permission binding through `read`.
85+
* reading its `now`, timezone, permission binding and option labels through
86+
* `read`.
8687
*/
8788
function buildEnvReading(read: () => StdLibBinding): Environment {
8889
const env = new Environment(CEL_ENV_OPTIONS);
@@ -121,8 +122,8 @@ let activeBinding: StdLibBinding = UNBOUND;
121122
* state: each evaluation gets its own `RootContext`, the parser resets its
122123
* input and limit counters at the start of every parse, and what the registry
123124
* caches (type declarations, overload resolution) is keyed on types, never on
124-
* values. What did vary per evaluation, the stdlib's `now`, timezone and
125-
* permission binding, is read from {@link activeBinding}.
125+
* values. What did vary per evaluation, the stdlib's `now`, timezone,
126+
* permission binding and option labels, is read from {@link activeBinding}.
126127
*/
127128
let evaluationEnv: Environment | undefined;
128129

@@ -2046,6 +2047,7 @@ export const celEngine: DialectEngine = {
20462047
permissions: ctx.permissions,
20472048
systemPermissions: ctx.systemPermissions,
20482049
},
2050+
optionLabels: ctx.optionLabels,
20492051
};
20502052
try {
20512053
// #3183 — coerce a date-field operand compared with `==`/`!=` against a

‎packages/formula/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,4 +170,4 @@ export type {
170170
ExpressionSourceKind,
171171
} from './expression-refusal';
172172
export type { SeedValue, SeedPrimitive } from './seed-eval';
173-
export type { DialectEngine, EvalContext, EvalResult, EvalError, EvalPermissions, EvalSystemPermissions } from './types';
173+
export type { DialectEngine, EvalContext, EvalResult, EvalError, EvalPermissions, EvalSystemPermissions, EvalOptionLabels } from './types';
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { celEngine } from './cel-engine';
4+
import { inferExpressionType } from './validate';
5+
import type { EvalOptionLabels } from './types';
6+
import type { Expression } from '@objectstack/spec';
7+
8+
/**
9+
* `label(record.<field>)` — a select field's stored value as the label its
10+
* reader reads (#26608). The record name a formula composes out of option
11+
* values (`v2 · clean`) renders in the reader's locale (`v2 · 清稿`).
12+
*
13+
* The labels are DATA the caller pinned (`EvalContext.optionLabels`), resolved
14+
* in the reader's locale before evaluation; these tests hand the two maps a
15+
* zh-CN and an en-US reader receive and pin what each reads.
16+
*/
17+
18+
const cel = (source: string): Expression => ({ dialect: 'cel', source });
19+
20+
const NAME = "'v' + string(record.version_no) + ' · ' + label(record.kind)";
21+
22+
const zhCN: EvalOptionLabels = { kind: { clean: '清稿', redline: '修订稿' }, stage: { draft: '草稿' } };
23+
const enUS: EvalOptionLabels = { kind: { clean: 'Clean', redline: 'Redline' }, stage: { draft: 'Draft' } };
24+
25+
const record = { version_no: 2, kind: 'clean', stage: 'draft', title: 'Master agreement', tags: ['a'] };
26+
27+
describe('label(record.<field>) — option labels in the reader\'s locale (#26608)', () => {
28+
it('composes a record name in zh-CN and in en-US from the same stored value', () => {
29+
expect(celEngine.evaluate(cel(NAME), { record, optionLabels: zhCN })).toEqual({ ok: true, value: 'v2 · 清稿' });
30+
expect(celEngine.evaluate(cel(NAME), { record, optionLabels: enUS })).toEqual({ ok: true, value: 'v2 · Clean' });
31+
});
32+
33+
it('reads each field\'s own options — the field name picks the map, not the value', () => {
34+
const r = celEngine.evaluate(cel("label(record.kind) + ' / ' + label(record.stage)"), {
35+
record,
36+
optionLabels: zhCN,
37+
});
38+
expect(r).toEqual({ ok: true, value: '清稿 / 草稿' });
39+
});
40+
41+
it('answers \'\' for an empty select, so a composed name still evaluates', () => {
42+
const r = celEngine.evaluate(cel(NAME), { record: { ...record, kind: null }, optionLabels: zhCN });
43+
expect(r).toEqual({ ok: true, value: 'v2 · ' });
44+
});
45+
46+
it('shows a stored value no option declares as itself, as the select control does', () => {
47+
const r = celEngine.evaluate(cel('label(record.kind)'), { record: { kind: 'retired' }, optionLabels: zhCN });
48+
expect(r).toEqual({ ok: true, value: 'retired' });
49+
});
50+
51+
it('refuses at run time when the context carries no option labels — never the stored value', () => {
52+
const r = celEngine.evaluate(cel(NAME), { record });
53+
expect(r.ok).toBe(false);
54+
if (!r.ok) {
55+
expect(r.error.kind).toBe('runtime');
56+
expect(r.error.message).toContain('label(record.kind) cannot be answered');
57+
expect(r.error.message).toContain('optionLabels');
58+
}
59+
});
60+
61+
it('refuses a field that declares no options, on every row, and a list value', () => {
62+
const plain = celEngine.evaluate(cel('label(record.title)'), { record, optionLabels: zhCN });
63+
expect(plain.ok).toBe(false);
64+
if (!plain.ok) {
65+
expect(plain.error.kind).toBe('runtime');
66+
expect(plain.error.message).toContain('`title` declares no options');
67+
}
68+
// …on every row: an empty value does not hide that the field has no options.
69+
const empty = celEngine.evaluate(cel('label(record.title)'), { record: { ...record, title: null }, optionLabels: zhCN });
70+
expect(empty.ok).toBe(false);
71+
if (!empty.ok) expect(empty.error.message).toContain('`title` declares no options');
72+
const list = celEngine.evaluate(cel('label(record.tags)'), {
73+
record,
74+
optionLabels: { ...zhCN, tags: { a: 'A' } },
75+
});
76+
expect(list.ok).toBe(false);
77+
if (!list.ok) expect(list.error.message).toContain('renders ONE option value');
78+
});
79+
80+
it('compiles the one argument shape and refuses every other at the type check', () => {
81+
expect(celEngine.compile(NAME).ok).toBe(true);
82+
for (const source of [
83+
"label('clean')",
84+
'label(record.account.type)',
85+
'label(record.?kind)',
86+
'label(previous.kind)',
87+
"label(record.kind + '')",
88+
]) {
89+
const r = celEngine.compile(source);
90+
expect(r.ok, source).toBe(false);
91+
if (!r.ok) {
92+
expect(r.error.kind, source).toBe('type');
93+
expect(r.error.message, source).toContain('label(record.<field>)');
94+
}
95+
}
96+
});
97+
98+
it('types as text, so a formula built on it is a text formula', () => {
99+
expect(inferExpressionType(NAME)).toBe('text');
100+
expect(inferExpressionType('label(record.kind)')).toBe('text');
101+
});
102+
});

0 commit comments

Comments
 (0)