Skip to content

Commit d85ff19

Browse files
fix(core): resolve an import lookup cell by the target object's unique fields, an autonumber included (#28112)
Fixes #27947 Clause-②: no Metadata-protocol: none Skills-docs: none — `skills/**` and `content/docs/**` (outside the generated `references/` and the release-owned `releases/`) carry no sentence listing the import lookup candidate fields; grepped for the fixed keys, `reference_not_found`, `reference_ambiguous` and the lookup-cell wording. ## 维护者速读 - 改了什么:数据导入的 lookup 单元格匹配,在原有候选(id、lookup 的 displayField、目标对象标题字段、name/title/label/full_name/email/username)全部落空后,再按目标对象字段声明顺序尝试其唯一字段(`unique` 声明了约束的字段,以及未写 `unique` 的 autonumber 字段)。 - 为什么改:单元格写的是目标记录的编号(如 `ACC-000012`)或其它唯一字段时,今天一律报 `reference_not_found`;记录标题一改,之前导出的文件就全部导不回来。 - 风险与回滚:只在原有候选全部未命中时才多查几次;原有候选命中时行为不变,多条命中仍报 `reference_ambiguous`,不会静默挑一条。回滚即还原 `import-runner.ts` 这一处候选列表。 - 先看:`packages/core/src/utils/import-runner.ts` 的 `isUniqueReferenceField`(autonumber 默认唯一、`unique: false` 退出)与 `resolveRef` 候选列表末尾一行;其次 `import-runner-reference-unique.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/core exec vitest run src/utils/import-runner-reference-unique.test.ts`,预期 6 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes `resolveRef` in `packages/core/src/utils/import-runner.ts` asked the id, the lookup's `displayField`, the target's title field, then `name` / `title` / `label` / `full_name` / `email` / `username`, and stopped. A cell holding the target's autonumber or another unique field answered `reference_not_found`. The target's unique fields are now appended to the candidate list, read once per target per import off the same declaration the exposure check already reads (`servesReferenceTarget`), so no extra metadata read: - a field whose `unique` declares a constraint, judged by the spec's own `isUniqueDeclared` (`true`, `'organization'`, `'global'`); - an `autonumber` field that omits `unique`: the spec makes it `unique: 'organization'` by contract (`FieldSchema`'s autonumber default). The default is applied in the predicate too, so a declaration read unparsed still counts; - an `autonumber` declared `unique: false` has opted out (the spec's display-only ordinal) and is not asked. They come after every existing candidate, in the target's declared field order, de-duplicated against the existing list (a unique `email` is asked once, in its fixed place), and virtual fields are still skipped. The existing rules are unchanged: the first field to match wins, and a field matching more than one record is `reference_ambiguous`. No spec key, no published shape and no public export changes. ## Tests New `packages/core/src/utils/import-runner-reference-unique.test.ts` (6 cases), beside the `#24981` title pins: - an autonumber cell `ACC-000012` resolves and stores the matched id, asked after every existing candidate; - another declared-unique field (`unique: true`) resolves; - order control: a cell that is one record's `name` and another record's autonumber links the `name` match, and the autonumber is never asked; - a cell matching nothing still answers `reference_not_found`, after asking the unique fields in declared order, the unique `email` once, the opted-out autonumber never; - an opted-out autonumber's value answers `reference_not_found`; - a unique field two records answer is `reference_ambiguous` and links neither. Measured on `main` 8cb8c7f before the fix: 4 of 6 red, the autonumber cell answering `reference_not_found` (`expected ok: true`, received `code: "reference_not_found"`, `field: "account"`). Ablations on the fix commit, each restored from `HEAD` after the run: | Ablation | Red | |:---|:---| | drop the unique-field candidates | 4 (autonumber, unique, not-found order, ambiguity) | | ask the unique fields first | 5, the order-control pin among them | | ignore the `unique: false` opt-out | 2 (opt-out pin, declared-order pin) | | silently pick the first of several matches | 1 (ambiguity pin) | | drop the de-duplication | 4 | ## Acceptance notes - A cell that matches nothing now costs one query more per unique field of the target before it answers `reference_not_found` (misses are not cached, as before, so a repeated unmatched cell repeats them). Matching cells cost what they did. - An `organization`-scoped unique field read across organizations can answer two records; that is reported `reference_ambiguous`, never a silent pick. ## Verification - Pins and ablations as above. - Local gates: every family `node scripts/pm/dispatch-gates.mjs --commands` derives for this diff, exit codes captured, reconciled with `--ran`; `pnpm lint`. Counts are in the os-dev report on the card. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4czVnUdQQCVKSW5J2uk Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent 27e0bd5 commit d85ff19

3 files changed

