diff --git a/.changeset/import-multivalue-reference-split.md b/.changeset/import-multivalue-reference-split.md new file mode 100644 index 0000000000..8592624b9d --- /dev/null +++ b/.changeset/import-multivalue-reference-split.md @@ -0,0 +1,29 @@ +--- +'@objectstack/rest': patch +--- + +fix(rest): split multi-value fields on import so `multiple: true` columns resolve per-token (#3063) + +The bulk-import coercion (`import-coerce.ts`) resolved a reference cell as a +single value regardless of the field's `multiple` flag: a `multiple: true` +lookup/user cell like `张焊工;李质检` was passed whole to name resolution and +always failed with `no matches "张焊工;李质检"`, so every multi-value +association had to be back-filled by hand in the record UI after import. + +Coercion now mirrors objectql's `isMultiValueField` predicate. A field whose +stored value is an array — an inherently-multi type (multiselect/checkboxes/tags) +or a multi-capable type flagged `multiple: true` (per the spec: select, lookup, +file, image; `radio` shares select's branch and `user` shares lookup's) — has +its cell split on the export separator (`, ` / `;` / `、` / newline) and each +token coerced individually: + +- **lookup / user (`multiple: true`)** — resolve each name token to an id, store + the id array; an unmatched/ambiguous token reports the **specific token** + (`no sys_user matches "查无此人"`) instead of the whole string. +- **select / radio (`multiple: true`)** — match each token against the options, + store the option-value array. +- **file / image (`multiple: true`)** — split into an id/url array. + +Single-value fields and the non-multi-capable reference types (master_detail / +reference / tree) are unchanged — a stray `multiple: true` on them stays a +single resolved value, matching the engine. diff --git a/packages/rest/src/export-format.ts b/packages/rest/src/export-format.ts index e649bb16d2..b60129d7ea 100644 --- a/packages/rest/src/export-format.ts +++ b/packages/rest/src/export-format.ts @@ -22,6 +22,8 @@ export interface ExportFieldMeta { reference?: string; /** Field on the referenced record to show as its label. */ displayField?: string; + /** Field holds multiple values (an array), e.g. a `multiple: true` lookup. */ + multiple?: boolean; // The following four drive the import path's required-field pre-check // (import-runner.ts). They mirror the engine's insert-time validation // (objectql record-validator.ts) so a dry run can predict a NOT NULL / @@ -127,6 +129,7 @@ export function buildFieldMetaMap(schema: unknown): Map options: Array.isArray(f.options) ? f.options : undefined, reference: typeof f.reference === 'string' ? f.reference : undefined, displayField: typeof f.displayField === 'string' ? f.displayField : undefined, + multiple: f.multiple === true, required: f.required === true, system: f.system === true, readonly: f.readonly === true, diff --git a/packages/rest/src/import-coerce.test.ts b/packages/rest/src/import-coerce.test.ts index 100bb99412..740af7c283 100644 --- a/packages/rest/src/import-coerce.test.ts +++ b/packages/rest/src/import-coerce.test.ts @@ -158,6 +158,93 @@ describe('coerceRow', () => { expect(none.errors[0]).toMatchObject({ field: 'owner', code: 'reference_not_found' }); }); + it('splits a multi-value lookup cell and resolves each token to an id', async () => { + const metaMap = meta({ + members: { type: 'lookup', reference: 'sys_user', displayField: 'name', multiple: true }, + }); + const ids: Record = { 张焊工: 'u1', 李质检: 'u2' }; + const seen: string[] = []; + const resolveRef = async (_obj: string, display: string) => { + seen.push(display); + return ids[display]; + }; + // Semicolon-separated (issue's CSV) and comma-separated (export round-trip). + const semi = await coerceRow({ members: '张焊工;李质检' }, metaMap, { resolveRef }); + expect(semi.errors).toEqual([]); + expect(semi.data).toEqual({ members: ['u1', 'u2'] }); + const comma = await coerceRow({ members: '张焊工, 李质检' }, metaMap, { resolveRef }); + expect(comma.data).toEqual({ members: ['u1', 'u2'] }); + expect(seen).toEqual(['张焊工', '李质检', '张焊工', '李质检']); + }); + + it('names the specific unmatched token in a multi-value lookup', async () => { + const metaMap = meta({ + members: { type: 'lookup', reference: 'sys_user', displayField: 'name', multiple: true }, + }); + const resolveRef = async (_obj: string, display: string) => + display === '张焊工' ? 'u1' : undefined; + const { data, errors } = await coerceRow({ members: '张焊工;查无此人' }, metaMap, { resolveRef }); + expect(data.members).toBeUndefined(); + expect(errors[0]).toMatchObject({ field: 'members', code: 'reference_not_found' }); + expect(errors[0].message).toContain('查无此人'); + expect(errors[0].message).not.toContain('张焊工'); + }); + + it('keeps raw multi-value lookup tokens when no resolver is supplied', async () => { + const metaMap = meta({ + members: { type: 'lookup', reference: 'sys_user', multiple: true }, + }); + const { data } = await coerceRow({ members: 'u1;u2' }, metaMap, {}); + expect(data).toEqual({ members: ['u1', 'u2'] }); + }); + + it('splits a select flagged multiple:true into an array of option values', async () => { + const metaMap = meta({ + skills: { + type: 'select', multiple: true, + options: [{ label: '焊接', value: 'weld' }, { label: '质检', value: 'qc' }], + }, + }); + const { data, errors } = await coerceRow({ skills: '焊接;质检' }, metaMap, {}); + expect(errors).toEqual([]); + expect(data).toEqual({ skills: ['weld', 'qc'] }); + // A single-value select (no multiple flag) still stores one scalar. + const single = meta({ s: { type: 'select', options: [{ label: '焊接', value: 'weld' }] } }); + const one = await coerceRow({ s: '焊接' }, single, {}); + expect(one.data).toEqual({ s: 'weld' }); + }); + + it('names the specific unmatched token in a multiple:true select', async () => { + const metaMap = meta({ + skills: { type: 'select', multiple: true, options: [{ label: '焊接', value: 'weld' }] }, + }); + const { errors } = await coerceRow({ skills: '焊接,搬砖' }, metaMap, {}); + expect(errors[0]).toMatchObject({ field: 'skills', code: 'invalid_option' }); + expect(errors[0].message).toContain('搬砖'); + expect(errors[0].message).not.toContain('焊接'); + }); + + it('splits a file/image flagged multiple:true into an array of ids/urls', async () => { + const metaMap = meta({ photos: { type: 'image', multiple: true } }); + const { data } = await coerceRow({ photos: 'a.png;b.png' }, metaMap, {}); + expect(data).toEqual({ photos: ['a.png', 'b.png'] }); + // A single-value file passes through untouched. + const single = meta({ f: { type: 'file' } }); + const one = await coerceRow({ f: 'a.png' }, single, {}); + expect(one.data).toEqual({ f: 'a.png' }); + }); + + it('ignores multiple:true on types the spec does not make multi (master_detail)', async () => { + // master_detail is not multi-capable per the spec — a stray multiple flag + // must not split it; it stays a single resolved reference (engine parity). + const metaMap = meta({ + parent: { type: 'master_detail', reference: 'order', displayField: 'name', multiple: true }, + }); + const resolveRef = async (_obj: string, display: string) => (display === 'A;B' ? 'o1' : undefined); + const { data } = await coerceRow({ parent: 'A;B' }, metaMap, { resolveRef }); + expect(data).toEqual({ parent: 'o1' }); + }); + it('reports coercion errors per field instead of throwing', async () => { const metaMap = meta({ n: { type: 'number' }, b: { type: 'boolean' } }); const { data, errors } = await coerceRow({ n: 'abc', b: 'maybe' }, metaMap, {}); diff --git a/packages/rest/src/import-coerce.ts b/packages/rest/src/import-coerce.ts index 9682b7df38..0edf6660c6 100644 --- a/packages/rest/src/import-coerce.ts +++ b/packages/rest/src/import-coerce.ts @@ -20,6 +20,12 @@ * - select / radio → an option *value* * - multiselect / checkboxes / tags → an array of option values * - lookup / master_detail / user / reference → a record id (resolved async) + * - file / image → a file id / url (as-is) + * + * Any of the last four whose field is flagged `multiple: true` (per the spec, + * `multiple` applies to select / lookup / file / image; `radio`/`user` share + * their branch) instead store an **array** — the cell is split on the export + * separator and each token coerced individually. See `isMultiValueField`. * * Contract: when a field carries no usable metadata the value passes through * untouched, so an import stays byte-identical to the pre-coercion behaviour. @@ -37,6 +43,31 @@ const MULTI_OPTION_TYPES = new Set(['multiselect', 'checkboxes', 'tags']); const NUMBER_TYPES = new Set(['number', 'currency', 'percent', 'rating', 'slider']); /** Boolean field types (store a real boolean). */ const BOOL_TYPES = new Set(['boolean', 'toggle']); +/** Attachment field types (store a file id / url, or an array when `multiple`). */ +const FILE_TYPES = new Set(['file', 'image']); + +/** + * Single-value field types that become an ARRAY when flagged `multiple: true`. + * Mirrors objectql `record-validator.ts` (`MULTI_CAPABLE_TYPES`): per the spec + * (`field.zod.ts`), `multiple` applies to select / lookup / file / image; + * `radio` shares the select branch and `user` is stored identically to `lookup`. + * master_detail / reference / tree are NOT multi-capable, so a stray + * `multiple: true` on them is ignored (they stay single — same as the engine). + */ +const MULTI_CAPABLE_TYPES = new Set(['select', 'radio', 'lookup', 'user', 'file', 'image']); + +/** + * Whether a field's stored value is an array — an inherently-multi type + * (multiselect / checkboxes / tags) or a multi-capable type flagged + * `multiple: true`. Kept in lock-step with the engine's `isMultiValueField` + * so a coerced cell has the SAME shape the engine will accept on insert. + */ +function isMultiValueField(meta: ExportFieldMeta | undefined): boolean { + const t = meta?.type; + if (!t) return false; + if (MULTI_OPTION_TYPES.has(t)) return true; + return MULTI_CAPABLE_TYPES.has(t) && meta?.multiple === true; +} /** * Structured outcome of a reference lookup. `id` set → a single record matched. @@ -279,7 +310,24 @@ export async function coerceFieldValue( return { value: d }; } - if (OPTION_TYPES.has(t)) { + // select / radio / multiselect / checkboxes / tags — match the cell against + // the field's option list. Multi-valued when the type is inherently multi + // (multiselect/…) OR a select/radio is flagged `multiple: true`; split then + // and match each token, else match the whole cell as one option. + if (OPTION_TYPES.has(t) || MULTI_OPTION_TYPES.has(t)) { + if (isMultiValueField(meta)) { + const parts = splitMulti(raw); + const out: unknown[] = []; + for (const part of parts) { + const v = matchOption(part, meta?.options); + if (v === undefined) { + if (ctx.createMissingOptions) { out.push(part); continue; } + return { error: { field, code: 'invalid_option', message: `${field}: "${part}" is not a known option` } }; + } + out.push(v); + } + return { value: out }; + } const v = matchOption(raw, meta?.options); if (v === undefined) { if (ctx.createMissingOptions) return { value: String(raw).trim() }; @@ -288,21 +336,29 @@ export async function coerceFieldValue( return { value: v }; } - if (MULTI_OPTION_TYPES.has(t)) { - const parts = splitMulti(raw); - const out: unknown[] = []; - for (const part of parts) { - const v = matchOption(part, meta?.options); - if (v === undefined) { - if (ctx.createMissingOptions) { out.push(part); continue; } - return { error: { field, code: 'invalid_option', message: `${field}: "${part}" is not a known option` } }; + if (REFERENCE_TYPES.has(t)) { + // Multi-value reference (a `multiple: true` lookup / user): the cell holds + // several display names joined by the export separator (`, ` / `;`). Split + // first, then resolve each token; store an array of ids. Mirrors the + // multi-option branch above and the export path's `formatReference` join. + if (isMultiValueField(meta)) { + const tokens = splitMulti(raw); + // If we have no resolver / no target object, store the raw tokens and let + // referential integrity be enforced downstream. + if (!ctx.resolveRef || !meta.reference) return { value: tokens }; + const out: unknown[] = []; + for (const token of tokens) { + const m = normalizeRefMatch(await ctx.resolveRef(meta.reference, token, meta)); + if (m.ambiguous) { + return { error: { field, code: 'reference_ambiguous', message: `${field}: "${token}" matches more than one ${meta.reference} — use a unique value or the record id` } }; + } + if (m.id === undefined) { + return { error: { field, code: 'reference_not_found', message: `${field}: no ${meta.reference} matches "${token}"` } }; + } + out.push(m.id); } - out.push(v); + return { value: out }; } - return { value: out }; - } - - if (REFERENCE_TYPES.has(t)) { const display = String(raw).trim(); // If it already looks resolved (an id was pasted) or we have no resolver / // no target object, store the raw value and let referential integrity be @@ -318,8 +374,17 @@ export async function coerceFieldValue( return { value: match.id }; } - // Everything else (text, email, phone, json, html, file, …): pass through, - // trimming string cells so stray spreadsheet padding doesn't leak into storage. + // Attachment fields (file / image): the value is a file id / url the importer + // does not resolve. When `multiple: true` the cell holds several joined by the + // export separator — split into an array so the stored shape matches what the + // engine expects; a single-value attachment passes through untouched below. + if (FILE_TYPES.has(t) && isMultiValueField(meta)) { + return { value: splitMulti(raw) }; + } + + // Everything else (text, email, phone, json, html, single file, …): pass + // through, trimming string cells so stray spreadsheet padding doesn't leak + // into storage. return { value: trim && typeof raw === 'string' ? raw.trim() : raw }; } diff --git a/packages/rest/src/import-integration.test.ts b/packages/rest/src/import-integration.test.ts index b16f4d4315..ce977e2f91 100644 --- a/packages/rest/src/import-integration.test.ts +++ b/packages/rest/src/import-integration.test.ts @@ -94,6 +94,11 @@ const TASK = { score: { name: 'score', type: 'number' as const, label: '分数' }, due: { name: 'due', type: 'date' as const, label: '截止' }, owner: { name: 'owner', type: 'lookup' as const, label: '负责人', reference: 'user', displayField: 'name' }, + members: { name: 'members', type: 'lookup' as const, label: '成员', reference: 'user', displayField: 'name', multiple: true }, + skills: { + name: 'skills', type: 'select' as const, label: '技能', multiple: true, + options: [{ label: '焊接', value: 'weld' }, { label: '质检', value: 'qc' }], + }, }, }; @@ -188,6 +193,41 @@ describe('import route — real engine + protocol integration', () => { expect(String(stored.due)).toContain('2026-06-30'); }); + it('splits a multi-value lookup cell and resolves every token to an id (issue #3063)', async () => { + // The cell holds several display names joined by `;` (issue's CSV). Before + // the fix the whole string was resolved as one reference and always failed. + const csv = ['ID,标题,成员', '1,结构一班,张三;李四'].join('\n'); + const res = await call(route, { + format: 'csv', csv, + mapping: { ID: 'id', 标题: 'title', 成员: 'members' }, + }); + expect(res._json).toMatchObject({ total: 1, ok: 1, errors: 0, created: 1 }); + const stored = await engine.findOne('task', { where: { id: '1' } }); + expect(stored.members).toEqual(['u1', 'u2']); + }); + + it('splits a select flagged multiple:true into an option-value array on insert (issue #3063)', async () => { + const csv = ['ID,标题,技能', '1,焊工,焊接;质检'].join('\n'); + const res = await call(route, { + format: 'csv', csv, + mapping: { ID: 'id', 标题: 'title', 技能: 'skills' }, + }); + expect(res._json).toMatchObject({ total: 1, ok: 1, errors: 0, created: 1 }); + const stored = await engine.findOne('task', { where: { id: '1' } }); + expect(stored.skills).toEqual(['weld', 'qc']); + }); + + it('names the specific unmatched token in a multi-value lookup (issue #3063)', async () => { + const res = await call(route, { + format: 'json', + rows: [{ id: 'a', title: 'x', members: '张三;查无此人' }], + }); + const failed = res._json.results.find((r: any) => !r.ok); + expect(failed).toMatchObject({ field: 'members', code: 'reference_not_found' }); + expect(failed.error).toContain('查无此人'); + expect(failed.error).not.toContain('张三'); + }); + it('resolves a lookup by email when displayField is not the match, and reports not-found', async () => { // Default candidate fields include email — resolve 李四 via email. const res = await call(route, {