Skip to content

Commit f802213

Browse files
fix(rest): export lookup cells name the record by the target object's title field (#24980)
Fixes #24944 Clause-②: no ## What changes `GET /api/v1/data/:object/export` (Display → Export as CSV / XLSX) wrote a lookup, master_detail, user or tree cell from the lookup field's own `displayField`. Without one, it took the first scalar key of a fixed list (`name`, `title`, `label`, `full_name`, …) found on the expanded record. It never read the target object's `nameField`. A HotCRM contact declares `nameField: 'full_name'`, has no `name` column and has a job `title` column, so it exported as its job title. - `packages/rest/src/export-format.ts` - New `withReferenceTitleFields(metaMap, readSchema)`. It reads each reference target's schema once per export (deduplicated by target) and stamps `titleField` on that field's meta. It is best-effort per target and never throws: a target whose schema cannot be read keeps the key-list fallback. - `ExportCellMeta` is an export-only extension of core's `ExportFieldMeta` with that one key. `ExportFieldMeta` in `@objectstack/core` is not touched, and nothing new is exported from the package index. - `displayFromRecord` reads, in order: the lookup's own `displayField`; else the target's `titleField`; else, only when the target resolves no title, the key list; else the record id. - `packages/rest/src/data-action-routes.ts` - The export route's schema read becomes one local `readObjectSchema` (the same `getMetaItem`, then `getObjectSchema`, order as before). It serves the export's own object and each target. - `metaMap = await withReferenceTitleFields(metaMap, readObjectSchema)` runs before the `$expand` is built. - No key, query parameter, header, column set or payload shape moves. Only the text of a lookup cell changes, in csv, xlsx and json alike. ## The title rule: `resolveDisplayField`, not `titleFieldOf` - `titleFieldOf` (`packages/objectql/src/record-title.ts`) is not reachable from `@objectstack/rest` without a new runtime dependency. `@objectstack/objectql` is only a devDependency here. - `resolveDisplayField` (`@objectstack/spec/data`, `display-name.ts`) is reachable through the existing dependency. It is ADR-0079's single resolver, and plugin-audit and plugin-approvals use it to name a related record. Its rungs are `nameField`, then `displayNameField`, then a derivation. The first two rungs are exactly `titleFieldOf`'s rule. The formatter receives the resolved field and holds no rule of its own. - On every body the protocol serves, the two answer the same. At registration the registry runs `provisionPrimary(schema, { synthesize: false })`, which is `resolveDisplayField`, and stamps the result as `nameField`. Measured through `protocol.getMetaItem` on a real engine (a throwaway probe, not committed): | Registered object (no pointer declared) | Served `nameField` | Old key-list pick | |---|---|---| | `id`, `subject` (text), `email` | `subject` | `email` | | `id`, `label` (select) | `id` | `label` (the raw option code) | | `title`, `full_name` (text) | `title` | `title` | | `amount` (number) only | none | none (id floor) | - They differ only on a served body that carries no pointer but has a title-eligible field. There `resolveDisplayField` gives ADR-0079's derivation where `titleFieldOf` gives nothing. None of the measured registry bodies is in that state. - Consequence: an object that declares no title is still served with a designated one. Its lookup cells now follow that designated title. In the table above, `subject` replaces `email`, and the id replaces a raw select code. ## Visible changes beyond the reported case - **Account.** HotCRM's account declares `nameField: 'display_title'` and has a literal `name` column. Its lookup cells change from `name` to `display_title`. That follows the declaration (pinned: `Acme` becomes `Acme (EMEA)`). - **Field-level security.** The `$expand` sub-read is the engine's own `find` on the target, so FLS deletes an unreadable title field from the expanded record. The cell then falls to the record id, not to the key list. The card's mechanism assumption 3 expected the fallback "as it does today". Today's fallback for an unreadable `full_name` is the job title, which is the defect itself. The binding ruling keeps the key list only for an object that declares neither. - The lookup field's own `displayField` still wins (pinned as a control). ## Evidence All readings are on `packages/rest`. They are taken at `0fb2a82b06` (the fix) and `6dabdfbeab` (the unit pins and the changeset), on top of base `693352e4c`. - **Red on `main`.** `src/export-lookup-title-field.test.ts` run against the unmodified route: `Tests 5 failed | 2 passed (7)`. The contact cell read `[ '1', 'VP of Engineering' ]` where `[ '1', 'John Smith' ]` was expected. The two controls (the lookup's own `displayField`, and a target served with no pointer) were green. - **Green with the fix.** The same file, now 10 tests: `Tests 10 passed (10)`. - **Ablation 1, the title-field read.** `withReferenceTitleFields`'s `resolveDisplayField(schema as never)` was replaced by `undefined` through `scripts/ablation-replace.mjs` (anchor 1 → 0, blob `6fd4e383` → `dbe37ef0`). Result: `Tests 5 failed | 2 passed (7)`, with the first pin red. Restored to `6fd4e383032f` == HEAD, and `git diff HEAD` was empty. - **Ablation 2, the key list held back when a title exists.** `} else {` was replaced by `} {` (blob `6fd4e383` → `f6f403cc`). Result: `Tests 1 failed | 6 passed (7)`, the FLS pin. Restored hash-equal to HEAD. - The subject is this package's own `src/`, reached by relative import, so the ablations needed no `dist/` rebuild. ## Gates All readings below are at the final head `321ea1f2be`. That is `6dabdfbeab` with `origin/main` `6440cf1633` merged in; the merged commit touches only plugin-security and its changeset. - `pnpm lint` (`eslint . --no-inline-config`): exit 0, no findings printed. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 66 derived commands, plus the 10 families under "Declared WIDE population". All 76 were run with the exit code captured before any pipe, and all 76 exited 0. - `--ran` reconciliation: `✓ dispatch-gates --ran: 66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED (a DERIVED zero — all 66 recorded an exit code and none of them is 3).` - `pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2`: `Test Files 306 passed (306)`, `Tests 6154 passed | 326 skipped (6480)`. - `pnpm --filter @objectstack/rest typecheck`: exit 0. ## Acceptance notes - **Export, then re-import, of a lookup column.** The import's reference resolver (`packages/core/src/utils/import-runner.ts`, `resolveRef`) matches a cell against `id`, the lookup's `displayField`, then `name`, `title`, `label`, `full_name`, `email` and `username`. It never reads the target's title field. Measured through `POST /api/v1/data/xt_case/import` on the fixture of this PR: - `John Smith` (a contact named by its formula `full_name`) answers `reference_not_found`. So do `Wren` (a stored `displayNameField` that is not on the list) and `Acme (EMEA)` (a formula `display_title`). - The cells `main` wrote all resolved: `VP of Engineering` and `Chief Procurement Officer` by `title`, and `Acme` by `name`. - So a file this export writes no longer re-imports those lookup columns. The import side is in `packages/core`, which is held off for this card. It is reported to the dispatching seat as a finding, and not fixed here. - No other import or export path was changed. --- _Generated by [Claude Code](https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0603bfa commit f802213

