Skip to content

Commit 2318df5

Browse files
mabaasitaddaleaxgribnoysup
authored
feat(explain-aggregation): explain aggregation COMPASS-5788 (#3102)
Co-authored-by: Anna Henningsen <[email protected]> Co-authored-by: Sergey Petushkov <[email protected]>
1 parent f2633dd commit 2318df5

File tree

28 files changed

+747
-43
lines changed

28 files changed

+747
-43
lines changed

package-lock.json

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/compass-aggregations/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
},
102102
"dependencies": {
103103
"@mongodb-js/compass-logging": "^0.12.0",
104+
"@mongodb-js/explain-plan-helper": "^0.9.0",
104105
"@mongodb-js/mongodb-redux-common": "^1.12.0",
105106
"acorn-loose": "^8.0.2",
106107
"astring": "^1.7.0",
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import React from 'react';
2+
import { Badge, BadgeVariant, Body } from '@mongodb-js/compass-components';
3+
import type { IndexInformation } from '@mongodb-js/explain-plan-helper';
4+
5+
type ExplainIndexesProps = {
6+
indexes: IndexInformation[];
7+
};
8+
9+
export const ExplainIndexes: React.FunctionComponent<ExplainIndexesProps> = ({
10+
indexes,
11+
}) => {
12+
if (indexes.filter(({ index }) => index).length === 0) {
13+
return <Body weight="medium">No index available for this query.</Body>;
14+
}
15+
16+
return (
17+
<div>
18+
{indexes.map((info, idx) => (
19+
<Badge key={idx} variant={BadgeVariant.LightGray}>
20+
{info.index} {info.shard && <>({info.shard})</>}
21+
</Badge>
22+
))}
23+
</div>
24+
);
25+
};
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import React from 'react';
2+
import { Body, Subtitle, css, spacing } from '@mongodb-js/compass-components';
3+
import type { IndexInformation } from '@mongodb-js/explain-plan-helper';
4+
5+
import { ExplainIndexes } from './explain-indexes';
6+
7+
type ExplainQueryPerformanceProps = {
8+
executionTimeMillis: number;
9+
nReturned: number;
10+
usedIndexes: IndexInformation[];
11+
};
12+
13+
const containerStyles = css({
14+
display: 'flex',
15+
gap: spacing[3],
16+
flexDirection: 'column',
17+
});
18+
19+
const statsStyles = css({
20+
gap: spacing[1],
21+
display: 'flex',
22+
flexDirection: 'column',
23+
});
24+
25+
const statItemStyles = css({
26+
display: 'flex',
27+
gap: spacing[1],
28+
});
29+
30+
const statTitleStyles = css({
31+
whiteSpace: 'nowrap',
32+
});
33+
34+
export const ExplainQueryPerformance: React.FunctionComponent<ExplainQueryPerformanceProps> =
35+
({ nReturned, executionTimeMillis, usedIndexes }) => {
36+
return (
37+
<div
38+
className={containerStyles}
39+
data-testid="pipeline-explain-results-summary"
40+
>
41+
<Subtitle>Query Performance Summary</Subtitle>
42+
<div className={statsStyles}>
43+
{typeof nReturned === 'number' && (
44+
<div className={statItemStyles}>
45+
<Body>Documents returned:</Body>
46+
<Body weight="medium">{nReturned}</Body>
47+
</div>
48+
)}
49+
{executionTimeMillis > 0 && (
50+
<div className={statItemStyles}>
51+
<Body>Actual query execution time(ms):</Body>
52+
<Body weight="medium">{executionTimeMillis}</Body>
53+
</div>
54+
)}
55+
<div className={statItemStyles}>
56+
<Body className={statTitleStyles}>
57+
Query used the following indexes:
58+
</Body>
59+
<ExplainIndexes indexes={usedIndexes} />
60+
</div>
61+
</div>
62+
</div>
63+
);
64+
};
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React from 'react';
2+
import { css, spacing, Card } from '@mongodb-js/compass-components';
3+
import { Document } from '@mongodb-js/compass-crud';
4+
import HadronDocument from 'hadron-document';
5+
6+
import type { ExplainData } from '../../modules/explain';
7+
import { ExplainQueryPerformance } from './explain-query-performance';
8+
9+
type ExplainResultsProps = {
10+
plan: ExplainData['plan'];
11+
stats?: ExplainData['stats'];
12+
};
13+
14+
const containerStyles = css({
15+
display: 'flex',
16+
flexDirection: 'column',
17+
gap: spacing[4],
18+
});
19+
20+
const cardStyles = css({
21+
// 170px works with minimum-height of compass
22+
// todo: handle height for bigger sized compass
23+
height: '170px',
24+
overflowY: 'scroll',
25+
});
26+
27+
export const ExplainResults: React.FunctionComponent<ExplainResultsProps> = ({
28+
plan,
29+
stats,
30+
}) => {
31+
return (
32+
<div className={containerStyles} data-testid="pipeline-explain-results">
33+
{stats && (
34+
<ExplainQueryPerformance
35+
nReturned={stats.nReturned}
36+
executionTimeMillis={stats.executionTimeMillis}
37+
usedIndexes={stats.usedIndexes}
38+
/>
39+
)}
40+
<Card className={cardStyles} data-testid="pipeline-explain-results-json">
41+
<Document
42+
doc={plan}
43+
editable={false}
44+
copyToClipboard={() => {
45+
void navigator.clipboard.writeText(
46+
new HadronDocument(plan).toEJSON()
47+
);
48+
}}
49+
/>
50+
</Card>
51+
</div>
52+
);
53+
};
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import React from 'react';
2+
import type { ComponentProps } from 'react';
3+
import { render, screen, within } from '@testing-library/react';
4+
import userEvent from '@testing-library/user-event';
5+
import { spy } from 'sinon';
6+
import { expect } from 'chai';
7+
8+
import { PipelineExplain } from './index';
9+
10+
const renderPipelineExplain = (
11+
props: Partial<ComponentProps<typeof PipelineExplain>> = {}
12+
) => {
13+
render(
14+
<PipelineExplain
15+
isLoading={false}
16+
isModalOpen={true}
17+
onCancelExplain={() => {}}
18+
onCloseModal={() => {}}
19+
onRunExplain={() => {}}
20+
{...props}
21+
/>
22+
);
23+
};
24+
25+
describe('PipelineExplain', function () {
26+
it('renders loading state', function () {
27+
const onCancelExplainSpy = spy();
28+
renderPipelineExplain({
29+
isLoading: true,
30+
onCancelExplain: onCancelExplainSpy,
31+
});
32+
const modal = screen.getByTestId('pipeline-explain-modal');
33+
expect(within(modal).getByTestId('pipeline-explain-cancel')).to.exist;
34+
expect(onCancelExplainSpy.callCount).to.equal(0);
35+
36+
userEvent.click(within(modal).getByText(/cancel/gi), null, {
37+
skipPointerEventsCheck: true,
38+
});
39+
expect(onCancelExplainSpy.callCount).to.equal(1);
40+
41+
expect(() => {
42+
within(modal).getByTestId('pipeline-explain-footer-close-button');
43+
}, 'does not show footer in loading state').to.throw;
44+
});
45+
46+
it('renders error state', function () {
47+
renderPipelineExplain({
48+
error: 'Error occurred',
49+
});
50+
const modal = screen.getByTestId('pipeline-explain-modal');
51+
expect(within(modal).getByTestId('pipeline-explain-error')).to.exist;
52+
expect(within(modal).findByText('Error occurred')).to.exist;
53+
expect(() => {
54+
within(modal).getByTestId('pipeline-explain-retry-button');
55+
}).to.throw;
56+
57+
expect(within(modal).getByTestId('pipeline-explain-footer-close-button')).to
58+
.exist;
59+
});
60+
61+
it('renders explain results - without stats', function () {
62+
renderPipelineExplain({
63+
explain: {
64+
plan: {
65+
stages: [],
66+
},
67+
},
68+
});
69+
const results = screen.getByTestId('pipeline-explain-results');
70+
expect(within(results).getByTestId('pipeline-explain-results-json')).to
71+
.exist;
72+
expect(() => {
73+
within(results).getByTestId('pipeline-explain-results-summary');
74+
}).to.throw;
75+
76+
expect(screen.getByTestId('pipeline-explain-footer-close-button')).to.exist;
77+
});
78+
79+
it('renders explain results - with stats', function () {
80+
renderPipelineExplain({
81+
explain: {
82+
stats: {
83+
executionTimeMillis: 20,
84+
nReturned: 100,
85+
usedIndexes: [{ index: 'name', shard: 'shard1' }],
86+
},
87+
plan: {
88+
stages: [],
89+
},
90+
},
91+
});
92+
const results = screen.getByTestId('pipeline-explain-results');
93+
expect(results).to.exist;
94+
expect(within(results).getByTestId('pipeline-explain-results-json')).to
95+
.exist;
96+
97+
const summary = within(results).getByTestId(
98+
'pipeline-explain-results-summary'
99+
);
100+
expect(summary).to.exist;
101+
102+
expect(within(summary).getByText(/documents returned/gi)).to.exist;
103+
expect(within(summary).getByText(/actual query execution time/gi)).to.exist;
104+
expect(within(summary).getByText(/query used the following indexes/gi)).to
105+
.exist;
106+
107+
expect(screen.getByTestId('pipeline-explain-footer-close-button')).to.exist;
108+
});
109+
});
Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,103 @@
11
import React from 'react';
2-
import { Modal, H3, Body } from '@mongodb-js/compass-components';
2+
import {
3+
css,
4+
spacing,
5+
Modal,
6+
CancelLoader,
7+
H3,
8+
ModalFooter,
9+
Button,
10+
ErrorSummary,
11+
} from '@mongodb-js/compass-components';
312
import { connect } from 'react-redux';
413

514
import type { RootState } from '../../modules';
6-
import { closeExplainModal } from '../../modules/explain';
15+
import type { ExplainData } from '../../modules/explain';
16+
import { closeExplainModal, cancelExplain } from '../../modules/explain';
17+
import { ExplainResults } from './explain-results';
718

819
type PipelineExplainProps = {
920
isModalOpen: boolean;
21+
isLoading: boolean;
22+
error?: string;
23+
explain?: ExplainData;
1024
onCloseModal: () => void;
25+
onCancelExplain: () => void;
1126
};
1227

28+
const contentStyles = css({
29+
marginTop: spacing[3],
30+
marginBottom: spacing[3],
31+
});
32+
33+
const footerStyles = css({
34+
paddingRight: 0,
35+
paddingBottom: 0,
36+
});
37+
1338
export const PipelineExplain: React.FunctionComponent<PipelineExplainProps> = ({
1439
isModalOpen,
40+
isLoading,
41+
error,
42+
explain,
1543
onCloseModal,
44+
onCancelExplain,
1645
}) => {
46+
let content = null;
47+
if (isLoading) {
48+
content = (
49+
<CancelLoader
50+
data-testid="pipeline-explain-cancel"
51+
cancelText="Cancel"
52+
onCancel={() => onCancelExplain()}
53+
progressText="Running explain"
54+
/>
55+
);
56+
} else if (error) {
57+
content = (
58+
<ErrorSummary data-testid="pipeline-explain-error" errors={error} />
59+
);
60+
} else if (explain) {
61+
content = <ExplainResults plan={explain.plan} stats={explain.stats} />;
62+
}
63+
64+
if (!content) {
65+
return null;
66+
}
67+
1768
return (
1869
<Modal
1970
setOpen={onCloseModal}
2071
open={isModalOpen}
2172
data-testid="pipeline-explain-modal"
2273
>
2374
<H3>Explain</H3>
24-
<Body>Implementation in progress ...</Body>
75+
<div className={contentStyles}>{content}</div>
76+
{!isLoading && (
77+
<ModalFooter className={footerStyles}>
78+
<Button
79+
onClick={onCloseModal}
80+
data-testid="pipeline-explain-footer-close-button"
81+
>
82+
Close
83+
</Button>
84+
</ModalFooter>
85+
)}
2586
</Modal>
2687
);
2788
};
2889

29-
const mapState = ({ explain: { isModalOpen } }: RootState) => ({
30-
isModalOpen: isModalOpen,
90+
const mapState = ({
91+
explain: { isModalOpen, isLoading, error, explain },
92+
}: RootState) => ({
93+
isModalOpen,
94+
isLoading,
95+
error,
96+
explain,
3197
});
3298

3399
const mapDispatch = {
34100
onCloseModal: closeExplainModal,
101+
onCancelExplain: cancelExplain,
35102
};
36103
export default connect(mapState, mapDispatch)(PipelineExplain);

0 commit comments

Comments
 (0)