Skip to content

Commit 333c116

Browse files
feat(compass-aggregations): disable search stages for incompatible views COMPASS-9693 (#7217)
1 parent 5b4ec13 commit 333c116

File tree

6 files changed

+220
-16
lines changed

6 files changed

+220
-16
lines changed
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import React from 'react';
2+
import {
3+
fireEvent,
4+
render,
5+
screen,
6+
within,
7+
} from '@mongodb-js/testing-library-compass';
8+
import { expect } from 'chai';
9+
import type { Stage } from './stage-operator-select';
10+
import { StageOperatorSelect } from './stage-operator-select';
11+
import Sinon from 'sinon';
12+
13+
describe('StageOperatorSelect', () => {
14+
const mockStages: Stage[] = [
15+
{
16+
name: 'basicStage',
17+
env: ['on-prem'],
18+
description: 'basicStage description.',
19+
},
20+
{
21+
name: 'atlasOnlyStage',
22+
env: ['atlas'],
23+
description: 'atlasOnlyStage description.',
24+
},
25+
{
26+
name: '$search',
27+
env: ['atlas'],
28+
description: 'searchStage description.',
29+
},
30+
];
31+
32+
const defaultMockProps = {
33+
index: 0,
34+
onChange: Sinon.stub(),
35+
selectedStage: null,
36+
isDisabled: false,
37+
stages: mockStages,
38+
serverVersion: '8.1.0',
39+
sourceName: 'sourceName',
40+
collectionStats: {
41+
pipeline: [{ $addFields: { field: 'value' } }],
42+
},
43+
};
44+
45+
const renderCombobox = (
46+
props: Partial<React.ComponentProps<typeof StageOperatorSelect>> = {}
47+
) => {
48+
return render(<StageOperatorSelect {...defaultMockProps} {...props} />);
49+
};
50+
51+
it('renders the correct descriptions if not in readonly view', () => {
52+
renderCombobox({ sourceName: null });
53+
fireEvent.click(screen.getByRole('combobox'));
54+
const listbox = screen.getByRole('listbox');
55+
56+
expect(within(listbox).getByText('basicStage description.')).to.exist;
57+
expect(within(listbox).getByText('Atlas only. atlasOnlyStage description.'))
58+
.to.exist;
59+
expect(within(listbox).getByText('Atlas only. searchStage description.')).to
60+
.exist;
61+
});
62+
63+
it('renders the correct descriptions if in readonly view with non queryable pipeline', () => {
64+
renderCombobox({
65+
collectionStats: {
66+
pipeline: [
67+
{ $addFields: { field: 'value' } },
68+
{ project: { newField: 1 } },
69+
],
70+
},
71+
});
72+
fireEvent.click(screen.getByRole('combobox'));
73+
const listbox = screen.getByRole('listbox');
74+
75+
expect(within(listbox).getByText('basicStage description.')).to.exist;
76+
expect(within(listbox).getByText('Atlas only. atlasOnlyStage description.'))
77+
.to.exist;
78+
expect(
79+
within(listbox).getByText(
80+
'Atlas only. Only views containing $match stages with the $expr operator, $addFields, or $set are compatible with search indexes. searchStage description.'
81+
)
82+
).to.exist;
83+
});
84+
85+
it('renders the correct descriptions for $search stage in readonly view with 8.0 version', () => {
86+
renderCombobox({ serverVersion: '8.0.0' });
87+
fireEvent.click(screen.getByRole('combobox'));
88+
const listbox = screen.getByRole('listbox');
89+
90+
expect(within(listbox).getByText('basicStage description.')).to.exist;
91+
expect(within(listbox).getByText('Atlas only. atlasOnlyStage description.'))
92+
.to.exist;
93+
expect(
94+
within(listbox).getByText(
95+
'Atlas only. Requires MongoDB 8.1+ to run on a view. To use a search index on a view on MongoDB 8.0, query the view’s source collection sourceName. searchStage description.'
96+
)
97+
).to.exist;
98+
});
99+
100+
it('renders the correct descriptions for $search stage in readonly view with incompatible version', () => {
101+
renderCombobox({ serverVersion: '7.0.0' });
102+
fireEvent.click(screen.getByRole('combobox'));
103+
const listbox = screen.getByRole('listbox');
104+
105+
expect(within(listbox).getByText('basicStage description.')).to.exist;
106+
expect(within(listbox).getByText('Atlas only. atlasOnlyStage description.'))
107+
.to.exist;
108+
expect(
109+
within(listbox).getByText(
110+
'Atlas only. Requires MongoDB 8.1+ to run on a view. searchStage description.'
111+
)
112+
).to.exist;
113+
});
114+
});

packages/compass-aggregations/src/components/stage-toolbar/stage-operator-select.tsx

Lines changed: 98 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useCallback } from 'react';
22
import { withPreferences } from 'compass-preferences-model/provider';
33
import { connect } from 'react-redux';
4+
import { VIEW_PIPELINE_UTILS } from '@mongodb-js/mongodb-constants';
45