4 files changed

Lines changed: 426 additions & 23 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): a lookup cell in the list export names the record by the target object's title field (#24944)
6+
7+
Clause-②: no
8+
9+
`GET /api/v1/data/:object/export` (Display → Export as CSV / XLSX) wrote a lookup, master-detail, user or tree cell from the lookup field's own `displayField`. Without one, it took the first key of a fixed list (`name`, `title`, `label`, `full_name`, …) found on the expanded record. It never read the target object's `nameField`. So a contact that declares `nameField: 'full_name'` and has no `name` column exported as its job `title` ("VP of Engineering" instead of "John Smith").
10+
11+
- **Now.** The export reads each target object's title field once per export, by ADR-0079's `resolveDisplayField`: the `nameField`, then the deprecated `displayNameField` alias. The registry sets that pointer on every object it holds. The cell shows that field's value from the expanded record. This applies to csv, xlsx and json.
12+
- **Precedence.** The lookup field's own `displayField` still wins when it is set. The key list is used only when the target resolves no title field, or when its schema cannot be read.
13+
- **Field-level security.** When the caller cannot read the target's title field, the `$expand` read leaves it off the record. The cell then shows the record's id. Another column, such as the job title, no longer stands in for the name.
14+
- **Visible change.** A target with a literal `name` column and a different declared title now exports the declared title. For example, an account with `nameField: 'display_title'` exports its display title instead of `name`.
15+
- **Unchanged.** No query parameter, header, column set or payload shape moves. Only the text of a lookup cell changes.

