diff --git a/packages/compass-collection/src/components/collection-header-actions/collection-header-actions.spec.tsx b/packages/compass-collection/src/components/collection-header-actions/collection-header-actions.spec.tsx index 1f98a73199d..05ddc16c916 100644 --- a/packages/compass-collection/src/components/collection-header-actions/collection-header-actions.spec.tsx +++ b/packages/compass-collection/src/components/collection-header-actions/collection-header-actions.spec.tsx @@ -61,6 +61,7 @@ describe('CollectionHeaderActions [Component]', function () { hasSchemaAnalysisData={true} analyzedSchemaDepth={2} schemaAnalysisStatus="complete" + schemaAnalysisError={null} {...props} /> @@ -294,5 +295,37 @@ describe('CollectionHeaderActions [Component]', function () { expect(button).to.exist; expect(button).to.have.attribute('aria-disabled', 'true'); }); + + it('should show an error banner when the schema is in an unsupported state', async function () { + mockUseAssignment.returns({ + assignment: { + assignmentData: { + variant: 'mockDataGeneratorVariant', + }, + }, + }); + + await renderCollectionHeaderActions( + { + namespace: 'test.collection', + isReadonly: false, + hasSchemaAnalysisData: false, + schemaAnalysisStatus: 'error', + schemaAnalysisError: { + errorType: 'unsupportedState', + errorMessage: 'Unsupported state', + }, + onOpenMockDataModal: sinon.stub(), + }, + {}, + atlasConnectionInfo + ); + + const button = screen.getByTestId( + 'collection-header-generate-mock-data-button' + ); + expect(button).to.exist; + expect(button).to.have.attribute('aria-disabled', 'true'); + }); }); }); diff --git a/packages/compass-collection/src/components/collection-header-actions/collection-header-actions.tsx b/packages/compass-collection/src/components/collection-header-actions/collection-header-actions.tsx index e8204823d5c..38ac74d9d84 100644 --- a/packages/compass-collection/src/components/collection-header-actions/collection-header-actions.tsx +++ b/packages/compass-collection/src/components/collection-header-actions/collection-header-actions.tsx @@ -21,6 +21,7 @@ import { import { SCHEMA_ANALYSIS_STATE_ANALYZING, type SchemaAnalysisStatus, + type SchemaAnalysisError, } from '../../schema-analysis-types'; /** @@ -35,6 +36,14 @@ const collectionHeaderActionsStyles = css({ gap: spacing[200], }); +const tooltipMessageStyles = css({ + display: 'block', + marginBottom: spacing[100], + '&:last-child': { + marginBottom: 0, + }, +}); + function buildChartsUrl( groupId: string, clusterName: string, @@ -57,6 +66,7 @@ type CollectionHeaderActionsProps = { sourcePipeline?: unknown[]; onOpenMockDataModal: () => void; hasSchemaAnalysisData: boolean; + schemaAnalysisError: SchemaAnalysisError | null; analyzedSchemaDepth: number; schemaAnalysisStatus: SchemaAnalysisStatus | null; }; @@ -73,6 +83,7 @@ const CollectionHeaderActions: React.FunctionComponent< hasSchemaAnalysisData, analyzedSchemaDepth, schemaAnalysisStatus, + schemaAnalysisError, }: CollectionHeaderActionsProps) => { const connectionInfo = useConnectionInfo(); const { id: connectionId, atlasMetadata } = connectionInfo; @@ -145,10 +156,30 @@ const CollectionHeaderActions: React.FunctionComponent< } > - {exceedsMaxNestingDepth && - 'At this time we are unable to generate mock data for collections that have deeply nested documents'} - {isCollectionEmpty && - 'Please add data to your collection to generate similar mock documents'} + {/* TODO(CLOUDP-333853): update disabled open-modal button + tooltip to communicate if schema analysis is incomplete */} + <> + {exceedsMaxNestingDepth && ( + + At this time we are unable to generate mock data for collections + that have deeply nested documents. + + )} + {isCollectionEmpty && ( + + Please add data to your collection to generate similar mock + documents. + + )} + {schemaAnalysisError && + schemaAnalysisError.errorType === 'unsupportedState' && ( + + This collection has a field with a name that contains a + ".", which mock data generation does not support at + this time. + + )} + )} {atlasMetadata && ( diff --git a/packages/compass-collection/src/components/collection-header/collection-header.tsx b/packages/compass-collection/src/components/collection-header/collection-header.tsx index 7aca29141b7..1219529954c 100644 --- a/packages/compass-collection/src/components/collection-header/collection-header.tsx +++ b/packages/compass-collection/src/components/collection-header/collection-header.tsx @@ -24,7 +24,9 @@ import { openMockDataGeneratorModal } from '../../modules/collection-tab'; import type { CollectionState } from '../../modules/collection-tab'; import { SCHEMA_ANALYSIS_STATE_COMPLETE, + SCHEMA_ANALYSIS_STATE_ERROR, type SchemaAnalysisStatus, + type SchemaAnalysisError, } from '../../schema-analysis-types'; const collectionHeaderStyles = css({ @@ -70,6 +72,7 @@ type CollectionHeaderProps = { hasSchemaAnalysisData: boolean; analyzedSchemaDepth: number; schemaAnalysisStatus: SchemaAnalysisStatus | null; + schemaAnalysisError: SchemaAnalysisError | null; }; const getInsightsForPipeline = (pipeline: any[], isAtlas: boolean) => { @@ -108,6 +111,7 @@ const CollectionHeader: React.FunctionComponent = ({ hasSchemaAnalysisData, analyzedSchemaDepth, schemaAnalysisStatus, + schemaAnalysisError, }) => { const darkMode = useDarkMode(); const showInsights = usePreference('showInsights'); @@ -188,6 +192,7 @@ const CollectionHeader: React.FunctionComponent = ({ hasSchemaAnalysisData={hasSchemaAnalysisData} analyzedSchemaDepth={analyzedSchemaDepth} schemaAnalysisStatus={schemaAnalysisStatus} + schemaAnalysisError={schemaAnalysisError} /> @@ -199,6 +204,10 @@ const mapStateToProps = (state: CollectionState) => { const { schemaAnalysis } = state; return { + schemaAnalysisError: + schemaAnalysis && schemaAnalysis.status === SCHEMA_ANALYSIS_STATE_ERROR + ? schemaAnalysis.error + : null, hasSchemaAnalysisData: schemaAnalysis && schemaAnalysis.status === SCHEMA_ANALYSIS_STATE_COMPLETE && 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-screen.tsx similarity index 71% rename from packages/compass-collection/src/components/mock-data-generator-modal/faker-schema-editor.tsx rename to packages/compass-collection/src/components/mock-data-generator-modal/faker-schema-editor-screen.tsx index f95105066d8..c888cafdcc7 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-screen.tsx @@ -1,7 +1,7 @@ import React from 'react'; // TODO: More to come from CLOUDP-333853, CLOUDP-333854 -const FakerSchemaEditor = () => { +const FakerSchemaEditorScreen = () => { return (
Schema Editor Content Placeholder @@ -9,4 +9,4 @@ const FakerSchemaEditor = () => { ); }; -export default FakerSchemaEditor; +export default FakerSchemaEditorScreen; 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..7fec9d737fd 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 @@ -20,10 +20,12 @@ describe('MockDataGeneratorModal', () => { async function renderModal({ isOpen = true, currentStep = MockDataGeneratorStep.SCHEMA_CONFIRMATION, + enableGenAISampleDocumentPassing = false, mockServices = createMockServices(), connectionInfo, }: { isOpen?: boolean; + enableGenAISampleDocumentPassing?: boolean; currentStep?: MockDataGeneratorStep; mockServices?: any; connectionInfo?: ConnectionInfo; @@ -63,7 +65,12 @@ describe('MockDataGeneratorModal', () => { , - connectionInfo + connectionInfo, + { + preferences: { + enableGenAISampleDocumentPassing, + }, + } ); } @@ -204,6 +211,37 @@ describe('MockDataGeneratorModal', () => { ).to.equal('true'); }); + it('displays the namespace', async () => { + await renderModal(); + expect(screen.getByText('test.collection')).to.exist; + }); + + it('uses the appropriate copy when Generative AI sample document passing is enabled', async () => { + await renderModal({ enableGenAISampleDocumentPassing: true }); + expect(screen.getByText('Sample Documents Collected')).to.exist; + expect( + screen.getByText( + 'A sample of documents from your collection will be sent to an LLM for processing.' + ) + ).to.exist; + // fragment from { "name": "John" } + expect(screen.getByText('"John"')).to.exist; + expect(screen.queryByText('"String"')).to.not.exist; + }); + + it('uses the appropriate copy when Generative AI sample document passing is disabled', async () => { + await renderModal(); + expect(screen.getByText('Document Schema Identified')).to.exist; + expect( + screen.queryByText( + 'We have identified the following schema from your documents. This schema will be sent to an LLM for processing.' + ) + ).to.exist; + // fragment from { "name": "String" } + expect(screen.getByText('"String"')).to.exist; + expect(screen.queryByText('"John"')).to.not.exist; + }); + it('renders the faker schema editor when the confirm button is clicked', async () => { await renderModal(); @@ -230,10 +268,9 @@ describe('MockDataGeneratorModal', () => { expect(screen.queryByTestId('faker-schema-editor')).to.not.exist; }); - // todo: assert a user-friendly error is displayed (CLOUDP-333852) + expect(screen.getByText('LLM Request failed. Please confirm again.')).to + .exist; }); - - // todo: assert that closing then re-opening the modal after an LLM err removes the err message }); describe('on the generate data 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 1f85dd8e998..5f6982de0cb 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 @@ -3,6 +3,7 @@ import { connect } from 'react-redux'; import { css, + Body, Button, ButtonVariant, ModalBody, @@ -21,8 +22,8 @@ import { generateFakerMappings, mockDataGeneratorPreviousButtonClicked, } from '../../modules/collection-tab'; -import { default as SchemaConfirmationScreen } from './raw-schema-confirmation'; -import FakerSchemaEditor from './faker-schema-editor'; +import RawSchemaConfirmationScreen from './raw-schema-confirmation-screen'; +import FakerSchemaEditorScreen from './faker-schema-editor-screen'; import ScriptScreen from './script-screen'; const footerStyles = css` @@ -36,6 +37,11 @@ const rightButtonsStyles = css` flex-direction: row; `; +const namespaceStyles = css({ + marginTop: spacing[200], + marginBottom: spacing[400], +}); + interface Props { isOpen: boolean; onClose: () => void; @@ -43,6 +49,7 @@ interface Props { onNextStep: () => void; onConfirmSchema: () => Promise; onPreviousStep: () => void; + namespace: string; } const MockDataGeneratorModal = ({ @@ -52,13 +59,14 @@ const MockDataGeneratorModal = ({ onNextStep, onConfirmSchema, onPreviousStep, + namespace, }: Props) => { const modalBodyContent = useMemo(() => { switch (currentStep) { case MockDataGeneratorStep.SCHEMA_CONFIRMATION: - return ; + return ; case MockDataGeneratorStep.SCHEMA_EDITOR: - return ; + return ; case MockDataGeneratorStep.DOCUMENT_COUNT: return <>; // TODO: CLOUDP-333856 case MockDataGeneratorStep.PREVIEW_DATA: @@ -78,6 +86,9 @@ const MockDataGeneratorModal = ({ } }; + const shouldShowNamespace = + currentStep !== MockDataGeneratorStep.GENERATE_DATA; + return ( + {shouldShowNamespace && ( + {namespace} + )}
{modalBodyContent}
@@ -120,6 +134,7 @@ const MockDataGeneratorModal = ({ const mapStateToProps = (state: CollectionState) => ({ isOpen: state.mockDataGenerator.isModalOpen, currentStep: state.mockDataGenerator.currentStep, + namespace: state.namespace, }); const ConnectedMockDataGeneratorModal = connect(mapStateToProps, { diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/raw-schema-confirmation-screen.tsx b/packages/compass-collection/src/components/mock-data-generator-modal/raw-schema-confirmation-screen.tsx new file mode 100644 index 00000000000..e55182e192c --- /dev/null +++ b/packages/compass-collection/src/components/mock-data-generator-modal/raw-schema-confirmation-screen.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +import { + css, + palette, + spacing, + Banner, + BannerVariant, + Body, + DocumentList, +} from '@mongodb-js/compass-components'; + +import { usePreference } from 'compass-preferences-model/provider'; +import toSimplifiedFieldInfo from './to-simplified-field-info'; +import type { CollectionState } from '../../modules/collection-tab'; +import type { SchemaAnalysisState } from '../../schema-analysis-types'; +import type { MockDataGeneratorState } from './types'; +import HadronDocument from 'hadron-document'; + +interface RawSchemaConfirmationScreenProps { + schemaAnalysis: SchemaAnalysisState; + fakerSchemaGenerationStatus: MockDataGeneratorState['status']; +} + +const documentContainerStyles = css({ + backgroundColor: palette.gray.light3, + border: `1px solid ${palette.gray.light2}`, + borderRadius: spacing[400], +}); + +const documentStyles = css({ + padding: `${spacing[400]}px ${spacing[900]}px`, +}); + +const descriptionStyles = css({ + marginBottom: spacing[200], +}); + +const errorBannerStyles = css({ + marginTop: spacing[400], +}); + +const errorBannerTextStyles = css({ + color: palette.red.dark2, +}); + +const RawSchemaConfirmationScreen = ({ + schemaAnalysis, + fakerSchemaGenerationStatus, +}: RawSchemaConfirmationScreenProps) => { + const enableSampleDocumentPassing = usePreference( + 'enableGenAISampleDocumentPassing' + ); + + const subtitleText = enableSampleDocumentPassing + ? 'Sample Documents Collected' + : 'Document Schema Identified'; + + const descriptionText = enableSampleDocumentPassing + ? 'A sample of documents from your collection will be sent to an LLM for processing.' + : 'We have identified the following schema from your documents. This schema will be sent to an LLM for processing.'; + + return ( +
+ {schemaAnalysis.status === 'complete' ? ( + <> + + {subtitleText} + + {descriptionText} +
+ +
+ {fakerSchemaGenerationStatus === 'error' && ( + + + LLM Request failed. Please confirm again. + + + )} + + ) : ( + // Not reachable since schema analysis must be finished before the modal can be opened + We are analyzing your collection. + )} +
+ ); +}; + +const mapStateToProps = (state: CollectionState) => { + const schemaAnalysis = state.schemaAnalysis; + const fakerSchemaGenerationStatus = state.fakerSchemaGeneration.status; + + return { + schemaAnalysis, + fakerSchemaGenerationStatus, + }; +}; + +const ConnectedRawSchemaConfirmationScreen = connect( + mapStateToProps, + {} +)(RawSchemaConfirmationScreen); + +export default ConnectedRawSchemaConfirmationScreen; diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/raw-schema-confirmation.tsx b/packages/compass-collection/src/components/mock-data-generator-modal/raw-schema-confirmation.tsx deleted file mode 100644 index 6848d5b8c97..00000000000 --- a/packages/compass-collection/src/components/mock-data-generator-modal/raw-schema-confirmation.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; - -import { Code, Body, Subtitle } from '@mongodb-js/compass-components'; - -import type { CollectionState } from '../../modules/collection-tab'; -import type { FieldInfo } from '../../schema-analysis-types'; - -interface RawSchemaConfirmationProps { - schemaContent: Record | null; - namespace: string; -} - -// Note: Currently a placeholder. The final contents will be addressed by CLOUDP-333852 -const RawSchemaConfirmation = (props: RawSchemaConfirmationProps) => { - // this will change - const codeContent = props.schemaContent - ? JSON.stringify(props.schemaContent, null, 4) - : 'No schema data available'; - - return ( -
- {props.namespace} - Document Schema Identified - - We have identified the following schema from your documents. This schema - will be sent to an LLM for processing. - - - {codeContent} - -
- ); -}; - -const mapStateToProps = (state: CollectionState) => { - const schemaContent = - state.schemaAnalysis.status === 'complete' - ? state.schemaAnalysis.processedSchema - : null; - return { - schemaContent, - namespace: state.namespace, - }; -}; - -const ConnectedRawSchemaConfirmation = connect( - mapStateToProps, - {} -)(RawSchemaConfirmation); - -export default ConnectedRawSchemaConfirmation; diff --git a/packages/compass-collection/src/components/mock-data-generator-modal/to-simplified-field-info.spec.ts b/packages/compass-collection/src/components/mock-data-generator-modal/to-simplified-field-info.spec.ts new file mode 100644 index 00000000000..36040a301ef --- /dev/null +++ b/packages/compass-collection/src/components/mock-data-generator-modal/to-simplified-field-info.spec.ts @@ -0,0 +1,256 @@ +import { expect } from 'chai'; +import toSimplifiedFieldInfo from './to-simplified-field-info'; +import type { SimplifiedFieldInfoTree } from './to-simplified-field-info'; + +describe('toSimplifiedFieldInfo', function () { + it('simple case with minimal nesting and no arrays', function () { + const input = { + 'user.name': { + type: 'String' as const, + sample_values: ['John'], + probability: 1.0, + }, + 'user.age': { + type: 'Number' as const, + sample_values: [25, 30], + probability: 0.8, + }, + 'user.profile.bio': { + type: 'String' as const, + sample_values: ['Software engineer'], + probability: 0.9, + }, + 'user.profile.isVerified': { + type: 'Boolean' as const, + sample_values: [true, false], + probability: 0.7, + }, + 'metadata.createdAt': { + type: 'Date' as const, + sample_values: [new Date('2023-01-01')], + probability: 1.0, + }, + 'metadata.objectId': { + type: 'ObjectId' as const, + sample_values: ['642d766b7300158b1f22e972'], + probability: 1.0, + }, + }; + + const result = toSimplifiedFieldInfo(input); + + const expected: SimplifiedFieldInfoTree = { + user: { + name: 'String', + age: 'Number', + profile: { + bio: 'String', + isVerified: 'Boolean', + }, + }, + metadata: { + createdAt: 'Date', + objectId: 'ObjectId', + }, + }; + + expect(result).to.deep.equal(expected); + }); + + it('handles nested arrays of primitives', function () { + const input = { + 'tags[]': { + type: 'String' as const, + sample_values: ['red', 'blue', 'green'], + probability: 1.0, + }, + 'scores[]': { + type: 'Number' as const, + sample_values: [85, 92, 78], + probability: 0.9, + }, + 'matrix[][]': { + type: 'Number' as const, + sample_values: [1, 2, 3, 4], + probability: 1.0, + }, + 'flags[]': { + type: 'Boolean' as const, + sample_values: [true, false], + probability: 0.8, + }, + 'timestamps[]': { + type: 'Date' as const, + sample_values: [new Date('2023-01-01'), new Date('2023-06-15')], + probability: 0.7, + }, + 'ids[]': { + type: 'ObjectId' as const, + sample_values: ['642d766b7300158b1f22e972', '642d766b7300158b1f22e973'], + probability: 1.0, + }, + }; + + const result = toSimplifiedFieldInfo(input); + + const expected: SimplifiedFieldInfoTree = { + 'tags[]': 'String', + 'scores[]': 'Number', + 'matrix[][]': 'Number', + 'flags[]': 'Boolean', + 'timestamps[]': 'Date', + 'ids[]': 'ObjectId', + }; + + expect(result).to.deep.equal(expected); + }); + + it('handles nested arrays of documents', function () { + const input = { + 'items[].id': { + type: 'Number' as const, + sample_values: [1, 2], + probability: 1.0, + }, + 'items[].name': { + type: 'String' as const, + sample_values: ['Item A', 'Item B'], + probability: 1.0, + }, + 'items[].metadata.createdBy': { + type: 'String' as const, + sample_values: ['admin', 'user'], + probability: 0.9, + }, + 'items[].metadata.tags[]': { + type: 'String' as const, + sample_values: ['urgent', 'review', 'approved'], + probability: 0.8, + }, + 'items[].price': { + type: 'Decimal128' as const, + sample_values: [19.99, 29.99], + probability: 0.95, + }, + 'items[].binary': { + type: 'Binary' as const, + sample_values: ['dGVzdA=='], + probability: 0.3, + }, + }; + + const result = toSimplifiedFieldInfo(input); + + const expected: SimplifiedFieldInfoTree = { + 'items[]': { + id: 'Number', + name: 'String', + metadata: { + createdBy: 'String', + 'tags[]': 'String', + }, + price: 'Decimal128', + binary: 'Binary', + }, + }; + + expect(result).to.deep.equal(expected); + }); + + it('handles nested arrays of arrays', function () { + // Input based on complex nested array structures + const input = { + 'cube[][][]': { + type: 'Number' as const, + sample_values: [1, 2, 3, 4, 5, 6, 7, 8], + probability: 1.0, + }, + 'matrix[][].x': { + type: 'Number' as const, + sample_values: [1, 3], + probability: 1.0, + }, + 'matrix[][].y': { + type: 'Number' as const, + sample_values: [2, 4], + probability: 1.0, + }, + 'teams[].members[]': { + type: 'String' as const, + sample_values: ['Alice', 'Bob', 'Charlie'], + probability: 1.0, + }, + 'teams[].name': { + type: 'String' as const, + sample_values: ['Team A', 'Team B'], + probability: 1.0, + }, + 'complex[][].data[]': { + type: 'Long' as const, + sample_values: [123456789, 987654321], + probability: 0.9, + }, + 'complex[][].regex': { + type: 'RegExp' as const, + sample_values: ['pattern'], + probability: 0.6, + }, + 'complex[][].code': { + type: 'Code' as const, + sample_values: ['function() {}'], + probability: 0.4, + }, + 'nested[][].symbols[]': { + type: 'Symbol' as const, + sample_values: ['symbol1', 'symbol2'], + probability: 0.5, + }, + 'timestamps[][].created': { + type: 'Timestamp' as const, + sample_values: [4294967297], + probability: 0.8, + }, + 'keys[][].max': { + type: 'MaxKey' as const, + sample_values: ['MaxKey'], + probability: 0.2, + }, + 'keys[][].min': { + type: 'MinKey' as const, + sample_values: ['MinKey'], + probability: 0.2, + }, + }; + + const result = toSimplifiedFieldInfo(input); + + const expected: SimplifiedFieldInfoTree = { + 'cube[][][]': 'Number', + 'matrix[][]': { + x: 'Number', + y: 'Number', + }, + 'teams[]': { + 'members[]': 'String', + name: 'String', + }, + 'complex[][]': { + 'data[]': 'Long', + regex: 'RegExp', + code: 'Code', + }, + 'nested[][]': { + 'symbols[]': 'Symbol', + }, + 'timestamps[][]': { + created: 'Timestamp', + }, + 'keys[][]': { + max: 'MaxKey', + min: 'MinKey', + }, + }; + + expect(result).to.deep.equal(expected); + }); +}); 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 new file mode 100644 index 00000000000..056dad9d670 --- /dev/null +++ b/packages/compass-collection/src/components/mock-data-generator-modal/to-simplified-field-info.ts @@ -0,0 +1,48 @@ +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'; + +type UserFriendlyFieldInfoNode = + | { [field: string]: UserFriendlyFieldInfoNode } + | FieldInfo['type']; +export type SimplifiedFieldInfoTree = { + [field: string]: UserFriendlyFieldInfoNode; +}; + +/** + * Usage is for display purposes only. The result is derived from the work of `processSchema`, + * ensuring that the user sees a simplification of what the LLM processes. + */ +export default function toSimplifiedFieldInfo( + input: ReturnType +): SimplifiedFieldInfoTree { + // ensure parent nodes are created before their children + const sortedFieldPaths = Object.keys(input).sort( + (f1, f2) => countSeparators(f1) - countSeparators(f2) + ); + + const result: SimplifiedFieldInfoTree = {}; + for (const path of sortedFieldPaths) { + const fieldParts = path.split(FIELD_NAME_SEPARATOR); + + let node = result; + for (let i = 0; i < fieldParts.length; i++) { + const part = fieldParts[i]; + + if (i === fieldParts.length - 1) { + node[part] = input[path].type; + break; + } + + if (typeof node[part] !== 'object' || node[part] === null) { + node[part] = {}; + } + node = node[part]; + } + } + return result; +} + +function countSeparators(input: string): number { + return input.split(FIELD_NAME_SEPARATOR).length - 1; +} diff --git a/packages/compass-collection/src/modules/collection-tab.ts b/packages/compass-collection/src/modules/collection-tab.ts index e1f3afa2eac..8c7323045f6 100644 --- a/packages/compass-collection/src/modules/collection-tab.ts +++ b/packages/compass-collection/src/modules/collection-tab.ts @@ -32,7 +32,10 @@ import { type FieldInfo, } from '../schema-analysis-types'; import { calculateSchemaDepth } from '../calculate-schema-depth'; -import { processSchema } from '../transform-schema-to-field-info'; +import { + processSchema, + ProcessSchemaUnsupportedStateError, +} 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'; @@ -51,6 +54,13 @@ function isAction( const ERROR_CODE_MAX_TIME_MS_EXPIRED = 50; function getErrorDetails(error: Error): SchemaAnalysisError { + if (error instanceof ProcessSchemaUnsupportedStateError) { + return { + errorType: 'unsupportedState', + errorMessage: error.message, + }; + } + const errorCode = (error as MongoError).code; const errorMessage = error.message || 'Unknown error'; let errorType: SchemaAnalysisError['errorType'] = 'general'; diff --git a/packages/compass-collection/src/schema-analysis-types.ts b/packages/compass-collection/src/schema-analysis-types.ts index 83a501ceb20..286fc166158 100644 --- a/packages/compass-collection/src/schema-analysis-types.ts +++ b/packages/compass-collection/src/schema-analysis-types.ts @@ -22,7 +22,7 @@ export type SchemaAnalysisStartedState = { export type SchemaAnalysisError = { errorMessage: string; - errorType: 'timeout' | 'highComplexity' | 'general'; + errorType: 'timeout' | 'highComplexity' | 'general' | 'unsupportedState'; }; export type SchemaAnalysisErrorState = { 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 04002bcda05..06bd64de345 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 @@ -1105,4 +1105,89 @@ describe('processSchema', function () { }, }); }); + + /** + * Verifies malformed field paths can be caught by bugs in the construction logic. + * These are unlikely to occur with valid `Schema` inputs to `processSchema`. + */ + describe('validateFieldPath error conditions', function () { + it('throws error for empty field parts', function () { + const schema: Schema = { + fields: [ + { + name: 'parent', + path: ['parent'], + count: 1, + type: ['Document'], + probability: 1.0, + hasDuplicates: false, + types: [ + { + name: 'Document', + bsonType: 'Document', + path: ['parent'], + count: 1, + probability: 1.0, + fields: [ + { + name: '', // Empty field name + path: ['parent', ''], + count: 1, + type: ['String'], + probability: 1.0, + hasDuplicates: false, + types: [ + { + name: 'String', + bsonType: 'String', + path: ['parent', ''], + count: 1, + probability: 1.0, + values: ['test'], + }, + ], + }, + ], + }, + ], + }, + ], + count: 1, + }; + + expect(() => processSchema(schema)).to.throw( + "invalid fieldPath 'parent.': field parts cannot be empty" + ); + }); + + it('throws error for a field part that only contains "[]"', function () { + const schema: Schema = { + fields: [ + { + name: '[]', // Field name is just "[]" + path: ['[]'], + count: 1, + type: ['String'], + probability: 1.0, + hasDuplicates: false, + types: [ + { + name: 'String', + bsonType: 'String', + path: ['[]'], + count: 1, + probability: 1.0, + values: ['test'], + }, + ], + }, + ], + count: 1, + }; + + expect(() => processSchema(schema)).to.throw( + "invalid fieldPath '[]': field parts must have characters other than '[]'" + ); + }); + }); }); 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 9d782c7a187..f88dfb1e6a1 100644 --- a/packages/compass-collection/src/transform-schema-to-field-info.ts +++ b/packages/compass-collection/src/transform-schema-to-field-info.ts @@ -43,6 +43,21 @@ import { * Maximum number of sample values to include for each field */ const MAX_SAMPLE_VALUES = 10; +export const FIELD_NAME_SEPARATOR = '.'; + +export class ProcessSchemaUnsupportedStateError extends Error { + constructor(message: string) { + super(message); + this.name = 'ProcessSchemaUnsupportedStateError'; + } +} + +export class ProcessSchemaValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ProcessSchemaValidationError'; + } +} /** * Converts a BSON value to its primitive JavaScript equivalent @@ -122,6 +137,8 @@ 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. + * + * The result is used for the Mock Data Generator LLM call. */ export function processSchema(schema: Schema): Record { const result: Record = {}; @@ -135,6 +152,11 @@ export function processSchema(schema: Schema): Record { processNamedField(field, '', result); } + // post-processing validation + for (const fieldPath of Object.keys(result)) { + validateFieldPath(fieldPath); + } + return result; } @@ -156,6 +178,12 @@ function processNamedField( return; } + if (field.name.includes(FIELD_NAME_SEPARATOR)) { + throw new ProcessSchemaUnsupportedStateError( + `no support for field names that contain a '${FIELD_NAME_SEPARATOR}' ; field name: '${field.name}'` + ); + } + const currentPath = pathPrefix ? `${pathPrefix}.${field.name}` : field.name; // Process based on the type @@ -221,3 +249,25 @@ function getMostFrequentType(types: SchemaType[]): SchemaType | null { return validTypes[0] || null; } + +/** + * Note: This validation takes a defensive stance. As illustrated by the unit tests, malformed + * inputs are required to simulate these unlikely errors. + */ +function validateFieldPath(fieldPath: string) { + const parts = fieldPath.split(FIELD_NAME_SEPARATOR); + + for (const part of parts) { + if (part === '') { + throw new ProcessSchemaValidationError( + `invalid fieldPath '${fieldPath}': field parts cannot be empty` + ); + } + + if (part.replaceAll('[]', '') === '') { + throw new ProcessSchemaValidationError( + `invalid fieldPath '${fieldPath}': field parts must have characters other than '[]'` + ); + } + } +} diff --git a/packages/compass-components/src/components/document-list/document.tsx b/packages/compass-components/src/components/document-list/document.tsx index 71368e43955..64788bf7c9e 100644 --- a/packages/compass-components/src/components/document-list/document.tsx +++ b/packages/compass-components/src/components/document-list/document.tsx @@ -88,6 +88,7 @@ const HadronDocument: React.FunctionComponent<{ extraGutterWidth?: number; onUpdateQuery?: (field: string, value: unknown) => void; query?: Record; + className?: string; }> = ({ value: document, editable = false, @@ -96,6 +97,7 @@ const HadronDocument: React.FunctionComponent<{ extraGutterWidth, onUpdateQuery, query, + className = '', }) => { const { elements, visibleElements } = useHadronDocument(document); const [autoFocus, setAutoFocus] = useState<{ @@ -130,7 +132,7 @@ const HadronDocument: React.FunctionComponent<{ ); return ( -
+