Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/27947-import-lookup-unique-fields.md
Original file line number Diff line number Diff line change
@@ -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`.
138 changes: 138 additions & 0 deletions packages/core/src/utils/import-runner-reference-unique.test.ts
Original file line number Diff line number Diff line change
@@ -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<ImportProtocolLike['findData']>[0];
type Row = Record<string, unknown>;
type Declaration = { fields: Record<string, Row>; nameField?: string };

const TEXT = { type: 'text' };

const DECLARATIONS: Record<string, Declaration> = {
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<string, Row[]> = {
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<string, ExportFieldMeta>([
['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<typeof protocol>['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']);
});
});
44 changes: 38 additions & 6 deletions packages/core/src/utils/import-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };

Expand All @@ -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<Record<string, ReferenceTargetFieldMeta | undefined>>;
/**
* [#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<Record<string, SearchFieldMeta | undefined>>;
uniqueFields?: readonly string[];
}

/** Upper bound on rows in one createManyData batch (framework#2678 suggests 100-500). */
Expand Down Expand Up @@ -821,7 +849,10 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
// 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.
//
Expand All @@ -836,6 +867,7 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
...(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<RefMatch> => {
let match: RefMatch = {};
Expand Down
Loading