‎packages/rest/src/data-action-routes.ts‎

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ import {
6464
formatRowForJson,
6565
cellFontColor,
6666
exportContentDisposition,
67-
type ExportFieldMeta,
67+
withReferenceTitleFields,
68+
type ExportCellMeta,
6869
} from './export-format.js';
6970
import { importControlOptOutRefusal, prepareImportRequest } from './import-prepare.js';
7071
import { readTemplateMode, templateFieldDefs } from './import-template.js';
@@ -1086,27 +1087,32 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
10861087
// labels, 是/否, formatted dates) and the $expand that resolves
10871088
// references. Best-effort: when the schema is unavailable the export
10881089
// falls back to raw values, byte-identical to the un-formatted path.
1089-
let metaMap = new Map<string, ExportFieldMeta>();
1090+
let metaMap = new Map<string, ExportCellMeta>();
10901091
// Localized object display label (e.g. 合同) — drives the
10911092
// suggested download filename below.
10921093
let objectLabel: string | undefined;
1093-
try {
1094-
// Field metadata comes from the same place `findData` resolves
1095-
// the object: `getMetaItem` is registry-first (DB fallback), so
1096-
// it returns the live `ObjectSchema` whose `fields` is an object
1097-
// map. The read hands back the envelope `{ type, name, item }`
1098-
// — one shape, unconditionally, since #5563 — so the schema
1099-
// document is read straight off `.item`. Legacy
1100-
// `getObjectSchema` is consulted as a last resort so existing
1101-
// test doubles keep working.
1094+
// Field metadata comes from the same place `findData` resolves
1095+
// the object: `getMetaItem` is registry-first (DB fallback), so
1096+
// it returns the live `ObjectSchema` whose `fields` is an object
1097+
// map. The read hands back the envelope `{ type, name, item }`
1098+
// — one shape, unconditionally, since #5563 — so the schema
1099+
// document is read straight off `.item`. Legacy
1100+
// `getObjectSchema` is consulted as a last resort so existing
1101+
// test doubles keep working. The same read serves the export's
1102+
// object and each reference field's target object below.
1103+
const readObjectSchema = async (name: string): Promise<any> => {
11021104
let schema: any = undefined;
11031105
if (typeof (p as any).getMetaItem === 'function') {
1104-
const res: any = await (p as any).getMetaItem({ type: 'object', name: objectName });
1106+
const res: any = await (p as any).getMetaItem({ type: 'object', name });
11051107
schema = res?.item;
11061108
}
11071109
if (!schema && typeof (p as any).getObjectSchema === 'function') {
1108-
schema = await (p as any).getObjectSchema(objectName, environmentId);
1110+
schema = await (p as any).getObjectSchema(name, environmentId);
11091111
}
1112+
return schema;
1113+
};
1114+
try {
1115+
let schema: any = await readObjectSchema(objectName);
11101116
// Localize field labels to the request locale (Accept-Language /
11111117
// `?locale=`) the same way the metadata endpoints do, so the
11121118
// export header row matches the UI column headers instead of
@@ -1134,6 +1140,13 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
11341140
}
11351141
} catch { /* fall back to first-row derivation + raw values */ }
11361142

1143+
// A lookup cell names the referenced record by the TARGET
1144+
// object's title field (ADR-0079), read once per target here and
1145+
// handed to the formatter on the field meta. Best-effort per
1146+
// target, never throws: an unreadable target schema keeps that
1147+
// column's key-list fallback.
1148+
metaMap = await withReferenceTitleFields(metaMap, readObjectSchema);
1149+
11371150
// Expand reference fields so lookup/user ids resolve to their record
11381151
// (and thus a name). Batched $in inside findData — no N+1.
11391152
const expandFields = referenceFieldNames(metaMap);

‎packages/rest/src/export-format.ts‎

Lines changed: 93 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,29 @@
3131
*/
3232

