diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/faker-mapping-selector.tsx b/packages/compass-collection/src/components/mock-data-generator-modal/faker-mapping-selector.tsx index b6c1aa726b0..07c26457ba9 100644 --- a/packages/compass-collection/src/components/mock-data-generator-modal/faker-mapping-selector.tsx +++ b/packages/compass-collection/src/components/mock-data-generator-modal/faker-mapping-selector.tsx @@ -2,7 +2,9 @@ import { Banner, BannerVariant, Body, + Code, css, + Label, Option, palette, Select, @@ -11,6 +13,7 @@ import { import React from 'react'; import { UNRECOGNIZED_FAKER_METHOD } from '../../modules/collection-tab'; import type { MongoDBFieldType } from '@mongodb-js/compass-generative-ai'; +import type { FakerArg } from './script-generation-utils'; const fieldMappingSelectorsStyles = css({ width: '50%', @@ -24,16 +27,48 @@ const labelStyles = css({ fontWeight: 600, }); +const stringifyFakerArg = (arg: FakerArg): string => { + if (typeof arg === 'object' && arg !== null && 'json' in arg) { + try { + return JSON.stringify(JSON.parse(arg.json)); + } catch { + return ''; + } + } + + // Handle arrays recursively + if (Array.isArray(arg)) { + return `[${arg.map(stringifyFakerArg).join(', ')}]`; + } + + if (typeof arg === 'string') { + return JSON.stringify(arg); + } + + // Numbers and booleans + return String(arg); +}; + +const formatFakerFunctionCallWithArgs = ( + fakerFunction: string, + fakerArgs: FakerArg[] +) => { + const parsedFakerArgs = fakerArgs.map(stringifyFakerArg); + return `faker.${fakerFunction}(${parsedFakerArgs.join(', ')})`; +}; + interface Props { activeJsonType: string; activeFakerFunction: string; onJsonTypeSelect: (jsonType: MongoDBFieldType) => void; + activeFakerArgs: FakerArg[]; onFakerFunctionSelect: (fakerFunction: string) => void; } const FakerMappingSelector = ({ activeJsonType, activeFakerFunction, + activeFakerArgs, onJsonTypeSelect, onFakerFunctionSelect, }: Props) => { @@ -66,13 +101,29 @@ const FakerMappingSelector = ({ ))} - {activeFakerFunction === UNRECOGNIZED_FAKER_METHOD && ( + {activeFakerFunction === UNRECOGNIZED_FAKER_METHOD ? ( Please select a function or we will default fill this field with the string "Unrecognized" + ) : ( + <> + + + {formatFakerFunctionCallWithArgs( + activeFakerFunction, + activeFakerArgs + )} + + )} - {/* TODO(CLOUDP-344400): Render faker function parameters once we have a way to validate them. */} ); }; diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/faker-schema-editor-screen.tsx b/packages/compass-collection/src/components/mock-data-generator-modal/faker-schema-editor-screen.tsx index 39b6a5d7a27..55ebe80bd0b 100644 --- a/packages/compass-collection/src/components/mock-data-generator-modal/faker-schema-editor-screen.tsx +++ b/packages/compass-collection/src/components/mock-data-generator-modal/faker-schema-editor-screen.tsx @@ -64,6 +64,7 @@ const FakerSchemaEditorContent = ({ const activeJsonType = fakerSchemaFormValues[activeField]?.mongoType; const activeFakerFunction = fakerSchemaFormValues[activeField]?.fakerMethod; + const activeFakerArgs = fakerSchemaFormValues[activeField]?.fakerArgs; const resetIsSchemaConfirmed = () => { onSchemaConfirmed(false); @@ -109,6 +110,7 @@ const FakerSchemaEditorContent = ({ diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/mock-data-generator-modal.spec.tsx b/packages/compass-collection/src/components/mock-data-generator-modal/mock-data-generator-modal.spec.tsx index a03ed42f29a..43be38d08b0 100644 --- a/packages/compass-collection/src/components/mock-data-generator-modal/mock-data-generator-modal.spec.tsx +++ b/packages/compass-collection/src/components/mock-data-generator-modal/mock-data-generator-modal.spec.tsx @@ -71,7 +71,7 @@ describe('MockDataGeneratorModal', () => { const store = createStore( collectionTabReducer, - initialState, + initialState as any, applyMiddleware(thunk.withExtraArgument(mockServices)) ); @@ -545,6 +545,111 @@ describe('MockDataGeneratorModal', () => { ); }); + it('displays preview of the faker call without args when the args are invalid', async () => { + const largeLengthArgs = Array.from({ length: 11 }, () => 'testArg'); + const mockServices = createMockServices(); + mockServices.atlasAiService.getMockDataSchema = () => + Promise.resolve({ + fields: [ + { + fieldPath: 'name', + mongoType: 'String', + fakerMethod: 'person.firstName', + fakerArgs: largeLengthArgs, + isArray: false, + probability: 1.0, + }, + { + fieldPath: 'age', + mongoType: 'Int32', + fakerMethod: 'number.int', + fakerArgs: [ + { + json: JSON.stringify({ + a: largeLengthArgs, + }), + }, + ], + isArray: false, + probability: 1.0, + }, + { + fieldPath: 'username', + mongoType: 'String', + fakerMethod: 'string.alpha', + // large string + fakerArgs: ['a'.repeat(1001)], + isArray: false, + probability: 1.0, + }, + { + fieldPath: 'avatar', + mongoType: 'String', + fakerMethod: 'image.url', + fakerArgs: [ + { + json: JSON.stringify({ + width: 100_000, + height: 100_000, + }), + }, + ], + isArray: false, + probability: 1.0, + }, + ], + }); + + await renderModal({ + mockServices, + schemaAnalysis: { + ...defaultSchemaAnalysisState, + processedSchema: { + name: { + type: 'String', + probability: 1.0, + }, + age: { + type: 'Int32', + probability: 1.0, + }, + username: { + type: 'String', + probability: 1.0, + }, + avatar: { + type: 'String', + probability: 1.0, + }, + }, + }, + }); + + // advance to the schema editor step + userEvent.click(screen.getByText('Confirm')); + await waitFor(() => { + expect(screen.getByTestId('faker-schema-editor')).to.exist; + }); + + userEvent.click(screen.getByText('name')); + expect(screen.getByTestId('faker-function-call-preview')).to.exist; + expect(screen.queryByText(/testArg/)).to.not.exist; + + userEvent.click(screen.getByText('age')); + expect(screen.getByTestId('faker-function-call-preview')).to.exist; + expect(screen.queryByText(/testArg/)).to.not.exist; + + userEvent.click(screen.getByText('username')); + expect(screen.queryByText(/aaaaaaa/)).to.not.exist; + expect(screen.getByTestId('faker-function-call-preview')).to.exist; + + userEvent.click(screen.getByText('avatar')); + expect(screen.getByTestId('faker-function-call-preview')).to.exist; + expect(screen.queryByText(/width/)).to.not.exist; + expect(screen.queryByText(/height/)).to.not.exist; + expect(screen.queryByText(/100000/)).to.not.exist; + }); + it('disables the Next button when the faker schema mapping is not confirmed', async () => { await renderModal({ mockServices: mockServicesWithMockDataResponse, diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/script-generation-utils.ts b/packages/compass-collection/src/components/mock-data-generator-modal/script-generation-utils.ts index 15687a4b76d..3138663fd55 100644 --- a/packages/compass-collection/src/components/mock-data-generator-modal/script-generation-utils.ts +++ b/packages/compass-collection/src/components/mock-data-generator-modal/script-generation-utils.ts @@ -2,7 +2,12 @@ import type { MongoDBFieldType } from '@mongodb-js/compass-generative-ai'; import type { FakerFieldMapping } from './types'; import { prettify } from '@mongodb-js/compass-editor'; -export type FakerArg = string | number | boolean | { json: string }; +export type FakerArg = + | string + | number + | boolean + | { json: string } + | FakerArg[]; const DEFAULT_ARRAY_LENGTH = 3; diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/utils.spec.ts b/packages/compass-collection/src/components/mock-data-generator-modal/utils.spec.ts new file mode 100644 index 00000000000..d73e62f85b2 --- /dev/null +++ b/packages/compass-collection/src/components/mock-data-generator-modal/utils.spec.ts @@ -0,0 +1,251 @@ +import { expect } from 'chai'; +import { areFakerArgsValid, isValidFakerMethod } from './utils'; + +import Sinon from 'sinon'; +import { faker } from '@faker-js/faker/locale/en'; +import { createNoopLogger } from '@mongodb-js/compass-logging/provider'; + +describe('Mock Data Generator Utils', () => { + const sandbox = Sinon.createSandbox(); + const logger = createNoopLogger(); + + afterEach(() => { + sandbox.restore(); + }); + + describe('areFakerArgsValid', () => { + it('returns true for empty array', () => { + expect(areFakerArgsValid([])).to.be.true; + }); + + it('returns false if top-level args count exceeds max faker args count', () => { + expect(areFakerArgsValid([1, 2, 3])).to.be.false; + }); + + it('returns true for valid numbers, strings, booleans', () => { + expect(areFakerArgsValid([1, 'foo'])).to.be.true; + expect(areFakerArgsValid([true, false])).to.be.true; + }); + + it('returns false for non-finite numbers', () => { + expect(areFakerArgsValid([Infinity])).to.be.false; + expect(areFakerArgsValid([NaN])).to.be.false; + }); + + it('returns false for strings exceeding max faker string length', () => { + const longStr = 'a'.repeat(1001); + expect(areFakerArgsValid([longStr])).to.be.false; + }); + + it('returns false for numbers that are too large', () => { + expect(areFakerArgsValid([10001])).to.be.false; + expect(areFakerArgsValid([-10001])).to.be.false; + expect(areFakerArgsValid([{ json: { length: 10001 } } as any])).to.be + .false; + expect(areFakerArgsValid([{ json: { length: -10001 } } as any])).to.be + .false; + expect( + areFakerArgsValid([{ json: { width: 10001, height: 10001 } } as any]) + ).to.be.false; + }); + + it('returns true for nested valid arrays', () => { + expect( + areFakerArgsValid([ + [1, 'foo', true], + [2, false], + ]) + ).to.be.true; + }); + + it('returns false for nested arrays exceeding max array length', () => { + const nested = [Array(11).fill(1)]; // nested array has 11 elements > MAX_ARRAY_LENGTH (10) + expect(areFakerArgsValid(nested)).to.be.false; + }); + + it('returns true for exactly 2 top-level arguments', () => { + expect(areFakerArgsValid(['arg1', 'arg2'])).to.be.true; + }); + + it('returns true for nested arrays within the limit', () => { + const nested = [Array(10).fill(1)]; + expect(areFakerArgsValid(nested)).to.be.true; + }); + + it('returns true for valid object with json property', () => { + const obj = { json: JSON.stringify({ a: 1, b: 'foo', c: true }) }; + expect(areFakerArgsValid([obj])).to.be.true; + }); + + it('returns false for object with invalid json property', () => { + const obj = { json: '{invalid json}' }; + expect(areFakerArgsValid([obj])).to.be.false; + }); + + it('returns false for object with json property containing invalid values', () => { + const obj = { json: JSON.stringify({ a: Infinity }) }; + expect(areFakerArgsValid([obj])).to.be.false; + }); + + it('returns false for unrecognized argument types', () => { + expect(areFakerArgsValid([undefined as any])).to.be.false; + expect(areFakerArgsValid([null as any])).to.be.false; + expect(areFakerArgsValid([(() => {}) as any])).to.be.false; + }); + + it('returns true for deeply nested valid structures', () => { + const obj = { + json: JSON.stringify({ + a: [1, { json: JSON.stringify({ b: 'foo' }) }], + }), + }; + expect(areFakerArgsValid([obj])).to.be.true; + }); + + it('returns false for deeply nested invalid structures', () => { + const obj = { + json: JSON.stringify({ + a: [1, { json: JSON.stringify({ b: Infinity }) }], + }), + }; + expect(areFakerArgsValid([obj])).to.be.false; + }); + + it('returns false for deeply nested invalid structures with max depth', () => { + const obj = { + json: JSON.stringify({ + a: [ + 1, + { + json: JSON.stringify({ + b: 2, + c: { + json: JSON.stringify({ + d: 3, + }), + }, + }), + }, + ], + }), + }; + expect(areFakerArgsValid([obj])).to.be.false; + }); + }); + + describe('isValidFakerMethod', () => { + it('returns false for invalid method format', () => { + sandbox.stub(faker.internet, 'email'); + + expect(isValidFakerMethod('invalidMethod', [], logger)).to.deep.equal({ + isValid: false, + fakerArgs: [], + }); + expect( + isValidFakerMethod('internet.email.extra', [], logger) + ).to.deep.equal({ + isValid: false, + fakerArgs: [], + }); + }); + + it('returns false for non-existent faker module', () => { + expect(isValidFakerMethod('notamodule.email', [], logger)).to.deep.equal({ + isValid: false, + fakerArgs: [], + }); + }); + + it('returns false for non-existent faker method', () => { + expect( + isValidFakerMethod('internet.notamethod', [], logger) + ).to.deep.equal({ + isValid: false, + fakerArgs: [], + }); + }); + + it('returns true for valid method without arguments', () => { + sandbox.stub(faker.internet, 'email').returns('test@test.com'); + + const result = isValidFakerMethod('internet.email', [], logger); + expect(result.isValid).to.be.true; + expect(result.fakerArgs).to.deep.equal([]); + }); + + it('returns true for valid method with valid arguments', () => { + sandbox.stub(faker.person, 'firstName'); + + const result = isValidFakerMethod('person.firstName', ['female'], logger); + expect(result.isValid).to.be.true; + expect(result.fakerArgs).to.deep.equal(['female']); + }); + + it('returns true for valid method with no args if args are invalid but fallback works', () => { + sandbox.stub(faker.internet, 'email').returns('test@test.com'); + + const result = isValidFakerMethod('internet.email', [], logger); + expect(result.isValid).to.be.true; + expect(result.fakerArgs).to.deep.equal([]); + }); + + it('returns true valid method with invalid arguments and strips args', () => { + sandbox.stub(faker.date, 'month').returns('February'); + + const result = isValidFakerMethod( + 'date.month', + [{ foo: 'bar' } as any], + logger + ); + expect(result.isValid).to.be.true; + expect(result.fakerArgs).to.deep.equal([]); + }); + + it('returns false for helpers methods except arrayElement', () => { + expect(isValidFakerMethod('helpers.fake', [], logger)).to.deep.equal({ + isValid: false, + fakerArgs: [], + }); + expect(isValidFakerMethod('helpers.slugify', [], logger)).to.deep.equal({ + isValid: false, + fakerArgs: [], + }); + }); + + it('returns true for helpers.arrayElement with valid arguments', () => { + sandbox.stub(faker.helpers, 'arrayElement').returns('a'); + + const arr = ['a', 'b', 'c']; + const result = isValidFakerMethod('helpers.arrayElement', [arr], logger); + expect(result.isValid).to.be.true; + expect(result.fakerArgs).to.deep.equal([arr]); + }); + + it('returns false for helpers.arrayElement with invalid arguments', () => { + // Exceeding max args length + const arr = Array(11).fill('x'); + const result = isValidFakerMethod('helpers.arrayElement', [arr], logger); + expect(result.isValid).to.be.false; + expect(result.fakerArgs).to.deep.equal([]); + }); + + it('returns true for valid method with invalid fakerArgs and strips args', () => { + sandbox.stub(faker.person, 'firstName').returns('a'); + + // Passing Infinity as argument + const result = isValidFakerMethod('person.firstName', [Infinity], logger); + expect(result.isValid).to.be.true; + expect(result.fakerArgs).to.deep.equal([]); + }); + + it('returns false when calling a faker method fails', () => { + sandbox + .stub(faker.person, 'firstName') + .throws(new Error('Invalid faker method')); + + const result = isValidFakerMethod('person.firstName', [], logger); + expect(result.isValid).to.be.false; + expect(result.fakerArgs).to.deep.equal([]); + }); + }); +}); diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/utils.ts b/packages/compass-collection/src/components/mock-data-generator-modal/utils.ts new file mode 100644 index 00000000000..7349f260804 --- /dev/null +++ b/packages/compass-collection/src/components/mock-data-generator-modal/utils.ts @@ -0,0 +1,198 @@ +import { type Logger, mongoLogId } from '@mongodb-js/compass-logging/provider'; +import type { FakerArg } from './script-generation-utils'; +import { faker } from '@faker-js/faker/locale/en'; + +const MAX_FAKER_ARGS_COUNT = 2; +const MAX_ARRAY_LENGTH = 10; +const MAX_FAKER_STRING_LENGTH = 1000; +const MAX_FAKER_ARGS_DEPTH = 3; +const MAX_FAKER_NUMBER_SIZE = 10000; + +/** + * Checks if the provided faker arguments are valid. + * - Numbers must be finite + * - Strings must not exceed max length + * - Arrays must not exceed max length and all elements must be valid + * - Objects must have a 'json' property that is a valid JSON string and all values must be valid + */ +export function areFakerArgsValid( + fakerArgs: FakerArg[], + depth: number = 0 +): boolean { + if (depth > MAX_FAKER_ARGS_DEPTH) { + return false; + } + if (fakerArgs.length === 0) { + return true; + } + // Check top-level argument count (max 2 for faker functions) + if (depth === 0 && fakerArgs.length > MAX_FAKER_ARGS_COUNT) { + return false; + } + // Check array length for nested arrays + if (depth > 0 && fakerArgs.length > MAX_ARRAY_LENGTH) { + return false; + } + for (const arg of fakerArgs) { + if (arg === null || arg === undefined) { + return false; + } else if (typeof arg === 'boolean') { + // booleans are always valid, continue + continue; + } else if (typeof arg === 'number') { + if (!Number.isFinite(arg) || Math.abs(arg) > MAX_FAKER_NUMBER_SIZE) { + return false; + } + } else if (typeof arg === 'string') { + if (arg.length > MAX_FAKER_STRING_LENGTH) { + return false; + } + } else if (Array.isArray(arg)) { + if (!areFakerArgsValid(arg, depth + 1)) { + return false; + } + } else if ( + typeof arg === 'object' && + arg !== null && + 'json' in arg && + typeof arg.json === 'string' + ) { + try { + const parsedJson = JSON.parse(arg.json); + if (!areFakerArgsValid(Object.values(parsedJson), depth + 1)) { + return false; + } + } catch { + return false; + } + } else { + // Unrecognized argument type + return false; + } + } + return true; +} + +/** + * Checks if the method exists and is callable on the faker object. + * + * Note: Only supports the format `module.method` (e.g., `internet.email`). + * - Nested modules are not supported. + * - Most methods in the `helpers` module are not supported due to their complex argument requirements. @see {@link https://fakerjs.dev/api/helpers.html} + * - If the method call with provided args fails, it retries without args before marking as invalid. + * + * @see {@link https://fakerjs.dev/api/} + */ +export function isValidFakerMethod( + fakerMethod: string, + fakerArgs: FakerArg[], + logger: Logger +): { + isValid: boolean; + fakerArgs: FakerArg[]; +} { + const moduleAndMethod = getFakerModuleAndMethod(fakerMethod); + if (!moduleAndMethod) { + return { isValid: false, fakerArgs: [] }; + } + const { moduleName, methodName, fakerModule } = moduleAndMethod; + + if ( + isAllowedFakerFn(moduleName, methodName) && + canInvokeFakerMethod(fakerModule, methodName) + ) { + const callableFakerMethod = ( + fakerModule as Record any> + )[methodName]; + return tryInvokeFakerMethod( + callableFakerMethod, + fakerMethod, + fakerArgs, + logger + ); + } else { + return { isValid: false, fakerArgs: [] }; + } +} + +function getFakerModuleAndMethod(method: string) { + const parts = method.split('.'); + if (parts.length !== 2) { + return null; + } + const [moduleName, methodName] = parts; + const fakerModule = (faker as unknown as Record)[moduleName]; + return { moduleName, methodName, fakerModule }; +} + +function isAllowedFakerFn(moduleName: string, methodName: string) { + if (moduleName !== 'helpers') { + // Non-helper modules are allowed + return true; + } else { + // If helpers module, only array helpers are allowed + return methodName === 'arrayElement' || methodName === 'arrayElements'; + } +} + +function canInvokeFakerMethod(fakerModule: unknown, methodName: string) { + return ( + fakerModule !== null && + fakerModule !== undefined && + typeof fakerModule === 'object' && + typeof (fakerModule as Record)[methodName] === 'function' + ); +} + +function tryInvokeFakerMethod( + callable: (...args: readonly unknown[]) => unknown, + fakerMethod: string, + args: FakerArg[], + logger: Logger +) { + // If args are present and safe, try calling with args + if (args.length > 0 && areFakerArgsValid(args)) { + try { + const usableArgs = prepareFakerArgs(args); + callable(...usableArgs); + return { isValid: true, fakerArgs: args }; + } catch { + // Call with args failed. Fall through to trying without args + } + } + + // Try without args (either because args were invalid or args failed) + try { + callable(); + return { isValid: true, fakerArgs: [] }; + } catch (error) { + // Calling the method without arguments failed. + logger.log.warn( + mongoLogId(1_001_000_377), + 'Collection', + 'Invalid faker method', + { error, fakerMethod, fakerArgs: args } + ); + return { isValid: false, fakerArgs: [] }; + } +} + +/** + * Prepares the faker args to ensure we can call the method with the args. + * Objects with a 'json' property are parsed into a JSON object. + * @example + * [ + * { json: '{"a": 1}' }, + * { json: '{"b": 2}' }, + * ] + * becomes + * [ { a: 1 }, { b: 2 } ] + */ +function prepareFakerArgs(args: FakerArg[]) { + return args.map((arg) => { + if (typeof arg === 'object' && arg !== null && 'json' in arg) { + return JSON.parse(arg.json); + } + return arg; + }); +} diff --git a/packages/compass-collection/src/modules/collection-tab.ts b/packages/compass-collection/src/modules/collection-tab.ts index 2433de40329..a1bfe9866e5 100644 --- a/packages/compass-collection/src/modules/collection-tab.ts +++ b/packages/compass-collection/src/modules/collection-tab.ts @@ -42,7 +42,7 @@ import type { MockDataGeneratorState, } from '../components/mock-data-generator-modal/types'; -import { faker } from '@faker-js/faker/locale/en'; +import { isValidFakerMethod } from '../components/mock-data-generator-modal/utils'; const DEFAULT_SAMPLE_SIZE = 100; @@ -729,38 +729,6 @@ function transformFakerSchemaToObject( return result; } -/** - * Checks if the method exists and is callable on the faker object. - * - * Note: Only supports the format `module.method` (e.g., `internet.email`). - * Nested modules or other formats are not supported. - * @see {@link https://fakerjs.dev/api/} - */ -function isValidFakerMethod(fakerMethod: string): boolean { - const parts = fakerMethod.split('.'); - - // Validate format: exactly module.method - if (parts.length !== 2) { - return false; - } - - const [moduleName, methodName] = parts; - - try { - const fakerModule = (faker as unknown as Record)[ - moduleName - ]; - return ( - fakerModule !== null && - fakerModule !== undefined && - typeof fakerModule === 'object' && - typeof (fakerModule as Record)[methodName] === 'function' - ); - } catch { - return false; - } -} - /** * Validates a given faker schema against an input schema. * @@ -788,8 +756,16 @@ const validateFakerSchema = ( probability: inputSchema[fieldPath].probability, }; // Validate the faker method - if (isValidFakerMethod(fakerMapping.fakerMethod)) { - result[fieldPath] = fakerMapping; + const { isValid: isValidMethod, fakerArgs } = isValidFakerMethod( + fakerMapping.fakerMethod, + fakerMapping.fakerArgs, + logger + ); + if (isValidMethod) { + result[fieldPath] = { + ...fakerMapping, + fakerArgs, + }; } else { logger.log.warn( mongoLogId(1_001_000_372),