diff --git a/package-lock.json b/package-lock.json index 69db816c5d7..9d61b6ff6bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11299,6 +11299,18 @@ "node": ">=8" } }, + "node_modules/@mongodb-js/shell-bson-parser": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mongodb-js/shell-bson-parser/-/shell-bson-parser-1.3.1.tgz", + "integrity": "sha512-BnSjWc/XPVkugi78VaoRzdJMVgzFXiXvngcwWApzXNrQuKXzAXXQSuZ6kazx5toExPjHwu20gQmre59vKR0ZXw==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.14.1" + }, + "peerDependencies": { + "bson": "^4.6.3 || ^5 || ^6" + } + }, "node_modules/@mongodb-js/signing-utils": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@mongodb-js/signing-utils/-/signing-utils-0.3.8.tgz", @@ -17710,9 +17722,10 @@ } }, "node_modules/acorn": { - "version": "8.8.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", - "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -34891,6 +34904,38 @@ "integrity": "sha512-yuXLm9j/9b+JST7txz/FyQ62LitULLMZlAjeRwM0aeKuKT2yEbSH6mkVHEPLxadGsJwEfQ4NgqvVfdZA20orjg==", "license": "Apache-2.0" }, + "node_modules/mongodb-query-parser": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/mongodb-query-parser/-/mongodb-query-parser-4.4.2.tgz", + "integrity": "sha512-QHLojfyklUqzQz0eU0xWj6cJrbmOGLzkdErpoYa2X/UBrRWxuRT/BWY++m0sONku0HTzTFnYaOK84dB0T7hBCg==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/shell-bson-parser": "^1.3.1", + "debug": "^4.4.0", + "javascript-stringify": "^2.1.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "bson": "^4.6.3 || ^5 || ^6" + } + }, + "node_modules/mongodb-query-parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/mongodb-query-util": { "resolved": "packages/mongodb-query-util", "link": true @@ -48146,6 +48191,7 @@ "@mongodb-js/compass-app-stores": "^7.64.1", "@mongodb-js/compass-components": "^1.54.1", "@mongodb-js/compass-connections": "^1.78.1", + "@mongodb-js/compass-editor": "^0.56.1", "@mongodb-js/compass-generative-ai": "^0.57.1", "@mongodb-js/compass-logging": "^1.7.19", "@mongodb-js/compass-telemetry": "^1.16.1", @@ -48153,7 +48199,7 @@ "@mongodb-js/compass-workspaces": "^0.59.1", "@mongodb-js/connection-info": "^0.21.1", "@mongodb-js/mongodb-constants": "^0.14.0", - "bson": "^6.10.1", + "bson": "^6.10.4", "compass-preferences-model": "^2.57.1", "hadron-document": "^8.10.4", "mongodb": "^6.19.0", @@ -61965,6 +62011,7 @@ "@mongodb-js/compass-app-stores": "^7.64.1", "@mongodb-js/compass-components": "^1.54.1", "@mongodb-js/compass-connections": "^1.78.1", + "@mongodb-js/compass-editor": "^0.56.1", "@mongodb-js/compass-generative-ai": "^0.57.1", "@mongodb-js/compass-logging": "^1.7.19", "@mongodb-js/compass-telemetry": "^1.16.1", @@ -61984,7 +62031,7 @@ "@types/react": "^17.0.5", "@types/react-dom": "^17.0.10", "@types/sinon-chai": "^3.2.5", - "bson": "^6.10.1", + "bson": "^6.10.4", "chai": "^4.3.6", "compass-preferences-model": "^2.57.1", "depcheck": "^1.4.1", @@ -62006,6 +62053,27 @@ "xvfb-maybe": "^0.2.1" }, "dependencies": { + "@mongodb-js/compass-editor": { + "version": "https://registry.npmjs.org/@mongodb-js/compass-editor/-/compass-editor-0.55.0.tgz", + "integrity": "sha512-+4B1NtWFBCaKkyCWg0nlbEYrVTOc/SjwxJNtiXjMGy6CPnsCSoHsxO0aoNvZrjR8Px9PL0f2rvnplNHBL7yoxw==", + "requires": { + "@codemirror/autocomplete": "^6.18.6", + "@codemirror/commands": "^6.8.1", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/language": "^6.11.2", + "@codemirror/lint": "^6.8.5", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.38.0", + "@lezer/highlight": "^1.2.1", + "@mongodb-js/compass-components": "^1.53.0", + "@mongodb-js/mongodb-constants": "^0.14.0", + "mongodb-query-parser": "^4.3.0", + "polished": "^4.2.2", + "prettier": "^2.7.1", + "react": "^17.0.2" + } + }, "@mongodb-js/mongodb-constants": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/@mongodb-js/mongodb-constants/-/mongodb-constants-0.14.0.tgz", @@ -66926,6 +66994,14 @@ } } }, + "@mongodb-js/shell-bson-parser": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mongodb-js/shell-bson-parser/-/shell-bson-parser-1.3.1.tgz", + "integrity": "sha512-BnSjWc/XPVkugi78VaoRzdJMVgzFXiXvngcwWApzXNrQuKXzAXXQSuZ6kazx5toExPjHwu20gQmre59vKR0ZXw==", + "requires": { + "acorn": "^8.14.1" + } + }, "@mongodb-js/signing-utils": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@mongodb-js/signing-utils/-/signing-utils-0.3.8.tgz", @@ -72385,9 +72461,9 @@ } }, "acorn": { - "version": "8.8.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", - "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==" + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==" }, "acorn-import-attributes": { "version": "1.9.5", @@ -86795,6 +86871,27 @@ "resolved": "https://registry.npmjs.org/mongodb-ns/-/mongodb-ns-3.0.1.tgz", "integrity": "sha512-yuXLm9j/9b+JST7txz/FyQ62LitULLMZlAjeRwM0aeKuKT2yEbSH6mkVHEPLxadGsJwEfQ4NgqvVfdZA20orjg==" }, + "mongodb-query-parser": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/mongodb-query-parser/-/mongodb-query-parser-4.4.2.tgz", + "integrity": "sha512-QHLojfyklUqzQz0eU0xWj6cJrbmOGLzkdErpoYa2X/UBrRWxuRT/BWY++m0sONku0HTzTFnYaOK84dB0T7hBCg==", + "requires": { + "@mongodb-js/shell-bson-parser": "^1.3.1", + "debug": "^4.4.0", + "javascript-stringify": "^2.1.0", + "lodash": "^4.17.21" + }, + "dependencies": { + "debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "requires": { + "ms": "^2.1.3" + } + } + } + }, "mongodb-query-util": { "version": "file:packages/mongodb-query-util", "requires": { diff --git a/packages/compass-collection/package.json b/packages/compass-collection/package.json index 6a6cc1365ab..d0ee81c25e1 100644 --- a/packages/compass-collection/package.json +++ b/packages/compass-collection/package.json @@ -53,6 +53,7 @@ "@mongodb-js/compass-app-stores": "^7.64.1", "@mongodb-js/compass-components": "^1.54.1", "@mongodb-js/compass-connections": "^1.78.1", + "@mongodb-js/compass-editor": "^0.56.1", "@mongodb-js/compass-generative-ai": "^0.57.1", "@mongodb-js/compass-logging": "^1.7.19", "@mongodb-js/compass-telemetry": "^1.16.1", @@ -60,7 +61,7 @@ "@mongodb-js/compass-workspaces": "^0.59.1", "@mongodb-js/connection-info": "^0.21.1", "@mongodb-js/mongodb-constants": "^0.14.0", - "bson": "^6.10.1", + "bson": "^6.10.4", "compass-preferences-model": "^2.57.1", "hadron-document": "^8.10.4", "mongodb": "^6.19.0", 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 e758e4dbac6..f51d33fef2d 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 @@ -1,4 +1,5 @@ import { expect } from 'chai'; +import sinon from 'sinon'; import React from 'react'; import { screen, @@ -18,6 +19,7 @@ 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'; import type { SchemaAnalysisState } from '../../schema-analysis-types'; +import * as scriptGenerationUtils from './script-generation-utils'; const defaultSchemaAnalysisState: SchemaAnalysisState = { status: 'complete', @@ -28,6 +30,7 @@ const defaultSchemaAnalysisState: SchemaAnalysisState = { sample_values: ['John', 'Jane'], }, }, + arrayLengthMap: {}, sampleDocument: { name: 'John' }, schemaMetadata: { maxNestingDepth: 1, validationRules: null }, }; @@ -39,6 +42,7 @@ describe('MockDataGeneratorModal', () => { enableGenAISampleDocumentPassing = false, mockServices = createMockServices(), schemaAnalysis = defaultSchemaAnalysisState, + fakerSchemaGeneration = { status: 'idle' }, connectionInfo, }: { isOpen?: boolean; @@ -47,15 +51,14 @@ describe('MockDataGeneratorModal', () => { mockServices?: any; connectionInfo?: ConnectionInfo; schemaAnalysis?: SchemaAnalysisState; + fakerSchemaGeneration?: CollectionState['fakerSchemaGeneration']; } = {}) { const initialState: CollectionState = { workspaceTabId: 'test-workspace-tab-id', namespace: 'test.collection', metadata: null, schemaAnalysis, - fakerSchemaGeneration: { - status: 'idle', - }, + fakerSchemaGeneration, mockDataGenerator: { isModalOpen: isOpen, currentStep: currentStep, @@ -629,7 +632,21 @@ describe('MockDataGeneratorModal', () => { describe('on the generate data step', () => { it('enables the Back button', async () => { - await renderModal({ currentStep: MockDataGeneratorStep.GENERATE_DATA }); + await renderModal({ + currentStep: MockDataGeneratorStep.GENERATE_DATA, + fakerSchemaGeneration: { + status: 'completed', + fakerSchema: { + name: { + fakerMethod: 'person.firstName', + fakerArgs: [], + probability: 1.0, + mongoType: 'String', + }, + }, + requestId: 'test-request-id', + }, + }); expect( screen @@ -639,7 +656,21 @@ describe('MockDataGeneratorModal', () => { }); it('renders the main sections: Prerequisites, steps, and Resources', async () => { - await renderModal({ currentStep: MockDataGeneratorStep.GENERATE_DATA }); + await renderModal({ + currentStep: MockDataGeneratorStep.GENERATE_DATA, + fakerSchemaGeneration: { + status: 'completed', + fakerSchema: { + name: { + fakerMethod: 'person.firstName', + fakerArgs: [], + probability: 1.0, + mongoType: 'String', + }, + }, + requestId: 'test-request-id', + }, + }); expect(screen.getByText('Prerequisites')).to.exist; expect(screen.getByText('1. Create a .js file with the following script')) @@ -649,7 +680,21 @@ describe('MockDataGeneratorModal', () => { }); it('closes the modal when the Done button is clicked', async () => { - await renderModal({ currentStep: MockDataGeneratorStep.GENERATE_DATA }); + await renderModal({ + currentStep: MockDataGeneratorStep.GENERATE_DATA, + fakerSchemaGeneration: { + status: 'completed', + fakerSchema: { + name: { + fakerMethod: 'person.firstName', + fakerArgs: [], + probability: 1.0, + mongoType: 'String', + }, + }, + requestId: 'test-request-id', + }, + }); expect(screen.getByTestId('generate-mock-data-modal')).to.exist; userEvent.click(screen.getByText('Done')); @@ -684,6 +729,18 @@ describe('MockDataGeneratorModal', () => { await renderModal({ currentStep: MockDataGeneratorStep.GENERATE_DATA, connectionInfo: atlasConnectionInfo, + fakerSchemaGeneration: { + status: 'completed', + fakerSchema: { + name: { + fakerMethod: 'person.firstName', + fakerArgs: [], + probability: 1.0, + mongoType: 'String', + }, + }, + requestId: 'test-request-id', + }, }); const databaseUsersLink = screen.getByRole('link', { @@ -710,7 +767,76 @@ describe('MockDataGeneratorModal', () => { .to.not.exist; }); - // todo: assert that the generated script is displayed in the code block (CLOUDP-333860) + it('shows error banner when script generation fails', async () => { + // Mock the generateScript function to return an error + const generateScriptStub = sinon.stub( + scriptGenerationUtils, + 'generateScript' + ); + generateScriptStub.returns({ + success: false, + error: 'Test error: Invalid faker schema format', + }); + + try { + await renderModal({ + currentStep: MockDataGeneratorStep.GENERATE_DATA, + fakerSchemaGeneration: { + status: 'completed', + fakerSchema: { + name: { + fakerMethod: 'person.firstName', + fakerArgs: [], + probability: 1.0, + mongoType: 'String', + }, + }, + requestId: 'test-request-id', + }, + }); + + expect(screen.getByRole('alert')).to.exist; + expect(screen.getByText(/Script Generation Failed:/)).to.exist; + expect(screen.getByText(/Test error: Invalid faker schema format/)).to + .exist; + expect(screen.getByText(/Please go back to the start screen/)).to.exist; + + const codeBlock = screen.getByText('// Script generation failed.'); + expect(codeBlock).to.exist; + } finally { + generateScriptStub.restore(); + } + }); + + it('displays the script when generation succeeds', async () => { + await renderModal({ + currentStep: MockDataGeneratorStep.GENERATE_DATA, + fakerSchemaGeneration: { + status: 'completed', + fakerSchema: { + name: { + fakerMethod: 'person.firstName', + fakerArgs: [], + probability: 1.0, + mongoType: 'String', + }, + email: { + fakerMethod: 'internet.email', + fakerArgs: [], + probability: 1.0, + mongoType: 'String', + }, + }, + requestId: 'test-request-id', + }, + }); + + // Check that no error banner is displayed + expect(screen.queryByRole('alert')).to.not.exist; + expect(screen.queryByText('Script generation failed')).to.not.exist; + expect(screen.getByText('firstName')).to.exist; // faker method + expect(screen.getByText('insertMany')).to.exist; + }); }); describe('when rendering the modal in a specific step', () => { 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 e2aa91a67a8..5e9772d773d 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 @@ -22,6 +22,7 @@ import { generateFakerMappings, mockDataGeneratorPreviousButtonClicked, } from '../../modules/collection-tab'; + import RawSchemaConfirmationScreen from './raw-schema-confirmation-screen'; import FakerSchemaEditorScreen from './faker-schema-editor-screen'; import ScriptScreen from './script-screen'; diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/script-generation-utils.spec.ts b/packages/compass-collection/src/components/mock-data-generator-modal/script-generation-utils.spec.ts index 82dcf6cb1da..a18cefb4427 100644 --- a/packages/compass-collection/src/components/mock-data-generator-modal/script-generation-utils.spec.ts +++ b/packages/compass-collection/src/components/mock-data-generator-modal/script-generation-utils.spec.ts @@ -94,10 +94,9 @@ describe('Script Generation', () => { expect(result.success).to.equal(true); if (result.success) { - const expectedReturnBlock = `return { - tags: Array.from({length: 3}, () => faker.lorem.word()) - };`; - expect(result.script).to.contain(expectedReturnBlock); + expect(result.script).to.contain('Array.from'); + expect(result.script).to.contain('length: 3'); + expect(result.script).to.contain('faker.lorem.word()'); // Test that the generated document code is executable const document = testDocumentCodeExecution(result.script); @@ -122,9 +121,8 @@ describe('Script Generation', () => { expect(result.success).to.equal(true); if (result.success) { - // Should generate the complete return block with proper structure const expectedReturnBlock = `return { - users: Array.from({length: 3}, () => ({ + users: Array.from({ length: 3 }, () => ({ name: faker.person.fullName(), email: faker.internet.email() })) @@ -157,7 +155,11 @@ describe('Script Generation', () => { expect(result.success).to.equal(true); if (result.success) { const expectedReturnBlock = `return { - matrix: Array.from({length: 3}, () => Array.from({length: 3}, () => faker.number.int())) + matrix: Array.from({ length: 3 }, () => + Array.from({ length: 3 }, () => + faker.number.int() + ) + ) };`; expect(result.script).to.contain(expectedReturnBlock); @@ -186,9 +188,11 @@ describe('Script Generation', () => { expect(result.success).to.equal(true); if (result.success) { const expectedReturnBlock = `return { - users: Array.from({length: 3}, () => ({ + users: Array.from({ length: 3 }, () => ({ name: faker.person.fullName(), - tags: Array.from({length: 3}, () => faker.lorem.word()) + tags: Array.from({ length: 3 }, () => + faker.lorem.word() + ) })) };`; expect(result.script).to.contain(expectedReturnBlock); @@ -224,9 +228,11 @@ describe('Script Generation', () => { if (result.success) { const expectedReturnBlock = `return { title: faker.lorem.sentence(), - authors: Array.from({length: 3}, () => ({ + authors: Array.from({ length: 3 }, () => ({ name: faker.person.fullName(), - books: Array.from({length: 3}, () => faker.lorem.words()) + books: Array.from({ length: 3 }, () => + faker.lorem.words() + ) })), publishedYear: faker.date.recent() };`; @@ -282,10 +288,7 @@ describe('Script Generation', () => { expect(result.success).to.equal(true); if (result.success) { - const expectedReturnBlock = `return { - value: faker.number.int() - };`; - expect(result.script).to.contain(expectedReturnBlock); + expect(result.script).to.contain('faker.number.int()'); // Test that the generated document code is executable const document = testDocumentCodeExecution(result.script); @@ -346,13 +349,10 @@ describe('Script Generation', () => { expect(result.success).to.equal(true); if (result.success) { - // These should be treated as regular field names, not arrays - const expectedReturnBlock = `return { - "squareBrackets[]InMiddle": faker.lorem.word(), - "field[]WithMore": faker.lorem.word(), - "start[]middle[]end": faker.lorem.word() - };`; - expect(result.script).to.contain(expectedReturnBlock); + // Verify these are treated as regular field names, not arrays + expect(result.script).to.contain('"squareBrackets[]InMiddle"'); + expect(result.script).to.contain('"field[]WithMore"'); + expect(result.script).to.contain('"start[]middle[]end"'); expect(result.script).not.to.contain('Array.from'); // Test that the generated document code is executable @@ -369,22 +369,121 @@ describe('Script Generation', () => { name: createFieldMapping('person.fullName'), }; - const result = generateScript(schema, { + // Test various special characters: quotes, newlines, tabs + const result1 = generateScript(schema, { databaseName: 'test\'db`with"quotes', collectionName: 'coll\nwith\ttabs', documentCount: 1, }); + expect(result1.success).to.equal(true); + if (result1.success) { + expect(result1.script).to.contain('use("test\'db`with\\"quotes")'); + expect(result1.script).to.contain( + 'getCollection("coll\\nwith\\ttabs")' + ); + // Should not contain unescaped special characters that could break JS + expect(result1.script).not.to.contain("use('test'db"); + expect(result1.script).not.to.contain("getCollection('coll\nwith"); + + // Test that the generated document code is executable + testDocumentCodeExecution(result1.script); + } + + // Test backticks and dollar signs (template literal characters) + const result2 = generateScript(schema, { + databaseName: 'test`${}', + collectionName: 'collection`${}', + documentCount: 1, + }); + + expect(result2.success).to.equal(true); + if (result2.success) { + // Verify the script is syntactically valid + // eslint-disable-next-line @typescript-eslint/no-implied-eval + expect(() => new Function(result2.script)).to.not.throw(); + + // Verify template literal characters are properly escaped in console.log + expect(result2.script).to.contain('test\\`\\${}'); + expect(result2.script).to.contain('collection\\`\\${}'); + + // Test that the generated document code is executable + testDocumentCodeExecution(result2.script); + } + }); + + it('should prevent code injection attacks via database and collection names', () => { + const schema = { + name: { + mongoType: 'String' as const, + fakerMethod: 'person.firstName', + fakerArgs: [], + }, + }; + + // Test with potentially dangerous names that could inject malicious code + const result = generateScript(schema, { + databaseName: 'test`; require("fs").rmSync("/"); //', + collectionName: 'my "collection"', + documentCount: 1, + }); + expect(result.success).to.equal(true); if (result.success) { - // Should use JSON.stringify for safe string insertion - expect(result.script).to.contain('use("test\'db`with\\"quotes")'); + // Verify the script is syntactically valid JavaScript + // eslint-disable-next-line @typescript-eslint/no-implied-eval + expect(() => new Function(result.script)).to.not.throw(); + + // Verify malicious code is safely contained in string expect(result.script).to.contain( - 'db.getCollection("coll\\nwith\\ttabs")' + 'use(\'test`; require("fs").rmSync("/"); //\')' ); - // Should not contain unescaped special characters that could break JS - expect(result.script).not.to.contain("use('test'db"); - expect(result.script).not.to.contain("getCollection('coll\nwith"); + expect(result.script).to.contain('getCollection(\'my "collection"\')'); + + // Verify template literal injection is prevented (backticks are escaped) + expect(result.script).to.contain( + 'test\\`; require("fs").rmSync("/"); //' + ); + + // Verify malicious code in name is safely contained in code comment + expect(result.script).to.contain( + '// Generated for database: test`; require("fs").rmSync("/"); //; collection: my "collection"' + ); + + // Test that the generated document code is executable + testDocumentCodeExecution(result.script); + } + }); + + it('should sanitize newlines in database and collection names in comments', () => { + const schema = { + field: { + mongoType: 'String' as const, + fakerMethod: 'lorem.word', + fakerArgs: [], + }, + }; + + // Test with names containing actual newlines and carriage returns + const result = generateScript(schema, { + databaseName: 'test\nwith\nnewlines', + collectionName: 'coll\rwith\r\nreturns', + documentCount: 1, + }); + + expect(result.success).to.equal(true); + if (result.success) { + // Verify newlines are replaced with spaces in comments to prevent syntax errors + expect(result.script).to.contain( + '// Generated for database: test with newlines; collection: coll with returns' + ); + + // Verify the script is still syntactically valid + // eslint-disable-next-line @typescript-eslint/no-implied-eval + expect(() => new Function(result.script)).to.not.throw(); + + // Test that the generated document code is executable + testDocumentCodeExecution(result.script); } }); }); @@ -403,10 +502,8 @@ describe('Script Generation', () => { expect(result.success).to.equal(true); if (result.success) { - const expectedReturnBlock = `return { - tags: Array.from({length: 3}, () => faker.lorem.word()) - };`; - expect(result.script).to.contain(expectedReturnBlock); + expect(result.script).to.contain('length: 3'); + expect(result.script).to.contain('faker.lorem.word()'); // Test that the generated document code is executable const document = testDocumentCodeExecution(result.script); @@ -426,16 +523,14 @@ describe('Script Generation', () => { collectionName: 'posts', documentCount: 1, arrayLengthMap: { - tags: [5], + 'tags[]': 5, }, }); expect(result.success).to.equal(true); if (result.success) { - const expectedReturnBlock = `return { - tags: Array.from({length: 5}, () => faker.lorem.word()) - };`; - expect(result.script).to.contain(expectedReturnBlock); + expect(result.script).to.contain('length: 5'); + expect(result.script).to.contain('faker.lorem.word()'); // Test that the generated document code is executable const document = testDocumentCodeExecution(result.script); @@ -455,20 +550,18 @@ describe('Script Generation', () => { collectionName: 'groups', documentCount: 1, arrayLengthMap: { - users: { - length: 5, - elements: { - tags: [4], - }, - }, + 'users[]': 5, + 'users[].tags[]': 4, }, }); expect(result.success).to.equal(true); if (result.success) { const expectedReturnBlock = `return { - users: Array.from({length: 5}, () => ({ - tags: Array.from({length: 4}, () => faker.lorem.word()) + users: Array.from({ length: 5 }, () => ({ + tags: Array.from({ length: 4 }, () => + faker.lorem.word() + ) })) };`; expect(result.script).to.contain(expectedReturnBlock); @@ -495,17 +588,20 @@ describe('Script Generation', () => { collectionName: 'posts', documentCount: 1, arrayLengthMap: { - tags: [0], - categories: [2], + 'tags[]': 0, + 'categories[]': 2, }, }); expect(result.success).to.equal(true); if (result.success) { - // Should have tags array with length 0 (empty array) and categories with length 2 const expectedReturnBlock = `return { - tags: Array.from({length: 0}, () => faker.lorem.word()), - categories: Array.from({length: 2}, () => faker.lorem.word()) + tags: Array.from({ length: 0 }, () => + faker.lorem.word() + ), + categories: Array.from({ length: 2 }, () => + faker.lorem.word() + ) };`; expect(result.script).to.contain(expectedReturnBlock); @@ -530,16 +626,29 @@ describe('Script Generation', () => { collectionName: 'data', documentCount: 1, arrayLengthMap: { - matrix: [2, 5], // 2x5 matrix - cube: [3, 4, 2], // 3x4x2 cube + 'matrix[]': 2, + 'matrix[][]': 5, + 'cube[]': 3, + 'cube[][]': 4, + 'cube[][][]': 2, }, }); expect(result.success).to.equal(true); if (result.success) { const expectedReturnBlock = `return { - matrix: Array.from({length: 2}, () => Array.from({length: 5}, () => faker.number.int())), - cube: Array.from({length: 3}, () => Array.from({length: 4}, () => Array.from({length: 2}, () => faker.number.float()))) + matrix: Array.from({ length: 2 }, () => + Array.from({ length: 5 }, () => + faker.number.int() + ) + ), + cube: Array.from({ length: 3 }, () => + Array.from({ length: 4 }, () => + Array.from({ length: 2 }, () => + faker.number.float() + ) + ) + ) };`; expect(result.script).to.contain(expectedReturnBlock); @@ -562,35 +671,35 @@ describe('Script Generation', () => { collectionName: 'complex', documentCount: 1, arrayLengthMap: { - users: { - length: 2, - elements: { - tags: [3], - posts: { - length: 4, - elements: { - comments: [5], - }, - }, - }, - }, - matrix: [2, 3], + 'users[]': 2, + 'users[].tags[]': 3, + 'users[].posts[]': 4, + 'users[].posts[].comments[]': 5, + 'matrix[]': 2, + 'matrix[][]': 3, }, }); expect(result.success).to.equal(true); if (result.success) { - // Complex nested structure with custom array lengths const expectedReturnBlock = `return { - users: Array.from({length: 2}, () => ({ + users: Array.from({ length: 2 }, () => ({ name: faker.person.fullName(), - tags: Array.from({length: 3}, () => faker.lorem.word()), - posts: Array.from({length: 4}, () => ({ + tags: Array.from({ length: 3 }, () => + faker.lorem.word() + ), + posts: Array.from({ length: 4 }, () => ({ title: faker.lorem.sentence(), - comments: Array.from({length: 5}, () => faker.lorem.words()) + comments: Array.from({ length: 5 }, () => + faker.lorem.words() + ) })) })), - matrix: Array.from({length: 2}, () => Array.from({length: 3}, () => faker.number.int())) + matrix: Array.from({ length: 2 }, () => + Array.from({ length: 3 }, () => + faker.number.int() + ) + ) };`; expect(result.script).to.contain(expectedReturnBlock); @@ -598,6 +707,68 @@ describe('Script Generation', () => { testDocumentCodeExecution(result.script); } }); + + it('should handle field names with [] in middle (not array notation)', () => { + const schema = { + 'brackets[]InMiddle': createFieldMapping('lorem.word'), + 'items[].nested[]ArrayFieldWithBrackets[]': + createFieldMapping('lorem.sentence'), + 'matrix[]WithBrackets[][]': createFieldMapping('number.int'), + }; + + const result = generateScript(schema, { + databaseName: 'testdb', + collectionName: 'edgecases', + documentCount: 1, + arrayLengthMap: { + 'items[]': 2, + 'items[].nested[]ArrayFieldWithBrackets[]': 3, + 'matrix[]WithBrackets[]': 2, + 'matrix[]WithBrackets[][]': 4, + }, + }); + + expect(result.success).to.equal(true); + if (result.success) { + // Verify field names with [] in middle are treated as regular field names + expect(result.script).to.contain('"brackets[]InMiddle"'); + expect(result.script).to.contain('faker.lorem.word()'); + + // Verify array of objects with bracket field names containing arrays + expect(result.script).to.contain('"nested[]ArrayFieldWithBrackets"'); + expect(result.script).to.contain('Array.from({ length: 3 }'); + expect(result.script).to.contain('faker.lorem.sentence()'); + + // Verify multi-dimensional arrays with bracket field names + expect(result.script).to.contain('"matrix[]WithBrackets"'); + expect(result.script).to.contain('Array.from({ length: 2 }'); + expect(result.script).to.contain('faker.number.int()'); + + // Test that the generated document code is executable + const document = testDocumentCodeExecution(result.script); + expect(document).to.be.an('object'); + + // Verify the three specific edge cases + expect(document).to.have.property('brackets[]InMiddle'); + + expect(document).to.have.property('items'); + expect(document.items).to.be.an('array').with.length(2); + expect(document.items[0]).to.have.property( + 'nested[]ArrayFieldWithBrackets' + ); + expect(document.items[0]['nested[]ArrayFieldWithBrackets']) + .to.be.an('array') + .with.length(3); + + expect(document).to.have.property('matrix[]WithBrackets'); + expect(document['matrix[]WithBrackets']) + .to.be.an('array') + .with.length(2); + expect(document['matrix[]WithBrackets'][0]) + .to.be.an('array') + .with.length(4); + } + }); }); describe('Unrecognized Field Defaults', () => { @@ -618,10 +789,7 @@ describe('Script Generation', () => { expect(result.success).to.equal(true); if (result.success) { - const expectedReturnBlock = `return { - unknownField: faker.lorem.word() - };`; - expect(result.script).to.contain(expectedReturnBlock); + expect(result.script).to.contain('faker.lorem.word()'); // Test that the generated document code is executable const document = testDocumentCodeExecution(result.script); @@ -942,9 +1110,9 @@ describe('Script Generation', () => { expect(result.success).to.equal(true); if (result.success) { - expect(result.script).to.contain( - 'faker.number.int({"min":0,"max":100})' - ); + expect(result.script).to.contain('faker.number.int('); + expect(result.script).to.contain('min: 0'); + expect(result.script).to.contain('max: 100'); // Test that the generated document code is executable testDocumentCodeExecution(result.script); @@ -956,7 +1124,7 @@ describe('Script Generation', () => { color: { mongoType: 'String' as const, fakerMethod: 'helpers.arrayElement', - fakerArgs: [{ json: "['red', 'blue', 'green']" }], + fakerArgs: [{ json: '["red", "blue", "green"]' }], }, }; @@ -968,9 +1136,10 @@ describe('Script Generation', () => { expect(result.success).to.equal(true); if (result.success) { - expect(result.script).to.contain( - "faker.helpers.arrayElement(['red', 'blue', 'green'])" - ); + expect(result.script).to.contain('faker.helpers.arrayElement('); + expect(result.script).to.contain('red'); + expect(result.script).to.contain('blue'); + expect(result.script).to.contain('green'); // Test that the generated document code is executable testDocumentCodeExecution(result.script); @@ -1020,9 +1189,9 @@ describe('Script Generation', () => { expect(result.success).to.equal(true); if (result.success) { - expect(result.script).to.contain( - 'faker.helpers.arrayElement(["It\'s a \'test\' string", "another option"])' - ); + expect(result.script).to.contain('faker.helpers.arrayElement('); + expect(result.script).to.contain("It's a 'test' string"); + expect(result.script).to.contain('another option'); // Test that the generated document code is executable testDocumentCodeExecution(result.script); @@ -1111,12 +1280,6 @@ describe('Script Generation', () => { fakerArgs: [], probability: -0.5, // Invalid - should default to 1.0 }, - field3: { - mongoType: 'String' as const, - fakerMethod: 'lorem.word', - fakerArgs: [], - probability: 'invalid' as any, // Invalid - should default to 1.0 - }, }; const result = generateScript(schema, { @@ -1130,8 +1293,7 @@ describe('Script Generation', () => { // All fields should be treated as probability 1.0 (always present) const expectedReturnBlock = `return { field1: faker.lorem.word(), - field2: faker.lorem.word(), - field3: faker.lorem.word() + field2: faker.lorem.word() };`; expect(result.script).to.contain(expectedReturnBlock); expect(result.script).not.to.contain('Math.random()'); @@ -1155,7 +1317,9 @@ describe('Script Generation', () => { expect(result.success).to.equal(true); if (result.success) { const expectedReturnBlock = `return { - ...(Math.random() < 0.7 ? { optionalField: faker.lorem.word() } : {}) + ...(Math.random() < 0.7 + ? { optionalField: faker.lorem.word() } + : {}) };`; expect(result.script).to.contain(expectedReturnBlock); @@ -1182,8 +1346,14 @@ describe('Script Generation', () => { if (result.success) { const expectedReturnBlock = `return { alwaysPresent: faker.person.fullName(), - ...(Math.random() < 0.8 ? { sometimesPresent: faker.internet.email() } : {}), - ...(Math.random() < 0.2 ? { rarelyPresent: faker.phone.number() } : {}), + ...(Math.random() < 0.8 + ? { + sometimesPresent: faker.internet.email() + } + : {}), + ...(Math.random() < 0.2 + ? { rarelyPresent: faker.phone.number() } + : {}), defaultProbability: faker.lorem.word() };`; expect(result.script).to.contain(expectedReturnBlock); @@ -1217,9 +1387,8 @@ describe('Script Generation', () => { expect(result.success).to.equal(true); if (result.success) { - expect(result.script).to.contain( - '...(Math.random() < 0.9 ? { conditionalAge: faker.number.int(18, 65) } : {})' - ); + expect(result.script).to.contain('Math.random() < 0.9'); + expect(result.script).to.contain('faker.number.int(18, 65)'); // Test that the generated document code is executable testDocumentCodeExecution(result.script); @@ -1244,9 +1413,8 @@ describe('Script Generation', () => { expect(result.success).to.equal(true); if (result.success) { - expect(result.script).to.contain( - '...(Math.random() < 0.5 ? { unknownField: faker.lorem.word() } : {})' - ); + expect(result.script).to.contain('Math.random() < 0.5'); + expect(result.script).to.contain('faker.lorem.word()'); // Test that the generated document code is executable testDocumentCodeExecution(result.script); 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 6d465e6628a..15687a4b76d 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 @@ -1,22 +1,17 @@ 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 }; const DEFAULT_ARRAY_LENGTH = 3; -const INDENT_SIZE = 2; -// Array length configuration for different array types -export type ArrayLengthMap = { - [fieldName: string]: - | number[] // Multi-dimensional: [2, 3, 4] - | ArrayObjectConfig; // Array of objects -}; - -export interface ArrayObjectConfig { - length?: number; // Length of the parent array (optional for nested object containers) - elements: ArrayLengthMap; // Configuration for nested arrays -} +// Stores the average array length of each array. +// Examples: +// "users[]": 5 - users array has 5 elements +// "users[].posts[]": 3 - each user has 3 posts +// "matrix[]": 3, "matrix[][]": 4 - matrix has 3 rows, each row has 4 columns +export type ArrayLengthMap = Record; export interface ScriptOptions { documentCount: number; @@ -53,14 +48,15 @@ export function generateScript( const documentCode = renderDocumentCode( structure, - INDENT_SIZE * 2, // 4 spaces: 2 for function body + 2 for inside return statement - options.arrayLengthMap + options.arrayLengthMap || {} ); - const script = `// Mock Data Generator Script -// Generated for collection: ${JSON.stringify( - options.databaseName - )}.${JSON.stringify(options.collectionName)} + // Generate unformatted script + const unformattedScript = `// Mock Data Generator Script +// Generated for database: ${options.databaseName.replace( + /[\r\n]/g, // Prevent newlines in names that could break the comment + ' ' + )}; collection: ${options.collectionName.replace(/[\r\n]/g, ' ')} // Document count: ${options.documentCount} const { faker } = require('@faker-js/faker'); @@ -70,13 +66,13 @@ use(${JSON.stringify(options.databaseName)}); // Document generation function function generateDocument() { - return ${documentCode}; +return ${documentCode}; } // Generate and insert documents const documents = []; for (let i = 0; i < ${options.documentCount}; i++) { - documents.push(generateDocument()); +documents.push(generateDocument()); } // Insert documents into collection @@ -84,9 +80,13 @@ db.getCollection(${JSON.stringify( options.collectionName )}).insertMany(documents); -console.log(\`Successfully inserted \${documents.length} documents into ${JSON.stringify( - options.databaseName - )}.${JSON.stringify(options.collectionName)}\`);`; +console.log(\`Successfully inserted \${documents.length} documents into ${options.databaseName.replace( + /[\\`$]/g, // Escape backslashes, backticks and dollar signs + '\\$&' + )}.${options.collectionName.replace(/[\\`$]/g, '\\$&')}\`);`; + + // Format the script using prettier + const script = prettify(unformattedScript, 'javascript'); return { script, @@ -311,16 +311,14 @@ function insertIntoStructure( */ function renderDocumentCode( structure: DocumentStructure, - indent: number = INDENT_SIZE, - arrayLengthMap: ArrayLengthMap = {} + arrayLengthMap: ArrayLengthMap = {}, + currentPath: string = '' ): string { // For each field in structure: // - If FakerFieldMapping: generate faker call // - If DocumentStructure: generate nested object // - If ArrayStructure: generate array - const fieldIndent = ' '.repeat(indent); - const closingBraceIndent = ' '.repeat(indent - INDENT_SIZE); const documentFields: string[] = []; for (const [fieldName, value] of Object.entries(structure)) { @@ -342,47 +340,39 @@ function renderDocumentCode( if (probability < 1.0) { // Use Math.random for conditional field inclusion documentFields.push( - `${fieldIndent}...(Math.random() < ${probability} ? { ${formatFieldName( + `...(Math.random() < ${probability} ? { ${formatFieldName( fieldName )}: ${fakerCall} } : {})` ); } else { // Normal field inclusion - documentFields.push( - `${fieldIndent}${formatFieldName(fieldName)}: ${fakerCall}` - ); + documentFields.push(`${formatFieldName(fieldName)}: ${fakerCall}`); } } else if ('type' in value && value.type === 'array') { // It's an array + const fieldPath = currentPath + ? `${currentPath}.${fieldName}[]` + : `${fieldName}[]`; const arrayCode = renderArrayCode( value as ArrayStructure, - indent + INDENT_SIZE, fieldName, arrayLengthMap, - 0 // Start at dimension 0 - ); - documentFields.push( - `${fieldIndent}${formatFieldName(fieldName)}: ${arrayCode}` + fieldPath ); + documentFields.push(`${formatFieldName(fieldName)}: ${arrayCode}`); } else { // It's a nested object: recursive call - // Get nested array length map for this field, - // including type validation and fallback for malformed maps - const arrayInfo = arrayLengthMap[fieldName]; - const nestedArrayLengthMap = - arrayInfo && !Array.isArray(arrayInfo) && 'elements' in arrayInfo - ? arrayInfo.elements - : {}; + const nestedPath = currentPath + ? `${currentPath}.${fieldName}` + : fieldName; const nestedCode = renderDocumentCode( value as DocumentStructure, - indent + INDENT_SIZE, - nestedArrayLengthMap - ); - documentFields.push( - `${fieldIndent}${formatFieldName(fieldName)}: ${nestedCode}` + arrayLengthMap, + nestedPath ); + documentFields.push(`${formatFieldName(fieldName)}: ${nestedCode}`); } } @@ -391,7 +381,7 @@ function renderDocumentCode( return '{}'; } - return `{\n${documentFields.join(',\n')}\n${closingBraceIndent}}`; + return `{${documentFields.join(',')}}`; } /** @@ -415,23 +405,16 @@ function formatFieldName(fieldName: string): string { */ function renderArrayCode( arrayStructure: ArrayStructure, - indent: number = INDENT_SIZE, fieldName: string = '', arrayLengthMap: ArrayLengthMap = {}, - dimensionIndex: number = 0 + currentFieldPath: string = '' ): string { const elementType = arrayStructure.elementType; // Get array length for this dimension - const arrayInfo = arrayLengthMap[fieldName]; let arrayLength = DEFAULT_ARRAY_LENGTH; - - if (Array.isArray(arrayInfo)) { - // single or multi-dimensional array: eg. [2, 3, 4] or [6] - arrayLength = arrayInfo[dimensionIndex] ?? DEFAULT_ARRAY_LENGTH; // Fallback for malformed array map - } else if (arrayInfo && 'length' in arrayInfo) { - // Array of objects/documents - arrayLength = arrayInfo.length ?? DEFAULT_ARRAY_LENGTH; + if (currentFieldPath && arrayLengthMap[currentFieldPath] !== undefined) { + arrayLength = arrayLengthMap[currentFieldPath]; } if ('mongoType' in elementType) { @@ -439,25 +422,20 @@ function renderArrayCode( const fakerCall = generateFakerCall(elementType as FakerFieldMapping); return `Array.from({length: ${arrayLength}}, () => ${fakerCall})`; } else if ('type' in elementType && elementType.type === 'array') { - // Nested array (e.g., matrix[][]) - keep same fieldName, increment dimension + // Nested array (e.g., matrix[][]) - append another [] to the path + const fieldPath = currentFieldPath + '[]'; const nestedArrayCode = renderArrayCode( elementType as ArrayStructure, - indent, fieldName, arrayLengthMap, - dimensionIndex + 1 // Next dimension + fieldPath ); return `Array.from({length: ${arrayLength}}, () => ${nestedArrayCode})`; } else { - // Array of objects - const nestedArrayLengthMap = - arrayInfo && !Array.isArray(arrayInfo) && 'elements' in arrayInfo - ? arrayInfo.elements - : {}; // Fallback to empty map for malformed array map const objectCode = renderDocumentCode( elementType as DocumentStructure, - indent, - nestedArrayLengthMap + arrayLengthMap, + currentFieldPath ); return `Array.from({length: ${arrayLength}}, () => (${objectCode}))`; } diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/script-screen.tsx b/packages/compass-collection/src/components/mock-data-generator-modal/script-screen.tsx index a3f090ce94f..8b9316ae4b7 100644 --- a/packages/compass-collection/src/components/mock-data-generator-modal/script-screen.tsx +++ b/packages/compass-collection/src/components/mock-data-generator-modal/script-screen.tsx @@ -1,5 +1,7 @@ -import React from 'react'; +import React, { useMemo } from 'react'; +import { connect } from 'react-redux'; import { + Banner, Body, Code, Copyable, @@ -14,6 +16,12 @@ import { useDarkMode, } from '@mongodb-js/compass-components'; import { useConnectionInfo } from '@mongodb-js/compass-connections/provider'; +import toNS from 'mongodb-ns'; +import { generateScript } from './script-generation-utils'; +import type { FakerSchema } from './types'; +import type { ArrayLengthMap } from './script-generation-utils'; +import type { CollectionState } from '../../modules/collection-tab'; +import { SCHEMA_ANALYSIS_STATE_COMPLETE } from '../../schema-analysis-types'; const RUN_SCRIPT_COMMAND = ` mongosh "mongodb+srv://.mongodb.net/" \\ @@ -63,12 +71,56 @@ const resourceSectionHeader = css({ marginBottom: spacing[300], }); -const ScriptScreen = () => { +const scriptCodeBlockStyles = css({ + maxHeight: '230px', + overflowY: 'auto', +}); + +interface ScriptScreenProps { + fakerSchema: FakerSchema | null; + namespace: string; + arrayLengthMap: ArrayLengthMap; + documentCount: number; +} + +const ScriptScreen = ({ + fakerSchema, + namespace, + arrayLengthMap, + documentCount, +}: ScriptScreenProps) => { const isDarkMode = useDarkMode(); const connectionInfo = useConnectionInfo(); + const { database, collection } = toNS(namespace); + + // Generate the script using the faker schema + const scriptResult = useMemo(() => { + // Handle case where fakerSchema is not yet available + if (!fakerSchema) { + return { + success: false as const, + error: 'Faker schema not available', + }; + } + + return generateScript(fakerSchema, { + documentCount, + databaseName: database, + collectionName: collection, + arrayLengthMap, + }); + }, [fakerSchema, documentCount, database, collection, arrayLengthMap]); + return (
+ {!scriptResult.success && ( + + Script Generation Failed: {scriptResult.error} +
+ Please go back to the start screen to re-submit the collection schema. +
+ )}
Prerequisites @@ -100,9 +152,14 @@ const ScriptScreen = () => { In the directory that you created, create a file named mockdatascript.js (or any name you'd like). - {/* TODO: CLOUDP-333860: Hook up to the code generated as part script generation */} - - TK + + {scriptResult.success + ? scriptResult.script + : '// Script generation failed.'}
@@ -156,4 +213,25 @@ const ScriptScreen = () => { ); }; -export default ScriptScreen; +const mapStateToProps = (state: CollectionState) => { + const { fakerSchemaGeneration, namespace, schemaAnalysis } = state; + + return { + fakerSchema: + fakerSchemaGeneration.status === 'completed' + ? fakerSchemaGeneration.fakerSchema + : null, + namespace, + arrayLengthMap: + schemaAnalysis?.status === SCHEMA_ANALYSIS_STATE_COMPLETE + ? schemaAnalysis.arrayLengthMap + : {}, + // TODO(CLOUDP-333856): When document count step is implemented, get documentCount from state + documentCount: 100, + }; +}; + +const ConnectedScriptScreen = connect(mapStateToProps)(ScriptScreen); + +export default ConnectedScriptScreen; +export type { ScriptScreenProps }; diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/to-simplified-field-info.ts b/packages/compass-collection/src/components/mock-data-generator-modal/to-simplified-field-info.ts index 056dad9d670..e15e79d84e7 100644 --- a/packages/compass-collection/src/components/mock-data-generator-modal/to-simplified-field-info.ts +++ b/packages/compass-collection/src/components/mock-data-generator-modal/to-simplified-field-info.ts @@ -1,10 +1,10 @@ import { FIELD_NAME_SEPARATOR } from '../../transform-schema-to-field-info'; import type { processSchema } from '../../transform-schema-to-field-info'; -import type { FieldInfo } from '../../schema-analysis-types'; +import type { PrimitiveSchemaType } from 'mongodb-schema'; type UserFriendlyFieldInfoNode = | { [field: string]: UserFriendlyFieldInfoNode } - | FieldInfo['type']; + | PrimitiveSchemaType['name']; export type SimplifiedFieldInfoTree = { [field: string]: UserFriendlyFieldInfoNode; }; @@ -14,7 +14,7 @@ export type SimplifiedFieldInfoTree = { * ensuring that the user sees a simplification of what the LLM processes. */ export default function toSimplifiedFieldInfo( - input: ReturnType + input: ReturnType['fieldInfo'] ): SimplifiedFieldInfoTree { // ensure parent nodes are created before their children const sortedFieldPaths = Object.keys(input).sort( diff --git a/packages/compass-collection/src/modules/collection-tab.ts b/packages/compass-collection/src/modules/collection-tab.ts index 77720bd4bf3..b567c2097ff 100644 --- a/packages/compass-collection/src/modules/collection-tab.ts +++ b/packages/compass-collection/src/modules/collection-tab.ts @@ -143,6 +143,7 @@ interface SchemaAnalysisStartedAction { interface SchemaAnalysisFinishedAction { type: CollectionActions.SchemaAnalysisFinished; processedSchema: Record; + arrayLengthMap: Record; sampleDocument: Document; schemaMetadata: { maxNestingDepth: number; @@ -262,6 +263,7 @@ const reducer: Reducer = ( schemaAnalysis: { status: SCHEMA_ANALYSIS_STATE_COMPLETE, processedSchema: action.processedSchema, + arrayLengthMap: action.arrayLengthMap, sampleDocument: action.sampleDocument, schemaMetadata: action.schemaMetadata, }, @@ -629,7 +631,7 @@ export const analyzeCollectionSchema = (): CollectionThunkAction< ); // Transform schema to structure that will be used by the LLM - const processedSchema = processSchema(schema); + const processSchemaResult = processSchema(schema); const maxNestingDepth = await calculateSchemaDepth(schema); const { database, collection } = toNS(namespace); @@ -648,7 +650,8 @@ export const analyzeCollectionSchema = (): CollectionThunkAction< dispatch({ type: CollectionActions.SchemaAnalysisFinished, - processedSchema, + processedSchema: processSchemaResult.fieldInfo, + arrayLengthMap: processSchemaResult.arrayLengthMap, sampleDocument: sampleDocuments[0], schemaMetadata, }); diff --git a/packages/compass-collection/src/schema-analysis-types.ts b/packages/compass-collection/src/schema-analysis-types.ts index 954080599af..66a68bb263d 100644 --- a/packages/compass-collection/src/schema-analysis-types.ts +++ b/packages/compass-collection/src/schema-analysis-types.ts @@ -54,6 +54,7 @@ export interface FieldInfo { export type SchemaAnalysisCompletedState = { status: typeof SCHEMA_ANALYSIS_STATE_COMPLETE; processedSchema: Record; + arrayLengthMap: Record; sampleDocument: Document; schemaMetadata: { maxNestingDepth: number; diff --git a/packages/compass-collection/src/transform-schema-to-field-info.spec.ts b/packages/compass-collection/src/transform-schema-to-field-info.spec.ts index 06bd64de345..a38e4fe32de 100644 --- a/packages/compass-collection/src/transform-schema-to-field-info.spec.ts +++ b/packages/compass-collection/src/transform-schema-to-field-info.spec.ts @@ -53,13 +53,14 @@ describe('processSchema', function () { const result = processSchema(schema); - expect(result).to.deep.equal({ + expect(result.fieldInfo).to.deep.equal({ mixed: { type: 'String', // Should pick the most probable type sample_values: ['text'], probability: 1.0, }, }); + expect(result.arrayLengthMap).to.deep.equal({}); }); it('filters out undefined and null types', function () { @@ -103,13 +104,14 @@ describe('processSchema', function () { const result = processSchema(schema); - expect(result).to.deep.equal({ + expect(result.fieldInfo).to.deep.equal({ optional: { type: 'String', sample_values: ['value'], probability: 0.67, }, }); + expect(result.arrayLengthMap).to.deep.equal({}); }); it('handles fields with no types', function () { @@ -130,7 +132,8 @@ describe('processSchema', function () { const result = processSchema(schema); - expect(result).to.deep.equal({}); + expect(result.fieldInfo).to.deep.equal({}); + expect(result.arrayLengthMap).to.deep.equal({}); }); it('handles empty schema', function () { @@ -141,7 +144,8 @@ describe('processSchema', function () { const result = processSchema(schema); - expect(result).to.deep.equal({}); + expect(result.fieldInfo).to.deep.equal({}); + expect(result.arrayLengthMap).to.deep.equal({}); }); it('limits sample values to 10', function () { @@ -173,8 +177,10 @@ describe('processSchema', function () { const result = processSchema(schema); - expect(result.field.sample_values).to.have.length(10); - expect(result.field.sample_values).to.deep.equal(manyValues.slice(0, 10)); + expect(result.fieldInfo.field.sample_values).to.have.length(10); + expect(result.fieldInfo.field.sample_values).to.deep.equal( + manyValues.slice(0, 10) + ); }); it('transforms simple primitive fields', function () { @@ -258,7 +264,7 @@ describe('processSchema', function () { const result = processSchema(schema); - expect(result).to.deep.equal({ + expect(result.fieldInfo).to.deep.equal({ name: { type: 'String', sample_values: ['John', 'Jane', 'Bob'], @@ -280,6 +286,7 @@ describe('processSchema', function () { probability: 0.7, }, }); + expect(result.arrayLengthMap).to.deep.equal({}); }); it('handles various BSON types', function () { @@ -471,7 +478,7 @@ describe('processSchema', function () { const result = processSchema(schema); - expect(result).to.deep.equal({ + expect(result.fieldInfo).to.deep.equal({ objectId: { type: 'ObjectId', sample_values: ['642d766b7300158b1f22e972'], @@ -523,6 +530,7 @@ describe('processSchema', function () { probability: 1.0, }, }); + expect(result.arrayLengthMap).to.deep.equal({}); }); it('transforms nested document field', function () { @@ -589,7 +597,7 @@ describe('processSchema', function () { const result = processSchema(schema); - expect(result).to.deep.equal({ + expect(result.fieldInfo).to.deep.equal({ 'user.name': { type: 'String', sample_values: ['John'], @@ -601,6 +609,7 @@ describe('processSchema', function () { probability: 0.8, }, }); + expect(result.arrayLengthMap).to.deep.equal({}); }); it('transforms array field', function () { @@ -643,13 +652,16 @@ describe('processSchema', function () { const result = processSchema(schema); - expect(result).to.deep.equal({ + expect(result.fieldInfo).to.deep.equal({ 'tags[]': { type: 'String', sample_values: ['red', 'blue', 'green'], probability: 1.0, }, }); + expect(result.arrayLengthMap).to.deep.equal({ + 'tags[]': 2, // Math.round(1.5) = 2 + }); }); it('handles deeply nested objects (documents)', function () { @@ -717,13 +729,14 @@ describe('processSchema', function () { const result = processSchema(schema); - expect(result).to.deep.equal({ + expect(result.fieldInfo).to.deep.equal({ 'level1.level2.value': { type: 'String', sample_values: ['deep'], probability: 1.0, }, }); + expect(result.arrayLengthMap).to.deep.equal({}); }); it('handles arrays of documents', function () { @@ -802,7 +815,7 @@ describe('processSchema', function () { const result = processSchema(schema); - expect(result).to.deep.equal({ + expect(result.fieldInfo).to.deep.equal({ 'items[].id': { type: 'Number', sample_values: [1, 2], @@ -814,6 +827,9 @@ describe('processSchema', function () { probability: 1.0, }, }); + expect(result.arrayLengthMap).to.deep.equal({ + 'items[]': 2, // averageLength: 2 + }); }); it('handles triple nested arrays (3D matrix)', function () { @@ -889,13 +905,18 @@ describe('processSchema', function () { const result = processSchema(schema); - expect(result).to.deep.equal({ + expect(result.fieldInfo).to.deep.equal({ 'cube[][][]': { type: 'Number', sample_values: [1, 2, 3, 4, 5, 6, 7, 8], probability: 1.0, }, }); + expect(result.arrayLengthMap).to.deep.equal({ + 'cube[]': 2, + 'cube[][]': 2, + 'cube[][][]': 2, + }); }); it('handles arrays of arrays of documents', function () { @@ -988,7 +1009,7 @@ describe('processSchema', function () { const result = processSchema(schema); - expect(result).to.deep.equal({ + expect(result.fieldInfo).to.deep.equal({ 'matrix[][].x': { type: 'Number', sample_values: [1, 3], @@ -1000,6 +1021,10 @@ describe('processSchema', function () { probability: 1.0, }, }); + expect(result.arrayLengthMap).to.deep.equal({ + 'matrix[]': 2, + 'matrix[][]': 1, + }); }); it('handles array of documents with nested arrays', function () { @@ -1092,7 +1117,7 @@ describe('processSchema', function () { const result = processSchema(schema); - expect(result).to.deep.equal({ + expect(result.fieldInfo).to.deep.equal({ 'teams[].name': { type: 'String', sample_values: ['Team A', 'Team B'], @@ -1104,6 +1129,10 @@ describe('processSchema', function () { probability: 1.0, }, }); + expect(result.arrayLengthMap).to.deep.equal({ + 'teams[]': 2, + 'teams[].members[]': 2, // Math.round(1.5) = 2 + }); }); /** @@ -1190,4 +1219,125 @@ describe('processSchema', function () { ); }); }); + + describe('Array Length Map', function () { + it('should handle array length bounds (min 1, max 50)', function () { + const schema: Schema = { + fields: [ + { + name: 'smallArray', + path: ['smallArray'], + count: 1, + type: ['Array'], + probability: 1.0, + hasDuplicates: false, + types: [ + { + name: 'Array', + bsonType: 'Array', + path: ['smallArray'], + count: 1, + probability: 1.0, + lengths: [0.3], // Very small average + averageLength: 0.3, + totalCount: 1, + types: [ + { + name: 'String', + bsonType: 'String', + path: ['smallArray'], + count: 1, + probability: 1.0, + values: ['test'], + }, + ], + }, + ], + }, + { + name: 'largeArray', + path: ['largeArray'], + count: 1, + type: ['Array'], + probability: 1.0, + hasDuplicates: false, + types: [ + { + name: 'Array', + bsonType: 'Array', + path: ['largeArray'], + count: 1, + probability: 1.0, + lengths: [100], // Very large average + averageLength: 100, + totalCount: 100, + types: [ + { + name: 'Number', + bsonType: 'Number', + path: ['largeArray'], + count: 100, + probability: 1.0, + values: [new Int32(1)], + }, + ], + }, + ], + }, + ], + count: 1, + }; + + const result = processSchema(schema); + + expect(result.arrayLengthMap).to.deep.equal({ + 'smallArray[]': 1, // Min 1 + 'largeArray[]': 50, // Max 50 + }); + }); + + it('should handle missing averageLength with default', function () { + const schema: Schema = { + fields: [ + { + name: 'defaultArray', + path: ['defaultArray'], + count: 1, + type: ['Array'], + probability: 1.0, + hasDuplicates: false, + types: [ + { + name: 'Array', + bsonType: 'Array', + path: ['defaultArray'], + count: 1, + probability: 1.0, + lengths: [2], + // averageLength is undefined + totalCount: 2, + types: [ + { + name: 'String', + bsonType: 'String', + path: ['defaultArray'], + count: 2, + probability: 1.0, + values: ['a', 'b'], + }, + ], + }, + ], + }, + ], + count: 1, + }; + + const result = processSchema(schema); + + expect(result.arrayLengthMap).to.deep.equal({ + 'defaultArray[]': 3, // DEFAULT_ARRAY_LENGTH = 3 + }); + }); + }); }); diff --git a/packages/compass-collection/src/transform-schema-to-field-info.ts b/packages/compass-collection/src/transform-schema-to-field-info.ts index f88dfb1e6a1..760f8c4a7e1 100644 --- a/packages/compass-collection/src/transform-schema-to-field-info.ts +++ b/packages/compass-collection/src/transform-schema-to-field-info.ts @@ -8,6 +8,7 @@ import type { ConstantSchemaType, } from 'mongodb-schema'; import type { FieldInfo, SampleValue } from './schema-analysis-types'; +import type { ArrayLengthMap } from './components/mock-data-generator-modal/script-generation-utils'; import { ObjectId, Binary, @@ -45,6 +46,40 @@ import { const MAX_SAMPLE_VALUES = 10; export const FIELD_NAME_SEPARATOR = '.'; +/** + * Default array length to use when no specific length information is available + */ +const DEFAULT_ARRAY_LENGTH = 3; + +/** + * Minimum allowed array length + */ +const MIN_ARRAY_LENGTH = 1; + +/** + * Maximum allowed array length + */ +const MAX_ARRAY_LENGTH = 50; + +/** + * Calculate array length from ArraySchemaType, using averageLength with bounds + */ +function calculateArrayLength(arrayType: ArraySchemaType): number { + const avgLength = arrayType.averageLength ?? DEFAULT_ARRAY_LENGTH; + return Math.max( + MIN_ARRAY_LENGTH, + Math.min(MAX_ARRAY_LENGTH, Math.round(avgLength)) + ); +} + +/** + * Result of processing a schema, including both field information and array length configuration + */ +export interface ProcessSchemaResult { + fieldInfo: Record; + arrayLengthMap: ArrayLengthMap; +} + export class ProcessSchemaUnsupportedStateError extends Error { constructor(message: string) { super(message); @@ -137,27 +172,29 @@ function isPrimitiveSchemaType(type: SchemaType): type is PrimitiveSchemaType { /** * Transforms a raw mongodb-schema Schema into a flat Record * using dot notation for nested fields and bracket notation for arrays. + * Also extracts array length information for script generation. * - * The result is used for the Mock Data Generator LLM call. + * The result is used for the Mock Data Generator LLM call and script generation. */ -export function processSchema(schema: Schema): Record { - const result: Record = {}; +export function processSchema(schema: Schema): ProcessSchemaResult { + const fieldInfo: Record = {}; + const arrayLengthMap: ArrayLengthMap = {}; if (!schema.fields) { - return result; + return { fieldInfo, arrayLengthMap }; } // Process each top-level field for (const field of schema.fields) { - processNamedField(field, '', result); + processNamedField(field, '', fieldInfo, arrayLengthMap); } // post-processing validation - for (const fieldPath of Object.keys(result)) { + for (const fieldPath of Object.keys(fieldInfo)) { validateFieldPath(fieldPath); } - return result; + return { fieldInfo, arrayLengthMap }; } /** @@ -166,7 +203,8 @@ export function processSchema(schema: Schema): Record { function processNamedField( field: SchemaField, pathPrefix: string, - result: Record + result: Record, + arrayLengthMap: ArrayLengthMap ): void { if (!field.types || field.types.length === 0) { return; @@ -187,7 +225,13 @@ function processNamedField( const currentPath = pathPrefix ? `${pathPrefix}.${field.name}` : field.name; // Process based on the type - processType(primaryType, currentPath, result, field.probability); + processType( + primaryType, + currentPath, + result, + field.probability, + arrayLengthMap + ); } /** @@ -197,14 +241,15 @@ function processType( type: SchemaType, currentPath: string, result: Record, - fieldProbability: number + fieldProbability: number, + arrayLengthMap: ArrayLengthMap ): void { if (isConstantSchemaType(type)) { return; } if (isArraySchemaType(type)) { - // Array: add [] to path and recurse into element type + // Array: add [] to path and collect array length information const elementType = getMostFrequentType(type.types || []); if (!elementType) { @@ -212,12 +257,24 @@ function processType( } const arrayPath = `${currentPath}[]`; - processType(elementType, arrayPath, result, fieldProbability); + + // Collect array length information + const arrayLength = calculateArrayLength(type); + arrayLengthMap[arrayPath] = arrayLength; + + // Recurse into element type + processType( + elementType, + arrayPath, + result, + fieldProbability, + arrayLengthMap + ); } else if (isDocumentSchemaType(type)) { // Document: Process nested document fields if (type.fields) { for (const nestedField of type.fields) { - processNamedField(nestedField, currentPath, result); + processNamedField(nestedField, currentPath, result, arrayLengthMap); } } } else if (isPrimitiveSchemaType(type)) { diff --git a/packages/compass-crud/src/stores/crud-store.spec.ts b/packages/compass-crud/src/stores/crud-store.spec.ts index 034cd1b5a0d..d2766b88fa0 100644 --- a/packages/compass-crud/src/stores/crud-store.spec.ts +++ b/packages/compass-crud/src/stores/crud-store.spec.ts @@ -2591,7 +2591,7 @@ describe('store', function () { 'SyntaxError' ); expect(store.state.bulkUpdate.syntaxError?.message).to.equal( - 'Unexpected token (2:25)' + 'Unexpected token (2:25) in (\n{ $set: { anotherField: } }\n)' ); await store.updateBulkUpdatePreview('{ $set: { anotherField: 2 } }'); diff --git a/packages/connection-form/src/utils/csfle-handler.spec.ts b/packages/connection-form/src/utils/csfle-handler.spec.ts index f1a83ab9c93..3ba94a0295b 100644 --- a/packages/connection-form/src/utils/csfle-handler.spec.ts +++ b/packages/connection-form/src/utils/csfle-handler.spec.ts @@ -468,7 +468,7 @@ describe('csfle-handler', function () { it('records the error for invalid shell BSON text', function () { expect(textToEncryptedFieldConfig('{')).to.deep.equal({ - '$compass.error': 'Unexpected token (3:0)', + '$compass.error': 'Unexpected token (3:0) in (\n{\n)', '$compass.rawText': '{', }); });