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
29 changes: 29 additions & 0 deletions .changeset/import-multivalue-reference-split.md
Original file line number Diff line number Diff line change
@@ -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 <object> 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.
3 changes: 3 additions & 0 deletions packages/rest/src/export-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 /
Expand Down Expand Up @@ -127,6 +129,7 @@ export function buildFieldMetaMap(schema: unknown): Map<string, ExportFieldMeta>
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,
Expand Down
87 changes: 87 additions & 0 deletions packages/rest/src/import-coerce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = { 张焊工: '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, {});
Expand Down
97 changes: 81 additions & 16 deletions packages/rest/src/import-coerce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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() };
Expand All @@ -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
Expand All @@ -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 };
}

Expand Down
40 changes: 40 additions & 0 deletions packages/rest/src/import-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }],
},
},
};

Expand Down Expand Up @@ -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, {
Expand Down