diff options
| author | ST-DDT <[email protected]> | 2023-12-25 01:51:33 +0100 |
|---|---|---|
| committer | GitHub <[email protected]> | 2023-12-25 01:51:33 +0100 |
| commit | 24482a30042eec5b553b30d60985e89fd69a8660 (patch) | |
| tree | fd4c2b351d03d0f06998e364352f461743f63866 /src/modules/helpers | |
| parent | c209030a660422a17f73d6f0b307af14507bd56d (diff) | |
| download | faker-24482a30042eec5b553b30d60985e89fd69a8660.tar.xz faker-24482a30042eec5b553b30d60985e89fd69a8660.zip | |
feat(helpers): add support for complex intermediate types (#2550)
Diffstat (limited to 'src/modules/helpers')
| -rw-r--r-- | src/modules/helpers/eval.ts | 229 | ||||
| -rw-r--r-- | src/modules/helpers/index.ts | 60 |
2 files changed, 234 insertions, 55 deletions
diff --git a/src/modules/helpers/eval.ts b/src/modules/helpers/eval.ts new file mode 100644 index 00000000..033c5d35 --- /dev/null +++ b/src/modules/helpers/eval.ts @@ -0,0 +1,229 @@ +import { FakerError } from '../../errors/faker-error'; +import type { Faker } from '../../faker'; + +const REGEX_DOT_OR_BRACKET = /\.|\(/; + +/** + * Resolves the given expression and returns its result. This method should only be used when using serialized expressions. + * + * This method is useful if you have to build a random string from a static, non-executable source + * (e.g. string coming from a developer, stored in a database or a file). + * + * It tries to resolve the expression on the given/default entrypoints: + * + * ```js + * const firstName = fakeEval('person.firstName', faker); + * const firstName2 = fakeEval('person.first_name', faker); + * ``` + * + * Is equivalent to: + * + * ```js + * const firstName = faker.person.firstName(); + * const firstName2 = faker.helpers.arrayElement(faker.rawDefinitions.person.first_name); + * ``` + * + * You can provide parameters as well. At first, they will be parsed as json, + * and if that isn't possible, it will fall back to string: + * + * ```js + * const message = fakeEval('phone.number(+!# !## #### #####!)', faker); + * ``` + * + * It is also possible to use multiple parameters (comma separated). + * + * ```js + * const pin = fakeEval('string.numeric(4, {"allowLeadingZeros": true})', faker); + * ``` + * + * This method can resolve expressions with varying depths (dot separated parts). + * + * ```ts + * const airlineModule = fakeEval('airline', faker); // AirlineModule + * const airlineObject = fakeEval('airline.airline', faker); // { name: 'Etihad Airways', iataCode: 'EY' } + * const airlineCode = fakeEval('airline.airline.iataCode', faker); // 'EY' + * const airlineName = fakeEval('airline.airline().name', faker); // 'Etihad Airways' + * const airlineMethodName = fakeEval('airline.airline.name', faker); // 'bound airline' + * ``` + * + * It is NOT possible to access any values not passed as entrypoints. + * + * This method will never return arrays, as it will pick a random element from them instead. + * + * @param expression The expression to evaluate on the entrypoints. + * @param faker The faker instance to resolve array elements. + * @param entrypoints The entrypoints to use when evaluating the expression. + * + * @see faker.helpers.fake() If you wish to have a string with multiple expressions. + * + * @example + * fakeEval('person.lastName', faker) // 'Barrows' + * fakeEval('helpers.arrayElement(["heads", "tails"])', faker) // 'tails' + * fakeEval('number.int(9999)', faker) // 4834 + * + * @since 8.4.0 + */ +export function fakeEval( + expression: string, + faker: Faker, + entrypoints: ReadonlyArray<unknown> = [faker, faker.rawDefinitions] +): unknown { + if (expression.length === 0) { + throw new FakerError('Eval expression cannot be empty.'); + } + + if (entrypoints.length === 0) { + throw new FakerError('Eval entrypoints cannot be empty.'); + } + + let current = entrypoints; + let remaining = expression; + do { + let index: number; + if (remaining.startsWith('(')) { + [index, current] = evalProcessFunction(remaining, current); + } else { + [index, current] = evalProcessExpression(remaining, current); + } + + remaining = remaining.substring(index); + + // Remove garbage and resolve array values + current = current + .filter((value) => value != null) + .map((value): unknown => + Array.isArray(value) ? faker.helpers.arrayElement(value) : value + ); + } while (remaining.length > 0 && current.length > 0); + + if (current.length === 0) { + throw new FakerError(`Cannot resolve expression '${expression}'`); + } + + const value = current[0]; + return typeof value === 'function' ? value() : value; +} + +/** + * Evaluates a function call and returns the new read index and the mapped results. + * + * @param input The input string to parse. + * @param entrypoints The entrypoints to attempt the call on. + */ +function evalProcessFunction( + input: string, + entrypoints: ReadonlyArray<unknown> +): [continueIndex: number, mapped: unknown[]] { + const [index, params] = findParams(input); + const nextChar = input[index + 1]; + switch (nextChar) { + case '.': + case '(': + case undefined: + break; // valid + default: + throw new FakerError( + `Expected dot ('.'), open parenthesis ('('), or nothing after function call but got '${nextChar}'` + ); + } + + return [ + index + (nextChar === '.' ? 2 : 1), // one for the closing bracket, one for the dot + entrypoints.map((entrypoint): unknown => + // TODO @ST-DDT 2023-12-11: Replace in v9 + // typeof entrypoint === 'function' ? entrypoint(...params) : undefined + typeof entrypoint === 'function' ? entrypoint(...params) : entrypoint + ), + ]; +} + +/** + * Tries to find the parameters of a function call. + * + * @param input The input string to parse. + */ +function findParams(input: string): [continueIndex: number, params: unknown[]] { + let index = input.indexOf(')', 1); + if (index === -1) { + throw new FakerError(`Missing closing parenthesis in '${input}'`); + } + + while (index !== -1) { + const params = input.substring(1, index); + try { + // assuming that the params are valid JSON + return [index, JSON.parse(`[${params}]`) as unknown[]]; + } catch { + if (!params.includes("'") && !params.includes('"')) { + try { + // assuming that the params are a single unquoted string + return [index, JSON.parse(`["${params}"]`) as unknown[]]; + } catch { + // try again with the next index + } + } + } + + index = input.indexOf(')', index + 1); + } + + index = input.lastIndexOf(')'); + const params = input.substring(1, index); + return [index, [params]]; +} + +/** + * Processes one expression part and returns the new read index and the mapped results. + * + * @param input The input string to parse. + * @param entrypoints The entrypoints to resolve on. + */ +function evalProcessExpression( + input: string, + entrypoints: ReadonlyArray<unknown> +): [continueIndex: number, mapped: unknown[]] { + const result = REGEX_DOT_OR_BRACKET.exec(input); + const dotMatch = (result?.[0] ?? '') === '.'; + const index = result?.index ?? input.length; + const key = input.substring(0, index); + if (key.length === 0) { + throw new FakerError(`Expression parts cannot be empty in '${input}'`); + } + + const next = input[index + 1]; + if (dotMatch && (next == null || next === '.' || next === '(')) { + throw new FakerError(`Found dot without property name in '${input}'`); + } + + return [ + index + (dotMatch ? 1 : 0), + entrypoints.map((entrypoint) => resolveProperty(entrypoint, key)), + ]; +} + +/** + * Resolves the given property on the given entrypoint. + * + * @param entrypoint The entrypoint to resolve the property on. + * @param key The property name to resolve. + */ +function resolveProperty(entrypoint: unknown, key: string): unknown { + switch (typeof entrypoint) { + case 'function': { + try { + entrypoint = entrypoint(); + } catch { + return undefined; + } + + return entrypoint?.[key as keyof typeof entrypoint]; + } + + case 'object': { + return entrypoint?.[key as keyof typeof entrypoint]; + } + + default: + return undefined; + } +} diff --git a/src/modules/helpers/index.ts b/src/modules/helpers/index.ts index 6e993c84..edc00ea9 100644 --- a/src/modules/helpers/index.ts +++ b/src/modules/helpers/index.ts @@ -2,6 +2,7 @@ import type { Faker, SimpleFaker } from '../..'; import { FakerError } from '../../errors/faker-error'; import { deprecated } from '../../internal/deprecated'; import { SimpleModuleBase } from '../../internal/module-base'; +import { fakeEval } from './eval'; import { luhnCheckValue } from './luhn-check'; import type { RecordKey } from './unique'; import * as uniqueExec from './unique'; @@ -1460,66 +1461,15 @@ export class HelpersModule extends SimpleHelpersModule { // extract method name from between the {{ }} that we found // for example: {{person.firstName}} const token = pattern.substring(start + 2, end + 2); - let method = token.replace('}}', '').replace('{{', ''); - - // extract method parameters - const regExp = /\(([^)]*)\)/; - const matches = regExp.exec(method); - let parameters = ''; - if (matches) { - method = method.replace(regExp, ''); - parameters = matches[1]; - } - - // split the method into module and function - const parts = method.split('.'); - - let currentModuleOrMethod: unknown = this.faker; - let currentDefinitions: unknown = this.faker.rawDefinitions; - - // Search for the requested method or definition - for (const part of parts) { - currentModuleOrMethod = - currentModuleOrMethod?.[part as keyof typeof currentModuleOrMethod]; - currentDefinitions = - currentDefinitions?.[part as keyof typeof currentDefinitions]; - } - - // Make method executable - let fn: (...args: unknown[]) => unknown; - if (typeof currentModuleOrMethod === 'function') { - fn = currentModuleOrMethod as (args?: unknown) => unknown; - } else if (Array.isArray(currentDefinitions)) { - fn = () => - this.faker.helpers.arrayElement(currentDefinitions as unknown[]); - } else { - throw new FakerError(`Invalid module method or definition: ${method} -- faker.${method} is not a function -- faker.definitions.${method} is not an array`); - } - - // assign the function from the module.function namespace - fn = fn.bind(this); - - // If parameters are populated here, they are always going to be of string type - // since we might actually be dealing with an object or array, - // we always attempt to the parse the incoming parameters into JSON - let params: unknown[]; - // Note: we experience a small performance hit here due to JSON.parse try / catch - // If anyone actually needs to optimize this specific code path, please open a support issue on github - try { - params = JSON.parse(`[${parameters}]`); - } catch { - // since JSON.parse threw an error, assume parameters was actually a string - params = [parameters]; - } + const method = token.replace('}}', '').replace('{{', ''); - const result = String(fn(...params)); + const result = fakeEval(method, this.faker); + const stringified = String(result); // Replace the found tag with the returned fake value // We cannot use string.replace here because the result might contain evaluated characters const res = - pattern.substring(0, start) + result + pattern.substring(end + 2); + pattern.substring(0, start) + stringified + pattern.substring(end + 2); // return the response recursively until we are done finding all tags return this.fake(res); |