3333
import { temporalStorageForm, type ExportFieldMeta } from '@objectstack/core';
34+
import { resolveDisplayField } from '@objectstack/spec/data';
3435

3536
// `ExportFieldMeta` and `buildFieldMetaMap` live in `@objectstack/core`
3637
// (`utils/import-field-meta.ts`), beside the import runner that reads them.
3738
export { buildFieldMetaMap, type ExportFieldMeta } from '@objectstack/core';
3839

40+
/**
41+
* The field metadata the export renderers read: core's {@link ExportFieldMeta}
42+
* plus one export-only key, the title field of a reference field's TARGET
43+
* object. It is resolved by the route ({@link withReferenceTitleFields}), never
44+
* by the formatter, so the cell renderer holds no title rule of its own.
45+
*/
46+
export interface ExportCellMeta extends ExportFieldMeta {
47+
/**
48+
* The field that titles a record of this reference field's target object:
49+
* ADR-0079's `resolveDisplayField` over the target schema the protocol
50+
* serves (`nameField`, then the deprecated `displayNameField` alias — the
51+
* pointer the registry designates on every object it holds). `undefined`
52+
* when the target schema could not be read or resolves no title.
53+
*/
54+
titleField?: string;
55+
}
56+
3957
/**
4058
* Build the `Content-Disposition` header for an export download.
4159
*
@@ -102,7 +120,9 @@ const MULTI_OPTION_TYPES = new Set(['multiselect', 'checkboxes', 'tags']);
102120

103121
/**
104122
* Keys tried, in order, to derive a referenced record's display value when the
105-
* field carries no explicit `displayField`.
123+
* field carries no explicit `displayField` AND the target object resolves no
124+
* title field ({@link ExportCellMeta.titleField}). Only a target with no title
125+
* pointer reaches this list: a declared title is never second-guessed by it.
106126
*/
107127
const NAME_KEY_FALLBACKS = [
108128
'name', 'title', 'label', 'full_name', 'fullName', 'display_name', 'username', 'email',
@@ -121,6 +141,50 @@ export function referenceFieldNames(metaMap: Map<string, ExportFieldMeta>): stri
121141
return out;
122142
}
123143

144+
/**
145+
* Stamp every reference field's {@link ExportCellMeta.titleField}: the title
146+
* field of its target object, read once per distinct target per export.
147+
*
148+
* The rule is ADR-0079's `resolveDisplayField`, the spec's single arbiter of
149+
* "which field titles a record of this object" — the same function the
150+
* registry designates `nameField` with at registration, so over a schema the
151+
* protocol serves it answers that designated pointer. A lookup cell therefore
152+
* names the record the way the record names itself, rather than by whichever
153+
* key of a fixed list the expanded record happens to carry (a contact's job
154+
* `title` ahead of its `full_name` title).
155+
*
156+
* Best-effort, per target: a schema read that throws or returns nothing leaves
157+
* that target's fields without a `titleField`, so their cells keep the
158+
* key-list fallback exactly as before. Never throws.
159+
*/
160+
export async function withReferenceTitleFields(
161+
metaMap: Map<string, ExportFieldMeta>,
162+
readSchema: (object: string) => Promise<unknown>,
163+
): Promise<Map<string, ExportCellMeta>> {
164+
const titles = new Map<string, Promise<string | undefined>>();
165+
const titleOf = (target: string): Promise<string | undefined> => {
166+
let pending = titles.get(target);
167+
if (!pending) {
168+
pending = readSchema(target).then(
169+
(schema) => (schema && typeof schema === 'object' ? resolveDisplayField(schema as never) : undefined),
170+
() => undefined,
171+
);
172+
titles.set(target, pending);
173+
}
174+
return pending;
175+
};
176+
const out = new Map<string, ExportCellMeta>();
177+
for (const [key, meta] of metaMap) {
178+
if (meta.type && REFERENCE_TYPES.has(meta.type) && meta.reference) {
179+
const titleField = await titleOf(meta.reference);
180+
out.set(key, titleField ? { ...meta, titleField } : meta);
181+
} else {
182+
out.set(key, meta);
183+
}
184+
}
185+
return out;
186+
}
187+
124188
/** Header label for a column: schema label when present, else the field name. */
125189
export function headerLabel(field: string, metaMap: Map<string, ExportFieldMeta>): string {
126190
return metaMap.get(field)?.label || field;
@@ -333,19 +397,38 @@ export function cellFontColor(value: unknown, meta?: ExportFieldMeta): string |
333397
return toArgb(hit?.color);
334398
}
335399

