Skip to content

Commit b7ec536

Browse files
committed
feat(import): server-side value coercion + write-mode (insert/update/upsert) for /data/:object/import
Move all special-value handling for list import to the server so one code path serves every client. Callers POST raw spreadsheet values + a column mapping; the server coerces each cell from the object's field metadata and routes rows to insert / update / upsert. - spec: ImportRequest / ImportResponse / ImportRowResult / ImportWriteMode / ImportMapping schemas (writeMode, matchFields, runAutomations, trimWhitespace, nullValues, createMissingOptions, skipBlankMatchKey, dryRun) - rest/import-coerce: coerceRow/coerceFieldValue — boolean, number, date/ datetime/time→ISO, select label→code, multi-select split+match, lookup name→id via a caching RefResolver; createMissingOptions + trimWhitespace toggles - rest-server /import: normalize mapping (record | entry[]), validate writeMode↔matchFields, resolve field metadata, findExisting with blank/none/ ambiguous guards, per-row action report (created/updated/skipped/failed), hook suppression via skipAutomations, clearer 413 for oversized payloads - client SDK: data.import(object, request) on both data namespaces - tests: import-coerce (unit) + import-integration (upsert/update/dryRun) — 28
1 parent c9b9b14 commit b7ec536

6 files changed

Lines changed: 1067 additions & 20 deletions

File tree

