From a48c7fa39243091211a97675a8a17fce79081314 Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Fri, 7 Mar 2025 13:44:24 +0100 Subject: [PATCH] move rulesGeneration into separate module + align errors --- .../src/components/validation-states.tsx | 63 ++++- .../src/modules/index.ts | 10 +- .../src/modules/rules-generation.ts | 223 ++++++++++++++++++ .../src/modules/validation.ts | 154 +----------- .../src/components/schema-toolbar.tsx | 2 +- 5 files changed, 290 insertions(+), 162 deletions(-) create mode 100644 packages/compass-schema-validation/src/modules/rules-generation.ts diff --git a/packages/compass-schema-validation/src/components/validation-states.tsx b/packages/compass-schema-validation/src/components/validation-states.tsx index 34e9fafb4db..b02d552529d 100644 --- a/packages/compass-schema-validation/src/components/validation-states.tsx +++ b/packages/compass-schema-validation/src/components/validation-states.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Banner, + BannerVariant, Button, ButtonVariant, CancelLoader, @@ -22,7 +23,9 @@ import { clearRulesGenerationError, generateValidationRules, stopRulesGeneration, -} from '../modules/validation'; + type RulesGenerationError, +} from '../modules/rules-generation'; +import { DISTINCT_FIELDS_ABORT_THRESHOLD } from '@mongodb-js/compass-schema'; const validationStatesStyles = css({ padding: spacing[400], @@ -67,7 +70,7 @@ const DOC_UPGRADE_REVISION = type ValidationStatesProps = { isZeroState: boolean; isRulesGenerationInProgress?: boolean; - rulesGenerationError?: string; + rulesGenerationError?: RulesGenerationError; isLoaded: boolean; changeZeroState: (value: boolean) => void; generateValidationRules: () => void; @@ -144,6 +147,51 @@ const GeneratingScreen: React.FunctionComponent<{ ); }; +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, @@ -173,10 +221,9 @@ export function ValidationStates({ data-testid="schema-validation-states" > {rulesGenerationError && ( - )} @@ -248,8 +295,8 @@ const mapStateToProps = (state: RootState) => ({ isZeroState: state.isZeroState, isLoaded: state.isLoaded, editMode: state.editMode, - isRulesGenerationInProgress: state.validation.isRulesGenerationInProgress, - rulesGenerationError: state.validation.rulesGenerationError, + isRulesGenerationInProgress: state.rulesGeneration.isInProgress, + rulesGenerationError: state.rulesGeneration.error, }); /** diff --git a/packages/compass-schema-validation/src/modules/index.ts b/packages/compass-schema-validation/src/modules/index.ts index fc7a1d4b8ca..df460142e3e 100644 --- a/packages/compass-schema-validation/src/modules/index.ts +++ b/packages/compass-schema-validation/src/modules/index.ts @@ -31,6 +31,11 @@ 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'; /** * Reset action constant. @@ -44,6 +49,7 @@ export interface RootState { namespace: NamespaceState; serverVersion: ServerVersionState; validation: ValidationState; + rulesGeneration: RulesGenerationState; sampleDocuments: SampleDocumentState; isZeroState: IsZeroStateState; isLoaded: IsLoadedState; @@ -92,6 +98,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, @@ -101,7 +108,7 @@ export const INITIAL_STATE: RootState = { /** * The reducer. */ -const appReducer = combineReducers({ +const appReducer = combineReducers({ namespace, serverVersion, validation, @@ -109,6 +116,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..b8a3c67a2f1 --- /dev/null +++ b/packages/compass-schema-validation/src/modules/rules-generation.ts @@ -0,0 +1,223 @@ +import type { SchemaValidationThunkAction } from '.'; +import { zeroStateChanged } from './zero-state'; +import { enableEditRules } from './edit-mode'; +import { analyzeSchema } from '@mongodb-js/compass-schema'; +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 } + ) => { + 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/modules/validation.ts b/packages/compass-schema-validation/src/modules/validation.ts index 2df1700199f..c2059e38ba7 100644 --- a/packages/compass-schema-validation/src/modules/validation.ts +++ b/packages/compass-schema-validation/src/modules/validation.ts @@ -7,15 +7,11 @@ import { zeroStateChanged } from './zero-state'; import { isLoadedChanged } from './is-loaded'; import { isEqual, pick } from 'lodash'; import type { ThunkDispatch } from 'redux-thunk'; -import { disableEditRules, enableEditRules } from './edit-mode'; -import { analyzeSchema } from '@mongodb-js/compass-schema'; +import { disableEditRules } from './edit-mode'; export type ValidationServerAction = 'error' | 'warn'; export type ValidationLevel = 'off' | 'moderate' | 'strict'; -const SAMPLE_SIZE = 1000; -const ABORT_MESSAGE = 'Operation cancelled'; - /** * The module action prefix. */ @@ -87,31 +83,6 @@ interface SyntaxErrorOccurredAction { syntaxError: null | { message: string }; } -export const RULES_GENERATION_STARTED = - `${PREFIX}/RULES_GENERATION_STARTED` as const; -interface RulesGenerationStartedAction { - type: typeof RULES_GENERATION_STARTED; -} - -export const RULES_GENERATION_FAILED = - `${PREFIX}/RULES_GENERATION_FAILED` as const; -interface RulesGenerationFailedAction { - type: typeof RULES_GENERATION_FAILED; - message: string; -} - -export const RULES_GENERATION_CLEAR_ERROR = - `${PREFIX}/RULES_GENERATION_CLEAR_ERROR` as const; -interface RulesGenerationClearErrorAction { - type: typeof RULES_GENERATION_CLEAR_ERROR; -} - -export const RULES_GENERATION_FINISHED = - `${PREFIX}/RULES_GENERATION_FINISHED` as const; -interface RulesGenerationFinishedAction { - type: typeof RULES_GENERATION_FINISHED; -} - export type ValidationAction = | ValidatorChangedAction | ValidationCanceledAction @@ -119,11 +90,7 @@ export type ValidationAction = | ValidationFetchedAction | ValidationActionChangedAction | ValidationLevelChangedAction - | SyntaxErrorOccurredAction - | RulesGenerationStartedAction - | RulesGenerationFinishedAction - | RulesGenerationFailedAction - | RulesGenerationClearErrorAction; + | SyntaxErrorOccurredAction; export interface Validation { validator: string; @@ -143,8 +110,6 @@ export interface ValidationState extends Validation { syntaxError: null | { message: string }; error: null | { message: string }; prevValidation?: Validation; - isRulesGenerationInProgress?: boolean; - rulesGenerationError?: string; } /** @@ -224,34 +189,6 @@ const setSyntaxError = ( syntaxError: action.syntaxError, }); -const startRulesGeneration = (state: ValidationState): ValidationState => ({ - ...state, - isRulesGenerationInProgress: true, - rulesGenerationError: undefined, -}); - -const finishRulesGeneration = (state: ValidationState): ValidationState => ({ - ...state, - isRulesGenerationInProgress: undefined, - rulesGenerationError: undefined, -}); - -const markRulesGenerationFailure = ( - state: ValidationState, - action: RulesGenerationFailedAction -): ValidationState => ({ - ...state, - isRulesGenerationInProgress: undefined, - rulesGenerationError: action.message, -}); - -const unsetRulesGenerationError = ( - state: ValidationState -): ValidationState => ({ - ...state, - rulesGenerationError: undefined, -}); - /** * Set validation. */ @@ -354,10 +291,6 @@ const MAPPINGS: { [VALIDATION_ACTION_CHANGED]: changeValidationAction, [VALIDATION_LEVEL_CHANGED]: changeValidationLevel, [SYNTAX_ERROR_OCCURRED]: setSyntaxError, - [RULES_GENERATION_STARTED]: startRulesGeneration, - [RULES_GENERATION_FINISHED]: finishRulesGeneration, - [RULES_GENERATION_FAILED]: markRulesGenerationFailure, - [RULES_GENERATION_CLEAR_ERROR]: unsetRulesGenerationError, }; /** @@ -448,11 +381,6 @@ export const syntaxErrorOccurred = ( syntaxError, }); -export const clearRulesGenerationError = - (): RulesGenerationClearErrorAction => ({ - type: RULES_GENERATION_CLEAR_ERROR, - }); - export const fetchValidation = (namespace: { database: string; collection: string; @@ -608,81 +536,3 @@ export const activateValidation = (): SchemaValidationThunkAction => { dispatch(fetchValidation(namespace)); }; }; - -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 } - ) => { - dispatch({ type: RULES_GENERATION_STARTED }); - - 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: RULES_GENERATION_FINISHED }); - dispatch(zeroStateChanged(false)); - } catch (error) { - if (abortSignal.aborted) { - dispatch({ type: RULES_GENERATION_FINISHED }); - return; - } - dispatch({ - type: RULES_GENERATION_FAILED, - message: `Rules generation failed: ${(error as Error).message}`, - }); - } - }; -}; diff --git a/packages/compass-schema/src/components/schema-toolbar.tsx b/packages/compass-schema/src/components/schema-toolbar.tsx index e7d888bc9a7..0fca62f0ef2 100644 --- a/packages/compass-schema/src/components/schema-toolbar.tsx +++ b/packages/compass-schema/src/components/schema-toolbar.tsx @@ -21,8 +21,8 @@ import { type SchemaAnalysisError, analysisErrorDismissed, } from '../stores/schema-analysis-reducer'; -import type { RootState } from '../stores/store'; import { DISTINCT_FIELDS_ABORT_THRESHOLD } from '../modules/schema-analysis'; +import type { RootState } from '../stores/store'; import { openExportSchema } from '../stores/schema-export-reducer'; const schemaToolbarStyles = css({