Skip to content

Commit

Permalink
feat(helpers): add support for complex intermediate types (#2550)
Browse files Browse the repository at this point in the history
  • Loading branch information
ST-DDT committed Dec 25, 2023
1 parent c209030 commit 24482a3
Show file tree
Hide file tree
Showing 6 changed files with 420 additions and 75 deletions.
229 changes: 229 additions & 0 deletions 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;
}
}
60 changes: 5 additions & 55 deletions src/modules/helpers/index.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion test/modules/__snapshots__/person.spec.ts.snap
Expand Up @@ -50,7 +50,7 @@ exports[`person > 42 > suffix > with sex 1`] = `"III"`;

exports[`person > 42 > zodiacSign 1`] = `"Gemini"`;

exports[`person > 1211 > bio 1`] = `"infrastructure supporter, photographer 🙆‍♀️"`;
exports[`person > 1211 > bio 1`] = `"teletype lover, dreamer 👄"`;

exports[`person > 1211 > firstName > noArgs 1`] = `"Tito"`;

Expand Down

0 comments on commit 24482a3

Please sign in to comment.