@workbench-kit/field-remap 0.0.2-prototype.0.2.6 → 0.0.2-prototype.0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -115
- package/package.json +1 -1
- package/src/domain/abort.ts +29 -0
- package/src/domain/constants.ts +14 -14
- package/src/domain/document/fieldRemapDocument.ts +129 -129
- package/src/domain/document/mappingEdge.ts +136 -136
- package/src/domain/ingest/sourceFieldsFromPlainObject.ts +103 -103
- package/src/domain/ingest/targetSlotsFromPlainObject.ts +41 -41
- package/src/domain/mapping/convertItemEdges.ts +98 -93
- package/src/domain/mapping/dateFormat.ts +102 -102
- package/src/domain/mapping/mappingConflicts.ts +90 -90
- package/src/domain/mapping/objectPathSafety.ts +51 -0
- package/src/domain/mapping/pathUtils.ts +145 -156
- package/src/domain/mapping/resolveMappedValue.ts +98 -96
- package/src/domain/mapping/transformOptions.ts +184 -184
- package/src/domain/mapping/treeUtils.ts +32 -32
- package/src/domain/shapes/conversionDefinition.ts +95 -95
- package/src/domain/shapes/convertToShape.ts +205 -187
- package/src/domain/shapes/dataShape.ts +109 -109
- package/src/domain/types.ts +191 -182
- package/src/index.ts +139 -137
- package/src/registry/builtinTransforms.ts +243 -243
- package/src/registry/createValueTransformRegistry.ts +197 -194
|
@@ -1,93 +1,98 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
readonly
|
|
39
|
-
readonly
|
|
40
|
-
readonly
|
|
41
|
-
readonly
|
|
42
|
-
readonly
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
1
|
+
import { throwIfAborted } from '../abort.js';
|
|
2
|
+
import { edgeTransformIds } from '../document/mappingEdge.js';
|
|
3
|
+
import { applyTransformChain } from '../../registry/createValueTransformRegistry.js';
|
|
4
|
+
import { BUILTIN_TRANSFORM_IDS } from '../../registry/builtinTransforms.js';
|
|
5
|
+
import { resolveOptionSteps } from './transformOptions.js';
|
|
6
|
+
import { isPlainObject, readObjectPath, writeObjectPath } from './pathUtils.js';
|
|
7
|
+
import { findTargetSlot, flattenSourceFields, flattenTargetSlots } from './treeUtils.js';
|
|
8
|
+
import type {
|
|
9
|
+
MappingEdge,
|
|
10
|
+
SourceField,
|
|
11
|
+
TargetSlot,
|
|
12
|
+
TransformContext,
|
|
13
|
+
ValueTransformRegistry,
|
|
14
|
+
} from '../types.js';
|
|
15
|
+
|
|
16
|
+
function findSourceField(fields: readonly SourceField[], fieldId: string): SourceField | undefined {
|
|
17
|
+
return flattenSourceFields(fields).find((field) => field.id === fieldId);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function itemRelativePath(
|
|
21
|
+
node: { readonly path?: string; readonly label: string; readonly id: string } | undefined,
|
|
22
|
+
): string {
|
|
23
|
+
if (!node) {
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
const path = node.path?.trim();
|
|
27
|
+
if (path) {
|
|
28
|
+
return path;
|
|
29
|
+
}
|
|
30
|
+
return node.label.trim() || node.id.split('.').pop() || '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Convert each object in a source array through list-context `itemEdges`.
|
|
35
|
+
* Child field paths are treated as item-relative (ingest array children).
|
|
36
|
+
*/
|
|
37
|
+
export async function convertArrayWithItemEdges(input: {
|
|
38
|
+
readonly items: unknown;
|
|
39
|
+
readonly itemEdges: readonly MappingEdge[];
|
|
40
|
+
readonly sources: readonly SourceField[];
|
|
41
|
+
readonly targets: readonly TargetSlot[];
|
|
42
|
+
readonly transforms: ValueTransformRegistry;
|
|
43
|
+
readonly context?: TransformContext;
|
|
44
|
+
}): Promise<unknown[]> {
|
|
45
|
+
if (!Array.isArray(input.items)) {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const targetLeaves = flattenTargetSlots(input.targets);
|
|
50
|
+
|
|
51
|
+
return Promise.all(
|
|
52
|
+
input.items.map(async (rawItem) => {
|
|
53
|
+
const item = isPlainObject(rawItem) ? rawItem : {};
|
|
54
|
+
let outItem: Record<string, unknown> = {};
|
|
55
|
+
|
|
56
|
+
for (const edge of input.itemEdges) {
|
|
57
|
+
throwIfAborted(input.context?.signal);
|
|
58
|
+
|
|
59
|
+
const sourceField = findSourceField(input.sources, edge.sourceFieldId);
|
|
60
|
+
const targetSlot =
|
|
61
|
+
findTargetSlot(input.targets, edge.targetSlotId) ??
|
|
62
|
+
targetLeaves.find((slot) => slot.id === edge.targetSlotId);
|
|
63
|
+
if (!sourceField || !targetSlot) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const sourcePath = itemRelativePath(sourceField);
|
|
68
|
+
const sourceValue = sourcePath ? readObjectPath(item, sourcePath) : undefined;
|
|
69
|
+
|
|
70
|
+
const chain = edgeTransformIds(edge);
|
|
71
|
+
let value: unknown;
|
|
72
|
+
if (chain.length === 0) {
|
|
73
|
+
const identity = input.transforms.get(BUILTIN_TRANSFORM_IDS.identity);
|
|
74
|
+
value = identity
|
|
75
|
+
? await identity.apply(sourceValue, { ...input.context, sampleValue: sourceValue })
|
|
76
|
+
: sourceValue;
|
|
77
|
+
} else {
|
|
78
|
+
const steps = resolveOptionSteps(chain, edge.transformOptionSteps, edge.transformOptions);
|
|
79
|
+
value = await applyTransformChain(
|
|
80
|
+
input.transforms,
|
|
81
|
+
chain,
|
|
82
|
+
sourceValue,
|
|
83
|
+
{ ...input.context, sampleValue: sourceValue },
|
|
84
|
+
steps,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const targetPath = itemRelativePath(targetSlot);
|
|
89
|
+
if (!targetPath) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
outItem = writeObjectPath(outItem, targetPath, value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return outItem;
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -1,102 +1,102 @@
|
|
|
1
|
-
export type DateParts = { readonly year: string; readonly month: string; readonly day: string };
|
|
2
|
-
|
|
3
|
-
const FORMAT_TOKEN_RE = /YYYY|MM|DD/g;
|
|
4
|
-
|
|
5
|
-
/** Parse a date string with a simple token format (YYYY/MM/DD only). */
|
|
6
|
-
export function parseDateParts(value: string, inputFormat: string): DateParts | undefined {
|
|
7
|
-
const format = inputFormat.trim();
|
|
8
|
-
const raw = value.trim();
|
|
9
|
-
if (!format || !raw) {
|
|
10
|
-
return undefined;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
let year = '';
|
|
14
|
-
let month = '';
|
|
15
|
-
let day = '';
|
|
16
|
-
let cursor = 0;
|
|
17
|
-
|
|
18
|
-
for (let i = 0; i < format.length;) {
|
|
19
|
-
if (format.startsWith('YYYY', i)) {
|
|
20
|
-
year = raw.slice(cursor, cursor + 4);
|
|
21
|
-
if (!/^\d{4}$/.test(year)) {
|
|
22
|
-
return undefined;
|
|
23
|
-
}
|
|
24
|
-
cursor += 4;
|
|
25
|
-
i += 4;
|
|
26
|
-
continue;
|
|
27
|
-
}
|
|
28
|
-
if (format.startsWith('MM', i)) {
|
|
29
|
-
month = raw.slice(cursor, cursor + 2);
|
|
30
|
-
if (!/^\d{2}$/.test(month)) {
|
|
31
|
-
return undefined;
|
|
32
|
-
}
|
|
33
|
-
cursor += 2;
|
|
34
|
-
i += 2;
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
if (format.startsWith('DD', i)) {
|
|
38
|
-
day = raw.slice(cursor, cursor + 2);
|
|
39
|
-
if (!/^\d{2}$/.test(day)) {
|
|
40
|
-
return undefined;
|
|
41
|
-
}
|
|
42
|
-
cursor += 2;
|
|
43
|
-
i += 2;
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
if (raw[cursor] !== format[i]) {
|
|
47
|
-
return undefined;
|
|
48
|
-
}
|
|
49
|
-
cursor += 1;
|
|
50
|
-
i += 1;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if (cursor !== raw.length || !year || !month || !day) {
|
|
54
|
-
return undefined;
|
|
55
|
-
}
|
|
56
|
-
return { year, month, day };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function formatDateParts(parts: DateParts, outputFormat: string): string {
|
|
60
|
-
return outputFormat.replace(FORMAT_TOKEN_RE, (token) => {
|
|
61
|
-
if (token === 'YYYY') {
|
|
62
|
-
return parts.year;
|
|
63
|
-
}
|
|
64
|
-
if (token === 'MM') {
|
|
65
|
-
return parts.month;
|
|
66
|
-
}
|
|
67
|
-
return parts.day;
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function reformatDateString(
|
|
72
|
-
value: string,
|
|
73
|
-
inputFormat: string,
|
|
74
|
-
outputFormat: string,
|
|
75
|
-
): string | undefined {
|
|
76
|
-
const parts = parseDateParts(value, inputFormat);
|
|
77
|
-
if (!parts) {
|
|
78
|
-
return undefined;
|
|
79
|
-
}
|
|
80
|
-
return formatDateParts(parts, outputFormat);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Split `YYYY-MM-DDTHH:mm:ss` / `YYYY-MM-DD HH:mm:ss` / date-only. */
|
|
84
|
-
export function splitDateTimeString(value: string): { date: string; time: string } | undefined {
|
|
85
|
-
const raw = value.trim();
|
|
86
|
-
if (!raw) {
|
|
87
|
-
return undefined;
|
|
88
|
-
}
|
|
89
|
-
const tIndex = raw.indexOf('T');
|
|
90
|
-
const spaceIndex = raw.indexOf(' ');
|
|
91
|
-
const splitAt = tIndex >= 0 ? tIndex : spaceIndex;
|
|
92
|
-
if (splitAt < 0) {
|
|
93
|
-
if (/^\d{4}-\d{2}-\d{2}$/.test(raw) || /^\d{8}$/.test(raw)) {
|
|
94
|
-
return { date: raw, time: '' };
|
|
95
|
-
}
|
|
96
|
-
return undefined;
|
|
97
|
-
}
|
|
98
|
-
return {
|
|
99
|
-
date: raw.slice(0, splitAt),
|
|
100
|
-
time: raw.slice(splitAt + 1),
|
|
101
|
-
};
|
|
102
|
-
}
|
|
1
|
+
export type DateParts = { readonly year: string; readonly month: string; readonly day: string };
|
|
2
|
+
|
|
3
|
+
const FORMAT_TOKEN_RE = /YYYY|MM|DD/g;
|
|
4
|
+
|
|
5
|
+
/** Parse a date string with a simple token format (YYYY/MM/DD only). */
|
|
6
|
+
export function parseDateParts(value: string, inputFormat: string): DateParts | undefined {
|
|
7
|
+
const format = inputFormat.trim();
|
|
8
|
+
const raw = value.trim();
|
|
9
|
+
if (!format || !raw) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let year = '';
|
|
14
|
+
let month = '';
|
|
15
|
+
let day = '';
|
|
16
|
+
let cursor = 0;
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i < format.length;) {
|
|
19
|
+
if (format.startsWith('YYYY', i)) {
|
|
20
|
+
year = raw.slice(cursor, cursor + 4);
|
|
21
|
+
if (!/^\d{4}$/.test(year)) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
cursor += 4;
|
|
25
|
+
i += 4;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (format.startsWith('MM', i)) {
|
|
29
|
+
month = raw.slice(cursor, cursor + 2);
|
|
30
|
+
if (!/^\d{2}$/.test(month)) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
cursor += 2;
|
|
34
|
+
i += 2;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (format.startsWith('DD', i)) {
|
|
38
|
+
day = raw.slice(cursor, cursor + 2);
|
|
39
|
+
if (!/^\d{2}$/.test(day)) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
cursor += 2;
|
|
43
|
+
i += 2;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (raw[cursor] !== format[i]) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
cursor += 1;
|
|
50
|
+
i += 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (cursor !== raw.length || !year || !month || !day) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
return { year, month, day };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function formatDateParts(parts: DateParts, outputFormat: string): string {
|
|
60
|
+
return outputFormat.replace(FORMAT_TOKEN_RE, (token) => {
|
|
61
|
+
if (token === 'YYYY') {
|
|
62
|
+
return parts.year;
|
|
63
|
+
}
|
|
64
|
+
if (token === 'MM') {
|
|
65
|
+
return parts.month;
|
|
66
|
+
}
|
|
67
|
+
return parts.day;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function reformatDateString(
|
|
72
|
+
value: string,
|
|
73
|
+
inputFormat: string,
|
|
74
|
+
outputFormat: string,
|
|
75
|
+
): string | undefined {
|
|
76
|
+
const parts = parseDateParts(value, inputFormat);
|
|
77
|
+
if (!parts) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
return formatDateParts(parts, outputFormat);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Split `YYYY-MM-DDTHH:mm:ss` / `YYYY-MM-DD HH:mm:ss` / date-only. */
|
|
84
|
+
export function splitDateTimeString(value: string): { date: string; time: string } | undefined {
|
|
85
|
+
const raw = value.trim();
|
|
86
|
+
if (!raw) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
const tIndex = raw.indexOf('T');
|
|
90
|
+
const spaceIndex = raw.indexOf(' ');
|
|
91
|
+
const splitAt = tIndex >= 0 ? tIndex : spaceIndex;
|
|
92
|
+
if (splitAt < 0) {
|
|
93
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(raw) || /^\d{8}$/.test(raw)) {
|
|
94
|
+
return { date: raw, time: '' };
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
date: raw.slice(0, splitAt),
|
|
100
|
+
time: raw.slice(splitAt + 1),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -1,90 +1,90 @@
|
|
|
1
|
-
import type { MappingEdge, SourceField, TargetSlot } from '../types.js';
|
|
2
|
-
import { flattenSourceFields, flattenTargetSlots } from './treeUtils.js';
|
|
3
|
-
|
|
4
|
-
export interface MappingConflict {
|
|
5
|
-
readonly kind: 'parent-child-source' | 'parent-child-target';
|
|
6
|
-
readonly parentId: string;
|
|
7
|
-
readonly childId: string;
|
|
8
|
-
readonly parentEdgeId: string;
|
|
9
|
-
readonly childEdgeId: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function isAncestorId(ancestorId: string, descendantId: string): boolean {
|
|
13
|
-
return (
|
|
14
|
-
descendantId.startsWith(`${ancestorId}.`) || descendantId.startsWith(`${ancestorId}.item.`)
|
|
15
|
-
);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Detect edges that map both a parent object/array and one of its descendants.
|
|
20
|
-
* Hosts should warn — writing both usually overwrites or double-defines output.
|
|
21
|
-
*/
|
|
22
|
-
export function findParentChildMappingConflicts(
|
|
23
|
-
edges: readonly MappingEdge[],
|
|
24
|
-
sources: readonly SourceField[],
|
|
25
|
-
targets: readonly TargetSlot[],
|
|
26
|
-
): MappingConflict[] {
|
|
27
|
-
const sourceIds = new Set(flattenSourceFields(sources).map((field) => field.id));
|
|
28
|
-
const targetIds = new Set(flattenTargetSlots(targets).map((slot) => slot.id));
|
|
29
|
-
const conflicts: MappingConflict[] = [];
|
|
30
|
-
|
|
31
|
-
for (let i = 0; i < edges.length; i += 1) {
|
|
32
|
-
const a = edges[i]!;
|
|
33
|
-
for (let j = i + 1; j < edges.length; j += 1) {
|
|
34
|
-
const b = edges[j]!;
|
|
35
|
-
if (
|
|
36
|
-
sourceIds.has(a.sourceFieldId) &&
|
|
37
|
-
sourceIds.has(b.sourceFieldId) &&
|
|
38
|
-
isAncestorId(a.sourceFieldId, b.sourceFieldId)
|
|
39
|
-
) {
|
|
40
|
-
conflicts.push({
|
|
41
|
-
kind: 'parent-child-source',
|
|
42
|
-
parentId: a.sourceFieldId,
|
|
43
|
-
childId: b.sourceFieldId,
|
|
44
|
-
parentEdgeId: a.id,
|
|
45
|
-
childEdgeId: b.id,
|
|
46
|
-
});
|
|
47
|
-
} else if (
|
|
48
|
-
sourceIds.has(a.sourceFieldId) &&
|
|
49
|
-
sourceIds.has(b.sourceFieldId) &&
|
|
50
|
-
isAncestorId(b.sourceFieldId, a.sourceFieldId)
|
|
51
|
-
) {
|
|
52
|
-
conflicts.push({
|
|
53
|
-
kind: 'parent-child-source',
|
|
54
|
-
parentId: b.sourceFieldId,
|
|
55
|
-
childId: a.sourceFieldId,
|
|
56
|
-
parentEdgeId: b.id,
|
|
57
|
-
childEdgeId: a.id,
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
targetIds.has(a.targetSlotId) &&
|
|
63
|
-
targetIds.has(b.targetSlotId) &&
|
|
64
|
-
isAncestorId(a.targetSlotId, b.targetSlotId)
|
|
65
|
-
) {
|
|
66
|
-
conflicts.push({
|
|
67
|
-
kind: 'parent-child-target',
|
|
68
|
-
parentId: a.targetSlotId,
|
|
69
|
-
childId: b.targetSlotId,
|
|
70
|
-
parentEdgeId: a.id,
|
|
71
|
-
childEdgeId: b.id,
|
|
72
|
-
});
|
|
73
|
-
} else if (
|
|
74
|
-
targetIds.has(a.targetSlotId) &&
|
|
75
|
-
targetIds.has(b.targetSlotId) &&
|
|
76
|
-
isAncestorId(b.targetSlotId, a.targetSlotId)
|
|
77
|
-
) {
|
|
78
|
-
conflicts.push({
|
|
79
|
-
kind: 'parent-child-target',
|
|
80
|
-
parentId: b.targetSlotId,
|
|
81
|
-
childId: a.targetSlotId,
|
|
82
|
-
parentEdgeId: b.id,
|
|
83
|
-
childEdgeId: a.id,
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
return conflicts;
|
|
90
|
-
}
|
|
1
|
+
import type { MappingEdge, SourceField, TargetSlot } from '../types.js';
|
|
2
|
+
import { flattenSourceFields, flattenTargetSlots } from './treeUtils.js';
|
|
3
|
+
|
|
4
|
+
export interface MappingConflict {
|
|
5
|
+
readonly kind: 'parent-child-source' | 'parent-child-target';
|
|
6
|
+
readonly parentId: string;
|
|
7
|
+
readonly childId: string;
|
|
8
|
+
readonly parentEdgeId: string;
|
|
9
|
+
readonly childEdgeId: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isAncestorId(ancestorId: string, descendantId: string): boolean {
|
|
13
|
+
return (
|
|
14
|
+
descendantId.startsWith(`${ancestorId}.`) || descendantId.startsWith(`${ancestorId}.item.`)
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Detect edges that map both a parent object/array and one of its descendants.
|
|
20
|
+
* Hosts should warn — writing both usually overwrites or double-defines output.
|
|
21
|
+
*/
|
|
22
|
+
export function findParentChildMappingConflicts(
|
|
23
|
+
edges: readonly MappingEdge[],
|
|
24
|
+
sources: readonly SourceField[],
|
|
25
|
+
targets: readonly TargetSlot[],
|
|
26
|
+
): MappingConflict[] {
|
|
27
|
+
const sourceIds = new Set(flattenSourceFields(sources).map((field) => field.id));
|
|
28
|
+
const targetIds = new Set(flattenTargetSlots(targets).map((slot) => slot.id));
|
|
29
|
+
const conflicts: MappingConflict[] = [];
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < edges.length; i += 1) {
|
|
32
|
+
const a = edges[i]!;
|
|
33
|
+
for (let j = i + 1; j < edges.length; j += 1) {
|
|
34
|
+
const b = edges[j]!;
|
|
35
|
+
if (
|
|
36
|
+
sourceIds.has(a.sourceFieldId) &&
|
|
37
|
+
sourceIds.has(b.sourceFieldId) &&
|
|
38
|
+
isAncestorId(a.sourceFieldId, b.sourceFieldId)
|
|
39
|
+
) {
|
|
40
|
+
conflicts.push({
|
|
41
|
+
kind: 'parent-child-source',
|
|
42
|
+
parentId: a.sourceFieldId,
|
|
43
|
+
childId: b.sourceFieldId,
|
|
44
|
+
parentEdgeId: a.id,
|
|
45
|
+
childEdgeId: b.id,
|
|
46
|
+
});
|
|
47
|
+
} else if (
|
|
48
|
+
sourceIds.has(a.sourceFieldId) &&
|
|
49
|
+
sourceIds.has(b.sourceFieldId) &&
|
|
50
|
+
isAncestorId(b.sourceFieldId, a.sourceFieldId)
|
|
51
|
+
) {
|
|
52
|
+
conflicts.push({
|
|
53
|
+
kind: 'parent-child-source',
|
|
54
|
+
parentId: b.sourceFieldId,
|
|
55
|
+
childId: a.sourceFieldId,
|
|
56
|
+
parentEdgeId: b.id,
|
|
57
|
+
childEdgeId: a.id,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (
|
|
62
|
+
targetIds.has(a.targetSlotId) &&
|
|
63
|
+
targetIds.has(b.targetSlotId) &&
|
|
64
|
+
isAncestorId(a.targetSlotId, b.targetSlotId)
|
|
65
|
+
) {
|
|
66
|
+
conflicts.push({
|
|
67
|
+
kind: 'parent-child-target',
|
|
68
|
+
parentId: a.targetSlotId,
|
|
69
|
+
childId: b.targetSlotId,
|
|
70
|
+
parentEdgeId: a.id,
|
|
71
|
+
childEdgeId: b.id,
|
|
72
|
+
});
|
|
73
|
+
} else if (
|
|
74
|
+
targetIds.has(a.targetSlotId) &&
|
|
75
|
+
targetIds.has(b.targetSlotId) &&
|
|
76
|
+
isAncestorId(b.targetSlotId, a.targetSlotId)
|
|
77
|
+
) {
|
|
78
|
+
conflicts.push({
|
|
79
|
+
kind: 'parent-child-target',
|
|
80
|
+
parentId: b.targetSlotId,
|
|
81
|
+
childId: a.targetSlotId,
|
|
82
|
+
parentEdgeId: b.id,
|
|
83
|
+
childEdgeId: a.id,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return conflicts;
|
|
90
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Object-path safety gate for field-remap read/write and template placeholders.
|
|
3
|
+
* Keeps prototype-mutating segments out of dotted path traversal.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Identifier or dotted path: `city`, `a.b` (no expressions / eval). */
|
|
7
|
+
const SAFE_PATH_RE = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
8
|
+
|
|
9
|
+
const UNSAFE_OBJECT_PATH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
10
|
+
|
|
11
|
+
export class UnsafeObjectPathError extends Error {
|
|
12
|
+
readonly code = 'unsafe_object_path' as const;
|
|
13
|
+
readonly path: string;
|
|
14
|
+
readonly segment: string;
|
|
15
|
+
|
|
16
|
+
constructor(path: string, segment: string) {
|
|
17
|
+
super(`Object path "${path}" contains unsafe segment "${segment}".`);
|
|
18
|
+
this.name = 'UnsafeObjectPathError';
|
|
19
|
+
this.path = path;
|
|
20
|
+
this.segment = segment;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function objectPathParts(path: string): string[] {
|
|
25
|
+
return path
|
|
26
|
+
.split('.')
|
|
27
|
+
.map((part) => part.trim())
|
|
28
|
+
.filter((part) => part.length > 0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function findUnsafeObjectPathSegment(parts: readonly string[]): string | undefined {
|
|
32
|
+
return parts.find((part) => UNSAFE_OBJECT_PATH_SEGMENTS.has(part));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Parse dotted path segments and reject unsafe segments when any parts exist. */
|
|
36
|
+
export function requireObjectPathParts(path: string): string[] {
|
|
37
|
+
const parts = objectPathParts(path);
|
|
38
|
+
const unsafeSegment = findUnsafeObjectPathSegment(parts);
|
|
39
|
+
if (unsafeSegment) {
|
|
40
|
+
throw new UnsafeObjectPathError(path, unsafeSegment);
|
|
41
|
+
}
|
|
42
|
+
return parts;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isSafeObjectPath(path: string): boolean {
|
|
46
|
+
const trimmed = path.trim();
|
|
47
|
+
return (
|
|
48
|
+
SAFE_PATH_RE.test(trimmed) &&
|
|
49
|
+
findUnsafeObjectPathSegment(objectPathParts(trimmed)) === undefined
|
|
50
|
+
);
|
|
51
|
+
}
|