Skip to content

Commit 443b2f4

Browse files
feat(spec,rest,lint): an import mapping target may name a declared part of a compound field (mailing_address.street) (#20246)
Fixes #20149 Clause-②: yes ## What this does The maintainer's ruling on #20149 (comment 5852138019, 「同意」): > **Ruled: A — a mapping target may name a declared part of a compound field (`mailing_address.street`); the importer assembles the parts into one value; the part names are the closed set the value schema declares.** An import mapping could write each source column to one flat field only, so nothing could build an `address` value from the street / city / state / postal code / country columns a spreadsheet carries. Now a `fieldMapping[].target` may name `field.part`, and the import door assembles every part one row maps into ONE value under the field's key, before the engine sees the row. It extends the ONE verdict #20150 built, at the arm that PR marked. There is no second predicate. - **`@objectstack/spec`** (`packages/spec/src/data/import-mapping-target.ts`): - `judgeImportMappingTarget` gains the `{ kind: 'part', target, field, part }` arm. - `indexImportMappingTargets` carries each compound field's parts on a new `parts` map, read from the field's stored value schema (`valueSchemaFor`). The part names are never listed by hand. - A dotted target that stays refused answers `unknown` with a `head` (what the text before the dot names, and its parts when it is compound), so every door can name the legal parts. - `unknownImportMappingTargets` gives each refused target a `reason`: `unknown`, or `collides` for a part of a field the same mapping also writes whole. - `ImportFieldMappingSchema.target` declares the part path in its `.describe()` and docblock. The generated reference page, `api-surface/` and `export-origins/` are regenerated. - **`@objectstack/rest`** (`import-mapping.ts`, `import-prepare.ts`): - `applyMappingToRows` takes the object definition, `trimWhitespace` and `nullValues`, and assembles parts from every transform: `none`, `map`, `constant`, `join`, and each element of a `split`. - `refuseUnknownMappingTargets` names the legal parts in its refusal. It still answers `400 INVALID_FIELD`, before any row. - `isBlank` is exported from `import-coerce.ts`, so a blank part is judged by the same rule as a blank cell. - **`@objectstack/lint`** (`validate-mapping-target-fields.ts`): the message "a dotted path into a field's value is not a target" is gone, because it is false for a declared part. The rule now reports the three refused cases, each with the legal parts. - **Docs**: in `content/docs/data-modeling/import-mappings.mdx` the `target` row said "Target field name(s)", which is now false. It is rewritten. Only the body is edited; the frontmatter is held by PR #20170. ## Decisions, each from a measurement **H1: which fields are compound.** I checked every one of the 49 `FieldType` values through `valueSchemaFor`. Two stored value schemas are closed objects (`catchall: never`): - `address`: seven parts (`street`, `city`, `state`, `postalCode`, `country`, `countryCode`, `formatted`). Every part is an optional string. - `location`: `lat` and `lng` are required numbers; `altitude` and `accuracy` are optional numbers. A field counts as compound when its value schema is a closed object whose every part is an optional string. That selects `address` only. **`location` is excluded, as the ruling asked me to decide and record:** - `LocationValueSchema` refuses `{ lat: '37.7', lng: '-122.4' }` (strings) and `{ lat: 37.7 }` (no `lng`). - On the ADR-0104 warn-first write path, the engine admits a value that fails its value schema and only warns. I measured this below with an address part holding a number. - So a location assembled from text cells would be stored with the wrong type, and nothing would refuse it. With optional-string parts, every subset of text cells is a valid value by construction. The census test pins the whole set against `FieldType.options`. **H2: what one row assembles.** Measured through the real engine (sqlite, JSON rows, no mapping) at `055d4b66e9`, with the same result on the dry run and the commit: | value sent | dry run | commit | read back | |:---|:---|:---|:---| | `{ street, city }` (partial) | ok | ok | `{ street, city }` | | `{ street: '', city }` (empty part) | ok | ok | `{ street: '', city }` | | `{ postalCode: 12345 }` (wrong type) | ok, with warning `invalid_type` | ok | `{ postalCode: 12345 }` | | update `{ city }` over `{ street, city }` | | ok | `{ city }` (the stored value is replaced whole) | The assembly rule, stated in `applyMappingToRows`' docblock and the docs row: - A blank part cell contributes nothing. Blank means empty, whitespace, or a `nullValues` token. - The schema would accept `street: ''`, but a blank flat cell leaves its field unset, and a blank part does the same one level down. - A string part is trimmed under `trimWhitespace`, as a flat text cell is. Coercion never reaches inside a compound value, so the trim happens in the assembly. - If every part a row maps is blank, the field is left unset. - Otherwise the assembled object is the field's whole new value. On an update it replaces the stored one. **Whole field and part together.** When a mapping writes `mailing_address` and also `mailing_address.street`, both write the same key of one row. They are refused as `reason: 'collides'` at all three doors, naming where the whole field is written. A repeated part target follows the same last-write rule as a repeated flat target. **One door.** The dry run and the commit both reach the assembly through `prepareImportRequest`, so they judge the same assembled row. `objectstack validate` asks the same spec verdict. **H5: census of dotted targets** at base `3875ae6773`: - The repo's one import mapping, `examples/app-showcase` `showcase_inquiry_feed` (5 entries), has no dotted target. - No `skills/` or docs page carries a dotted `fieldMapping` target. - Every dotted `target:` hit in the tree belongs to another schema: action or form targets, API endpoint `inputMapping` / `outputMapping`, and the seed loader. - hotcrm (the card's origin, hotcrm#1836) is NOT MEASURED from here. ## Pins (the pin sweep) - **Flipped:** - #20150 pinned `mailing_address.street` as `unknown` in `import-mapping-target.test.ts`, and as a finding in `validate-mapping-target-fields.test.ts`, on an object that declares that address field. - Both now assert the new meaning: all seven parts answer `part`, and the card's full address template is green at `validate`. - The #20150 integration pin keeps refusing `mailing_address.street` on `task`, which declares no such field. Its comment now says why. - **New, real engine** (`import-integration.test.ts`, sqlite): - The customer template's five columns over three rows (full, partial, all blank). - The dry run and the commit agree row for row: `ok 3` each. - Read back: one address object, a two-part address, and an unset field. - An unknown part and a dotted path on a text field are refused on both paths with identical bodies and the part list named, and nothing is written. - The whole-plus-part collision is refused on both paths. - The control mapping with no dotted target passes on both. - **Unit:** the assembly rule in `import-mapping.test.ts` (blank, trim, `trimWhitespace: false`, every transform, two compound fields kept apart, no object definition means no part reading). The door's three refusal texts. The lint rule's three refusals. ## Tests (all through `os-verify-lock.sh`, shared box; tree `f21f41dbea`) - `@objectstack/spec`: `vitest run --project local`: 542 files, 15945 passed, 2 todo. `typecheck` (tsc, scripts, test layer): exit 0. - `@objectstack/rest`: `vitest run --project local`: 199 files, 3569 passed, 1 skipped. `typecheck` (tsc and test layer): exit 0. The `repo` project is declared to CI. - `@objectstack/lint`: `vitest run`: 109 files, 4236 passed. `typecheck`: exit 0. - `@objectstack/cli` (unit): `test/validate-build-gate-parity.test.ts`: 22 passed. The `integration` layer is declared to CI. - ESLint over the 10 changed `.ts` files (`--no-inline-config --format json`): 10 files, 0 errors, 0 warnings. - The population is the config's `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus the build directories, which all 10 files fall under. - `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any file it does not touch. - The repo-wide `pnpm lint` is CI's. ### Ablations (one-shot; each through `scripts/ablation-replace.mjs`, anchor hit 1, blob changed, restored to the HEAD blob with `git diff HEAD` empty) - **A.** The spec part arm answers nothing (`import-mapping-target.ts`): 3 red, 18 green in `import-mapping-target.test.ts`. The red tests are the part arm, the address-by-parts mapping and the collision. - **B.** The rest assembly is off (`import-mapping.ts`): 5 red, 57 green. - 4 red are the assembly unit tests; the control stays green. - The fifth is the integration test. With assembly off, the dry run still answered `ok 3` while the commit answered `ok 1, errors 2`. That is the card's own dry-run-versus-commit defect, reproduced. - **C.** The lint dotted reason is dropped (`validate-mapping-target-fields.ts`): 2 red, 11 green (the unknown-part and no-parts refusals). ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 108 families at `f21f41dbea`, and `--ran` reconciles them: 105 run green, 3 NOT MEASURED, 0 unrun. - NOT MEASURED, each exit 3 (PREREQUISITE NOT MET): - `check:skill-examples` needs a `client-react` build. The shared lock never granted one inside its budget. - `check:dual-build-cjs-loads` and `check:type-check-debt` read a whole-repo build that this worktree does not have. - `check:generated`: all 15 artifacts are up to date after `gen:api-surface`, `gen:export-origins` and `gen:docs`, which are committed. - `check:adr-0087-registration`: green. The changeset declares a widening only (`Clause-②: yes`), so no disposition marker applies. - A mergeability probe against `origin/main` `ab820016b3`, from a bare clone with no regen driver: clean. ## Acceptance notes - Only `address` is compound for import. A `location` field still imports whole, as one JSON object. Adding it would need a per-part number coercion and a required-part check. Carrier: none. - A JSON-format row can put a non-string value in a part, such as a number for `postalCode`. That value passes through to the engine's value-shape check, which admits it with a warning on a warn-first deployment (measured above). This is the posture every structured value on the import path already has. Carrier: none. - On an update, the assembled value replaces the stored address whole (measured). Merging parts into the stored value would be a new decision. It is documented in the docs row and the changeset. Carrier: none. - The collision refusal travels under the existing rule id `mapping-target-field-unknown` and the existing door code `INVALID_FIELD`. No new rule id or error code was minted. - The spec verdict is shared. The door and the lint rule each phrase their own sentence from it (`head.parts`, `index.parts`), as #20150 left them. --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cfc3bcf commit 443b2f4

15 files changed

Lines changed: 959 additions & 81 deletions
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/rest": minor
4+
"@objectstack/lint": minor
5+
---
6+
7+
feat(spec,rest,lint): an import mapping target may name a declared part of a compound field (`mailing_address.street`), and the importer assembles the parts into one value (#20149)
8+
9+
Clause-②: yes
10+
11+
**What was missing.** A mapping could write each source column to one flat
12+
field only, so nothing could build an `address` value from the separate
13+
street / city / state / postal code / country columns a spreadsheet carries.
14+
A dotted target such as `mailing_address.street` named no field and was
15+
refused at `objectstack validate`, on the dry run and on the commit.
16+
17+
**What changes.**
18+
19+
- `@objectstack/spec`: `ImportFieldMappingSchema.target` declares the part
20+
path. A target may name `field.part` when `field` is a declared field whose
21+
stored value schema is a closed object of optional strings (today:
22+
`address`), and `part` is a key that schema declares: `street`, `city`,
23+
`state`, `postalCode`, `country`, `countryCode`, `formatted`. The part names
24+
are read from the value schema, never listed by hand. The one verdict,
25+
`judgeImportMappingTarget`, answers the new `{ kind: 'part', field, part }`;
26+
`indexImportMappingTargets` carries each compound field's parts on
27+
`parts`; `unknownImportMappingTargets` gives each refused target a `reason`
28+
(`unknown` or `collides`) and, for a dotted target, what its `head` names.
29+
`location` is not compound for import: its parts are required numbers, so a
30+
value assembled from text cells would be the wrong type.
31+
- `@objectstack/rest`: `applyMappingToRows` assembles every part target of a
32+
row into one value under the field's key, before the engine sees the row,
33+
whatever transform produced the part (`none`, `map`, `constant`, `join`,
34+
each element of a `split`). A blank part cell (empty, whitespace or a
35+
`nullValues` token) is left out, string parts are trimmed under
36+
`trimWhitespace`, and a row whose parts are all blank leaves the field
37+
unset, as a blank flat cell does. On an update the assembled value replaces
38+
the stored one. The dry run and the commit judge the same assembled row.
39+
- `@objectstack/lint`: `mapping-target-field-unknown` accepts a declared part
40+
and reports what stays refused, naming the legal parts each time.
41+
42+
**Still refused, at `objectstack validate`, on the dry run and on the commit
43+
(`400 INVALID_FIELD`, before any row):**
44+
45+
- a part the value does not declare (`mailing_address.stret`); the refusal
46+
lists the declared parts;
47+
- a dotted path on a field with no parts (`full_name.first`). A dotted target
48+
never traverses a reference (`account.name`): map the column to the
49+
reference field with transform `lookup`;
50+
- a mapping that writes a field both whole and by part (`mailing_address` and
51+
`mailing_address.street`): one row carries one value for the field. Map it
52+
whole or by its parts, not both.
53+
54+
**What to do.** Nothing, unless you want the capability: point each address
55+
column at `field.part`, for example `{ source: 'Zip', target:
56+
'mailing_address.postalCode' }`.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ Each `fieldMapping` entry:
104104
| Key | Type | What it does |
105105
|:---|:---|:---|
106106
| `source` | `string \| string[]` | Source column header(s). An array is only meaningful for `join`. |
107-
| `target` | `string \| string[]` | Target field name(s). An array is only meaningful for `split`. |
107+
| `target` | `string \| string[]` | Target field name(s), or `field.part` for a declared part of an address field (`mailing_address.street`; the parts are `street`, `city`, `state`, `postalCode`, `country`, `countryCode`, `formatted`). The parts one row maps are assembled into the field's one value: a blank part cell is left out, a row whose parts are all blank leaves the field unset, and on an update the assembled value replaces the stored one. A mapping writes a field whole or by its parts, never both. An array is only meaningful for `split`. |
108108
| `transform` | `TransformType` | Defaults to `'none'`. |
109109
| `params` | object | Configuration for the transform — `value`, `valueMap`, `separator`. |
110110

‎content/docs/references/data/mapping.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ImportFieldMappingSchema.parse(data);
2828
| Property | Type | Required | Description |
2929
| :--- | :--- | :--- | :--- |
3030
| **source** | `string \| string[]` | ✅ | Source column header(s) |
31-
| **target** | `string \| string[]` | ✅ | Target object field(s) |
31+
| **target** | `string \| string[]` | ✅ | Target object field(s); a declared part of a compound field is written field.part (e.g. mailing_address.street), and the parts one row maps are assembled into that field's value |
3232
| **transform** | `Enum<'none' \| 'constant' \| 'lookup' \| 'split' \| 'join' \| 'javascript' \| 'map'>` | optional (default: `"none"`) | |
3333
| **params** | `{ value?: any; valueMap?: Record<string, any>; separator?: string }` | optional | |
3434

@@ -61,7 +61,7 @@ const result = ImportFieldMappingSchema.parse(data);
6161
| Property | Type | Required | Description |
6262
| :--- | :--- | :--- | :--- |
6363
| **source** | `string \| string[]` | ✅ | Source column header(s) |
64-
| **target** | `string \| string[]` | ✅ | Target object field(s) |
64+
| **target** | `string \| string[]` | ✅ | Target object field(s); a declared part of a compound field is written field.part (e.g. mailing_address.street), and the parts one row maps are assembled into that field's value |
6565
| **transform** | `Enum<'none' \| 'constant' \| 'lookup' \| 'split' \| 'join' \| 'javascript' \| 'map'>` | optional (default: `"none"`) | |
6666
| **params** | `{ value?: any; valueMap?: Record<string, any>; separator?: string }` | optional | |
6767

‎packages/lint/src/validate-mapping-target-fields.test.ts‎

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@ const mapping = (fieldMapping: unknown[], extra: Record<string, unknown> = {}) =
2929
});
3030

