aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorST-DDT <[email protected]>2023-12-25 01:51:33 +0100
committerGitHub <[email protected]>2023-12-25 01:51:33 +0100
commit24482a30042eec5b553b30d60985e89fd69a8660 (patch)
treefd4c2b351d03d0f06998e364352f461743f63866
parentc209030a660422a17f73d6f0b307af14507bd56d (diff)
downloadfaker-24482a30042eec5b553b30d60985e89fd69a8660.tar.xz
faker-24482a30042eec5b553b30d60985e89fd69a8660.zip
feat(helpers): add support for complex intermediate types (#2550)
-rw-r--r--src/modules/helpers/eval.ts229
-rw-r--r--src/modules/helpers/index.ts60
-rw-r--r--test/modules/__snapshots__/person.spec.ts.snap2
-rw-r--r--test/modules/helpers-eval.spec.ts162
-rw-r--r--test/modules/helpers.spec.ts37
-rw-r--r--test/scripts/apidoc/verify-jsdoc-tags.spec.ts5
6 files changed, 420 insertions, 75 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);
diff --git a/test/modules/__snapshots__/person.spec.ts.snap b/test/modules/__snapshots__/person.spec.ts.snap
index 6f3e0963..a85f4ccb 100644
--- a/test/modules/__snapshots__/person.spec.ts.snap
+++ b/test/modules/__snapshots__/person.spec.ts.snap
@@ -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"`;
diff --git a/test/modules/helpers-eval.spec.ts b/test/modules/helpers-eval.spec.ts
new file mode 100644
index 00000000..40612c37
--- /dev/null
+++ b/test/modules/helpers-eval.spec.ts
@@ -0,0 +1,162 @@
+import { describe, expect, it, vi } from 'vitest';
+import { faker, FakerError } from '../../src';
+import { fakeEval } from '../../src/modules/helpers/eval';
+
+describe('fakeEval()', () => {
+ it('does not allow empty string input', () => {
+ expect(() => fakeEval('', faker)).toThrowError(
+ new FakerError('Eval expression cannot be empty.')
+ );
+ });
+
+ it('does not allow empty entrypoints', () => {
+ expect(() => fakeEval('foobar', faker, [])).toThrowError(
+ new FakerError('Eval entrypoints cannot be empty.')
+ );
+ });
+
+ it('supports single pattern part invocations', () => {
+ const actual = fakeEval('string', faker);
+ expect(actual).toBeTypeOf('object');
+ expect(actual).toBe(faker.string);
+ });
+
+ it('supports simple method calls', () => {
+ const spy = vi.spyOn(faker.string, 'numeric');
+ const actual = fakeEval('string.numeric', faker);
+ expect(spy).toHaveBeenCalledWith();
+ expect(actual).toBeTypeOf('string');
+ expect(actual).toMatch(/^\d$/);
+ });
+
+ it('supports method calls without arguments', () => {
+ const spy = vi.spyOn(faker.string, 'numeric');
+ const actual = fakeEval('string.numeric()', faker);
+ expect(spy).toHaveBeenCalledWith();
+ expect(actual).toBeTypeOf('string');
+ expect(actual).toMatch(/^\d$/);
+ });
+
+ it('supports method calls with simple arguments', () => {
+ const spy = vi.spyOn(faker.string, 'numeric');
+ const actual = fakeEval('string.numeric(5)', faker);
+ expect(spy).toHaveBeenCalledWith(5);
+ expect(actual).toBeTypeOf('string');
+ expect(actual).toMatch(/^\d{5}$/);
+ });
+
+ it('supports method calls with complex arguments', () => {
+ const spy = vi.spyOn(faker.string, 'numeric');
+ const actual = fakeEval(
+ 'string.numeric({ "length": 5, "allowLeadingZeros": true, "exclude": ["5"] })',
+ faker
+ );
+ expect(spy).toHaveBeenCalledWith({
+ length: 5,
+ allowLeadingZeros: true,
+ exclude: ['5'],
+ });
+ expect(actual).toBeTypeOf('string');
+ expect(actual).toMatch(/^[0-46-9]{5}$/);
+ });
+
+ it('supports method calls with multiple arguments', () => {
+ const spy = vi.spyOn(faker.helpers, 'mustache');
+ const actual = fakeEval(
+ 'helpers.mustache("{{foo}}", { "foo": "bar" })',
+ faker
+ );
+ expect(spy).toHaveBeenCalledWith('{{foo}}', { foo: 'bar' });
+ expect(actual).toBeTypeOf('string');
+ expect(actual).toBe('bar');
+ });
+
+ it('supports method calls with unquoted string argument', () => {
+ const spy = vi.spyOn(faker.helpers, 'slugify');
+ const actual = fakeEval('helpers.slugify(This Works)', faker);
+ expect(spy).toHaveBeenCalledWith('This Works');
+ expect(actual).toBeTypeOf('string');
+ expect(actual).toBe('This-Works');
+ });
+
+ it('supports method calls with wrongly quoted argument', () => {
+ const spy = vi.spyOn(faker.helpers, 'slugify');
+ const actual = fakeEval("helpers.slugify('')", faker);
+ expect(spy).toHaveBeenCalledWith("''");
+ expect(actual).toBeTypeOf('string');
+ expect(actual).toBe('');
+ });
+
+ it('should be able to return empty strings', () => {
+ const actual = fakeEval('string.alphanumeric(0)', faker);
+ expect(actual).toBeTypeOf('string');
+ expect(actual).toBe('');
+ });
+
+ it('supports returning complex objects', () => {
+ const actual = fakeEval('airline.airline', faker);
+ expect(actual).toBeTypeOf('object');
+ expect(faker.definitions.airline.airline).toContain(actual);
+ });
+
+ it('supports patterns after a function call', () => {
+ const actual = fakeEval('airline.airline().name', faker);
+ expect(actual).toBeTypeOf('string');
+ expect(faker.definitions.airline.airline.map(({ name }) => name)).toContain(
+ actual
+ ); // function().name
+ });
+
+ it('supports patterns after a function reference', () => {
+ const actual = fakeEval('airline.airline.iataCode', faker);
+ expect(actual).toBeTypeOf('string');
+ expect(
+ faker.definitions.airline.airline.map(({ iataCode }) => iataCode)
+ ).toContain(actual);
+ });
+
+ it('requires a dot after a function call', () => {
+ expect(() => fakeEval('airline.airline()iataCode', faker)).toThrowError(
+ new FakerError(
+ "Expected dot ('.'), open parenthesis ('('), or nothing after function call but got 'i'"
+ )
+ );
+ });
+
+ it('requires a function for parameters', () => {
+ // TODO @ST-DDT 2023-12-11: Replace in v9
+ // expect(faker.definitions.person.first_name).toBeDefined();
+ //expect(() => fakeEval('person.first_name()', faker)).toThrow(
+ // new FakerError(`Cannot resolve expression 'person.first_name'`)
+ // );
+ const actual = fakeEval('person.first_name()', faker);
+ expect(faker.definitions.person.first_name).toContain(actual);
+ });
+
+ it('requires a valid expression (missing value)', () => {
+ expect(() => fakeEval('foo.bar', faker)).toThrow(
+ new FakerError(`Cannot resolve expression 'foo.bar'`)
+ );
+ });
+
+ it('requires a valid expression (trailing dot)', () => {
+ expect(() => fakeEval('airline.airline.', faker)).toThrowError(
+ new FakerError("Found dot without property name in 'airline.'")
+ );
+ expect(() => fakeEval('airline.airline.()', faker)).toThrowError(
+ new FakerError("Found dot without property name in 'airline.()'")
+ );
+ expect(() => fakeEval('airline.airline.().iataCode', faker)).toThrowError(
+ new FakerError("Found dot without property name in 'airline.().iataCode'")
+ );
+ });
+
+ it('requires a valid expression (unclosed parenthesis)', () => {
+ expect(() => fakeEval('airline.airline(', faker)).toThrowError(
+ new FakerError("Missing closing parenthesis in '('")
+ );
+ expect(() => fakeEval('airline.airline(.iataCode', faker)).toThrowError(
+ new FakerError("Missing closing parenthesis in '(.iataCode'")
+ );
+ });
+});
diff --git a/test/modules/helpers.spec.ts b/test/modules/helpers.spec.ts
index 1a1db9c5..1b9ba3ae 100644
--- a/test/modules/helpers.spec.ts
+++ b/test/modules/helpers.spec.ts
@@ -1027,34 +1027,35 @@ describe('helpers', () => {
it('does not allow invalid module name', () => {
expect(() => faker.helpers.fake('{{foo.bar}}')).toThrow(
- new FakerError(`Invalid module method or definition: foo.bar
-- faker.foo.bar is not a function
-- faker.definitions.foo.bar is not an array`)
+ new FakerError(`Cannot resolve expression 'foo.bar'`)
);
});
- it('does not allow missing method name', () => {
- expect(() => faker.helpers.fake('{{location}}')).toThrow(
- new FakerError(`Invalid module method or definition: location
-- faker.location is not a function
-- faker.definitions.location is not an array`)
- );
+ it('does allow missing method name', () => {
+ const actual = faker.helpers.fake('{{location}}');
+ expect(actual).toBe('[object Object]');
});
it('does not allow invalid method name', () => {
expect(() => faker.helpers.fake('{{location.foo}}')).toThrow(
- new FakerError(`Invalid module method or definition: location.foo
-- faker.location.foo is not a function
-- faker.definitions.location.foo is not an array`)
+ new FakerError(`Cannot resolve expression 'location.foo'`)
);
});
- it('does not allow invalid definitions data', () => {
- expect(() => faker.helpers.fake('{{finance.credit_card}}')).toThrow(
- new FakerError(`Invalid module method or definition: finance.credit_card
-- faker.finance.credit_card is not a function
-- faker.definitions.finance.credit_card is not an array`)
- );
+ it('should support complex data', () => {
+ const actual = faker.helpers.fake('{{science.unit}}');
+ expect(actual).toBe('[object Object]');
+ });
+
+ it('should support resolving a value in a complex object', () => {
+ const complex = faker.helpers.fake('{{airline.airline}}');
+ expect(complex).toBe('[object Object]');
+
+ const actual = faker.helpers.fake('{{airline.airline.iataCode}}');
+ expect(actual).toBeTypeOf('string');
+ expect(
+ faker.definitions.airline.airline.map(({ iataCode }) => iataCode)
+ ).toContain(actual);
});
it('should be able to return empty strings', () => {
diff --git a/test/scripts/apidoc/verify-jsdoc-tags.spec.ts b/test/scripts/apidoc/verify-jsdoc-tags.spec.ts
index 76865c16..89afc225 100644
--- a/test/scripts/apidoc/verify-jsdoc-tags.spec.ts
+++ b/test/scripts/apidoc/verify-jsdoc-tags.spec.ts
@@ -236,7 +236,10 @@ describe('verify JSDoc tags', () => {
false
);
if (paramDefault) {
- if (/^{.*}$/.test(paramDefault)) {
+ if (
+ /^{.*}$/.test(paramDefault) ||
+ paramDefault.includes('\n')
+ ) {
expect(commentDefault).toBeUndefined();
} else {
expect(