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';