Skip to content

Commit fd4db1c

Browse files
committed
test(rest): a stored computed title re-imports a lookup cell through the real import door
The ruling's remedy for a composite title (a non-formula field with an expression, computed on write) resolves by its title on import, through the real POST /api/v1/data/:object/import route, a real ObjectQL engine and a sqlite :memory: driver. Two records whose computed titles collide answer reference_ambiguous. A formula-declared twin is the control leg. Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 72518d2 commit fd4db1c

1 file changed

Lines changed: 171 additions & 0 deletions

File tree

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24981] The import matches a lookup cell against a STORED title only
5+
* (ruling 6308445871, letter A: ADR-0079 addendum item 9, extended to the
6+
* import). A `formula` title is computed on read and stored nowhere, so no
7+
* `where` can ask it, and a target titled by one answers
8+
* `reference_not_found`. The remedy the ruling binds is the producer's: a
9+
* composite title that must round-trip through import is a STORED computed
10+
* field, a non-formula field with an `expression`, which the engine computes
11+
* and stores on every write (#24673).
12+
*
13+
* This file pins that remedy end to end, through the REAL import door
14+
* (`POST /api/v1/data/:object/import`), a REAL ObjectQL engine and a REAL
15+
* sqlite `:memory:` driver. The target rows are written WITHOUT their title,
16+
* the engine computes it on write, and the import's title rung (the target's
17+
* `resolveDisplayField`, after the id and the lookup's own `displayField`)
18+
* finds the record by one `where` over the stored column.
19+
*
20+
* Not repeated here, because the runner's own suite pins it closer to the
21+
* code (`packages/core/src/utils/import-runner-reference-title.test.ts`): that
22+
* a formula candidate is never asked by `where`, the exact candidate order,
23+
* the one metadata read per target, and #22739's exposure check. The formula
24+
* row below is only this file's control leg: the same title over the same
25+
* rows, declared as a formula, so the remedy is shown to be what moves the
26+
* verdict.
27+
*/
28+
29+
import { describe, it, expect, afterEach } from 'vitest';
30+
import { ObjectQL } from '@objectstack/objectql';
31+
import { SqlDriver } from '@objectstack/driver-sql';
32+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
33+
import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core';
34+
import { RestServer } from './rest-server';
35+
36+
const liveEngines: ObjectQL[] = [];
37+
afterEach(async () => {
38+
while (liveEngines.length) {
39+
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
40+
}
41+
});
42+
43+
const ACCOUNT_TITLE = { dialect: 'cel', source: 'record.name + " (" + record.region + ")"' };
44+
const CONTACT_TITLE = { dialect: 'cel', source: 'record.first_name + " " + record.last_name' };
45+
46+
/** `Acme (EMEA)`: a stored computed title whose name is NOT on the fallback key list. */
47+
const ACCOUNT = {
48+
name: 'xt_account', label: 'Account', systemFields: false, nameField: 'display_title',
49+
fields: {
50+
id: { name: 'id', type: 'text' as const, primaryKey: true },
51+
name: { name: 'name', type: 'text' as const, label: 'Name' },
52+
region: { name: 'region', type: 'text' as const, label: 'Region' },
53+
display_title: { name: 'display_title', type: 'text' as const, label: 'Display Title', expression: ACCOUNT_TITLE },
54+
},
55+
};
56+
57+
/** The control: the same title over the same rows, declared as a formula. */
58+
const FORMULA_ACCOUNT = {
59+
...ACCOUNT,
60+
name: 'xt_formula_account', label: 'Formula Account',
61+
fields: {
62+
...ACCOUNT.fields,
63+
display_title: {
64+
name: 'display_title', type: 'formula' as const, label: 'Display Title', returnType: 'text', expression: ACCOUNT_TITLE,
65+
},
66+
},
67+
};
68+
69+
/** `John Smith`: a stored computed title, the shape a contact's composite name takes under the ruling. */
70+
const CONTACT = {
71+
name: 'xt_contact', label: 'Contact', systemFields: false, nameField: 'full_name',
72+
fields: {
73+
id: { name: 'id', type: 'text' as const, primaryKey: true },
74+
title: { name: 'title', type: 'text' as const, label: 'Job Title' },
75+
first_name: { name: 'first_name', type: 'text' as const, label: 'First Name' },
76+
last_name: { name: 'last_name', type: 'text' as const, label: 'Last Name' },
77+
full_name: { name: 'full_name', type: 'text' as const, label: 'Full Name', expression: CONTACT_TITLE },
78+
},
79+
};
80+
81+
/** The imported object: one lookup per target, none with a `displayField` of its own. */
82+
const CASE = {
83+
name: 'xt_case', label: 'Case', systemFields: false,
84+
fields: {
85+
id: { name: 'id', type: 'text' as const, primaryKey: true },
86+
subject: { name: 'subject', type: 'text' as const, label: 'Subject' },
87+
account: { name: 'account', type: 'lookup' as const, label: 'Account', reference: 'xt_account' },
88+
formula_account: { name: 'formula_account', type: 'lookup' as const, label: 'Formula Account', reference: 'xt_formula_account' },
89+
contact: { name: 'contact', type: 'lookup' as const, label: 'Contact', reference: 'xt_contact' },
90+
},
91+
};
92+
93+
function createMockServer() {
94+
const noop = () => {};
95+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
96+
}
97+
98+
function makeRes() {
99+
const res: any = {
100+
write: () => true, end: () => {},
101+
header: () => res,
102+
status: (code: number) => { res._status = code; return res; },
103+
json: (body: any) => { res._json = body; return res; },
104+
};
105+
return res;
106+
}
107+
108+
async function boot() {
109+
const engine = new ObjectQL();
110+
liveEngines.push(engine);
111+
engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true);
112+
await engine.init();
113+
for (const o of [ACCOUNT, FORMULA_ACCOUNT, CONTACT, CASE]) engine.registry.registerObject(o as any);
114+
await engine.syncSchemas();
115+
for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) {
116+
if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any);
117+
}
118+
const protocol = new ObjectStackProtocolImplementation(engine as any);
119+
const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any);
120+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
121+
rest.registerRoutes();
122+
const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/import');
123+
expect(route).toBeDefined();
124+
const importCases = async (rows: Array<Record<string, unknown>>) => {
125+
const res = makeRes();
126+
await route!.handler({ params: { object: 'xt_case' }, body: { format: 'json', rows } } as any, res);
127+
return res._json;
128+
};
129+
return { engine, importCases };
130+
}
131+
132+
const verdicts = (summary: any) =>
133+
summary.results.map((r: any) => ({ ok: r.ok, code: r.code, field: r.field }));
134+
const OK = { ok: true, code: undefined, field: undefined };
135+
136+
describe('[#24981] the ruling\'s remedy: a stored computed title re-imports through the real door', () => {
137+
it('a title the engine computed on write resolves its lookup cell; the same title as a formula does not', async () => {
138+
const { engine, importCases } = await boot();
139+
// The title is NEVER sent: the engine computes and stores it on write.
140+
await engine.insert('xt_account', { id: 'a1', name: 'Acme', region: 'EMEA' });
141+
await engine.insert('xt_formula_account', { id: 'f1', name: 'Acme', region: 'EMEA' });
142+
await engine.insert('xt_contact', { id: 'c1', title: 'VP of Engineering', first_name: 'John', last_name: 'Smith' });
143+
expect((await engine.findOne('xt_account', { where: { id: 'a1' } }))?.display_title).toBe('Acme (EMEA)');
144+
expect((await engine.findOne('xt_contact', { where: { id: 'c1' } }))?.full_name).toBe('John Smith');
145+
146+
const summary = await importCases([
147+
{ id: 'k1', subject: 'stored, off the fallback list', account: 'Acme (EMEA)' },
148+
{ id: 'k2', subject: 'stored, on the fallback list', contact: 'John Smith' },
149+
{ id: 'k3', subject: 'control: the same title as a formula', formula_account: 'Acme (EMEA)' },
150+
]);
151+
expect(verdicts(summary)).toEqual([OK, OK, { ok: false, code: 'reference_not_found', field: 'formula_account' }]);
152+
expect((await engine.findOne('xt_case', { where: { id: 'k1' } }))?.account).toBe('a1');
153+
expect((await engine.findOne('xt_case', { where: { id: 'k2' } }))?.contact).toBe('c1');
154+
expect(await engine.findOne('xt_case', { where: { id: 'k3' } })).toBeFalsy();
155+
});
156+
157+
it('two records whose computed titles collide answer the import\'s ambiguity refusal, and link neither', async () => {
158+
const { engine, importCases } = await boot();
159+
await engine.insert('xt_account', { id: 'a1', name: 'Acme', region: 'EMEA' });
160+
await engine.insert('xt_account', { id: 'a2', name: 'Acme', region: 'EMEA' });
161+
await engine.insert('xt_account', { id: 'a3', name: 'Acme', region: 'APAC' });
162+
163+
const summary = await importCases([
164+
{ id: 'k1', subject: 'two records titled alike', account: 'Acme (EMEA)' },
165+
{ id: 'k2', subject: 'one record titled so', account: 'Acme (APAC)' },
166+
]);
167+
expect(verdicts(summary)).toEqual([{ ok: false, code: 'reference_ambiguous', field: 'account' }, OK]);
168+
expect(await engine.findOne('xt_case', { where: { id: 'k1' } })).toBeFalsy();
169+
expect((await engine.findOne('xt_case', { where: { id: 'k2' } }))?.account).toBe('a3');
170+
});
171+
});

0 commit comments

Comments
 (0)