Skip to content

Commit 055d4b6

Browse files
committed
feat(spec,rest,lint): an import mapping target may name a declared part of a compound field
A target may name field.part when the field's stored value schema is a closed object of optional strings (address); the import door assembles the parts one row maps into one value before the engine sees the row. An unknown part, a dotted path on a field with no parts, and a field written both whole and by part stay refused at validate, the dry run and the commit, naming the legal parts. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3875ae6 commit 055d4b6

12 files changed

Lines changed: 942 additions & 79 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
@@ -103,7 +103,7 @@ Each `fieldMapping` entry:
103103
| Key | Type | What it does |
104104
|:---|:---|:---|
105105
| `source` | `string \| string[]` | Source column header(s). An array is only meaningful for `join`. |
106-
| `target` | `string \| string[]` | Target field name(s). An array is only meaningful for `split`. |
106+
| `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`. |
107107
| `transform` | `TransformType` | Defaults to `'none'`. |
108108
| `params` | object | Configuration for the transform — `value`, `valueMap`, `separator`. |
109109

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

Lines changed: 80 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,85 @@ 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+
hint: 'Map the field whole or by its parts, not both.',
121+
});
122+
expect(findings[0].message).toContain(
123+
'writes the compound field "mailing_address" of object "crm_contact" both whole (fieldMapping[0].target) '
124+
+ 'and by its part "street" (fieldMapping[1].target)',
125+
);
51126
});
52127

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

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

Lines changed: 85 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,80 @@ 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+
const compound = index ? [...index.parts] : [];
132156
const mappingName = strName(mapping.name) ?? `#${mi}`;
157+
const where = `mapping "${mappingName}" · object "${objectName}"`;
133158

134159
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-
: '';
160+
if (miss.reason === 'collides') {
161+
const dot = miss.target.indexOf('.');
162+
findings.push({
163+
severity: 'error',
164+
rule: MAPPING_TARGET_FIELD_UNKNOWN,
165+
where,
166+
path: `mappings[${mi}].${miss.path}`,
167+
message:
168+
`Import mapping "${mappingName}" writes the compound field "${miss.target.slice(0, dot)}" of object ` +
169+
`"${objectName}" both whole (${miss.wholeAt}) and by its part "${miss.target.slice(dot + 1)}" ` +
170+
`(${miss.path}). One row carries one value for a field, so the two collide, and the import endpoint ` +
171+
`refuses this mapping before any row, on the dry run and the commit alike (INVALID_FIELD).`,
172+
hint: 'Map the field whole or by its parts, not both.',
173+
});
174+
continue;
175+
}
140176
findings.push({
141177
severity: 'error',
142178
rule: MAPPING_TARGET_FIELD_UNKNOWN,
143-
where: `mapping "${mappingName}" · object "${objectName}"`,
179+
where,
144180
path: `mappings[${mi}].${miss.path}`,
145181
message:
146182
`Import mapping "${mappingName}" writes target "${miss.target}", which names no field of ` +
147183
`object "${objectName}". The import endpoint refuses this mapping before any row, on the dry ` +
148184
`run and the commit alike (INVALID_FIELD), so it can import nothing.` +
149-
dotted +
150-
(dotted ? '' : suggestName(miss.target, known)),
185+
(miss.head?.field ? dottedReason(miss.head, miss.target, objectName) : suggestName(miss.target, known)),
151186
hint:
152187
`Point the target at a field "${objectName}" declares, or at a column the platform provisions ` +
153-
`on it. Addressable names: ${listNames(known)}.`,
188+
`on it` +
189+
(compound.length > 0
190+
? `, or at a declared part of a compound field as field.part (` +
191+
compound.map(([field, parts]) => `${field}: ${parts.join(', ')}`).join('; ') + ')'
192+
: '') +
193+
`. Addressable names: ${listNames(known)}.`,
154194
});
155195
}
156196
}
157197
return findings;
158198
}
199+
200+
/**
201+
* Why a dotted target whose head names a field of the object is still no
202+
* target (#20149): its part is not one the field's value declares, or the
203+
* field has no parts at all.
204+
*/
205+
function dottedReason(head: ImportMappingTargetHead, target: string, objectName: string): string {
206+
const field = head.type ? `the ${head.type} field "${head.name}"` : `"${head.name}"`;
207+
if (head.parts) {
208+
const part = target.slice(head.name.length + 1);
209+
return (
210+
` "${part}" is not a part of ${field}: the parts a target may name on it are ` +
211+
`${head.parts.join(', ')}.` + suggestName(part, head.parts)
212+
);
213+
}
214+
if (head.type && REFERENCE_VALUE_TYPES.has(head.type)) {
215+
return (
216+
` ${capitalize(field)} is a field of "${objectName}" with no parts: a dotted target never ` +
217+
`traverses a reference. Map the column to "${head.name}" with transform "lookup", which resolves ` +
218+
`the cell's display text to a record id.`
219+
);
220+
}
221+
return (
222+
` ${capitalize(field)} is a field of "${objectName}" with no parts: only a compound field ` +
223+
`(one whose value declares parts, such as an address) takes a part target.`
224+
);
225+
}
226+
227+
function capitalize(text: string): string {
228+
return text.length > 0 ? text[0].toUpperCase() + text.slice(1) : text;
229+
}

‎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)