diff --git a/package-lock.json b/package-lock.json index 71a96cf34f9..7c03529e957 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6031,6 +6031,22 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@faker-js/faker": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.0.0.tgz", + "integrity": "sha512-UollFEUkVXutsaP+Vndjxar40Gs5JL2HeLcl8xO1QAjJgOdhc3OmBFWyEylS+RddWaaBiAzH+5/17PLQJwDiLw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", + "npm": ">=10" + } + }, "node_modules/@fastify/busboy": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", @@ -47717,6 +47733,7 @@ "version": "4.72.0", "license": "SSPL", "dependencies": { + "@faker-js/faker": "^10.0.0", "@mongodb-js/compass-app-registry": "^9.4.22", "@mongodb-js/compass-app-stores": "^7.59.0", "@mongodb-js/compass-components": "^1.51.0", @@ -57964,6 +57981,11 @@ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==" }, + "@faker-js/faker": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.0.0.tgz", + "integrity": "sha512-UollFEUkVXutsaP+Vndjxar40Gs5JL2HeLcl8xO1QAjJgOdhc3OmBFWyEylS+RddWaaBiAzH+5/17PLQJwDiLw==" + }, "@fastify/busboy": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", @@ -61212,6 +61234,7 @@ "@mongodb-js/compass-collection": { "version": "file:packages/compass-collection", "requires": { + "@faker-js/faker": "^10.0.0", "@mongodb-js/compass-app-registry": "^9.4.22", "@mongodb-js/compass-app-stores": "^7.59.0", "@mongodb-js/compass-components": "^1.51.0", diff --git a/packages/compass-collection/package.json b/packages/compass-collection/package.json index 2b85d985f9d..006df76991e 100644 --- a/packages/compass-collection/package.json +++ b/packages/compass-collection/package.json @@ -48,6 +48,7 @@ "reformat": "npm run eslint . -- --fix && npm run prettier -- --write ." }, "dependencies": { + "@faker-js/faker": "^10.0.0", "@mongodb-js/compass-app-registry": "^9.4.22", "@mongodb-js/compass-app-stores": "^7.59.0", "@mongodb-js/compass-components": "^1.51.0", diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/faker-argument-validator.spec.ts b/packages/compass-collection/src/components/mock-data-generator-modal/faker-argument-validator.spec.ts new file mode 100644 index 00000000000..87b6ba9d4f4 --- /dev/null +++ b/packages/compass-collection/src/components/mock-data-generator-modal/faker-argument-validator.spec.ts @@ -0,0 +1,205 @@ +import { expect } from 'chai'; +import { + validateFakerArguments, + validateFakerSchemaMapping, + validateFakerSchemaMappings, +} from './faker-argument-validator'; + +describe('FakerArgumentValidator', () => { + describe('validateFakerArguments', () => { + it('validates valid arguments for string methods', () => { + const result = validateFakerArguments('string.alpha', [10]); + expect(result.isValid).to.be.true; + expect(result.error).to.be.undefined; + expect(result.sanitizedArgs).to.deep.equal([10]); + }); + + it('validates valid arguments for number methods', () => { + const result = validateFakerArguments('number.int', [ + { min: 1, max: 100 }, + ]); + expect(result.isValid).to.be.true; + expect(result.error).to.be.undefined; + }); + + it('validates valid arguments for date methods', () => { + const result = validateFakerArguments('date.between', [ + { from: '2020-01-01', to: '2025-01-01' }, + ]); + expect(result.isValid).to.be.true; + expect(result.error).to.be.undefined; + }); + + it('rejects invalid arguments that cause faker errors', () => { + const result = validateFakerArguments('string.alpha', [ + Number.MAX_SAFE_INTEGER, + ]); + expect(result.isValid).to.be.false; + expect(result.error).to.include('Invalid array length'); + }); + + it('rejects invalid date ranges', () => { + const result = validateFakerArguments('date.between', [ + { from: '2025-01-01', to: '2020-01-01' }, + ]); + expect(result.isValid).to.be.false; + expect(result.error).to.include('from` date must be before `to` date'); + }); + + it('rejects invalid number ranges', () => { + const result = validateFakerArguments('number.int', [ + { min: 10, max: 5 }, + ]); + expect(result.isValid).to.be.false; + expect(result.error).to.include('Max 5 should be greater than min 10'); + }); + + it('handles non-existent faker methods', () => { + const result = validateFakerArguments('nonexistent.method', []); + expect(result.isValid).to.be.false; + expect(result.error).to.include('does not exist'); + }); + + it('handles invalid method format', () => { + const result = validateFakerArguments('invalidformat', []); + expect(result.isValid).to.be.false; + expect(result.error).to.include('Invalid faker method format'); + }); + + it('validates methods with no arguments', () => { + const result = validateFakerArguments('person.firstName', []); + expect(result.isValid).to.be.true; + expect(result.error).to.be.undefined; + }); + + it('validates complex object arguments', () => { + const result = validateFakerArguments('location.nearbyGPSCoordinate', [ + { origin: [40.7128, -74.006], radius: 1000 }, + ]); + expect(result.isValid).to.be.true; + expect(result.error).to.be.undefined; + }); + + it('handles type errors gracefully', () => { + const result = validateFakerArguments('string.alpha', [null]); + expect(result.isValid).to.be.false; + expect(result.error).to.include('Cannot read properties of null'); + }); + + it('respects custom timeout settings', () => { + const result = validateFakerArguments('person.firstName', [], { + timeoutMs: 50, + }); + expect(result.isValid).to.be.true; + expect(result.executionTimeMs).to.be.a('number'); + expect(result.executionTimeMs).to.be.lessThan(50); + }); + + it('includes execution time in results', () => { + const result = validateFakerArguments('string.alpha', [10]); + expect(result.executionTimeMs).to.be.a('number'); + expect(result.executionTimeMs).to.be.greaterThanOrEqual(0); + }); + + it('logs performance when requested', () => { + const logs: string[] = []; + const mockLogger = { debug: (msg: string) => logs.push(msg) }; + + const result = validateFakerArguments('person.firstName', [], { + logPerformance: true, + logger: mockLogger, + }); + + expect(result.isValid).to.be.true; + expect(logs).to.have.length(1); + expect(logs[0]).to.include('Faker validation: person.firstName took'); + }); + }); + + describe('validateFakerSchemaMapping', () => { + it('validates a complete mapping with valid arguments', () => { + const mapping = { + fieldPath: 'name', + fakerMethod: 'person.firstName', + fakerArgs: [], + mongoType: 'string', + isArray: false, + probability: 1.0, + }; + + const result = validateFakerSchemaMapping(mapping); + expect(result.validationError).to.be.undefined; + expect(result.fakerMethod).to.equal('person.firstName'); + }); + + it('handles mappings with invalid arguments', () => { + const mapping = { + fieldPath: 'age', + fakerMethod: 'number.int', + fakerArgs: [{ min: 10, max: 5 }], // Invalid range + mongoType: 'number', + isArray: false, + probability: 1.0, + }; + + const result = validateFakerSchemaMapping(mapping); + expect(result.validationError).to.include( + 'Max 5 should be greater than min 10' + ); + expect(result.fakerArgs).to.deep.equal([]); // Cleared invalid args + }); + + it('skips validation for unrecognized methods', () => { + const mapping = { + fieldPath: 'unknown', + fakerMethod: 'Unrecognized', + fakerArgs: ['any', 'args'], + mongoType: 'string', + isArray: false, + probability: 1.0, + }; + + const result = validateFakerSchemaMapping(mapping); + expect(result.validationError).to.be.undefined; + expect(result.fakerArgs).to.deep.equal(['any', 'args']); // Preserved + }); + }); + + describe('validateFakerSchemaMappings', () => { + it('validates multiple mappings', () => { + const mappings = [ + { + fieldPath: 'name', + fakerMethod: 'person.firstName', + fakerArgs: [], + mongoType: 'string', + isArray: false, + probability: 1.0, + }, + { + fieldPath: 'age', + fakerMethod: 'number.int', + fakerArgs: [{ min: 1, max: 100 }], + mongoType: 'number', + isArray: false, + probability: 1.0, + }, + { + fieldPath: 'invalid', + fakerMethod: 'string.alpha', + fakerArgs: [Number.MAX_SAFE_INTEGER], + mongoType: 'string', + isArray: false, + probability: 1.0, + }, + ]; + + const results = validateFakerSchemaMappings(mappings); + + expect(results).to.have.length(3); + expect(results[0].validationError).to.be.undefined; + expect(results[1].validationError).to.be.undefined; + expect(results[2].validationError).to.include('Invalid array length'); + }); + }); +}); diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/faker-argument-validator.ts b/packages/compass-collection/src/components/mock-data-generator-modal/faker-argument-validator.ts new file mode 100644 index 00000000000..787bb19a912 --- /dev/null +++ b/packages/compass-collection/src/components/mock-data-generator-modal/faker-argument-validator.ts @@ -0,0 +1,185 @@ +// @ts-expect-error TypeScript warns us about importing ESM module from CommonJS module, but we can ignore since this code will be consumed by webpack. +import { faker } from '@faker-js/faker/locale/en'; + +export interface FakerArgumentValidationResult { + isValid: boolean; + error?: string; + sanitizedArgs?: unknown[]; + executionTimeMs?: number; +} + +export interface FakerValidationOptions { + timeoutMs?: number; + logPerformance?: boolean; + logger?: { debug: (message: string) => void }; +} + +/** + * Validates faker.js method arguments by attempting to execute the method. + * This is safe because we've already validated that the faker method exists. + * + * @param fakerMethod - The faker method (e.g., "string.alpha", "number.int") + * @param args - The arguments to validate + * @param options - Validation options including timeout and performance logging + * @returns Validation result with success/error information + */ +export function validateFakerArguments( + fakerMethod: string, + args: unknown[], + options: FakerValidationOptions = {} +): FakerArgumentValidationResult { + try { + // Parse the faker method path + const methodParts = fakerMethod.split('.'); + if (methodParts.length !== 2) { + return { + isValid: false, + error: 'Invalid faker method format. Expected "category.method"', + }; + } + + const [category, method] = methodParts; + + // Get the faker function - we know faker exists and has these properties + const fakerCategory = ( + faker as unknown as Record> + )[category]; + if (!fakerCategory || typeof fakerCategory[method] !== 'function') { + return { + isValid: false, + error: `Faker method ${fakerMethod} does not exist`, + }; + } + + const fakerFunction = fakerCategory[method] as ( + ...args: unknown[] + ) => unknown; + + // Attempt to execute the function with the provided arguments + // Use configurable timeout - default 100ms is sufficient for most faker operations + const configuredTimeoutMs = options.timeoutMs ?? 100; + const startTime = Date.now(); + + try { + // Execute the faker function - we don't need the result, just want to validate args + fakerFunction.apply(fakerCategory, args); + + // Check execution time and log if requested + const executionTime = Date.now() - startTime; + + if (options.logPerformance && options.logger) { + options.logger.debug( + `Faker validation: ${fakerMethod} took ${executionTime}ms` + ); + } + + if (executionTime > configuredTimeoutMs) { + return { + isValid: false, + error: `Faker method execution took too long (${executionTime}ms). Arguments may be too large.`, + executionTimeMs: executionTime, + }; + } + + // Validation successful + return { + isValid: true, + sanitizedArgs: args, + executionTimeMs: executionTime, + }; + } catch (executionError) { + // Faker.js threw an error - this means invalid arguments + const errorMessage = + executionError instanceof Error + ? executionError.message + : 'Unknown execution error'; + + return { + isValid: false, + error: `Invalid arguments for ${fakerMethod}: ${errorMessage}`, + executionTimeMs: Date.now() - startTime, + }; + } + } catch (error) { + // Unexpected error during validation + const errorMessage = + error instanceof Error ? error.message : 'Unknown validation error'; + return { + isValid: false, + error: `Validation error: ${errorMessage}`, + }; + } +} + +/** + * Validates and sanitizes a complete faker schema mapping. + * + * @param mapping - The faker schema mapping to validate + * @returns Updated mapping with validated arguments or error information + */ +export function validateFakerSchemaMapping(mapping: { + fieldPath: string; + fakerMethod: string; + fakerArgs: unknown[]; + mongoType: string; + isArray: boolean; + probability: number; +}): { + fieldPath: string; + fakerMethod: string; + fakerArgs: unknown[]; + mongoType: string; + isArray: boolean; + probability: number; + validationError?: string; +} { + // Skip validation for unrecognized methods + if (mapping.fakerMethod === 'Unrecognized') { + return mapping; + } + + const validation = validateFakerArguments( + mapping.fakerMethod, + mapping.fakerArgs + ); + + if (!validation.isValid) { + return { + ...mapping, + fakerArgs: [], // Clear invalid arguments + validationError: validation.error, + }; + } + + return { + ...mapping, + fakerArgs: validation.sanitizedArgs || mapping.fakerArgs, + }; +} + +/** + * Batch validates multiple faker schema mappings. + * + * @param mappings - Array of faker schema mappings to validate + * @returns Array of validated mappings with any validation errors noted + */ +export function validateFakerSchemaMappings( + mappings: Array<{ + fieldPath: string; + fakerMethod: string; + fakerArgs: unknown[]; + mongoType: string; + isArray: boolean; + probability: number; + }> +): Array<{ + fieldPath: string; + fakerMethod: string; + fakerArgs: unknown[]; + mongoType: string; + isArray: boolean; + probability: number; + validationError?: string; +}> { + return mappings.map(validateFakerSchemaMapping); +} 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 new file mode 100644 index 00000000000..083891701b3 --- /dev/null +++ b/packages/compass-collection/src/components/mock-data-generator-modal/faker-mapping-selector.tsx @@ -0,0 +1,76 @@ +import { + Banner, + BannerVariant, + Body, + css, + Option, + palette, + Select, + spacing, +} from '@mongodb-js/compass-components'; +import React from 'react'; +import { UNRECOGNIZED_FAKER_METHOD } from '../../modules/collection-tab'; + +const fieldMappingSelectorsStyles = css({ + width: '50%', + display: 'flex', + flexDirection: 'column', + gap: spacing[200], +}); + +const labelStyles = css({ + color: palette.gray.dark1, + fontWeight: 600, +}); + +interface Props { + activeJsonType: string; + activeFakerFunction: string; + onJsonTypeSelect: (jsonType: string) => void; + onFakerFunctionSelect: (fakerFunction: string) => void; +} + +const FakerMappingSelector = ({ + activeJsonType, + activeFakerFunction, + onJsonTypeSelect, + onFakerFunctionSelect, +}: Props) => { + return ( +
+ Mapping + + + {activeFakerFunction === UNRECOGNIZED_FAKER_METHOD && ( + + Please select a function or we will default fill this field with the + string "Unrecognized" + + )} + + {/* TODO: CLOUDP-344400: Render faker function parameters once we have a way to validate them. */} +
+ ); +}; + +export default FakerMappingSelector; diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/faker-schema-editor.tsx b/packages/compass-collection/src/components/mock-data-generator-modal/faker-schema-editor.tsx index f95105066d8..02c3d140194 100644 --- a/packages/compass-collection/src/components/mock-data-generator-modal/faker-schema-editor.tsx +++ b/packages/compass-collection/src/components/mock-data-generator-modal/faker-schema-editor.tsx @@ -1,10 +1,136 @@ +import { + Body, + Button, + ButtonSize, + ButtonVariant, + css, + Link, + palette, + spacing, +} from '@mongodb-js/compass-components'; import React from 'react'; +import FieldSelector from './schema-field-selector'; +import FakerMappingSelector from './faker-mapping-selector'; +import type { FakerSchemaMapping } from './types'; + +const containerStyles = css({ + display: 'flex', + flexDirection: 'column', + gap: spacing[400], +}); + +const innerEditorStyles = css({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', +}); + +const titleStyles = css({ + color: palette.black, + fontWeight: 600, + fontSize: '16px', + lineHeight: '20px', + marginBottom: 0, +}); + +const bodyStyles = css({ + color: palette.gray.dark1, +}); + +const confirmMappingsButtonStyles = css({ + width: '200px', +}); + +const FakerSchemaEditor = ({ + onSchemaConfirmed, + fakerMappings, +}: { + isSchemaConfirmed: boolean; + onSchemaConfirmed: () => void; + fakerMappings: Array; +}) => { + const [fakerSchemaFormValues, setFakerSchemaFormValues] = + React.useState>(fakerMappings); + const [activeField, setActiveField] = React.useState( + fakerSchemaFormValues[0].fieldPath + ); + + const activeJsonType = fakerSchemaFormValues.find( + (mapping) => mapping.fieldPath === activeField + )?.mongoType; + const activeFakerFunction = fakerSchemaFormValues.find( + (mapping) => mapping.fieldPath === activeField + )?.fakerMethod; + + const onJsonTypeSelect = (newJsonType: string) => { + const updatedFakerFieldMapping = fakerSchemaFormValues.find( + (mapping) => mapping.fieldPath === activeField + ); + if (updatedFakerFieldMapping) { + updatedFakerFieldMapping.mongoType = newJsonType; + setFakerSchemaFormValues( + fakerSchemaFormValues.map((mapping) => + mapping.fieldPath === activeField ? updatedFakerFieldMapping : mapping + ) + ); + } + }; + + const onFakerFunctionSelect = (newFakerFunction: string) => { + const updatedFakerFieldMapping = fakerSchemaFormValues.find( + (mapping) => mapping.fieldPath === activeField + ); + if (updatedFakerFieldMapping) { + updatedFakerFieldMapping.fakerMethod = newFakerFunction; + setFakerSchemaFormValues( + fakerSchemaFormValues.map((mapping) => + mapping.fieldPath === activeField ? updatedFakerFieldMapping : mapping + ) + ); + } + }; + + const onConfirmMappings = () => { + onSchemaConfirmed(); + }; -// TODO: More to come from CLOUDP-333853, CLOUDP-333854 -const FakerSchemaEditor = () => { return ( -
- Schema Editor Content Placeholder +
+
+

+ Confirm Field to Faker Function Mappings +

+ + We have sampled your collection and created a schema based on your + documents. That schema has been sent to an LLM and it has returned the + following mapping between your schema fields and{' '} + faker functions + . + +
+
+ mapping.fieldPath)} + onFieldSelect={setActiveField} + /> + {activeJsonType && activeFakerFunction && ( + + )} +
+
); }; diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/faker-validation-demo.ts b/packages/compass-collection/src/components/mock-data-generator-modal/faker-validation-demo.ts new file mode 100644 index 00000000000..4695f03fdca --- /dev/null +++ b/packages/compass-collection/src/components/mock-data-generator-modal/faker-validation-demo.ts @@ -0,0 +1,151 @@ +/** + * Demonstration script showing faker argument validation in action. + * This file is for development/testing purposes only. + */ + +import { validateFakerArguments } from './faker-argument-validator'; + +export function runFakerValidationDemo() { + console.log('=== Faker Argument Validation Demo ===\n'); + + const testCases = [ + // Valid cases + { + method: 'person.firstName', + args: [], + description: 'Simple method with no args', + }, + { + method: 'string.alpha', + args: [10], + description: 'String generation with valid length', + }, + { + method: 'number.int', + args: [{ min: 1, max: 100 }], + description: 'Number with valid range', + }, + { + method: 'date.between', + args: [{ from: '2020-01-01', to: '2025-01-01' }], + description: 'Date with valid range', + }, + + // Invalid cases that should be caught + { + method: 'string.alpha', + args: [Number.MAX_SAFE_INTEGER], + description: 'String with excessive length', + }, + { + method: 'number.int', + args: [{ min: 10, max: 5 }], + description: 'Number with invalid range (min > max)', + }, + { + method: 'date.between', + args: [{ from: '2025-01-01', to: '2020-01-01' }], + description: 'Date with invalid range', + }, + { + method: 'string.alpha', + args: [null], + description: 'String with null argument', + }, + { + method: 'nonexistent.method', + args: [], + description: 'Non-existent faker method', + }, + + // Edge cases + { + method: 'string.alpha', + args: [0], + description: 'String with zero length', + }, + { + method: 'string.alpha', + args: [-1], + description: 'String with negative length', + }, + { + method: 'helpers.arrayElements', + args: [['a', 'b', 'c'], 2], + description: 'Array selection with valid count', + }, + ]; + + const mockLogger = { + debug: (msg: string) => console.log(`[DEBUG] ${msg}`), + }; + + testCases.forEach((testCase, index) => { + console.log(`\n${index + 1}. ${testCase.description}`); + console.log(` Method: ${testCase.method}`); + console.log(` Args: ${JSON.stringify(testCase.args)}`); + + const result = validateFakerArguments(testCase.method, testCase.args, { + timeoutMs: 100, + logPerformance: true, + logger: mockLogger, + }); + + if (result.isValid) { + console.log(` ✅ VALID (${result.executionTimeMs}ms)`); + } else { + console.log( + ` ❌ INVALID: ${result.error} (${result.executionTimeMs}ms)` + ); + } + }); + + console.log('\n=== Performance Comparison ===\n'); + + // Test performance with different argument sizes + const performanceTests = [ + { method: 'string.alpha', args: [10], label: 'Small string (10 chars)' }, + { method: 'string.alpha', args: [1000], label: 'Medium string (1K chars)' }, + { + method: 'string.alpha', + args: [10000], + label: 'Large string (10K chars)', + }, + { + method: 'helpers.arrayElements', + args: [Array.from({ length: 100 }, (_, i) => i), 50], + label: 'Array selection (100 items)', + }, + { + method: 'date.betweens', + args: [{ from: '2020-01-01', to: '2025-01-01', count: 100 }], + label: 'Multiple dates (100 dates)', + }, + ]; + + performanceTests.forEach((test) => { + const iterations = 10; + const times: number[] = []; + + for (let i = 0; i < iterations; i++) { + const result = validateFakerArguments(test.method, test.args); + if (result.isValid && result.executionTimeMs !== undefined) { + times.push(result.executionTimeMs); + } + } + + if (times.length > 0) { + const avg = times.reduce((a, b) => a + b, 0) / times.length; + const max = Math.max(...times); + const min = Math.min(...times); + console.log( + `${test.label}: avg=${avg.toFixed(2)}ms, min=${min}ms, max=${max}ms` + ); + } + }); + + console.log('\n=== Demo Complete ==='); +} + +// Run the demo: +runFakerValidationDemo(); 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 e45f5816cf5..a9458050336 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 @@ -15,6 +15,7 @@ import { StepButtonLabelMap } from './constants'; import type { CollectionState } from '../../modules/collection-tab'; import { default as collectionTabReducer } from '../../modules/collection-tab'; import type { ConnectionInfo } from '@mongodb-js/connection-info'; +import type { MockDataSchemaResponse } from '@mongodb-js/compass-generative-ai'; describe('MockDataGeneratorModal', () => { async function renderModal({ @@ -73,10 +74,19 @@ describe('MockDataGeneratorModal', () => { atlasAiService: { getMockDataSchema: () => { return Promise.resolve({ - contents: { - fields: [], + content: { + fields: [ + { + fieldPath: 'name', + mongoType: 'string', + fakerMethod: 'person.firstName', + fakerArgs: [], + isArray: false, + probability: 1.0, + }, + ], }, - }); + } as MockDataSchemaResponse); }, }, workspaces: {}, @@ -168,7 +178,7 @@ describe('MockDataGeneratorModal', () => { userEvent.click(screen.getByText('Confirm')); await waitFor(() => { - expect(screen.getByTestId('faker-schema-editor')).to.exist; + expect(screen.getByTestId('faker-schema-editor-loader')).to.exist; }); userEvent.click(screen.getByText('Cancel')); @@ -184,7 +194,7 @@ describe('MockDataGeneratorModal', () => { userEvent.click(screen.getByText('Confirm')); await waitFor(() => { - expect(screen.getByTestId('faker-schema-editor')).to.exist; + expect(screen.getByTestId('faker-schema-editor-loader')).to.exist; }); userEvent.click(screen.getByText('Back')); @@ -236,6 +246,182 @@ describe('MockDataGeneratorModal', () => { // todo: assert that closing then re-opening the modal after an LLM err removes the err message }); + describe('on the schema editor step', () => { + const mockServicesWithMockDataResponse = createMockServices(); + mockServicesWithMockDataResponse.atlasAiService.getMockDataSchema = () => + Promise.resolve({ + content: { + fields: [ + { + fieldPath: 'name', + mongoType: 'string', + fakerMethod: 'person.firstName', + fakerArgs: [], + isArray: false, + probability: 1.0, + }, + { + fieldPath: 'age', + mongoType: 'int', + fakerMethod: 'number.int', + fakerArgs: [], + isArray: false, + probability: 1.0, + }, + { + fieldPath: 'email', + mongoType: 'string', + fakerMethod: 'internet', + fakerArgs: [], + isArray: false, + probability: 1.0, + }, + { + fieldPath: 'username', + mongoType: 'string', + fakerMethod: 'noSuchMethod', + fakerArgs: [], + isArray: false, + probability: 1.0, + }, + ], + }, + }); + + it('shows a loading spinner when the faker schema generation is in progress', async () => { + const mockServices = createMockServices(); + mockServices.atlasAiService.getMockDataSchema = () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + content: { + fields: [], + }, + }), + 1000 + ) + ); + + await renderModal(); + + // advance to the schema editor step + userEvent.click(screen.getByText('Confirm')); + expect(screen.getByTestId('faker-schema-editor-loader')).to.exist; + }); + + it('shows the faker schema editor when the faker schema generation is completed', async () => { + await renderModal({ mockServices: mockServicesWithMockDataResponse }); + + // advance to the schema editor step + userEvent.click(screen.getByText('Confirm')); + + expect(await screen.findByTestId('faker-schema-editor')).to.exist; + expect(screen.getByText('name')).to.exist; + expect(screen.getByText('age')).to.exist; + }); + + it('shows correct values for the faker schema editor', async () => { + await renderModal({ mockServices: mockServicesWithMockDataResponse }); + + // advance to the schema editor step + userEvent.click(screen.getByText('Confirm')); + await waitFor(() => { + expect(screen.getByTestId('faker-schema-editor')).to.exist; + }); + // the "name" field should be selected by default + expect(screen.getByText('name')).to.exist; + expect(screen.getByLabelText('JSON Type')).to.have.value('string'); + expect(screen.getByLabelText('Faker Function')).to.have.value( + 'person.firstName' + ); + // select the "age" field + userEvent.click(screen.getByText('age')); + expect(screen.getByText('age')).to.exist; + expect(screen.getByLabelText('JSON Type')).to.have.value('int'); + expect(screen.getByLabelText('Faker Function')).to.have.value( + 'number.int' + ); + // select the "email" field + userEvent.click(screen.getByText('email')); + expect(screen.getByText('email')).to.exist; + expect(screen.getByLabelText('JSON Type')).to.have.value('string'); + // the "email" field should have a warning banner since the faker method is invalid + expect(screen.getByLabelText('Faker Function')).to.have.value( + 'Unrecognized' + ); + expect( + screen.getByText( + 'Please select a function or we will default fill this field with the string "Unrecognized"' + ) + ).to.exist; + + // select the "username" field + userEvent.click(screen.getByText('username')); + expect(screen.getByText('username')).to.exist; + expect(screen.getByLabelText('JSON Type')).to.have.value('string'); + expect(screen.getByLabelText('Faker Function')).to.have.value( + 'Unrecognized' + ); + }); + + it('disables the Next button when the faker schema mapping is not confirmed', async () => { + await renderModal({ + mockServices: mockServicesWithMockDataResponse, + }); + + // advance to the schema editor step + userEvent.click(screen.getByText('Confirm')); + await waitFor(() => { + expect(screen.getByTestId('faker-schema-editor')).to.exist; + }); + + expect( + screen.getByTestId('next-step-button').getAttribute('aria-disabled') + ).to.equal('true'); + }); + + it('validates faker arguments and handles invalid arguments gracefully', async () => { + const mockServicesWithInvalidArgs = createMockServices(); + mockServicesWithInvalidArgs.atlasAiService.getMockDataSchema = () => + Promise.resolve({ + content: { + fields: [ + { + fieldPath: 'validField', + mongoType: 'string', + fakerMethod: 'person.firstName', + fakerArgs: [], + isArray: false, + probability: 1.0, + }, + { + fieldPath: 'invalidArgsField', + mongoType: 'string', + fakerMethod: 'string.alpha', + fakerArgs: [Number.MAX_SAFE_INTEGER], // This should cause validation error + isArray: false, + probability: 1.0, + }, + ], + }, + }); + + await renderModal({ mockServices: mockServicesWithInvalidArgs }); + + // advance to the schema editor step + userEvent.click(screen.getByText('Confirm')); + await waitFor(() => { + expect(screen.getByTestId('faker-schema-editor')).to.exist; + }); + + // The component should handle invalid arguments gracefully + // and still render the schema editor + expect(screen.getByText('validField')).to.exist; + expect(screen.getByText('invalidArgsField')).to.exist; + }); + }); + describe('on the generate data step', () => { it('enables the Back button', async () => { await renderModal({ currentStep: MockDataGeneratorStep.GENERATE_DATA }); diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/mock-data-generator-modal.tsx b/packages/compass-collection/src/components/mock-data-generator-modal/mock-data-generator-modal.tsx index 1f85dd8e998..a55545a6932 100644 --- a/packages/compass-collection/src/components/mock-data-generator-modal/mock-data-generator-modal.tsx +++ b/packages/compass-collection/src/components/mock-data-generator-modal/mock-data-generator-modal.tsx @@ -10,9 +10,10 @@ import { Modal, ModalFooter, spacing, + SpinLoaderWithLabel, } from '@mongodb-js/compass-components'; -import { MockDataGeneratorStep } from './types'; +import { type MockDataGeneratorState, MockDataGeneratorStep } from './types'; import { StepButtonLabelMap } from './constants'; import type { CollectionState } from '../../modules/collection-tab'; import { @@ -36,6 +37,12 @@ const rightButtonsStyles = css` flex-direction: row; `; +const schemaEditorLoaderStyles = css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}); + interface Props { isOpen: boolean; onClose: () => void; @@ -43,6 +50,7 @@ interface Props { onNextStep: () => void; onConfirmSchema: () => Promise; onPreviousStep: () => void; + fakerSchemaGenerationState: MockDataGeneratorState; } const MockDataGeneratorModal = ({ @@ -52,13 +60,38 @@ const MockDataGeneratorModal = ({ onNextStep, onConfirmSchema, onPreviousStep, + fakerSchemaGenerationState, }: Props) => { + const [isSchemaConfirmed, setIsSchemaConfirmed] = + React.useState(false); + const modalBodyContent = useMemo(() => { switch (currentStep) { case MockDataGeneratorStep.SCHEMA_CONFIRMATION: return ; case MockDataGeneratorStep.SCHEMA_EDITOR: - return ; + { + if (fakerSchemaGenerationState.status === 'in-progress') { + return ( +
+ +
+ ); + } + if (fakerSchemaGenerationState.status === 'completed') { + return ( + setIsSchemaConfirmed(true)} + fakerMappings={fakerSchemaGenerationState.fakerSchema} + /> + ); + } + } + break; case MockDataGeneratorStep.DOCUMENT_COUNT: return <>; // TODO: CLOUDP-333856 case MockDataGeneratorStep.PREVIEW_DATA: @@ -66,7 +99,11 @@ const MockDataGeneratorModal = ({ case MockDataGeneratorStep.GENERATE_DATA: return ; } - }, [currentStep]); + }, [currentStep, fakerSchemaGenerationState]); + + const isNextButtonDisabled = + currentStep === MockDataGeneratorStep.SCHEMA_EDITOR && + (!isSchemaConfirmed || fakerSchemaGenerationState.status === 'in-progress'); const handleNextClick = () => { if (currentStep === MockDataGeneratorStep.GENERATE_DATA) { @@ -108,6 +145,7 @@ const MockDataGeneratorModal = ({ variant={ButtonVariant.Primary} onClick={handleNextClick} data-testid="next-step-button" + disabled={isNextButtonDisabled} > {StepButtonLabelMap[currentStep]} @@ -120,6 +158,7 @@ const MockDataGeneratorModal = ({ const mapStateToProps = (state: CollectionState) => ({ isOpen: state.mockDataGenerator.isModalOpen, currentStep: state.mockDataGenerator.currentStep, + fakerSchemaGenerationState: state.fakerSchemaGeneration, }); const ConnectedMockDataGeneratorModal = connect(mapStateToProps, { diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/schema-field-selector.tsx b/packages/compass-collection/src/components/mock-data-generator-modal/schema-field-selector.tsx new file mode 100644 index 00000000000..ccfeff58482 --- /dev/null +++ b/packages/compass-collection/src/components/mock-data-generator-modal/schema-field-selector.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { + css, + cx, + spacing, + palette, + useDarkMode, + Body, +} from '@mongodb-js/compass-components'; + +const fieldsContainerStyles = css({ + width: '40%', + display: 'flex', + flexDirection: 'column', + gap: spacing[100], +}); + +const fieldSelectorStyles = css({ + maxHeight: '300px', + overflow: 'auto', +}); + +const buttonStyles = css({ + borderRadius: spacing[100], + cursor: 'pointer', + marginBottom: spacing[100], + background: 'none', + border: 'none', + width: '100%', + padding: spacing[200], + textAlign: 'left', + fontWeight: 500, +}); + +const activeStylesLight = css({ + color: palette.green.dark2, + backgroundColor: palette.green.light3, + fontWeight: 600, + + '&:active,&:focus': { + backgroundColor: palette.green.light3, + }, +}); + +const activeStylesDark = css({ + color: palette.white, + '&:active,&:focus': { + backgroundColor: palette.gray.dark3, + color: palette.white, + }, +}); + +const hoverStylesLight = css({ + '&:hover,&:focus': { + backgroundColor: palette.gray.light2, + color: palette.black, + }, +}); + +const hoverStylesDark = css({ + '&:hover,&:focus': { + backgroundColor: palette.gray.dark3, + color: palette.gray.light2, + }, +}); + +const labelStyles = css({ + color: palette.gray.dark1, + fontWeight: 600, +}); + +type SidebarProps = { + activeField: string; + onFieldSelect: (field: string) => void; + fields: Array; +}; + +const FieldSelector: React.FunctionComponent = ({ + activeField, + fields, + onFieldSelect, +}) => { + const darkMode = useDarkMode(); + + return ( +
+ Document Fields +
+ {fields.map((field) => ( + + ))} +
+
+ ); +}; + +export default FieldSelector; diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/types.ts b/packages/compass-collection/src/components/mock-data-generator-modal/types.ts index 5812f3693a4..563f5a8bb2a 100644 --- a/packages/compass-collection/src/components/mock-data-generator-modal/types.ts +++ b/packages/compass-collection/src/components/mock-data-generator-modal/types.ts @@ -1,5 +1,3 @@ -import type { MockDataSchemaResponse } from '@mongodb-js/compass-generative-ai'; - export enum MockDataGeneratorStep { SCHEMA_CONFIRMATION = 'SCHEMA_CONFIRMATION', SCHEMA_EDITOR = 'SCHEMA_EDITOR', @@ -19,7 +17,7 @@ type MockDataGeneratorInProgressState = { type MockDataGeneratorCompletedState = { status: 'completed'; - fakerSchema: MockDataSchemaResponse; + fakerSchema: Array; requestId: string; }; @@ -34,3 +32,19 @@ export type MockDataGeneratorState = | MockDataGeneratorInProgressState | MockDataGeneratorCompletedState | MockDataGeneratorErrorState; + +export type FakerSchemaMapping = { + fieldPath: string; + mongoType: string; + fakerMethod: string; + fakerArgs: Array< + | string + | number + | boolean + | { + json: string; + } + >; + isArray: boolean; + probability: number; +}; diff --git a/packages/compass-collection/src/modules/collection-tab.ts b/packages/compass-collection/src/modules/collection-tab.ts index e1f3afa2eac..0220e0d6f87 100644 --- a/packages/compass-collection/src/modules/collection-tab.ts +++ b/packages/compass-collection/src/modules/collection-tab.ts @@ -35,7 +35,14 @@ import { calculateSchemaDepth } from '../calculate-schema-depth'; import { processSchema } from '../transform-schema-to-field-info'; import type { Document, MongoError } from 'mongodb'; import { MockDataGeneratorStep } from '../components/mock-data-generator-modal/types'; -import type { MockDataGeneratorState } from '../components/mock-data-generator-modal/types'; +import type { + FakerSchemaMapping, + MockDataGeneratorState, +} from '../components/mock-data-generator-modal/types'; +import { validateFakerArguments } from '../components/mock-data-generator-modal/faker-argument-validator'; + +// @ts-expect-error TypeScript warns us about importing ESM module from CommonJS module, but we can ignore since this code will be consumed by webpack. +import { faker } from '@faker-js/faker/locale/en'; const DEFAULT_SAMPLE_SIZE = 100; @@ -49,6 +56,7 @@ function isAction( } const ERROR_CODE_MAX_TIME_MS_EXPIRED = 50; +export const UNRECOGNIZED_FAKER_METHOD = 'Unrecognized'; function getErrorDetails(error: Error): SchemaAnalysisError { const errorCode = (error as MongoError).code; @@ -168,7 +176,7 @@ export interface FakerMappingGenerationStartedAction { export interface FakerMappingGenerationCompletedAction { type: CollectionActions.FakerMappingGenerationCompleted; - fakerSchema: MockDataSchemaResponse; + fakerSchema: Array; requestId: string; } @@ -682,6 +690,71 @@ export const cancelSchemaAnalysis = (): CollectionThunkAction => { }; }; +const validateFakerSchema = ( + fakerSchema: MockDataSchemaResponse, + logger: Logger +) => { + return fakerSchema.content.fields.map((field) => { + const { fakerMethod, fakerArgs } = field; + + // First validate that the faker method exists + const [firstLevel, secondLevel] = fakerMethod.split('.'); + if (typeof (faker as any)[firstLevel]?.[secondLevel] !== 'function') { + logger.log.warn( + mongoLogId(1_001_000_365), + 'Collection', + 'Invalid faker method', + { fakerMethod } + ); + return { + ...field, + fakerMethod: UNRECOGNIZED_FAKER_METHOD, + }; + } + + // Then validate the arguments if they exist + if (fakerArgs && fakerArgs.length > 0) { + const argumentValidation = validateFakerArguments( + fakerMethod, + fakerArgs, + { + timeoutMs: 200, // Allow a bit more time in production + logPerformance: false, // Don't log performance in production + logger: logger, // Pass the logger for potential debug logging + } + ); + + if (!argumentValidation.isValid) { + logger.log.warn( + mongoLogId(1_001_000_366), + 'Collection', + 'Invalid faker method arguments', + { + fakerMethod, + fakerArgs, + error: argumentValidation.error, + executionTimeMs: argumentValidation.executionTimeMs, + } + ); + + // Keep the method but clear the invalid arguments + return { + ...field, + fakerArgs: [], + }; + } + + // Use sanitized arguments if validation provided them + return { + ...field, + fakerArgs: argumentValidation.sanitizedArgs || fakerArgs, + }; + } + + return field; + }); +}; + export const generateFakerMappings = (): CollectionThunkAction< Promise > => { @@ -748,10 +821,12 @@ export const generateFakerMappings = (): CollectionThunkAction< connectionInfoRef.current ); + const validatedFakerSchema = validateFakerSchema(response, logger); + fakerSchemaGenerationAbortControllerRef.current = undefined; dispatch({ type: CollectionActions.FakerMappingGenerationCompleted, - fakerSchema: response, + fakerSchema: validatedFakerSchema, requestId: requestId, }); } catch (e) { diff --git a/packages/compass-web/webpack.config.js b/packages/compass-web/webpack.config.js index e71d2f01f47..59259754e3e 100644 --- a/packages/compass-web/webpack.config.js +++ b/packages/compass-web/webpack.config.js @@ -332,6 +332,8 @@ module.exports = (env, args) => { // bson is not that big, but is a shared dependency of compass-web, // compass-components and bson-transpilers, so splitting it out 'bson', + // dependency of compass-collection + '@faker-js/faker', ]); return bundles;