From 6ef0eb0de14fe8dcd4345bfaf8f283625cb46e9a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 18:31:49 +0000 Subject: [PATCH 1/4] fix(rest): apply inline import mapping entries' transform, defaultValue and required Claude-Session: https://claude.ai/code/session_01CxBKycaiUpA1yGx1XyoGqX Co-authored-by: Claude --- .../src/import-inline-mapping-entry.test.ts | 220 ++++++++++++++++++ packages/rest/src/import-prepare.ts | 120 +++++++++- 2 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 packages/rest/src/import-inline-mapping-entry.test.ts diff --git a/packages/rest/src/import-inline-mapping-entry.test.ts b/packages/rest/src/import-inline-mapping-entry.test.ts new file mode 100644 index 00000000000..ee08f08f792 --- /dev/null +++ b/packages/rest/src/import-inline-mapping-entry.test.ts @@ -0,0 +1,220 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25491] An inline `FieldMappingEntry[]` mapping's per-cell rules + * (`transform`, `defaultValue`, `required`) are applied by the REAL + * `POST /data/:object/import` route before the cells are coerced: they used to + * be accepted and dropped, leaving only the rename. Driven through a real + * ObjectQL engine + protocol on sqlite `:memory:`, as + * `import-integration.test.ts` is. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; +import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; + +const SUPPLIER = { + name: 'supplier', label: 'Supplier', systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', type: 'text' as const, label: 'Name' }, + country: { name: 'country', type: 'text' as const, label: 'Country' }, + credit_code: { name: 'credit_code', type: 'text' as const, label: 'Credit code' }, + }, + validations: [{ + type: 'format' as const, name: 'credit_code_format', field: 'credit_code', + regex: '^[0-9A-Z]{18}$', message: 'Credit code must be 18 digits or capital letters', + }], +}; + +const CONTACT = { + name: 'contact', label: 'Contact', systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + supplier: { name: 'supplier', type: 'text' as const, label: 'Supplier' }, + email: { name: 'email', type: 'email' as const, label: 'Email' }, + name: { name: 'name', type: 'text' as const, label: 'Name' }, + }, +}; + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +async function boot() { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true, + }), true); + await engine.init(); + engine.registry.registerObject(SUPPLIER as any); + engine.registry.registerObject(CONTACT as any); + await engine.syncSchemas(); + await installDefaultOrganization(engine); + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } + const protocol = new ObjectStackProtocolImplementation(engine as any); + const noop = () => {}; + const server = { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; + const rest = new RestServer(server as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/import'); + expect(route).toBeDefined(); + return { engine, route }; +} + +describe('import route — inline FieldMappingEntry rules (#25491)', () => { + let engine: any; + let route: any; + const call = async (object: string, body: any) => { + const res = makeRes(); + await route.handler({ params: { object }, body } as any, res); + return res; + }; + + beforeEach(async () => { + ({ engine, route } = await boot()); + }); + + it('uppercase: a lowercase credit code passes the ^[0-9A-Z]{18}$ rule and is stored upper-cased (the card\'s reproduction)', async () => { + const res = await call('supplier', { + format: 'csv', + csv: '公司全称,国家 / 地区,统一社会信用代码\n示例公司,中国,91000000ma0trial02\n', + mapping: [ + { sourceField: '公司全称', targetField: 'name' }, + { sourceField: '国家 / 地区', targetField: 'country' }, + { sourceField: '统一社会信用代码', targetField: 'credit_code', transform: 'uppercase' }, + ], + writeMode: 'upsert', matchFields: ['credit_code'], + }); + expect(res._json).toMatchObject({ total: 1, ok: 1, errors: 0, created: 1 }); + const stored = await engine.find('supplier', {}); + expect(stored).toHaveLength(1); + expect(stored[0]).toMatchObject({ name: '示例公司', country: '中国', credit_code: '91000000MA0TRIAL02' }); + }); + + it('lowercase: two spellings of one email merge into one contact under matchFields [supplier, email]', async () => { + const mapping = [ + { sourceField: 'Supplier', targetField: 'supplier' }, + { sourceField: 'Email', targetField: 'email', transform: 'lowercase' }, + { sourceField: 'Name', targetField: 'name' }, + ]; + const first = await call('contact', { + format: 'csv', csv: 'Supplier,Email,Name\ns1,ZHOU@X.EXAMPLE.COM,Zhou\n', + mapping, writeMode: 'upsert', matchFields: ['supplier', 'email'], + }); + expect(first._json).toMatchObject({ ok: 1, created: 1 }); + const second = await call('contact', { + format: 'csv', csv: 'Supplier,Email,Name\ns1,zhou@x.example.com,Zhou Wei\n', + mapping, writeMode: 'upsert', matchFields: ['supplier', 'email'], + }); + expect(second._json).toMatchObject({ ok: 1, created: 0, updated: 1 }); + const stored = await engine.find('contact', {}); + expect(stored).toHaveLength(1); + expect(stored[0]).toMatchObject({ supplier: 's1', email: 'zhou@x.example.com', name: 'Zhou Wei' }); + }); + + it('trim: strips the cell even when the request turns trimWhitespace off', async () => { + const res = await call('supplier', { + format: 'json', trimWhitespace: false, + rows: [{ N: ' Acme ', C: ' CN ' }], + mapping: [ + { sourceField: 'N', targetField: 'name', transform: 'trim' }, + { sourceField: 'C', targetField: 'country' }, + ], + }); + expect(res._json).toMatchObject({ ok: 1, created: 1 }); + const [stored] = await engine.find('supplier', {}); + expect(stored).toMatchObject({ name: 'Acme', country: ' CN ' }); + }); + + it('defaultValue: fills a blank cell and a missing column, leaves a filled cell alone', async () => { + const res = await call('supplier', { + format: 'csv', csv: 'Name,Country\nA,\nB,JP\n', + mapping: [ + { sourceField: 'Name', targetField: 'name' }, + { sourceField: 'Country', targetField: 'country', defaultValue: 'CN' }, + { sourceField: 'Code', targetField: 'credit_code', defaultValue: '000000000000000000' }, + ], + }); + expect(res._json).toMatchObject({ ok: 2, created: 2 }); + const stored = await engine.find('supplier', { orderBy: [{ field: 'name', order: 'asc' }] }); + expect(stored.map((r: any) => [r.name, r.country, r.credit_code])).toEqual([ + ['A', 'CN', '000000000000000000'], + ['B', 'JP', '000000000000000000'], + ]); + }); + + it('required: a blank required cell refuses the request before any row, on the dry run and the commit alike', async () => { + const body = { + format: 'csv', csv: 'Name,Country\nA,CN\nB,\n', + mapping: [ + { sourceField: 'Name', targetField: 'name' }, + { sourceField: 'Country', targetField: 'country', required: true }, + ], + }; + const dry = await call('supplier', { ...body, dryRun: true }); + const commit = await call('supplier', body); + for (const res of [dry, commit]) { + expect(res._status).toBe(400); + expect(res._json?.error?.code ?? res._json?.code).toBe('MISSING_REQUIRED_FIELD'); + expect(JSON.stringify(res._json)).toContain('row 2'); + } + expect(await engine.find('supplier', {})).toHaveLength(0); + }); + + it('required with a defaultValue is satisfied by the default', async () => { + const res = await call('supplier', { + format: 'csv', csv: 'Name,Country\nB,\n', + mapping: [ + { sourceField: 'Name', targetField: 'name' }, + { sourceField: 'Country', targetField: 'country', required: true, defaultValue: 'CN' }, + ], + }); + expect(res._json).toMatchObject({ ok: 1, created: 1 }); + }); + + it('date_format is refused with 400 UNSUPPORTED_TRANSFORM naming it, and nothing is written', async () => { + const res = await call('supplier', { + format: 'csv', csv: 'Name\nA\n', + mapping: [{ sourceField: 'Name', targetField: 'name', transform: 'date_format' }], + }); + expect(res._status).toBe(400); + expect(res._json?.error?.code ?? res._json?.code).toBe('UNSUPPORTED_TRANSFORM'); + expect(JSON.stringify(res._json)).toContain('date_format'); + expect(await engine.find('supplier', {})).toHaveLength(0); + }); + + it('control: a plain { sourceField, targetField } array mapping is the rename it always was', async () => { + const res = await call('supplier', { + format: 'csv', csv: 'Name,Country\n Acme ,cn\n', trimWhitespace: false, + mapping: [ + { sourceField: 'Name', targetField: 'name' }, + { sourceField: 'Country', targetField: 'country', transform: 'none' }, + ], + }); + expect(res._json).toMatchObject({ ok: 1, created: 1 }); + const [stored] = await engine.find('supplier', {}); + expect(stored).toMatchObject({ name: ' Acme ', country: 'cn' }); + }); +}); diff --git a/packages/rest/src/import-prepare.ts b/packages/rest/src/import-prepare.ts index 9d9ccc7c1aa..c170dc38857 100644 --- a/packages/rest/src/import-prepare.ts +++ b/packages/rest/src/import-prepare.ts @@ -17,6 +17,7 @@ import { resolveNamedMapping, applyMappingToRows, refuseUnknownMappingTargets, type MappingArtifactLike, } from './import-mapping.js'; import { asXlsxLoadInput, loadExcelJs } from './xlsx-module.js'; +import { isBlank } from '@objectstack/core'; /** * Minimal RFC-4180-style CSV parser used by the bulk-import endpoint @@ -194,6 +195,112 @@ export async function parseXlsxToRows( return out; } +/** + * [#25491] The per-cell rules of one inline `FieldMappingEntry` + * (`ImportRequestSchema.mapping` in its array form), keyed by the entry's + * target: the column has already been renamed to it when the rules run. + */ +interface InlineEntryRule { + sourceField: string; + targetField: string; + transform: InlineTransform; + hasDefault: boolean; + defaultValue?: unknown; + required: boolean; +} + +/** + * The transforms an inline mapping entry may declare that this path applies, + * each to a string cell (any other value passes through). `none` and `lookup` + * copy the cell: a `lookup` cell is resolved to a record id by the import's + * reference resolution downstream, as the named mapping's `lookup` is. + */ +const INLINE_TRANSFORMS = { + none: (s: string) => s, + lookup: (s: string) => s, + trim: (s: string) => s.trim(), + uppercase: (s: string) => s.toUpperCase(), + lowercase: (s: string) => s.toLowerCase(), +} as const; +type InlineTransform = keyof typeof INLINE_TRANSFORMS; + +type ImportRefusal = { ok: false; status: number; code: string; error: string }; + +/** + * Read one inline entry's rules, refusing a transform this path does not + * apply instead of running the import without it. `date_format` is declared + * by the spec, but an entry carries no format for it to apply. Returns no + * rule for a plain rename (no transform, no default, not required), which + * leaves the row exactly as the rename built it. + */ +function readInlineEntryRule( + e: { sourceField: string; targetField: string; transform?: unknown; defaultValue?: unknown; required?: unknown }, +): { ok: true; rule?: InlineEntryRule } | ImportRefusal { + const transform = e.transform === undefined ? 'none' : e.transform; + if (typeof transform !== 'string' || !Object.prototype.hasOwnProperty.call(INLINE_TRANSFORMS, transform)) { + const named = typeof transform === 'string' ? `"${transform}"` : String(transform); + const why = transform === 'date_format' + ? 'a mapping entry carries no date format for it to apply; a cell mapped to a date or datetime field is already parsed as a date, so drop the transform' + : `the transforms a mapping entry may declare are ${Object.keys(INLINE_TRANSFORMS).join(', ')}`; + return { + ok: false, status: 400, code: 'UNSUPPORTED_TRANSFORM', + error: `Mapping entry "${e.sourceField}" to "${e.targetField}" declares transform ${named}, which the import path does not apply, ` + + `so the import is refused rather than run with that transform skipped: ${why}.`, + }; + } + const hasDefault = e.defaultValue !== undefined; + const required = e.required === true; + if (transform === 'none' && !hasDefault && !required) return { ok: true }; + return { + ok: true, + rule: { + sourceField: e.sourceField, targetField: e.targetField, + transform: transform as InlineTransform, hasDefault, defaultValue: e.defaultValue, required, + }, + }; +} + +/** + * Apply each inline entry's rules to every row, in place, before the cells + * are coerced: the transform to a string cell, then `defaultValue` to a cell + * that is blank (what cell coercion calls blank: absent, empty, whitespace or + * one of the request's `nullValues`), then `required` to what is left. A + * required cell still blank refuses the whole request before any row is + * written, on the dry run and the commit alike, naming the column and the + * rows (numbered as the row report numbers them). + */ +function applyInlineEntryRules( + rows: Array>, + rules: InlineEntryRule[], + nullValues: string[] | undefined, +): ImportRefusal | undefined { + const misses = new Map(); + rows.forEach((row, i) => { + for (const rule of rules) { + let value = row[rule.targetField]; + if (typeof value === 'string') value = INLINE_TRANSFORMS[rule.transform](value); + if (rule.hasDefault && isBlank(value, nullValues)) value = rule.defaultValue; + if (value !== undefined) row[rule.targetField] = value; + if (rule.required && isBlank(value, nullValues)) { + const at = misses.get(rule) ?? []; + at.push(i + 1); + misses.set(rule, at); + } + } + }); + if (misses.size === 0) return undefined; + const listed = [...misses].map(([rule, at]) => { + const shown = at.length > 10 ? `${at.slice(0, 10).join(', ')} and ${at.length - 10} more` : at.join(', '); + return `"${rule.sourceField}" to "${rule.targetField}" (row${at.length === 1 ? '' : 's'} ${shown})`; + }); + return { + ok: false, status: 400, code: 'MISSING_REQUIRED_FIELD', + error: `The mapping marks ${listed.length === 1 ? 'a column' : 'columns'} required that some rows leave blank: ${listed.join('; ')}. ` + + 'The import is refused before any row, on the dry run and the commit alike. ' + + 'Fill those cells, give the entry a defaultValue, or drop required from it.', + }; +} + /** Everything the import runner needs, parsed & validated from a request body. */ export interface PreparedImport { rows: Array>; @@ -453,12 +560,18 @@ export async function prepareImportRequest( } // Normalize `mapping` to a `{ sourceColumn: targetField }` record. Accepts - // either that compact form or a FieldMappingEntry[]. + // either that compact form or a FieldMappingEntry[]. An entry's own + // per-cell rules (`transform` / `defaultValue` / `required`) are kept + // beside the rename and applied once the rows are built (#25491). const mapping: Record = {}; + const entryRules: InlineEntryRule[] = []; if (Array.isArray(body?.mapping)) { for (const e of body.mapping) { if (e && typeof e.sourceField === 'string' && typeof e.targetField === 'string') { mapping[e.sourceField] = e.targetField; + const read = readInlineEntryRule(e); + if (!read.ok) return read; + if (read.rule) entryRules.push(read.rule); } } } else if (body?.mapping && typeof body.mapping === 'object') { @@ -499,6 +612,11 @@ export async function prepareImportRequest( return { ok: false, status: 413, code: 'PAYLOAD_TOO_LARGE', error: `Import limit is ${maxRows} rows per request (got ${rows.length}).` }; } + if (entryRules.length > 0) { + const refused = applyInlineEntryRules(rows, entryRules, nullValues); + if (refused) return refused; + } + // Resolve the object's field metadata so cells coerce to storage values // (booleans, numbers, dates→ISO, select label→code) and lookup names resolve // to record ids. Best-effort: a failed lookup leaves `metaMap` empty and From db06ea30f59bc6808291130ef626900b0228a38c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 18:51:07 +0000 Subject: [PATCH 2/4] chore(changeset): inline import mapping entry rules Claude-Session: https://claude.ai/code/session_01CxBKycaiUpA1yGx1XyoGqX Co-authored-by: Claude --- ...25491-import-inline-mapping-entry-rules.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .changeset/25491-import-inline-mapping-entry-rules.md diff --git a/.changeset/25491-import-inline-mapping-entry-rules.md b/.changeset/25491-import-inline-mapping-entry-rules.md new file mode 100644 index 00000000000..9a999e585f0 --- /dev/null +++ b/.changeset/25491-import-inline-mapping-entry-rules.md @@ -0,0 +1,26 @@ +--- +'@objectstack/rest': patch +--- + +fix(rest)!: an inline import mapping entry's `transform`, `defaultValue` and `required` are applied, not dropped + +`POST /api/v1/data/:object/import` (and the async import job, which parses the same body) accepts `mapping` as a `FieldMappingEntry[]`, whose entries carry `transform`, `defaultValue` and `required`. The request parser kept only each entry's `sourceField` → `targetField` rename and dropped the rest, so a request asking for `transform: 'uppercase'` validated, ran, and stored the cell unchanged. Each entry's rules now run on its column before the cells are coerced: + +- `trim`, `uppercase` and `lowercase` rewrite a string cell. `none` and `lookup` copy it; a `lookup` cell is resolved to a record id by the import's reference resolution, as before. +- `defaultValue` fills a cell that is blank (absent, empty, whitespace or one of the request's `nullValues`). +- `required: true` refuses the request with `400 MISSING_REQUIRED_FIELD` when a row still leaves that column blank, naming the column and the rows. The refusal comes before any row is written, on the dry run and the commit alike. +- `transform: 'date_format'` refuses the request with `400 UNSUPPORTED_TRANSFORM`: an entry carries no date format for it to apply. A cell mapped to a date or datetime field is already parsed as a date, so drop the transform. A transform name outside the declared set is refused the same way. + +A plain `{ sourceField, targetField }` entry, and the compact `{ column: field }` record, rename exactly as before. + +Clause-②: no (narrowing) — the import doors refuse two request shapes they used to run: an inline mapping entry declaring `transform: 'date_format'` (or a transform name outside the declared set), and an entry marked `required: true` whose column a row leaves blank. Nothing is widened. + +Metadata-protocol: none (a REST import request's handling; nothing a metadata author writes changes) + + + +**BREAKING** for a caller that relied on either request running. + +- **Before.** An inline entry's `transform`, `defaultValue` and `required` were ignored; the import ran with the rename alone. +- **Now.** They are applied; `date_format` and a blank `required` cell refuse the request with `400`. +- **Fix.** Drop `transform: 'date_format'` from the entry (the date field's own parse still applies), and fill a required column or give its entry a `defaultValue`. From b5a3141e7f0a2efdf94e9ae94b88d935cf96f37a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 19:42:29 +0000 Subject: [PATCH 3/4] chore(changeset): word the inline mapping fix without rewrite framing Claude-Session: https://claude.ai/code/session_01CxBKycaiUpA1yGx1XyoGqX Co-authored-by: Claude --- .changeset/25491-import-inline-mapping-entry-rules.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/25491-import-inline-mapping-entry-rules.md b/.changeset/25491-import-inline-mapping-entry-rules.md index 9a999e585f0..6d642f0bb55 100644 --- a/.changeset/25491-import-inline-mapping-entry-rules.md +++ b/.changeset/25491-import-inline-mapping-entry-rules.md @@ -4,14 +4,14 @@ fix(rest)!: an inline import mapping entry's `transform`, `defaultValue` and `required` are applied, not dropped -`POST /api/v1/data/:object/import` (and the async import job, which parses the same body) accepts `mapping` as a `FieldMappingEntry[]`, whose entries carry `transform`, `defaultValue` and `required`. The request parser kept only each entry's `sourceField` → `targetField` rename and dropped the rest, so a request asking for `transform: 'uppercase'` validated, ran, and stored the cell unchanged. Each entry's rules now run on its column before the cells are coerced: +`POST /api/v1/data/:object/import` (and the async import job, which parses the same body) accepts `mapping` as a `FieldMappingEntry[]`, whose entries carry `transform`, `defaultValue` and `required`. The request parser kept only the column each entry names with `sourceField` and the field it names with `targetField` and dropped the rest, so a request asking for `transform: 'uppercase'` validated, ran, and stored the cell unchanged. Each entry's rules now run on its column before the cells are coerced: - `trim`, `uppercase` and `lowercase` rewrite a string cell. `none` and `lookup` copy it; a `lookup` cell is resolved to a record id by the import's reference resolution, as before. - `defaultValue` fills a cell that is blank (absent, empty, whitespace or one of the request's `nullValues`). - `required: true` refuses the request with `400 MISSING_REQUIRED_FIELD` when a row still leaves that column blank, naming the column and the rows. The refusal comes before any row is written, on the dry run and the commit alike. - `transform: 'date_format'` refuses the request with `400 UNSUPPORTED_TRANSFORM`: an entry carries no date format for it to apply. A cell mapped to a date or datetime field is already parsed as a date, so drop the transform. A transform name outside the declared set is refused the same way. -A plain `{ sourceField, targetField }` entry, and the compact `{ column: field }` record, rename exactly as before. +A plain `{ sourceField, targetField }` entry, and the compact `{ column: field }` record, map columns exactly as before. Clause-②: no (narrowing) — the import doors refuse two request shapes they used to run: an inline mapping entry declaring `transform: 'date_format'` (or a transform name outside the declared set), and an entry marked `required: true` whose column a row leaves blank. Nothing is widened. From 36a18af9884943c958a4969248352192e5b059c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 21:35:34 +0000 Subject: [PATCH 4/4] docs(rest): name the third import door and the inline entry rules The contract review on PR #25540 found two stale prose claims: the changeset named two of the three doors that parse through prepareImportRequest (plugin-auth's admin user import is the third), and the import-mappings page still called the inline form a rename only and did not list the two new whole-request refusals. The import-mapping.ts seam docblock is brought in line with the same rule. No code changes. Claude-Session: https://claude.ai/code/session_01CxBKycaiUpA1yGx1XyoGqX Co-authored-by: Claude --- .changeset/25491-import-inline-mapping-entry-rules.md | 4 ++-- content/docs/data-modeling/import-mappings.mdx | 5 +++-- packages/rest/src/import-mapping.ts | 7 +++++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.changeset/25491-import-inline-mapping-entry-rules.md b/.changeset/25491-import-inline-mapping-entry-rules.md index 6d642f0bb55..1eb40bf1d88 100644 --- a/.changeset/25491-import-inline-mapping-entry-rules.md +++ b/.changeset/25491-import-inline-mapping-entry-rules.md @@ -4,7 +4,7 @@ fix(rest)!: an inline import mapping entry's `transform`, `defaultValue` and `required` are applied, not dropped -`POST /api/v1/data/:object/import` (and the async import job, which parses the same body) accepts `mapping` as a `FieldMappingEntry[]`, whose entries carry `transform`, `defaultValue` and `required`. The request parser kept only the column each entry names with `sourceField` and the field it names with `targetField` and dropped the rest, so a request asking for `transform: 'uppercase'` validated, ran, and stored the cell unchanged. Each entry's rules now run on its column before the cells are coerced: +`POST /api/v1/data/:object/import`, the async import job and plugin-auth's admin user import (`POST /api/v1/auth/admin/import-users`) all parse their body through the same request parser, which accepts `mapping` as a `FieldMappingEntry[]`, whose entries carry `transform`, `defaultValue` and `required`. The request parser kept only the column each entry names with `sourceField` and the field it names with `targetField` and dropped the rest, so a request asking for `transform: 'uppercase'` validated, ran, and stored the cell unchanged. Each entry's rules now run on its column before the cells are coerced: - `trim`, `uppercase` and `lowercase` rewrite a string cell. `none` and `lookup` copy it; a `lookup` cell is resolved to a record id by the import's reference resolution, as before. - `defaultValue` fills a cell that is blank (absent, empty, whitespace or one of the request's `nullValues`). @@ -13,7 +13,7 @@ fix(rest)!: an inline import mapping entry's `transform`, `defaultValue` and `re A plain `{ sourceField, targetField }` entry, and the compact `{ column: field }` record, map columns exactly as before. -Clause-②: no (narrowing) — the import doors refuse two request shapes they used to run: an inline mapping entry declaring `transform: 'date_format'` (or a transform name outside the declared set), and an entry marked `required: true` whose column a row leaves blank. Nothing is widened. +Clause-②: no (narrowing) — the import doors (the sync import, the async import job and plugin-auth's admin user import) refuse two request shapes they used to run: an inline mapping entry declaring `transform: 'date_format'` (or a transform name outside the declared set), and an entry marked `required: true` whose column a row leaves blank. Nothing is widened. Metadata-protocol: none (a REST import request's handling; nothing a metadata author writes changes) diff --git a/content/docs/data-modeling/import-mappings.mdx b/content/docs/data-modeling/import-mappings.mdx index 30346844d70..85625b12851 100644 --- a/content/docs/data-modeling/import-mappings.mdx +++ b/content/docs/data-modeling/import-mappings.mdx @@ -198,7 +198,7 @@ sending both is `400 CONFLICTING_MAPPING`. | | inline `mapping` | `mappingName` | |:---|:---|:---| | Shape | A flat `{ "": "" }` rename (or a `sourceField`/`targetField` array) | A registered `mapping` artifact | -| Transforms | None — rename only | The `fieldMapping` pipeline | +| Transforms | Per entry of the array form: `transform` (`trim`, `uppercase`, `lowercase`; `none` and `lookup` copy the cell), `defaultValue` for a blank cell, `required`. The flat record is a rename only | The `fieldMapping` pipeline | | Unmapped columns | **Pass through** to the write path under their own header | **Dropped.** The artifact is a strict projection: only mapped targets survive | | Lives | In the one request | In a package or in metadata, reusable | @@ -218,7 +218,8 @@ These are whole-request failures — nothing is written, and there is no per-row | 400 | `MAPPING_TARGET_MISMATCH` | The artifact's `targetObject` is not the object in the URL. | | 400 | `MAPPING_FORMAT_UNSUPPORTED` | The artifact declares `sourceFormat: 'xml'` or `'sql'`; the endpoint accepts csv, json and xlsx payloads only. | | 400 | `MAPPING_FORMAT_MISMATCH` | The declared `sourceFormat` contradicts the payload actually sent. A `csv` mapping does apply to an `xlsx` payload — those rows are tabular in the same way — but a `json` mapping does not. | -| 400 | `UNSUPPORTED_TRANSFORM` | Some entry declares `javascript`, or a transform name the pipeline does not implement. | +| 400 | `UNSUPPORTED_TRANSFORM` | Some artifact entry declares `javascript`, or a transform name the pipeline does not implement; or an inline entry declares `date_format` (it carries no format to apply) or a name outside `none`, `lookup`, `trim`, `uppercase`, `lowercase`. | +| 400 | `MISSING_REQUIRED_FIELD` | An inline entry is marked `required: true` and some row leaves its column blank after its `defaultValue`. The error names the column and the rows; it applies to the dry run and the commit alike. | | 400 | `CONFLICTING_MAPPING` | Both `mappingName` and an inline `mapping` were supplied. | | 400 | `INVALID_REQUEST` | No recognizable payload, or `writeMode` resolved to update/upsert with no `matchFields` from either the request or the artifact. | | 413 | `PAYLOAD_TOO_LARGE` | More than 5,000 rows on the synchronous route. | diff --git a/packages/rest/src/import-mapping.ts b/packages/rest/src/import-mapping.ts index 5b340f56fc2..bb938dcbe21 100644 --- a/packages/rest/src/import-mapping.ts +++ b/packages/rest/src/import-mapping.ts @@ -6,8 +6,11 @@ * pipeline to parsed rows. * * Seam with the inline request `mapping`: - * • inline = a plain `{ sourceColumn: targetField }` RENAME for one-off, - * wizard-driven imports — unmapped columns pass through untouched. + * • inline = a `{ sourceColumn: targetField }` rename (or a + * `FieldMappingEntry[]` whose entries may add `transform` + * trim/uppercase/lowercase, `defaultValue` and `required`, applied in + * `import-prepare.ts`) for one-off, wizard-driven imports — unmapped + * columns pass through untouched. * • artifact = a reusable, governed ETL projection for recurring / * programmatic imports — the output row contains ONLY the mapped * targets (a strict projection; source files from external systems