From 24482a30042eec5b553b30d60985e89fd69a8660 Mon Sep 17 00:00:00 2001 From: ST-DDT Date: Mon, 25 Dec 2023 01:51:33 +0100 Subject: [PATCH] feat(helpers): add support for complex intermediate types (#2550) --- src/modules/helpers/eval.ts | 229 ++++++++++++++++++ src/modules/helpers/index.ts | 60 +---- .../modules/__snapshots__/person.spec.ts.snap | 2 +- test/modules/helpers-eval.spec.ts | 162 +++++++++++++ test/modules/helpers.spec.ts | 37 +-- test/scripts/apidoc/verify-jsdoc-tags.spec.ts | 5 +- 6 files changed, 420 insertions(+), 75 deletions(-) create mode 100644 src/modules/helpers/eval.ts create mode 100644 test/modules/helpers-eval.spec.ts diff --git a/src/modules/helpers/eval.ts b/src/modules/helpers/eval.ts new file mode 100644 index 00000000000..033c5d35d80 --- /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 = [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 +): [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 +): [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 6e993c8412c..edc00ea9a01 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 6f3e0963034..a85f4ccb62e 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 00000000000..40612c37312 --- /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 1a1db9c5ae2..1b9ba3aeff5 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 76865c16e80..89afc225368 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(