56
import {
67
Combobox,
@@ -13,13 +14,15 @@ import type { RootState } from '../../modules';
1314
import { changeStageOperator } from '../../modules/pipeline-builder/stage-editor';
1415
import type { StoreStage } from '../../modules/pipeline-builder/stage-editor';
1516

16-
import { filterStageOperators } from '../../utils/stage';
17+
import { filterStageOperators, isSearchStage } from '../../utils/stage';
1718
import { isAtlasOnly } from '../../utils/stage';
1819
import type { ServerEnvironment } from '../../modules/env';
20+
import type { CollectionStats } from '../../modules/collection-stats';
21+
import semver from 'semver';
1922

2023
const inputWidth = spacing[1400] * 3;
2124
// width of options popover
22-
const comboxboxOptionsWidth = spacing[1200] * 10;
25+
const comboxboxOptionsWidth = spacing[1200] * 14;
2326
// left position of options popover wrt input. this aligns it with the start of input
2427
const comboboxOptionsLeft = (comboxboxOptionsWidth - inputWidth) / 2;
2528

@@ -38,11 +41,69 @@ type StageOperatorSelectProps = {
3841
index: number;
3942
selectedStage: string | null;
4043
isDisabled: boolean;
41-
stages: {
42-
name: string;
43-
env: ServerEnvironment[];
44-
description: string;
45-
}[];
44+
stages: Stage[];
45+
serverVersion: string;
46+
sourceName: string | null;
47+
collectionStats: CollectionStats;
48+
};
49+
50+
export type Stage = {
51+
name: string;
52+
env: ServerEnvironment[];
53+
description: string;
54+
};
55+
56+
const sourceCollectionSupportsViewIndex = (serverVersion: string) => {
57+
try {
58+
// Check if the serverVersion is 8.0
59+
return (
60+
semver.gte(serverVersion, '8.0.0') && semver.lt(serverVersion, '8.1.0')
61+
);
62+
} catch {
63+
return false;
64+
}
65+
};
66+
67+
export const getStageDescription = (
68+
stage: Stage,
69+
sourceName: string | null,
70+
serverVersion: string,
71+
versionIncompatibleCompass: boolean,
72+
isPipelineSearchQueryable: boolean
73+
) => {
74+
const isReadonlyView = !!sourceName;
75+
if (isReadonlyView && isSearchStage(stage.name)) {
76+
const minVersionCompatibility =
77+
VIEW_PIPELINE_UTILS.MIN_VERSION_FOR_VIEW_SEARCH_COMPATIBILITY_COMPASS.split(
78+
'.'
79+
)
80+
.slice(0, 2)
81+
.join('.');
82+
83+
if (versionIncompatibleCompass) {
84+
// If version is <8.1
85+
if (sourceCollectionSupportsViewIndex(serverVersion)) {
86+
// version is 8.0
87+
return (
88+
`Atlas only. Requires MongoDB ${minVersionCompatibility}+ to run on a view. To use a search index on a view on MongoDB 8.0, query the view’s source collection ${sourceName}. ` +
89+
stage.description
90+
);
91+
}
92+
93+
return (
94+
`Atlas only. Requires MongoDB ${minVersionCompatibility}+ to run on a view. ` +
95+
stage.description
96+
);
97+
}
98+
99+
if (!isPipelineSearchQueryable) {
100+
return (
101+
`Atlas only. Only views containing $match stages with the $expr operator, $addFields, or $set are compatible with search indexes. ` +
102+
stage.description
103+
);
104+
}
105+
}
106+
return (isAtlasOnly(stage.env) ? 'Atlas only. ' : '') + stage.description;
46107
};
47108

48109
// exported for tests
@@ -51,6 +112,9 @@ export const StageOperatorSelect = ({
51112
index,
52113
selectedStage,
53114
isDisabled,
115+
serverVersion,
116+
sourceName,
117+
collectionStats,
54118
stages,
55119
}: StageOperatorSelectProps) => {
56120
const onStageOperatorSelected = useCallback(
@@ -59,6 +123,21 @@ export const StageOperatorSelect = ({
59123
},
60124
[onChange, index]
61125
);
126+
const versionIncompatibleCompass =
127+
!VIEW_PIPELINE_UTILS.isVersionSearchCompatibleForViewsCompass(
128+
serverVersion
129+
);
130+
131+
const pipelineIsSearchQueryable = collectionStats?.pipeline
132+
? VIEW_PIPELINE_UTILS.isPipelineSearchQueryable(
133+
collectionStats.pipeline as Document[]
134+
)
135+
: true;
136+
const isReadonlyView = !!sourceName;
137+
const disableSearchStage =
138+
isReadonlyView &&
139+
(!pipelineIsSearchQueryable || versionIncompatibleCompass);
140+
62141
return (
63142
<Combobox
64143
value={selectedStage}
@@ -70,14 +149,19 @@ export const StageOperatorSelect = ({
70149
data-testid="stage-operator-combobox"
71150
className={comboboxStyles}
72151
>
73-
{stages.map((stage, index) => (
152+
{stages.map((stage: Stage, index) => (
74153
<ComboboxOption
75154
data-testid={`combobox-option-stage-${stage.name}`}
76155
key={`combobox-option-stage-${index}`}
77156
value={stage.name}
78-
description={
79-
(isAtlasOnly(stage.env) ? 'Atlas only. ' : '') + stage.description
80-
}
157+
disabled={isSearchStage(stage.name) && disableSearchStage}
158+
description={getStageDescription(
159+
stage,
160+
sourceName,
161+
serverVersion,
162+
versionIncompatibleCompass,
163+
pipelineIsSearchQueryable
164+
)}
81165
/>
82166
))}
83167
</Combobox>
@@ -113,6 +197,9 @@ export default withPreferences(
113197
selectedStage: stage.stageOperator,
114198
isDisabled: stage.disabled,
115199
stages: stages,
200+
serverVersion: state.serverVersion,
201+
sourceName: state.sourceName,
202+
collectionStats: state.collectionStats,
116203
};
117204
},
118205
(dispatch: any, ownProps) => {

packages/compass-aggregations/src/modules/collection-stats.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,19 @@ import { isAction } from '../utils/is-action';
44

55
export type CollectionStats = {
66
document_count?: number;
7+
pipeline?: unknown[];
78
};
89

910
export const INITIAL_STATE: CollectionStats = {
1011
document_count: undefined,
12+
pipeline: undefined,
1113
};
1214

1315
export function pickCollectionStats(collection: Collection): CollectionStats {
14-
const { document_count } = collection.toJSON();
16+
const { document_count, pipeline } = collection.toJSON();
1517
return {
1618
document_count,
19+
pipeline,
1720
};
1821
}
1922

packages/compass-indexes/src/components/indexes-toolbar/indexes-toolbar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ export const IndexesToolbar: React.FunctionComponent<IndexesToolbarProps> = ({
134134
)
135135
: true;
136136
const pipelineNotSearchQueryableDescription =
137-
'Search indexes can only be created on views containing $addFields, $set or $match stages with the $expr operator.';
137+
'Search indexes can only be created on views containing $match stages with the $expr operator, $addFields, or $set';
138138
return (
139139
<div
140140
className={indexesToolbarContainerStyles}

packages/compass-indexes/src/components/indexes/indexes.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ const ViewNotSearchCompatibleBanner = ({
106106
</>
107107
)}
108108
This view is incompatible with search indexes. Only views containing
109-
$addFields, $set or $match stages with the $expr operator are compatible
109+
$match stages with the $expr operator, $addFields, or $set are compatible
110110
with search indexes.{' '}
111111
{!hasNoSearchIndexes && 'Edit the view to rebuild search indexes.'}{' '}
112112
<Link

packages/compass-indexes/src/components/search-indexes-table/search-indexes-table.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ function ZeroState({
9494
</Button>
9595
}
9696
>
97-
Search indexes can only be created on views containing $addFields,
98-
$set or $match stages with the $expr operator.
97+
Search indexes can only be created on views containing $match stages
98+
with the $expr operator, $addFields, or $set.
9999
</Tooltip>
100100
}
101101
callToActionLink={

0 commit comments

Comments
 (0)