packages/client/src/index.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ import {
8282
UnsubscribeResponse,
8383
WellKnownCapabilities,
8484
ApiRoutes,
85+
ImportRequest,
86+
ImportResponse,
8587
} from '@objectstack/spec/api';
8688
import type {
8789
ApprovalRequestRow,
@@ -3046,6 +3048,25 @@ export class ObjectStackClient {
30463048
return this.unwrapResponse<T[]>(res);
30473049
},
30483050

3051+
/**
3052+
* Bulk-import rows (CSV text or JSON row objects) into an object.
3053+
*
3054+
* The server coerces each cell to its storage value using the object's field
3055+
* metadata (booleans, numbers, dates→ISO, select label→code, lookup name→id),
3056+
* so callers send raw spreadsheet values plus an optional column `mapping`.
3057+
* `writeMode` selects insert / update / upsert (the latter two need
3058+
* `matchFields`); `dryRun` validates + previews without persisting. The
3059+
* response carries per-row outcomes for an import report.
3060+
*/
3061+
import: async (object: string, request: ImportRequest): Promise<ImportResponse> => {
3062+
const route = this.getRoute('data');
3063+
const res = await this.fetch(`${this.baseUrl}${route}/${object}/import`, {
3064+
method: 'POST',
3065+
body: JSON.stringify(request),
3066+
});
3067+
return this.unwrapResponse<ImportResponse>(res);
3068+
},
3069+
30493070
update: async <T = any>(
30503071
object: string,
30513072
id: string,
@@ -3456,6 +3477,21 @@ export class ScopedProjectClient {
34563477
});
34573478
return this.parent._unwrap<T[]>(res);
34583479
},
3480+
/**
3481+
* Bulk-import rows (CSV text or JSON row objects) into an object. The server
3482+
* coerces each cell to its storage value from field metadata (booleans,
3483+
* numbers, dates→ISO, select label→code, lookup name→id); callers send raw
3484+
* values plus an optional column `mapping`. `writeMode` selects
3485+
* insert/update/upsert (update/upsert need `matchFields`); `dryRun`
3486+
* validates + previews without persisting.
3487+
*/
3488+
import: async (object: string, request: ImportRequest): Promise<ImportResponse> => {
3489+
const res = await this.parent._fetch(this.url(`/data/${object}/import`), {
3490+
method: 'POST',
3491+
body: JSON.stringify(request),
3492+
});
3493+
return this.parent._unwrap<ImportResponse>(res);
3494+
},
34593495
update: async <T = any>(object: string, id: string, data: Partial<T>): Promise<UpdateDataResult<T>> => {
34603496
const res = await this.parent._fetch(this.url(`/data/${object}/${id}`), {
34613497
method: 'PATCH',
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Unit tests for the import value-coercion module (`import-coerce.ts`) — the
5+
* inverse of `export-format.ts`. These are pure (no engine): scalar parsers plus
6+
* `coerceRow` driven by a fake reference resolver.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import {
11+
parseBooleanCell,
12+
parseNumberCell,
13+
parseDateCell,
14+
matchOption,
15+
splitMulti,
16+
coerceRow,
17+
} from './import-coerce';
18+
import type { ExportFieldMeta } from './export-format';
19+
20+
describe('parseBooleanCell', () => {
21+
it('accepts common truthy spellings across languages', () => {
22+
for (const t of ['true', 'TRUE', 'yes', 'Y', '1', 'on', '是', '对', '✓', true, 1]) {
23+
expect(parseBooleanCell(t)).toBe(true);
24+
}
25+
});
26+
it('accepts common falsy spellings', () => {
27+
for (const f of ['false', 'No', 'n', '0', 'off', '否', '错', false, 0]) {
28+
expect(parseBooleanCell(f)).toBe(false);
29+
}
30+
});
31+
it('returns undefined for gibberish', () => {
32+
expect(parseBooleanCell('maybe')).toBeUndefined();
33+
expect(parseBooleanCell(2)).toBeUndefined();
34+
});
35+
});
36+
37+
describe('parseNumberCell', () => {
38+
it('strips thousands separators, currency symbols, and percent signs', () => {
39+
expect(parseNumberCell('1,234.5')).toBe(1234.5);
40+
expect(parseNumberCell('$1,000')).toBe(1000);
41+
expect(parseNumberCell('¥2,500.75')).toBe(2500.75);
42+
expect(parseNumberCell('25%')).toBe(25);
43+
});
44+
it('handles accounting-style parenthesised negatives', () => {
45+
expect(parseNumberCell('(1,234)')).toBe(-1234);
46+
});
47+
it('rejects non-numeric residue', () => {
48+
expect(parseNumberCell('abc')).toBeUndefined();
49+
expect(parseNumberCell('12x3')).toBeUndefined();
50+
expect(parseNumberCell('')).toBeUndefined();
51+
});
52+
});
53+
54+
describe('parseDateCell', () => {
55+
it('normalises bare calendar dates without timezone drift', () => {
56+
expect(parseDateCell('2026-06-30', 'date')).toBe('2026-06-30');
57+
expect(parseDateCell('2026/6/3', 'date')).toBe('2026-06-03');
58+
});
59+
it('emits full ISO for datetime', () => {
60+
expect(parseDateCell('2026-06-30', 'datetime')).toBe('2026-06-30T00:00:00.000Z');
61+
});
62+
it('accepts and normalises time-of-day', () => {
63+
expect(parseDateCell('14:30', 'time')).toBe('14:30:00');
64+
expect(parseDateCell('09:05:07', 'time')).toBe('09:05:07');
65+
});
66+
it('rejects nonsense', () => {
67+
expect(parseDateCell('not-a-date', 'date')).toBeUndefined();
68+
expect(parseDateCell('2026-13-40', 'date')).toBeUndefined();
69+
});
70+
});
71+
72+
describe('matchOption', () => {
73+
const options = [{ label: '高', value: 'high' }, { label: '低', value: 'low' }];
74+
it('matches by option value (code)', () => {
75+
expect(matchOption('high', options)).toBe('high');
76+
});
77+
it('matches by human label, case-insensitively', () => {
78+
expect(matchOption('高', options)).toBe('high');
79+
expect(matchOption('LOW', [{ label: 'Low', value: 'low' }])).toBe('low');
80+
});
81+
it('returns undefined when nothing matches', () => {
82+
expect(matchOption('medium', options)).toBeUndefined();
83+
});
84+
});
85+
86+
describe('splitMulti', () => {
87+
it('splits on commas, semicolons, Chinese comma, and newlines', () => {
88+
expect(splitMulti('a, b;c、d\ne')).toEqual(['a', 'b', 'c', 'd', 'e']);
89+
});
90+
it('passes arrays through, trimming blanks', () => {
91+
expect(splitMulti([' x ', '', 'y'])).toEqual(['x', 'y']);
92+
});
93+
});
94+
95+
describe('coerceRow', () => {
96+
const meta = (defs: Record<string, Partial<ExportFieldMeta>>): Map<string, ExportFieldMeta> => {
97+
const m = new Map<string, ExportFieldMeta>();
98+
for (const [name, d] of Object.entries(defs)) m.set(name, { name, ...d });
99+
return m;
100+
};
101+
102+
it('coerces every special value type to its storage shape', async () => {
103+
const metaMap = meta({
104+
done: { type: 'boolean' },
105+
amount: { type: 'currency' },
106+
priority: { type: 'select', options: [{ label: '高', value: 'high' }] },
107+
tags: { type: 'multiselect', options: [{ label: 'A', value: 'a' }, { label: 'B', value: 'b' }] },
108+
due: { type: 'date' },
109+
note: { type: 'text' },
110+
});
111+
const { data, errors } = await coerceRow(
112+
{ done: '是', amount: '$1,200.50', priority: '高', tags: 'A, B', due: '2026/07/01', note: ' hi ' },
113+
metaMap,
114+
{},
115+
);
116+
expect(errors).toEqual([]);
117+
expect(data).toEqual({
118+
done: true,
119+
amount: 1200.5,
120+
priority: 'high',
121+
tags: ['a', 'b'],
122+
due: '2026-07-01',
123+
note: 'hi',
124+
});
125+
});
126+
127+
it('resolves reference fields via the async resolver (name → id)', async () => {
128+
const metaMap = meta({ owner: { type: 'lookup', reference: 'user', displayField: 'name' } });
129+
const seen: string[] = [];
130+
const resolveRef = async (obj: string, display: string) => {
131+
seen.push(`${obj}:${display}`);
132+
return display === '张三' ? 'u1' : undefined;
133+
};
134+
const ok = await coerceRow({ owner: '张三' }, metaMap, { resolveRef });
135+
expect(ok.data).toEqual({ owner: 'u1' });
136+
expect(seen).toEqual(['user:张三']);
137+
138+
const bad = await coerceRow({ owner: '王五' }, metaMap, { resolveRef });
139+
expect(bad.data.owner).toBeUndefined();
140+
expect(bad.errors[0]).toMatchObject({ field: 'owner', code: 'reference_not_found' });
141+
});
142+
143+
it('reports coercion errors per field instead of throwing', async () => {
144+
const metaMap = meta({ n: { type: 'number' }, b: { type: 'boolean' } });
145+
const { data, errors } = await coerceRow({ n: 'abc', b: 'maybe' }, metaMap, {});
146+
expect(data).toEqual({});
147+
expect(errors.map((e) => e.code).sort()).toEqual(['invalid_boolean', 'invalid_number']);
148+
});
149+
150+
it('drops blank cells so schema defaults / existing values win', async () => {
151+
const metaMap = meta({ a: { type: 'text' }, b: { type: 'number' } });
152+
const { data } = await coerceRow({ a: '', b: ' ' }, metaMap, {});
153+
expect(data).toEqual({});
154+
});
155+
156+
it('honours createMissingOptions by keeping the raw select value', async () => {
157+
const metaMap = meta({ s: { type: 'select', options: [{ label: 'A', value: 'a' }] } });
158+
const strict = await coerceRow({ s: 'zzz' }, metaMap, {});
159+
expect(strict.errors[0]?.code).toBe('invalid_option');
160+
const lax = await coerceRow({ s: 'zzz' }, metaMap, { createMissingOptions: true });
161+
expect(lax.errors).toEqual([]);
162+
expect(lax.data).toEqual({ s: 'zzz' });
163+
});
164+
165+
it('passes unknown columns through untouched', async () => {
166+
const { data } = await coerceRow({ mystery: 'raw' }, new Map(), {});
167+
expect(data).toEqual({ mystery: 'raw' });
168+
});
169+
});

0 commit comments

Comments
 (0)