3131
describe('validateMappingTargetFields', () => {
32-
it('reports a target that names no field of the object, located at the mapping (the card\'s shape)', () => {
32+
it('reports a target that names no field of the object, located at the mapping', () => {
3333
const findings = validateMappingTargetFields({
3434
objects: [contact],
3535
mappings: [mapping([
3636
{ source: 'Name', target: 'full_name' },
37-
{ source: 'Street', target: 'mailing_address.street' },
37+
{ source: 'Mail', target: 'emial' },
3838
])],
3939
});
4040
expect(findings).toHaveLength(1);
@@ -44,10 +44,87 @@ describe('validateMappingTargetFields', () => {
4444
where: 'mapping "contact_import" · object "crm_contact"',
4545
path: 'mappings[0].fieldMapping[1].target',
4646
});
47-
// Names the target and the object, and says why a dotted path is not one.
48-
expect(findings[0].message).toContain('"mailing_address.street"');
47+
// Names the target and the object, and the closest name the object has.
48+
expect(findings[0].message).toContain('"emial"');
4949
expect(findings[0].message).toContain('"crm_contact"');
50-
expect(findings[0].message).toContain('"mailing_address" is a field of "crm_contact"');
50+
expect(findings[0].message).toContain('Did you mean "email"?');
51+
});
52+
53+
// [#20149] The card's shape, which #20150 pinned red here, is now a target:
54+
// a declared part of a compound field. The flip is load-bearing: the whole
55+
// address template is green, every part the value schema declares.
56+
it('is green on a mapping that writes an address by its declared parts (the card\'s five columns and more)', () => {
57+
expect(validateMappingTargetFields({
58+
objects: [contact],
59+
mappings: [mapping([
60+
{ source: 'Name', target: 'full_name' },
61+
{ source: 'Street', target: 'mailing_address.street' },
62+
{ source: 'City', target: 'mailing_address.city' },
63+
{ source: 'State', target: 'mailing_address.state' },
64+
{ source: 'Zip', target: 'mailing_address.postalCode' },
65+
{ source: 'Country', target: 'mailing_address.country' },
66+
{ source: 'x', target: 'mailing_address.countryCode', transform: 'constant', params: { value: 'US' } },
67+
{ source: ['Line 1', 'Line 2'], target: 'mailing_address.formatted', transform: 'join' },
68+
])],
69+
})).toEqual([]);
70+
});
71+
72+
it('refuses a part the address value does not declare, naming every part it does', () => {
73+
const findings = validateMappingTargetFields({
74+
objects: [contact],
75+
mappings: [mapping([{ source: 'Street', target: 'mailing_address.stret' }])],
76+
});
77+
expect(findings.map((f) => f.path)).toEqual(['mappings[0].fieldMapping[0].target']);
78+
expect(findings[0].rule).toBe(MAPPING_TARGET_FIELD_UNKNOWN);
79+
expect(findings[0].message).toContain(
80+
'"stret" is not a part of the address field "mailing_address": the parts a target may name on it are '
81+
+ 'street, city, state, postalCode, country, countryCode, formatted.',
82+
);
83+
expect(findings[0].message).toContain('Did you mean "street"?');
84+
expect(findings[0].hint).toContain(
85+
'or at a declared part of a compound field as field.part (mailing_address: street, city, state, postalCode, country, countryCode, formatted)',
86+
);
87+
});
88+
89+
it('refuses a dotted path on a field with no parts — and never reads it as a lookup traversal', () => {
90+
const findings = validateMappingTargetFields({
91+
objects: [{ ...contact, fields: { ...contact.fields, account: { type: 'lookup', reference: 'crm_account' } } }],
92+
mappings: [mapping([
93+
{ source: 'First', target: 'full_name.first' },
94+
{ source: 'Account', target: 'account.name' },
95+
])],
96+
});
97+
expect(findings.map((f) => f.path)).toEqual([
98+
'mappings[0].fieldMapping[0].target',
99+
'mappings[0].fieldMapping[1].target',
100+
]);
101+
expect(findings[0].message).toContain('The text field "full_name" is a field of "crm_contact" with no parts');
102+
expect(findings[1].message).toContain('The lookup field "account" is a field of "crm_contact" with no parts');
103+
expect(findings[1].message).toContain('Map the column to "account" with transform "lookup"');
104+
// The part list rides along either way, so the author sees what IS a part.
105+
expect(findings[0].hint).toContain('mailing_address: street, city');
106+
});
107+
108+
it('refuses a part of a field the same mapping also writes whole — the two collide', () => {
109+
const findings = validateMappingTargetFields({
110+
objects: [contact],
111+
mappings: [mapping([
112+
{ source: 'Address JSON', target: 'mailing_address' },
113+
{ source: 'Street', target: 'mailing_address.street' },
114+
])],
115+
});
116+
expect(findings).toHaveLength(1);
117+
expect(findings[0]).toMatchObject({
118+
rule: MAPPING_TARGET_FIELD_UNKNOWN,
119+
path: 'mappings[0].fieldMapping[1].target',
120+
// The collision names the field AND lists its legal parts, as every refusal does.
121+
hint: 'Map the field whole or by its parts, not both. A declared part of a compound field is written '
122+
+ 'field.part (mailing_address: street, city, state, postalCode, country, countryCode, formatted).',
123+
});
124+
expect(findings[0].message).toContain(
125+
'writes the compound field "mailing_address" of object "crm_contact" both whole (fieldMapping[0].target) '
126+
+ 'and by its part "street" (fieldMapping[1].target)',
127+
);
51128
});
52129

53130
it('is green on the control mapping — declared fields only', () => {

‎packages/lint/src/validate-mapping-target-fields.ts‎

Lines changed: 88 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@
1515
* written, so the author meets the refusal at `os validate` rather than at the
1616
* first import.
1717
*
18+
* ## A declared part of a compound field (#20149)
19+
*
20+
* A target may also name a declared PART of a compound field
21+
* (`mailing_address.street`); the import endpoint assembles the parts one row
22+
* maps into that field's one value. The shared verdict decides which fields
23+
* are compound and which parts they declare, from the field's value schema.
24+
* This rule reports what stays refused, and names the legal parts each time:
25+
* a part the value does not declare (`mailing_address.stret`), a dotted path
26+
* on a field with no parts (never a lookup traversal), and a part of a field
27+
* the same mapping also writes whole (the two would collide on one value).
28+
*
1829
* ## One verdict, two doors
1930
*
2031
* The verdict is not written here. It is `unknownImportMappingTargets`
@@ -46,11 +57,20 @@
4657
* them; the import door, which reads the registered object, still judges them.
4758
*/
4859

49-
import { indexImportMappingTargets, unknownImportMappingTargets } from '@objectstack/spec/data';
60+
import {
61+
indexImportMappingTargets,
62+
REFERENCE_VALUE_TYPES,
63+
unknownImportMappingTargets,
64+
type ImportMappingTargetHead,
65+
} from '@objectstack/spec/data';
5066

5167
import { listNames, recordsOf, suggestName } from './object-graph.js';
5268

53-
/** A `fieldMapping[].target` that names no field of the mapping's object. */
69+
/**
70+
* A `fieldMapping[].target` the mapping cannot write on its object: it names no
71+
* field and no declared part of a compound field, or it names a part of a field
72+
* the same mapping also writes whole.
73+
*/
5474
export const MAPPING_TARGET_FIELD_UNKNOWN = 'mapping-target-field-unknown';
5575

5676
export interface MappingTargetFieldFinding {
@@ -99,8 +119,10 @@ function extensionFieldsByTarget(stack: AnyRec): Map<string, AnyRec[]> {
99119
}
100120

101121
/**
102-
* Report every import-mapping target that names no field of its object.
103-
* Returns findings (empty = clean).
122+
* Report every import-mapping target its mapping cannot write on its object:
123+
* one that names no field and no declared part of a compound field, and a
124+
* part of a field the same mapping also writes whole. Returns findings
125+
* (empty = clean).
104126
*/
105127
export function validateMappingTargetFields(stack: AnyRec): MappingTargetFieldFinding[] {
106128
const findings: MappingTargetFieldFinding[] = [];
@@ -128,31 +150,83 @@ export function validateMappingTargetFields(stack: AnyRec): MappingTargetFieldFi
128150

129151
const misses = unknownImportMappingTargets(mapping.fieldMapping, objectDef);
130152
if (misses.length === 0) continue; // clean, or skip 2
131-
const known = indexImportMappingTargets(objectDef)?.names ?? new Set<string>();
153+
const index = indexImportMappingTargets(objectDef);
154+
const known = index?.names ?? new Set<string>();
155+
// The object's compound fields and their legal parts, which EVERY finding's
156+
// hint lists, a collision's included (ruling on #20149, item 3).
157+
const compoundParts = index && index.parts.size > 0
158+
? [...index.parts].map(([field, parts]) => `${field}: ${parts.join(', ')}`).join('; ')
159+
: '';
132160
const mappingName = strName(mapping.name) ?? `#${mi}`;
161+
const where = `mapping "${mappingName}" · object "${objectName}"`;
133162

134163
for (const miss of misses) {
135-
const dot = miss.target.indexOf('.');
136-
const head = dot > 0 ? miss.target.slice(0, dot) : undefined;
137-
const dotted = head !== undefined && known.has(head)
138-
? ` "${head}" is a field of "${objectName}", but a target names a whole field: a dotted path into a field's value is not a target.`
139-
: '';
164+
if (miss.reason === 'collides') {
165+
const dot = miss.target.indexOf('.');
166+
findings.push({
167+
severity: 'error',
168+
rule: MAPPING_TARGET_FIELD_UNKNOWN,
169+
where,
170+
path: `mappings[${mi}].${miss.path}`,
171+
message:
172+
`Import mapping "${mappingName}" writes the compound field "${miss.target.slice(0, dot)}" of object ` +
173+
`"${objectName}" both whole (${miss.wholeAt}) and by its part "${miss.target.slice(dot + 1)}" ` +
174+
`(${miss.path}). One row carries one value for a field, so the two collide, and the import endpoint ` +
175+
`refuses this mapping before any row, on the dry run and the commit alike (INVALID_FIELD).`,
176+
hint:
177+
'Map the field whole or by its parts, not both. A declared part of a compound field is ' +
178+
`written field.part (${compoundParts}).`,
179+
});
180+
continue;
181+
}
140182
findings.push({
141183
severity: 'error',
142184
rule: MAPPING_TARGET_FIELD_UNKNOWN,
143-
where: `mapping "${mappingName}" · object "${objectName}"`,
185+
where,
144186
path: `mappings[${mi}].${miss.path}`,
145187
message:
146188
`Import mapping "${mappingName}" writes target "${miss.target}", which names no field of ` +
147189
`object "${objectName}". The import endpoint refuses this mapping before any row, on the dry ` +
148190
`run and the commit alike (INVALID_FIELD), so it can import nothing.` +
149-
dotted +
150-
(dotted ? '' : suggestName(miss.target, known)),
191+
(miss.head?.field ? dottedReason(miss.head, miss.target, objectName) : suggestName(miss.target, known)),
151192
hint:
152193
`Point the target at a field "${objectName}" declares, or at a column the platform provisions ` +
153-
`on it. Addressable names: ${listNames(known)}.`,
194+
`on it` +
195+
(compoundParts ? `, or at a declared part of a compound field as field.part (${compoundParts})` : '') +
196+
`. Addressable names: ${listNames(known)}.`,
154197
});
155198
}
156199
}
157200
return findings;
158201
}
202+
203+
/**
204+
* Why a dotted target whose head names a field of the object is still no
205+
* target (#20149): its part is not one the field's value declares, or the
206+
* field has no parts at all.
207+
*/
208+
function dottedReason(head: ImportMappingTargetHead, target: string, objectName: string): string {
209+
const field = head.type ? `the ${head.type} field "${head.name}"` : `"${head.name}"`;
210+
if (head.parts) {
211+
const part = target.slice(head.name.length + 1);
212+
return (
213+
` "${part}" is not a part of ${field}: the parts a target may name on it are ` +
214+
`${head.parts.join(', ')}.` + suggestName(part, head.parts)
215+
);
216+
}
217+
if (head.type && REFERENCE_VALUE_TYPES.has(head.type)) {
218+
return (
219+
` ${capitalize(field)} is a field of "${objectName}" with no parts: a dotted target never ` +
220+
`traverses a reference. Map the column to "${head.name}" with transform "lookup", which resolves ` +
221+
`the cell's display text to a record id.`
222+
);
223+
}
224+
return (
225+
` ${capitalize(field)} is a field of "${objectName}" with no parts: only a compound field ` +
226+
`(one whose value declares parts, such as an address) takes a part target.`
227+
);
228+
}
229+
230+
function capitalize(text: string): string {
231+
return text.length > 0 ? text[0].toUpperCase() + text.slice(1) : text;
232+
}

‎packages/rest/src/import-coerce.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,12 @@ function coerceError(
178178

179179
// ── blank / null handling ──────────────────────────────────────────
180180

181-
function isBlank(value: unknown, nullValues?: string[]): boolean {
181+
/**
182+
* Whether a cell is blank: absent, empty or whitespace, or one of the
183+
* request's `nullValues`. Exported for the named mapping's compound-part
184+
* assembly (#20149), which drops a blank part by this same rule.
185+
*/
186+
export function isBlank(value: unknown, nullValues?: string[]): boolean {
182187
if (value === null || value === undefined) return true;
183188
if (typeof value === 'string') {
184189
const s = value.trim();

0 commit comments

Comments
 (0)