diff --git a/package-lock.json b/package-lock.json index 183d374b..ad1f557d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30599,6 +30599,7 @@ "@types/semver": "^7.7.0", "@types/sinon-chai": "^3.2.5", "acorn": "^8.14.1", + "bson": "^6.10.3", "chai": "^4.5.0", "depcheck": "^1.4.7", "eslint": "^7.25.0", @@ -30608,6 +30609,9 @@ "prettier": "^3.5.3", "sinon": "^9.2.3", "typescript": "^5.0.4" + }, + "peerDependencies": { + "bson": "^6.10.3" } }, "packages/mongodb-constants/node_modules/acorn": { @@ -38764,6 +38768,7 @@ "@types/semver": "^7.7.0", "@types/sinon-chai": "^3.2.5", "acorn": "^8.14.1", + "bson": "^6.10.3", "chai": "^4.5.0", "depcheck": "^1.4.7", "eslint": "^7.25.0", diff --git a/packages/mongodb-constants/package.json b/packages/mongodb-constants/package.json index e7fa65e4..056e8478 100644 --- a/packages/mongodb-constants/package.json +++ b/packages/mongodb-constants/package.json @@ -51,6 +51,9 @@ "test-ci": "npm run test-cov", "reformat": "npm run prettier -- --write ." }, + "peerDependencies": { + "bson": "^6.10.3" + }, "devDependencies": { "@mongodb-js/eslint-config-devtools": "0.9.11", "@mongodb-js/mocha-config-devtools": "^1.0.5", @@ -60,6 +63,7 @@ "@types/mocha": "^9.1.1", "@types/semver": "^7.7.0", "@types/sinon-chai": "^3.2.5", + "bson": "^6.10.3", "acorn": "^8.14.1", "chai": "^4.5.0", "depcheck": "^1.4.7", diff --git a/packages/mongodb-constants/src/index.spec.ts b/packages/mongodb-constants/src/index.spec.ts index 62e7a779..987ba3ff 100644 --- a/packages/mongodb-constants/src/index.spec.ts +++ b/packages/mongodb-constants/src/index.spec.ts @@ -32,6 +32,7 @@ describe('constants', function () { 'VALIDATION_TEMPLATE', 'ATLAS_SEARCH_TEMPLATES', 'ATLAS_VECTOR_SEARCH_TEMPLATE', + 'VIEW_PIPELINE_UTILS', ]); }); }); diff --git a/packages/mongodb-constants/src/index.ts b/packages/mongodb-constants/src/index.ts index d58137de..af174c2c 100644 --- a/packages/mongodb-constants/src/index.ts +++ b/packages/mongodb-constants/src/index.ts @@ -16,3 +16,4 @@ export type { FilterOptions as CompletionFilterOptions, } from './filter'; export * from './atlas-search-templates'; +export * from './views'; diff --git a/packages/mongodb-constants/src/views.spec.ts b/packages/mongodb-constants/src/views.spec.ts new file mode 100644 index 00000000..b56770e5 --- /dev/null +++ b/packages/mongodb-constants/src/views.spec.ts @@ -0,0 +1,175 @@ +import { expect } from 'chai'; +import { VIEW_PIPELINE_UTILS } from './views'; +import type { Document } from 'bson'; + +describe('views', function () { + describe('isPipelineSearchQueryable', function () { + it('should return true for a valid pipeline with $addFields stage', function () { + const pipeline: Document[] = [{ $addFields: { testField: 'testValue' } }]; + expect(VIEW_PIPELINE_UTILS.isPipelineSearchQueryable(pipeline)).to.equal( + true, + ); + }); + + it('should return true for a valid pipeline with $set stage', function () { + const pipeline: Document[] = [{ $set: { testField: 'testValue' } }]; + expect(VIEW_PIPELINE_UTILS.isPipelineSearchQueryable(pipeline)).to.equal( + true, + ); + }); + + it('should return true for a valid pipeline with $match stage using $expr', function () { + const pipeline: Document[] = [ + { $match: { $expr: { $eq: ['$field', 'value'] } } }, + ]; + expect(VIEW_PIPELINE_UTILS.isPipelineSearchQueryable(pipeline)).to.equal( + true, + ); + }); + + it('should return false for a pipeline with an unsupported stage', function () { + const pipeline: Document[] = [{ $group: { _id: '$field' } }]; + expect(VIEW_PIPELINE_UTILS.isPipelineSearchQueryable(pipeline)).to.equal( + false, + ); + }); + + it('should return false for a $match stage without $expr', function () { + const pipeline: Document[] = [{ $match: { nonExprKey: 'someValue' } }]; + expect(VIEW_PIPELINE_UTILS.isPipelineSearchQueryable(pipeline)).to.equal( + false, + ); + }); + + it('should return false for a $match stage with $expr and additional fields', function () { + const pipeline: Document[] = [ + { + $match: { + $expr: { $eq: ['$field', 'value'] }, + anotherField: 'value', + }, + }, + ]; + expect(VIEW_PIPELINE_UTILS.isPipelineSearchQueryable(pipeline)).to.equal( + false, + ); + }); + + it('should return true for an empty pipeline', function () { + const pipeline: Document[] = []; + expect(VIEW_PIPELINE_UTILS.isPipelineSearchQueryable(pipeline)).to.equal( + true, + ); + }); + + it('should return false if any stage in the pipeline is invalid', function () { + const pipeline: Document[] = [ + { $addFields: { testField: 'testValue' } }, + { $match: { $expr: { $eq: ['$field', 'value'] } } }, + { $group: { _id: '$field' } }, + ]; + expect(VIEW_PIPELINE_UTILS.isPipelineSearchQueryable(pipeline)).to.equal( + false, + ); + }); + + it('should handle a pipeline with multiple valid stages', function () { + const pipeline: Document[] = [ + { $addFields: { field1: 'value1' } }, + { $match: { $expr: { $eq: ['$field', 'value'] } } }, + { $set: { field2: 'value2' } }, + ]; + expect(VIEW_PIPELINE_UTILS.isPipelineSearchQueryable(pipeline)).to.equal( + true, + ); + }); + + it('should return false for a $match stage with no conditions', function () { + const pipeline: Document[] = [{ $match: {} }]; + expect(VIEW_PIPELINE_UTILS.isPipelineSearchQueryable(pipeline)).to.equal( + false, + ); + }); + }); + + describe('isVersionSearchCompatibleForViewsDataExplorer', function () { + it('should return true for a version greater than or equal to 8.0.0', function () { + expect( + VIEW_PIPELINE_UTILS.isVersionSearchCompatibleForViewsDataExplorer( + '8.0.0', + ), + ).to.equal(true); + expect( + VIEW_PIPELINE_UTILS.isVersionSearchCompatibleForViewsDataExplorer( + '8.0.1', + ), + ).to.equal(true); + expect( + VIEW_PIPELINE_UTILS.isVersionSearchCompatibleForViewsDataExplorer( + '8.1.0', + ), + ).to.equal(true); + }); + + it('should return false for a version less than 8.0.0', function () { + expect( + VIEW_PIPELINE_UTILS.isVersionSearchCompatibleForViewsDataExplorer( + '7.9.9', + ), + ).to.equal(false); + expect( + VIEW_PIPELINE_UTILS.isVersionSearchCompatibleForViewsDataExplorer( + '7.0.0', + ), + ).to.equal(false); + }); + + it('should handle invalid version format by returning false', function () { + expect( + VIEW_PIPELINE_UTILS.isVersionSearchCompatibleForViewsDataExplorer( + 'invalid-version', + ), + ).to.equal(false); + expect( + VIEW_PIPELINE_UTILS.isVersionSearchCompatibleForViewsDataExplorer(''), + ).to.equal(false); + }); + }); + + describe('isVersionSearchCompatibleForViewsCompass', function () { + it('should return true for a version greater than or equal to 8.1.0', function () { + expect( + VIEW_PIPELINE_UTILS.isVersionSearchCompatibleForViewsCompass('8.1.0'), + ).to.equal(true); + expect( + VIEW_PIPELINE_UTILS.isVersionSearchCompatibleForViewsCompass('8.1.1'), + ).to.equal(true); + expect( + VIEW_PIPELINE_UTILS.isVersionSearchCompatibleForViewsCompass('8.2.0'), + ).to.equal(true); + }); + + it('should return false for a version less than 8.1.0', function () { + expect( + VIEW_PIPELINE_UTILS.isVersionSearchCompatibleForViewsCompass('8.0.9'), + ).to.equal(false); + expect( + VIEW_PIPELINE_UTILS.isVersionSearchCompatibleForViewsCompass('8.0.0'), + ).to.equal(false); + expect( + VIEW_PIPELINE_UTILS.isVersionSearchCompatibleForViewsCompass('7.9.9'), + ).to.equal(false); + }); + + it('should handle invalid version format by returning false', function () { + expect( + VIEW_PIPELINE_UTILS.isVersionSearchCompatibleForViewsCompass( + 'invalid-version', + ), + ).to.equal(false); + expect( + VIEW_PIPELINE_UTILS.isVersionSearchCompatibleForViewsCompass(''), + ).to.equal(false); + }); + }); +}); diff --git a/packages/mongodb-constants/src/views.ts b/packages/mongodb-constants/src/views.ts new file mode 100644 index 00000000..8f49707e --- /dev/null +++ b/packages/mongodb-constants/src/views.ts @@ -0,0 +1,89 @@ +/** utils related to view pipeline **/ + +import type { Document } from 'bson'; +import semver from 'semver'; + +const MIN_VERSION_FOR_VIEW_SEARCH_COMPATIBILITY_DE = '8.0.0'; +const MIN_VERSION_FOR_VIEW_SEARCH_COMPATIBILITY_COMPASS = '8.1.0'; + +/** + * A view pipeline is searchQueryable (ie: a search index can be created on view) if + * a pipeline consists of only addFields, set and match with expr stages + * + * @param pipeline the view pipeline + * @returns whether pipeline is search queryable + */ +const isPipelineSearchQueryable = (pipeline: Document[]): boolean => { + for (const stage of pipeline) { + const stageKey = Object.keys(stage)[0]; + + // Check if the stage is $addFields, $set, or $match + if ( + !( + stageKey === '$addFields' || + stageKey === '$set' || + stageKey === '$match' + ) + ) { + return false; + } + + // If the stage is $match, check if it uses $expr + if (stageKey === '$match') { + const matchStage = stage['$match'] as Document; + const matchKeys = Object.keys(matchStage || {}); + + if (matchKeys.length !== 1 || !matchKeys.includes('$expr')) { + return false; + } + } + } + + return true; +}; + +/** + * Views allow search indexes to be made on them in DE for server version 8.1+ + * + * @param serverVersion the server version + * @returns whether serverVersion is search compatible for views in DE + */ +const isVersionSearchCompatibleForViewsDataExplorer = ( + serverVersion: string, +): boolean => { + try { + return semver.gte( + serverVersion, + MIN_VERSION_FOR_VIEW_SEARCH_COMPATIBILITY_DE, + ); + } catch { + return false; + } +}; + +/** + * Views allow search indexes to be made on them in compass for mongodb version 8.0+ + * + * @param serverVersion the server version + * @returns whether serverVersion is search compatible for views in Compass + */ +const isVersionSearchCompatibleForViewsCompass = ( + serverVersion: string, +): boolean => { + try { + return semver.gte( + serverVersion, + MIN_VERSION_FOR_VIEW_SEARCH_COMPATIBILITY_COMPASS, + ); + } catch { + return false; + } +}; + +export const VIEW_PIPELINE_UTILS = { + MIN_VERSION_FOR_VIEW_SEARCH_COMPATIBILITY_DE, + MIN_VERSION_FOR_VIEW_SEARCH_COMPATIBILITY_COMPASS, + isPipelineSearchQueryable, + isVersionSearchCompatibleForViewsDataExplorer, + isVersionSearchCompatibleForViewsCompass, +};