Lines changed: 188 additions & 6 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
fix(core): an import lookup cell also resolves by the target object's unique fields, an autonumber included
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A data import lookup cell is matched against the target object's id, the lookup's `displayField`, the target's title field and the fixed keys `name`, `title`, `label`, `full_name`, `email`, `username`. A cell holding the target's autonumber (`ACC-000012`) or another unique field matched none of them and answered `reference_not_found`, so a title change broke every export file written before it.
11+
12+
The matcher now also asks, after those candidates and in the target's declared field order, every field whose `unique` declares a constraint (`true`, `'organization'`, `'global'`) and every `autonumber` field that omits `unique` (unique per organization by the spec's default). An autonumber declared `unique: false` is not asked. The existing rules are unchanged: the first field to match wins, an earlier candidate that matches still wins, a field that matches more than one record answers `reference_ambiguous`, and a cell matching nothing answers `reference_not_found`.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27947] An import cell is also resolved against a lookup TARGET's unique
5+
* fields: every field whose `unique` declares a constraint (`isUniqueDeclared`:
6+
* `true`, `'organization'`, `'global'`), and every `autonumber` field that
7+
* omits `unique`, which the spec makes `unique: 'organization'` by contract.
8+
* An autonumber that writes `unique: false` has opted out and is not asked.
9+
*
10+
* They are asked AFTER the existing candidates (the id, the lookup's
11+
* `displayField`, the target's title field, the fixed human keys), in the
12+
* target's declared field order, each at most once. The existing rules hold:
13+
* the first field to match wins, and a field matching more than one record is
14+
* `reference_ambiguous`, never a silent pick.
15+
*
16+
* The fake's `findData` answers like the engine: equality over the stored row.
17+
*/
18+
19+
import { describe, it, expect, vi } from 'vitest';
20+
import { runImport, type ImportProtocolLike } from './import-runner';
21+
import type { ExportFieldMeta } from './import-field-meta.js';
22+
23+
type FindArgs = Parameters<ImportProtocolLike['findData']>[0];
24+
type Row = Record<string, unknown>;
25+
type Declaration = { fields: Record<string, Row>; nameField?: string };
26+
27+
const TEXT = { type: 'text' };
28+
29+
const DECLARATIONS: Record<string, Declaration> = {
30+
xu_account: {
31+
nameField: 'name',
32+
fields: {
33+
name: TEXT,
34+
// The contract default: an autonumber that omits `unique` is unique.
35+
account_number: { type: 'autonumber', autonumberFormat: 'ACC-{000000}' },
36+
region: TEXT,
37+
tax_id: { type: 'text', unique: true },
38+
// An opted-out autonumber: a display-only ordinal, never asked.
39+
ordinal: { type: 'autonumber', unique: false },
40+
external_code: { type: 'text', unique: 'global' },
41+
// Already a fixed key: asked once, in its fixed place.
42+
email: { type: 'email', unique: 'organization' },
43+
},
44+
},
45+
};
46+
47+
const ROWS: Record<string, Row[]> = {
48+
xu_account: [
49+
{ id: 'a1', name: 'Acme', account_number: 'ACC-000012', region: 'EMEA', tax_id: 'TX-1', ordinal: '7', external_code: 'EXT-1' },
50+
// A name that equals another record's autonumber: the name is asked first.
51+
{ id: 'a2', name: 'ACC-000013', account_number: 'ACC-000002', region: 'APAC', tax_id: 'TX-2', ordinal: '8' },
52+
{ id: 'a3', name: 'Globex', account_number: 'ACC-000013', region: 'APAC', tax_id: 'TX-3', ordinal: '9' },
53+
// Two holders of one tax id, as a read across organizations can see.
54+
{ id: 'a4', name: 'Initech', account_number: 'ACC-000004', tax_id: 'TX-SHARED', ordinal: '10' },
55+
{ id: 'a5', name: 'Umbrella', account_number: 'ACC-000005', tax_id: 'TX-SHARED', ordinal: '11' },
56+
],
57+
};
58+
59+
const metaMap = new Map<string, ExportFieldMeta>([
60+
['name', { name: 'name', type: 'text' }],
61+
['account', { name: 'account', type: 'lookup', reference: 'xu_account' }],
62+
]);
63+
64+
function protocol() {
65+
const findData = vi.fn(async (args: FindArgs) => {
66+
const [[field, value]] = Object.entries(args.query!.where!);
67+
return (ROWS[args.object] ?? []).filter((r) => r[field] === value);
68+
});
69+
const getMetaItem = vi.fn(async ({ name }: { type: string; name: string }) =>
70+
({ type: 'object', name, item: { name, ...DECLARATIONS[name] } }));
71+
const createData = vi.fn(async (a: { data: Row }) => ({ id: 'new', ...a.data }));
72+
const p: ImportProtocolLike = { findData, getMetaItem, createData, updateData: vi.fn() };
73+
return { p, findData, createData };
74+
}
75+
76+
const run = (p: ImportProtocolLike, rows: Row[]) =>
77+
runImport({
78+
p, context: {}, objectName: 'xu_source', metaMap, writeMode: 'insert', matchFields: [], dryRun: false,
79+
runAutomations: false, trimWhitespace: true, createMissingOptions: false, skipBlankMatchKey: false, rows,
80+
});
81+
82+
/** The `where` keys asked of the target, in order. */
83+
const asked = (findData: ReturnType<typeof protocol>['findData']) =>
84+
findData.mock.calls.filter(([a]) => a.object === 'xu_account').map(([a]) => Object.keys(a.query!.where!)[0]);
85+
86+
const verdict = (r: { ok: boolean; code?: string; field?: string }) => ({ ok: r.ok, code: r.code, field: r.field });
87+
const OK = { ok: true, code: undefined, field: undefined };
88+
const FIXED = ['id', 'name', 'title', 'label', 'full_name', 'email', 'username'];
89+
90+
describe('[#27947] import resolves a lookup cell by the target object\'s unique fields', () => {
91+
it('an autonumber cell (ACC-000012) resolves, asked after every existing candidate', async () => {
92+
const { p, findData, createData } = protocol();
93+
const summary = await run(p, [{ name: 'r', account: 'ACC-000012' }]);
94+
expect(summary.results.map(verdict)).toEqual([OK]);
95+
expect(createData.mock.calls.map(([a]) => a.data.account)).toEqual(['a1']);
96+
expect(asked(findData)).toEqual([...FIXED, 'account_number']);
97+
});
98+
99+
it('another declared-unique field (tax_id, unique: true) resolves', async () => {
100+
const { p, findData, createData } = protocol();
101+
const summary = await run(p, [{ name: 'r', account: 'TX-3' }]);
102+
expect(summary.results.map(verdict)).toEqual([OK]);
103+
expect(createData.mock.calls.map(([a]) => a.data.account)).toEqual(['a3']);
104+
expect(asked(findData)).toEqual([...FIXED, 'account_number', 'tax_id']);
105+
});
106+
107+
it('an earlier candidate that matches still wins: the name ACC-000013 links a2, not a3\'s autonumber', async () => {
108+
const { p, findData, createData } = protocol();
109+
const summary = await run(p, [{ name: 'r', account: 'ACC-000013' }]);
110+
expect(summary.results.map(verdict)).toEqual([OK]);
111+
expect(createData.mock.calls.map(([a]) => a.data.account)).toEqual(['a2']);
112+
expect(asked(findData)).toEqual(['id', 'name']);
113+
});
114+
115+
it('a value matching nothing still answers reference_not_found, after the unique fields in declared order', async () => {
116+
const { p, findData, createData } = protocol();
117+
const summary = await run(p, [{ name: 'r', account: 'ACC-999999' }]);
118+
expect(summary.results.map(verdict)).toEqual([{ ok: false, code: 'reference_not_found', field: 'account' }]);
119+
expect(createData).not.toHaveBeenCalled();
120+
// Declared order, `email` not asked twice, the opted-out `ordinal` never.
121+
expect(asked(findData)).toEqual([...FIXED, 'account_number', 'tax_id', 'external_code']);
122+
});
123+
124+
it('an opted-out autonumber (unique: false) is not asked: its value answers reference_not_found', async () => {
125+
const { p, findData } = protocol();
126+
const summary = await run(p, [{ name: 'r', account: '7' }]);
127+
expect(summary.results.map(verdict)).toEqual([{ ok: false, code: 'reference_not_found', field: 'account' }]);
128+
expect(asked(findData)).not.toContain('ordinal');
129+
});
130+
131+
it('a unique field two records answer is still reference_ambiguous, and links neither', async () => {
132+
const { p, findData, createData } = protocol();
133+
const summary = await run(p, [{ name: 'r', account: 'TX-SHARED' }]);
134+
expect(summary.results.map(verdict)).toEqual([{ ok: false, code: 'reference_ambiguous', field: 'account' }]);
135+
expect(createData).not.toHaveBeenCalled();
136+
expect(asked(findData)).toEqual([...FIXED, 'account_number', 'tax_id']);
137+
});
138+
});

‎packages/core/src/utils/import-runner.ts‎

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { coerceRow, type CoerceContext, type RefResolver, type RefMatch } from '
55
import type { ExportFieldMeta } from './import-field-meta.js';
66
import type { ValidationMessageTranslator } from '@objectstack/spec/system';
77
import {
8-
canServeApiOperation, isMultiValueField, isVirtualSearchField, resolveDisplayField, STRUCTURED_JSON_TYPES,
8+
canServeApiOperation, isMultiValueField, isUniqueDeclared, isVirtualSearchField, resolveDisplayField, STRUCTURED_JSON_TYPES,
99
type DisplayNameObjectMeta, type DroppedFieldsEvent, type EnableLike, type SearchFieldMeta,
1010
} from '@objectstack/spec/data';
1111
import type { ExecutionContext } from '@objectstack/spec/kernel';
@@ -639,12 +639,33 @@ async function servesReferenceTarget(p: ImportProtocolLike, target: string): Pro
639639
}
640640
if (!canServeApiOperation(item?.enable, REFERENCE_TARGET_OPERATION)) return null;
641641
if (!item || typeof item !== 'object') return {};
642+
const fields = item.fields as ServedReferenceTarget['fields'];
642643
return {
643644
titleField: resolveDisplayField(item),
644-
fields: item.fields as ServedReferenceTarget['fields'],
645+
fields,
646+
uniqueFields: Object.keys(fields ?? {}).filter((f) => isUniqueReferenceField(fields?.[f])),
645647
};
646648
}
647649

