aboutsummaryrefslogtreecommitdiff
path: root/src/modules
diff options
context:
space:
mode:
Diffstat (limited to 'src/modules')
-rw-r--r--src/modules/helpers/eval.ts229
-rw-r--r--src/modules/helpers/index.ts60
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);