diff --git a/packages/compass-aggregations/src/components/stage-toolbar/stage-operator-select.spec.tsx b/packages/compass-aggregations/src/components/stage-toolbar/stage-operator-select.spec.tsx new file mode 100644 index 00000000000..a0de2e7ec15 --- /dev/null +++ b/packages/compass-aggregations/src/components/stage-toolbar/stage-operator-select.spec.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { + fireEvent, + render, + screen, + within, +} from '@mongodb-js/testing-library-compass'; +import { expect } from 'chai'; +import type { Stage } from './stage-operator-select'; +import { StageOperatorSelect } from './stage-operator-select'; +import Sinon from 'sinon'; + +describe('StageOperatorSelect', () => { + const mockStages: Stage[] = [ + { + name: 'basicStage', + env: ['on-prem'], + description: 'basicStage description.', + }, + { + name: 'atlasOnlyStage', + env: ['atlas'], + description: 'atlasOnlyStage description.', + }, + { + name: '$search', + env: ['atlas'], + description: 'searchStage description.', + }, + ]; + + const defaultMockProps = { + index: 0, + onChange: Sinon.stub(), + selectedStage: null, + isDisabled: false, + stages: mockStages, + serverVersion: '8.1.0', + sourceName: 'sourceName', + collectionStats: { + pipeline: [{ $addFields: { field: 'value' } }], + }, + }; + + const renderCombobox = ( + props: Partial> = {} + ) => { + return render(); + }; + + it('renders the correct descriptions if not in readonly view', () => { + renderCombobox({ sourceName: null }); + fireEvent.click(screen.getByRole('combobox')); + const listbox = screen.getByRole('listbox'); + + expect(within(listbox).getByText('basicStage description.')).to.exist; + expect(within(listbox).getByText('Atlas only. atlasOnlyStage description.')) + .to.exist; + expect(within(listbox).getByText('Atlas only. searchStage description.')).to + .exist; + }); + + it('renders the correct descriptions if in readonly view with non queryable pipeline', () => { + renderCombobox({ + collectionStats: { + pipeline: [ + { $addFields: { field: 'value' } }, + { project: { newField: 1 } }, + ], + }, + }); + fireEvent.click(screen.getByRole('combobox')); + const listbox = screen.getByRole('listbox'); + + expect(within(listbox).getByText('basicStage description.')).to.exist; + expect(within(listbox).getByText('Atlas only. atlasOnlyStage description.')) + .to.exist; + expect( + within(listbox).getByText( + 'Atlas only. Only views containing $match stages with the $expr operator, $addFields, or $set are compatible with search indexes. searchStage description.' + ) + ).to.exist; + }); + + it('renders the correct descriptions for $search stage in readonly view with 8.0 version', () => { + renderCombobox({ serverVersion: '8.0.0' }); + fireEvent.click(screen.getByRole('combobox')); + const listbox = screen.getByRole('listbox'); + + expect(within(listbox).getByText('basicStage description.')).to.exist; + expect(within(listbox).getByText('Atlas only. atlasOnlyStage description.')) + .to.exist; + expect( + within(listbox).getByText( + '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.' + ) + ).to.exist; + }); + + it('renders the correct descriptions for $search stage in readonly view with incompatible version', () => { + renderCombobox({ serverVersion: '7.0.0' }); + fireEvent.click(screen.getByRole('combobox')); + const listbox = screen.getByRole('listbox'); + + expect(within(listbox).getByText('basicStage description.')).to.exist; + expect(within(listbox).getByText('Atlas only. atlasOnlyStage description.')) + .to.exist; + expect( + within(listbox).getByText( + 'Atlas only. Requires MongoDB 8.1+ to run on a view. searchStage description.' + ) + ).to.exist; + }); +}); diff --git a/packages/compass-aggregations/src/components/stage-toolbar/stage-operator-select.tsx b/packages/compass-aggregations/src/components/stage-toolbar/stage-operator-select.tsx index 0258debb235..7806883180d 100644 --- a/packages/compass-aggregations/src/components/stage-toolbar/stage-operator-select.tsx +++ b/packages/compass-aggregations/src/components/stage-toolbar/stage-operator-select.tsx @@ -1,6 +1,7 @@ import React, { useCallback } from 'react'; import { withPreferences } from 'compass-preferences-model/provider'; import { connect } from 'react-redux'; +import { VIEW_PIPELINE_UTILS } from '@mongodb-js/mongodb-constants'; import { Combobox, @@ -13,13 +14,15 @@ import type { RootState } from '../../modules'; import { changeStageOperator } from '../../modules/pipeline-builder/stage-editor'; import type { StoreStage } from '../../modules/pipeline-builder/stage-editor'; -import { filterStageOperators } from '../../utils/stage'; +import { filterStageOperators, isSearchStage } from '../../utils/stage'; import { isAtlasOnly } from '../../utils/stage'; import type { ServerEnvironment } from '../../modules/env'; +import type { CollectionStats } from '../../modules/collection-stats'; +import semver from 'semver'; const inputWidth = spacing[1400] * 3; // width of options popover -const comboxboxOptionsWidth = spacing[1200] * 10; +const comboxboxOptionsWidth = spacing[1200] * 14; // left position of options popover wrt input. this aligns it with the start of input const comboboxOptionsLeft = (comboxboxOptionsWidth - inputWidth) / 2; @@ -38,11 +41,69 @@ type StageOperatorSelectProps = { index: number; selectedStage: string | null; isDisabled: boolean; - stages: { - name: string; - env: ServerEnvironment[]; - description: string; - }[]; + stages: Stage[]; + serverVersion: string; + sourceName: string | null; + collectionStats: CollectionStats; +}; + +export type Stage = { + name: string; + env: ServerEnvironment[]; + description: string; +}; + +const sourceCollectionSupportsViewIndex = (serverVersion: string) => { + try { + // Check if the serverVersion is 8.0 + return ( + semver.gte(serverVersion, '8.0.0') && semver.lt(serverVersion, '8.1.0') + ); + } catch { + return false; + } +}; + +export const getStageDescription = ( + stage: Stage, + sourceName: string | null, + serverVersion: string, + versionIncompatibleCompass: boolean, + isPipelineSearchQueryable: boolean +) => { + const isReadonlyView = !!sourceName; + if (isReadonlyView && isSearchStage(stage.name)) { + const minVersionCompatibility = + VIEW_PIPELINE_UTILS.MIN_VERSION_FOR_VIEW_SEARCH_COMPATIBILITY_COMPASS.split( + '.' + ) + .slice(0, 2) + .join('.'); + + if (versionIncompatibleCompass) { + // If version is <8.1 + if (sourceCollectionSupportsViewIndex(serverVersion)) { + // version is 8.0 + return ( + `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}. ` + + stage.description + ); + } + + return ( + `Atlas only. Requires MongoDB ${minVersionCompatibility}+ to run on a view. ` + + stage.description + ); + } + + if (!isPipelineSearchQueryable) { + return ( + `Atlas only. Only views containing $match stages with the $expr operator, $addFields, or $set are compatible with search indexes. ` + + stage.description + ); + } + } + return (isAtlasOnly(stage.env) ? 'Atlas only. ' : '') + stage.description; }; // exported for tests @@ -51,6 +112,9 @@ export const StageOperatorSelect = ({ index, selectedStage, isDisabled, + serverVersion, + sourceName, + collectionStats, stages, }: StageOperatorSelectProps) => { const onStageOperatorSelected = useCallback( @@ -59,6 +123,21 @@ export const StageOperatorSelect = ({ }, [onChange, index] ); + const versionIncompatibleCompass = + !VIEW_PIPELINE_UTILS.isVersionSearchCompatibleForViewsCompass( + serverVersion + ); + + const pipelineIsSearchQueryable = collectionStats?.pipeline + ? VIEW_PIPELINE_UTILS.isPipelineSearchQueryable( + collectionStats.pipeline as Document[] + ) + : true; + const isReadonlyView = !!sourceName; + const disableSearchStage = + isReadonlyView && + (!pipelineIsSearchQueryable || versionIncompatibleCompass); + return ( - {stages.map((stage, index) => ( + {stages.map((stage: Stage, index) => ( ))} @@ -113,6 +197,9 @@ export default withPreferences( selectedStage: stage.stageOperator, isDisabled: stage.disabled, stages: stages, + serverVersion: state.serverVersion, + sourceName: state.sourceName, + collectionStats: state.collectionStats, }; }, (dispatch: any, ownProps) => { diff --git a/packages/compass-aggregations/src/modules/collection-stats.ts b/packages/compass-aggregations/src/modules/collection-stats.ts index a42f4e4d3af..cc794b3d13c 100644 --- a/packages/compass-aggregations/src/modules/collection-stats.ts +++ b/packages/compass-aggregations/src/modules/collection-stats.ts @@ -4,16 +4,19 @@ import { isAction } from '../utils/is-action'; export type CollectionStats = { document_count?: number; + pipeline?: unknown[]; }; export const INITIAL_STATE: CollectionStats = { document_count: undefined, + pipeline: undefined, }; export function pickCollectionStats(collection: Collection): CollectionStats { - const { document_count } = collection.toJSON(); + const { document_count, pipeline } = collection.toJSON(); return { document_count, + pipeline, }; } diff --git a/packages/compass-indexes/src/components/indexes-toolbar/indexes-toolbar.tsx b/packages/compass-indexes/src/components/indexes-toolbar/indexes-toolbar.tsx index cb522741e3e..52a3cd900fd 100644 --- a/packages/compass-indexes/src/components/indexes-toolbar/indexes-toolbar.tsx +++ b/packages/compass-indexes/src/components/indexes-toolbar/indexes-toolbar.tsx @@ -134,7 +134,7 @@ export const IndexesToolbar: React.FunctionComponent = ({ ) : true; const pipelineNotSearchQueryableDescription = - 'Search indexes can only be created on views containing $addFields, $set or $match stages with the $expr operator.'; + 'Search indexes can only be created on views containing $match stages with the $expr operator, $addFields, or $set'; return (
)} This view is incompatible with search indexes. Only views containing - $addFields, $set or $match stages with the $expr operator are compatible + $match stages with the $expr operator, $addFields, or $set are compatible with search indexes.{' '} {!hasNoSearchIndexes && 'Edit the view to rebuild search indexes.'}{' '} } > - Search indexes can only be created on views containing $addFields, - $set or $match stages with the $expr operator. + Search indexes can only be created on views containing $match stages + with the $expr operator, $addFields, or $set. } callToActionLink={