|
15 | 15 | * written, so the author meets the refusal at `os validate` rather than at the |
16 | 16 | * first import. |
17 | 17 | * |
| 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 | + * |
18 | 29 | * ## One verdict, two doors |
19 | 30 | * |
20 | 31 | * The verdict is not written here. It is `unknownImportMappingTargets` |
|
46 | 57 | * them; the import door, which reads the registered object, still judges them. |
47 | 58 | */ |
48 | 59 |
|
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'; |
50 | 66 |
|
51 | 67 | import { listNames, recordsOf, suggestName } from './object-graph.js'; |
52 | 68 |
|
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 | + */ |
54 | 74 | export const MAPPING_TARGET_FIELD_UNKNOWN = 'mapping-target-field-unknown'; |
55 | 75 |
|
56 | 76 | export interface MappingTargetFieldFinding { |
@@ -99,8 +119,10 @@ function extensionFieldsByTarget(stack: AnyRec): Map<string, AnyRec[]> { |
99 | 119 | } |
100 | 120 |
|
101 | 121 | /** |
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). |
104 | 126 | */ |
105 | 127 | export function validateMappingTargetFields(stack: AnyRec): MappingTargetFieldFinding[] { |
106 | 128 | const findings: MappingTargetFieldFinding[] = []; |
@@ -128,31 +150,80 @@ export function validateMappingTargetFields(stack: AnyRec): MappingTargetFieldFi |
128 | 150 |
|
129 | 151 | const misses = unknownImportMappingTargets(mapping.fieldMapping, objectDef); |
130 | 152 | 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] : []; |
132 | 156 | const mappingName = strName(mapping.name) ?? `#${mi}`; |
| 157 | + const where = `mapping "${mappingName}" · object "${objectName}"`; |
133 | 158 |
|
134 | 159 | 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 | + } |
140 | 176 | findings.push({ |
141 | 177 | severity: 'error', |
142 | 178 | rule: MAPPING_TARGET_FIELD_UNKNOWN, |
143 | | - where: `mapping "${mappingName}" · object "${objectName}"`, |
| 179 | + where, |
144 | 180 | path: `mappings[${mi}].${miss.path}`, |
145 | 181 | message: |
146 | 182 | `Import mapping "${mappingName}" writes target "${miss.target}", which names no field of ` + |
147 | 183 | `object "${objectName}". The import endpoint refuses this mapping before any row, on the dry ` + |
148 | 184 | `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)), |
151 | 186 | hint: |
152 | 187 | `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)}.`, |
154 | 194 | }); |
155 | 195 | } |
156 | 196 | } |
157 | 197 | return findings; |
158 | 198 | } |
| 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 | +} |
0 commit comments