650+
/**
651+
* [#27947] Does this target field name at most one record, so a cell holding
652+
* its value identifies the record? Yes when its `unique` declares a constraint
653+
* (`isUniqueDeclared`: `true`, `'organization'`, `'global'`), and for an
654+
* `autonumber` that omits `unique`: the spec makes that field
655+
* `unique: 'organization'` by contract (`FieldSchema`'s autonumber default),
656+
* and the default is applied here too in case the declaration was read
657+
* unparsed. An autonumber that writes `unique: false` has opted out (a
658+
* display-only ordinal) and is not an identifier.
659+
*/
660+
function isUniqueReferenceField(meta: ReferenceTargetFieldMeta | undefined): boolean {
661+
if (!meta) return false;
662+
if (isUniqueDeclared(meta.unique)) return true;
663+
return meta.type === 'autonumber' && meta.unique === undefined;
664+
}
665+
666+
/** The slice of a lookup target's field declaration the reference resolution reads. */
667+
type ReferenceTargetFieldMeta = SearchFieldMeta & { unique?: unknown };
668+
648669
/** The slice of a lookup target's declaration the reference resolution reads. */
649670
type ReferenceTargetDeclaration = DisplayNameObjectMeta & { enable?: EnableLike | null };
650671

@@ -662,11 +683,18 @@ interface ServedReferenceTarget {
662683
*/
663684
titleField?: string;
664685
/**
665-
* The target's declared field map. It is read for one question only: which
686+
* The target's declared field map. It is read for two questions: which
666687
* candidate fields are virtual ({@link isVirtualSearchField}), because a
667-
* `where` over a virtual field is refused by the engine's filter seam.
688+
* `where` over a virtual field is refused by the engine's filter seam, and
689+
* which are unique ({@link ServedReferenceTarget.uniqueFields}).
690+
*/
691+
fields?: Readonly<Record<string, ReferenceTargetFieldMeta | undefined>>;
692+
/**
693+
* [#27947] The target's unique fields ({@link isUniqueReferenceField}), in
694+
* declared order: the last candidates a cell is asked by, so an export file
695+
* written before a record's title changed still resolves by its autonumber.
668696
*/
669-
fields?: Readonly<Record<string, SearchFieldMeta | undefined>>;
697+
uniqueFields?: readonly string[];
670698
}
671699

672700
/** Upper bound on rows in one createManyData batch (framework#2678 suggests 100-500). */
@@ -821,7 +849,10 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
821849
// Try an exact id first (authoritative + unique when the user pasted an id),
822850
// then the configured display field, then the target's own title field
823851
// (#24981: ADR-0079's rule, the field the list export writes the cell by),
824-
// then the usual human identifiers. De-dupe so a field isn't queried twice.
852+
// then the usual human identifiers, then the target's unique fields in
853+
// declared order (#27947: an autonumber included, so a cell naming the
854+
// record by its number resolves after a title change). De-dupe so a field
855+
// isn't queried twice.
825856
// The first candidate field to match wins; if that field matches >1 record
826857
// we stop and report ambiguity rather than silently linking the first.
827858
//
@@ -836,6 +867,7 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
836867
...(meta.displayField ? [meta.displayField] : []),
837868
...(target.titleField ? [target.titleField] : []),
838869
'name', 'title', 'label', 'full_name', 'email', 'username',
870+
...(target.uniqueFields ?? []),
839871
])].filter((f) => !isVirtualSearchField(target.fields?.[f]));
840872
const lookup = async (): Promise<RefMatch> => {
841873
let match: RefMatch = {};

0 commit comments

Comments
 (0)