Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions packages/ui-tests/cypress/e2e/datamapper/json-to-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ describe('Test for DataMapper : JSON to JSON', () => {
"fn:number[@key='Price']",
);

cy.countMappingLines(12);
cy.countMappingLines(13);
});

it('attach parameter schema, engage mappings, detach parameter schema', () => {
Expand Down Expand Up @@ -165,7 +165,7 @@ describe('Test for DataMapper : JSON to JSON', () => {
"$Account-x/fn:map/fn:map[@key='Address']/fn:string[@key='Country']",
);

cy.countMappingLines(7);
cy.countMappingLines(8);

cy.detachParameterSchema('Account');
cy.countMappingLines(0);
Expand Down Expand Up @@ -196,7 +196,7 @@ describe('Test for DataMapper : JSON to JSON', () => {
['document-doc-targetBody-Body', 'node-target-fj-string-OrderPerson'],
"/fn:array/fn:map/fn:string[@key='OrderPerson']",
);
cy.countMappingLines(1);
cy.countMappingLines(2);
});

it('attach parameter schema, engage mappings, delete parameter', () => {
Expand Down Expand Up @@ -242,7 +242,7 @@ describe('Test for DataMapper : JSON to JSON', () => {
"$Account-x/fn:map/fn:map[@key='Address']/fn:string[@key='Country']",
);

cy.countMappingLines(7);
cy.countMappingLines(8);

cy.deleteParameter('Account');
cy.countMappingLines(0);
Expand Down
2 changes: 1 addition & 1 deletion packages/ui-tests/cypress/e2e/datamapper/xml-to-xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ describe('Test for DataMapper : XML to XML', () => {
'Price',
);

cy.countMappingLines(10);
cy.countMappingLines(11);
});

it('attach parameter schema, engage mappings, detach parameter schema', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { ChangeEvent, createRef, FunctionComponent, useCallback } from 'react';

import { useDataMapper } from '../../../hooks/useDataMapper';
import { MappingSerializerService } from '../../../services/mapping/mapping-serializer.service';
import { WrapperAutoDetectionService } from '../../../services/mapping/wrapper-auto-detection.service';
import { readFileAsString } from '../../../stubs/read-file-as-string';

type ImportMappingFileDropdownItemProps = {
Expand All @@ -14,7 +13,14 @@ type ImportMappingFileDropdownItemProps = {
export const ImportMappingFileDropdownItem: FunctionComponent<ImportMappingFileDropdownItemProps> = ({
onComplete,
}) => {
const { mappingTree, sourceParameterMap, targetBodyDocument, refreshMappingTree, sendAlert } = useDataMapper();
const {
mappingTree,
sourceParameterMap,
targetBodyDocument,
refreshMappingTree,
sendAlert,
updateDataMapperSettings,
} = useDataMapper();
const fileInputRef = createRef<HTMLInputElement>();

const onClick = useCallback(() => {
Expand All @@ -27,21 +33,19 @@ export const ImportMappingFileDropdownItem: FunctionComponent<ImportMappingFileD
if (!file) return;
readFileAsString(file)
.then((content) => {
const { messages } = MappingSerializerService.deserialize(
const { messages, dataMapperSettings } = MappingSerializerService.deserialize(
content,
targetBodyDocument,
mappingTree,
sourceParameterMap,
);
WrapperAutoDetectionService.autoDetectWrapperSelections(
mappingTree,
targetBodyDocument,
mappingTree.namespaceMap,
);
for (const msg of messages) {
sendAlert(msg);
}
refreshMappingTree({ structural: true });
// Carry the imported stylesheet's output settings over, otherwise the refresh below
// immediately re-serializes with the session's settings and overwrites them.
updateDataMapperSettings(dataMapperSettings);
refreshMappingTree({ structural: true, dataMapperSettings });
onComplete();
})
.catch((error: unknown) => {
Expand All @@ -52,7 +56,15 @@ export const ImportMappingFileDropdownItem: FunctionComponent<ImportMappingFileD
});
});
},
[mappingTree, onComplete, refreshMappingTree, sendAlert, sourceParameterMap, targetBodyDocument],
[
mappingTree,
onComplete,
refreshMappingTree,
sendAlert,
sourceParameterMap,
targetBodyDocument,
updateDataMapperSettings,
],
);

return (
Expand Down
78 changes: 78 additions & 0 deletions packages/ui/src/models/datamapper/mapping.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { TestUtil } from '../../stubs/datamapper/data-mapper';
import { DocumentDefinitionType, DocumentType, IField } from './document';
import {
ChooseItem,
CopyOfSelector,
CopyOfType,
FieldItem,
ForEachGroupItem,
ForEachItem,
GroupingStrategy,
Expand Down Expand Up @@ -39,6 +43,71 @@ describe('mapping.ts', () => {
});
});

describe('FieldItem.soleSelector', () => {
let field: IField;

beforeEach(() => {
field = TestUtil.createTargetOrderDoc().fields[0].fields[1];
});

it('should return the sole ValueOfSelector', () => {
const fieldItem = new FieldItem(tree, field);
const selector = new ValueOfSelector(fieldItem);
fieldItem.children.push(selector);
expect(fieldItem.soleSelector).toBe(selector);
});

it('should return the sole CopyOfSelector', () => {
const fieldItem = new FieldItem(tree, field);
const selector = new CopyOfSelector(fieldItem, CopyOfType.CONTAINER);
fieldItem.children.push(selector);
expect(fieldItem.soleSelector).toBe(selector);
});

it('should return undefined when there are no children', () => {
expect(new FieldItem(tree, field).soleSelector).toBeUndefined();
});

it('should return undefined when there is more than one child', () => {
const fieldItem = new FieldItem(tree, field);
fieldItem.children.push(new ValueOfSelector(fieldItem), new ValueOfSelector(fieldItem));
expect(fieldItem.soleSelector).toBeUndefined();
});

it('should return undefined when the sole child is a recursive FieldItem, not a selector', () => {
const fieldItem = new FieldItem(tree, field);
fieldItem.children.push(new FieldItem(fieldItem, field));
expect(fieldItem.soleSelector).toBeUndefined();
});
});

describe('ValueSelector.sourcePath', () => {
it('should return the expression unchanged for a ValueOfSelector', () => {
const selector = new ValueOfSelector(tree);
selector.expression = '/ns0:ShipOrder/ns0:OrderPerson';
expect(selector.sourcePath).toBe('/ns0:ShipOrder/ns0:OrderPerson');
});

it('should strip the trailing /node() for a CONTAINER_NODE CopyOfSelector', () => {
const selector = new CopyOfSelector(tree, CopyOfType.CONTAINER_NODE);
selector.expression = '/ns0:ShipOrder/ns0:ShipTo/node()';
expect(selector.sourcePath).toBe('/ns0:ShipOrder/ns0:ShipTo');
});

it('should leave a CONTAINER CopyOfSelector expression untouched', () => {
const selector = new CopyOfSelector(tree, CopyOfType.CONTAINER);
selector.expression = '/ns0:ShipOrder/ns0:ShipTo';
expect(selector.sourcePath).toBe('/ns0:ShipOrder/ns0:ShipTo');
});

it('should follow a valueType reassignment', () => {
const selector = new CopyOfSelector(tree, CopyOfType.CONTAINER_NODE);
selector.expression = '/ns0:ShipOrder/ns0:ShipTo/node()';
selector.valueType = CopyOfType.CONTAINER;
expect(selector.sourcePath).toBe('/ns0:ShipOrder/ns0:ShipTo/node()');
});
});

describe('IfItem', () => {
it('clone() should copy expression and children', () => {
const item = new IfItem(tree);
Expand All @@ -55,6 +124,15 @@ describe('mapping.ts', () => {
expect(cloned).not.toBe(item);
});

it('clone() should copy isAutoOptionalIf flag', () => {
const item = new IfItem(tree);
item.isAutoOptionalIf = true;

const cloned = item.clone();

expect(cloned.isAutoOptionalIf).toBe(true);
});

it('clone() should reparent cloned children to the cloned parent', () => {
const item = new IfItem(tree);
const child = new ValueOfSelector(item);
Expand Down
29 changes: 29 additions & 0 deletions packages/ui/src/models/datamapper/mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ export class FieldItem extends MappingItem {
const name = field.id;
super(parent, name, getCamelRandomId(name, 4));
}
/**
* The single {@link ValueOfSelector}/{@link CopyOfSelector} carrying this field's value, or
* `undefined` when the field has none, has several children, or has non-selector children —
* most notably the recursive child mappings produced for an auto-mapped container.
*/
get soleSelector(): ValueOfSelector | CopyOfSelector | undefined {
if (this.children.length !== 1) return undefined;
const only = this.children[0];
return only instanceof ValueOfSelector || only instanceof CopyOfSelector ? only : undefined;
}
doClone() {
const cloned = new FieldItem(this.parent, this.field);
cloned.isUserCreated = this.isUserCreated;
Expand Down Expand Up @@ -148,12 +158,14 @@ export class IfItem extends InstructionItem implements IExpressionHolder {
super(parent, 'if');
}
expression = '';
isAutoOptionalIf = false;
doClone() {
return new IfItem(this.parent);
}
clone() {
const cloned = super.clone() as IfItem;
cloned.expression = this.expression;
cloned.isAutoOptionalIf = this.isAutoOptionalIf;
return cloned;
}
}
Expand Down Expand Up @@ -366,6 +378,14 @@ export abstract class ValueSelector extends MappingItem implements IExpressionHo
super(parent, label, getCamelRandomId(label, 4));
}
expression = '';
/**
* {@link expression} reduced to the path it addresses, so it can be compared against a path
* built from a source field. Only {@link CopyOfSelector} overrides this — every other selector
* stores the addressed path verbatim.
*/
get sourcePath(): string {
return this.expression;
}
clone() {
const cloned = super.clone() as ValueSelector;
cloned.expression = this.expression;
Expand Down Expand Up @@ -407,6 +427,15 @@ export class CopyOfSelector extends ValueSelector {
) {
super(parent, 'copy');
}
/**
* {@link CopyOfType.CONTAINER_NODE} appends `/node()` so `xsl:copy-of` copies the children
* rather than the wrapper element (see {@link MappingService.mapToFieldWithValueType}); the
* addressed path is the expression without it. A getter rather than a stored value because
* {@link valueType} is reassigned when a container mapping is re-applied.
*/
override get sourcePath(): string {
return this.valueType === CopyOfType.CONTAINER_NODE ? this.expression.replace(/\/node\(\)$/, '') : this.expression;
}
doClone() {
return new CopyOfSelector(this.parent, this.valueType);
}
Expand Down
7 changes: 1 addition & 6 deletions packages/ui/src/providers/datamapper.provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ import { DataMapperSettingsService } from '../services/datamapper-settings.servi
import { DocumentService } from '../services/document/document.service';
import { MappingService } from '../services/mapping/mapping.service';
import { MappingSerializerService } from '../services/mapping/mapping-serializer.service';
import { WrapperAutoDetectionService } from '../services/mapping/wrapper-auto-detection.service';

export interface IDataMapperContext {
isLoading: boolean;
Expand Down Expand Up @@ -148,16 +147,12 @@ const createInitialState = (documentInitializationModel?: DocumentInitialization
tree,
sourceParameterMap,
);
WrapperAutoDetectionService.autoDetectWrapperSelections(mappingTree, targetBodyDocument, namespaceMap);
return {
sourceParameterMap,
sourceBodyDocument,
targetBodyDocument,
mappingTree,
dataMapperSettings: DataMapperSettingsService.sanitizeForTarget(
dataMapperSettings,
targetBodyDocument.definitionType,
),
dataMapperSettings,
alerts: messages,
};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
FieldItem,
ForEachItem,
IDocument,
IfItem,
MappingTree,
NS_XSL,
PrimitiveDocument,
Expand All @@ -21,6 +22,7 @@ import {
} from '../../stubs/datamapper/data-mapper';
import { JsonSchemaDocument, JsonSchemaField } from '../document/json-schema/json-schema-document.model';
import { JsonSchemaDocumentService } from '../document/json-schema/json-schema-document.service';
import { MappingService } from './mapping.service';
import { MappingSerializerService } from './mapping-serializer.service';
import { TO_JSON_TARGET_VARIABLE } from './mapping-serializer-json-addon';

Expand Down Expand Up @@ -388,5 +390,72 @@ describe('MappingSerializerService / JSON', () => {
.iterateNext();
expect(templateValueOfSelect?.nodeValue).toBe(`xml-to-json($${TO_JSON_TARGET_VARIABLE})`);
});

it('should wrap an optional-to-optional JSON mapping in xsl:if and restore isAutoOptionalIf on round trip', () => {
// OrderPerson (target) and Name (source, under Account) are both outside their schema's
// `required` array, i.e. minOccurs=0 on both sides — this is the auto-optional-if guard's
// target case, and the lossless JSON XML representation (fn:map/fn:string) consumed by
// xml-to-json() omits a key exactly when its element is absent, so an xsl:if wrapping the
// fn:string element is the correct way to make the key conditional, same as for XML.
const sourceNameField = (accountParamDoc.fields[0] as JsonSchemaField).fields.find((f) => f.key === 'Name')!;
const targetOrderPersonField = (targetDoc.fields[0] as JsonSchemaField).fields.find(
(f) => f.key === 'OrderPerson',
)!;
expect(sourceNameField.minOccurs).toBe(0);
expect(targetOrderPersonField.minOccurs).toBe(0);

const mappingTree = new MappingTree(
DocumentType.TARGET_BODY,
BODY_DOCUMENT_ID,
DocumentDefinitionType.JSON_SCHEMA,
);
const rootItem = new FieldItem(mappingTree, targetDoc.fields[0]);
mappingTree.children.push(rootItem);
const fieldItem = new FieldItem(rootItem, targetOrderPersonField);
rootItem.children.push(fieldItem);
MappingService.mapToField(sourceNameField, fieldItem);
const ifItem = MappingService.tryAutoOptionalIfWrap(fieldItem, sourceNameField, false)!;
expect(ifItem.isAutoOptionalIf).toBe(true);
expect(ifItem.expression).toBe("$Account-x/fn:map/fn:string[@key='Name']");

const xsltString = MappingSerializerService.serialize(mappingTree, sourceParameterMap);
const xsltDocument = domParser.parseFromString(xsltString, 'text/xml');
const varMappedXml = xsltDocument
.evaluate(
`/xsl:stylesheet/xsl:variable[@name='${TO_JSON_TARGET_VARIABLE}']`,
xsltDocument,
nsResolver,
XPathResult.ANY_TYPE,
)
.iterateNext() as Element;
const rootMap = varMappedXml.getElementsByTagNameNS(NS_XPATH_FUNCTIONS, 'map')[0];

const xslIf = rootMap.getElementsByTagNameNS(NS_XSL, 'if')[0];
expect(xslIf).toBeDefined();
expect(xslIf.getAttribute('test')).toBe("$Account-x/fn:map/fn:string[@key='Name']");
// The fn:string must be a DIRECT child of xsl:if (not of fn:map) — otherwise the key
// would always be emitted regardless of whether the source is present.
expect(Array.from(rootMap.children).some((c) => c.namespaceURI === NS_XPATH_FUNCTIONS)).toBe(false);
const orderPerson = xslIf.getElementsByTagNameNS(NS_XPATH_FUNCTIONS, 'string')[0];
expect(orderPerson.parentNode).toBe(xslIf);
expect(orderPerson.getAttribute('key')).toBe('OrderPerson');
const orderPersonValueOf = orderPerson.getElementsByTagNameNS(NS_XSL, 'value-of')[0];
expect(orderPersonValueOf.getAttribute('select')).toBe("$Account-x/fn:map/fn:string[@key='Name']");

const freshTree = new MappingTree(DocumentType.TARGET_BODY, BODY_DOCUMENT_ID, DocumentDefinitionType.JSON_SCHEMA);
const { mappingTree: deserialized } = MappingSerializerService.deserialize(
xsltString,
targetDoc,
freshTree,
sourceParameterMap,
);

const deserializedRootItem = deserialized.children[0] as FieldItem;
const deserializedIfItem = deserializedRootItem.children.find((c) => c instanceof IfItem) as IfItem;
expect(deserializedIfItem).toBeDefined();
expect(deserializedIfItem.isAutoOptionalIf).toBe(true);
const deserializedFieldItem = deserializedIfItem.children.find((c) => c instanceof FieldItem) as FieldItem;
expect((deserializedFieldItem.field as JsonSchemaField).key).toBe('OrderPerson');
});
});
});
Loading
Loading