Skip to content

Commit f557d90

Browse files
fix(rest, core)!: refuse a multi-value or structured field as an import match key (#27465) (#27562)
domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw <!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu7dXrqazMkNZSnk4Zd21pny)_ Fixes #27465 Clause-②: yes (narrowing) — an update / upsert import whose `matchFields`, or whose named mapping's `upsertKey`, names a field stored as a JSON column was accepted and then failed every row; the door now refuses it with `400 INVALID_REQUEST` before any row. One surface is widened: `@objectstack/core` gains one root export, `multiValueMatchKeyRefusal(matchFields, metaMap, keyedBy?)` (beside `runImport`, through `export * from './utils/import-runner.js'`), so the door and the runner judge one rule. Metadata-protocol: none Skills-docs: updated here — content/docs/data-modeling/import-mappings.mdx (the 400 INVALID_REQUEST reasons list names the multi-value / structured match-key refusal; the per-row results table adds the runner-path VALIDATION_ERROR row), prose-only commit 1e4dd12 ## 维护者速读 - **改了什么**:导入(更新 / 更新或新建)时,匹配键若选了多值查找、多选、标签、JSON 这类「一格多值或结构化」的字段,导入入口直接以 400 拒绝并点名该字段,不再让每一行都报一条看不懂的 SQL 绑定错误。 - **为什么改**:企业客户在导入向导里勾选多值查找作匹配键,28 行全部失败;这类字段本来就无法唯一定位一条记录。分诊要求「选一条规则并写明,绝不把裸 SQL 错给用户」,席位选了规则一:不能作匹配键。 - **风险与回滚**:只收窄一种此前必然全行失败的请求,原本能成功的导入行为不变;回滚即 revert 本 PR。 - **先看**:`packages/core/src/utils/import-runner.ts`(`multiValueMatchKeyRefusal` 判据与 runner 守卫)、`packages/rest/src/import-prepare.ts`(入口拒绝,命名映射的 upsertKey 同判)、`packages/rest/src/import-multi-value-match-key.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/rest exec vitest run src/import-multi-value-match-key.test.ts`,预期 9 个用例全过;其中卡面场景断言 400 `INVALID_REQUEST`、消息含 `"work_object"`、零读零写。 - **需要你做的**:无,席位落地(向导不再提供多值字段的 ui 半另立 `domain:ui` 卡)。 ## What changes - **The rule, one definition** (`multiValueMatchKeyRefusal`, `@objectstack/core`, exported beside `runImport`). A match key cannot name a field stored as a JSON column, asked of the spec, never re-spelled: `isMultiValueField` (a multi-capable type flagged `multiple: true`: `lookup`, `user`, `select`, `radio`, `file`, `image`; or an inherently multi option type: `multiselect`, `checkboxes`, `tags`) or `STRUCTURED_JSON_TYPES` (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`). A single-value lookup or file field and every scalar field stay legal keys. A name the field map does not carry is judged by nobody, as cell coercion passes it through. The message names each offending field with its kind, for example `"work_object" (lookup, multiple: true)`, and says to match on single-value fields. - **The door** (`prepareImportRequest`, `@objectstack/rest`, so the sync import and the async import-job route alike). Once the object's field map is resolved, an update / upsert request whose key names such a field is refused with `400 INVALID_REQUEST`, before any row is read, matched or written, on the dry run and the commit. A named mapping's `upsertKey`, when it supplies the key, is judged the same way and the message names the mapping. Insert mode matches nothing, so a `matchFields` it carries is not judged. - **The runner** (`runImport`). The programmatic path gets a guard: the connector pull (`@objectstack/service-automation`) writes a mapping's `upsertKey` straight into `runImport` without the door, so the runner fails every row with `VALIDATION_ERROR`, `field` = the first offending field and the same message, before any row is coerced, read or written (the lookup cells' own reference resolution included), and reports progress once. `VALIDATION_ERROR` is a standard-catalog code; `INVALID_REQUEST` is not registered under `@objectstack/core` in the error-code ledger, so the runner does not stamp it. ## Why Measured at base (`d4ff1a02`, through the real `POST /data/:object/import` over better-sqlite3), an upsert keyed on each kind answered `200` with every row failed, dry run and commit alike: - `multiple: true` lookup, `multiselect`, `tags`: `INVALID_FILTER` — `The implicit-equality comparand on field "work_object" requires a single comparable value, but received an array (["prj_a","prj_b"]) …` - `json`: `FILTER_TOKEN_UNKNOWN` — `Unresolvable filter placeholder "{"a":1}" …`, a wrong answer about a filter token the author never wrote. So the JSON-stored kinds beside the card's lookup are in the rule on measurement, not by analogy. Exact array or object equality is order- and serialization-sensitive even where a backend could evaluate it, so refusing is the rule triage asked to pick (comment 6491237090), not matching on the set. ## Tests - `packages/rest/src/import-multi-value-match-key.test.ts` (new, 9 cases, real route over a real `SqlDriver`): the card's lookup in upsert and update, commit and dry run; beside a scalar in one key set and as a later ordered key set; multiselect, tags and json, one by one and all named in one answer; a named mapping's `upsertKey`; each refusal pin asserts `400`, `INVALID_REQUEST`, the field named, no `results`, and that `findData` / `updateData` / `createManyData` were never called and the stored rows are unchanged. Preservation pins: a scalar key upserts with the lookup written as an ordinary column (the arguments that landed), and an insert carrying such a key is not judged. - `packages/core/src/utils/import-runner-multi-value-match-key.test.ts` (new, 23 cases): the rule on all eleven refused kinds and on its boundary (scalar fields, a single-value lookup, a single-value file field, an unknown name, ordered sets of legal keys); `runImport` in upsert commit, upsert dry run and update commit fails every row with the refusal and calls nothing on the protocol; a later ordered key set is refused the same way; insert and a scalar key are preserved. ## Acceptance notes - **Open-PR overlap.** None of the 29 open pull requests' heads differs from `main` on `import-prepare.ts` or `import-runner.ts` (read at this PR's creation), so no merge-tree conflict is possible on this diff's source files. - **The wizard half is not in this diff.** `ui/packages/plugin-grid/src/ImportWizard.tsx` still offers every mapped field under "Match on" (its field shape carries no `multiple`). It is a separate `domain:ui` card that links back to #27465; until it lands, ticking such a field now meets this door's 400 instead of 28 row errors. - **Spec describe.** `ImportRequestSchema.matchFields`'s describe (`packages/spec/src/api/export.zod.ts`) does not yet say a multi-value or structured field is refused. `packages/spec/**` is outside this card's file surface; noted for the seat. - **Branch name.** The card thread can push only its harness-assigned branch `claude/project-thread-138abn`, not `claude/issue-27465-…` (the claim records the exception). - **Clause-② (narrowing)** owes one contract-tier review before the queue, run by the seat. ## Verification All on head `d94c12dd` (the dogfood battery and the gate sweep on `a427e79a`, which differs from it only in the changeset text; the changeset-reading gates re-run on `d94c12dd`), tree `objectstack-ai/objectstack`, base `origin/main` `d4ff1a02` (no newer main to merge). <details><summary>Red leg (base blobs of both source files, core rebuilt) and green leg</summary> - Red: `vitest run src/import-multi-value-match-key.test.ts` with the base `import-runner.ts` and `import-prepare.ts` (core `dist` rebuilt, marker count 0): `Tests 7 failed | 2 passed (9)` — every refusal pin red on `expected 200 to be 400`, both preservation pins green at base. - Green: rest `Tests 9 passed (9)`; core `Tests 23 passed (23)`. </details> <details><summary>Ablations (scripts/ablation-replace.mjs, each restored to blob == HEAD, git diff HEAD empty)</summary> - Runner guard (`const refusedKey = … multiValueMatchKeyRefusal(matchFields, metaMap)` replaced by `undefined`): core suite `Tests 4 failed | 19 passed (23)`, the four `runImport` refusal cases red; restored `eb4cdc46a04d`. - Door refusal (`if (refusedKey) return { … 400 INVALID_REQUEST … }` replaced by a no-op): rest suite `Tests 7 failed | 2 passed (9)`, `expected 200 to be 400` (the runner guard still failed the rows, so the door pin is independent of it); restored `f6f96ed81bc4`. </details> <details><summary>Package suites, typecheck, lint</summary> - `pnpm --filter @objectstack/core test` :: exit 0 — `Test Files 126 passed (126)`, `Tests 2848 passed (2848)` - `pnpm --filter @objectstack/rest test` :: exit 0 — `Test Files 335 passed (335)`, `Tests 6587 passed | 326 skipped (6913)` - `pnpm --filter @objectstack/core typecheck` :: exit 0 (after `a427e79a`; the first run named 4 test-typecheck errors in the new runner test's double, fixed there) - `pnpm --filter @objectstack/rest typecheck` :: exit 0 - `eslint packages/core packages/rest --no-inline-config` :: exit 0 </details> <details><summary>Derived gates (dispatch-gates --commands, 62 commands)</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 62 commands derived; every one run, exit code captured before any pipe: **62/62 exit 0**. - `check:dual-build-cjs-loads` first answered exit 3 (PREREQUISITE NOT MET: 7 dists absent in a fresh clone); after building those 7 packages it answered exit 0 (112 entry points across 69 packages load). - `check:type-check-debt` ran under the verify lock: exit 0, 0 ledger entries above their measurement. - Re-run on `d94c12dd` after the changeset edit: `check-adr-0087-registration --base origin/main`, `check-changeset-no-major`, `check-empty-changeset`, `check:doc-authoring`, `check-issue-citations`, `check:nul-bytes` — all exit 0. - `dispatch-gates --ran`: `62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero)`. </details> <details><summary>Dogfood battery (Clause-② narrowing)</summary> - Closure: `turbo run build --filter='@objectstack/dogfood^...' --filter=@objectstack/driver-turso` exit 0 (67 tasks). - `pnpm --filter @objectstack/dogfood test` under `os-verify-lock.sh`, no shard, no path, head `a427e79a`: `VERDICT command-exit 0` · `Test Files 350 passed | 3 skipped (353)` · `Tests 2817 passed | 20 skipped (2837)` · no failure. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgUhfndKYgTyqpG7X6V)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgUhfndKYgTyqpG7X6V --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1ed3360 commit f557d90

