From 0fb2a82b061acc3d1867f50410a37c8ad60c6b30 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:43:03 +0000 Subject: [PATCH 1/2] fix(rest): export lookup cells name the target record by its declared title field A lookup cell in GET /data/:object/export read the lookup's own displayField and then a fixed key list on the expanded record, never the target object's nameField, so a contact (nameField full_name, no name column) exported as its job title. The route now resolves each reference target's title field once per export with ADR-0079's resolveDisplayField and hands it to the formatter on the field meta; the key list remains only for a target that resolves no title. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- packages/rest/src/data-action-routes.ts | 39 ++- packages/rest/src/export-format.ts | 103 ++++++- .../src/export-lookup-title-field.test.ts | 251 ++++++++++++++++++ 3 files changed, 370 insertions(+), 23 deletions(-) create mode 100644 packages/rest/src/export-lookup-title-field.test.ts diff --git a/packages/rest/src/data-action-routes.ts b/packages/rest/src/data-action-routes.ts index bf6a57a75bb..8c2c5eb139f 100644 --- a/packages/rest/src/data-action-routes.ts +++ b/packages/rest/src/data-action-routes.ts @@ -64,7 +64,8 @@ import { formatRowForJson, cellFontColor, exportContentDisposition, - type ExportFieldMeta, + withReferenceTitleFields, + type ExportCellMeta, } from './export-format.js'; import { importControlOptOutRefusal, prepareImportRequest } from './import-prepare.js'; import { readTemplateMode, templateFieldDefs } from './import-template.js'; @@ -1086,27 +1087,32 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: // labels, 是/否, formatted dates) and the $expand that resolves // references. Best-effort: when the schema is unavailable the export // falls back to raw values, byte-identical to the un-formatted path. - let metaMap = new Map(); + let metaMap = new Map(); // Localized object display label (e.g. 合同) — drives the // suggested download filename below. let objectLabel: string | undefined; - try { - // Field metadata comes from the same place `findData` resolves - // the object: `getMetaItem` is registry-first (DB fallback), so - // it returns the live `ObjectSchema` whose `fields` is an object - // map. The read hands back the envelope `{ type, name, item }` - // — one shape, unconditionally, since #5563 — so the schema - // document is read straight off `.item`. Legacy - // `getObjectSchema` is consulted as a last resort so existing - // test doubles keep working. + // Field metadata comes from the same place `findData` resolves + // the object: `getMetaItem` is registry-first (DB fallback), so + // it returns the live `ObjectSchema` whose `fields` is an object + // map. The read hands back the envelope `{ type, name, item }` + // — one shape, unconditionally, since #5563 — so the schema + // document is read straight off `.item`. Legacy + // `getObjectSchema` is consulted as a last resort so existing + // test doubles keep working. The same read serves the export's + // object and each reference field's target object below. + const readObjectSchema = async (name: string): Promise => { let schema: any = undefined; if (typeof (p as any).getMetaItem === 'function') { - const res: any = await (p as any).getMetaItem({ type: 'object', name: objectName }); + const res: any = await (p as any).getMetaItem({ type: 'object', name }); schema = res?.item; } if (!schema && typeof (p as any).getObjectSchema === 'function') { - schema = await (p as any).getObjectSchema(objectName, environmentId); + schema = await (p as any).getObjectSchema(name, environmentId); } + return schema; + }; + try { + let schema: any = await readObjectSchema(objectName); // Localize field labels to the request locale (Accept-Language / // `?locale=`) the same way the metadata endpoints do, so the // export header row matches the UI column headers instead of @@ -1134,6 +1140,13 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: } } catch { /* fall back to first-row derivation + raw values */ } + // A lookup cell names the referenced record by the TARGET + // object's title field (ADR-0079), read once per target here and + // handed to the formatter on the field meta. Best-effort per + // target, never throws: an unreadable target schema keeps that + // column's key-list fallback. + metaMap = await withReferenceTitleFields(metaMap, readObjectSchema); + // Expand reference fields so lookup/user ids resolve to their record // (and thus a name). Batched $in inside findData — no N+1. const expandFields = referenceFieldNames(metaMap); diff --git a/packages/rest/src/export-format.ts b/packages/rest/src/export-format.ts index eba9a6bf457..6fd4e383032 100644 --- a/packages/rest/src/export-format.ts +++ b/packages/rest/src/export-format.ts @@ -31,11 +31,29 @@ */ import { temporalStorageForm, type ExportFieldMeta } from '@objectstack/core'; +import { resolveDisplayField } from '@objectstack/spec/data'; // `ExportFieldMeta` and `buildFieldMetaMap` live in `@objectstack/core` // (`utils/import-field-meta.ts`), beside the import runner that reads them. export { buildFieldMetaMap, type ExportFieldMeta } from '@objectstack/core'; +/** + * The field metadata the export renderers read: core's {@link ExportFieldMeta} + * plus one export-only key, the title field of a reference field's TARGET + * object. It is resolved by the route ({@link withReferenceTitleFields}), never + * by the formatter, so the cell renderer holds no title rule of its own. + */ +export interface ExportCellMeta extends ExportFieldMeta { + /** + * The field that titles a record of this reference field's target object: + * ADR-0079's `resolveDisplayField` over the target schema the protocol + * serves (`nameField`, then the deprecated `displayNameField` alias — the + * pointer the registry designates on every object it holds). `undefined` + * when the target schema could not be read or resolves no title. + */ + titleField?: string; +} + /** * Build the `Content-Disposition` header for an export download. * @@ -102,7 +120,9 @@ const MULTI_OPTION_TYPES = new Set(['multiselect', 'checkboxes', 'tags']); /** * Keys tried, in order, to derive a referenced record's display value when the - * field carries no explicit `displayField`. + * field carries no explicit `displayField` AND the target object resolves no + * title field ({@link ExportCellMeta.titleField}). Only a target with no title + * pointer reaches this list: a declared title is never second-guessed by it. */ const NAME_KEY_FALLBACKS = [ 'name', 'title', 'label', 'full_name', 'fullName', 'display_name', 'username', 'email', @@ -121,6 +141,50 @@ export function referenceFieldNames(metaMap: Map): stri return out; } +/** + * Stamp every reference field's {@link ExportCellMeta.titleField}: the title + * field of its target object, read once per distinct target per export. + * + * The rule is ADR-0079's `resolveDisplayField`, the spec's single arbiter of + * "which field titles a record of this object" — the same function the + * registry designates `nameField` with at registration, so over a schema the + * protocol serves it answers that designated pointer. A lookup cell therefore + * names the record the way the record names itself, rather than by whichever + * key of a fixed list the expanded record happens to carry (a contact's job + * `title` ahead of its `full_name` title). + * + * Best-effort, per target: a schema read that throws or returns nothing leaves + * that target's fields without a `titleField`, so their cells keep the + * key-list fallback exactly as before. Never throws. + */ +export async function withReferenceTitleFields( + metaMap: Map, + readSchema: (object: string) => Promise, +): Promise> { + const titles = new Map>(); + const titleOf = (target: string): Promise => { + let pending = titles.get(target); + if (!pending) { + pending = readSchema(target).then( + (schema) => (schema && typeof schema === 'object' ? resolveDisplayField(schema as never) : undefined), + () => undefined, + ); + titles.set(target, pending); + } + return pending; + }; + const out = new Map(); + for (const [key, meta] of metaMap) { + if (meta.type && REFERENCE_TYPES.has(meta.type) && meta.reference) { + const titleField = await titleOf(meta.reference); + out.set(key, titleField ? { ...meta, titleField } : meta); + } else { + out.set(key, meta); + } + } + return out; +} + /** Header label for a column: schema label when present, else the field name. */ export function headerLabel(field: string, metaMap: Map): string { return metaMap.get(field)?.label || field; @@ -333,19 +397,38 @@ export function cellFontColor(value: unknown, meta?: ExportFieldMeta): string | return toArgb(hit?.color); } -function displayFromRecord(rec: Record, displayField?: string): string { +/** + * The display text of one expanded referenced record. + * + * In order: the lookup field's own `displayField` (the field author's explicit + * choice); else the target object's resolved `titleField`; else — only for a + * target that resolves no title — the first scalar hit of + * {@link NAME_KEY_FALLBACKS}; else the record's id. + * + * A target that HAS a title field never consults the key list, also when the + * title is absent from this record: field-level security deletes an unreadable + * field from the expanded record, and the key list would then put another + * column in the name's place (a contact named by its job title). The id is the + * cell's floor instead. + */ +function displayFromRecord(rec: Record, displayField?: string, titleField?: string): string { if (displayField && rec[displayField] != null) return String(rec[displayField]); - for (const k of NAME_KEY_FALLBACKS) { - const v = rec[k]; + if (titleField) { + const v = rec[titleField]; if (v != null && typeof v !== 'object') return String(v); + } else { + for (const k of NAME_KEY_FALLBACKS) { + const v = rec[k]; + if (v != null && typeof v !== 'object') return String(v); + } } if (rec.id != null) return String(rec.id); try { return JSON.stringify(rec); } catch { return String(rec); } } -function formatReference(value: unknown, displayField?: string): unknown { +function formatReference(value: unknown, displayField?: string, titleField?: string): unknown { const one = (v: unknown): unknown => - v && typeof v === 'object' ? displayFromRecord(v as Record, displayField) : v; + v && typeof v === 'object' ? displayFromRecord(v as Record, displayField, titleField) : v; if (Array.isArray(value)) return value.map(one).join(', '); return one(value); } @@ -359,7 +442,7 @@ function formatReference(value: unknown, displayField?: string): unknown { */ export function formatCellValue( value: unknown, - meta?: ExportFieldMeta, + meta?: ExportCellMeta, timezone?: string, ): unknown { if (value === null || value === undefined) return value; @@ -377,7 +460,7 @@ export function formatCellValue( } if (t === 'date') return formatDate(value, false); if (t === 'datetime') return formatDate(value, true, timezone); - if (REFERENCE_TYPES.has(t)) return formatReference(value, meta.displayField); + if (REFERENCE_TYPES.has(t)) return formatReference(value, meta.displayField, meta.titleField); return value; } @@ -390,7 +473,7 @@ export function formatCellValue( export function formatRowCells( row: Record, fields: string[], - metaMap: Map, + metaMap: Map, timezone?: string, ): unknown[] { return fields.map((f) => formatCellValue(row?.[f], metaMap.get(f), timezone)); @@ -403,7 +486,7 @@ export function formatRowCells( */ export function formatRowForJson( row: Record, - metaMap: Map, + metaMap: Map, timezone?: string, ): Record { if (metaMap.size === 0 || !row || typeof row !== 'object') return row; diff --git a/packages/rest/src/export-lookup-title-field.test.ts b/packages/rest/src/export-lookup-title-field.test.ts new file mode 100644 index 00000000000..ad1820f1a6f --- /dev/null +++ b/packages/rest/src/export-lookup-title-field.test.ts @@ -0,0 +1,251 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A lookup cell in the list export names the referenced record by the TARGET + * object's declared title field (ADR-0079 `nameField`, deprecated alias + * `displayNameField`), not by the first hit of a fixed key list. + * + * Before this, `displayFromRecord` tried the lookup field's own `displayField` + * and then `['name', 'title', 'label', 'full_name', …]` against the expanded + * record. A contact declares `nameField: 'full_name'` and has no `name` column, + * so its job `title` came first and every case export named the contact by its + * job title ("VP of Engineering" instead of "John Smith"). + * + * The route under test is the REAL streaming export (`GET + * /api/v1/data/:object/export`) over a REAL engine + protocol + sqlite + * `:memory:` driver: the title field is read off the target schema the + * protocol serves, and the title value off the record the engine's `$expand` + * loaded through its own read path (formula titles evaluated there). + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; +import { RestServer } from './rest-server'; +import { loadXlsxWorkbook } from './xlsx-test-loader.js'; + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +/** The measured HotCRM shape: a FORMULA title, a job `title` column, no `name` column. */ +const CONTACT = { + name: 'xt_contact', + label: 'Contact', + systemFields: false, + nameField: 'full_name', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + title: { name: 'title', type: 'text' as const, label: 'Job Title' }, + first_name: { name: 'first_name', type: 'text' as const, label: 'First Name' }, + last_name: { name: 'last_name', type: 'text' as const, label: 'Last Name' }, + full_name: { + name: 'full_name', type: 'formula' as const, label: 'Full Name', returnType: 'text', + expression: { dialect: 'cel', source: 'record.first_name + " " + record.last_name' }, + }, + }, +}; + +/** A title on a stored column, declared through the deprecated alias only. */ +const PERSON = { + name: 'xt_person', + label: 'Person', + systemFields: false, + displayNameField: 'nickname', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + title: { name: 'title', type: 'text' as const, label: 'Job Title' }, + nickname: { name: 'nickname', type: 'text' as const, label: 'Nickname' }, + }, +}; + +/** A literal `name` column AND a declared title that is not it (HotCRM's account). */ +const ACCOUNT = { + name: 'xt_account', + label: 'Account', + systemFields: false, + nameField: 'display_title', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', type: 'text' as const, label: 'Name' }, + region: { name: 'region', type: 'text' as const, label: 'Region' }, + display_title: { + name: 'display_title', type: 'formula' as const, label: 'Display Title', returnType: 'text', + expression: { dialect: 'cel', source: 'record.name + " (" + record.region + ")"' }, + }, + }, +}; + +const CASE = { + name: 'xt_case', + label: 'Case', + systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + subject: { name: 'subject', type: 'text' as const, label: 'Subject' }, + contact: { name: 'contact', type: 'lookup' as const, label: 'Contact', reference: 'xt_contact' }, + person: { name: 'person', type: 'lookup' as const, label: 'Person', reference: 'xt_person' }, + account: { name: 'account', type: 'lookup' as const, label: 'Account', reference: 'xt_account' }, + // CONTROL: the lookup field's own `displayField` is the field author's + // explicit choice and still wins over the target's title. + contact_role: { + name: 'contact_role', type: 'lookup' as const, label: 'Contact Role', + reference: 'xt_contact', displayField: 'title', + }, + }, +}; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const chunks: string[] = []; + const headers: Record = {}; + const res: any = { + write: (s: string) => { chunks.push(typeof s === 'string' ? s : String(s)); return true; }, + end: () => {}, + header: (n: string, v: string) => { headers[n] = v; return res; }, + status: () => res, + json: (body: any) => { (res as any)._json = body; return res; }, + }; + return { res, chunks, headers }; +} + +function makeBinRes() { + const chunks: Buffer[] = []; + const res: any = { + write: (c: any) => { chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)); return true; }, + end: () => {}, + header: () => res, + status: () => res, + json: () => res, + }; + return { res, getBuffer: () => Buffer.concat(chunks) }; +} + +async function boot(opts: { protocolOverride?: (protocol: any) => any } = {}) { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), true); + await engine.init(); + for (const o of [CONTACT, PERSON, ACCOUNT, CASE]) engine.registry.registerObject(o as any); + await engine.syncSchemas(); + await engine.insert('xt_contact', { id: 'c1', title: 'VP of Engineering', first_name: 'John', last_name: 'Smith' }); + await engine.insert('xt_person', { id: 'p1', title: 'Chief Procurement Officer', nickname: 'Wren' }); + await engine.insert('xt_account', { id: 'a1', name: 'Acme', region: 'EMEA' }); + await engine.insert('xt_case', { + id: '1', subject: 'Login issues', contact: 'c1', person: 'p1', account: 'a1', contact_role: 'c1', + }); + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } + const real = new ObjectStackProtocolImplementation(engine as any); + const protocol = opts.protocolOverride ? opts.protocolOverride(real) : real; + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const route = rest.getRoutes().find( + (r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export', + )!; + return { engine, route }; +} + +function parseCsv(text: string): string[][] { + return text.split('\r\n').filter((l) => l.length > 0).map((l) => l.split(',')); +} + +async function exportCsv(route: any, fields: string): Promise { + const { res, chunks } = makeRes(); + await route.handler({ params: { object: 'xt_case' }, query: { format: 'csv', fields } } as any, res); + return parseCsv(chunks.join('')); +} + +describe('export — a lookup cell names the target record by its declared title field', () => { + it('a target with `nameField` and no `name` column exports the title, not the job title (CSV)', async () => { + const { route } = await boot(); + const rows = await exportCsv(route, 'id,contact'); + expect(rows[0]).toEqual(['ID', 'Contact']); + expect(rows[1]).toEqual(['1', 'John Smith']); + }); + + it('the same cell through xlsx and json', async () => { + const { route } = await boot(); + const bin = makeBinRes(); + await route.handler({ params: { object: 'xt_case' }, query: { format: 'xlsx', fields: 'id,contact' } } as any, bin.res); + const ws = (await loadXlsxWorkbook(bin.getBuffer())).worksheets[0]; + expect((ws.getRow(2).values as any[]).slice(1).map(String)).toEqual(['1', 'John Smith']); + + const json = makeRes(); + await route.handler({ params: { object: 'xt_case' }, query: { format: 'json', fields: 'id,contact' } } as any, json.res); + expect(JSON.parse(json.chunks.join(''))).toEqual([{ id: '1', contact: 'John Smith' }]); + }); + + it('the deprecated `displayNameField` alias is honoured the same way', async () => { + const { route } = await boot(); + const rows = await exportCsv(route, 'id,person'); + expect(rows[1]).toEqual(['1', 'Wren']); + }); + + it('a declared title outranks a literal `name` column (account → display_title)', async () => { + const { route } = await boot(); + const rows = await exportCsv(route, 'id,account'); + expect(rows[1]).toEqual(['1', 'Acme (EMEA)']); + }); + + it('CONTROL: the lookup field\'s own `displayField` still wins', async () => { + const { route } = await boot(); + const rows = await exportCsv(route, 'id,contact_role'); + expect(rows[1]).toEqual(['1', 'VP of Engineering']); + }); + + it('CONTROL: a target whose schema declares neither keeps the key-list fallback', async () => { + // The target schema is served WITHOUT a title pointer (a registry object + // always carries one: the registry designates it at registration), so the + // only way to an undeclared target through the real route is a served body + // that lacks both keys. The key list then answers as before: `title`. + const { route } = await boot({ + protocolOverride: (real) => new Proxy(real, { + get(target, prop, receiver) { + if (prop !== 'getMetaItem') return Reflect.get(target, prop, receiver); + return async (req: any) => { + const out = await target.getMetaItem(req); + if (req?.name !== 'xt_contact') return out; + const { nameField: _n, displayNameField: _d, ...item } = (out as any).item; + return { ...(out as any), item }; + }; + }, + }), + }); + const rows = await exportCsv(route, 'id,contact'); + expect(rows[1]).toEqual(['1', 'VP of Engineering']); + }); + + it('a title field the caller cannot read is not exported, and the key list does not stand in for it', async () => { + const { engine, route } = await boot(); + // The FieldMasker's delete-key semantics on the TARGET object: the `$expand` + // sub-read is a `find` on `xt_contact`, so the mask applies to it. + engine.registerHook( + 'afterFind', + (ctx: any) => { if (Array.isArray(ctx.result)) for (const r of ctx.result) { if (r) delete r.full_name; } }, + { object: 'xt_contact' }, + ); + const rows = await exportCsv(route, 'id,contact'); + // Neither the masked title nor the job title passing for a name — the + // record's id, the cell's final floor. + expect(rows[1]).toEqual(['1', 'c1']); + const json = makeRes(); + await route.handler({ params: { object: 'xt_case' }, query: { format: 'json', fields: 'id,contact' } } as any, json.res); + expect(json.chunks.join('')).not.toContain('John Smith'); + }); +}); From 6dabdfbeab235ed65279c1f252ee492407ba0c14 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:45:35 +0000 Subject: [PATCH 2/2] test(rest): pin the lookup-cell title precedence at unit level; changeset Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- .changeset/24944-export-lookup-title-field.md | 15 +++++++ .../src/export-lookup-title-field.test.ts | 41 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 .changeset/24944-export-lookup-title-field.md diff --git a/.changeset/24944-export-lookup-title-field.md b/.changeset/24944-export-lookup-title-field.md new file mode 100644 index 00000000000..7bf133a27ac --- /dev/null +++ b/.changeset/24944-export-lookup-title-field.md @@ -0,0 +1,15 @@ +--- +'@objectstack/rest': patch +--- + +fix(rest): a lookup cell in the list export names the record by the target object's title field (#24944) + +Clause-②: no + +`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"). + +- **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. +- **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. +- **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. +- **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`. +- **Unchanged.** No query parameter, header, column set or payload shape moves. Only the text of a lookup cell changes. diff --git a/packages/rest/src/export-lookup-title-field.test.ts b/packages/rest/src/export-lookup-title-field.test.ts index ad1820f1a6f..e9449f7ef80 100644 --- a/packages/rest/src/export-lookup-title-field.test.ts +++ b/packages/rest/src/export-lookup-title-field.test.ts @@ -24,6 +24,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; import { RestServer } from './rest-server'; +import { formatCellValue, withReferenceTitleFields, type ExportFieldMeta } from './export-format.js'; import { loadXlsxWorkbook } from './xlsx-test-loader.js'; const liveEngines: ObjectQL[] = []; @@ -249,3 +250,43 @@ describe('export — a lookup cell names the target record by its declared title expect(json.chunks.join('')).not.toContain('John Smith'); }); }); + +describe('withReferenceTitleFields + formatCellValue — the precedence, unit level', () => { + const lookup = (name: string, reference: string, displayField?: string): ExportFieldMeta => + ({ name, type: 'lookup', reference, ...(displayField ? { displayField } : {}) }); + + it('reads each target schema once per export and stamps only reference fields', async () => { + const reads: string[] = []; + const meta = new Map([ + ['subject', { name: 'subject', type: 'text' }], + ['contact', lookup('contact', 'xt_contact')], + ['backup_contact', lookup('backup_contact', 'xt_contact')], + ]); + const out = await withReferenceTitleFields(meta, async (object) => { + reads.push(object); + return { name: object, nameField: 'full_name', fields: { full_name: { type: 'text' } } }; + }); + expect(reads).toEqual(['xt_contact']); + expect(out.get('contact')?.titleField).toBe('full_name'); + expect(out.get('backup_contact')?.titleField).toBe('full_name'); + expect(out.get('subject')).toEqual({ name: 'subject', type: 'text' }); + }); + + it('a target schema read that throws leaves that column on the key list, and does not throw', async () => { + const meta = new Map([['contact', lookup('contact', 'xt_contact')]]); + const out = await withReferenceTitleFields(meta, async () => { throw new Error('metadata offline'); }); + expect(out.get('contact')?.titleField).toBeUndefined(); + const rec = { id: 'c1', title: 'VP of Engineering', full_name: 'John Smith' }; + expect(formatCellValue(rec, out.get('contact'))).toBe('VP of Engineering'); + }); + + it('displayField, then titleField, then the id — the key list only without a titleField', () => { + const rec = { id: 'c1', title: 'VP of Engineering', full_name: 'John Smith' }; + const titled = { ...lookup('contact', 'xt_contact'), titleField: 'full_name' }; + expect(formatCellValue(rec, titled)).toBe('John Smith'); + expect(formatCellValue(rec, { ...titled, displayField: 'title' })).toBe('VP of Engineering'); + expect(formatCellValue({ id: 'c1', title: 'VP of Engineering' }, titled)).toBe('c1'); + expect(formatCellValue([rec, { id: 'c2', full_name: 'Ann Lee' }], { ...titled, multiple: true })) + .toBe('John Smith, Ann Lee'); + }); +});