336-
function displayFromRecord(rec: Record<string, unknown>, displayField?: string): string {
400+
/**
401+
* The display text of one expanded referenced record.
402+
*
403+
* In order: the lookup field's own `displayField` (the field author's explicit
404+
* choice); else the target object's resolved `titleField`; else — only for a
405+
* target that resolves no title — the first scalar hit of
406+
* {@link NAME_KEY_FALLBACKS}; else the record's id.
407+
*
408+
* A target that HAS a title field never consults the key list, also when the
409+
* title is absent from this record: field-level security deletes an unreadable
410+
* field from the expanded record, and the key list would then put another
411+
* column in the name's place (a contact named by its job title). The id is the
412+
* cell's floor instead.
413+
*/
414+
function displayFromRecord(rec: Record<string, unknown>, displayField?: string, titleField?: string): string {
337415
if (displayField && rec[displayField] != null) return String(rec[displayField]);
338-
for (const k of NAME_KEY_FALLBACKS) {
339-
const v = rec[k];
416+
if (titleField) {
417+
const v = rec[titleField];
340418
if (v != null && typeof v !== 'object') return String(v);
419+
} else {
420+
for (const k of NAME_KEY_FALLBACKS) {
421+
const v = rec[k];
422+
if (v != null && typeof v !== 'object') return String(v);
423+
}
341424
}
342425
if (rec.id != null) return String(rec.id);
343426
try { return JSON.stringify(rec); } catch { return String(rec); }
344427
}
345428

346-
function formatReference(value: unknown, displayField?: string): unknown {
429+
function formatReference(value: unknown, displayField?: string, titleField?: string): unknown {
347430
const one = (v: unknown): unknown =>
348-
v && typeof v === 'object' ? displayFromRecord(v as Record<string, unknown>, displayField) : v;
431+
v && typeof v === 'object' ? displayFromRecord(v as Record<string, unknown>, displayField, titleField) : v;
349432
if (Array.isArray(value)) return value.map(one).join(', ');
350433
return one(value);
351434
}
@@ -359,7 +442,7 @@ function formatReference(value: unknown, displayField?: string): unknown {
359442
*/
360443
export function formatCellValue(
361444
value: unknown,
362-
meta?: ExportFieldMeta,
445+
meta?: ExportCellMeta,
363446
timezone?: string,
364447
): unknown {
365448
if (value === null || value === undefined) return value;
@@ -377,7 +460,7 @@ export function formatCellValue(
377460
}
378461
if (t === 'date') return formatDate(value, false);
379462
if (t === 'datetime') return formatDate(value, true, timezone);
380-
if (REFERENCE_TYPES.has(t)) return formatReference(value, meta.displayField);
463+
if (REFERENCE_TYPES.has(t)) return formatReference(value, meta.displayField, meta.titleField);
381464
return value;
382465
}
383466

@@ -390,7 +473,7 @@ export function formatCellValue(
390473
export function formatRowCells(
391474
row: Record<string, unknown>,
392475
fields: string[],
393-
metaMap: Map<string, ExportFieldMeta>,
476+
metaMap: Map<string, ExportCellMeta>,
394477
timezone?: string,
395478
): unknown[] {
396479
return fields.map((f) => formatCellValue(row?.[f], metaMap.get(f), timezone));
@@ -403,7 +486,7 @@ export function formatRowCells(
403486
*/
404487
export function formatRowForJson(
405488
row: Record<string, unknown>,
406-
metaMap: Map<string, ExportFieldMeta>,
489+
metaMap: Map<string, ExportCellMeta>,
407490
timezone?: string,
408491
): Record<string, unknown> {
409492
if (metaMap.size === 0 || !row || typeof row !== 'object') return row;

0 commit comments

Comments
 (0)