diff --git a/.husky/pre-commit b/.husky/pre-commit index e1c12eb..1c43e09 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -2,4 +2,5 @@ . "$(dirname -- "$0")/_/husky.sh" npm test -npx lint-staged +npm run lint:check +npm run lint-staged \ No newline at end of file diff --git a/package.json b/package.json index 6018b1d..c46ac5c 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "lint:check": "eslint . || exit 1", "format": "prettier --write '**/*.ts' '**/*.js' '**/*.json'", "lint": "npm run format && npm run lint:fix", + "lint-staged": "lint-staged", "prepare": "husky install", "jest:scenarios": "jest e2e.test.ts --verbose", "test:scenario": "jest test/scenario.test.ts --verbose", diff --git a/src/engine.ts b/src/engine.ts index 3f9e2a2..d8cca9e 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -36,10 +36,7 @@ export class JsonTemplateEngine { return translator.translate(); } - private static parseMappingPaths( - mappings: FlatMappingPaths[], - options?: EngineOptions, - ): Expression { + static parseMappingPaths(mappings: FlatMappingPaths[], options?: EngineOptions): Expression { const flatMappingAST = mappings.map((mapping) => ({ ...mapping, inputExpr: JsonTemplateEngine.parse(mapping.input, options).statements[0], diff --git a/src/lexer.ts b/src/lexer.ts index 307ccf0..6f10fae 100644 --- a/src/lexer.ts +++ b/src/lexer.ts @@ -73,6 +73,10 @@ export class JsonTemplateLexer { return this.match('{') && this.match('{', 1); } + matchMappings(): boolean { + return this.match('~m'); + } + matchSimplePath(): boolean { return this.match('~s'); } @@ -636,7 +640,7 @@ export class JsonTemplateLexer { const ch1 = this.codeChars[this.idx]; const ch2 = this.codeChars[this.idx + 1]; - if (ch1 === '~' && 'rsj'.includes(ch2)) { + if (ch1 === '~' && 'rsjm'.includes(ch2)) { this.idx += 2; return { type: TokenType.PUNCT, diff --git a/src/parser.ts b/src/parser.ts index f82d587..ed6668d 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -14,6 +14,7 @@ import { DefinitionExpression, EngineOptions, Expression, + FlatMappingPaths, FunctionCallExpression, FunctionExpression, IncrementExpression, @@ -1334,6 +1335,49 @@ export class JsonTemplateParser { } } + private static isValidMapping(mapping: ObjectPropExpression): boolean { + return ( + typeof mapping.key === 'string' && + mapping.value.type === SyntaxType.LITERAL && + mapping.value.tokenType === TokenType.STR + ); + } + + private static convertMappingsToFlatPaths(mappings: ObjectExpression): FlatMappingPaths { + const flatPaths: Record = {}; + for (const mappingProp of mappings.props) { + if (!JsonTemplateParser.isValidMapping(mappingProp)) { + throw new JsonTemplateParserError( + `Invalid mapping key=${JSON.stringify(mappingProp.key)} or value=${JSON.stringify( + mappingProp.value, + )}, expected string key and string value`, + ); + } + flatPaths[mappingProp.key as string] = mappingProp.value.value; + } + if (!flatPaths.input || !flatPaths.output) { + throw new JsonTemplateParserError( + `Invalid mapping: ${JSON.stringify(flatPaths)}, missing input or output`, + ); + } + return flatPaths as FlatMappingPaths; + } + + private parseMappings(): Expression { + this.lexer.expect('~m'); + const mappings: ArrayExpression = this.parseArrayExpr(); + const flatMappings: FlatMappingPaths[] = []; + for (const mapping of mappings.elements) { + if (mapping.type !== SyntaxType.OBJECT_EXPR) { + throw new JsonTemplateParserError( + `Invalid mapping=${JSON.stringify(mapping)}, expected object`, + ); + } + flatMappings.push(JsonTemplateParser.convertMappingsToFlatPaths(mapping as ObjectExpression)); + } + return JsonTemplateEngine.parseMappingPaths(flatMappings); + } + private parsePrimaryExpr(): Expression { if (this.lexer.match(';')) { return EMPTY_EXPR; @@ -1382,6 +1426,10 @@ export class JsonTemplateParser { return this.parsePathTypeExpr(); } + if (this.lexer.matchMappings()) { + return this.parseMappings(); + } + if (this.lexer.matchPath()) { return this.parsePath(); } diff --git a/src/utils/converter.ts b/src/utils/converter.ts index b6d740a..533c826 100644 --- a/src/utils/converter.ts +++ b/src/utils/converter.ts @@ -57,16 +57,17 @@ function processArrayIndexFilter( } function processAllFilter( - currentInputAST: PathExpression, + flatMapping: FlatMappingAST, currentOutputPropAST: ObjectPropExpression, -): Expression { +): ObjectExpression { + const currentInputAST = flatMapping.inputExpr; const filterIndex = currentInputAST.parts.findIndex( (part) => part.type === SyntaxType.OBJECT_FILTER_EXPR, ); if (filterIndex === -1) { if (currentOutputPropAST.value.type === SyntaxType.OBJECT_EXPR) { - return currentOutputPropAST.value; + return currentOutputPropAST.value as ObjectExpression; } } else { const matchedInputParts = currentInputAST.parts.splice(0, filterIndex + 1); @@ -84,7 +85,15 @@ function processAllFilter( } const blockExpr = getLastElement(currentOutputPropAST.value.parts) as Expression; - return blockExpr?.statements?.[0] || EMPTY_EXPR; + const objectExpr = blockExpr?.statements?.[0] || EMPTY_EXPR; + if ( + objectExpr.type !== SyntaxType.OBJECT_EXPR || + !objectExpr.props || + !Array.isArray(objectExpr.props) + ) { + throw new Error(`Failed to process output mapping: ${flatMapping.output}`); + } + return objectExpr; } function isWildcardSelector(expr: Expression): boolean { @@ -146,10 +155,10 @@ function handleNextPart( flatMapping: FlatMappingAST, partNum: number, currentOutputPropAST: ObjectPropExpression, -): Expression { +): ObjectExpression | undefined { const nextOutputPart = flatMapping.outputExpr.parts[partNum]; if (nextOutputPart.filter?.type === SyntaxType.ALL_FILTER_EXPR) { - return processAllFilter(flatMapping.inputExpr, currentOutputPropAST); + return processAllFilter(flatMapping, currentOutputPropAST); } if (nextOutputPart.filter?.type === SyntaxType.ARRAY_INDEX_FILTER_EXPR) { return processArrayIndexFilter( @@ -164,7 +173,32 @@ function handleNextPart( partNum === flatMapping.outputExpr.parts.length - 1, ); } - return currentOutputPropAST.value; +} + +function handleNextParts( + flatMapping: FlatMappingAST, + partNum: number, + currentOutputPropAST: ObjectPropExpression, +): ObjectExpression { + let objectExpr = currentOutputPropAST.value as ObjectExpression; + let newPartNum = partNum; + while (newPartNum < flatMapping.outputExpr.parts.length) { + const nextObjectExpr = handleNextPart(flatMapping, newPartNum, currentOutputPropAST); + if (!nextObjectExpr) { + break; + } + newPartNum++; + objectExpr = nextObjectExpr; + } + return objectExpr; +} + +function isOutputPartRegularSelector(outputPart: Expression) { + return ( + outputPart.type === SyntaxType.SELECTOR && + outputPart.prop?.value && + outputPart.prop.value !== '*' + ); } function processFlatMappingPart( @@ -173,12 +207,7 @@ function processFlatMappingPart( currentOutputPropsAST: ObjectPropExpression[], ): ObjectPropExpression[] { const outputPart = flatMapping.outputExpr.parts[partNum]; - - if ( - outputPart.type !== SyntaxType.SELECTOR || - !outputPart.prop?.value || - outputPart.prop.value === '*' - ) { + if (!isOutputPartRegularSelector(outputPart)) { return currentOutputPropsAST; } const key = outputPart.prop.value; @@ -193,32 +222,35 @@ function processFlatMappingPart( } const currentOutputPropAST = findOrCreateObjectPropExpression(currentOutputPropsAST, key); - const objectExpr = handleNextPart(flatMapping, partNum + 1, currentOutputPropAST); - if ( - objectExpr.type !== SyntaxType.OBJECT_EXPR || - !objectExpr.props || - !Array.isArray(objectExpr.props) - ) { - throw new Error(`Failed to process output mapping: ${flatMapping.output}`); - } + const objectExpr = handleNextParts(flatMapping, partNum + 1, currentOutputPropAST); return objectExpr.props; } -function processFlatMapping(flatMapping, currentOutputPropsAST) { - if (flatMapping.outputExpr.parts.length === 0) { - currentOutputPropsAST.push({ - type: SyntaxType.OBJECT_PROP_EXPR, - value: { - type: SyntaxType.SPREAD_EXPR, - value: flatMapping.inputExpr, - }, - } as ObjectPropExpression); - return; +function handleRootOnlyOutputMapping(flatMapping: FlatMappingAST, outputAST: ObjectExpression) { + outputAST.props.push({ + type: SyntaxType.OBJECT_PROP_EXPR, + value: { + type: SyntaxType.SPREAD_EXPR, + value: flatMapping.inputExpr, + }, + } as ObjectPropExpression); +} + +function validateMapping(flatMapping: FlatMappingAST) { + if (flatMapping.outputExpr.type !== SyntaxType.PATH) { + throw new Error( + `Invalid object mapping: output=${flatMapping.output} should be a path expression`, + ); } +} + +function processFlatMappingParts(flatMapping: FlatMappingAST, objectExpr: ObjectExpression) { + let currentOutputPropsAST = objectExpr.props; for (let i = 0; i < flatMapping.outputExpr.parts.length; i++) { currentOutputPropsAST = processFlatMappingPart(flatMapping, i, currentOutputPropsAST); } } + /** * Convert Flat to Object Mappings */ @@ -226,9 +258,24 @@ export function convertToObjectMapping( flatMappingASTs: FlatMappingAST[], ): ObjectExpression | PathExpression { const outputAST: ObjectExpression = createObjectExpression(); + let pathAST: PathExpression | undefined; for (const flatMapping of flatMappingASTs) { - processFlatMapping(flatMapping, outputAST.props); + validateMapping(flatMapping); + let objectExpr = outputAST; + if (flatMapping.outputExpr.parts.length > 0) { + if (!isOutputPartRegularSelector(flatMapping.outputExpr.parts[0])) { + const objectPropExpr = { + type: SyntaxType.OBJECT_PROP_EXPR, + key: '', + value: objectExpr as Expression, + }; + objectExpr = handleNextParts(flatMapping, 0, objectPropExpr); + pathAST = objectPropExpr.value as PathExpression; + } + processFlatMappingParts(flatMapping, objectExpr); + } else { + handleRootOnlyOutputMapping(flatMapping, outputAST); + } } - - return outputAST; + return pathAST ?? outputAST; } diff --git a/test/scenario.test.ts b/test/scenario.test.ts index a741757..09f4038 100644 --- a/test/scenario.test.ts +++ b/test/scenario.test.ts @@ -19,14 +19,11 @@ describe(`${scenarioName}:`, () => { const scenarioDir = join(__dirname, 'scenarios', scenarioName); const scenarios = ScenarioUtils.extractScenarios(scenarioDir); const scenario: Scenario = scenarios[index] || scenarios[0]; - it(`Scenario ${index}: ${Scenario.getTemplatePath(scenario)}`, async () => { + const templatePath = Scenario.getTemplatePath(scenario); + it(`Scenario ${index}: ${templatePath}`, async () => { let result; try { - console.log( - `Executing scenario: ${scenarioName}, test: ${index}, template: ${ - scenario.templatePath || 'template.jt' - }`, - ); + console.log(`Executing scenario: ${scenarioName}, test: ${index}, template: ${templatePath}`); result = await ScenarioUtils.evaluateScenario(scenarioDir, scenario); expect(result).toEqual(scenario.output); } catch (error: any) { diff --git a/test/scenarios/mappings/data.ts b/test/scenarios/mappings/data.ts index 5fc6967..4887c6d 100644 --- a/test/scenarios/mappings/data.ts +++ b/test/scenarios/mappings/data.ts @@ -156,6 +156,10 @@ export const data: Scenario[] = [ mappingsPath: 'invalid_object_mappings.json', error: 'Invalid object mapping', }, + { + mappingsPath: 'invalid_output_mapping.json', + error: 'Invalid object mapping', + }, { mappingsPath: 'mappings_with_root_fields.json', input, @@ -233,22 +237,49 @@ export const data: Scenario[] = [ }, { mappingsPath: 'object_mappings.json', - input: { + user_id: 1, traits1: { name: 'John Doe', age: 30, }, - traits2: { - name: { - value: 'John Doe', + traits2: [ + { + name: { + value: 'John Doe', + }, }, - age: { - value: 30, + { + age: { + value: 30, + }, }, - }, + ], }, output: { + user_id: { + value: 1, + }, + traits1: { + value: { + name: 'John Doe', + age: 30, + }, + }, + traits2: { + value: [ + { + name: { + value: 'John Doe', + }, + }, + { + age: { + value: 30, + }, + }, + ], + }, properties1: { name: { value: 'John Doe', @@ -257,11 +288,55 @@ export const data: Scenario[] = [ value: 30, }, }, - properties2: { - name: 'John Doe', - age: 30, + properties2: [ + { + name: 'John Doe', + }, + { + age: 30, + }, + ], + }, + }, + { + mappingsPath: 'root_array_mappings.json', + input: [ + { + user_id: 1, + user_name: 'John Doe', + }, + { + user_id: 2, + user_name: 'Jane Doe', + }, + ], + output: [ + { + user: { + id: 1, + name: 'John Doe', + }, }, + { + user: { + id: 2, + name: 'Jane Doe', + }, + }, + ], + }, + { + mappingsPath: 'root_index_mappings.json', + input: { + id: 1, + name: 'John Doe', }, + output: [ + { + user_id: 1, + user_name: 'John Doe', + }, + ], }, { mappingsPath: 'root_mappings.json', @@ -278,6 +353,77 @@ export const data: Scenario[] = [ timestamp: 1630000000, }, }, + { + mappingsPath: 'root_nested_mappings.json', + input: [ + { + user_id: 1, + user_name: 'John Doe', + }, + { + user_id: 2, + user_name: 'Jane Doe', + }, + ], + output: [ + { + user_id: { + value: 1, + }, + user_name: { + value: 'John Doe', + }, + }, + { + user_id: { + value: 2, + }, + user_name: { + value: 'Jane Doe', + }, + }, + ], + }, + { + mappingsPath: 'root_object_mappings.json', + input: { + user_id: 1, + user_name: 'John Doe', + }, + output: { + user_id: { + value: 1, + }, + user_name: { + value: 'John Doe', + }, + }, + }, + { + input: { + a: [ + { + a: 1, + }, + { + a: 2, + }, + ], + }, + output: 3, + }, + { + template: '~m[1, 2]', + error: 'Invalid mapping', + }, + { + template: '~m[{}]', + error: 'Invalid mapping', + }, + { + template: '~m[{input: 1, output: 2}]', + error: 'Invalid mapping', + }, { mappingsPath: 'transformations.json', input, diff --git a/test/scenarios/mappings/invalid_output_mapping.json b/test/scenarios/mappings/invalid_output_mapping.json new file mode 100644 index 0000000..91499d4 --- /dev/null +++ b/test/scenarios/mappings/invalid_output_mapping.json @@ -0,0 +1,7 @@ +[ + { + "description": "output is not a path expression", + "input": "$.a", + "output": "'a'" + } +] diff --git a/test/scenarios/mappings/object_mappings.json b/test/scenarios/mappings/object_mappings.json index 24cf951..ba13424 100644 --- a/test/scenarios/mappings/object_mappings.json +++ b/test/scenarios/mappings/object_mappings.json @@ -1,10 +1,14 @@ [ + { + "input": "$.*", + "output": "$.*.value" + }, { "input": "$.traits1.*", "output": "$.properties1.*.value" }, { - "input": "$.traits2.*.value", - "output": "$.properties2.*" + "input": "$.traits2[*].*.value", + "output": "$.properties2[*].*" } ] diff --git a/test/scenarios/mappings/root_array_mappings.json b/test/scenarios/mappings/root_array_mappings.json new file mode 100644 index 0000000..f7c6fe2 --- /dev/null +++ b/test/scenarios/mappings/root_array_mappings.json @@ -0,0 +1,10 @@ +[ + { + "input": "$[*].user_id", + "output": "$[*].user.id" + }, + { + "input": "$[*].user_name", + "output": "$[*].user.name" + } +] diff --git a/test/scenarios/mappings/root_index_mappings.json b/test/scenarios/mappings/root_index_mappings.json new file mode 100644 index 0000000..9f7c89d --- /dev/null +++ b/test/scenarios/mappings/root_index_mappings.json @@ -0,0 +1,10 @@ +[ + { + "input": "$.id", + "output": "$[0].user_id" + }, + { + "input": "$.name", + "output": "$[0].user_name" + } +] diff --git a/test/scenarios/mappings/root_nested_mappings.json b/test/scenarios/mappings/root_nested_mappings.json new file mode 100644 index 0000000..f9c0c0e --- /dev/null +++ b/test/scenarios/mappings/root_nested_mappings.json @@ -0,0 +1,6 @@ +[ + { + "input": "$[*].*", + "output": "$[*].*.value" + } +] diff --git a/test/scenarios/mappings/root_object_mappings.json b/test/scenarios/mappings/root_object_mappings.json new file mode 100644 index 0000000..40ddfa3 --- /dev/null +++ b/test/scenarios/mappings/root_object_mappings.json @@ -0,0 +1,6 @@ +[ + { + "input": "$.*", + "output": "$.*.value" + } +] diff --git a/test/scenarios/mappings/template.jt b/test/scenarios/mappings/template.jt new file mode 100644 index 0000000..5bf9315 --- /dev/null +++ b/test/scenarios/mappings/template.jt @@ -0,0 +1,8 @@ +const temp = ~m[ +{ + input: '^.a[*].a', + output: '^.b[*].b', +} +]; + +~r temp.b.b.sum(); diff --git a/test/test_engine.ts b/test/test_engine.ts index a3a68d7..57ff7e5 100644 --- a/test/test_engine.ts +++ b/test/test_engine.ts @@ -242,40 +242,42 @@ const address = { // ), // ); -console.log( - JsonTemplateEngine.evaluateAsSync( - [ - { - description: 'Copies properties of a to root level in the output', - input: '$.a', - output: '$', - }, - { - description: 'Combines first and last name in the output', - input: "$.b[*].(@.firstName + ' ' + @.lastName)", - output: '$.items[*].name', - }, - { - input: "'buzz'", - output: '$.fizz', - }, - ], - { defaultPathType: PathType.JSON }, - { - a: { - foo: 1, - bar: 2, - }, - b: [ - { - firstName: 'foo', - lastName: 'bar', - }, - { - firstName: 'fizz', - lastName: 'buzz', - }, - ], - }, - ), -); +// console.log( +// JsonTemplateEngine.evaluateAsSync( +// [ +// { +// description: 'Copies properties of a to root level in the output', +// input: '$.a', +// output: '$', +// }, +// { +// description: 'Combines first and last name in the output', +// input: "$.b[*].(@.firstName + ' ' + @.lastName)", +// output: '$.items[*].name', +// }, +// { +// input: "'buzz'", +// output: '$.fizz', +// }, +// ], +// { defaultPathType: PathType.JSON }, +// { +// a: { +// foo: 1, +// bar: 2, +// }, +// b: [ +// { +// firstName: 'foo', +// lastName: 'bar', +// }, +// { +// firstName: 'fizz', +// lastName: 'buzz', +// }, +// ], +// }, +// ), +// ); + +console.log(JsonTemplateEngine.reverseTranslate(JsonTemplateEngine.parse('$.traits.*')));