diff --git a/package-lock.json b/package-lock.json index f25675ffda8..368b008a5f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31224,9 +31224,9 @@ } }, "node_modules/mongodb": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.17.0.tgz", - "integrity": "sha512-neerUzg/8U26cgruLysKEjJvoNSXhyID3RvzvdcpsIi2COYM3FS3o9nlH7fxFtefTb942dX3W9i37oPfCVj4wA==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.18.0.tgz", + "integrity": "sha512-fO5ttN9VC8P0F5fqtQmclAkgXZxbIkYRTUi1j8JO6IYwvamkhtYDilJr35jOPELR49zqCJgXZWwCtW7B+TM8vQ==", "license": "Apache-2.0", "dependencies": { "@mongodb-js/saslprep": "^1.1.9", @@ -44122,8 +44122,11 @@ "@mongodb-js/connection-info": "^0.17.0", "@mongodb-js/mongodb-constants": "^0.12.2", "compass-preferences-model": "^2.49.0", + "hadron-document": "^8.9.4", + "mongodb": "^6.18.0", "mongodb-collection-model": "^5.31.0", "mongodb-ns": "^2.4.2", + "mongodb-schema": "^12.6.2", "react": "^17.0.2", "react-redux": "^8.1.3", "redux": "^4.2.1", @@ -56774,9 +56777,12 @@ "compass-preferences-model": "^2.49.0", "depcheck": "^1.4.1", "electron-mocha": "^12.2.0", + "hadron-document": "^8.9.4", "mocha": "^10.2.0", + "mongodb": "^6.18.0", "mongodb-collection-model": "^5.31.0", "mongodb-ns": "^2.4.2", + "mongodb-schema": "^12.6.2", "nyc": "^15.1.0", "react": "^17.0.2", "react-dom": "^17.0.2", @@ -60694,14 +60700,14 @@ "@babel/eslint-parser": "^7.22.7", "@babel/preset-env": "^7.22.7", "@babel/preset-react": "^7.22.5", - "@typescript-eslint/eslint-plugin": "^8.39.0", - "@typescript-eslint/parser": "^8.39.0", + "@typescript-eslint/eslint-plugin": "^5.59.0", + "@typescript-eslint/parser": "^5.59.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-filename-rules": "^1.2.0", - "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-jsx-a11y": "^6.4.1", "eslint-plugin-mocha": "^8.0.0", - "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^5.2.0" + "eslint-plugin-react": "^7.24.0", + "eslint-plugin-react-hooks": "^4.2.0" } }, "@mongodb-js/eslint-plugin-compass": { @@ -65155,7 +65161,7 @@ "requires": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^8.0.0", - "@types/react-dom": "^17.0.25" + "@types/react-dom": "<18.0.0" } }, "@testing-library/react-hooks": { @@ -65164,8 +65170,8 @@ "integrity": "sha512-dYxpz8u9m4q1TuzfcUApqi8iFfR6R0FaMbr2hjZJy1uC8z+bO/K4v8Gs9eogGKYQop7QsrBTFkv/BCF7MzD2Cg==", "requires": { "@babel/runtime": "^7.12.5", - "@types/react": "^17.0.83", - "@types/react-dom": "^17.0.25", + "@types/react": ">=16.9.0", + "@types/react-dom": ">=16.9.0", "@types/react-test-renderer": ">=16.9.0", "react-error-boundary": "^3.1.0" } @@ -65447,7 +65453,7 @@ "dev": true, "requires": { "@types/cheerio": "*", - "@types/react": "^17.0.83" + "@types/react": "^16" } }, "@types/estree": { @@ -65528,7 +65534,7 @@ "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", "requires": { - "@types/react": "^17.0.83", + "@types/react": "*", "hoist-non-react-statics": "^3.3.0" } }, @@ -65730,7 +65736,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.25.tgz", "integrity": "sha512-urx7A7UxkZQmThYA4So0NelOVjx3V4rNFVJwp0WZlbIK5eM4rNJDiN3R/E9ix0MBh6kAEojk/9YL+Te6D9zHNA==", "requires": { - "@types/react": "^17.0.83" + "@types/react": "^17" } }, "@types/react-is": { @@ -65738,7 +65744,7 @@ "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-zts4lhQn5ia0cF/y2+3V6Riu0MAfez9/LJYavdM8TvcVl+S91A/7VWxyBT8hbRuWspmuCaiGI0F41OJYGrKhRA==", "requires": { - "@types/react": "^17.0.83" + "@types/react": "^18" } }, "@types/react-test-renderer": { @@ -65746,7 +65752,7 @@ "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz", "integrity": "sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw==", "requires": { - "@types/react": "^17.0.83" + "@types/react": "*" } }, "@types/react-transition-group": { @@ -65761,7 +65767,7 @@ "integrity": "sha512-GH8sAnBEM5GV9LTeiz56r4ZhMOUSrP43tAQNSRVxNexDjcNKLCEtnxusAItg1owFUFE6k0NslV26gqVClVvong==", "dev": true, "requires": { - "@types/react": "^17.0.83" + "@types/react": "*" } }, "@types/react-window": { @@ -65770,7 +65776,7 @@ "integrity": "sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw==", "dev": true, "requires": { - "@types/react": "^17.0.83" + "@types/react": "*" } }, "@types/reflux": { @@ -65779,7 +65785,7 @@ "integrity": "sha512-nRTTsQmy0prliP0I0GvpAbE27k7+I+MqD15gs4YuQGkuZjRHK65QHPLkykgHnPTdjZYNaY0sOvMQ7OtbcoDkKA==", "dev": true, "requires": { - "@types/react": "^17.0.83" + "@types/react": "*" } }, "@types/relateurl": { @@ -72175,7 +72181,7 @@ "integrity": "sha512-Dw8/Gs4vRjxY6/6i9wU0V+utmQO9kvh9XLnz3LIudviOnVYDEe2ec+0k+NQoMamn1VrjKgCUOWj5jG/5M5M0Qw==", "requires": { "array.prototype.flat": "^1.2.3", - "cheerio": "1.0.0-rc.10", + "cheerio": "^1.0.0-rc.3", "enzyme-shallow-equal": "^1.0.1", "function.prototype.name": "^1.1.2", "has": "^1.0.3", @@ -79712,9 +79718,9 @@ "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==" }, "mongodb": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.17.0.tgz", - "integrity": "sha512-neerUzg/8U26cgruLysKEjJvoNSXhyID3RvzvdcpsIi2COYM3FS3o9nlH7fxFtefTb942dX3W9i37oPfCVj4wA==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.18.0.tgz", + "integrity": "sha512-fO5ttN9VC8P0F5fqtQmclAkgXZxbIkYRTUi1j8JO6IYwvamkhtYDilJr35jOPELR49zqCJgXZWwCtW7B+TM8vQ==", "requires": { "@mongodb-js/saslprep": "^1.1.9", "bson": "^6.10.4", diff --git a/packages/compass-collection/package.json b/packages/compass-collection/package.json index 5fefe25c6c4..968191b08d3 100644 --- a/packages/compass-collection/package.json +++ b/packages/compass-collection/package.json @@ -48,6 +48,7 @@ "reformat": "npm run eslint . -- --fix && npm run prettier -- --write ." }, "dependencies": { + "@mongodb-js/compass-app-registry": "^9.4.18", "@mongodb-js/compass-app-stores": "^7.55.0", "@mongodb-js/compass-components": "^1.47.0", "@mongodb-js/compass-connections": "^1.69.0", @@ -57,9 +58,11 @@ "@mongodb-js/connection-info": "^0.17.0", "@mongodb-js/mongodb-constants": "^0.12.2", "compass-preferences-model": "^2.49.0", - "@mongodb-js/compass-app-registry": "^9.4.18", + "hadron-document": "^8.9.4", + "mongodb": "^6.18.0", "mongodb-collection-model": "^5.31.0", "mongodb-ns": "^2.4.2", + "mongodb-schema": "^12.6.2", "react": "^17.0.2", "react-redux": "^8.1.3", "redux": "^4.2.1", diff --git a/packages/compass-collection/src/calculate-schema-depth.spec.ts b/packages/compass-collection/src/calculate-schema-depth.spec.ts new file mode 100644 index 00000000000..743b833da67 --- /dev/null +++ b/packages/compass-collection/src/calculate-schema-depth.spec.ts @@ -0,0 +1,165 @@ +import { expect } from 'chai'; +import { calculateSchemaDepth } from './calculate-schema-depth'; +import type { + Schema, + SchemaField, + DocumentSchemaType, + ArraySchemaType, +} from 'mongodb-schema'; + +describe('calculateSchemaDepth', function () { + it('returns 1 for flat schema', async function () { + const schema: Schema = { + fields: [ + { name: 'a', types: [{ bsonType: 'String' }] } as SchemaField, + { name: 'b', types: [{ bsonType: 'Number' }] } as SchemaField, + ], + count: 2, + }; + const depth = await calculateSchemaDepth(schema); + expect(depth).to.equal(1); + }); + + it('returns correct depth for nested document', async function () { + const schema: Schema = { + fields: [ + { + name: 'a', + types: [ + { + bsonType: 'Document', + fields: [ + { + name: 'b', + types: [ + { + bsonType: 'Document', + fields: [ + { + name: 'c', + types: [{ bsonType: 'String' }], + } as SchemaField, + ], + } as DocumentSchemaType, + ], + } as SchemaField, + ], + } as DocumentSchemaType, + ], + } as SchemaField, + ], + count: 1, + }; + const depth = await calculateSchemaDepth(schema); + expect(depth).to.equal(3); + }); + + it('returns correct depth for nested arrays', async function () { + const schema: Schema = { + fields: [ + { + name: 'arr', + types: [ + { + bsonType: 'Array', + types: [ + { + bsonType: 'Array', + types: [ + { + bsonType: 'Document', + fields: [ + { + name: 'x', + types: [{ bsonType: 'String' }], + } as SchemaField, + ], + } as DocumentSchemaType, + ], + } as ArraySchemaType, + ], + } as ArraySchemaType, + ], + } as SchemaField, + ], + count: 1, + }; + const depth = await calculateSchemaDepth(schema); + expect(depth).to.equal(4); + }); + + it('returns 0 for empty schema', async function () { + const schema: Schema = { fields: [], count: 0 }; + const depth = await calculateSchemaDepth(schema); + expect(depth).to.equal(0); + }); + + it('handles mixed types at root', async function () { + const schema: Schema = { + fields: [ + { + name: 'a', + types: [ + { bsonType: 'String' }, + { + bsonType: 'Document', + fields: [ + { + name: 'b', + types: [{ bsonType: 'Number' }], + } as SchemaField, + ], + } as DocumentSchemaType, + ], + } as SchemaField, + ], + count: 1, + }; + const depth = await calculateSchemaDepth(schema); + expect(depth).to.equal(2); + }); + + it('handles deeply nested mixed arrays and documents', async function () { + const schema: Schema = { + fields: [ + { + name: 'root', + types: [ + { + bsonType: 'Array', + types: [ + { + bsonType: 'Document', + fields: [ + { + name: 'nestedArr', + types: [ + { + bsonType: 'Array', + types: [ + { + bsonType: 'Document', + fields: [ + { + name: 'leaf', + types: [{ bsonType: 'String' }], + } as SchemaField, + ], + } as DocumentSchemaType, + ], + } as ArraySchemaType, + ], + } as SchemaField, + ], + } as DocumentSchemaType, + ], + } as ArraySchemaType, + ], + } as SchemaField, + ], + count: 1, + }; + const depth = await calculateSchemaDepth(schema); + expect(depth).to.equal(5); + }); +}); diff --git a/packages/compass-collection/src/calculate-schema-depth.ts b/packages/compass-collection/src/calculate-schema-depth.ts new file mode 100644 index 00000000000..82a0ed02cb9 --- /dev/null +++ b/packages/compass-collection/src/calculate-schema-depth.ts @@ -0,0 +1,57 @@ +import type { + ArraySchemaType, + DocumentSchemaType, + Schema, + SchemaField, + SchemaType, +} from 'mongodb-schema'; + +// Every 1000 iterations, unblock the thread. +const UNBLOCK_INTERVAL_COUNT = 1000; +const unblockThread = async () => + new Promise((resolve) => setTimeout(resolve)); + +export async function calculateSchemaDepth(schema: Schema): Promise { + let unblockThreadCounter = 0; + let deepestPath = 0; + + async function traverseSchemaTree( + fieldsOrTypes: SchemaField[] | SchemaType[], + depth: number + ): Promise { + unblockThreadCounter++; + if (unblockThreadCounter === UNBLOCK_INTERVAL_COUNT) { + unblockThreadCounter = 0; + await unblockThread(); + } + + if (!fieldsOrTypes || fieldsOrTypes.length === 0) { + return; + } + + deepestPath = Math.max(depth, deepestPath); + + for (const fieldOrType of fieldsOrTypes) { + if ((fieldOrType as DocumentSchemaType).bsonType === 'Document') { + await traverseSchemaTree( + (fieldOrType as DocumentSchemaType).fields, + depth + 1 // Increment by one when we go a level deeper. + ); + } else if ( + (fieldOrType as ArraySchemaType).bsonType === 'Array' || + (fieldOrType as SchemaField).types + ) { + const increment = + (fieldOrType as ArraySchemaType).bsonType === 'Array' ? 1 : 0; + await traverseSchemaTree( + (fieldOrType as ArraySchemaType | SchemaField).types, + depth + increment // Increment by one when we go a level deeper. + ); + } + } + } + + await traverseSchemaTree(schema.fields, 1); + + return deepestPath; +} diff --git a/packages/compass-collection/src/modules/collection-tab.ts b/packages/compass-collection/src/modules/collection-tab.ts index 12655bc0dce..223788fd300 100644 --- a/packages/compass-collection/src/modules/collection-tab.ts +++ b/packages/compass-collection/src/modules/collection-tab.ts @@ -1,4 +1,6 @@ import type { Reducer, AnyAction, Action } from 'redux'; +import { analyzeDocuments, type Schema } from 'mongodb-schema'; + import type { CollectionMetadata } from 'mongodb-collection-model'; import type { ThunkAction } from 'redux-thunk'; import type AppRegistry from '@mongodb-js/compass-app-registry'; @@ -6,6 +8,24 @@ import type { workspacesServiceLocator } from '@mongodb-js/compass-workspaces/pr import type { CollectionSubtab } from '@mongodb-js/compass-workspaces'; import type { DataService } from '@mongodb-js/compass-connections/provider'; import type { experimentationServiceLocator } from '@mongodb-js/compass-telemetry/provider'; +import { type Logger, mongoLogId } from '@mongodb-js/compass-logging/provider'; +import { type PreferencesAccess } from 'compass-preferences-model/provider'; +import { isInternalFieldPath } from 'hadron-document'; +import toNS from 'mongodb-ns'; +import { + SCHEMA_ANALYSIS_STATE_ANALYZING, + SCHEMA_ANALYSIS_STATE_COMPLETE, + SCHEMA_ANALYSIS_STATE_ERROR, + SCHEMA_ANALYSIS_STATE_INITIAL, + type SchemaAnalysisError, + type SchemaAnalysisState, +} from '../schema-analysis-types'; +import { calculateSchemaDepth } from '../calculate-schema-depth'; +import type { MongoError } from 'mongodb'; + +const DEFAULT_SAMPLE_SIZE = 100; + +const NO_DOCUMENTS_ERROR = 'No documents found in the collection to analyze.'; function isAction( action: AnyAction, @@ -14,6 +34,24 @@ function isAction( return action.type === type; } +const ERROR_CODE_MAX_TIME_MS_EXPIRED = 50; + +function getErrorDetails(error: Error): SchemaAnalysisError { + const errorCode = (error as MongoError).code; + const errorMessage = error.message || 'Unknown error'; + let errorType: SchemaAnalysisError['errorType'] = 'general'; + if (errorCode === ERROR_CODE_MAX_TIME_MS_EXPIRED) { + errorType = 'timeout'; + } else if (error.message.includes('Schema analysis aborted: Fields count')) { + errorType = 'highComplexity'; + } + + return { + errorType, + errorMessage, + }; +} + type CollectionThunkAction = ThunkAction< R, CollectionState, @@ -22,6 +60,8 @@ type CollectionThunkAction = ThunkAction< dataService: DataService; workspaces: ReturnType; experimentationServices: ReturnType; + logger: Logger; + preferences: PreferencesAccess; }, A >; @@ -31,10 +71,14 @@ export type CollectionState = { namespace: string; metadata: CollectionMetadata | null; editViewName?: string; + schemaAnalysis: SchemaAnalysisState; }; enum CollectionActions { CollectionMetadataFetched = 'compass-collection/CollectionMetadataFetched', + SchemaAnalysisStarted = 'compass-collection/SchemaAnalysisStarted', + SchemaAnalysisFinished = 'compass-collection/SchemaAnalysisFinished', + SchemaAnalysisFailed = 'compass-collection/SchemaAnalysisFailed', } interface CollectionMetadataFetchedAction { @@ -42,12 +86,34 @@ interface CollectionMetadataFetchedAction { metadata: CollectionMetadata; } +interface SchemaAnalysisStartedAction { + type: CollectionActions.SchemaAnalysisStarted; +} + +interface SchemaAnalysisFinishedAction { + type: CollectionActions.SchemaAnalysisFinished; + schema: Schema; + sampleDocument: Document; + schemaMetadata: { + maxNestingDepth: number; + validationRules: Document | null; + }; +} + +interface SchemaAnalysisFailedAction { + type: CollectionActions.SchemaAnalysisFailed; + error: Error; +} + const reducer: Reducer = ( state = { // TODO(COMPASS-7782): use hook to get the workspace tab id instead workspaceTabId: '', namespace: '', metadata: null, + schemaAnalysis: { + status: SCHEMA_ANALYSIS_STATE_INITIAL, + }, }, action ) => { @@ -62,6 +128,57 @@ const reducer: Reducer = ( metadata: action.metadata, }; } + + if ( + isAction( + action, + CollectionActions.SchemaAnalysisStarted + ) + ) { + return { + ...state, + schemaAnalysis: { + status: SCHEMA_ANALYSIS_STATE_ANALYZING, + error: null, + schema: null, + sampleDocument: null, + schemaMetadata: null, + }, + }; + } + + if ( + isAction( + action, + CollectionActions.SchemaAnalysisFinished + ) + ) { + return { + ...state, + schemaAnalysis: { + status: SCHEMA_ANALYSIS_STATE_COMPLETE, + schema: action.schema, + sampleDocument: action.sampleDocument, + schemaMetadata: action.schemaMetadata, + }, + }; + } + + if ( + isAction( + action, + CollectionActions.SchemaAnalysisFailed + ) + ) { + return { + ...state, + schemaAnalysis: { + status: SCHEMA_ANALYSIS_STATE_ERROR, + error: getErrorDetails(action.error), + }, + }; + } + return state; }; @@ -82,6 +199,90 @@ export const selectTab = ( }; }; +export const analyzeCollectionSchema = (): CollectionThunkAction< + Promise +> => { + return async (dispatch, getState, { dataService, preferences, logger }) => { + const { schemaAnalysis, namespace } = getState(); + const analysisStatus = schemaAnalysis.status; + if (analysisStatus === SCHEMA_ANALYSIS_STATE_ANALYZING) { + logger.debug( + 'Schema analysis is already in progress, skipping new analysis.' + ); + return; + } + + try { + logger.debug('Schema analysis started.'); + + dispatch({ + type: CollectionActions.SchemaAnalysisStarted, + }); + + // Sample documents + const samplingOptions = { size: DEFAULT_SAMPLE_SIZE }; + const driverOptions = { + maxTimeMS: preferences.getPreferences().maxTimeMS, + }; + const sampleDocuments = await dataService.sample( + namespace, + samplingOptions, + driverOptions, + { + fallbackReadPreference: 'secondaryPreferred', + } + ); + if (sampleDocuments.length === 0) { + logger.debug(NO_DOCUMENTS_ERROR); + dispatch({ + type: CollectionActions.SchemaAnalysisFailed, + error: new Error(NO_DOCUMENTS_ERROR), + }); + return; + } + + // Analyze sampled documents + const schemaAccessor = await analyzeDocuments(sampleDocuments); + const schema = await schemaAccessor.getInternalSchema(); + + // Filter out internal fields from the schema + schema.fields = schema.fields.filter( + ({ path }) => !isInternalFieldPath(path[0]) + ); + // TODO: Transform schema to structure that will be used by the LLM. + + const maxNestingDepth = await calculateSchemaDepth(schema); + const { database, collection } = toNS(namespace); + const collInfo = await dataService.collectionInfo(database, collection); + const validationRules = collInfo?.validation?.validator ?? null; + const schemaMetadata = { + maxNestingDepth, + validationRules, + }; + dispatch({ + type: CollectionActions.SchemaAnalysisFinished, + schema, + sampleDocument: sampleDocuments[0], + schemaMetadata, + }); + } catch (err: any) { + logger.log.error( + mongoLogId(1_001_000_363), + 'Collection', + 'Schema analysis failed', + { + namespace, + error: err.message, + } + ); + dispatch({ + type: CollectionActions.SchemaAnalysisFailed, + error: err as Error, + }); + } + }; +}; + export type CollectionTabPluginMetadata = CollectionMetadata & { /** * Initial query for the query bar diff --git a/packages/compass-collection/src/schema-analysis-types.ts b/packages/compass-collection/src/schema-analysis-types.ts new file mode 100644 index 00000000000..3ca6b85e39a --- /dev/null +++ b/packages/compass-collection/src/schema-analysis-types.ts @@ -0,0 +1,46 @@ +import { type Schema } from 'mongodb-schema'; + +export const SCHEMA_ANALYSIS_STATE_INITIAL = 'initial'; +export const SCHEMA_ANALYSIS_STATE_ANALYZING = 'analyzing'; +export const SCHEMA_ANALYSIS_STATE_COMPLETE = 'complete'; +export const SCHEMA_ANALYSIS_STATE_ERROR = 'error'; + +export type SchemaAnalysisStatus = + | typeof SCHEMA_ANALYSIS_STATE_INITIAL + | typeof SCHEMA_ANALYSIS_STATE_ANALYZING + | typeof SCHEMA_ANALYSIS_STATE_COMPLETE + | typeof SCHEMA_ANALYSIS_STATE_ERROR; + +export type SchemaAnalysisInitialState = { + status: typeof SCHEMA_ANALYSIS_STATE_INITIAL; +}; + +export type SchemaAnalysisStartedState = { + status: typeof SCHEMA_ANALYSIS_STATE_ANALYZING; +}; + +export type SchemaAnalysisError = { + errorMessage: string; + errorType: 'timeout' | 'highComplexity' | 'general'; +}; + +export type SchemaAnalysisErrorState = { + status: typeof SCHEMA_ANALYSIS_STATE_ERROR; + error: SchemaAnalysisError; +}; + +export type SchemaAnalysisCompletedState = { + status: typeof SCHEMA_ANALYSIS_STATE_COMPLETE; + schema: Schema; + sampleDocument: Document; + schemaMetadata: { + maxNestingDepth: number; + validationRules: Document | null; + }; +}; + +export type SchemaAnalysisState = + | SchemaAnalysisErrorState + | SchemaAnalysisInitialState + | SchemaAnalysisStartedState + | SchemaAnalysisCompletedState; diff --git a/packages/compass-collection/src/stores/collection-tab.spec.ts b/packages/compass-collection/src/stores/collection-tab.spec.ts index 3d49b319c35..d200d24b0d9 100644 --- a/packages/compass-collection/src/stores/collection-tab.spec.ts +++ b/packages/compass-collection/src/stores/collection-tab.spec.ts @@ -1,6 +1,7 @@ import type { CollectionTabOptions } from './collection-tab'; import { activatePlugin } from './collection-tab'; import { selectTab } from '../modules/collection-tab'; +import * as collectionTabModule from '../modules/collection-tab'; import { waitFor } from '@mongodb-js/testing-library-compass'; import Sinon from 'sinon'; import AppRegistry from '@mongodb-js/compass-app-registry'; @@ -11,6 +12,7 @@ import type { connectionInfoRefLocator } from '@mongodb-js/compass-connections/p import { createNoopLogger } from '@mongodb-js/compass-logging/provider'; import { ReadOnlyPreferenceAccess } from 'compass-preferences-model/provider'; import { ExperimentTestName } from '@mongodb-js/compass-telemetry/provider'; +import { type CollectionMetadata } from 'mongodb-collection-model'; const defaultMetadata = { namespace: 'test.foo', @@ -27,16 +29,6 @@ const defaultTabOptions = { namespace: defaultMetadata.namespace, }; -const mockCollection = { - _id: defaultMetadata.namespace, - fetchMetadata() { - return Promise.resolve(defaultMetadata); - }, - toJSON() { - return this; - }, -}; - const mockAtlasConnectionInfo = { current: { id: 'test-connection', @@ -67,6 +59,9 @@ describe('Collection Tab Content store', function () { const sandbox = Sinon.createSandbox(); const localAppRegistry = sandbox.spy(new AppRegistry()); + const analyzeCollectionSchemaStub = sandbox + .stub(collectionTabModule, 'analyzeCollectionSchema') + .returns(async () => {}); const dataService = {} as any; let store: ReturnType['store']; let deactivate: ReturnType['deactivate']; @@ -85,8 +80,18 @@ describe('Collection Tab Content store', function () { enableGenAIFeatures: true, enableGenAIFeaturesAtlasOrg: true, cloudFeatureRolloutAccess: { GEN_AI_COMPASS: true }, - }) + }), + collectionMetadata: Partial = defaultMetadata ) => { + const mockCollection = { + _id: collectionMetadata.namespace, + fetchMetadata() { + return Promise.resolve(collectionMetadata); + }, + toJSON() { + return this; + }, + }; ({ store, deactivate } = activatePlugin( { ...defaultTabOptions, @@ -107,7 +112,7 @@ describe('Collection Tab Content store', function () { await waitFor(() => { expect(store.getState()) .to.have.property('metadata') - .deep.eq(defaultMetadata); + .deep.eq(collectionMetadata); }); return store; }; @@ -231,4 +236,40 @@ describe('Collection Tab Content store', function () { }); }); }); + + describe('schema analysis on collection load', function () { + it('should start schema analysis if collection is not read-only and not time-series', async function () { + await configureStore(); + + expect(analyzeCollectionSchemaStub).to.have.been.calledOnce; + }); + + it('should not start schema analysis if collection is read-only', async function () { + await configureStore( + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { ...defaultMetadata, isReadonly: true } + ); + + expect(analyzeCollectionSchemaStub).to.not.have.been.called; + }); + + it('should not start schema analysis if collection is time-series', async function () { + await configureStore( + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { ...defaultMetadata, isTimeSeries: true } + ); + + expect(analyzeCollectionSchemaStub).to.not.have.been.called; + }); + }); }); diff --git a/packages/compass-collection/src/stores/collection-tab.ts b/packages/compass-collection/src/stores/collection-tab.ts index 925572bf7e3..f5b4170f9d2 100644 --- a/packages/compass-collection/src/stores/collection-tab.ts +++ b/packages/compass-collection/src/stores/collection-tab.ts @@ -1,10 +1,12 @@ import type AppRegistry from '@mongodb-js/compass-app-registry'; import type { DataService } from '@mongodb-js/compass-connections/provider'; import { createStore, applyMiddleware } from 'redux'; + import thunk from 'redux-thunk'; import reducer, { selectTab, collectionMetadataFetched, + analyzeCollectionSchema, } from '../modules/collection-tab'; import type { Collection } from '@mongodb-js/compass-app-stores/provider'; import type { ActivateHelpers } from '@mongodb-js/compass-app-registry'; @@ -17,6 +19,7 @@ import { type PreferencesAccess, } from 'compass-preferences-model/provider'; import { ExperimentTestName } from '@mongodb-js/compass-telemetry/provider'; +import { SCHEMA_ANALYSIS_STATE_INITIAL } from '../schema-analysis-types'; export type CollectionTabOptions = { /** @@ -77,6 +80,9 @@ export function activatePlugin( namespace, metadata: null, editViewName, + schemaAnalysis: { + status: SCHEMA_ANALYSIS_STATE_INITIAL, + }, }, applyMiddleware( thunk.withExtraArgument({ @@ -84,6 +90,8 @@ export function activatePlugin( workspaces, localAppRegistry, experimentationServices, + logger, + preferences, }) ) ); @@ -125,6 +133,11 @@ export function activatePlugin( }); }); } + + if (!metadata.isReadonly && !metadata.isTimeSeries) { + // TODO: Consider checking experiment variant + void store.dispatch(analyzeCollectionSchema()); + } }); return {