diff --git a/.gitignore b/.gitignore index 4187637..77f0619 100644 --- a/.gitignore +++ b/.gitignore @@ -137,4 +137,6 @@ build/ .stryker-tmp Mac -.DS_Store \ No newline at end of file +.DS_Store + +test/test_engine.ts \ No newline at end of file diff --git a/.nvmrc b/.nvmrc index a9d0873..561a1e9 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18.19.0 +18.20.3 diff --git a/readme.md b/readme.md index 6965574..54117bb 100644 --- a/readme.md +++ b/readme.md @@ -216,6 +216,12 @@ If we use this rich path`~r a.b.c` then it automatically handles following varia - `{"a": [{ "b": [{"c": 2}]}]}` Refer this [example](test/scenarios/paths/rich_path.jt) for more details. +#### Json Paths +We support some features of [JSON Path](https://goessner.net/articles/JsonPath/index.html#) syntax using path option (`~j`). +Note: This is an experimental feature and may not support all the features of JSON Paths. + +Refer this [example](test/scenarios/paths/json_path.jt) for more details. + #### Simple selectors ```js @@ -333,6 +339,8 @@ We can override the default path option using tags. ~s a.b.c // Use ~r to treat a.b.c as rich path ~r a.b.c +// Use ~j for using json paths +~j items[?(@.a>1)] ``` **Note:** Rich paths are slower compare to the simple paths. diff --git a/src/constants.ts b/src/constants.ts index 401682b..ba00551 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,6 +1,9 @@ +import { SyntaxType } from './types'; + export const VARS_PREFIX = '___'; export const DATA_PARAM_KEY = '___d'; export const BINDINGS_PARAM_KEY = '___b'; export const BINDINGS_CONTEXT_KEY = '___b.context.'; export const RESULT_KEY = '___r'; export const FUNCTION_RESULT_KEY = '___f'; +export const EMPTY_EXPR = { type: SyntaxType.EMPTY }; diff --git a/src/engine.ts b/src/engine.ts index c83d5e3..138b305 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1,9 +1,11 @@ +/* eslint-disable import/no-cycle */ import { BINDINGS_PARAM_KEY, DATA_PARAM_KEY } from './constants'; import { JsonTemplateLexer } from './lexer'; import { JsonTemplateParser } from './parser'; +import { JsonTemplateReverseTranslator } from './reverse_translator'; import { JsonTemplateTranslator } from './translator'; -import { EngineOptions, Expression } from './types'; -import { CommonUtils } from './utils'; +import { EngineOptions, Expression, FlatMappingPaths, TemplateInput } from './types'; +import { CreateAsyncFunction, convertToObjectMapping, isExpression } from './utils'; export class JsonTemplateEngine { private readonly fn: Function; @@ -12,17 +14,6 @@ export class JsonTemplateEngine { this.fn = fn; } - static create(templateOrExpr: string | Expression, options?: EngineOptions): JsonTemplateEngine { - return new JsonTemplateEngine(this.compileAsAsync(templateOrExpr, options)); - } - - static createAsSync( - templateOrExpr: string | Expression, - options?: EngineOptions, - ): JsonTemplateEngine { - return new JsonTemplateEngine(this.compileAsSync(templateOrExpr, options)); - } - private static compileAsSync( templateOrExpr: string | Expression, options?: EngineOptions, @@ -31,40 +22,68 @@ export class JsonTemplateEngine { return Function(DATA_PARAM_KEY, BINDINGS_PARAM_KEY, this.translate(templateOrExpr, options)); } - private static compileAsAsync( - templateOrExpr: string | Expression, - options?: EngineOptions, - ): Function { - return CommonUtils.CreateAsyncFunction( + private static compileAsAsync(templateOrExpr: TemplateInput, options?: EngineOptions): Function { + return CreateAsyncFunction( DATA_PARAM_KEY, BINDINGS_PARAM_KEY, this.translate(templateOrExpr, options), ); } - static parse(template: string, options?: EngineOptions): Expression { - const lexer = new JsonTemplateLexer(template); - const parser = new JsonTemplateParser(lexer, options); - return parser.parse(); + private static translateExpression(expr: Expression): string { + const translator = new JsonTemplateTranslator(expr); + return translator.translate(); } - static translate(templateOrExpr: string | Expression, options?: EngineOptions): string { - if (typeof templateOrExpr === 'string') { - return this.translateTemplate(templateOrExpr, options); + private static parseMappingPaths( + mappings: FlatMappingPaths[], + options?: EngineOptions, + ): Expression { + const flatMappingAST = mappings.map((mapping) => ({ + ...mapping, + inputExpr: JsonTemplateEngine.parse(mapping.input, options).statements[0], + outputExpr: JsonTemplateEngine.parse(mapping.output, options).statements[0], + })); + return convertToObjectMapping(flatMappingAST); + } + + static create(templateOrExpr: TemplateInput, options?: EngineOptions): JsonTemplateEngine { + return new JsonTemplateEngine(this.compileAsAsync(templateOrExpr, options)); + } + + static createAsSync( + templateOrExpr: string | Expression, + options?: EngineOptions, + ): JsonTemplateEngine { + return new JsonTemplateEngine(this.compileAsSync(templateOrExpr, options)); + } + + static parse(template: TemplateInput, options?: EngineOptions): Expression { + if (isExpression(template)) { + return template as Expression; } - return this.translateExpression(templateOrExpr); + if (typeof template === 'string') { + const lexer = new JsonTemplateLexer(template); + const parser = new JsonTemplateParser(lexer, options); + return parser.parse(); + } + return this.parseMappingPaths(template as FlatMappingPaths[], options); } - private static translateTemplate(template: string, options?: EngineOptions): string { + static translate(template: TemplateInput, options?: EngineOptions): string { return this.translateExpression(this.parse(template, options)); } - private static translateExpression(expr: Expression): string { - const translator = new JsonTemplateTranslator(expr); - return translator.translate(); + static reverseTranslate(expr: Expression, options?: EngineOptions): string { + const translator = new JsonTemplateReverseTranslator(options); + return translator.translate(expr); + } + + static convertMappingsToTemplate(mappings: FlatMappingPaths[], options?: EngineOptions): string { + return this.reverseTranslate(this.parseMappingPaths(mappings, options), options); } - evaluate(data: any, bindings: Record = {}): any { + evaluate(data: unknown, bindings: Record = {}): unknown { return this.fn(data ?? {}, bindings); } } diff --git a/src/lexer.ts b/src/lexer.ts index fd10f84..dcdc7e0 100644 --- a/src/lexer.ts +++ b/src/lexer.ts @@ -1,4 +1,4 @@ -import { BINDINGS_PARAM_KEY, VARS_PREFIX } from './constants'; +import { VARS_PREFIX } from './constants'; import { JsonTemplateLexerError } from './errors'; import { Keyword, Token, TokenType } from './types'; @@ -81,8 +81,12 @@ export class JsonTemplateLexer { return this.match('~r'); } + matchJsonPath(): boolean { + return this.match('~j'); + } + matchPathType(): boolean { - return this.matchRichPath() || this.matchSimplePath(); + return this.matchRichPath() || this.matchJsonPath() || this.matchSimplePath(); } matchPath(): boolean { @@ -113,7 +117,7 @@ export class JsonTemplateLexer { const token = this.lookahead(); if (token.type === TokenType.PUNCT) { const { value } = token; - return value === '.' || value === '..' || value === '^'; + return value === '.' || value === '..' || value === '^' || value === '$' || value === '@'; } return false; @@ -145,10 +149,38 @@ export class JsonTemplateLexer { return token.type === TokenType.KEYWORD && token.value === val; } + matchContains(): boolean { + return this.matchKeywordValue(Keyword.CONTAINS); + } + + matchEmpty(): boolean { + return this.matchKeywordValue(Keyword.EMPTY); + } + + matchSize(): boolean { + return this.matchKeywordValue(Keyword.SIZE); + } + + matchSubsetOf(): boolean { + return this.matchKeywordValue(Keyword.SUBSETOF); + } + + matchAnyOf(): boolean { + return this.matchKeywordValue(Keyword.ANYOF); + } + + matchNoneOf(): boolean { + return this.matchKeywordValue(Keyword.NONEOF); + } + matchIN(): boolean { return this.matchKeywordValue(Keyword.IN); } + matchNotIN(): boolean { + return this.matchKeywordValue(Keyword.NOT_IN); + } + matchFunction(): boolean { return this.matchKeywordValue(Keyword.FUNCTION); } @@ -259,7 +291,12 @@ export class JsonTemplateLexer { }; } - const token = this.scanPunctuator() ?? this.scanID() ?? this.scanString() ?? this.scanInteger(); + const token = + this.scanRegularExpressions() ?? + this.scanPunctuator() ?? + this.scanID() ?? + this.scanString() ?? + this.scanInteger(); if (token) { return token; } @@ -294,7 +331,8 @@ export class JsonTemplateLexer { token.type === TokenType.FLOAT || token.type === TokenType.STR || token.type === TokenType.NULL || - token.type === TokenType.UNDEFINED + token.type === TokenType.UNDEFINED || + token.type === TokenType.REGEXP ); } @@ -317,7 +355,7 @@ export class JsonTemplateLexer { } private static isIdStart(ch: string) { - return ch === '$' || ch === '_' || (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'); + return ch === '_' || (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'); } private static isIdPart(ch: string) { @@ -383,7 +421,7 @@ export class JsonTemplateLexer { JsonTemplateLexer.validateID(id); return { type: TokenType.ID, - value: id.replace(/^\$/, `${BINDINGS_PARAM_KEY}`), + value: id, range: [start, this.idx], }; } @@ -521,7 +559,7 @@ export class JsonTemplateLexer { }; } } else if (ch1 === '=') { - if ('^$*'.indexOf(ch2) >= 0) { + if ('^$*~'.indexOf(ch2) >= 0) { this.idx += 2; return { type: TokenType.PUNCT, @@ -565,7 +603,7 @@ export class JsonTemplateLexer { const start = this.idx; const ch1 = this.codeChars[this.idx]; - if (',;:{}()[]^+-*/%!><|=@~#?\n'.includes(ch1)) { + if (',;:{}()[]^+-*/%!><|=@~$#?\n'.includes(ch1)) { return { type: TokenType.PUNCT, value: ch1, @@ -594,7 +632,7 @@ export class JsonTemplateLexer { const ch1 = this.codeChars[this.idx]; const ch2 = this.codeChars[this.idx + 1]; - if (ch1 === '~' && 'rs'.includes(ch2)) { + if (ch1 === '~' && 'rsj'.includes(ch2)) { this.idx += 2; return { type: TokenType.PUNCT, @@ -618,6 +656,56 @@ export class JsonTemplateLexer { } } + private static isValidRegExp(regexp: string, modifiers: string) { + try { + RegExp(regexp, modifiers); + return true; + } catch (e) { + return false; + } + } + + private getRegExpModifiers(): string { + let modifiers = ''; + while ('gimsuyv'.includes(this.codeChars[this.idx])) { + modifiers += this.codeChars[this.idx]; + this.idx++; + } + return modifiers; + } + + private scanRegularExpressions(): Token | undefined { + const start = this.idx; + const ch1 = this.codeChars[this.idx]; + + if (ch1 === '/') { + let end = this.idx + 1; + while (end < this.codeChars.length) { + if (this.codeChars[end] === '\n') { + return; + } + if (this.codeChars[end] === '/') { + break; + } + end++; + } + + if (end < this.codeChars.length) { + this.idx = end + 1; + const regexp = this.getCode(start + 1, end); + const modifiers = this.getRegExpModifiers(); + if (!JsonTemplateLexer.isValidRegExp(regexp, modifiers)) { + JsonTemplateLexer.throwError("invalid regular expression '%0'", regexp); + } + return { + type: TokenType.REGEXP, + value: this.getCode(start, this.idx), + range: [start, this.idx], + }; + } + } + } + private scanPunctuator(): Token | undefined { return ( this.scanPunctuatorForDots() ?? diff --git a/src/operators.ts b/src/operators.ts index 9d90515..31fde54 100644 --- a/src/operators.ts +++ b/src/operators.ts @@ -23,14 +23,16 @@ function endsWith(val1, val2): string { } function containsStrict(val1, val2): string { - return `(typeof ${val1} === 'string' && ${val1}.includes(${val2}))`; + return `((typeof ${val1} === 'string' || Array.isArray(${val1})) && ${val1}.includes(${val2}))`; } function contains(val1, val2): string { const code: string[] = []; - code.push(`(typeof ${val1} === 'string' && `); - code.push(`typeof ${val2} === 'string' && `); - code.push(`${val1}.toLowerCase().includes(${val2}.toLowerCase()))`); + code.push(`(typeof ${val1} === 'string' && typeof ${val2} === 'string') ?`); + code.push(`(${val1}.toLowerCase().includes(${val2}.toLowerCase()))`); + code.push(':'); + code.push(`(Array.isArray(${val1}) && (${val1}.includes(${val2})`); + code.push(`|| (typeof ${val2} === 'string' && ${val1}.includes(${val2}.toLowerCase()))))`); return code.join(''); } @@ -56,7 +58,14 @@ export const binaryOperators = { '!==': (val1, val2): string => `${val1}!==${val2}`, - '!=': (val1, val2): string => `${val1}!=${val2}`, + '!=': (val1, val2): string => { + const code: string[] = []; + code.push(`(typeof ${val1} == 'string' && typeof ${val2} == 'string') ?`); + code.push(`(${val1}.toLowerCase() != ${val2}.toLowerCase())`); + code.push(':'); + code.push(`(${val1} != ${val2})`); + return code.join(''); + }, '^==': startsWithStrict, @@ -74,9 +83,22 @@ export const binaryOperators = { '=$': (val1, val2): string => endsWith(val2, val1), - '==*': (val1, val2): string => containsStrict(val2, val1), + '=~': (val1, val2): string => + `(${val2} instanceof RegExp) ? (${val2}.test(${val1})) : (${val1}==${val2})`, + + contains, + + '==*': (val1, val2): string => containsStrict(val1, val2), - '=*': (val1, val2): string => contains(val2, val1), + '=*': (val1, val2): string => contains(val1, val2), + + size: (val1, val2): string => `${val1}.length === ${val2}`, + + empty: (val1, val2): string => `(${val1}.length === 0) === ${val2}`, + + subsetof: (val1, val2): string => `${val1}.every((el) => {return ${val2}.includes(el);})`, + + anyof: (val1, val2): string => `${val1}.some((el) => {return ${val2}.includes(el);})`, '+': (val1, val2): string => `${val1}+${val2}`, @@ -94,3 +116,66 @@ export const binaryOperators = { '**': (val1, val2): string => `${val1}**${val2}`, }; + +export const standardFunctions = { + sum: `function sum(arr) { + if(!Array.isArray(arr)) { + throw new Error('Expected an array'); + } + return arr.reduce((a, b) => a + b, 0); + }`, + max: `function max(arr) { + if(!Array.isArray(arr)) { + throw new Error('Expected an array'); + } + return Math.max(...arr); + }`, + min: `function min(arr) { + if(!Array.isArray(arr)) { + throw new Error('Expected an array'); + } + return Math.min(...arr); + }`, + avg: `function avg(arr) { + if(!Array.isArray(arr)) { + throw new Error('Expected an array'); + } + return sum(arr) / arr.length; + }`, + length: `function length(arr) { + if(!Array.isArray(arr) && typeof arr !== 'string') { + throw new Error('Expected an array or string'); + } + return arr.length; + }`, + stddev: `function stddev(arr) { + if(!Array.isArray(arr)) { + throw new Error('Expected an array'); + } + const mu = avg(arr); + const diffSq = arr.map((el) => (el - mu) ** 2); + return Math.sqrt(avg(diffSq)); + }`, + first: `function first(arr) { + if(!Array.isArray(arr)) { + throw new Error('Expected an array'); + } + return arr[0]; + }`, + last: `function last(arr) { + if(!Array.isArray(arr)) { + throw new Error('Expected an array'); + } + return arr[arr.length - 1]; + }`, + index: `function index(arr, i) { + if(!Array.isArray(arr)) { + throw new Error('Expected an array'); + } + if (i < 0) { + return arr[arr.length + i]; + } + return arr[i]; + }`, + keys: 'function keys(obj) { return Object.keys(obj); }', +}; diff --git a/src/parser.ts b/src/parser.ts index dbacd30..e673fc6 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,4 +1,6 @@ -import { BINDINGS_PARAM_KEY, DATA_PARAM_KEY } from './constants'; +/* eslint-disable import/no-cycle */ +import { BINDINGS_PARAM_KEY, DATA_PARAM_KEY, EMPTY_EXPR } from './constants'; +import { JsonTemplateEngine } from './engine'; import { JsonTemplateLexerError, JsonTemplateParserError } from './errors'; import { JsonTemplateLexer } from './lexer'; import { @@ -38,14 +40,25 @@ import { TokenType, UnaryExpression, } from './types'; -import { CommonUtils } from './utils'; +import { + convertToStatementsExpr, + createBlockExpression, + getLastElement, + toArray, +} from './utils/common'; + +type PathTypeResult = { + pathType: PathType; + inferredPathType: PathType; +}; -const EMPTY_EXPR = { type: SyntaxType.EMPTY }; export class JsonTemplateParser { private lexer: JsonTemplateLexer; private options?: EngineOptions; + private pathTypesStack: PathTypeResult[] = []; + // indicates currently how many loops being parsed private loopCount = 0; @@ -137,10 +150,10 @@ export class JsonTemplateParser { if (!path.root || typeof path.root === 'object' || path.root === DATA_PARAM_KEY) { throw new JsonTemplateParserError('Invalid assignment path'); } - if (JsonTemplateParser.isRichPath(expr as PathExpression)) { + if (!JsonTemplateParser.isSimplePath(expr as PathExpression)) { throw new JsonTemplateParserError('Invalid assignment path'); } - path.pathType = PathType.SIMPLE; + path.inferredPathType = PathType.SIMPLE; return { type: SyntaxType.ASSIGNMENT_EXPR, value: this.parseBaseExpr(), @@ -211,10 +224,10 @@ export class JsonTemplateParser { return this.parseSelector(); } else if (this.lexer.matchToArray()) { return this.parsePathOptions(); - } else if (this.lexer.match('[')) { - return this.parseArrayFilterExpr(); } else if (this.lexer.match('{')) { return this.parseObjectFiltersExpr(); + } else if (this.lexer.match('[')) { + return this.parseArrayFilterExpr(); } else if (this.lexer.match('@') || this.lexer.match('#')) { return this.parsePathOptions(); } @@ -222,13 +235,13 @@ export class JsonTemplateParser { private parsePathParts(): Expression[] { let parts: Expression[] = []; - let newParts: Expression[] | undefined; - // eslint-disable-next-line no-cond-assign - while ((newParts = CommonUtils.toArray(this.parsePathPart()))) { + let newParts: Expression[] | undefined = toArray(this.parsePathPart()); + while (newParts) { parts = parts.concat(newParts); if (newParts[0].type === SyntaxType.FUNCTION_CALL_EXPR) { break; } + newParts = toArray(this.parsePathPart()); } return JsonTemplateParser.ignoreEmptySelectors(parts); } @@ -265,50 +278,80 @@ export class JsonTemplateParser { }; } - private parsePathRoot(root?: Expression): Expression | string | undefined { + private parsePathRoot( + pathType: PathTypeResult, + root?: Expression, + ): Expression | string | undefined { if (root) { return root; } - if (this.lexer.match('^')) { - this.lexer.ignoreTokens(1); - return DATA_PARAM_KEY; - } if (this.lexer.matchID()) { return this.lexer.value(); } + const nextToken = this.lexer.lookahead(); + const tokenReturnValues = { + '^': DATA_PARAM_KEY, + $: pathType.inferredPathType === PathType.JSON ? DATA_PARAM_KEY : BINDINGS_PARAM_KEY, + '@': undefined, + }; + if (Object.prototype.hasOwnProperty.call(tokenReturnValues, nextToken.value)) { + this.lexer.ignoreTokens(1); + return tokenReturnValues[nextToken.value]; + } + } + + private getInferredPathType(): PathTypeResult { + if (this.pathTypesStack.length > 0) { + return this.pathTypesStack[this.pathTypesStack.length - 1]; + } + return { + pathType: PathType.UNKNOWN, + inferredPathType: this.options?.defaultPathType ?? PathType.RICH, + }; + } + + private createPathResult(pathType: PathType) { + return { + pathType, + inferredPathType: pathType, + }; } - private parsePathType(): PathType { + private parsePathType(): PathTypeResult { if (this.lexer.matchSimplePath()) { this.lexer.ignoreTokens(1); - return PathType.SIMPLE; + return this.createPathResult(PathType.SIMPLE); } if (this.lexer.matchRichPath()) { this.lexer.ignoreTokens(1); - return PathType.RICH; + return this.createPathResult(PathType.RICH); + } + if (this.lexer.matchJsonPath()) { + this.lexer.ignoreTokens(1); + return this.createPathResult(PathType.JSON); } - return this.options?.defaultPathType ?? PathType.RICH; + + return this.getInferredPathType(); } private parsePathTypeExpr(): Expression { - const pathType = this.parsePathType(); + const pathTypeResult = this.parsePathType(); + this.pathTypesStack.push(pathTypeResult); const expr = this.parseBaseExpr(); - if (expr.type === SyntaxType.PATH) { - expr.pathType = pathType; - } + this.pathTypesStack.pop(); return expr; } private parsePath(options?: { root?: Expression }): PathExpression | Expression { - const pathType = this.parsePathType(); + const pathTypeResult = this.parsePathType(); const expr: PathExpression = { type: SyntaxType.PATH, - root: this.parsePathRoot(options?.root), + root: this.parsePathRoot(pathTypeResult, options?.root), parts: this.parsePathParts(), - pathType, + ...pathTypeResult, }; if (!expr.parts.length) { - expr.pathType = PathType.SIMPLE; + expr.inferredPathType = PathType.SIMPLE; return expr; } return JsonTemplateParser.updatePathExpr(expr); @@ -342,8 +385,16 @@ export class JsonTemplateParser { } let prop: Token | undefined; - if (this.lexer.match('*') || this.lexer.matchID() || this.lexer.matchTokenType(TokenType.STR)) { + if ( + this.lexer.match('*') || + this.lexer.matchID() || + this.lexer.matchKeyword() || + this.lexer.matchTokenType(TokenType.STR) + ) { prop = this.lexer.lex(); + if (prop.type === TokenType.KEYWORD) { + prop.type = TokenType.ID; + } } return { type: SyntaxType.SELECTOR, @@ -413,7 +464,7 @@ export class JsonTemplateParser { private parseObjectFilter(): IndexFilterExpression | ObjectFilterExpression { let exclude = false; - if (this.lexer.match('~')) { + if (this.lexer.match('~') || this.lexer.match('!')) { this.lexer.ignoreTokens(1); exclude = true; } @@ -548,17 +599,42 @@ export class JsonTemplateParser { }; } - private parseArrayFilterExpr(): ArrayFilterExpression { - this.lexer.expect('['); - const filter = this.parseArrayFilter(); - this.lexer.expect(']'); - + private parseJSONObjectFilter(): ObjectFilterExpression { + this.lexer.expect('?'); + const filter = this.parseBaseExpr(); return { - type: SyntaxType.ARRAY_FILTER_EXPR, + type: SyntaxType.OBJECT_FILTER_EXPR, filter, }; } + private parseAllFilter(): ObjectFilterExpression { + this.lexer.expect('*'); + return { + type: SyntaxType.OBJECT_FILTER_EXPR, + filter: { + type: SyntaxType.ALL_FILTER_EXPR, + }, + }; + } + + private parseArrayFilterExpr(): ArrayFilterExpression | ObjectFilterExpression { + this.lexer.expect('['); + let expr: ArrayFilterExpression | ObjectFilterExpression; + if (this.lexer.match('?')) { + expr = this.parseJSONObjectFilter(); + } else if (this.lexer.match('*')) { + expr = this.parseAllFilter(); + } else { + expr = { + type: SyntaxType.ARRAY_FILTER_EXPR, + filter: this.parseArrayFilter(), + }; + } + this.lexer.expect(']'); + return expr; + } + private combineExpressionsAsBinaryExpr( values: Expression[], type: SyntaxType, @@ -646,6 +722,7 @@ export class JsonTemplateParser { this.lexer.match('$=') || this.lexer.match('=$') || this.lexer.match('==*') || + this.lexer.match('=~') || this.lexer.match('=*') ) { return { @@ -654,10 +731,18 @@ export class JsonTemplateParser { args: [expr, this.parseEqualityExpr()], }; } - return expr; } + private parseInExpr(expr: Expression): BinaryExpression { + this.lexer.ignoreTokens(1); + return { + type: SyntaxType.IN_EXPR, + op: Keyword.IN, + args: [expr, this.parseRelationalExpr()], + }; + } + private parseRelationalExpr(): BinaryExpression | Expression { const expr = this.parseNextExpr(OperatorType.RELATIONAL); @@ -666,15 +751,44 @@ export class JsonTemplateParser { this.lexer.match('>') || this.lexer.match('<=') || this.lexer.match('>=') || - this.lexer.matchIN() + this.lexer.matchContains() || + this.lexer.matchSize() || + this.lexer.matchEmpty() || + this.lexer.matchAnyOf() || + this.lexer.matchSubsetOf() ) { return { - type: this.lexer.matchIN() ? SyntaxType.IN_EXPR : SyntaxType.COMPARISON_EXPR, + type: SyntaxType.COMPARISON_EXPR, op: this.lexer.value(), args: [expr, this.parseRelationalExpr()], }; } + if (this.lexer.matchIN()) { + return this.parseInExpr(expr); + } + + if (this.lexer.matchNotIN()) { + return { + type: SyntaxType.UNARY_EXPR, + op: '!', + arg: createBlockExpression(this.parseInExpr(expr)), + }; + } + + if (this.lexer.matchNoneOf()) { + this.lexer.ignoreTokens(1); + return { + type: SyntaxType.UNARY_EXPR, + op: '!', + arg: createBlockExpression({ + type: SyntaxType.COMPARISON_EXPR, + op: Keyword.ANYOF, + args: [expr, this.parseRelationalExpr()], + }), + }; + } + return expr; } @@ -976,9 +1090,9 @@ export class JsonTemplateParser { this.lexer.ignoreTokens(1); key = this.parseBaseExpr(); this.lexer.expect(']'); - } else if (this.lexer.matchID()) { + } else if (this.lexer.matchID() || this.lexer.matchKeyword()) { key = this.lexer.value(); - } else if (this.lexer.matchTokenType(TokenType.STR)) { + } else if (this.lexer.matchLiteral() && !this.lexer.matchTokenType(TokenType.REGEXP)) { key = this.parseLiteralExpr(); } else { this.lexer.throwUnexpectedToken(); @@ -987,7 +1101,10 @@ export class JsonTemplateParser { } private parseShortKeyValueObjectPropExpr(): ObjectPropExpression | undefined { - if (this.lexer.matchID() && (this.lexer.match(',', 1) || this.lexer.match('}', 1))) { + if ( + (this.lexer.matchID() || this.lexer.matchKeyword()) && + (this.lexer.match(',', 1) || this.lexer.match('}', 1)) + ) { const key = this.lexer.lookahead().value; const value = this.parseBaseExpr(); return { @@ -1097,9 +1214,10 @@ export class JsonTemplateParser { const expr = this.parseBaseExpr(); return { type: SyntaxType.FUNCTION_EXPR, - body: CommonUtils.convertToStatementsExpr(expr), + body: convertToStatementsExpr(expr), params: ['...args'], async: asyncFn, + lambda: true, }; } @@ -1119,14 +1237,12 @@ export class JsonTemplateParser { const expr = skipJsonify ? this.parseCompileTimeBaseExpr() : this.parseBaseExpr(); this.lexer.expect('}'); this.lexer.expect('}'); - // eslint-disable-next-line global-require - const { JsonTemplateEngine } = require('./engine'); const exprVal = JsonTemplateEngine.createAsSync(expr).evaluate( {}, this.options?.compileTimeBindings, ); const template = skipJsonify ? exprVal : JSON.stringify(exprVal); - return JsonTemplateParser.parseBaseExprFromTemplate(template); + return JsonTemplateParser.parseBaseExprFromTemplate(template as string); } private parseNumber(): LiteralExpression { @@ -1272,14 +1388,10 @@ export class JsonTemplateParser { return { type: SyntaxType.FUNCTION_EXPR, block: true, - body: CommonUtils.convertToStatementsExpr(expr), + body: convertToStatementsExpr(expr), }; } - private static prependFunctionID(prefix: string, id?: string): string { - return id ? `${prefix}.${id}` : prefix; - } - private static ignoreEmptySelectors(parts: Expression[]): Expression[] { return parts.filter( (part) => !(part.type === SyntaxType.SELECTOR && part.selector === '.' && !part.prop), @@ -1306,7 +1418,7 @@ export class JsonTemplateParser { fnExpr: FunctionCallExpression, pathExpr: PathExpression, ): FunctionCallExpression | PathExpression { - const lastPart = CommonUtils.getLastElement(pathExpr.parts); + const lastPart = getLastElement(pathExpr.parts); // Updated const newFnExpr = fnExpr; if (lastPart?.type === SyntaxType.SELECTOR) { @@ -1314,13 +1426,11 @@ export class JsonTemplateParser { if (selectorExpr.selector === '.' && selectorExpr.prop?.type === TokenType.ID) { pathExpr.parts.pop(); newFnExpr.id = selectorExpr.prop.value; - newFnExpr.dot = true; } } if (!pathExpr.parts.length && pathExpr.root && typeof pathExpr.root !== 'object') { - newFnExpr.id = this.prependFunctionID(pathExpr.root, fnExpr.id); - newFnExpr.dot = false; + newFnExpr.parent = pathExpr.root; } else { newFnExpr.object = pathExpr; } @@ -1365,27 +1475,28 @@ export class JsonTemplateParser { } const shouldConvertAsBlock = JsonTemplateParser.pathContainsVariables(newPathExpr.parts); - let lastPart = CommonUtils.getLastElement(newPathExpr.parts); + let lastPart = getLastElement(newPathExpr.parts); let fnExpr: FunctionCallExpression | undefined; if (lastPart?.type === SyntaxType.FUNCTION_CALL_EXPR) { fnExpr = newPathExpr.parts.pop() as FunctionCallExpression; } - lastPart = CommonUtils.getLastElement(newPathExpr.parts); + lastPart = getLastElement(newPathExpr.parts); if (lastPart?.type === SyntaxType.PATH_OPTIONS) { newPathExpr.parts.pop(); newPathExpr.returnAsArray = lastPart.options?.toArray; } newPathExpr.parts = JsonTemplateParser.combinePathOptionParts(newPathExpr.parts); + let expr: Expression = newPathExpr; if (fnExpr) { expr = JsonTemplateParser.convertToFunctionCallExpr(fnExpr, newPathExpr); } if (shouldConvertAsBlock) { expr = JsonTemplateParser.convertToBlockExpr(expr); - newPathExpr.pathType = PathType.RICH; + newPathExpr.inferredPathType = PathType.RICH; } else if (this.isRichPath(newPathExpr)) { - newPathExpr.pathType = PathType.RICH; + newPathExpr.inferredPathType = PathType.RICH; } return expr; } diff --git a/src/reverse_translator.ts b/src/reverse_translator.ts new file mode 100644 index 0000000..330b789 --- /dev/null +++ b/src/reverse_translator.ts @@ -0,0 +1,475 @@ +import { + ArrayExpression, + ArrayFilterExpression, + AssignmentExpression, + BinaryExpression, + BlockExpression, + ConditionalExpression, + DefinitionExpression, + EngineOptions, + Expression, + FunctionCallExpression, + FunctionExpression, + IncrementExpression, + IndexFilterExpression, + LambdaArgExpression, + LiteralExpression, + LoopControlExpression, + LoopExpression, + ObjectExpression, + ObjectFilterExpression, + ObjectPropExpression, + PathExpression, + PathOptions, + PathType, + RangeFilterExpression, + ReturnExpression, + SelectorExpression, + SpreadExpression, + StatementsExpression, + SyntaxType, + ThrowExpression, + TokenType, + UnaryExpression, +} from './types'; +import { translateLiteral } from './utils/translator'; +import { BINDINGS_PARAM_KEY, DATA_PARAM_KEY, EMPTY_EXPR } from './constants'; +import { escapeStr } from './utils'; + +export class JsonTemplateReverseTranslator { + private options?: EngineOptions; + + constructor(options?: EngineOptions) { + this.options = options; + } + + translate(expr: Expression): string { + let code: string = this.translateExpression(expr); + code = code.replace(/\.\s+\./g, '.'); + return code; + } + + translateExpression(expr: Expression): string { + switch (expr.type) { + case SyntaxType.LITERAL: + return this.translateLiteralExpression(expr as LiteralExpression); + case SyntaxType.STATEMENTS_EXPR: + return this.translateStatementsExpression(expr as StatementsExpression); + case SyntaxType.MATH_EXPR: + case SyntaxType.COMPARISON_EXPR: + case SyntaxType.IN_EXPR: + case SyntaxType.LOGICAL_AND_EXPR: + case SyntaxType.LOGICAL_OR_EXPR: + case SyntaxType.LOGICAL_COALESCE_EXPR: + return this.translateBinaryExpression(expr as BinaryExpression); + case SyntaxType.ARRAY_EXPR: + return this.translateArrayExpression(expr as ArrayExpression); + case SyntaxType.OBJECT_EXPR: + return this.translateObjectExpression(expr as ObjectExpression); + case SyntaxType.SPREAD_EXPR: + return this.translateSpreadExpression(expr as SpreadExpression); + case SyntaxType.BLOCK_EXPR: + return this.translateBlockExpression(expr as BlockExpression); + case SyntaxType.UNARY_EXPR: + return this.translateUnaryExpression(expr as UnaryExpression); + case SyntaxType.INCREMENT: + return this.translateIncrementExpression(expr as IncrementExpression); + case SyntaxType.PATH: + return this.translatePathExpression(expr as PathExpression); + case SyntaxType.CONDITIONAL_EXPR: + return this.translateConditionalExpression(expr as ConditionalExpression); + case SyntaxType.DEFINITION_EXPR: + return this.translateDefinitionExpression(expr as DefinitionExpression); + case SyntaxType.ASSIGNMENT_EXPR: + return this.translateAssignmentExpression(expr as AssignmentExpression); + case SyntaxType.FUNCTION_CALL_EXPR: + return this.translateFunctionCallExpression(expr as FunctionCallExpression); + case SyntaxType.FUNCTION_EXPR: + return this.translateFunctionExpression(expr as FunctionExpression); + case SyntaxType.THROW_EXPR: + return this.translateThrowExpression(expr as ThrowExpression); + case SyntaxType.RETURN_EXPR: + return this.translateReturnExpression(expr as ReturnExpression); + case SyntaxType.LOOP_EXPR: + return this.translateLoopExpression(expr as LoopExpression); + case SyntaxType.LOOP_CONTROL_EXPR: + return this.translateLoopControlExpression(expr as LoopControlExpression); + case SyntaxType.LAMBDA_ARG: + return this.translateLambdaArgExpression(expr as LambdaArgExpression); + case SyntaxType.OBJECT_FILTER_EXPR: + return this.translateObjectFilterExpression(expr as ObjectFilterExpression); + case SyntaxType.SELECTOR: + return this.translateSelectorExpression(expr as SelectorExpression); + case SyntaxType.OBJECT_PROP_EXPR: + return this.translateObjectPropExpression(expr as ObjectPropExpression); + case SyntaxType.OBJECT_INDEX_FILTER_EXPR: + return this.translateObjectIndexFilterExpression(expr as IndexFilterExpression); + case SyntaxType.ARRAY_FILTER_EXPR: + return this.translateArrayFilterExpression(expr as ArrayFilterExpression); + case SyntaxType.ARRAY_INDEX_FILTER_EXPR: + return this.translateArrayIndexFilterExpression(expr as IndexFilterExpression); + case SyntaxType.RANGE_FILTER_EXPR: + return this.translateRangeFilterExpression(expr as RangeFilterExpression); + default: + return ''; + } + } + + translateArrayFilterExpression(expr: ArrayFilterExpression): string { + return this.translateExpression(expr.filter); + } + + translateRangeFilterExpression(expr: RangeFilterExpression): string { + const code: string[] = []; + code.push('['); + if (expr.fromIdx) { + code.push(this.translateExpression(expr.fromIdx)); + } + code.push(':'); + if (expr.toIdx) { + code.push(this.translateExpression(expr.toIdx)); + } + code.push(']'); + return code.join(''); + } + + translateArrayIndexFilterExpression(expr: IndexFilterExpression): string { + return this.translateExpression(expr.indexes); + } + + translateObjectIndexFilterExpression(expr: IndexFilterExpression): string { + const code: string[] = []; + code.push('{'); + if (expr.exclude) { + code.push('!'); + } + code.push(this.translateExpression(expr.indexes)); + code.push('}'); + return code.join(''); + } + + translateSelectorExpression(expr: SelectorExpression): string { + const code: string[] = []; + code.push(expr.selector); + if (expr.prop) { + if (expr.prop.type === TokenType.STR) { + code.push(escapeStr(expr.prop.value)); + } else { + code.push(expr.prop.value); + } + } + return code.join(''); + } + + translateWithWrapper(expr: Expression, prefix: string, suffix: string): string { + return `${prefix}${this.translateExpression(expr)}${suffix}`; + } + + translateObjectFilterExpression(expr: ObjectFilterExpression): string { + if (expr.filter.type === SyntaxType.ALL_FILTER_EXPR) { + return '[*]'; + } + if (this.options?.defaultPathType === PathType.JSON) { + return this.translateWithWrapper(expr.filter, '[?(', ')]'); + } + return this.translateWithWrapper(expr.filter, '{', '}'); + } + + translateLambdaArgExpression(expr: LambdaArgExpression): string { + return `?${expr.index}`; + } + + translateLoopControlExpression(expr: LoopControlExpression): string { + return expr.control; + } + + translateLoopExpression(expr: LoopExpression): string { + const code: string[] = []; + code.push('for'); + code.push('('); + if (expr.init) { + code.push(this.translateExpression(expr.init)); + } + code.push(';'); + if (expr.test) { + code.push(this.translateExpression(expr.test)); + } + code.push(';'); + if (expr.update) { + code.push(this.translateExpression(expr.update)); + } + code.push(')'); + code.push('{'); + code.push(this.translateExpression(expr.body)); + code.push('}'); + return code.join(' '); + } + + translateReturnExpression(expr: ReturnExpression): string { + return `return ${this.translateExpression(expr.value || EMPTY_EXPR)};`; + } + + translateThrowExpression(expr: ThrowExpression): string { + return `throw ${this.translateExpression(expr.value)}`; + } + + translateExpressions(exprs: Expression[], sep: string): string { + return exprs.map((expr) => this.translateExpression(expr)).join(sep); + } + + translateLambdaFunctionExpression(expr: FunctionExpression): string { + return `lambda ${this.translateExpression(expr.body)}`; + } + + translateRegularFunctionExpression(expr: FunctionExpression): string { + const code: string[] = []; + code.push('function'); + code.push('('); + if (expr.params && expr.params.length > 0) { + code.push(expr.params.join(', ')); + } + code.push(')'); + code.push('{'); + code.push(this.translateExpression(expr.body)); + code.push('}'); + return code.join(' '); + } + + translateFunctionExpression(expr: FunctionExpression): string { + if (expr.block) { + return this.translateExpression(expr.body.statements[0]); + } + const code: string[] = []; + if (expr.async) { + code.push('async'); + } + if (expr.lambda) { + code.push(this.translateLambdaFunctionExpression(expr)); + } else { + code.push(this.translateRegularFunctionExpression(expr)); + } + return code.join(' '); + } + + translateFunctionCallExpression(expr: FunctionCallExpression): string { + const code: string[] = []; + if (expr.object) { + code.push(this.translateExpression(expr.object)); + if (expr.id) { + code.push(` .${expr.id}`); + } + } else if (expr.parent) { + code.push(this.translatePathRootString(expr.parent, PathType.SIMPLE)); + if (expr.id) { + code.push(` .${expr.id}`); + } + } else if (expr.id) { + code.push(expr.id); + } + code.push('('); + if (expr.args) { + code.push(this.translateExpressions(expr.args, ', ')); + } + code.push(')'); + return code.join(''); + } + + translateAssignmentExpression(expr: AssignmentExpression): string { + const code: string[] = []; + code.push(this.translatePathExpression(expr.path)); + code.push(expr.op); + code.push(this.translateExpression(expr.value)); + return code.join(' '); + } + + translateDefinitionExpression(expr: DefinitionExpression): string { + const code: string[] = []; + code.push(expr.definition); + if (expr.fromObject) { + code.push('{ '); + } + code.push(expr.vars.join(', ')); + if (expr.fromObject) { + code.push(' }'); + } + code.push(' = '); + code.push(this.translateExpression(expr.value)); + return code.join(' '); + } + + translateConditionalExpressionBody(expr: Expression): string { + if (expr.type === SyntaxType.STATEMENTS_EXPR) { + return this.translateWithWrapper(expr, '{', '}'); + } + return this.translateExpression(expr); + } + + translateConditionalExpression(expr: ConditionalExpression): string { + const code: string[] = []; + code.push(this.translateExpression(expr.if)); + code.push(' ? '); + code.push(this.translateConditionalExpressionBody(expr.then)); + if (expr.else) { + code.push(' : '); + code.push(this.translateConditionalExpressionBody(expr.else)); + } + return code.join(''); + } + + translatePathType(pathType: PathType): string { + switch (pathType) { + case PathType.JSON: + return '~j '; + case PathType.RICH: + return '~r '; + case PathType.SIMPLE: + return '~s '; + default: + return ''; + } + } + + translatePathRootString(root: string, pathType: PathType): string { + if (root === BINDINGS_PARAM_KEY) { + return '$'; + } + if (root === DATA_PARAM_KEY) { + return pathType === PathType.JSON ? '$' : '^'; + } + return root; + } + + translatePathRoot(expr: PathExpression, pathType: PathType): string { + if (typeof expr.root === 'string') { + return this.translatePathRootString(expr.root, pathType); + } + if (expr.root) { + const code: string[] = []; + code.push(this.translateExpression(expr.root)); + if (expr.root.type === SyntaxType.PATH) { + code.push('.(). '); + } + return code.join(''); + } + return '. '; + } + + translatePathOptions(options?: PathOptions): string { + if (!options) { + return ''; + } + const code: string[] = []; + if (options.item) { + code.push('@'); + code.push(options.item); + } + if (options.index) { + code.push('#'); + code.push(options.index); + } + if (options.toArray) { + code.push('[]'); + } + return code.join(''); + } + + translatePathParts(parts: Expression[]): string { + const code: string[] = []; + if ( + parts.length > 0 && + parts[0].type !== SyntaxType.SELECTOR && + parts[0].type !== SyntaxType.BLOCK_EXPR + ) { + code.push('.'); + } + for (const part of parts) { + if (part.type === SyntaxType.BLOCK_EXPR) { + code.push('.'); + } + code.push(this.translateExpression(part)); + code.push(this.translatePathOptions(part.options)); + } + return code.join(''); + } + + translatePathExpression(expr: PathExpression): string { + const code: string[] = []; + code.push(this.translatePathType(expr.pathType)); + code.push(this.translatePathRoot(expr, expr.inferredPathType)); + code.push(this.translatePathOptions(expr.options)); + code.push(this.translatePathParts(expr.parts)); + if (expr.returnAsArray) { + code.push('[]'); + } + return code.join(''); + } + + translateIncrementExpression(expr: IncrementExpression): string { + if (expr.postfix) { + return `${expr.id}${expr.op}`; + } + return `${expr.op}${expr.id}`; + } + + translateUnaryExpression(expr: UnaryExpression): string { + return `${expr.op} ${this.translateExpression(expr.arg)}`; + } + + translateBlockExpression(expr: BlockExpression): string { + const code: string[] = []; + code.push('('); + code.push(this.translateExpressions(expr.statements, ';')); + code.push(')'); + return code.join(''); + } + + translateSpreadExpression(expr: SpreadExpression): string { + return `...${this.translateExpression(expr.value)}`; + } + + translateObjectExpression(expr: ObjectExpression): string { + const code: string[] = []; + code.push('{\n\t'); + code.push(this.translateExpressions(expr.props, ',\n\t')); + code.push('\n}'); + return code.join(''); + } + + translateObjectPropExpression(expr: ObjectPropExpression): string { + const code: string[] = []; + if (expr.key) { + if (typeof expr.key === 'string') { + code.push(expr.key); + } else if (expr.key.type === SyntaxType.LITERAL) { + code.push(this.translateExpression(expr.key)); + } else { + code.push(this.translateWithWrapper(expr.key, '[', ']')); + } + code.push(': '); + } + code.push(this.translateExpression(expr.value)); + return code.join(''); + } + + translateArrayExpression(expr: ArrayExpression): string { + const code: string[] = []; + code.push('['); + code.push(this.translateExpressions(expr.elements, ', ')); + code.push(']'); + return code.join(''); + } + + translateLiteralExpression(expr: LiteralExpression): string { + return translateLiteral(expr.tokenType, expr.value); + } + + translateStatementsExpression(expr: StatementsExpression): string { + return this.translateExpressions(expr.statements, ';\n'); + } + + translateBinaryExpression(expr: BinaryExpression): string { + const left = this.translateExpression(expr.args[0]); + const right = this.translateExpression(expr.args[1]); + return `${left} ${expr.op} ${right}`; + } +} diff --git a/src/translator.ts b/src/translator.ts index c6a772e..1d90e16 100644 --- a/src/translator.ts +++ b/src/translator.ts @@ -7,7 +7,7 @@ import { VARS_PREFIX, } from './constants'; import { JsonTemplateTranslatorError } from './errors'; -import { binaryOperators } from './operators'; +import { binaryOperators, standardFunctions } from './operators'; import { ArrayExpression, AssignmentExpression, @@ -40,7 +40,8 @@ import { IncrementExpression, LoopControlExpression, } from './types'; -import { CommonUtils } from './utils'; +import { convertToStatementsExpr, escapeStr } from './utils/common'; +import { translateLiteral } from './utils/translator'; export class JsonTemplateTranslator { private vars: string[] = []; @@ -49,6 +50,8 @@ export class JsonTemplateTranslator { private unusedVars: string[] = []; + private standardFunctions: Record = {}; + private readonly expr: Expression; constructor(expr: Expression) { @@ -90,6 +93,10 @@ export class JsonTemplateTranslator { this.init(); const code: string[] = []; const exprCode = this.translateExpr(this.expr, dest, ctx); + const functions = Object.values(this.standardFunctions); + if (functions.length > 0) { + code.push(functions.join('').replaceAll(/\s+/g, ' ')); + } code.push(`let ${dest};`); code.push(this.vars.map((elm) => `let ${elm};`).join('')); code.push(exprCode); @@ -243,7 +250,7 @@ export class JsonTemplateTranslator { code.push(`return ${value};`); this.releaseVars(value); } - code.push(`return ${ctx};`); + code.push(`return;`); return code.join(''); } @@ -370,7 +377,7 @@ export class JsonTemplateTranslator { } private translatePathExpr(expr: PathExpression, dest: string, ctx: string): string { - if (expr.pathType === PathType.SIMPLE) { + if (expr.inferredPathType === PathType.SIMPLE) { return this.translateSimplePathExpr(expr, dest, ctx); } const code: string[] = []; @@ -389,9 +396,9 @@ export class JsonTemplateTranslator { const valuesCode = JsonTemplateTranslator.returnObjectValues(ctx); code.push(`${dest} = ${valuesCode}.flat();`); } else if (prop) { - const propStr = CommonUtils.escapeStr(prop); - code.push(`if(${ctx} && Object.prototype.hasOwnProperty.call(${ctx}, ${propStr})){`); - code.push(`${dest}=${ctx}[${propStr}];`); + const escapedPropName = escapeStr(prop); + code.push(`if(${ctx} && Object.prototype.hasOwnProperty.call(${ctx}, ${escapedPropName})){`); + code.push(`${dest}=${ctx}[${escapedPropName}];`); code.push('} else {'); code.push(`${dest} = undefined;`); code.push('}'); @@ -417,7 +424,7 @@ export class JsonTemplateTranslator { const result = this.acquireVar(); code.push(JsonTemplateTranslator.generateAssignmentCode(result, '[]')); const { prop } = expr; - const propStr = CommonUtils.escapeStr(prop?.value); + const propStr = escapeStr(prop?.value); code.push(`${ctxs}=[${baseCtx}];`); code.push(`while(${ctxs}.length > 0) {`); code.push(`${currCtx} = ${ctxs}.shift();`); @@ -453,7 +460,7 @@ export class JsonTemplateTranslator { } const fnExpr: FunctionExpression = { type: SyntaxType.FUNCTION_EXPR, - body: CommonUtils.convertToStatementsExpr(...expr.statements), + body: convertToStatementsExpr(...expr.statements), block: true, }; return this.translateExpr(fnExpr, dest, ctx); @@ -474,7 +481,13 @@ export class JsonTemplateTranslator { } private getFunctionName(expr: FunctionCallExpression, ctx: string): string { - return expr.dot ? `${ctx}.${expr.id}` : expr.id || ctx; + if (expr.object) { + return expr.id ? `${ctx}.${expr.id}` : ctx; + } + if (expr.parent) { + return expr.id ? `${expr.parent}.${expr.id}` : expr.parent; + } + return expr.id as string; } private translateFunctionCallExpr( @@ -490,7 +503,17 @@ export class JsonTemplateTranslator { code.push(`if(${JsonTemplateTranslator.returnIsNotEmpty(result)}){`); } const functionArgsStr = this.translateSpreadableExpressions(expr.args, result, code); - code.push(result, '=', this.getFunctionName(expr, result), '(', functionArgsStr, ');'); + const functionName = this.getFunctionName(expr, result); + if (expr.id && standardFunctions[expr.id]) { + this.standardFunctions[expr.id] = standardFunctions[expr.id]; + code.push(`if(${functionName} && typeof ${functionName} === 'function'){`); + code.push(result, '=', functionName, '(', functionArgsStr, ');'); + code.push('} else {'); + code.push(result, '=', expr.id, '(', expr.parent ?? result, ',', functionArgsStr, ');'); + code.push('}'); + } else { + code.push(result, '=', functionName, '(', functionArgsStr, ');'); + } if (expr.object) { code.push('}'); } @@ -551,13 +574,13 @@ export class JsonTemplateTranslator { } private translateLiteralExpr(expr: LiteralExpression, dest: string, _ctx: string): string { - const literalCode = this.translateLiteral(expr.tokenType, expr.value); + const literalCode = translateLiteral(expr.tokenType, expr.value); return JsonTemplateTranslator.generateAssignmentCode(dest, literalCode); } private getSimplePathSelector(expr: SelectorExpression, isAssignment: boolean): string { if (expr.prop?.type === TokenType.STR) { - return `${isAssignment ? '' : '?.'}[${CommonUtils.escapeStr(expr.prop?.value)}]`; + return `${isAssignment ? '' : '?.'}[${escapeStr(expr.prop?.value)}]`; } return `${isAssignment ? '' : '?'}.${expr.prop?.value}`; } @@ -695,13 +718,6 @@ export class JsonTemplateTranslator { return code.join(''); } - private translateLiteral(type: TokenType, val: any): string { - if (type === TokenType.STR) { - return CommonUtils.escapeStr(val); - } - return String(val); - } - private translateUnaryExpr(expr: UnaryExpression, dest: string, ctx: string): string { const code: string[] = []; const val = this.acquireVar(); @@ -715,7 +731,7 @@ export class JsonTemplateTranslator { const code: string[] = []; if (expr.filter.type === SyntaxType.ARRAY_INDEX_FILTER_EXPR) { code.push(this.translateIndexFilterExpr(expr.filter as IndexFilterExpression, dest, ctx)); - } else { + } else if (expr.filter.type === SyntaxType.RANGE_FILTER_EXPR) { code.push(this.translateRangeFilterExpr(expr.filter as RangeFilterExpression, dest, ctx)); } return code.join(''); @@ -728,6 +744,7 @@ export class JsonTemplateTranslator { ): string { const code: string[] = []; const condition = this.acquireVar(); + code.push(JsonTemplateTranslator.generateAssignmentCode(condition, 'true')); code.push(this.translateExpr(expr.filter, condition, ctx)); code.push(`if(!${condition}) {${dest} = undefined;}`); this.releaseVars(condition); diff --git a/src/types.ts b/src/types.ts index 5f5e033..ecd16d8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,7 +8,14 @@ export enum Keyword { AWAIT = 'await', ASYNC = 'async', IN = 'in', + NOT_IN = 'nin', NOT = 'not', + CONTAINS = 'contains', + SUBSETOF = 'subsetof', + ANYOF = 'anyof', + NONEOF = 'noneof', + EMPTY = 'empty', + SIZE = 'size', RETURN = 'return', THROW = 'throw', CONTINUE = 'continue', @@ -30,6 +37,7 @@ export enum TokenType { THROW = 'throw', KEYWORD = 'keyword', EOT = 'eot', + REGEXP = 'regexp', } // In the order of precedence @@ -69,6 +77,7 @@ export enum SyntaxType { SPREAD_EXPR = 'spread_expr', CONDITIONAL_EXPR = 'conditional_expr', ARRAY_INDEX_FILTER_EXPR = 'array_index_filter_expr', + ALL_FILTER_EXPR = 'all_filter_expr', OBJECT_INDEX_FILTER_EXPR = 'object_index_filter_expr', RANGE_FILTER_EXPR = 'range_filter_expr', OBJECT_FILTER_EXPR = 'object_filter_expr', @@ -80,7 +89,6 @@ export enum SyntaxType { ARRAY_EXPR = 'array_expr', BLOCK_EXPR = 'block_expr', FUNCTION_EXPR = 'function_expr', - FUNCTION_CALL_ARG = 'function_call_arg', FUNCTION_CALL_EXPR = 'function_call_expr', RETURN_EXPR = 'return_expr', THROW_EXPR = 'throw_expr', @@ -92,6 +100,8 @@ export enum SyntaxType { export enum PathType { SIMPLE = 'simple', RICH = 'rich', + JSON = 'json', + UNKNOWN = 'unknown', } export interface EngineOptions { @@ -117,6 +127,10 @@ export interface Expression { [key: string]: any; } +export interface PathOptionsExpression extends Expression { + options: PathOptions; +} + export interface LambdaArgExpression extends Expression { index: number; } @@ -126,6 +140,7 @@ export interface FunctionExpression extends Expression { body: StatementsExpression; block?: boolean; async?: boolean; + lambda?: boolean; } export interface BlockExpression extends Expression { @@ -186,6 +201,8 @@ export interface IndexFilterExpression extends Expression { exclude?: boolean; } +export interface AllFilterExpression extends Expression {} + export interface ObjectFilterExpression extends Expression { filter: Expression; } @@ -193,8 +210,10 @@ export interface ObjectFilterExpression extends Expression { export interface ArrayFilterExpression extends Expression { filter: RangeFilterExpression | IndexFilterExpression; } + +export type Literal = string | number | boolean | null | undefined; export interface LiteralExpression extends Expression { - value: string | number | boolean | null | undefined; + value: Literal; tokenType: TokenType; } export interface PathExpression extends Expression { @@ -202,6 +221,7 @@ export interface PathExpression extends Expression { root?: Expression | string; returnAsArray?: boolean; pathType: PathType; + inferredPathType: PathType; } export interface IncrementExpression extends Expression { @@ -222,7 +242,7 @@ export interface FunctionCallExpression extends Expression { args: Expression[]; object?: Expression; id?: string; - dot?: boolean; + parent?: string; } export interface ConditionalExpression extends Expression { @@ -253,3 +273,15 @@ export interface LoopExpression extends Expression { export interface ThrowExpression extends Expression { value: Expression; } + +export type FlatMappingPaths = { + input: string; + output: string; +}; + +export type FlatMappingAST = FlatMappingPaths & { + inputExpr: PathExpression; + outputExpr: PathExpression; +}; + +export type TemplateInput = string | Expression | FlatMappingPaths[]; diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 07a8bcd..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Expression, StatementsExpression, SyntaxType } from './types'; - -export class CommonUtils { - static toArray(val: any): any[] | undefined { - if (val === undefined || val === null) { - return undefined; - } - return Array.isArray(val) ? val : [val]; - } - - static getLastElement(arr: T[]): T | undefined { - if (!arr.length) { - return undefined; - } - return arr[arr.length - 1]; - } - - static convertToStatementsExpr(...expressions: Expression[]): StatementsExpression { - return { - type: SyntaxType.STATEMENTS_EXPR, - statements: expressions, - }; - } - - static CreateAsyncFunction(...args) { - // eslint-disable-next-line @typescript-eslint/no-empty-function, func-names - return async function () {}.constructor(...args); - } - - static escapeStr(s?: string): string { - if (typeof s !== 'string') { - return ''; - } - return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; - } -} diff --git a/src/utils/common.test.ts b/src/utils/common.test.ts new file mode 100644 index 0000000..16d0cd8 --- /dev/null +++ b/src/utils/common.test.ts @@ -0,0 +1,66 @@ +import { EMPTY_EXPR } from '../constants'; +import { SyntaxType } from '../types'; +import * as CommonUtils from './common'; + +describe('Common Utils tests', () => { + describe('toArray', () => { + it('should return array for non array', () => { + expect(CommonUtils.toArray(1)).toEqual([1]); + }); + it('should return array for array', () => { + expect(CommonUtils.toArray([1])).toEqual([1]); + }); + it('should return array for undefined', () => { + expect(CommonUtils.toArray(undefined)).toBeUndefined(); + }); + }); + describe('getLastElement', () => { + it('should return last element of non empty array', () => { + expect(CommonUtils.getLastElement([1, 2])).toEqual(2); + }); + it('should return undefined for empty array', () => { + expect(CommonUtils.getLastElement([])).toBeUndefined(); + }); + }); + describe('convertToStatementsExpr', () => { + it('should return statement expression for no expressions', () => { + expect(CommonUtils.convertToStatementsExpr()).toEqual({ + type: SyntaxType.STATEMENTS_EXPR, + statements: [], + }); + }); + it('should return statement expression for single expression', () => { + expect(CommonUtils.convertToStatementsExpr(EMPTY_EXPR)).toEqual({ + type: SyntaxType.STATEMENTS_EXPR, + statements: [EMPTY_EXPR], + }); + }); + it('should return statement expression for multiple expression', () => { + expect(CommonUtils.convertToStatementsExpr(EMPTY_EXPR, EMPTY_EXPR)).toEqual({ + type: SyntaxType.STATEMENTS_EXPR, + statements: [EMPTY_EXPR, EMPTY_EXPR], + }); + }); + }); + + describe('escapeStr', () => { + it('should return emtpy string for non string input', () => { + expect(CommonUtils.escapeStr(undefined)).toEqual(''); + }); + it('should return escaped string for simple string input', () => { + expect(CommonUtils.escapeStr('aabc')).toEqual(`'aabc'`); + }); + + it('should return escaped string for string with escape characters', () => { + expect(CommonUtils.escapeStr(`a\nb'c`)).toEqual(`'a\nb\\'c'`); + }); + }); + describe('CreateAsyncFunction', () => { + it('should return async function from code without args', async () => { + expect(await CommonUtils.CreateAsyncFunction('return 1')()).toEqual(1); + }); + it('should return async function from code with args', async () => { + expect(await CommonUtils.CreateAsyncFunction('input', 'return input')(1)).toEqual(1); + }); + }); +}); diff --git a/src/utils/common.ts b/src/utils/common.ts new file mode 100644 index 0000000..83bf884 --- /dev/null +++ b/src/utils/common.ts @@ -0,0 +1,53 @@ +import { + type Expression, + type StatementsExpression, + SyntaxType, + BlockExpression, + FlatMappingPaths, +} from '../types'; + +export function toArray(val: T | T[] | undefined): T[] | undefined { + if (val === undefined || val === null) { + return undefined; + } + return Array.isArray(val) ? val : [val]; +} + +export function getLastElement(arr: T[]): T | undefined { + if (!arr.length) { + return undefined; + } + return arr[arr.length - 1]; +} + +export function createBlockExpression(expr: Expression): BlockExpression { + return { + type: SyntaxType.BLOCK_EXPR, + statements: [expr], + }; +} + +export function convertToStatementsExpr(...expressions: Expression[]): StatementsExpression { + return { + type: SyntaxType.STATEMENTS_EXPR, + statements: expressions, + }; +} + +export function CreateAsyncFunction(...args) { + // eslint-disable-next-line @typescript-eslint/no-empty-function, func-names + return async function () {}.constructor(...args); +} + +export function isExpression(val: string | Expression | FlatMappingPaths[]): boolean { + return ( + typeof val === 'object' && !Array.isArray(val) && Object.values(SyntaxType).includes(val.type) + ); +} + +export function escapeStr(s?: string): string { + if (typeof s !== 'string') { + return ''; + } + return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; +} diff --git a/src/utils/converter.ts b/src/utils/converter.ts new file mode 100644 index 0000000..48d722c --- /dev/null +++ b/src/utils/converter.ts @@ -0,0 +1,150 @@ +/* eslint-disable no-param-reassign */ +import { + SyntaxType, + PathExpression, + ObjectPropExpression, + ArrayExpression, + ObjectExpression, + FlatMappingAST, + Expression, + IndexFilterExpression, + BlockExpression, +} from '../types'; +import { createBlockExpression, getLastElement } from './common'; + +function CreateObjectExpression(): ObjectExpression { + return { + type: SyntaxType.OBJECT_EXPR, + props: [] as ObjectPropExpression[], + }; +} + +function findOrCreateObjectPropExpression( + props: ObjectPropExpression[], + key: string, +): ObjectPropExpression { + let match = props.find((prop) => prop.key === key); + if (!match) { + match = { + type: SyntaxType.OBJECT_PROP_EXPR, + key, + value: CreateObjectExpression(), + }; + props.push(match); + } + return match; +} + +function processArrayIndexFilter( + currrentOutputPropAST: ObjectPropExpression, + filter: IndexFilterExpression, +): ObjectExpression { + const filterIndex = filter.indexes.elements[0].value; + if (currrentOutputPropAST.value.type !== SyntaxType.ARRAY_EXPR) { + const elements: Expression[] = []; + elements[filterIndex] = currrentOutputPropAST.value; + currrentOutputPropAST.value = { + type: SyntaxType.ARRAY_EXPR, + elements, + }; + } else if (!currrentOutputPropAST.value.elements[filterIndex]) { + (currrentOutputPropAST.value as ArrayExpression).elements[filterIndex] = + CreateObjectExpression(); + } + return currrentOutputPropAST.value.elements[filterIndex]; +} + +function processAllFilter( + currentInputAST: PathExpression, + currentOutputPropAST: ObjectPropExpression, +): ObjectExpression { + const filterIndex = currentInputAST.parts.findIndex( + (part) => part.type === SyntaxType.OBJECT_FILTER_EXPR, + ); + + if (filterIndex === -1) { + return currentOutputPropAST.value as ObjectExpression; + } + const matchedInputParts = currentInputAST.parts.splice(0, filterIndex + 1); + if (currentOutputPropAST.value.type !== SyntaxType.PATH) { + matchedInputParts.push(createBlockExpression(currentOutputPropAST.value)); + currentOutputPropAST.value = { + type: SyntaxType.PATH, + root: currentInputAST.root, + pathType: currentInputAST.pathType, + parts: matchedInputParts, + returnAsArray: true, + } as PathExpression; + } + currentInputAST.root = undefined; + + const blockExpr = getLastElement(currentOutputPropAST.value.parts) as BlockExpression; + return blockExpr.statements[0] as ObjectExpression; +} + +function handleNextPart( + nextOutputPart: Expression, + currentInputAST: PathExpression, + currentOutputPropAST: ObjectPropExpression, +): ObjectExpression { + if (nextOutputPart.filter?.type === SyntaxType.ALL_FILTER_EXPR) { + return processAllFilter(currentInputAST, currentOutputPropAST); + } + if (nextOutputPart.filter?.type === SyntaxType.ARRAY_INDEX_FILTER_EXPR) { + return processArrayIndexFilter( + currentOutputPropAST, + nextOutputPart.filter as IndexFilterExpression, + ); + } + return currentOutputPropAST.value as ObjectExpression; +} + +function processFlatMappingPart( + flatMapping: FlatMappingAST, + partNum: number, + currentOutputPropsAST: ObjectPropExpression[], +): ObjectPropExpression[] { + const outputPart = flatMapping.outputExpr.parts[partNum]; + + if (outputPart.type !== SyntaxType.SELECTOR || !outputPart.prop?.value) { + return currentOutputPropsAST; + } + const key = outputPart.prop.value; + + if (partNum === flatMapping.outputExpr.parts.length - 1) { + currentOutputPropsAST.push({ + type: SyntaxType.OBJECT_PROP_EXPR, + key, + value: flatMapping.inputExpr, + } as ObjectPropExpression); + return currentOutputPropsAST; + } + + const currentOutputPropAST = findOrCreateObjectPropExpression(currentOutputPropsAST, key); + const nextOutputPart = flatMapping.outputExpr.parts[partNum + 1]; + const objectExpr = handleNextPart(nextOutputPart, flatMapping.inputExpr, currentOutputPropAST); + if ( + objectExpr.type !== SyntaxType.OBJECT_EXPR || + !objectExpr.props || + !Array.isArray(objectExpr.props) + ) { + throw new Error(`Failed to process output mapping: ${flatMapping.output}`); + } + return objectExpr.props; +} + +/** + * Convert Flat to Object Mappings + */ +export function convertToObjectMapping(flatMappingAST: FlatMappingAST[]): ObjectExpression { + const outputAST: ObjectExpression = CreateObjectExpression(); + + for (const flatMapping of flatMappingAST) { + let currentOutputPropsAST = outputAST.props; + for (let i = 0; i < flatMapping.outputExpr.parts.length; i++) { + currentOutputPropsAST = processFlatMappingPart(flatMapping, i, currentOutputPropsAST); + } + } + + return outputAST; +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..f0cb94d --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from './common'; +export * from './converter'; diff --git a/src/utils/translator.ts b/src/utils/translator.ts new file mode 100644 index 0000000..2507ac7 --- /dev/null +++ b/src/utils/translator.ts @@ -0,0 +1,10 @@ +import { TokenType, Literal } from '../types'; +import { escapeStr } from './common'; + +export function translateLiteral(type: TokenType, val: Literal): string { + if (type === TokenType.STR) { + return escapeStr(String(val)); + } + + return String(val); +} diff --git a/test/scenario.test.ts b/test/scenario.test.ts index 1aeca0e..fbde619 100644 --- a/test/scenario.test.ts +++ b/test/scenario.test.ts @@ -12,17 +12,14 @@ command .parse(); const opts = command.opts(); -const scenarioName = opts.scenario || 'none'; +const scenarioName = opts.scenario || 'arrays'; const index = +(opts.index || 0); describe(`${scenarioName}:`, () => { - it(`Scenario ${index}`, async () => { - if (scenarioName === 'none') { - return; - } - const scenarioDir = join(__dirname, 'scenarios', scenarioName); - const scenarios = ScenarioUtils.extractScenarios(scenarioDir); - const scenario: Scenario = scenarios[index] || scenarios[0]; + 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 () => { let result; try { console.log( @@ -34,6 +31,7 @@ describe(`${scenarioName}:`, () => { result = await ScenarioUtils.evaluateScenario(templateEngine, scenario); expect(result).toEqual(scenario.output); } catch (error: any) { + console.error(error); console.log('Actual result', JSON.stringify(result, null, 2)); console.log('Expected result', JSON.stringify(scenario.output, null, 2)); expect(error.message).toContain(scenario.error); diff --git a/test/scenarios/bad_templates/bad_regex.jt b/test/scenarios/bad_templates/bad_regex.jt new file mode 100644 index 0000000..2dc3072 --- /dev/null +++ b/test/scenarios/bad_templates/bad_regex.jt @@ -0,0 +1 @@ +/?/ \ No newline at end of file diff --git a/test/scenarios/bad_templates/data.ts b/test/scenarios/bad_templates/data.ts index 97e98a3..34e1e6e 100644 --- a/test/scenarios/bad_templates/data.ts +++ b/test/scenarios/bad_templates/data.ts @@ -25,6 +25,10 @@ export const data: Scenario[] = [ templatePath: 'bad_number.jt', error: 'Unexpected token', }, + { + templatePath: 'bad_regex.jt', + error: 'invalid regular expression', + }, { templatePath: 'bad_string.jt', error: 'Unexpected end of template', diff --git a/test/scenarios/bad_templates/object_with_invalid_key.jt b/test/scenarios/bad_templates/object_with_invalid_key.jt index 78ba118..839c5db 100644 --- a/test/scenarios/bad_templates/object_with_invalid_key.jt +++ b/test/scenarios/bad_templates/object_with_invalid_key.jt @@ -1 +1 @@ -{1: 2} \ No newline at end of file +{/1/: 2} \ No newline at end of file diff --git a/test/scenarios/comparisons/anyof.jt b/test/scenarios/comparisons/anyof.jt new file mode 100644 index 0000000..7b8f859 --- /dev/null +++ b/test/scenarios/comparisons/anyof.jt @@ -0,0 +1,4 @@ +{ + true: [1, 2] anyof [2, 3], + false: [1, 2] anyof [3, 4] +} \ No newline at end of file diff --git a/test/scenarios/comparisons/contains.jt b/test/scenarios/comparisons/contains.jt new file mode 100644 index 0000000..d9fedea --- /dev/null +++ b/test/scenarios/comparisons/contains.jt @@ -0,0 +1,4 @@ +{ + true: ["aBc" ==* "aB", "abc" contains "c", ["a", "b", "c"] contains "c"], + false: ["aBc" ==* "ab", "abc" contains "d", ["a", "b", "c"] contains "d"] +} \ No newline at end of file diff --git a/test/scenarios/comparisons/data.ts b/test/scenarios/comparisons/data.ts index 0974cfa..8d98bbe 100644 --- a/test/scenarios/comparisons/data.ts +++ b/test/scenarios/comparisons/data.ts @@ -2,27 +2,178 @@ import { Scenario } from '../../types'; export const data: Scenario[] = [ { - output: [ - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true, - ], + templatePath: 'anyof.jt', + output: { + true: true, + false: false, + }, + }, + { + templatePath: 'contains.jt', + output: { + true: [true, true, true], + false: [false, false, false], + }, + }, + { + templatePath: 'empty.jt', + output: { + true: [true, true], + false: [false, false], + }, + }, + { + templatePath: 'string_contains_ignore_case.jt', + output: { + true: true, + false: false, + }, + }, + { + templatePath: 'ends_with.jt', + output: { + true: [true, true], + false: [false, false], + }, + }, + { + templatePath: 'ends_with_ignore_case.jt', + output: { + true: [true, true], + false: [false, false], + }, + }, + { + templatePath: 'eq.jt', + output: { + true: true, + false: false, + }, + }, + { + templatePath: 'ge.jt', + output: { + true: true, + false: false, + }, + }, + { + templatePath: 'gte.jt', + output: { + true: true, + false: false, + }, + }, + { + templatePath: 'in.jt', + output: { + true: [true, true], + false: [false, false], + }, + }, + { + templatePath: 'le.jt', + output: { + true: true, + false: false, + }, + }, + { + templatePath: 'lte.jt', + output: { + true: true, + false: false, + }, + }, + { + templatePath: 'ne.jt', + output: { + true: true, + false: false, + }, + }, + { + templatePath: 'noneof.jt', + output: { + true: true, + false: false, + }, + }, + { + templatePath: 'not_in.jt', + output: { + true: [true, true], + false: [false, false], + }, + }, + { + templatePath: 'regex.jt', + output: { + true: [true, true], + false: [false, false], + }, + }, + { + templatePath: 'size.jt', + output: { + true: [true, true], + false: [false, false], + }, + }, + { + templatePath: 'starts_with.jt', + output: { + true: [true, true], + false: [false, false], + }, + }, + { + templatePath: 'starts_with_ignore_case.jt', + output: { + true: [true, true], + false: [false, false], + }, + }, + { + templatePath: 'string_eq.jt', + output: { + true: true, + false: false, + }, + }, + { + templatePath: 'string_ne.jt', + output: { + true: true, + false: false, + }, + }, + { + templatePath: 'string_eq_ingore_case.jt', + output: { + true: true, + false: false, + }, + }, + { + templatePath: 'string_ne.jt', + output: { + true: true, + false: false, + }, + }, + { + templatePath: 'string_ne_ingore_case.jt', + output: { + true: true, + false: false, + }, + }, + { + templatePath: 'subsetof.jt', + output: { + true: [true, true], + false: [false, false], + }, }, ]; diff --git a/test/scenarios/comparisons/empty.jt b/test/scenarios/comparisons/empty.jt new file mode 100644 index 0000000..d81d4c9 --- /dev/null +++ b/test/scenarios/comparisons/empty.jt @@ -0,0 +1,4 @@ +{ + true: ["" empty true, [] empty true], + false: ["a" empty true, ["a"] empty true] +} \ No newline at end of file diff --git a/test/scenarios/comparisons/ends_with.jt b/test/scenarios/comparisons/ends_with.jt new file mode 100644 index 0000000..bc63d4e --- /dev/null +++ b/test/scenarios/comparisons/ends_with.jt @@ -0,0 +1,4 @@ +{ + true:["EndsWith" $== "With", "With" ==$ "EndsWith"], + false: ["EndsWith" $== "NotWith", "NotWith" ==$ "EndsWith"] +} \ No newline at end of file diff --git a/test/scenarios/comparisons/ends_with_ignore_case.jt b/test/scenarios/comparisons/ends_with_ignore_case.jt new file mode 100644 index 0000000..fa66028 --- /dev/null +++ b/test/scenarios/comparisons/ends_with_ignore_case.jt @@ -0,0 +1,4 @@ +{ + true:["EndsWith" $= "with", "with" =$ "EndsWith"], + false: ["EndsWith" $= "NotWith", "NotWith" =$ "EndsWith"] +} \ No newline at end of file diff --git a/test/scenarios/comparisons/eq.jt b/test/scenarios/comparisons/eq.jt new file mode 100644 index 0000000..1defeac --- /dev/null +++ b/test/scenarios/comparisons/eq.jt @@ -0,0 +1,4 @@ +{ + true: 10 == 10, + false: 10 == 2 +} \ No newline at end of file diff --git a/test/scenarios/comparisons/ge.jt b/test/scenarios/comparisons/ge.jt new file mode 100644 index 0000000..f62a813 --- /dev/null +++ b/test/scenarios/comparisons/ge.jt @@ -0,0 +1,4 @@ +{ + true: 10 > 2, + false: 2 > 10 +} \ No newline at end of file diff --git a/test/scenarios/comparisons/gte.jt b/test/scenarios/comparisons/gte.jt new file mode 100644 index 0000000..7e1885c --- /dev/null +++ b/test/scenarios/comparisons/gte.jt @@ -0,0 +1,4 @@ +{ + true: 10 >= 10, + false: 2 >= 10 +} \ No newline at end of file diff --git a/test/scenarios/comparisons/in.jt b/test/scenarios/comparisons/in.jt new file mode 100644 index 0000000..3b6a81d --- /dev/null +++ b/test/scenarios/comparisons/in.jt @@ -0,0 +1,4 @@ +{ + true: ["a" in ["a", "b"], "a" in {"a": 1, "b": 2}], + false: ["c" in ["a", "b"], "c" in {"a": 1, "b": 2}] +} \ No newline at end of file diff --git a/test/scenarios/comparisons/le.jt b/test/scenarios/comparisons/le.jt new file mode 100644 index 0000000..b362697 --- /dev/null +++ b/test/scenarios/comparisons/le.jt @@ -0,0 +1,4 @@ +{ + true: 2 < 10, + false: 10 < 2 +} \ No newline at end of file diff --git a/test/scenarios/comparisons/lte.jt b/test/scenarios/comparisons/lte.jt new file mode 100644 index 0000000..a1a9c2f --- /dev/null +++ b/test/scenarios/comparisons/lte.jt @@ -0,0 +1,4 @@ +{ + true: 10 <= 10, + false: 10 <= 2 +} \ No newline at end of file diff --git a/test/scenarios/comparisons/ne.jt b/test/scenarios/comparisons/ne.jt new file mode 100644 index 0000000..a32bc59 --- /dev/null +++ b/test/scenarios/comparisons/ne.jt @@ -0,0 +1,4 @@ +{ + true: 10 != 2, + false: 10 != 10 +} \ No newline at end of file diff --git a/test/scenarios/comparisons/noneof.jt b/test/scenarios/comparisons/noneof.jt new file mode 100644 index 0000000..05a0103 --- /dev/null +++ b/test/scenarios/comparisons/noneof.jt @@ -0,0 +1,4 @@ +{ + true: [1, 2] noneof [3, 4], + false: [1, 2] noneof [2, 3], +} \ No newline at end of file diff --git a/test/scenarios/comparisons/not_in.jt b/test/scenarios/comparisons/not_in.jt new file mode 100644 index 0000000..aa87f2c --- /dev/null +++ b/test/scenarios/comparisons/not_in.jt @@ -0,0 +1,4 @@ +{ + true: ["c" nin ["a", "b"], "c" nin {"a": 1, "b": 2}], + false: ["a" nin ["a", "b"], "a" nin {"a": 1, "b": 2}] +} \ No newline at end of file diff --git a/test/scenarios/comparisons/regex.jt b/test/scenarios/comparisons/regex.jt new file mode 100644 index 0000000..7d51c78 --- /dev/null +++ b/test/scenarios/comparisons/regex.jt @@ -0,0 +1,4 @@ +{ + true: ['abc' =~ /a.*c/, 'aBC' =~ /a.*c/i], + false: ['abC' =~ /a.*c/, 'aBd' =~ /a.*c/i] +} \ No newline at end of file diff --git a/test/scenarios/comparisons/size.jt b/test/scenarios/comparisons/size.jt new file mode 100644 index 0000000..c73f863 --- /dev/null +++ b/test/scenarios/comparisons/size.jt @@ -0,0 +1,4 @@ +{ + true: [["a", "b"] size 2, "ab" size 2], + false: [[] size 1, "" size 1] +} \ No newline at end of file diff --git a/test/scenarios/comparisons/starts_with.jt b/test/scenarios/comparisons/starts_with.jt new file mode 100644 index 0000000..345df80 --- /dev/null +++ b/test/scenarios/comparisons/starts_with.jt @@ -0,0 +1,4 @@ +{ + true:["StartsWith" ^== "Starts", "Starts" ==^ "StartsWith"], + false: ["StartsWith" ^= "NotStarts", "NotStarts" =^ "StartsWith"] +} \ No newline at end of file diff --git a/test/scenarios/comparisons/starts_with_ignore_case.jt b/test/scenarios/comparisons/starts_with_ignore_case.jt new file mode 100644 index 0000000..3c7f7d4 --- /dev/null +++ b/test/scenarios/comparisons/starts_with_ignore_case.jt @@ -0,0 +1,4 @@ +{ + true:["StartsWith" ^= "starts", "starts" =^ "StartsWith"], + false: ["StartsWith" ^= "NotStarts", "NotStarts" =^ "StartsWith"] +} \ No newline at end of file diff --git a/test/scenarios/comparisons/string_contains_ignore_case.jt b/test/scenarios/comparisons/string_contains_ignore_case.jt new file mode 100644 index 0000000..c364247 --- /dev/null +++ b/test/scenarios/comparisons/string_contains_ignore_case.jt @@ -0,0 +1,4 @@ +{ + true: "aBc" =* "aB", + false: "ac" =* "aB" +} \ No newline at end of file diff --git a/test/scenarios/comparisons/string_eq.jt b/test/scenarios/comparisons/string_eq.jt new file mode 100644 index 0000000..006861a --- /dev/null +++ b/test/scenarios/comparisons/string_eq.jt @@ -0,0 +1,4 @@ +{ + true: "aBc" === "aBc", + false: "abc" === "aBc" +} \ No newline at end of file diff --git a/test/scenarios/comparisons/string_eq_ingore_case.jt b/test/scenarios/comparisons/string_eq_ingore_case.jt new file mode 100644 index 0000000..fb6e5b7 --- /dev/null +++ b/test/scenarios/comparisons/string_eq_ingore_case.jt @@ -0,0 +1,4 @@ +{ + true: "abc" == "aBc", + false: "adc" == "aBc" +} \ No newline at end of file diff --git a/test/scenarios/comparisons/string_ne.jt b/test/scenarios/comparisons/string_ne.jt new file mode 100644 index 0000000..773c1d8 --- /dev/null +++ b/test/scenarios/comparisons/string_ne.jt @@ -0,0 +1,4 @@ +{ + true: "abc" !== "aBc", + false: "aBc" !== "aBc" +} \ No newline at end of file diff --git a/test/scenarios/comparisons/string_ne_ingore_case.jt b/test/scenarios/comparisons/string_ne_ingore_case.jt new file mode 100644 index 0000000..8195fd4 --- /dev/null +++ b/test/scenarios/comparisons/string_ne_ingore_case.jt @@ -0,0 +1,4 @@ +{ + true: "adc" != "aBc", + false: "abc" != "aBc" +} \ No newline at end of file diff --git a/test/scenarios/comparisons/subsetof.jt b/test/scenarios/comparisons/subsetof.jt new file mode 100644 index 0000000..9a96331 --- /dev/null +++ b/test/scenarios/comparisons/subsetof.jt @@ -0,0 +1,4 @@ +{ + true: [[1, 2] subsetof [1, 2, 3], [] subsetof [1]], + false: [[1, 2] subsetof [1], [1] subsetof []], +} \ No newline at end of file diff --git a/test/scenarios/comparisons/template.jt b/test/scenarios/comparisons/template.jt deleted file mode 100644 index 613e133..0000000 --- a/test/scenarios/comparisons/template.jt +++ /dev/null @@ -1,22 +0,0 @@ -[ -10>2, -2<10, -10>=2, -2<=10, -10 != 2, -'IgnoreCase' == 'ignorecase', -'CompareWithCase' !== 'comparewithCase', -'CompareWithCase' === 'CompareWithCase', -'i' =* 'I contain', -'I' ==* 'I contain', -'I end with' $= 'With', -'I end with' $== 'with', -'With' =$ 'I end with', -'with' ==$ 'I end with', -'I start with' ^= 'i', -'i' =^ 'I start with', -'I start with' ^== 'I', -'I' ==^ 'I start with', -"a" in ["a", "b"], -"a" in {a: 1, b: 2} -] \ No newline at end of file diff --git a/test/scenarios/context_variables/filter.jt b/test/scenarios/context_variables/filter.jt index 5fb62bd..ecedf51 100644 --- a/test/scenarios/context_variables/filter.jt +++ b/test/scenarios/context_variables/filter.jt @@ -5,4 +5,5 @@ .{.[].length > 1}.().@item#idx.({ a: ~r item.a, idx: idx -}) \ No newline at end of file +}) + diff --git a/test/scenarios/filters/object_filters.jt b/test/scenarios/filters/object_filters.jt index cc79126..a5fa75e 100644 --- a/test/scenarios/filters/object_filters.jt +++ b/test/scenarios/filters/object_filters.jt @@ -5,3 +5,4 @@ {.a[].length > 1} {3 in .a}.{.a[].includes(4)} {typeof .b === "number"} + diff --git a/test/scenarios/filters/object_indexes.jt b/test/scenarios/filters/object_indexes.jt index caa14f3..60f22cd 100644 --- a/test/scenarios/filters/object_indexes.jt +++ b/test/scenarios/filters/object_indexes.jt @@ -4,4 +4,4 @@ let obj = { c: 3, d: 4 }; -{...obj{["a", "b"]}, ...obj{~["a", "b"]}} \ No newline at end of file +{...obj{["a", "b"]}, ...obj{~["a", "b", "c"]}, ...obj{!["d"]}}; \ No newline at end of file diff --git a/test/scenarios/functions/array_functions.jt b/test/scenarios/functions/array_functions.jt new file mode 100644 index 0000000..e2b276c --- /dev/null +++ b/test/scenarios/functions/array_functions.jt @@ -0,0 +1,4 @@ +{ + map: .map(lambda ?0 * 2), + filter: .filter(lambda ?0 % 2 == 0) +} \ No newline at end of file diff --git a/test/scenarios/functions/data.ts b/test/scenarios/functions/data.ts index 0aa3a1a..e582341 100644 --- a/test/scenarios/functions/data.ts +++ b/test/scenarios/functions/data.ts @@ -1,6 +1,14 @@ import { Scenario } from '../../types'; export const data: Scenario[] = [ + { + templatePath: 'array_functions.jt', + input: [1, 2, 3, 4], + output: { + map: [2, 4, 6, 8], + filter: [2, 4], + }, + }, { templatePath: 'function_calls.jt', output: ['abc', null, undefined], diff --git a/test/scenarios/mappings/all_features.json b/test/scenarios/mappings/all_features.json new file mode 100644 index 0000000..01f55b1 --- /dev/null +++ b/test/scenarios/mappings/all_features.json @@ -0,0 +1,54 @@ +[ + { + "input": "$.userId", + "output": "$.user.id" + }, + { + "input": "$.discount", + "output": "$.events[0].items[*].discount" + }, + { + "input": "$.products[?(@.category)].id", + "output": "$.events[0].items[*].product_id" + }, + { + "input": "$.events[0]", + "output": "$.events[0].name" + }, + { + "input": "$.products[?(@.category)].name", + "output": "$.events[0].items[*].product_name" + }, + { + "input": "$.products[?(@.category)].category", + "output": "$.events[0].items[*].product_category" + }, + { + "input": "$.products[?(@.category)].variations[*].size", + "output": "$.events[0].items[*].options[*].s" + }, + { + "input": "$.products[?(@.category)].(@.price * @.quantity * (1 - $.discount / 100))", + "output": "$.events[0].items[*].value" + }, + { + "input": "$.products[?(@.category)].(@.price * @.quantity * (1 - $.discount / 100)).sum()", + "output": "$.events[0].revenue" + }, + { + "input": "$.products[?(@.category)].variations[*].length", + "output": "$.events[0].items[*].options[*].l" + }, + { + "input": "$.products[?(@.category)].variations[*].width", + "output": "$.events[0].items[*].options[*].w" + }, + { + "input": "$.products[?(@.category)].variations[*].color", + "output": "$.events[0].items[*].options[*].c" + }, + { + "input": "$.products[?(@.category)].variations[*].height", + "output": "$.events[0].items[*].options[*].h" + } +] diff --git a/test/scenarios/mappings/data.ts b/test/scenarios/mappings/data.ts new file mode 100644 index 0000000..0c7972d --- /dev/null +++ b/test/scenarios/mappings/data.ts @@ -0,0 +1,269 @@ +import { PathType } from '../../../src'; +import type { Scenario } from '../../types'; + +const input = { + userId: 'u1', + discount: 10, + events: ['purchase', 'custom'], + products: [ + { + id: 1, + name: 'p1', + category: 'baby', + price: 3, + quantity: 2, + variations: [ + { + color: 'blue', + size: 1, + }, + { + size: 2, + }, + ], + }, + { + id: 2, + name: 'p2', + price: 5, + quantity: 3, + variations: [ + { + length: 1, + }, + { + color: 'red', + length: 2, + }, + ], + }, + { + id: 3, + name: 'p3', + category: 'home', + price: 10, + quantity: 1, + variations: [ + { + width: 1, + height: 2, + length: 3, + }, + ], + }, + ], +}; +export const data: Scenario[] = [ + { + containsMappings: true, + templatePath: 'all_features.json', + options: { + defaultPathType: PathType.JSON, + }, + input, + output: { + events: [ + { + items: [ + { + discount: 10, + product_id: 1, + product_name: 'p1', + product_category: 'baby', + options: [ + { + s: 1, + c: 'blue', + }, + { + s: 2, + }, + ], + value: 5.4, + }, + { + discount: 10, + product_id: 3, + product_name: 'p3', + product_category: 'home', + options: [ + { + l: 3, + w: 1, + h: 2, + }, + ], + value: 9, + }, + ], + name: 'purchase', + revenue: 14.4, + }, + ], + user: { + id: 'u1', + }, + }, + }, + { + containsMappings: true, + templatePath: 'filters.json', + options: { + defaultPathType: PathType.JSON, + }, + input, + output: { + items: [ + { + product_id: 1, + product_name: 'p1', + product_category: 'baby', + }, + { + product_id: 3, + product_name: 'p3', + product_category: 'home', + }, + ], + }, + }, + { + containsMappings: true, + templatePath: 'index_mappings.json', + options: { + defaultPathType: PathType.JSON, + }, + input, + output: { + events: [ + { + name: 'purchase', + type: 'identify', + }, + { + name: 'custom', + type: 'track', + }, + ], + }, + }, + { + containsMappings: true, + templatePath: 'invalid_mappings.json', + options: { + defaultPathType: PathType.JSON, + }, + error: 'Failed to process output mapping', + }, + { + containsMappings: true, + templatePath: 'mappings_with_root_fields.json', + options: { + defaultPathType: PathType.JSON, + }, + input, + output: { + items: [ + { + product_id: 1, + product_name: 'p1', + product_category: 'baby', + discount: 10, + }, + { + product_id: 2, + product_name: 'p2', + discount: 10, + }, + { + product_id: 3, + product_name: 'p3', + product_category: 'home', + discount: 10, + }, + ], + }, + }, + + { + containsMappings: true, + templatePath: 'nested_mappings.json', + options: { + defaultPathType: PathType.JSON, + }, + input, + output: { + items: [ + { + product_id: 1, + product_name: 'p1', + product_category: 'baby', + options: [ + { + s: 1, + c: 'blue', + }, + { + s: 2, + }, + ], + }, + { + product_id: 2, + product_name: 'p2', + options: [ + { + l: 1, + }, + { + l: 2, + c: 'red', + }, + ], + }, + { + product_id: 3, + product_name: 'p3', + product_category: 'home', + options: [ + { + l: 3, + w: 1, + h: 2, + }, + ], + }, + ], + }, + }, + { + containsMappings: true, + templatePath: 'transformations.json', + options: { + defaultPathType: PathType.JSON, + }, + input, + output: { + items: [ + { + product_id: 1, + product_name: 'p1', + product_category: 'baby', + value: 5.4, + }, + { + product_id: 2, + product_name: 'p2', + value: 13.5, + }, + { + product_id: 3, + product_name: 'p3', + product_category: 'home', + value: 9, + }, + ], + revenue: 27.9, + }, + }, +]; diff --git a/test/scenarios/mappings/filters.json b/test/scenarios/mappings/filters.json new file mode 100644 index 0000000..34106f2 --- /dev/null +++ b/test/scenarios/mappings/filters.json @@ -0,0 +1,14 @@ +[ + { + "input": "$.products[?(@.category)].id", + "output": "$.items[*].product_id" + }, + { + "input": "$.products[?(@.category)].name", + "output": "$.items[*].product_name" + }, + { + "input": "$.products[?(@.category)].category", + "output": "$.items[*].product_category" + } +] diff --git a/test/scenarios/mappings/index_mappings.json b/test/scenarios/mappings/index_mappings.json new file mode 100644 index 0000000..09129a1 --- /dev/null +++ b/test/scenarios/mappings/index_mappings.json @@ -0,0 +1,18 @@ +[ + { + "input": "$.events[0]", + "output": "$.events[0].name" + }, + { + "input": "'identify'", + "output": "$.events[0].type" + }, + { + "input": "$.events[1]", + "output": "$.events[1].name" + }, + { + "input": "'track'", + "output": "$.events[1].type" + } +] diff --git a/test/scenarios/mappings/invalid_mappings.json b/test/scenarios/mappings/invalid_mappings.json new file mode 100644 index 0000000..266a1ec --- /dev/null +++ b/test/scenarios/mappings/invalid_mappings.json @@ -0,0 +1,10 @@ +[ + { + "input": "$.events[0]", + "output": "$.events[0].name" + }, + { + "input": "$.discount", + "output": "$.events[0].name[*].discount" + } +] diff --git a/test/scenarios/mappings/mappings_with_root_fields.json b/test/scenarios/mappings/mappings_with_root_fields.json new file mode 100644 index 0000000..1e80449 --- /dev/null +++ b/test/scenarios/mappings/mappings_with_root_fields.json @@ -0,0 +1,18 @@ +[ + { + "input": "$.discount", + "output": "$.items[*].discount" + }, + { + "input": "$.products[*].id", + "output": "$.items[*].product_id" + }, + { + "input": "$.products[*].name", + "output": "$.items[*].product_name" + }, + { + "input": "$.products[*].category", + "output": "$.items[*].product_category" + } +] diff --git a/test/scenarios/mappings/nested_mappings.json b/test/scenarios/mappings/nested_mappings.json new file mode 100644 index 0000000..9f4e0cc --- /dev/null +++ b/test/scenarios/mappings/nested_mappings.json @@ -0,0 +1,34 @@ +[ + { + "input": "$.products[*].id", + "output": "$.items[*].product_id" + }, + { + "input": "$.products[*].name", + "output": "$.items[*].product_name" + }, + { + "input": "$.products[*].category", + "output": "$.items[*].product_category" + }, + { + "input": "$.products[*].variations[*].size", + "output": "$.items[*].options[*].s" + }, + { + "input": "$.products[*].variations[*].length", + "output": "$.items[*].options[*].l" + }, + { + "input": "$.products[*].variations[*].width", + "output": "$.items[*].options[*].w" + }, + { + "input": "$.products[*].variations[*].color", + "output": "$.items[*].options[*].c" + }, + { + "input": "$.products[*].variations[*].height", + "output": "$.items[*].options[*].h" + } +] diff --git a/test/scenarios/mappings/transformations.json b/test/scenarios/mappings/transformations.json new file mode 100644 index 0000000..9ab2a3e --- /dev/null +++ b/test/scenarios/mappings/transformations.json @@ -0,0 +1,22 @@ +[ + { + "input": "$.products[*].id", + "output": "$.items[*].product_id" + }, + { + "input": "$.products[*].name", + "output": "$.items[*].product_name" + }, + { + "input": "$.products[*].category", + "output": "$.items[*].product_category" + }, + { + "input": "$.products[*].(@.price * @.quantity * (1 - $.discount / 100))", + "output": "$.items[*].value" + }, + { + "input": "$.products[*].(@.price * @.quantity * (1 - $.discount / 100)).sum()", + "output": "$.revenue" + } +] diff --git a/test/scenarios/objects/template.jt b/test/scenarios/objects/template.jt index 35df1f3..be659cf 100644 --- a/test/scenarios/objects/template.jt +++ b/test/scenarios/objects/template.jt @@ -2,7 +2,7 @@ let c = "c key"; let d = 3; let b = 2; let a = { - "a": 1, + "a b": 1, b, // [c] coverts to "c key" [c]: { @@ -11,7 +11,7 @@ let a = { }, }; a.({ - a: .a, + a: ."a b", b: .b, ...(.'c key') }) \ No newline at end of file diff --git a/test/scenarios/paths/block.jt b/test/scenarios/paths/block.jt index 45c6aa2..0f51161 100644 --- a/test/scenarios/paths/block.jt +++ b/test/scenarios/paths/block.jt @@ -1,7 +1,7 @@ [ .({ - a: .a + 1, - b: .b + 2 + a: @.a + 1, + b: @.b + 2 }), .([.a + 1, .b + 2]) ] \ No newline at end of file diff --git a/test/scenarios/paths/data.ts b/test/scenarios/paths/data.ts index 6012d2a..7e23e30 100644 --- a/test/scenarios/paths/data.ts +++ b/test/scenarios/paths/data.ts @@ -69,6 +69,55 @@ export const data: Scenario[] = [ }, ], }, + { + templatePath: 'json_path.jt', + input: { + foo: 'bar', + size: 1, + items: [ + { + a: 1, + b: 1, + }, + { + a: 2, + b: 2, + }, + { + a: 3, + b: 3, + }, + ], + }, + output: [ + 'bar', + [ + { + a: 1, + b: 1, + }, + { + a: 2, + b: 2, + }, + { + a: 3, + b: 3, + }, + ], + [ + { + a: 2, + b: 2, + }, + { + a: 3, + b: 3, + }, + ], + [2, 4, 6], + ], + }, { templatePath: 'options.jt', options: { diff --git a/test/scenarios/paths/json_path.jt b/test/scenarios/paths/json_path.jt new file mode 100644 index 0000000..5ba54c7 --- /dev/null +++ b/test/scenarios/paths/json_path.jt @@ -0,0 +1,6 @@ +[ + ~j $.foo, + ~j $.items[*], + ~j $.items[?(@.a>$.size)], + ~j $.items.(@.a + @.b) +] \ No newline at end of file diff --git a/test/scenarios/paths/simple_path.jt b/test/scenarios/paths/simple_path.jt index 8f2c1c0..6b57327 100644 --- a/test/scenarios/paths/simple_path.jt +++ b/test/scenarios/paths/simple_path.jt @@ -2,5 +2,5 @@ ~s ^.0.a."e", ~s .0.d.1.1[], .[0].b.()~s .1.e.1, - ~s {a: {b : 1}}.a.b + {a: {b : 1}}.a.b ] \ No newline at end of file diff --git a/test/scenarios/return/data.ts b/test/scenarios/return/data.ts index 5487785..c2c0d34 100644 --- a/test/scenarios/return/data.ts +++ b/test/scenarios/return/data.ts @@ -12,10 +12,16 @@ export const data: Scenario[] = [ 'return, throw, continue and break statements are only allowed as last statements in conditional expressions', }, { + templatePath: 'return_no_value.jt', + input: 2, + }, + { + templatePath: 'return_value.jt', input: 3, output: 1, }, { + templatePath: 'return_value.jt', input: 2, output: 1, }, diff --git a/test/scenarios/return/return_no_value.jt b/test/scenarios/return/return_no_value.jt new file mode 100644 index 0000000..309474f --- /dev/null +++ b/test/scenarios/return/return_no_value.jt @@ -0,0 +1,4 @@ +(. % 2 === 0) ? { + return; +} +(. - 1)/2; \ No newline at end of file diff --git a/test/scenarios/return/template.jt b/test/scenarios/return/return_value.jt similarity index 100% rename from test/scenarios/return/template.jt rename to test/scenarios/return/return_value.jt diff --git a/test/scenarios/standard_functions/data.ts b/test/scenarios/standard_functions/data.ts new file mode 100644 index 0000000..3064221 --- /dev/null +++ b/test/scenarios/standard_functions/data.ts @@ -0,0 +1,27 @@ +import { Scenario } from '../../types'; + +export const data: Scenario[] = [ + { + input: { + arr: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + obj: { + foo: 1, + bar: 2, + baz: 3, + quux: 4, + }, + }, + output: { + sum: 55, + sum2: 55, + avg: 5.5, + min: 1, + max: 10, + stddev: 2.8722813232690143, + length: 10, + first: 1, + last: 10, + keys: ['foo', 'bar', 'baz', 'quux'], + }, + }, +]; diff --git a/test/scenarios/standard_functions/template.jt b/test/scenarios/standard_functions/template.jt new file mode 100644 index 0000000..fd6ff03 --- /dev/null +++ b/test/scenarios/standard_functions/template.jt @@ -0,0 +1,14 @@ +const arr = .arr; +const obj = .obj; +{ + sum: .arr.sum(), + sum2: (arr.index(0) + arr.index(-1)) * arr.length() / 2, + avg: arr.avg(), + min: arr.min(), + max: arr.max(), + stddev: arr.stddev(), + length: arr.length(), + first: arr.first(), + last: arr.last(), + keys: obj.keys(), +} \ No newline at end of file diff --git a/test/test_engine.ts b/test/test_engine.ts index efde75d..e26676f 100644 --- a/test/test_engine.ts +++ b/test/test_engine.ts @@ -216,25 +216,25 @@ const address = { // // ), // ); -JsonTemplateEngine.create( - ` -.b ?? .a -`, -) - .evaluate({ a: 1 }) - .then((a) => console.log(JSON.stringify(a))); +// JsonTemplateEngine.create( +// ` +// .b ?? .a +// `, +// ) +// .evaluate({ a: 1 }) +// .then((a) => console.log(JSON.stringify(a))); -console.log( - JsonTemplateEngine.translate(` - let a = [{a: [1, 2], b: "1"}, {a: [3, 4], b: 2}, {a:[5], b: 3}, {b: 4}] - a{.a.length > 1} - `), -); +// console.log( +// JsonTemplateEngine.translate(` +// let a = [{a: [1, 2], b: "1"}, {a: [3, 4], b: 2}, {a:[5], b: 3}, {b: 4}] +// a{.a.length > 1} +// `), +// ); console.log( JSON.stringify( JsonTemplateEngine.parse(` - .(.a) + ~j $.foo `), // null, // 2, diff --git a/test/types.ts b/test/types.ts index ee0c7ec..69ce1ef 100644 --- a/test/types.ts +++ b/test/types.ts @@ -1,17 +1,25 @@ -import { EngineOptions, PathType } from '../src'; +import type { EngineOptions } from '../src'; export type Scenario = { description?: string; - input?: any; + input?: unknown; templatePath?: string; + template?: string; + containsMappings?: true; options?: EngineOptions; - bindings?: any; - output?: any; + bindings?: Record | undefined; + output?: unknown; error?: string; }; export namespace Scenario { export function getTemplatePath(scenario: Scenario): string { - return scenario.templatePath || 'template.jt'; + if (scenario.templatePath) { + return scenario.templatePath; + } + if (scenario.containsMappings) { + return 'mappings.json'; + } + return 'template.jt'; } } diff --git a/test/utils/scenario.ts b/test/utils/scenario.ts index 6187fbc..4785181 100644 --- a/test/utils/scenario.ts +++ b/test/utils/scenario.ts @@ -1,15 +1,28 @@ -import { readFileSync } from 'fs'; -import { join } from 'path'; -import { JsonTemplateEngine, PathType } from '../../src'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { FlatMappingPaths, JsonTemplateEngine, PathType } from '../../src'; import { Scenario } from '../types'; export class ScenarioUtils { - static createTemplateEngine(scenarioDir: string, scenario: Scenario): JsonTemplateEngine { - const templatePath = join(scenarioDir, Scenario.getTemplatePath(scenario)); - const template = readFileSync(templatePath, 'utf-8'); + private static initializeScenario(scenarioDir: string, scenario: Scenario) { scenario.options = scenario.options || {}; scenario.options.defaultPathType = scenario.options.defaultPathType || PathType.SIMPLE; - return JsonTemplateEngine.create(template, scenario.options); + const templatePath = join(scenarioDir, Scenario.getTemplatePath(scenario)); + let template: string = readFileSync(templatePath, 'utf-8'); + if (scenario.containsMappings) { + template = JsonTemplateEngine.convertMappingsToTemplate( + JSON.parse(template) as FlatMappingPaths[], + ); + } + scenario.template = JsonTemplateEngine.reverseTranslate( + JsonTemplateEngine.parse(template, scenario.options), + scenario.options, + ); + } + + static createTemplateEngine(scenarioDir: string, scenario: Scenario): JsonTemplateEngine { + this.initializeScenario(scenarioDir, scenario); + return JsonTemplateEngine.create(scenario.template as string, scenario.options); } static evaluateScenario(templateEngine: JsonTemplateEngine, scenario: Scenario): any {