diff --git a/.changeset/27947-import-lookup-unique-fields.md b/.changeset/27947-import-lookup-unique-fields.md new file mode 100644 index 00000000000..8012b51eae7 --- /dev/null +++ b/.changeset/27947-import-lookup-unique-fields.md @@ -0,0 +1,12 @@ +--- +'@objectstack/core': patch +--- + +fix(core): an import lookup cell also resolves by the target object's unique fields, an autonumber included + +Clause-②: no +Metadata-protocol: none + +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. + +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`. diff --git a/packages/core/src/utils/import-runner-reference-unique.test.ts b/packages/core/src/utils/import-runner-reference-unique.test.ts new file mode 100644 index 00000000000..91379d86857 --- /dev/null +++ b/packages/core/src/utils/import-runner-reference-unique.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27947] An import cell is also resolved against a lookup TARGET's unique + * fields: every field whose `unique` declares a constraint (`isUniqueDeclared`: + * `true`, `'organization'`, `'global'`), and every `autonumber` field that + * omits `unique`, which the spec makes `unique: 'organization'` by contract. + * An autonumber that writes `unique: false` has opted out and is not asked. + * + * They are asked AFTER the existing candidates (the id, the lookup's + * `displayField`, the target's title field, the fixed human keys), in the + * target's declared field order, each at most once. The existing rules hold: + * the first field to match wins, and a field matching more than one record is + * `reference_ambiguous`, never a silent pick. + * + * The fake's `findData` answers like the engine: equality over the stored row. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { runImport, type ImportProtocolLike } from './import-runner'; +import type { ExportFieldMeta } from './import-field-meta.js'; + +type FindArgs = Parameters[0]; +type Row = Record; +type Declaration = { fields: Record; nameField?: string }; + +const TEXT = { type: 'text' }; + +const DECLARATIONS: Record = { + xu_account: { + nameField: 'name', + fields: { + name: TEXT, + // The contract default: an autonumber that omits `unique` is unique. + account_number: { type: 'autonumber', autonumberFormat: 'ACC-{000000}' }, + region: TEXT, + tax_id: { type: 'text', unique: true }, + // An opted-out autonumber: a display-only ordinal, never asked. + ordinal: { type: 'autonumber', unique: false }, + external_code: { type: 'text', unique: 'global' }, + // Already a fixed key: asked once, in its fixed place. + email: { type: 'email', unique: 'organization' }, + }, + }, +}; + +const ROWS: Record = { + xu_account: [ + { id: 'a1', name: 'Acme', account_number: 'ACC-000012', region: 'EMEA', tax_id: 'TX-1', ordinal: '7', external_code: 'EXT-1' }, + // A name that equals another record's autonumber: the name is asked first. + { id: 'a2', name: 'ACC-000013', account_number: 'ACC-000002', region: 'APAC', tax_id: 'TX-2', ordinal: '8' }, + { id: 'a3', name: 'Globex', account_number: 'ACC-000013', region: 'APAC', tax_id: 'TX-3', ordinal: '9' }, + // Two holders of one tax id, as a read across organizations can see. + { id: 'a4', name: 'Initech', account_number: 'ACC-000004', tax_id: 'TX-SHARED', ordinal: '10' }, + { id: 'a5', name: 'Umbrella', account_number: 'ACC-000005', tax_id: 'TX-SHARED', ordinal: '11' }, + ], +}; + +const metaMap = new Map([ + ['name', { name: 'name', type: 'text' }], + ['account', { name: 'account', type: 'lookup', reference: 'xu_account' }], +]); + +function protocol() { + const findData = vi.fn(async (args: FindArgs) => { + const [[field, value]] = Object.entries(args.query!.where!); + return (ROWS[args.object] ?? []).filter((r) => r[field] === value); + }); + const getMetaItem = vi.fn(async ({ name }: { type: string; name: string }) => + ({ type: 'object', name, item: { name, ...DECLARATIONS[name] } })); + const createData = vi.fn(async (a: { data: Row }) => ({ id: 'new', ...a.data })); + const p: ImportProtocolLike = { findData, getMetaItem, createData, updateData: vi.fn() }; + return { p, findData, createData }; +} + +const run = (p: ImportProtocolLike, rows: Row[]) => + runImport({ + p, context: {}, objectName: 'xu_source', metaMap, writeMode: 'insert', matchFields: [], dryRun: false, + runAutomations: false, trimWhitespace: true, createMissingOptions: false, skipBlankMatchKey: false, rows, + }); + +/** The `where` keys asked of the target, in order. */ +const asked = (findData: ReturnType['findData']) => + findData.mock.calls.filter(([a]) => a.object === 'xu_account').map(([a]) => Object.keys(a.query!.where!)[0]); + +const verdict = (r: { ok: boolean; code?: string; field?: string }) => ({ ok: r.ok, code: r.code, field: r.field }); +const OK = { ok: true, code: undefined, field: undefined }; +const FIXED = ['id', 'name', 'title', 'label', 'full_name', 'email', 'username']; + +describe('[#27947] import resolves a lookup cell by the target object\'s unique fields', () => { + it('an autonumber cell (ACC-000012) resolves, asked after every existing candidate', async () => { + const { p, findData, createData } = protocol(); + const summary = await run(p, [{ name: 'r', account: 'ACC-000012' }]); + expect(summary.results.map(verdict)).toEqual([OK]); + expect(createData.mock.calls.map(([a]) => a.data.account)).toEqual(['a1']); + expect(asked(findData)).toEqual([...FIXED, 'account_number']); + }); + + it('another declared-unique field (tax_id, unique: true) resolves', async () => { + const { p, findData, createData } = protocol(); + const summary = await run(p, [{ name: 'r', account: 'TX-3' }]); + expect(summary.results.map(verdict)).toEqual([OK]); + expect(createData.mock.calls.map(([a]) => a.data.account)).toEqual(['a3']); + expect(asked(findData)).toEqual([...FIXED, 'account_number', 'tax_id']); + }); + + it('an earlier candidate that matches still wins: the name ACC-000013 links a2, not a3\'s autonumber', async () => { + const { p, findData, createData } = protocol(); + const summary = await run(p, [{ name: 'r', account: 'ACC-000013' }]); + expect(summary.results.map(verdict)).toEqual([OK]); + expect(createData.mock.calls.map(([a]) => a.data.account)).toEqual(['a2']); + expect(asked(findData)).toEqual(['id', 'name']); + }); + + it('a value matching nothing still answers reference_not_found, after the unique fields in declared order', async () => { + const { p, findData, createData } = protocol(); + const summary = await run(p, [{ name: 'r', account: 'ACC-999999' }]); + expect(summary.results.map(verdict)).toEqual([{ ok: false, code: 'reference_not_found', field: 'account' }]); + expect(createData).not.toHaveBeenCalled(); + // Declared order, `email` not asked twice, the opted-out `ordinal` never. + expect(asked(findData)).toEqual([...FIXED, 'account_number', 'tax_id', 'external_code']); + }); + + it('an opted-out autonumber (unique: false) is not asked: its value answers reference_not_found', async () => { + const { p, findData } = protocol(); + const summary = await run(p, [{ name: 'r', account: '7' }]); + expect(summary.results.map(verdict)).toEqual([{ ok: false, code: 'reference_not_found', field: 'account' }]); + expect(asked(findData)).not.toContain('ordinal'); + }); + + it('a unique field two records answer is still reference_ambiguous, and links neither', async () => { + const { p, findData, createData } = protocol(); + const summary = await run(p, [{ name: 'r', account: 'TX-SHARED' }]); + expect(summary.results.map(verdict)).toEqual([{ ok: false, code: 'reference_ambiguous', field: 'account' }]); + expect(createData).not.toHaveBeenCalled(); + expect(asked(findData)).toEqual([...FIXED, 'account_number', 'tax_id']); + }); +}); diff --git a/packages/core/src/utils/import-runner.ts b/packages/core/src/utils/import-runner.ts index eb4cdc46a04..06d5fa2ab92 100644 --- a/packages/core/src/utils/import-runner.ts +++ b/packages/core/src/utils/import-runner.ts @@ -5,7 +5,7 @@ import { coerceRow, type CoerceContext, type RefResolver, type RefMatch } from ' import type { ExportFieldMeta } from './import-field-meta.js'; import type { ValidationMessageTranslator } from '@objectstack/spec/system'; import { - canServeApiOperation, isMultiValueField, isVirtualSearchField, resolveDisplayField, STRUCTURED_JSON_TYPES, + canServeApiOperation, isMultiValueField, isUniqueDeclared, isVirtualSearchField, resolveDisplayField, STRUCTURED_JSON_TYPES, type DisplayNameObjectMeta, type DroppedFieldsEvent, type EnableLike, type SearchFieldMeta, } from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; @@ -639,12 +639,33 @@ async function servesReferenceTarget(p: ImportProtocolLike, target: string): Pro } if (!canServeApiOperation(item?.enable, REFERENCE_TARGET_OPERATION)) return null; if (!item || typeof item !== 'object') return {}; + const fields = item.fields as ServedReferenceTarget['fields']; return { titleField: resolveDisplayField(item), - fields: item.fields as ServedReferenceTarget['fields'], + fields, + uniqueFields: Object.keys(fields ?? {}).filter((f) => isUniqueReferenceField(fields?.[f])), }; } +/** + * [#27947] Does this target field name at most one record, so a cell holding + * its value identifies the record? Yes when its `unique` declares a constraint + * (`isUniqueDeclared`: `true`, `'organization'`, `'global'`), and for an + * `autonumber` that omits `unique`: the spec makes that field + * `unique: 'organization'` by contract (`FieldSchema`'s autonumber default), + * and the default is applied here too in case the declaration was read + * unparsed. An autonumber that writes `unique: false` has opted out (a + * display-only ordinal) and is not an identifier. + */ +function isUniqueReferenceField(meta: ReferenceTargetFieldMeta | undefined): boolean { + if (!meta) return false; + if (isUniqueDeclared(meta.unique)) return true; + return meta.type === 'autonumber' && meta.unique === undefined; +} + +/** The slice of a lookup target's field declaration the reference resolution reads. */ +type ReferenceTargetFieldMeta = SearchFieldMeta & { unique?: unknown }; + /** The slice of a lookup target's declaration the reference resolution reads. */ type ReferenceTargetDeclaration = DisplayNameObjectMeta & { enable?: EnableLike | null }; @@ -662,11 +683,18 @@ interface ServedReferenceTarget { */ titleField?: string; /** - * The target's declared field map. It is read for one question only: which + * The target's declared field map. It is read for two questions: which * candidate fields are virtual ({@link isVirtualSearchField}), because a - * `where` over a virtual field is refused by the engine's filter seam. + * `where` over a virtual field is refused by the engine's filter seam, and + * which are unique ({@link ServedReferenceTarget.uniqueFields}). + */ + fields?: Readonly>; + /** + * [#27947] The target's unique fields ({@link isUniqueReferenceField}), in + * declared order: the last candidates a cell is asked by, so an export file + * written before a record's title changed still resolves by its autonumber. */ - fields?: Readonly>; + uniqueFields?: readonly string[]; } /** Upper bound on rows in one createManyData batch (framework#2678 suggests 100-500). */ @@ -821,7 +849,10 @@ export function runImport(opts: RunImportOptions): Promise { // Try an exact id first (authoritative + unique when the user pasted an id), // then the configured display field, then the target's own title field // (#24981: ADR-0079's rule, the field the list export writes the cell by), - // then the usual human identifiers. De-dupe so a field isn't queried twice. + // then the usual human identifiers, then the target's unique fields in + // declared order (#27947: an autonumber included, so a cell naming the + // record by its number resolves after a title change). De-dupe so a field + // isn't queried twice. // The first candidate field to match wins; if that field matches >1 record // we stop and report ambiguity rather than silently linking the first. // @@ -836,6 +867,7 @@ export function runImport(opts: RunImportOptions): Promise { ...(meta.displayField ? [meta.displayField] : []), ...(target.titleField ? [target.titleField] : []), 'name', 'title', 'label', 'full_name', 'email', 'username', + ...(target.uniqueFields ?? []), ])].filter((f) => !isVirtualSearchField(target.fields?.[f])); const lookup = async (): Promise => { let match: RefMatch = {};