6 files changed

Lines changed: 458 additions & 3 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/rest': patch
3+
'@objectstack/core': patch
4+
---
5+
6+
fix(rest, core)!: an update or upsert import whose match key names a multi-value or structured field is refused with `400 INVALID_REQUEST` naming the field, instead of failing every row on a raw SQL-binding error (#27465)
7+
8+
Clause-②: yes (narrowing) — an update / upsert import request whose `matchFields`, or whose named mapping's `upsertKey`, names a field stored as a JSON column was accepted and then failed every row; it is now refused at the door before any row. The one widening is a new root export of `@objectstack/core`, `multiValueMatchKeyRefusal(matchFields, metaMap, keyedBy?)`, the rule both the door and the runner use.
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export is removed or retyped: a request the import door accepted and could never serve is now refused up front, with a refusal that names the field and the remedy. A request that worked before works the same, so there is nothing for a migration to rewrite and no tombstone. -->
12+
13+
**BREAKING** accept-set narrowing at the import door (`POST /data/:object/import`, `POST /data/:object/import/jobs`), and the same rule in the shared import runner (`runImport`, `@objectstack/core`).
14+
15+
- **Before.** In update or upsert mode, a `matchFields` entry naming a `multiple: true` lookup was accepted. Each row's lookup cell coerced to an id array, the matcher queried `{ field: [ids] }` as an implicit equality, and the SQL driver refused to bind it, so every row of the dry run and of the import failed with `Operator "=" … received an array`.
16+
- **Now.** The door refuses the request with `400 INVALID_REQUEST`, before any row is read, matched or written, on the dry run and the import alike. The message names each offending field and its kind (`"work_object" (lookup, multiple: true)`) and says to match on single-value fields. A named mapping's `upsertKey` used as the key is judged the same way, and the message names the mapping.
17+
- **Which fields.** Every field stored as a JSON column, as the spec defines it: a multi-capable type flagged `multiple: true` (`lookup`, `user`, `select`, `radio`, `file`, `image`), an inherently multi option type (`multiselect`, `checkboxes`, `tags`), and a structured JSON type (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`). A single-value lookup or file field, and any scalar field, stays a legal key.
18+
- **The runner.** A caller that reaches `runImport` without the door (the connector pull, which writes a mapping's `upsertKey` straight through) gets every row failed with `VALIDATION_ERROR` and the same message, naming the field, before any row is coerced, read or written. `multiValueMatchKeyRefusal(matchFields, metaMap)` is the one rule both use, exported from `@objectstack/core`.
19+
- **Unchanged.** An insert import matches nothing, so a `matchFields` it carries is not judged. Writing a multi-value field as an ordinary column of an update or upsert keyed on scalar fields works as before.
20+
21+
**The remedy.** Match on single-value fields (a code, a name, an email). Measured through the SQL driver, a key naming any of these kinds already failed every row (the array kinds on the engine's implicit-equality refusal, a `json` field on a filter-token refusal), so an import that worked there before works the same now.

‎content/docs/data-modeling/import-mappings.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ These are whole-request failures — nothing is written, and there is no per-row
260260
| 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`. |
261261
| 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. |
262262
| 400 | `CONFLICTING_MAPPING` | Both `mappingName` and an inline `mapping` were supplied. |
263-
| 400 | `INVALID_REQUEST` | No recognizable payload; `writeMode` resolved to update/upsert with no `matchFields` from either the request or the artifact; malformed key sets in `matchFields` (an empty set or name, or names mixed with sets); `skipBlankMatchKey: true` with key sets; or an inline entry's `applyOn` outside `create` / `always`. |
263+
| 400 | `INVALID_REQUEST` | No recognizable payload; `writeMode` resolved to update/upsert with no `matchFields` from either the request or the artifact; malformed key sets in `matchFields` (an empty set or name, or names mixed with sets); `writeMode` update/upsert with a match key (the request's `matchFields` or the artifact's `upsertKey`) naming a field that holds a list or a structured value — a `multiple: true` lookup, user, select, radio, file or image field, a `multiselect`, `checkboxes` or `tags` field, or a `json`, `address`, `location` or other JSON-stored field — refused before any row, naming each such field (match on single-value fields instead); `skipBlankMatchKey: true` with key sets; or an inline entry's `applyOn` outside `create` / `always`. |
264264
| 413 | `PAYLOAD_TOO_LARGE` | More than 5,000 rows on the synchronous route. |
265265

266266
---
@@ -279,6 +279,7 @@ fails does **not** stop the import.
279279
| `failed` | `false` | `BLANK_MATCH_KEY` | Ordered key sets in `matchFields`, and every set has a blank field on the row. Nothing is written for that row. |
280280
| `failed` | `false` | `AMBIGUOUS_MATCH` | The match fields matched **more than one** record. Nothing is written for that row. |
281281
| `failed` | `false` | `DUPLICATE_MATCH_KEY` | `update` or `upsert`, and another row of the same import has the same match key, compared after cell coercion (`ISO 14001` and `ISO14001` coerce to one option value). Every row of that key fails, `error` names them all, and none of them is written, on a dry run too: keep one row per key and import again. |
282+
| `failed` | `false` | `VALIDATION_ERROR` | Only for a caller that hands rows to the import runner without this endpoint (a mapping's connector pull): the match key names a field that holds a list or a structured value, the case this endpoint refuses with `400 INVALID_REQUEST` above. Every row fails with that refusal, `field` names the first such field, and nothing is read or written. |
282283
| `failed` | `false` | a field error code | A cell could not be coerced, or the engine rejected the record. `field` names the column and `error` carries the message. |
283284

284285
So "a row that does not match" is not one behaviour but three, chosen by `writeMode`
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27465] The runner's half of the rule the import door enforces: a match key
5+
* naming a field that holds a list or a structured value cannot identify a
6+
* record. A caller that reaches {@link runImport} without the door (the
7+
* connector pull writes a mapping's `upsertKey` straight through) gets every
8+
* row failed with that refusal, naming the field, before any row is coerced,
9+
* read or written. At the base the matcher put the coerced id array straight
10+
* into an implicit `$eq`, and every row failed on the driver's raw refusal.
11+
*
12+
* The rule itself, {@link multiValueMatchKeyRefusal}, is pinned on the whole
13+
* JSON-column population and on its boundary (a single-value lookup, a
14+
* single-value file field, and a name the map does not carry stay legal keys).
15+
*/
16+
17+
import { describe, it, expect, vi } from 'vitest';
18+
import { multiValueMatchKeyRefusal, runImport, type ImportProtocolLike } from './import-runner';
19+
import type { ExportFieldMeta } from './import-field-meta.js';
20+
21+
type FindArgs = Parameters<ImportProtocolLike['findData']>[0];
22+
23+
const meta = (name: string, type: string, extra: Partial<ExportFieldMeta> = {}): [string, ExportFieldMeta] =>
24+
[name, { name, type, ...extra }];
25+
26+
const metaMap = new Map<string, ExportFieldMeta>([
27+
meta('code', 'text'),
28+
meta('title', 'text'),
29+
meta('work_object', 'lookup', { reference: 'pm_project', displayField: 'name', multiple: true }),
30+
meta('owner', 'lookup', { reference: 'pm_project', displayField: 'name' }),
31+
meta('watchers', 'user', { reference: 'sys_user', multiple: true }),
32+
meta('flags', 'select', { multiple: true, options: [{ label: 'A', value: 'a' }] }),
33+
meta('topics', 'multiselect', { options: [{ label: 'Red', value: 'red' }] }),
34+
meta('picks', 'checkboxes', { options: [{ label: 'X', value: 'x' }] }),
35+
meta('labels', 'tags'),
36+
meta('photos', 'image', { multiple: true }),
37+
meta('contract', 'file'),
38+
meta('meta', 'json'),
39+
meta('hq', 'address'),
40+
meta('spot', 'location'),
41+
meta('embedding', 'vector'),
42+
]);
43+
44+
function makeProtocol() {
45+
return {
46+
findData: vi.fn(async (_args: FindArgs) => ({ records: [] })),
47+
createData: vi.fn(async () => ({ id: 'new' })),
48+
createManyData: vi.fn(async (args: { records: any[] }) => ({ records: args.records.map((r, i) => ({ id: `n${i}`, ...r })) })),
49+
updateData: vi.fn(async () => ({ id: 'upd' })),
50+
};
51+
}
52+
53+
const baseOpts = {
54+
objectName: 'pm_task',
55+
metaMap,
56+
runAutomations: false,
57+
trimWhitespace: true,
58+
createMissingOptions: false,
59+
skipBlankMatchKey: false,
60+
};
61+
62+
const ROWS = [
63+
{ code: 'T-1', title: 'one', work_object: 'Apollo;Borealis' },
64+
{ code: 'T-2', title: 'two', work_object: 'Borealis' },
65+
];
66+
67+
describe('[#27465] multiValueMatchKeyRefusal: which fields cannot be a match key', () => {
68+
it.each([
69+
['a `multiple: true` lookup', 'work_object', '"work_object" (lookup, multiple: true)'],
70+
['a `multiple: true` user', 'watchers', '"watchers" (user, multiple: true)'],
71+
['a `multiple: true` select', 'flags', '"flags" (select, multiple: true)'],
72+
['a multiselect', 'topics', '"topics" (multiselect)'],
73+
['a checkboxes field', 'picks', '"picks" (checkboxes)'],
74+
['a tags field', 'labels', '"labels" (tags)'],
75+
['a `multiple: true` image', 'photos', '"photos" (image, multiple: true)'],
76+
['a json field', 'meta', '"meta" (json)'],
77+
['an address', 'hq', '"hq" (address)'],
78+
['a location', 'spot', '"spot" (location)'],
79+
['a vector', 'embedding', '"embedding" (vector)'],
80+
])('%s is refused, named with its kind', (_label, field, named) => {
81+
const refused = multiValueMatchKeyRefusal(['code', field], metaMap);
82+
expect(refused?.fields).toEqual([field]);
83+
expect(refused?.message).toContain(named);
84+
});
85+
86+
it('every refused field of every ordered key set is named once, in key order', () => {
87+
const refused = multiValueMatchKeyRefusal([['code', 'labels'], ['meta'], ['labels', 'title']], metaMap, 'Mapping "feed" upsertKey');
88+
expect(refused?.fields).toEqual(['labels', 'meta']);
89+
expect(refused?.message.startsWith('Mapping "feed" upsertKey names "labels" (tags), "meta" (json)')).toBe(true);
90+
});
91+
92+
it.each([
93+
['scalar text fields', ['code', 'title']],
94+
['a single-value lookup', ['owner']],
95+
['a single-value file field (a scalar id)', ['contract']],
96+
['a name the map does not carry', ['nowhere']],
97+
['ordered key sets of legal keys', [['code'], ['owner', 'title']]],
98+
])('%s are legal keys', (_label, matchFields) => {
99+
expect(multiValueMatchKeyRefusal(matchFields as string[] | string[][], metaMap)).toBeUndefined();
100+
});
101+
});
102+
103+
describe('[#27465] runImport fails every row of a key naming a multi-value field, before any read or write', () => {
104+
it.each([
105+
['upsert, commit', 'upsert', false],
106+
['upsert, dry run', 'upsert', true],
107+
['update, commit', 'update', false],
108+
] as const)('%s', async (_label, writeMode, dryRun) => {
109+
const p = makeProtocol();
110+
const onProgress = vi.fn();
111+
const summary = await runImport({
112+
...baseOpts, p, rows: ROWS, writeMode, dryRun, matchFields: ['code', 'work_object'], onProgress,
113+
});
114+
115+
const refusal = multiValueMatchKeyRefusal(['work_object'], metaMap)!.message;
116+
expect(summary.results).toEqual([
117+
{ row: 1, ok: false, action: 'failed', field: 'work_object', code: 'VALIDATION_ERROR', error: refusal },
118+
{ row: 2, ok: false, action: 'failed', field: 'work_object', code: 'VALIDATION_ERROR', error: refusal },
119+
]);
120+
expect(summary).toMatchObject({ total: 2, processed: 2, ok: 0, errors: 2, created: 0, updated: 0, skipped: 0, cancelled: false });
121+
expect(onProgress).toHaveBeenCalledTimes(1);
122+
// Nothing was read: not the matcher's query, not the lookup cells' own resolution.
123+
for (const fn of Object.values(p)) expect(fn).not.toHaveBeenCalled();
124+
});
125+
126+
it('ordered key sets naming one in a later set are refused all the same', async () => {
127+
const p = makeProtocol();
128+
const summary = await runImport({ ...baseOpts, p, rows: ROWS, writeMode: 'upsert', dryRun: false, matchFields: [['code'], ['labels']] });
129+
expect(summary.results.map((r) => [r.ok, r.code, r.field])).toEqual([[false, 'VALIDATION_ERROR', 'labels'], [false, 'VALIDATION_ERROR', 'labels']]);
130+
for (const fn of Object.values(p)) expect(fn).not.toHaveBeenCalled();
131+
});
132+
133+
it('preserved: an insert carrying such a key matches nothing and writes its rows', async () => {
134+
const p = makeProtocol();
135+
const summary = await runImport({
136+
...baseOpts, p, rows: [{ code: 'T-9', title: 'nine' }], writeMode: 'insert', dryRun: false, matchFields: ['work_object'],
137+
});
138+
expect(summary).toMatchObject({ total: 1, ok: 1, created: 1, errors: 0 });
139+
expect(p.findData).not.toHaveBeenCalled();
140+
});
141+
142+
it('preserved: a scalar key still queries one value per field', async () => {
143+
const p = makeProtocol();
144+
const summary = await runImport({
145+
...baseOpts, p, rows: [{ code: 'T-1', title: 'one' }], writeMode: 'upsert', dryRun: false, matchFields: ['code'],
146+
});
147+
expect(summary).toMatchObject({ total: 1, ok: 1, errors: 0 });
148+
expect(p.findData).toHaveBeenCalledWith(expect.objectContaining({ query: expect.objectContaining({ where: { code: 'T-1' } }) }));
149+
});
150+
});

‎packages/core/src/utils/import-runner.ts‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { coerceRow, type CoerceContext, type RefResolver, type RefMatch } from '
55
import type { ExportFieldMeta } from './import-field-meta.js';
66
import type { ValidationMessageTranslator } from '@objectstack/spec/system';
77
import {
8-
canServeApiOperation, isVirtualSearchField, resolveDisplayField,
8+
canServeApiOperation, isMultiValueField, isVirtualSearchField, resolveDisplayField, STRUCTURED_JSON_TYPES,
99
type DisplayNameObjectMeta, type DroppedFieldsEvent, type EnableLike, type SearchFieldMeta,
1010
} from '@objectstack/spec/data';
1111
import type { ExecutionContext } from '@objectstack/spec/kernel';
@@ -686,6 +686,59 @@ const yieldToEventLoop = (): Promise<void> =>
686686
else setTimeout(resolve, 0);
687687
});
688688

689+
/**
690+
* [#27465] The refusal of a match key naming a field that holds a list or a
691+
* structured value, or `undefined` when every named field can be a key.
692+
*
693+
* A match key is an equality per field, and such a field has no one value to
694+
* compare: a `multiple: true` lookup cell coerces to an id ARRAY, which the
695+
* SQL driver refuses to bind for an implicit `$eq`, so every row of the import
696+
* failed on a raw driver message; and exact array or object equality would be
697+
* order- and serialization-sensitive even where a backend could evaluate it.
698+
* The population is the one stored as a JSON column, asked of the spec, never
699+
* re-spelled: `isMultiValueField` (a multi-capable type flagged `multiple:
700+
* true`, or an inherently multi option type: `multiselect`, `checkboxes`,
701+
* `tags`) and `STRUCTURED_JSON_TYPES` (`json`, `address`, `location`, …). A
702+
* single-value file field is a scalar id and stays a legal key. A name the
703+
* map does not carry is judged by nobody, as cell coercion passes it through.
704+
*
705+
* One rule, two callers: the import door refuses the request with it
706+
* (`prepareImportRequest`, `400 INVALID_REQUEST`) before any row, and
707+
* {@link runImport} fails every row with it for a caller that reaches the
708+
* runner without that door (the connector pull writes a mapping's
709+
* `upsertKey` straight through), before any row is read or written.
710+
* `keyedBy` names what the caller read the key from.
711+
*/
712+
export function multiValueMatchKeyRefusal(
713+
matchFields: string[] | string[][],
714+
metaMap: ReadonlyMap<string, ExportFieldMeta>,
715+
keyedBy = 'matchFields',
716+
): { fields: string[]; message: string } | undefined {
717+
const fields: string[] = [];
718+
const named: string[] = [];
719+
for (const f of new Set<string>((matchFields as Array<string | string[]>).flat())) {
720+
const meta = metaMap.get(f);
721+
if (typeof meta?.type !== 'string') continue;
722+
const multiple = meta.multiple === true;
723+
if (STRUCTURED_JSON_TYPES.has(meta.type)) {
724+
named.push(`"${f}" (${meta.type})`);
725+
} else if (isMultiValueField({ type: meta.type, multiple })) {
726+
named.push(`"${f}" (${meta.type}${multiple ? ', multiple: true' : ''})`);
727+
} else {
728+
continue;
729+
}
730+
fields.push(f);
731+
}
732+
if (fields.length === 0) return undefined;
733+
const one = fields.length === 1;
734+
return {
735+
fields,
736+
message: `${keyedBy} names ${named.join(', ')}, which ${one ? 'holds' : 'hold'} a list or a structured value, `
737+
+ `so ${one ? 'it' : 'they'} cannot identify a record: a match key compares one value per field. `
738+
+ 'Match on single-value fields (a code, a name, an email) instead.',
739+
};
740+
}
741+
689742
export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
690743
const {
691744
p, objectName, environmentId, context, rows, metaMap, translate: messageTranslator,
@@ -1343,6 +1396,21 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
13431396
};
13441397

13451398
return (async () => {
1399+
// [#27465] A key naming a multi-value or structured field can match no
1400+
// row, so every row fails with the refusal, before any row is coerced,
1401+
// read or written. The import door refuses such a request before it gets
1402+
// here; this answers the callers that reach the runner without it.
1403+
const refusedKey = writeMode === 'insert' ? undefined : multiValueMatchKeyRefusal(matchFields, metaMap);
1404+
if (refusedKey) {
1405+
for (let i = 0; i < rows.length; i++) {
1406+
results[i] = {
1407+
row: i + 1, ok: false, action: 'failed', field: refusedKey.fields[0], code: 'VALIDATION_ERROR', error: refusedKey.message,
1408+
};
1409+
}
1410+
errCount = rows.length;
1411+
if (onProgress) await onProgress(snapshot(rows.length));
1412+
return { ...snapshot(rows.length), ok: okCount, results, cancelled, ...(collectUndo ? { undoLog } : {}) };
1413+
}
13461414
const sharingKey = await rowsSharingKey();
13471415
for (let i = 0; i < rows.length; i++) {
13481416
const rowNo = i + 1;

0 commit comments

Comments
 (0)