diff --git a/package-lock.json b/package-lock.json index 031e2251d87..52e9685f9e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56387,6 +56387,7 @@ "license": "SSPL", "dependencies": { "@mongodb-js/compass-logging": "^0.12.0", + "@mongodb-js/explain-plan-helper": "^0.9.0", "@mongodb-js/mongodb-redux-common": "^1.12.0", "acorn-loose": "^8.0.2", "astring": "^1.7.0", @@ -107813,6 +107814,7 @@ "@mongodb-js/compass-field-store": "^7.23.0", "@mongodb-js/compass-logging": "^0.12.0", "@mongodb-js/eslint-config-compass": "^0.9.0", + "@mongodb-js/explain-plan-helper": "*", "@mongodb-js/mocha-config-compass": "^0.10.0", "@mongodb-js/mongodb-redux-common": "^1.12.0", "@mongodb-js/prettier-config-compass": "^0.5.0", diff --git a/packages/compass-aggregations/package.json b/packages/compass-aggregations/package.json index 35b4ab7adf0..f4ae4d13fc8 100644 --- a/packages/compass-aggregations/package.json +++ b/packages/compass-aggregations/package.json @@ -101,6 +101,7 @@ }, "dependencies": { "@mongodb-js/compass-logging": "^0.12.0", + "@mongodb-js/explain-plan-helper": "^0.9.0", "@mongodb-js/mongodb-redux-common": "^1.12.0", "acorn-loose": "^8.0.2", "astring": "^1.7.0", diff --git a/packages/compass-aggregations/src/components/pipeline-explain/explain-indexes.tsx b/packages/compass-aggregations/src/components/pipeline-explain/explain-indexes.tsx new file mode 100644 index 00000000000..1e61ba07d6b --- /dev/null +++ b/packages/compass-aggregations/src/components/pipeline-explain/explain-indexes.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Badge, BadgeVariant, Body } from '@mongodb-js/compass-components'; +import type { IndexInformation } from '@mongodb-js/explain-plan-helper'; + +type ExplainIndexesProps = { + indexes: IndexInformation[]; +}; + +export const ExplainIndexes: React.FunctionComponent = ({ + indexes, +}) => { + if (indexes.filter(({ index }) => index).length === 0) { + return No index available for this query.; + } + + return ( +
+ {indexes.map((info, idx) => ( + + {info.index} {info.shard && <>({info.shard})} + + ))} +
+ ); +}; diff --git a/packages/compass-aggregations/src/components/pipeline-explain/explain-query-performance.tsx b/packages/compass-aggregations/src/components/pipeline-explain/explain-query-performance.tsx new file mode 100644 index 00000000000..bf55d8ee8d0 --- /dev/null +++ b/packages/compass-aggregations/src/components/pipeline-explain/explain-query-performance.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Body, Subtitle, css, spacing } from '@mongodb-js/compass-components'; +import type { IndexInformation } from '@mongodb-js/explain-plan-helper'; + +import { ExplainIndexes } from './explain-indexes'; + +type ExplainQueryPerformanceProps = { + executionTimeMillis: number; + nReturned: number; + usedIndexes: IndexInformation[]; +}; + +const containerStyles = css({ + display: 'flex', + gap: spacing[3], + flexDirection: 'column', +}); + +const statsStyles = css({ + gap: spacing[1], + display: 'flex', + flexDirection: 'column', +}); + +const statItemStyles = css({ + display: 'flex', + gap: spacing[1], +}); + +const statTitleStyles = css({ + whiteSpace: 'nowrap', +}); + +export const ExplainQueryPerformance: React.FunctionComponent = + ({ nReturned, executionTimeMillis, usedIndexes }) => { + return ( +
+ Query Performance Summary +
+ {typeof nReturned === 'number' && ( +
+ Documents returned: + {nReturned} +
+ )} + {executionTimeMillis > 0 && ( +
+ Actual query execution time(ms): + {executionTimeMillis} +
+ )} +
+ + Query used the following indexes: + + +
+
+
+ ); + }; diff --git a/packages/compass-aggregations/src/components/pipeline-explain/explain-results.tsx b/packages/compass-aggregations/src/components/pipeline-explain/explain-results.tsx new file mode 100644 index 00000000000..61ab80ac1a8 --- /dev/null +++ b/packages/compass-aggregations/src/components/pipeline-explain/explain-results.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { css, spacing, Card } from '@mongodb-js/compass-components'; +import { Document } from '@mongodb-js/compass-crud'; +import HadronDocument from 'hadron-document'; + +import type { ExplainData } from '../../modules/explain'; +import { ExplainQueryPerformance } from './explain-query-performance'; + +type ExplainResultsProps = { + plan: ExplainData['plan']; + stats?: ExplainData['stats']; +}; + +const containerStyles = css({ + display: 'flex', + flexDirection: 'column', + gap: spacing[4], +}); + +const cardStyles = css({ + // 170px works with minimum-height of compass + // todo: handle height for bigger sized compass + height: '170px', + overflowY: 'scroll', +}); + +export const ExplainResults: React.FunctionComponent = ({ + plan, + stats, +}) => { + return ( +
+ {stats && ( + + )} + + { + void navigator.clipboard.writeText( + new HadronDocument(plan).toEJSON() + ); + }} + /> + +
+ ); +}; diff --git a/packages/compass-aggregations/src/components/pipeline-explain/index.spec.tsx b/packages/compass-aggregations/src/components/pipeline-explain/index.spec.tsx new file mode 100644 index 00000000000..59815d123f5 --- /dev/null +++ b/packages/compass-aggregations/src/components/pipeline-explain/index.spec.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import type { ComponentProps } from 'react'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { spy } from 'sinon'; +import { expect } from 'chai'; + +import { PipelineExplain } from './index'; + +const renderPipelineExplain = ( + props: Partial> = {} +) => { + render( + {}} + onCloseModal={() => {}} + onRunExplain={() => {}} + {...props} + /> + ); +}; + +describe('PipelineExplain', function () { + it('renders loading state', function () { + const onCancelExplainSpy = spy(); + renderPipelineExplain({ + isLoading: true, + onCancelExplain: onCancelExplainSpy, + }); + const modal = screen.getByTestId('pipeline-explain-modal'); + expect(within(modal).getByTestId('pipeline-explain-cancel')).to.exist; + expect(onCancelExplainSpy.callCount).to.equal(0); + + userEvent.click(within(modal).getByText(/cancel/gi), null, { + skipPointerEventsCheck: true, + }); + expect(onCancelExplainSpy.callCount).to.equal(1); + + expect(() => { + within(modal).getByTestId('pipeline-explain-footer-close-button'); + }, 'does not show footer in loading state').to.throw; + }); + + it('renders error state', function () { + renderPipelineExplain({ + error: 'Error occurred', + }); + const modal = screen.getByTestId('pipeline-explain-modal'); + expect(within(modal).getByTestId('pipeline-explain-error')).to.exist; + expect(within(modal).findByText('Error occurred')).to.exist; + expect(() => { + within(modal).getByTestId('pipeline-explain-retry-button'); + }).to.throw; + + expect(within(modal).getByTestId('pipeline-explain-footer-close-button')).to + .exist; + }); + + it('renders explain results - without stats', function () { + renderPipelineExplain({ + explain: { + plan: { + stages: [], + }, + }, + }); + const results = screen.getByTestId('pipeline-explain-results'); + expect(within(results).getByTestId('pipeline-explain-results-json')).to + .exist; + expect(() => { + within(results).getByTestId('pipeline-explain-results-summary'); + }).to.throw; + + expect(screen.getByTestId('pipeline-explain-footer-close-button')).to.exist; + }); + + it('renders explain results - with stats', function () { + renderPipelineExplain({ + explain: { + stats: { + executionTimeMillis: 20, + nReturned: 100, + usedIndexes: [{ index: 'name', shard: 'shard1' }], + }, + plan: { + stages: [], + }, + }, + }); + const results = screen.getByTestId('pipeline-explain-results'); + expect(results).to.exist; + expect(within(results).getByTestId('pipeline-explain-results-json')).to + .exist; + + const summary = within(results).getByTestId( + 'pipeline-explain-results-summary' + ); + expect(summary).to.exist; + + expect(within(summary).getByText(/documents returned/gi)).to.exist; + expect(within(summary).getByText(/actual query execution time/gi)).to.exist; + expect(within(summary).getByText(/query used the following indexes/gi)).to + .exist; + + expect(screen.getByTestId('pipeline-explain-footer-close-button')).to.exist; + }); +}); diff --git a/packages/compass-aggregations/src/components/pipeline-explain/index.tsx b/packages/compass-aggregations/src/components/pipeline-explain/index.tsx index dd7b1cda9da..c4f0db32c95 100644 --- a/packages/compass-aggregations/src/components/pipeline-explain/index.tsx +++ b/packages/compass-aggregations/src/components/pipeline-explain/index.tsx @@ -1,19 +1,70 @@ import React from 'react'; -import { Modal, H3, Body } from '@mongodb-js/compass-components'; +import { + css, + spacing, + Modal, + CancelLoader, + H3, + ModalFooter, + Button, + ErrorSummary, +} from '@mongodb-js/compass-components'; import { connect } from 'react-redux'; import type { RootState } from '../../modules'; -import { closeExplainModal } from '../../modules/explain'; +import type { ExplainData } from '../../modules/explain'; +import { closeExplainModal, cancelExplain } from '../../modules/explain'; +import { ExplainResults } from './explain-results'; type PipelineExplainProps = { isModalOpen: boolean; + isLoading: boolean; + error?: string; + explain?: ExplainData; onCloseModal: () => void; + onCancelExplain: () => void; }; +const contentStyles = css({ + marginTop: spacing[3], + marginBottom: spacing[3], +}); + +const footerStyles = css({ + paddingRight: 0, + paddingBottom: 0, +}); + export const PipelineExplain: React.FunctionComponent = ({ isModalOpen, + isLoading, + error, + explain, onCloseModal, + onCancelExplain, }) => { + let content = null; + if (isLoading) { + content = ( + onCancelExplain()} + progressText="Running explain" + /> + ); + } else if (error) { + content = ( + + ); + } else if (explain) { + content = ; + } + + if (!content) { + return null; + } + return ( = ({ data-testid="pipeline-explain-modal" >

Explain

- Implementation in progress ... +
{content}
+ {!isLoading && ( + + + + )}
); }; -const mapState = ({ explain: { isModalOpen } }: RootState) => ({ - isModalOpen: isModalOpen, +const mapState = ({ + explain: { isModalOpen, isLoading, error, explain }, +}: RootState) => ({ + isModalOpen, + isLoading, + error, + explain, }); const mapDispatch = { onCloseModal: closeExplainModal, + onCancelExplain: cancelExplain, }; export default connect(mapState, mapDispatch)(PipelineExplain); diff --git a/packages/compass-aggregations/src/components/pipeline-toolbar/index.spec.tsx b/packages/compass-aggregations/src/components/pipeline-toolbar/index.spec.tsx index c270416780c..128f51b6004 100644 --- a/packages/compass-aggregations/src/components/pipeline-toolbar/index.spec.tsx +++ b/packages/compass-aggregations/src/components/pipeline-toolbar/index.spec.tsx @@ -14,9 +14,10 @@ describe('PipelineToolbar', function () { render( ); @@ -130,7 +131,12 @@ describe('PipelineToolbar', function () { it('does not render toolbar settings', function () { render( - + ); const toolbar = screen.getByTestId('pipeline-toolbar'); diff --git a/packages/compass-aggregations/src/components/pipeline-toolbar/index.tsx b/packages/compass-aggregations/src/components/pipeline-toolbar/index.tsx index a4d6924cc82..68d9b577137 100644 --- a/packages/compass-aggregations/src/components/pipeline-toolbar/index.tsx +++ b/packages/compass-aggregations/src/components/pipeline-toolbar/index.tsx @@ -52,12 +52,14 @@ type PipelineToolbarProps = { isSettingsVisible: boolean; showRunButton: boolean; showExportButton: boolean; + showExplainButton: boolean; }; export const PipelineToolbar: React.FunctionComponent = ({ isSettingsVisible, showRunButton, - showExportButton + showExportButton, + showExplainButton, }) => { const [isOptionsVisible, setIsOptionsVisible] = useState(false); return ( @@ -76,6 +78,7 @@ export const PipelineToolbar: React.FunctionComponent = ({ onToggleOptions={() => setIsOptionsVisible(!isOptionsVisible)} showRunButton={showRunButton} showExportButton={showExportButton} + showExplainButton={showExplainButton} /> {isOptionsVisible && (
diff --git a/packages/compass-aggregations/src/components/pipeline-toolbar/pipeline-header/index.spec.tsx b/packages/compass-aggregations/src/components/pipeline-toolbar/pipeline-header/index.spec.tsx index 455872bc2ec..b1b39425638 100644 --- a/packages/compass-aggregations/src/components/pipeline-toolbar/pipeline-header/index.spec.tsx +++ b/packages/compass-aggregations/src/components/pipeline-toolbar/pipeline-header/index.spec.tsx @@ -23,6 +23,7 @@ describe('PipelineHeader', function () { isOptionsVisible showRunButton showExportButton + showExplainButton onShowSavedPipelines={onShowSavedPipelinesSpy} onToggleOptions={onToggleOptionsSpy} /> diff --git a/packages/compass-aggregations/src/components/pipeline-toolbar/pipeline-header/index.tsx b/packages/compass-aggregations/src/components/pipeline-toolbar/pipeline-header/index.tsx index 5a24f4a09e0..a5408937a7c 100644 --- a/packages/compass-aggregations/src/components/pipeline-toolbar/pipeline-header/index.tsx +++ b/packages/compass-aggregations/src/components/pipeline-toolbar/pipeline-header/index.tsx @@ -43,6 +43,7 @@ type PipelineHeaderProps = { isOptionsVisible: boolean; showRunButton: boolean; showExportButton: boolean; + showExplainButton: boolean; onShowSavedPipelines: () => void; onToggleOptions: () => void; isOpenPipelineVisible: boolean; @@ -52,6 +53,7 @@ export const PipelineHeader: React.FunctionComponent = ({ onShowSavedPipelines, showRunButton, showExportButton, + showExplainButton, onToggleOptions, isOptionsVisible, isOpenPipelineVisible, @@ -81,6 +83,7 @@ export const PipelineHeader: React.FunctionComponent = ({ isOptionsVisible={isOptionsVisible} showRunButton={showRunButton} showExportButton={showExportButton} + showExplainButton={showExplainButton} />
diff --git a/packages/compass-aggregations/src/components/pipeline-toolbar/pipeline-header/pipeline-actions.spec.tsx b/packages/compass-aggregations/src/components/pipeline-toolbar/pipeline-header/pipeline-actions.spec.tsx index 7496d616c92..8ba1e69377e 100644 --- a/packages/compass-aggregations/src/components/pipeline-toolbar/pipeline-header/pipeline-actions.spec.tsx +++ b/packages/compass-aggregations/src/components/pipeline-toolbar/pipeline-header/pipeline-actions.spec.tsx @@ -28,6 +28,7 @@ describe('PipelineActions', function () { isOptionsVisible={true} showRunButton={true} showExportButton={true} + showExplainButton={true} onRunAggregation={onRunAggregationSpy} onToggleOptions={onToggleOptionsSpy} onExportAggregationResults={onExportAggregationResultsSpy} @@ -94,6 +95,7 @@ describe('PipelineActions', function () { isOptionsVisible={false} showRunButton={true} showExportButton={true} + showExplainButton={true} onRunAggregation={onRunAggregationSpy} onToggleOptions={onToggleOptionsSpy} onExportAggregationResults={() => {}} @@ -121,7 +123,6 @@ describe('PipelineActions', function () { let onExplainAggregationSpy: SinonSpy; beforeEach(function () { - process.env.COMPASS_ENABLE_AGGREGATION_EXPORT = 'true'; process.env.COMPASS_ENABLE_AGGREGATION_EXPLAIN = 'true'; onRunAggregationSpy = spy(); onExportAggregationResultsSpy = spy(); @@ -134,6 +135,7 @@ describe('PipelineActions', function () { isOptionsVisible={true} showRunButton={true} showExportButton={true} + showExplainButton={true} onRunAggregation={onRunAggregationSpy} onToggleOptions={() => {}} onExportAggregationResults={onExportAggregationResultsSpy} diff --git a/packages/compass-aggregations/src/components/pipeline-toolbar/pipeline-header/pipeline-actions.tsx b/packages/compass-aggregations/src/components/pipeline-toolbar/pipeline-header/pipeline-actions.tsx index 3208456bc0a..5a13400267a 100644 --- a/packages/compass-aggregations/src/components/pipeline-toolbar/pipeline-header/pipeline-actions.tsx +++ b/packages/compass-aggregations/src/components/pipeline-toolbar/pipeline-header/pipeline-actions.tsx @@ -13,7 +13,7 @@ import { } from '../../../modules/aggregation'; import { isEmptyishStage } from '../../../modules/stage'; import { updateView } from '../../../modules/update-view'; -import { openExplainModal } from '../../../modules/explain'; +import { explainAggregation } from '../../../modules/explain'; const containerStyles = css({ display: 'flex', @@ -34,6 +34,7 @@ type PipelineActionsProps = { isUpdateViewButtonDisabled?: boolean; onUpdateView: () => void; + showExplainButton?: boolean; isExplainButtonDisabled?: boolean; onExplainAggregation: () => void; @@ -50,6 +51,7 @@ export const PipelineActions: React.FunctionComponent = ({ showUpdateViewButton, isUpdateViewButtonDisabled, isExplainButtonDisabled, + showExplainButton: _showExplainButton, onUpdateView, onRunAggregation, onToggleOptions, @@ -57,7 +59,8 @@ export const PipelineActions: React.FunctionComponent = ({ onExplainAggregation, }) => { const showExplainButton = - process?.env?.COMPASS_ENABLE_AGGREGATION_EXPLAIN === 'true'; + process?.env?.COMPASS_ENABLE_AGGREGATION_EXPLAIN === 'true' && + _showExplainButton; return (
{showUpdateViewButton && ( @@ -147,7 +150,7 @@ const mapDispatch = { onUpdateView: updateView, onRunAggregation: runAggregation, onExportAggregationResults: exportAggregationResults, - onExplainAggregation: openExplainModal, + onExplainAggregation: explainAggregation, }; export default connect(mapState, mapDispatch)(PipelineActions); diff --git a/packages/compass-aggregations/src/components/pipeline/pipeline.jsx b/packages/compass-aggregations/src/components/pipeline/pipeline.jsx index 345b4267e71..1ba26045557 100644 --- a/packages/compass-aggregations/src/components/pipeline/pipeline.jsx +++ b/packages/compass-aggregations/src/components/pipeline/pipeline.jsx @@ -126,6 +126,7 @@ class Pipeline extends PureComponent { refreshInputDocuments: PropTypes.func.isRequired, showExportButton: PropTypes.bool.isRequired, showRunButton: PropTypes.bool.isRequired, + showExplainButton: PropTypes.bool.isRequired, }; static defaultProps = { @@ -193,6 +194,7 @@ class Pipeline extends PureComponent { ); } diff --git a/packages/compass-aggregations/src/modules/explain.ts b/packages/compass-aggregations/src/modules/explain.ts index 47c583c61d0..9769c3fdded 100644 --- a/packages/compass-aggregations/src/modules/explain.ts +++ b/packages/compass-aggregations/src/modules/explain.ts @@ -1,51 +1,219 @@ import type { Reducer } from 'redux'; +import type { AggregateOptions, Document } from 'mongodb'; +import type { ThunkAction } from 'redux-thunk'; +import { ExplainPlan } from '@mongodb-js/explain-plan-helper'; +import type { IndexInformation } from '@mongodb-js/explain-plan-helper'; +import { createLoggerAndTelemetry } from '@mongodb-js/compass-logging'; +import type { RootState } from '.'; +import { DEFAULT_MAX_TIME_MS } from '../constants'; +import { generateStage } from './stage'; +import { PROMISE_CANCELLED_ERROR } from '../utils/cancellable-promise'; +import { explainPipeline } from '../utils/cancellable-aggregation'; +const { log, mongoLogId } = createLoggerAndTelemetry( + 'COMPASS-AGGREGATIONS-UI' +); export enum ActionTypes { - ModalOpened = 'compass-aggregations/modalOpened', - ModalClosed = 'compass-aggregations/modalClosed', + ExplainStarted = 'compass-aggregations/explainStarted', + ExplainFinished = 'compass-aggregations/explainFinished', + ExplainFailed = 'compass-aggregations/explainFailed', + ExplainCancelled = 'compass-aggregations/explainCancelled', } -type ModalOpenedAction = { - type: ActionTypes.ModalOpened; +type ExplainStartedAction = { + type: ActionTypes.ExplainStarted; + abortController: AbortController; }; -type ModalClosedAction = { - type: ActionTypes.ModalClosed; +type ExplainFinishedAction = { + type: ActionTypes.ExplainFinished; + explain: ExplainData; +}; + +type ExplainFailedAction = { + type: ActionTypes.ExplainFailed; + error: string; +}; + +type ExplainCancelledAction = { + type: ActionTypes.ExplainCancelled; }; export type Actions = - | ModalOpenedAction - | ModalClosedAction; + | ExplainStartedAction + | ExplainFinishedAction + | ExplainFailedAction + | ExplainCancelledAction; + +export type ExplainData = { + plan: Document; + stats?: { + executionTimeMillis: number; + nReturned: number; + usedIndexes: IndexInformation[]; + }; +}; export type State = { + isLoading: boolean; isModalOpen: boolean; + explain?: ExplainData; + abortController?: AbortController; + error?: string; }; export const INITIAL_STATE: State = { + isLoading: false, isModalOpen: false, }; const reducer: Reducer = (state = INITIAL_STATE, action) => { switch (action.type) { - case ActionTypes.ModalOpened: + case ActionTypes.ExplainStarted: return { isModalOpen: true, + isLoading: true, + explain: undefined, + error: undefined, + abortController: action.abortController, }; - case ActionTypes.ModalClosed: + case ActionTypes.ExplainFinished: return { - isModalOpen: false, + isModalOpen: true, + isLoading: false, + explain: action.explain, + error: undefined, + abortController: undefined, }; + case ActionTypes.ExplainFailed: + return { + isModalOpen: true, + isLoading: false, + explain: undefined, + error: action.error, + abortController: undefined, + }; + case ActionTypes.ExplainCancelled: + return INITIAL_STATE; default: return state; } }; -export const openExplainModal = (): ModalOpenedAction => ({ - type: ActionTypes.ModalOpened, -}); +export const closeExplainModal = (): ThunkAction< + void, + RootState, + void, + Actions +> => { + return (dispatch) => { + dispatch(cancelExplain()); + }; +}; + +export const cancelExplain = (): ThunkAction< + void, + RootState, + void, + Actions +> => { + return (dispatch, getState) => { + const { explain: { abortController } } = getState(); + abortController?.abort(); + dispatch({ + type: ActionTypes.ExplainCancelled + }); + }; +}; + +export const explainAggregation = (): ThunkAction< + void, + RootState, + void, + Actions +> => { + return async (dispatch, getState) => { + const { + isDataLake, + pipeline, + namespace, + maxTimeMS, + collation, + dataService: { dataService }, + } = getState(); -export const closeExplainModal = (): ModalClosedAction => ({ - type: ActionTypes.ModalClosed, -}); + if (!dataService) { + return; + } + + try { + const abortController = new AbortController(); + const signal = abortController.signal; + dispatch({ + type: ActionTypes.ExplainStarted, + abortController, + }); + + const options: AggregateOptions = { + maxTimeMS: maxTimeMS ?? DEFAULT_MAX_TIME_MS, + allowDiskUse: true, + collation: collation || undefined, + }; + + const rawExplain = await explainPipeline({ + dataService, + signal, + namespace, + pipeline: pipeline.map(generateStage).filter(x => Object.keys(x).length > 0), + options, + isDataLake, + }); + + const explain: ExplainData = { + plan: rawExplain, + }; + try { + const { + nReturned, + executionTimeMillis, + usedIndexes + } = new ExplainPlan(rawExplain as any); + const stats = { + executionTimeMillis, + nReturned, + usedIndexes, + } + explain.stats = stats; + } catch (e) { + log.warn( + mongoLogId(1_001_000_137), + 'Explain', + 'Failed to parse aggregation explain', + { message: (e as Error).message } + ); + } finally { + // If parsing fails, we still show raw explain json. + dispatch({ + type: ActionTypes.ExplainFinished, + explain, + }); + } + } catch (e) { + // Cancellation is handled in cancelExplain + if ((e as Error).name !== PROMISE_CANCELLED_ERROR) { + dispatch({ + type: ActionTypes.ExplainFailed, + error: (e as Error).message, + }); + log.error( + mongoLogId(1_001_000_138), + 'Explain', + 'Failed to run aggregation explain', + { message: (e as Error).message } + ); + } + } + } +}; export default reducer; \ No newline at end of file diff --git a/packages/compass-aggregations/src/modules/index.ts b/packages/compass-aggregations/src/modules/index.ts index afecb3e1655..8ce6e35cdd1 100644 --- a/packages/compass-aggregations/src/modules/index.ts +++ b/packages/compass-aggregations/src/modules/index.ts @@ -124,6 +124,10 @@ import explain, { INITIAL_STATE as EXPLAIN_INITIAL_STATE } from './explain'; +import isDataLake, { + INITIAL_STATE as DATALAKE_INITIAL_STATE +} from './is-datalake'; + import workspace, { INITIAL_STATE as WORKSPACE_INITIAL_STATE } from './workspace'; @@ -182,6 +186,7 @@ export const INITIAL_STATE = { workspace: WORKSPACE_INITIAL_STATE, countDocuments: COUNT_INITIAL_STATE, explain: EXPLAIN_INITIAL_STATE, + isDataLake: DATALAKE_INITIAL_STATE, }; /** @@ -263,6 +268,7 @@ const appReducer = combineReducers({ countDocuments, aggregationWorkspaceId, explain, + isDataLake, }); export type RootState = ReturnType; diff --git a/packages/compass-aggregations/src/modules/is-datalake.ts b/packages/compass-aggregations/src/modules/is-datalake.ts new file mode 100644 index 00000000000..17bf08d68cc --- /dev/null +++ b/packages/compass-aggregations/src/modules/is-datalake.ts @@ -0,0 +1,31 @@ +import type { Reducer } from 'redux'; + +enum ActionTypes { + SetDataLake = 'compass-aggregations/setDataLake', +}; + +type SetDataLakeAction = { + type: ActionTypes.SetDataLake; + dataLake: boolean; +}; + +type Actions = SetDataLakeAction; +type State = boolean; + +export const INITIAL_STATE: State = false; + +const reducer: Reducer = (state = INITIAL_STATE, action) => { + switch (action.type) { + case ActionTypes.SetDataLake: + return action.dataLake; + default: + return state; + } +}; + +export const setDataLake = (dataLake: boolean): SetDataLakeAction => ({ + type: ActionTypes.SetDataLake, + dataLake, +}); + +export default reducer; \ No newline at end of file diff --git a/packages/compass-aggregations/src/plugin.jsx b/packages/compass-aggregations/src/plugin.jsx index 99db87aa9e5..dcef33b8a31 100644 --- a/packages/compass-aggregations/src/plugin.jsx +++ b/packages/compass-aggregations/src/plugin.jsx @@ -18,13 +18,15 @@ class Plugin extends Component { static propTypes = { store: PropTypes.object.isRequired, showExportButton: PropTypes.bool, - showRunButton: PropTypes.bool - } + showRunButton: PropTypes.bool, + showExplainButton: PropTypes.bool, + }; static defaultProps = { showExportButton: false, showRunButton: false, - } + showExplainButton: false, + }; /** * Connect the Plugin to the store and render. @@ -37,6 +39,7 @@ class Plugin extends Component { ); diff --git a/packages/compass-aggregations/src/stores/store.js b/packages/compass-aggregations/src/stores/store.js index 6729e9b9f5c..8c746d0ca0a 100644 --- a/packages/compass-aggregations/src/stores/store.js +++ b/packages/compass-aggregations/src/stores/store.js @@ -20,6 +20,7 @@ import { localAppRegistryActivated, globalAppRegistryActivated } from '@mongodb-js/mongodb-redux-common/app-registry'; +import { setDataLake } from '../modules/is-datalake'; /** * Refresh the input documents. @@ -306,6 +307,10 @@ const configureStore = (options = {}) => { getPipelineFromIndexedDB(options.aggregation.id)(store.dispatch); } + if (options.isDataLake) { + store.dispatch(setDataLake(options.isDataLake)); + } + return store; }; diff --git a/packages/compass-aggregations/src/stores/store.spec.js b/packages/compass-aggregations/src/stores/store.spec.js index ce92752d5d2..5a2106cb76a 100644 --- a/packages/compass-aggregations/src/stores/store.spec.js +++ b/packages/compass-aggregations/src/stores/store.spec.js @@ -264,6 +264,7 @@ describe('Aggregation Store', function() { workspace: INITIAL_STATE.workspace, countDocuments: INITIAL_STATE.countDocuments, explain: INITIAL_STATE.explain, + isDataLake: INITIAL_STATE.isDataLake, }); }); }); diff --git a/packages/compass-aggregations/src/utils/cancellable-aggregation.ts b/packages/compass-aggregations/src/utils/cancellable-aggregation.ts index 0879b27de83..b71b502fe6f 100644 --- a/packages/compass-aggregations/src/utils/cancellable-aggregation.ts +++ b/packages/compass-aggregations/src/utils/cancellable-aggregation.ts @@ -63,3 +63,62 @@ export async function aggregatePipeline({ return result; } + + +/** + * todo: move to data-service (COMPASS-5808) + */ +export async function explainPipeline({ + dataService, + signal, + namespace, + pipeline, + options, + isDataLake, +}: { + dataService: DataService; + signal: AbortSignal; + namespace: string; + pipeline: Document[]; + options: AggregateOptions; + isDataLake: boolean; +}): Promise { + if (signal.aborted) { + return Promise.reject(createCancelError()); + } + const session = dataService.startSession('CRUD'); + const cursor = dataService.aggregate( + namespace, + pipeline, + options + ); + const abort = () => { + Promise.all([ + cursor.close(), + dataService.killSessions(session) + ]).catch((err) => { + log.warn( + mongoLogId(1_001_000_139), + 'Aggregation explain', + 'Attempting to kill the session failed', + { error: err.message } + ); + }); + }; + signal.addEventListener('abort', abort, { once: true }); + let result = {}; + try { + const lastStage = pipeline[pipeline.length - 1] ?? {}; + const isOutOrMergePipeline = + Object.prototype.hasOwnProperty.call(lastStage, '$out') || + Object.prototype.hasOwnProperty.call(lastStage, '$merge'); + const verbosity = isDataLake + ? 'queryPlannerExtended' + : isOutOrMergePipeline ? 'queryPlanner' // $out & $merge only work with queryPlanner + : 'allPlansExecution'; + result = await raceWithAbort(cursor.explain(verbosity), signal); + } finally { + signal.removeEventListener('abort', abort); + } + return result; +} diff --git a/packages/compass-collection/src/modules/is-data-lake.ts b/packages/compass-collection/src/modules/is-data-lake.ts index 9d584a35fe4..297385ce953 100644 --- a/packages/compass-collection/src/modules/is-data-lake.ts +++ b/packages/compass-collection/src/modules/is-data-lake.ts @@ -32,3 +32,8 @@ export default function reducer( } return state; } + +export const dataLakeChanged = (isDataLake: boolean): AnyAction => ({ + type: IS_DATA_LAKE_CHANGED, + isDataLake, +}); diff --git a/packages/compass-collection/src/stores/context.tsx b/packages/compass-collection/src/stores/context.tsx index 159ad7df36d..9cba2a3d800 100644 --- a/packages/compass-collection/src/stores/context.tsx +++ b/packages/compass-collection/src/stores/context.tsx @@ -107,6 +107,7 @@ const setupStore = ({ serverVersion, isReadonly, isTimeSeries, + isDataLake, isClustered, isFLE, actions, @@ -129,6 +130,7 @@ const setupStore = ({ serverVersion, isReadonly, isTimeSeries, + isDataLake, isClustered, isFLE, actions: actions, @@ -173,6 +175,7 @@ const setupPlugin = ({ serverVersion, isReadonly, isTimeSeries, + isDataLake, isClustered, isFLE, sourceName, @@ -190,6 +193,7 @@ const setupPlugin = ({ serverVersion, isReadonly, isTimeSeries, + isDataLake, isClustered, isFLE, sourceName, @@ -231,6 +235,7 @@ const setupScopedModals = ({ serverVersion, isReadonly, isTimeSeries, + isDataLake, isClustered, isFLE, sourceName, @@ -249,6 +254,7 @@ const setupScopedModals = ({ serverVersion, isReadonly, isTimeSeries, + isDataLake, isClustered, isFLE, sourceName, @@ -348,6 +354,7 @@ const createContext = ({ serverVersion, isReadonly, isTimeSeries, + isDataLake, isClustered, isFLE, actions, @@ -373,6 +380,7 @@ const createContext = ({ ...(role.name === 'Aggregations' && { showExportButton: true, showRunButton: true, + showExplainButton: true, }), }; @@ -404,6 +412,7 @@ const createContext = ({ serverVersion, isReadonly, isTimeSeries, + isDataLake, isClustered, isFLE, sourceName, diff --git a/packages/compass-collection/src/stores/index.ts b/packages/compass-collection/src/stores/index.ts index 56833b5aa08..29ce8a96043 100644 --- a/packages/compass-collection/src/stores/index.ts +++ b/packages/compass-collection/src/stores/index.ts @@ -5,6 +5,7 @@ import { createStore, applyMiddleware } from 'redux'; import type { AnyAction } from 'redux'; import thunk from 'redux-thunk'; import toNS from 'mongodb-ns'; +import type { DataService } from 'mongodb-data-service'; import appRegistry, { appRegistryActivated, @@ -19,6 +20,7 @@ import serverVersion, { INITIAL_STATE as SERVER_VERSION_INITIAL_STATE, } from '../modules/server-version'; import isDataLake, { + dataLakeChanged, INITIAL_STATE as IS_DATA_LAKE_INITIAL_STATE, } from '../modules/is-data-lake'; import stats, { @@ -141,6 +143,12 @@ store.onActivated = (appRegistry: AppRegistry) => { } } ); + instance.dataLake.on( + 'change:isDataLake', + (_model: unknown, value: boolean) => { + store.dispatch(dataLakeChanged(value)); + } + ); } }); @@ -248,13 +256,13 @@ store.onActivated = (appRegistry: AppRegistry) => { /** * Set the data service in the store when connected. - * - * @param {Error} error - The error. - * @param {DataService} dataService - The data service. */ - appRegistry.on('data-service-connected', (error, dataService) => { - store.dispatch(dataServiceConnected(error, dataService)); - }); + appRegistry.on( + 'data-service-connected', + (error, dataService: DataService) => { + store.dispatch(dataServiceConnected(error, dataService)); + } + ); /** * When the instance is loaded, set our server version. diff --git a/packages/compass-crud/index.d.ts b/packages/compass-crud/index.d.ts index 6973b5d0578..d5e22c2f551 100644 --- a/packages/compass-crud/index.d.ts +++ b/packages/compass-crud/index.d.ts @@ -1,7 +1,9 @@ import type HadronDocument from "hadron-document"; +type Doc = HadronDocument | Record; + export const Document: React.ComponentClass<{ - doc: HadronDocument; + doc: Doc; editable?: boolean; isTimeSeries?: boolean; removeDocument?: () => void; @@ -12,7 +14,7 @@ export const Document: React.ComponentClass<{ }>; type ListViewProps = { - docs: HadronDocument[]; + docs: Doc[]; isEditable?: boolean; isTimeSeries?: boolean; removeDocument?: (doc: HadronDocument) => void; diff --git a/packages/compass-e2e-tests/helpers/selectors.ts b/packages/compass-e2e-tests/helpers/selectors.ts index 134c835c4f2..97fd732be7a 100644 --- a/packages/compass-e2e-tests/helpers/selectors.ts +++ b/packages/compass-e2e-tests/helpers/selectors.ts @@ -630,6 +630,14 @@ export const RunPipelineButton = `[data-testid="pipeline-toolbar-run-button"]`; export const EditPipelineButton = `[data-testid="pipeline-toolbar-edit-button"]`; export const GoToCollectionButton = `[data-testid="pipeline-results-go-to-collection"]`; +// New Aggregation Toolbar Specific +export const AggregationToolbarCreateMenu = '[data-testid="create-new-menu"]'; +export const AggregationToolbarCreateNewPipeline = + '[data-testid="create-new-menu-content"] > li:nth-child(1)'; +export const AggregationExplainButton = + '[data-testid="pipeline-toolbar-explain-aggregation-button"]'; +export const AggregationExplainModal = '[data-testid="pipeline-explain-modal"]'; + // Create view from pipeline modal export const CreateViewModal = '[data-testid="create_view_modal"]'; export const CreateViewNameInput = '#create-view-name'; diff --git a/packages/compass-e2e-tests/tests/collection-aggregations-tab.test.ts b/packages/compass-e2e-tests/tests/collection-aggregations-tab.test.ts index 9e6aee9968b..6fb5be8602f 100644 --- a/packages/compass-e2e-tests/tests/collection-aggregations-tab.test.ts +++ b/packages/compass-e2e-tests/tests/collection-aggregations-tab.test.ts @@ -19,6 +19,10 @@ async function waitForAnyText( return text !== ''; }); } +const initialAggregationToolbarValue = + process.env.COMPASS_SHOW_NEW_AGGREGATION_TOOLBAR; +const initialAggregationExplainValue = + process.env.COMPASS_ENABLE_AGGREGATION_EXPLAIN; describe('Collection aggregations tab', function () { let compass: Compass; @@ -497,6 +501,60 @@ describe('Collection aggregations tab', function () { }); }); + describe('Aggregation Explain', function () { + let compass: Compass; + let browser: CompassBrowser; + + before(async function () { + process.env.COMPASS_SHOW_NEW_AGGREGATION_TOOLBAR = 'true'; + process.env.COMPASS_ENABLE_AGGREGATION_EXPLAIN = 'true'; + + compass = await beforeTests(); + browser = compass.browser; + }); + + beforeEach(async function () { + await createNumbersCollection(); + await browser.connectWithConnectionString( + 'mongodb://localhost:27018/test' + ); + // Some tests navigate away from the numbers collection aggregations tab + await browser.navigateToCollectionTab('test', 'numbers', 'Aggregations'); + // Get us back to the empty stage every time. Also test the Create New + // Pipeline flow while at it. + await browser.clickVisible(Selectors.AggregationToolbarCreateMenu); + await browser.clickVisible(Selectors.AggregationToolbarCreateNewPipeline); + const modalElement = await browser.$(Selectors.ConfirmNewPipelineModal); + await modalElement.waitForDisplayed(); + await browser.clickVisible( + Selectors.ConfirmNewPipelineModalConfirmButton + ); + await modalElement.waitForDisplayed({ reverse: true }); + }); + + after(async function () { + process.env.COMPASS_SHOW_NEW_AGGREGATION_TOOLBAR = + initialAggregationToolbarValue; + process.env.COMPASS_ENABLE_AGGREGATION_EXPLAIN = + initialAggregationExplainValue; + await afterTests(compass, this.currentTest); + }); + + afterEach(async function () { + await afterTest(compass, this.currentTest); + }); + + it('shows the explain for a pipeline', async function () { + await browser.clickVisible(Selectors.AggregationExplainButton); + await browser.waitForAnimations(Selectors.AggregationExplainModal); + + const modal = await browser.$(Selectors.AggregationExplainModal); + await modal.waitForDisplayed(); + await browser.waitForAnimations(Selectors.AggregationExplainModal); + expect(await modal.getText()).to.contain('Query Performance Summary'); + }); + }); + async function goToRunAggregation() { if (await browser.$(Selectors.AggregationBuilderWorkspace).isDisplayed()) { await browser.clickVisible(Selectors.RunPipelineButton); diff --git a/packages/explain-plan-helper/src/index.ts b/packages/explain-plan-helper/src/index.ts index 04577ea7365..93eb035cc5c 100644 --- a/packages/explain-plan-helper/src/index.ts +++ b/packages/explain-plan-helper/src/index.ts @@ -9,7 +9,7 @@ export type Stage = Record & { stage: string; [kParent]: Stage | null; }; -type IndexInformation = +export type IndexInformation = | { shard: string; index: string } | { shard: string; index: null } | { shard: null; index: string };