diff --git a/package-lock.json b/package-lock.json index 235b0d7bbf8..dca3b88211c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46427,6 +46427,40 @@ "xvfb-maybe": "^0.2.1" } }, + "packages/compass-schema-analysis": { + "name": "@mongodb-js/compass-schema-analysis", + "version": "1.0.0", + "extraneous": true, + "license": "SSPL", + "dependencies": { + "@mongodb-js/compass-connections": "^1.50.5", + "@mongodb-js/compass-logging": "^1.6.5", + "bson": "^6.10.3", + "compass-preferences-model": "^2.33.5", + "hadron-document": "^8.8.5", + "mongodb": "^6.14.1", + "mongodb-schema": "^12.4.0" + }, + "devDependencies": { + "@mongodb-js/eslint-config-compass": "^1.3.3", + "@mongodb-js/mocha-config-compass": "^1.6.3", + "@mongodb-js/prettier-config-compass": "^1.2.3", + "@mongodb-js/testing-library-compass": "^1.2.3", + "@mongodb-js/tsconfig-compass": "^1.2.3", + "@types/chai": "^4.2.21", + "@types/mocha": "^9.0.0", + "@types/sinon-chai": "^3.2.5", + "chai": "^4.3.6", + "depcheck": "^1.4.1", + "eslint": "^7.25.0", + "gen-esm-wrapper": "^1.1.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "prettier": "^2.7.1", + "sinon": "^17.0.1", + "typescript": "^5.0.4" + } + }, "packages/compass-schema-validation": { "name": "@mongodb-js/compass-schema-validation", "version": "6.50.5", @@ -46440,6 +46474,7 @@ "@mongodb-js/compass-editor": "^0.36.5", "@mongodb-js/compass-field-store": "^9.25.5", "@mongodb-js/compass-logging": "^1.6.5", + "@mongodb-js/compass-schema": "^6.51.5", "@mongodb-js/compass-telemetry": "^1.4.5", "@mongodb-js/compass-workspaces": "^0.31.5", "bson": "^6.10.3", @@ -46447,6 +46482,7 @@ "hadron-app-registry": "^9.4.5", "javascript-stringify": "^2.0.1", "lodash": "^4.17.21", + "mongodb": "^6.14.1", "mongodb-ns": "^2.4.2", "mongodb-query-parser": "^4.3.0", "react": "^17.0.2", @@ -50481,6 +50517,39 @@ "node": ">=0.10.0" } }, + "packages/schema-analysis": { + "name": "@mongodb-js/compass-schema-analysis", + "version": "1.0.0", + "extraneous": true, + "license": "SSPL", + "dependencies": { + "@mongodb-js/compass-connections": "^1.50.5", + "@mongodb-js/compass-logging": "^1.6.5", + "compass-preferences-model": "^2.33.5", + "mongodb": "^6.14.1", + "mongodb-schema": "^12.4.0" + }, + "devDependencies": { + "@mongodb-js/compass-connections": "^1.50.3", + "@mongodb-js/eslint-config-compass": "^1.3.3", + "@mongodb-js/mocha-config-compass": "^1.6.3", + "@mongodb-js/prettier-config-compass": "^1.2.3", + "@mongodb-js/testing-library-compass": "^1.2.3", + "@mongodb-js/tsconfig-compass": "^1.2.3", + "@types/chai": "^4.2.21", + "@types/mocha": "^9.0.0", + "@types/sinon-chai": "^3.2.5", + "chai": "^4.3.6", + "depcheck": "^1.4.1", + "eslint": "^7.25.0", + "gen-esm-wrapper": "^1.1.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "prettier": "^2.7.1", + "sinon": "^17.0.1", + "typescript": "^5.0.4" + } + }, "packages/ssh-tunnel": { "name": "@mongodb-js/ssh-tunnel", "version": "2.3.3", @@ -57988,6 +58057,7 @@ "@mongodb-js/compass-editor": "^0.36.5", "@mongodb-js/compass-field-store": "^9.25.5", "@mongodb-js/compass-logging": "^1.6.5", + "@mongodb-js/compass-schema": "^6.51.5", "@mongodb-js/compass-telemetry": "^1.4.5", "@mongodb-js/compass-workspaces": "^0.31.5", "@mongodb-js/eslint-config-compass": "^1.3.5", @@ -58009,6 +58079,7 @@ "javascript-stringify": "^2.0.1", "lodash": "^4.17.21", "mocha": "^10.2.0", + "mongodb": "^6.14.1", "mongodb-instance-model": "^12.26.5", "mongodb-ns": "^2.4.2", "mongodb-query-parser": "^4.3.0", diff --git a/packages/compass-e2e-tests/helpers/selectors.ts b/packages/compass-e2e-tests/helpers/selectors.ts index 48aec8a66a4..31ff13c4d76 100644 --- a/packages/compass-e2e-tests/helpers/selectors.ts +++ b/packages/compass-e2e-tests/helpers/selectors.ts @@ -1161,6 +1161,8 @@ export const ValidationActionSelector = '[data-testid="validation-action-selector"]'; export const ValidationLevelSelector = '[data-testid="validation-level-selector"]'; +export const GenerateValidationRulesButton = + '[data-testid="generate-rules-button"]'; // Find (Documents and Schema tabs) export const queryBar = (tabName: string): string => { diff --git a/packages/compass-e2e-tests/tests/collection-validation-tab.test.ts b/packages/compass-e2e-tests/tests/collection-validation-tab.test.ts index 65f00b477d8..79c5358cc38 100644 --- a/packages/compass-e2e-tests/tests/collection-validation-tab.test.ts +++ b/packages/compass-e2e-tests/tests/collection-validation-tab.test.ts @@ -8,6 +8,8 @@ import { import type { Compass } from '../helpers/compass'; import * as Selectors from '../helpers/selectors'; import { createNumbersCollection } from '../helpers/insert-data'; +import { expect } from 'chai'; +import { isTestingDesktop } from '../helpers/test-runner-context'; const NO_PREVIEW_DOCUMENTS = 'No Preview Documents'; const PASSING_VALIDATOR = '{ $jsonSchema: {} }'; @@ -52,6 +54,45 @@ describe('Collection validation tab', function () { await browser.setValidation(validation); } + context('when the schema validation is empty', function () { + before(async function () { + if (isTestingDesktop()) { + await browser.setFeature('enableExportSchema', true); + } + }); + + it('provides users with a button to generate rules', async function () { + await browser.clickVisible(Selectors.GenerateValidationRulesButton); + const editor = browser.$(Selectors.ValidationEditor); + await editor.waitForDisplayed(); + + // rules are generated + const generatedRules = await browser.getCodemirrorEditorText( + Selectors.ValidationEditor + ); + expect(JSON.parse(generatedRules)).to.deep.equal({ + $jsonSchema: { + bsonType: 'object', + required: ['_id', 'i', 'j'], + properties: { + _id: { + bsonType: 'objectId', + }, + i: { + bsonType: 'int', + }, + j: { + bsonType: 'int', + }, + }, + }, + }); + + // generated rules can be edited and saved + await browser.setValidation(PASSING_VALIDATOR); + }); + }); + context('when the schema validation is set or modified', function () { it('provides users with a single button to load sample documents', async function () { await addValidation(PASSING_VALIDATOR); diff --git a/packages/compass-schema-validation/package.json b/packages/compass-schema-validation/package.json index d16513c5dfc..62ed6d995b1 100644 --- a/packages/compass-schema-validation/package.json +++ b/packages/compass-schema-validation/package.json @@ -77,6 +77,7 @@ "@mongodb-js/compass-editor": "^0.36.5", "@mongodb-js/compass-field-store": "^9.25.5", "@mongodb-js/compass-logging": "^1.6.5", + "@mongodb-js/compass-schema": "^6.51.5", "@mongodb-js/compass-telemetry": "^1.4.5", "@mongodb-js/compass-workspaces": "^0.31.5", "bson": "^6.10.3", @@ -84,6 +85,7 @@ "hadron-app-registry": "^9.4.5", "javascript-stringify": "^2.0.1", "lodash": "^4.17.21", + "mongodb": "^6.14.1", "mongodb-ns": "^2.4.2", "mongodb-query-parser": "^4.3.0", "react": "^17.0.2", diff --git a/packages/compass-schema-validation/src/components/validation-states.spec.tsx b/packages/compass-schema-validation/src/components/validation-states.spec.tsx index bc4edc5fe75..cdf149c3d9e 100644 --- a/packages/compass-schema-validation/src/components/validation-states.spec.tsx +++ b/packages/compass-schema-validation/src/components/validation-states.spec.tsx @@ -31,8 +31,8 @@ const { renderWithConnections } = createPluginTestHelpers( describe('ValidationStates [Component]', function () { let props: any; - const render = (props: any) => { - return renderWithConnections(); + const render = (props: any, options: any = {}) => { + return renderWithConnections(, options); }; beforeEach(function () { @@ -255,13 +255,18 @@ describe('ValidationStates [Component]', function () { props.isZeroState = true; props.isLoaded = true; props.serverVersion = '3.2.0'; - - render(props); }); it('renders the zero state', function () { + render(props); expect(screen.getByTestId('empty-content')).to.exist; }); + + it('when enableExportSchema is set, shows button for rules generation', function () { + render(props, { preferences: { enableExportSchema: true } }); + const btn = screen.getByRole('button', { name: 'Generate rules' }); + expect(btn).to.be.visible; + }); }); context('when it is not in the zero state and not loaded', function () { diff --git a/packages/compass-schema-validation/src/components/validation-states.tsx b/packages/compass-schema-validation/src/components/validation-states.tsx index e13d9d8375c..ca31f5f47da 100644 --- a/packages/compass-schema-validation/src/components/validation-states.tsx +++ b/packages/compass-schema-validation/src/components/validation-states.tsx @@ -1,24 +1,47 @@ import React from 'react'; import { Banner, + BannerVariant, Button, ButtonVariant, + CancelLoader, EmptyContent, + ErrorSummary, Link, WarningSummary, css, spacing, } from '@mongodb-js/compass-components'; import { connect } from 'react-redux'; -import { usePreference } from 'compass-preferences-model/provider'; +import { usePreferences } from 'compass-preferences-model/provider'; import { changeZeroState } from '../modules/zero-state'; import type { RootState } from '../modules'; import ValidationEditor from './validation-editor'; import { SampleDocuments } from './sample-documents'; import { ZeroGraphic } from './zero-graphic'; +import { + clearRulesGenerationError, + generateValidationRules, + stopRulesGeneration, + type RulesGenerationError, +} from '../modules/rules-generation'; +import { DISTINCT_FIELDS_ABORT_THRESHOLD } from '@mongodb-js/compass-schema'; -const validationStatesStyles = css({ padding: spacing[3] }); +const validationStatesStyles = css({ + padding: spacing[400], + height: '100%', +}); const contentContainerStyles = css({ height: '100%' }); +const zeroStateButtonsStyles = css({ + display: 'flex', + gap: spacing[400], +}); + +const loaderStyles = css({ + height: '100%', + display: 'flex', + justifyContent: 'center', +}); /** * Warnings for the banner. @@ -46,8 +69,13 @@ const DOC_UPGRADE_REVISION = type ValidationStatesProps = { isZeroState: boolean; + isRulesGenerationInProgress?: boolean; + rulesGenerationError?: RulesGenerationError; isLoaded: boolean; changeZeroState: (value: boolean) => void; + generateValidationRules: () => void; + clearRulesGenerationError: () => void; + stopRulesGeneration: () => void; editMode: { collectionTimeSeries?: boolean; collectionReadOnly?: boolean; @@ -104,13 +132,81 @@ function ValidationBanners({ return null; } +const GeneratingScreen: React.FunctionComponent<{ + onCancelClicked: () => void; +}> = ({ onCancelClicked }) => { + return ( +
+ +
+ ); +}; + +const RulesGenerationErrorBanner: React.FunctionComponent<{ + error: RulesGenerationError; + onDismissError: () => void; +}> = ({ error, onDismissError }) => { + if (error?.errorType === 'timeout') { + return ( + + ); + } + if (error?.errorType === 'highComplexity') { + return ( + + The rules generation was aborted because the number of fields exceeds{' '} + {DISTINCT_FIELDS_ABORT_THRESHOLD}. Consider breaking up your data into + more collections with smaller documents, and using references to + consolidate the data you need.  + + Learn more + + + ); + } + + return ( + + ); +}; + export function ValidationStates({ isZeroState, + isRulesGenerationInProgress, + rulesGenerationError, isLoaded, changeZeroState, + generateValidationRules, + clearRulesGenerationError, + stopRulesGeneration, editMode, }: ValidationStatesProps) { - const readOnly = usePreference('readOnly'); + const { readOnly, enableExportSchema } = usePreferences([ + 'readOnly', + 'enableExportSchema', + ]); const isEditable = !editMode.collectionReadOnly && @@ -124,24 +220,47 @@ export function ValidationStates({ className={validationStatesStyles} data-testid="schema-validation-states" > + {rulesGenerationError && ( + + )} {isLoaded && ( <> - {isZeroState ? ( + {isZeroState && !isRulesGenerationInProgress && ( changeZeroState(false)} - variant={ButtonVariant.Primary} - size="small" - > - Add Rule - +
+ {enableExportSchema && ( + + )} + +
} callToActionLink={ @@ -149,7 +268,11 @@ export function ValidationStates({ } /> - ) : ( + )} + {isZeroState && isRulesGenerationInProgress && ( + + )} + {!isZeroState && (
@@ -172,6 +295,8 @@ const mapStateToProps = (state: RootState) => ({ isZeroState: state.isZeroState, isLoaded: state.isLoaded, editMode: state.editMode, + isRulesGenerationInProgress: state.rulesGeneration.isInProgress, + rulesGenerationError: state.rulesGeneration.error, }); /** @@ -179,4 +304,7 @@ const mapStateToProps = (state: RootState) => ({ */ export default connect(mapStateToProps, { changeZeroState, + generateValidationRules, + clearRulesGenerationError, + stopRulesGeneration, })(ValidationStates); diff --git a/packages/compass-schema-validation/src/components/zero-graphic.tsx b/packages/compass-schema-validation/src/components/zero-graphic.tsx index c5f1592a4f4..171ef6f40f0 100644 --- a/packages/compass-schema-validation/src/components/zero-graphic.tsx +++ b/packages/compass-schema-validation/src/components/zero-graphic.tsx @@ -1,5 +1,12 @@ import React, { useMemo } from 'react'; -import { palette, useDarkMode } from '@mongodb-js/compass-components'; +import { + css, + palette, + spacing, + useDarkMode, +} from '@mongodb-js/compass-components'; + +const svgStyles = css({ marginLeft: spacing[300] }); const ZeroGraphic: React.FunctionComponent = () => { const darkMode = useDarkMode(); @@ -16,6 +23,7 @@ const ZeroGraphic: React.FunctionComponent = () => { viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg" + className={svgStyles} > , connectionInfoRef: connectionInfoRefLocator, instance: mongoDBInstanceLocator, diff --git a/packages/compass-schema-validation/src/modules/index.ts b/packages/compass-schema-validation/src/modules/index.ts index dcbf69bd2f1..09fb5cf926e 100644 --- a/packages/compass-schema-validation/src/modules/index.ts +++ b/packages/compass-schema-validation/src/modules/index.ts @@ -25,12 +25,18 @@ import type { ThunkAction } from 'redux-thunk'; import type { PreferencesAccess } from 'compass-preferences-model'; import type { ConnectionInfoRef, - DataService, + DataService as OriginalDataService, } from '@mongodb-js/compass-connections/provider'; import type AppRegistry from 'hadron-app-registry'; import type { Logger } from '@mongodb-js/compass-logging/provider'; import type { TrackFunction } from '@mongodb-js/compass-telemetry'; import { type WorkspacesService } from '@mongodb-js/compass-workspaces/provider'; +import type { RulesGenerationState } from './rules-generation'; +import { + INITIAL_STATE as RULES_GENERATION_STATE, + rulesGenerationReducer, +} from './rules-generation'; +import type { analyzeSchema } from '@mongodb-js/compass-schema'; /** * Reset action constant. @@ -44,6 +50,7 @@ export interface RootState { namespace: NamespaceState; serverVersion: ServerVersionState; validation: ValidationState; + rulesGeneration: RulesGenerationState; sampleDocuments: SampleDocumentState; isZeroState: IsZeroStateState; isLoaded: IsLoadedState; @@ -60,17 +67,25 @@ export type RootAction = | EditModeAction | ResetAction; +export type DataService = Pick< + OriginalDataService, + | 'aggregate' + | 'collectionInfo' + | 'updateCollection' + | 'sample' + | 'isCancelError' +>; + export type SchemaValidationExtraArgs = { - dataService: Pick< - DataService, - 'aggregate' | 'collectionInfo' | 'updateCollection' - >; + dataService: DataService; connectionInfoRef: ConnectionInfoRef; preferences: PreferencesAccess; globalAppRegistry: AppRegistry; workspaces: WorkspacesService; logger: Logger; track: TrackFunction; + rulesGenerationAbortControllerRef: { current?: AbortController }; + analyzeSchema: typeof analyzeSchema; }; export type SchemaValidationThunkAction< @@ -85,6 +100,7 @@ export const INITIAL_STATE: RootState = { namespace: NS_INITIAL_STATE, serverVersion: SV_INITIAL_STATE, validation: VALIDATION_STATE, + rulesGeneration: RULES_GENERATION_STATE, sampleDocuments: SAMPLE_DOCUMENTS_STATE, isZeroState: IS_ZERO_STATE, isLoaded: IS_LOADED_STATE, @@ -94,7 +110,7 @@ export const INITIAL_STATE: RootState = { /** * The reducer. */ -const appReducer = combineReducers({ +const appReducer = combineReducers({ namespace, serverVersion, validation, @@ -102,6 +118,7 @@ const appReducer = combineReducers({ isZeroState, isLoaded, editMode, + rulesGeneration: rulesGenerationReducer, }); /** diff --git a/packages/compass-schema-validation/src/modules/rules-generation.ts b/packages/compass-schema-validation/src/modules/rules-generation.ts new file mode 100644 index 00000000000..352a611febd --- /dev/null +++ b/packages/compass-schema-validation/src/modules/rules-generation.ts @@ -0,0 +1,228 @@ +import type { SchemaValidationThunkAction } from '.'; +import { zeroStateChanged } from './zero-state'; +import { enableEditRules } from './edit-mode'; +import type { MongoError } from 'mongodb'; +import type { Action, AnyAction, Reducer } from 'redux'; +import { validationLevelChanged, validatorChanged } from './validation'; + +export function isAction( + action: AnyAction, + type: A['type'] +): action is A { + return action.type === type; +} + +export type ValidationServerAction = 'error' | 'warn'; +export type ValidationLevel = 'off' | 'moderate' | 'strict'; + +const ERROR_CODE_MAX_TIME_MS_EXPIRED = 50; + +const SAMPLE_SIZE = 1000; +const ABORT_MESSAGE = 'Operation cancelled'; + +export const enum RulesGenerationActions { + generationStarted = 'schema-validation/rules-generation/generationStarted', + generationFailed = 'schema-validation/rules-generation/generationFailed', + generationFinished = 'schema-validation/rules-generation/generationFinished', + generationErrorCleared = 'schema-validation/rules-generation/generationErrorCleared', +} + +export type RulesGenerationStarted = { + type: RulesGenerationActions.generationStarted; +}; + +export type RulesGenerationFailed = { + type: RulesGenerationActions.generationFailed; + error: Error; +}; + +export type RulesGenerationErrorCleared = { + type: RulesGenerationActions.generationErrorCleared; +}; + +export type RulesGenerationFinished = { + type: RulesGenerationActions.generationFinished; +}; + +export type RulesGenerationError = { + errorMessage: string; + errorType: 'timeout' | 'highComplexity' | 'general'; +}; + +export interface RulesGenerationState { + isInProgress: boolean; + error?: RulesGenerationError; +} + +/** + * The initial state. + */ +export const INITIAL_STATE: RulesGenerationState = { + isInProgress: false, +}; + +function getErrorDetails(error: Error): RulesGenerationError { + const errorCode = (error as MongoError).code; + const errorMessage = error.message || 'Unknown error'; + let errorType: RulesGenerationError['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, + }; +} + +/** + * Reducer function for handle state changes to status. + */ +export const rulesGenerationReducer: Reducer = ( + state = INITIAL_STATE, + action +) => { + if ( + isAction( + action, + RulesGenerationActions.generationStarted + ) + ) { + return { + ...state, + isInProgress: true, + error: undefined, + }; + } + + if ( + isAction( + action, + RulesGenerationActions.generationFinished + ) + ) { + return { + ...state, + isInProgress: false, + }; + } + + if ( + isAction( + action, + RulesGenerationActions.generationFailed + ) + ) { + return { + ...state, + isInProgress: false, + error: getErrorDetails(action.error), + }; + } + + if ( + isAction( + action, + RulesGenerationActions.generationErrorCleared + ) + ) { + return { + ...state, + error: undefined, + }; + } + + return state; +}; + +export const clearRulesGenerationError = + (): SchemaValidationThunkAction => { + return (dispatch) => + dispatch({ type: RulesGenerationActions.generationErrorCleared }); + }; + +export const stopRulesGeneration = (): SchemaValidationThunkAction => { + return (dispatch, getState, { rulesGenerationAbortControllerRef }) => { + if (!rulesGenerationAbortControllerRef.current) return; + rulesGenerationAbortControllerRef.current?.abort(ABORT_MESSAGE); + }; +}; + +/** + * Get $jsonSchema from schema analysis + * @returns + */ +export const generateValidationRules = (): SchemaValidationThunkAction< + Promise +> => { + return async ( + dispatch, + getState, + { + dataService, + logger, + preferences, + rulesGenerationAbortControllerRef, + analyzeSchema, + } + ) => { + dispatch({ type: RulesGenerationActions.generationStarted }); + + rulesGenerationAbortControllerRef.current = new AbortController(); + const abortSignal = rulesGenerationAbortControllerRef.current.signal; + + const { namespace } = getState(); + const { maxTimeMS } = preferences.getPreferences(); + + try { + const samplingOptions = { + query: {}, + size: SAMPLE_SIZE, + fields: undefined, + }; + const driverOptions = { + maxTimeMS, + }; + const schemaAccessor = await analyzeSchema( + dataService, + abortSignal, + namespace.toString(), + samplingOptions, + driverOptions, + logger, + preferences + ); + if (abortSignal?.aborted) { + throw new Error(ABORT_MESSAGE); + } + + const jsonSchema = await schemaAccessor?.getMongoDBJsonSchema({ + signal: abortSignal, + }); + if (abortSignal?.aborted) { + throw new Error(ABORT_MESSAGE); + } + const validator = JSON.stringify( + { $jsonSchema: jsonSchema }, + undefined, + 2 + ); + dispatch(validationLevelChanged('moderate')); + dispatch(validatorChanged(validator)); + dispatch(enableEditRules()); + dispatch({ type: RulesGenerationActions.generationFinished }); + dispatch(zeroStateChanged(false)); + } catch (error) { + if (abortSignal.aborted) { + dispatch({ type: RulesGenerationActions.generationFinished }); + return; + } + dispatch({ + type: RulesGenerationActions.generationFailed, + error, + }); + } + }; +}; diff --git a/packages/compass-schema-validation/src/stores/store.spec.ts b/packages/compass-schema-validation/src/stores/store.spec.ts index 32a661d85d4..07b25d15a20 100644 --- a/packages/compass-schema-validation/src/stores/store.spec.ts +++ b/packages/compass-schema-validation/src/stores/store.spec.ts @@ -20,6 +20,11 @@ import { createNoopTrack } from '@mongodb-js/compass-telemetry/provider'; import type { ConnectionInfoRef } from '@mongodb-js/compass-connections/provider'; import { type WorkspacesService } from '@mongodb-js/compass-workspaces/provider'; import Sinon from 'sinon'; +import { + generateValidationRules, + stopRulesGeneration, +} from '../modules/rules-generation'; +import { waitFor } from '@mongodb-js/testing-library-compass'; const topologyDescription = { type: 'Unknown', @@ -39,6 +44,8 @@ const fakeDataService = { new Promise(() => { /* never resolves */ }), + isCancelError: () => false, + sample: () => [{ prop1: 'abc' }], } as any; const fakeWorkspaces = { @@ -46,7 +53,7 @@ const fakeWorkspaces = { onTabClose: () => {}, } as unknown as WorkspacesService; -const getMockedStore = async () => { +const getMockedStore = async (analyzeSchema: any) => { const globalAppRegistry = new AppRegistry(); const connectionInfoRef = { current: {}, @@ -63,11 +70,20 @@ const getMockedStore = async () => { track: createNoopTrack(), connectionInfoRef, }, - createActivateHelpers() + createActivateHelpers(), + analyzeSchema ); return activateResult; }; +const schemaAccessor = { + getMongoDBJsonSchema: () => { + return new Promise((resolve) => { + setTimeout(() => resolve({ required: ['prop1'] }), 100); // waiting to give abort a chance + }); + }, +}; + describe('Schema Validation Store', function () { let store: Store; let deactivate: null | (() => void) = null; @@ -77,7 +93,8 @@ describe('Schema Validation Store', function () { sandbox = Sinon.createSandbox(); fakeWorkspaces.onTabClose = sandbox.stub(); fakeWorkspaces.onTabReplace = sandbox.stub(); - const activateResult = await getMockedStore(); + const fakeAnalyzeSchema = sandbox.fake.resolves(schemaAccessor); + const activateResult = await getMockedStore(fakeAnalyzeSchema); store = activateResult.store; deactivate = activateResult.deactivate; }); @@ -278,5 +295,121 @@ describe('Schema Validation Store', function () { store.dispatch(validationLevelChanged(validationLevel)); }); }); + + context('when the action is generateValidationRules', function () { + it('executes rules generation', async function () { + store.dispatch(generateValidationRules() as any); + + await waitFor(() => { + expect(store.getState().rulesGeneration.isInProgress).to.equal(true); + }); + await waitFor(() => { + expect( + JSON.parse(store.getState().validation.validator) + ).to.deep.equal({ + $jsonSchema: { + required: ['prop1'], + }, + }); + expect(store.getState().rulesGeneration.isInProgress).to.equal(false); + expect(store.getState().rulesGeneration.error).to.be.undefined; + }); + }); + + it('rules generation can be aborted', async function () { + store.dispatch(generateValidationRules() as any); + + await waitFor(() => { + expect(store.getState().rulesGeneration.isInProgress).to.equal(true); + }); + + store.dispatch(stopRulesGeneration() as any); + await waitFor(() => { + expect(store.getState().validation.validator).to.equal(''); + expect(store.getState().rulesGeneration.isInProgress).to.equal(false); + expect(store.getState().rulesGeneration.error).to.be.undefined; + }); + }); + + context('rules generation failure', function () { + it('handles general error', async function () { + const fakeAnalyzeSchema = sandbox.fake.rejects( + new Error('Such a failure') + ); + const activateResult = await getMockedStore(fakeAnalyzeSchema); + store = activateResult.store; + deactivate = activateResult.deactivate; + store.dispatch(generateValidationRules() as any); + + await waitFor(() => { + expect(store.getState().rulesGeneration.isInProgress).to.equal( + true + ); + }); + + await waitFor(() => { + expect(store.getState().rulesGeneration.isInProgress).to.equal( + false + ); + expect(store.getState().rulesGeneration.error).to.deep.equal({ + errorMessage: 'Such a failure', + errorType: 'general', + }); + }); + }); + + it('handles complexity error', async function () { + const fakeAnalyzeSchema = sandbox.fake.rejects( + new Error('Schema analysis aborted: Fields count above 1000') + ); + const activateResult = await getMockedStore(fakeAnalyzeSchema); + store = activateResult.store; + deactivate = activateResult.deactivate; + store.dispatch(generateValidationRules() as any); + + await waitFor(() => { + expect(store.getState().rulesGeneration.isInProgress).to.equal( + true + ); + }); + + await waitFor(() => { + expect(store.getState().rulesGeneration.isInProgress).to.equal( + false + ); + expect(store.getState().rulesGeneration.error).to.deep.equal({ + errorMessage: 'Schema analysis aborted: Fields count above 1000', + errorType: 'highComplexity', + }); + }); + }); + + it('handles timeout error', async function () { + const timeoutError: any = new Error('Too long, didnt execute'); + timeoutError.code = 50; + const fakeAnalyzeSchema = sandbox.fake.rejects(timeoutError); + const activateResult = await getMockedStore(fakeAnalyzeSchema); + store = activateResult.store; + deactivate = activateResult.deactivate; + store.dispatch(generateValidationRules() as any); + + await waitFor(() => { + expect(store.getState().rulesGeneration.isInProgress).to.equal( + true + ); + }); + + await waitFor(() => { + expect(store.getState().rulesGeneration.isInProgress).to.equal( + false + ); + expect(store.getState().rulesGeneration.error).to.deep.equal({ + errorMessage: 'Too long, didnt execute', + errorType: 'timeout', + }); + }); + }); + }); + }); }); }); diff --git a/packages/compass-schema-validation/src/stores/store.ts b/packages/compass-schema-validation/src/stores/store.ts index 138e8655726..0bcea1a7b95 100644 --- a/packages/compass-schema-validation/src/stores/store.ts +++ b/packages/compass-schema-validation/src/stores/store.ts @@ -1,6 +1,6 @@ import { createStore, applyMiddleware } from 'redux'; import thunk from 'redux-thunk'; -import type { RootState } from '../modules'; +import type { DataService, RootState } from '../modules'; import reducer, { INITIAL_STATE } from '../modules'; import toNS from 'mongodb-ns'; import { activateValidation } from '../modules/validation'; @@ -8,15 +8,13 @@ import { editModeChanged } from '../modules/edit-mode'; import semver from 'semver'; import type { CollectionTabPluginMetadata } from '@mongodb-js/compass-collection'; import type { ActivateHelpers, AppRegistry } from 'hadron-app-registry'; -import type { - ConnectionInfoRef, - DataService, -} from '@mongodb-js/compass-connections/provider'; +import type { ConnectionInfoRef } from '@mongodb-js/compass-connections/provider'; import type { MongoDBInstance } from '@mongodb-js/compass-app-stores/provider'; import type { PreferencesAccess } from 'compass-preferences-model'; import type { Logger } from '@mongodb-js/compass-logging/provider'; import type { TrackFunction } from '@mongodb-js/compass-telemetry'; import { type WorkspacesService } from '@mongodb-js/compass-workspaces/provider'; +import { analyzeSchema as compassAnalyzeSchema } from '@mongodb-js/compass-schema'; /** * The lowest supported version. @@ -25,10 +23,7 @@ const MIN_VERSION = '3.2.0'; export type SchemaValidationServices = { globalAppRegistry: AppRegistry; - dataService: Pick< - DataService, - 'aggregate' | 'collectionInfo' | 'updateCollection' - >; + dataService: DataService; connectionInfoRef: ConnectionInfoRef; preferences: PreferencesAccess; instance: MongoDBInstance; @@ -49,15 +44,25 @@ export function configureStore( | 'logger' | 'track' | 'connectionInfoRef' - > + >, + analyzeSchema = compassAnalyzeSchema ) { + const rulesGenerationAbortControllerRef = { + current: undefined, + }; return createStore( reducer, { ...INITIAL_STATE, ...state, }, - applyMiddleware(thunk.withExtraArgument(services)) + applyMiddleware( + thunk.withExtraArgument({ + ...services, + rulesGenerationAbortControllerRef, + analyzeSchema, + }) + ) ); } @@ -76,7 +81,8 @@ export function onActivated( workspaces, track, }: SchemaValidationServices, - { on, cleanup, addCleanup }: ActivateHelpers + { on, cleanup, addCleanup }: ActivateHelpers, + analyzeSchema?: typeof compassAnalyzeSchema ) { const store = configureStore( { @@ -98,7 +104,8 @@ export function onActivated( workspaces, logger, track, - } + }, + analyzeSchema ); // isWritable can change later diff --git a/packages/compass-schema/src/index.ts b/packages/compass-schema/src/index.ts index 6f6d2870d39..b546ea37cd3 100644 --- a/packages/compass-schema/src/index.ts +++ b/packages/compass-schema/src/index.ts @@ -49,3 +49,5 @@ export const CompassSchemaPlugin = { content: CompassSchema as React.FunctionComponent /* reflux store */, header: SchemaTabTitle, }; + +export * from './modules/schema-analysis';