From 060978dd9647c0dc0486be2bea787a815a2d8ff1 Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Thu, 29 Apr 2021 16:59:26 +0200 Subject: [PATCH 1/5] feat(autocomplete): add show/use completions MONGOSH-563 Add support for autocompletion for shell commands, in particular, `show` and `use`. Autocompletion for `use` works similar to autocompletion for `db.`. The implementation details for use/show autocompletion here have been left to the shell-api package, so that changes to them do not require any autocompletion changes. --- packages/autocomplete/index.spec.ts | 64 +++++++++++++++++-- packages/autocomplete/index.ts | 23 ++++++- packages/cli-repl/src/cli-repl.spec.ts | 38 +++++++++-- packages/cli-repl/src/mongosh-repl.ts | 11 +++- .../shell-api/src/aggregation-cursor.spec.ts | 4 +- packages/shell-api/src/bulk.spec.ts | 8 ++- .../src/change-stream-cursor.spec.ts | 4 +- packages/shell-api/src/collection.spec.ts | 4 +- packages/shell-api/src/cursor.spec.ts | 4 +- packages/shell-api/src/database.spec.ts | 4 +- packages/shell-api/src/decorators.ts | 36 ++++++++++- .../shell-api/src/explainable-cursor.spec.ts | 4 +- packages/shell-api/src/explainable.spec.ts | 4 +- .../src/field-level-encryption.spec.ts | 8 ++- packages/shell-api/src/integration.spec.ts | 1 + packages/shell-api/src/mongo.spec.ts | 4 +- packages/shell-api/src/mongo.ts | 37 ++++++++--- packages/shell-api/src/plan-cache.spec.ts | 4 +- packages/shell-api/src/replica-set.spec.ts | 4 +- packages/shell-api/src/session.spec.ts | 4 +- packages/shell-api/src/shard.spec.ts | 4 +- packages/shell-api/src/shell-api.spec.ts | 58 ++++++++++++++--- packages/shell-api/src/shell-api.ts | 42 ++++++++++-- .../shell-api/src/shell-internal-state.ts | 13 ++++ 24 files changed, 330 insertions(+), 57 deletions(-) diff --git a/packages/autocomplete/index.spec.ts b/packages/autocomplete/index.spec.ts index 796780bfec..39bc968159 100644 --- a/packages/autocomplete/index.spec.ts +++ b/packages/autocomplete/index.spec.ts @@ -4,6 +4,7 @@ import { signatures as shellSignatures, Topologies } from '@mongosh/shell-api'; import { expect } from 'chai'; let collections: string[]; +let databases: string[]; const standalone440 = { topology: () => Topologies.Standalone, connectionInfo: () => ({ @@ -11,7 +12,8 @@ const standalone440 = { is_data_lake: false, server_version: '4.4.0' }), - getCollectionCompletionsForCurrentDb: () => collections + getCollectionCompletionsForCurrentDb: () => collections, + getDatabaseCompletions: () => databases }; const sharded440 = { topology: () => Topologies.Sharded, @@ -20,7 +22,8 @@ const sharded440 = { is_data_lake: false, server_version: '4.4.0' }), - getCollectionCompletionsForCurrentDb: () => collections + getCollectionCompletionsForCurrentDb: () => collections, + getDatabaseCompletions: () => databases }; const standalone300 = { @@ -30,7 +33,8 @@ const standalone300 = { is_data_lake: false, server_version: '3.0.0' }), - getCollectionCompletionsForCurrentDb: () => collections + getCollectionCompletionsForCurrentDb: () => collections, + getDatabaseCompletions: () => databases }; const datalake440 = { topology: () => Topologies.Sharded, @@ -39,13 +43,15 @@ const datalake440 = { is_data_lake: true, server_version: '4.4.0' }), - getCollectionCompletionsForCurrentDb: () => collections + getCollectionCompletionsForCurrentDb: () => collections, + getDatabaseCompletions: () => databases }; const noParams = { topology: () => Topologies.Standalone, connectionInfo: () => undefined, - getCollectionCompletionsForCurrentDb: () => collections + getCollectionCompletionsForCurrentDb: () => collections, + getDatabaseCompletions: () => databases }; describe('completer.completer', () => { @@ -66,7 +72,7 @@ describe('completer.completer', () => { it('is an exact match to one of shell completions', async() => { const i = 'use'; - expect(await completer(standalone440, i)).to.deep.equal([[i], i]); + expect(await completer(standalone440, i)).to.deep.equal([[], i, 'exclusive']); }); }); @@ -482,4 +488,50 @@ describe('completer.completer', () => { expect(await completer(standalone440, i)).to.deep.equal([[], i]); }); }); + + context('for shell commands', () => { + it('completes partial commands', async() => { + const i = 'sho'; + expect(await completer(noParams, i)) + .to.deep.equal([['show'], i]); + }); + + it('completes partial commands', async() => { + const i = 'show'; + const result = await completer(noParams, i); + expect(result[0]).to.contain('show databases'); + }); + + it('completes show databases', async() => { + const i = 'show d'; + expect(await completer(noParams, i)) + .to.deep.equal([['show databases'], i, 'exclusive']); + }); + + it('completes show profile', async() => { + const i = 'show pr'; + expect(await completer(noParams, i)) + .to.deep.equal([['show profile'], i, 'exclusive']); + }); + + it('completes use db', async() => { + databases = ['db1', 'db2']; + const i = 'use'; + expect(await completer(noParams, i)) + .to.deep.equal([['use db1', 'use db2'], i, 'exclusive']); + }); + + it('does not try to complete over-long commands', async() => { + databases = ['db1', 'db2']; + const i = 'use db1 d'; + expect(await completer(noParams, i)) + .to.deep.equal([[], i, 'exclusive']); + }); + + it('completes commands like exit', async() => { + const i = 'exi'; + expect(await completer(noParams, i)) + .to.deep.equal([['exit'], i, 'exclusive']); + }); + }); }); diff --git a/packages/autocomplete/index.ts b/packages/autocomplete/index.ts index a7960ca009..12956f0efd 100644 --- a/packages/autocomplete/index.ts +++ b/packages/autocomplete/index.ts @@ -24,6 +24,7 @@ export interface AutocompleteParameters { server_version: string; }, getCollectionCompletionsForCurrentDb: (collName: string) => string[] | Promise; + getDatabaseCompletions: (dbName: string) => string[] | Promise; } export const BASE_COMPLETIONS = EXPRESSION_OPERATORS.concat( @@ -50,7 +51,7 @@ const GROUP = '$group'; * * @returns {array} Matching Completions, Current User Input. */ -async function completer(params: AutocompleteParameters, line: string): Promise<[string[], string]> { +async function completer(params: AutocompleteParameters, line: string): Promise<[string[], string, 'exclusive'] | [string[], string]> { const SHELL_COMPLETIONS = shellSignatures.ShellApi.attributes as TypeSignatureAttributes; const COLL_COMPLETIONS = shellSignatures.Collection.attributes as TypeSignatureAttributes; const DB_COMPLETIONS = shellSignatures.Database.attributes as TypeSignatureAttributes; @@ -60,6 +61,26 @@ async function completer(params: AutocompleteParameters, line: string): Promise< const CONFIG_COMPLETIONS = shellSignatures.ShellConfig.attributes as TypeSignatureAttributes; const SHARD_COMPLETE = shellSignatures.Shard.attributes as TypeSignatureAttributes; + const splitLineWhitespace = line.split(' '); + const command = splitLineWhitespace[0]; + if (SHELL_COMPLETIONS[command]?.isDirectShellCommand) { + // If we encounter a direct shell commmand, we know that we want completions + // specific to that command, so we set the 'exclusive' flag on the result. + // If the shell API provides us with a completer, use it. + const completer = SHELL_COMPLETIONS[command].shellCommandCompleter; + if (completer) { + if (splitLineWhitespace.length === 1) { + splitLineWhitespace.push(''); // Treat e.g. 'show' like 'show '. + } + const hits = await completer(params, splitLineWhitespace) || []; + // Adjust to full input, because `completer` only completed the last item + // in the line, e.g. ['profile'] -> ['show profile'] + const fullLineHits = hits.map(hit => [...splitLineWhitespace.slice(0, -1), hit].join(' ')); + return [fullLineHits, line, 'exclusive']; + } + return [[line], line, 'exclusive']; + } + // keep initial line param intact to always return in return statement // check for contents of line with: const splitLine = line.split('.'); diff --git a/packages/cli-repl/src/cli-repl.spec.ts b/packages/cli-repl/src/cli-repl.spec.ts index d5b6d2ab75..8d5770f5a7 100644 --- a/packages/cli-repl/src/cli-repl.spec.ts +++ b/packages/cli-repl/src/cli-repl.spec.ts @@ -403,7 +403,8 @@ describe('CliRepl', () => { testServer: null, wantWatch: true, wantShardDistribution: true, - hasCollectionNames: false + hasCollectionNames: false, + hasDatabaseNames: false }); }); @@ -566,7 +567,8 @@ describe('CliRepl', () => { testServer: testServer, wantWatch: false, wantShardDistribution: false, - hasCollectionNames: true + hasCollectionNames: true, + hasDatabaseNames: true }); context('analytics integration', () => { @@ -765,7 +767,8 @@ describe('CliRepl', () => { testServer: startTestServer('not-shared', '--replicaset', '--nodes', '1'), wantWatch: true, wantShardDistribution: false, - hasCollectionNames: true + hasCollectionNames: true, + hasDatabaseNames: true }); }); @@ -774,7 +777,8 @@ describe('CliRepl', () => { testServer: startTestServer('not-shared', '--replicaset', '--sharded', '0'), wantWatch: true, wantShardDistribution: true, - hasCollectionNames: false // We're only spinning up a mongos here + hasCollectionNames: false, // We're only spinning up a mongos here + hasDatabaseNames: true }); }); @@ -783,15 +787,17 @@ describe('CliRepl', () => { testServer: startTestServer('not-shared', '--auth'), wantWatch: false, wantShardDistribution: false, - hasCollectionNames: false + hasCollectionNames: false, + hasDatabaseNames: false }); }); - function verifyAutocompletion({ testServer, wantWatch, wantShardDistribution, hasCollectionNames }: { + function verifyAutocompletion({ testServer, wantWatch, wantShardDistribution, hasCollectionNames, hasDatabaseNames }: { testServer: MongodSetup | null, wantWatch: boolean, wantShardDistribution: boolean, - hasCollectionNames: boolean + hasCollectionNames: boolean, + hasDatabaseNames: boolean }): void { describe('autocompletion', () => { let cliRepl: CliRepl; @@ -871,6 +877,24 @@ describe('CliRepl', () => { expect(output).not.to.include('JSON.stringify'); expect(output).not.to.include('rawValue'); }); + + it('completes shell commands', async() => { + input.write('const dSomeVariableStartingWithD = 10;\n'); + await waitEval(cliRepl.bus); + + output = ''; + input.write(`show d${tab}`); + await waitCompletion(cliRepl.bus); + expect(output).to.include('show databases'); + expect(output).not.to.include('dSomeVariableStartingWithD'); + }); + + it('completes use ', async() => { + if (!hasDatabaseNames) return; + input.write(`use adm${tab}`); + await waitCompletion(cliRepl.bus); + expect(output).to.include('use admin'); + }); }); } }); diff --git a/packages/cli-repl/src/mongosh-repl.ts b/packages/cli-repl/src/mongosh-repl.ts index 54c6e74cb7..44d70c71d9 100644 --- a/packages/cli-repl/src/mongosh-repl.ts +++ b/packages/cli-repl/src/mongosh-repl.ts @@ -137,11 +137,20 @@ class MongoshNodeRepl implements EvaluationListener { this.insideAutoComplete = true; try { // Merge the results from the repl completer and the mongosh completer. - const [ [replResults], [mongoshResults] ] = await Promise.all([ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [ [replResults], [mongoshResults, _, mongoshResultsExclusive] ] = await Promise.all([ (async() => await origReplCompleter(text) || [[]])(), (async() => await mongoshCompleter(text))() ]); this.bus.emit('mongosh:autocompletion-complete'); // For testing. + + // Sometimes the mongosh completion knows that what it is doing is right, + // and that autocompletion based on inspecting the actual objects that + // are being accessed will not be helpful, e.g. in `use a`, we know + // that we want *only* database names and not e.g. `assert`. + if (mongoshResultsExclusive) { + return [mongoshResults, text]; + } // Remove duplicates, because shell API methods might otherwise show // up in both completions. const deduped = [...new Set([...replResults, ...mongoshResults])]; diff --git a/packages/shell-api/src/aggregation-cursor.spec.ts b/packages/shell-api/src/aggregation-cursor.spec.ts index c5f048b653..4c007e7fad 100644 --- a/packages/shell-api/src/aggregation-cursor.spec.ts +++ b/packages/shell-api/src/aggregation-cursor.spec.ts @@ -28,7 +28,9 @@ describe('AggregationCursor', () => { returnType: 'AggregationCursor', platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: false, + shellCommandCompleter: undefined }); }); }); diff --git a/packages/shell-api/src/bulk.spec.ts b/packages/shell-api/src/bulk.spec.ts index 739e0bf6ce..1400a222fe 100644 --- a/packages/shell-api/src/bulk.spec.ts +++ b/packages/shell-api/src/bulk.spec.ts @@ -39,7 +39,9 @@ describe('Bulk API', () => { returnType: 'BulkFindOp', platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: false, + shellCommandCompleter: undefined }); }); it('hasAsyncChild', () => { @@ -241,7 +243,9 @@ describe('Bulk API', () => { returnType: 'BulkFindOp', platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: false, + shellCommandCompleter: undefined }); }); it('hasAsyncChild', () => { diff --git a/packages/shell-api/src/change-stream-cursor.spec.ts b/packages/shell-api/src/change-stream-cursor.spec.ts index 73eeda5bdc..095b97583e 100644 --- a/packages/shell-api/src/change-stream-cursor.spec.ts +++ b/packages/shell-api/src/change-stream-cursor.spec.ts @@ -34,7 +34,9 @@ describe('ChangeStreamCursor', () => { returnType: { type: 'unknown', attributes: {} }, platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: false, + shellCommandCompleter: undefined }); }); }); diff --git a/packages/shell-api/src/collection.spec.ts b/packages/shell-api/src/collection.spec.ts index 398503f1ca..28343bcece 100644 --- a/packages/shell-api/src/collection.spec.ts +++ b/packages/shell-api/src/collection.spec.ts @@ -44,7 +44,9 @@ describe('Collection', () => { returnType: 'AggregationCursor', platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: false, + shellCommandCompleter: undefined }); }); it('hasAsyncChild', () => { diff --git a/packages/shell-api/src/cursor.spec.ts b/packages/shell-api/src/cursor.spec.ts index 0558759242..ee95590230 100644 --- a/packages/shell-api/src/cursor.spec.ts +++ b/packages/shell-api/src/cursor.spec.ts @@ -32,7 +32,9 @@ describe('Cursor', () => { returnType: 'Cursor', platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: false, + shellCommandCompleter: undefined }); }); }); diff --git a/packages/shell-api/src/database.spec.ts b/packages/shell-api/src/database.spec.ts index b710bfaa02..dcb401dabb 100644 --- a/packages/shell-api/src/database.spec.ts +++ b/packages/shell-api/src/database.spec.ts @@ -84,7 +84,9 @@ describe('Database', () => { returnType: 'AggregationCursor', platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: false, + shellCommandCompleter: undefined }); }); it('hasAsyncChild', () => { diff --git a/packages/shell-api/src/decorators.ts b/packages/shell-api/src/decorators.ts index 04ffc92193..8689e36544 100644 --- a/packages/shell-api/src/decorators.ts +++ b/packages/shell-api/src/decorators.ts @@ -133,6 +133,19 @@ function wrapWithAddSourceToResult(fn: Function): Function { return wrapper; } +// This is a bit more restrictive than `AutocompleteParameters` used in the +// internal state code, so that it can also be accessed by testing code in the +// autocomplete package. You can expand this type to be closed to `AutocompleteParameters` +// as needed. +export interface ShellCommandAutocompleteParameters { + getCollectionCompletionsForCurrentDb: (collName: string) => string[] | Promise; + getDatabaseCompletions: (dbName: string) => string[] | Promise; +} +// Provide a suggested list of completions for the last item in a shell command, +// e.g. `show pro` to `show profile` by returning ['profile']. +export type ShellCommandCompleter = + (params: ShellCommandAutocompleteParameters, args: string[]) => Promise; + export interface TypeSignature { type: string; hasAsyncChild?: boolean; @@ -142,6 +155,8 @@ export interface TypeSignature { deprecated?: boolean; returnType?: string | TypeSignature; attributes?: { [key: string]: TypeSignature }; + isDirectShellCommand?: boolean; + shellCommandCompleter?: ShellCommandCompleter; } interface Signatures { @@ -169,6 +184,8 @@ type ClassSignature = { returnsPromise: boolean; deprecated: boolean; platforms: ReplPlatform[]; + isDirectShellCommand: boolean; + shellCommandCompleter?: ShellCommandCompleter; } }; }; @@ -217,6 +234,8 @@ export function shellApiClassGeneric(constructor: Function, hasHelp: boolean): v method.returnsPromise = method.returnsPromise || false; method.deprecated = method.deprecated || false; method.platforms = method.platforms || ALL_PLATFORMS; + method.isDirectShellCommand = method.isDirectShellCommand || false; + method.shellCommandCompleter = method.shellCommandCompleter || undefined; classSignature.attributes[propertyName] = { type: 'function', @@ -225,7 +244,9 @@ export function shellApiClassGeneric(constructor: Function, hasHelp: boolean): v returnType: method.returnType === 'this' ? className : method.returnType, returnsPromise: method.returnsPromise, deprecated: method.deprecated, - platforms: method.platforms + platforms: method.platforms, + isDirectShellCommand: method.isDirectShellCommand, + shellCommandCompleter: method.shellCommandCompleter }; const attributeHelpKeyPrefix = `${classHelpKeyPrefix}.attributes.${propertyName}`; @@ -274,7 +295,9 @@ export function shellApiClassGeneric(constructor: Function, hasHelp: boolean): v returnType: method.returnType === 'this' ? className : method.returnType, returnsPromise: method.returnsPromise, deprecated: method.deprecated, - platforms: method.platforms + platforms: method.platforms, + isDirectShellCommand: method.isDirectShellCommand, + shellCommandCompleter: method.shellCommandCompleter }; const attributeHelpKeyPrefix = `${superClassHelpKeyPrefix}.attributes.${propertyName}`; @@ -349,6 +372,15 @@ export function returnsPromise(_target: any, _propertyKey: string, descriptor: P export function directShellCommand(_target: any, _propertyKey: string, descriptor: PropertyDescriptor): void { descriptor.value.isDirectShellCommand = true; } +export function shellCommandCompleter(completer: ShellCommandCompleter): Function { + return function( + _target: any, + _propertyKey: string, + descriptor: PropertyDescriptor + ): void { + descriptor.value.shellCommandCompleter = completer; + }; +} export function returnType(type: string | TypeSignature): Function { return function( _target: any, diff --git a/packages/shell-api/src/explainable-cursor.spec.ts b/packages/shell-api/src/explainable-cursor.spec.ts index 6cf851c3a2..55e7dc89a2 100644 --- a/packages/shell-api/src/explainable-cursor.spec.ts +++ b/packages/shell-api/src/explainable-cursor.spec.ts @@ -25,7 +25,9 @@ describe('ExplainableCursor', () => { returnType: 'ExplainableCursor', platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: false, + shellCommandCompleter: undefined }); }); }); diff --git a/packages/shell-api/src/explainable.spec.ts b/packages/shell-api/src/explainable.spec.ts index 17a0bab499..66ae74ffdd 100644 --- a/packages/shell-api/src/explainable.spec.ts +++ b/packages/shell-api/src/explainable.spec.ts @@ -32,7 +32,9 @@ describe('Explainable', () => { returnType: 'ExplainableCursor', platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: false, + shellCommandCompleter: undefined }); }); it('hasAsyncChild', () => { diff --git a/packages/shell-api/src/field-level-encryption.spec.ts b/packages/shell-api/src/field-level-encryption.spec.ts index c975bab9f6..92482b26ed 100644 --- a/packages/shell-api/src/field-level-encryption.spec.ts +++ b/packages/shell-api/src/field-level-encryption.spec.ts @@ -98,7 +98,9 @@ describe('Field Level Encryption', () => { returnType: { attributes: {}, type: 'unknown' }, platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: false, + shellCommandCompleter: undefined }); expect(signatures.ClientEncryption.attributes.encrypt).to.deep.equal({ type: 'function', @@ -107,7 +109,9 @@ describe('Field Level Encryption', () => { returnType: { attributes: {}, type: 'unknown' }, platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: false, + shellCommandCompleter: undefined }); }); it('hasAsyncChild', () => { diff --git a/packages/shell-api/src/integration.spec.ts b/packages/shell-api/src/integration.spec.ts index 7d88136ebd..18cc62b8b8 100644 --- a/packages/shell-api/src/integration.spec.ts +++ b/packages/shell-api/src/integration.spec.ts @@ -1919,6 +1919,7 @@ describe('Shell API (integration)', function() { expect(await database._getCollectionNames()).to.deep.equal(['docs']); expect(await params.getCollectionCompletionsForCurrentDb('d')).to.deep.equal(['docs']); expect(await params.getCollectionCompletionsForCurrentDb('e')).to.deep.equal([]); + expect(await params.getDatabaseCompletions('test-')).to.deep.equal([database.getName()]); }); }); }); diff --git a/packages/shell-api/src/mongo.spec.ts b/packages/shell-api/src/mongo.spec.ts index 3667e17a3c..8082d69c56 100644 --- a/packages/shell-api/src/mongo.spec.ts +++ b/packages/shell-api/src/mongo.spec.ts @@ -48,7 +48,9 @@ describe('Mongo', () => { returnType: { attributes: {}, type: 'unknown' }, platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: false, + shellCommandCompleter: undefined }); }); it('hasAsyncChild', () => { diff --git a/packages/shell-api/src/mongo.ts b/packages/shell-api/src/mongo.ts index 023f6f809f..23da7a757a 100644 --- a/packages/shell-api/src/mongo.ts +++ b/packages/shell-api/src/mongo.ts @@ -23,6 +23,7 @@ import { ChangeStreamOptions, Document, generateUri, + ListDatabasesOptions, ReadConcernLevelId, ReadPreference, ReadPreferenceLike, @@ -66,6 +67,7 @@ export default class Mongo extends ShellApiClass { private _clientEncryption: ClientEncryption | undefined; private _readPreferenceWasExplicitlyRequested = false; private _explicitEncryptionOnly = false; + private _cachedDatabaseNames: string[] = []; constructor( internalState: ShellInternalState, @@ -216,6 +218,31 @@ export default class Mongo extends ShellApiClass { return `switched to db ${db}`; } + async _listDatabases(opts: ListDatabasesOptions = {}): Promise<{name: string, sizeOnDisk: number, empty: boolean}[]> { + const result = await this._serviceProvider.listDatabases('admin', { ...opts }); + if (!('databases' in result)) { + const err = new MongoshRuntimeError('Got invalid result from "listDatabases"', CommonErrors.CommandFailed); + this._internalState.messageBus.emit('mongosh:error', err); + throw err; + } + this._cachedDatabaseNames = result.databases.map((db: any) => db.name); + return result.databases; + } + + async _getDatabaseNamesForCompletion(): Promise { + return await Promise.race([ + (async() => { + return (await this._listDatabases({ readPreference: 'primaryPreferred' })).map(db => db.name); + })(), + (async() => { + // See the comment in _getCollectionNamesForCompletion/database.ts + // for the choice of 200 ms. + await new Promise(resolve => setTimeout(resolve, 200).unref()); + return this._cachedDatabaseNames; + })() + ]); + } + @returnsPromise async show(cmd: string, arg?: string): Promise { this._internalState.messageBus.emit('mongosh:show', { method: `show ${cmd}` }); @@ -223,14 +250,8 @@ export default class Mongo extends ShellApiClass { switch (cmd) { case 'databases': case 'dbs': - const result = await this._serviceProvider.listDatabases('admin', { readPreference: 'primaryPreferred' }); - if (!('databases' in result)) { - const err = new MongoshRuntimeError('Got invalid result from "listDatabases"', CommonErrors.CommandFailed); - this._internalState.messageBus.emit('mongosh:error', err); - throw err; - } - - return new CommandResult('ShowDatabasesResult', result.databases); + const result = await this._listDatabases({ readPreference: 'primaryPreferred' }); + return new CommandResult('ShowDatabasesResult', result); case 'collections': case 'tables': const collectionNames = await this._internalState.currentDb._getCollectionNames({ readPreference: 'primaryPreferred' }); diff --git a/packages/shell-api/src/plan-cache.spec.ts b/packages/shell-api/src/plan-cache.spec.ts index 32a9f3be9c..12a5d3c145 100644 --- a/packages/shell-api/src/plan-cache.spec.ts +++ b/packages/shell-api/src/plan-cache.spec.ts @@ -26,7 +26,9 @@ describe('PlanCache', () => { returnType: { attributes: {}, type: 'unknown' }, platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ['4.4.0', ServerVersions.latest] + serverVersions: ['4.4.0', ServerVersions.latest], + isDirectShellCommand: false, + shellCommandCompleter: undefined }); }); it('hasAsyncChild', () => { diff --git a/packages/shell-api/src/replica-set.spec.ts b/packages/shell-api/src/replica-set.spec.ts index ae9d62eb7b..220f3d5627 100644 --- a/packages/shell-api/src/replica-set.spec.ts +++ b/packages/shell-api/src/replica-set.spec.ts @@ -48,7 +48,9 @@ describe('ReplicaSet', () => { returnType: { type: 'unknown', attributes: {} }, platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: false, + shellCommandCompleter: undefined }); }); diff --git a/packages/shell-api/src/session.spec.ts b/packages/shell-api/src/session.spec.ts index 4362b5f414..687aa715fe 100644 --- a/packages/shell-api/src/session.spec.ts +++ b/packages/shell-api/src/session.spec.ts @@ -39,7 +39,9 @@ describe('Session', () => { returnType: { type: 'unknown', attributes: {} }, platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: false, + shellCommandCompleter: undefined }); }); }); diff --git a/packages/shell-api/src/shard.spec.ts b/packages/shell-api/src/shard.spec.ts index 00ba78c993..b170df525b 100644 --- a/packages/shell-api/src/shard.spec.ts +++ b/packages/shell-api/src/shard.spec.ts @@ -37,7 +37,9 @@ describe('Shard', () => { returnType: { type: 'unknown', attributes: {} }, platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: false, + shellCommandCompleter: undefined }); }); it('hasAsyncChild', () => { diff --git a/packages/shell-api/src/shell-api.spec.ts b/packages/shell-api/src/shell-api.spec.ts index d0c87e542f..68b82cdedf 100644 --- a/packages/shell-api/src/shell-api.spec.ts +++ b/packages/shell-api/src/shell-api.spec.ts @@ -40,7 +40,9 @@ describe('ShellApi', () => { returnType: { type: 'unknown', attributes: {} }, platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: true, + shellCommandCompleter: signatures.ShellApi.attributes.use.shellCommandCompleter }); expect(signatures.ShellApi.attributes.show).to.deep.equal({ type: 'function', @@ -49,7 +51,9 @@ describe('ShellApi', () => { returnType: { type: 'unknown', attributes: {} }, platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: true, + shellCommandCompleter: signatures.ShellApi.attributes.show.shellCommandCompleter }); expect(signatures.ShellApi.attributes.exit).to.deep.equal({ type: 'function', @@ -58,7 +62,9 @@ describe('ShellApi', () => { returnType: { type: 'unknown', attributes: {} }, platforms: [ ReplPlatform.CLI ], topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: true, + shellCommandCompleter: undefined }); expect(signatures.ShellApi.attributes.it).to.deep.equal({ type: 'function', @@ -67,7 +73,9 @@ describe('ShellApi', () => { returnType: { type: 'unknown', attributes: {} }, platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: true, + shellCommandCompleter: undefined }); expect(signatures.ShellApi.attributes.print).to.deep.equal({ type: 'function', @@ -76,7 +84,9 @@ describe('ShellApi', () => { returnType: { type: 'unknown', attributes: {} }, platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: false, + shellCommandCompleter: undefined }); expect(signatures.ShellApi.attributes.printjson).to.deep.equal({ type: 'function', @@ -85,7 +95,9 @@ describe('ShellApi', () => { returnType: { type: 'unknown', attributes: {} }, platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: false, + shellCommandCompleter: undefined }); expect(signatures.ShellApi.attributes.sleep).to.deep.equal({ type: 'function', @@ -94,7 +106,9 @@ describe('ShellApi', () => { returnType: { type: 'unknown', attributes: {} }, platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: false, + shellCommandCompleter: undefined }); expect(signatures.ShellApi.attributes.cls).to.deep.equal({ type: 'function', @@ -103,7 +117,9 @@ describe('ShellApi', () => { returnType: { type: 'unknown', attributes: {} }, platforms: ALL_PLATFORMS, topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: true, + shellCommandCompleter: undefined }); expect(signatures.ShellApi.attributes.Mongo).to.deep.equal({ type: 'function', @@ -112,7 +128,9 @@ describe('ShellApi', () => { returnType: 'Mongo', platforms: [ ReplPlatform.CLI ], topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: false, + shellCommandCompleter: undefined }); expect(signatures.ShellApi.attributes.connect).to.deep.equal({ type: 'function', @@ -121,7 +139,9 @@ describe('ShellApi', () => { returnType: 'Database', platforms: [ ReplPlatform.CLI ], topologies: ALL_TOPOLOGIES, - serverVersions: ALL_SERVER_VERSIONS + serverVersions: ALL_SERVER_VERSIONS, + isDirectShellCommand: false, + shellCommandCompleter: undefined }); }); }); @@ -672,6 +692,24 @@ describe('ShellApi', () => { }); }); }); + describe('command completers', () => { + const params = { + getCollectionCompletionsForCurrentDb: () => [''], + getDatabaseCompletions: (dbName) => ['dbOne', 'dbTwo'].filter(s => s.startsWith(dbName)) + }; + + it('provides completions for show', async() => { + const completer = signatures.ShellApi.attributes.show.shellCommandCompleter; + expect(await completer(params, ['show', ''])).to.contain('databases'); + expect(await completer(params, ['show', 'pro'])).to.deep.equal(['profile']); + }); + + it('provides completions for use', async() => { + const completer = signatures.ShellApi.attributes.use.shellCommandCompleter; + expect(await completer(params, ['use', ''])).to.deep.equal(['dbOne', 'dbTwo']); + expect(await completer(params, ['use', 'dbO'])).to.deep.equal(['dbOne']); + }); + }); }); describe('returnsPromise marks async functions', () => { diff --git a/packages/shell-api/src/shell-api.ts b/packages/shell-api/src/shell-api.ts index e251074fec..e2dd046667 100644 --- a/packages/shell-api/src/shell-api.ts +++ b/packages/shell-api/src/shell-api.ts @@ -7,13 +7,15 @@ import { platforms, toShellResult, ShellResult, - directShellCommand + directShellCommand, + shellCommandCompleter, + ShellCommandAutocompleteParameters } from './decorators'; import { asPrintable } from './enums'; import Mongo from './mongo'; import Database from './database'; import { CommandResult, CursorIterationResult } from './result'; -import ShellInternalState from './shell-internal-state'; +import type ShellInternalState from './shell-internal-state'; import { assertArgsDefinedType, assertCLI } from './helpers'; import { DEFAULT_DB, ReplPlatform, ServerApi, ServerApiVersionId } from '@mongosh/service-provider-core'; import { CommonErrors, MongoshUnimplementedError, MongoshInternalError } from '@mongosh/errors'; @@ -25,6 +27,7 @@ import { ShellUserConfig } from '@mongosh/types'; import i18n from '@mongosh/i18n'; const internalStateSymbol = Symbol.for('@@mongosh.internalState'); +const loadCallNestingLevelSymbol = Symbol.for('@@mongosh.loadCallNestingLevel'); @shellApiClassDefault @hasAsyncChild @@ -71,21 +74,38 @@ class ShellConfig extends ShellApiClass { } } +// Complete e.g. `use adm` by returning `['admin']`. +async function useCompleter(params: ShellCommandAutocompleteParameters, args: string[]): Promise { + if (args.length > 2) return undefined; + return await params.getDatabaseCompletions(args[1] ?? ''); +} + +// Complete a `show` subcommand. +async function showCompleter(params: ShellCommandAutocompleteParameters, args: string[]): Promise { + if (args.length > 2) return undefined; + if (args[1] === 'd') { + // Special-case: The user might want `show dbs` or `show databases`, but they won't care about which they get. + return ['databases']; + } + const candidates = ['databases', 'dbs', 'collections', 'tables', 'profile', 'users', 'roles', 'log', 'logs']; + return candidates.filter(str => str.startsWith(args[1] ?? '')); +} + @shellApiClassDefault @hasAsyncChild export default class ShellApi extends ShellApiClass { - // Use a symbol to make sure this is *not* one of the things copied over into + // Use symbols to make sure these are *not* among the things copied over into // the global scope. [internalStateSymbol]: ShellInternalState; - public DBQuery: DBQuery; - loadCallNestingLevel: number; + [loadCallNestingLevelSymbol]: number; + DBQuery: DBQuery; config: ShellConfig; constructor(internalState: ShellInternalState) { super(); this[internalStateSymbol] = internalState; + this[loadCallNestingLevelSymbol] = 0; this.DBQuery = new DBQuery(internalState); - this.loadCallNestingLevel = 0; this.config = new ShellConfig(internalState); } @@ -93,13 +113,23 @@ export default class ShellApi extends ShellApiClass { return this[internalStateSymbol]; } + get loadCallNestingLevel(): number { + return this[loadCallNestingLevelSymbol]; + } + + set loadCallNestingLevel(value: number) { + this[loadCallNestingLevelSymbol] = value; + } + @directShellCommand + @shellCommandCompleter(useCompleter) use(db: string): any { return this.internalState.currentDb._mongo.use(db); } @directShellCommand @returnsPromise + @shellCommandCompleter(showCompleter) async show(cmd: string, arg?: string): Promise { return await this.internalState.currentDb._mongo.show(cmd, arg); } diff --git a/packages/shell-api/src/shell-internal-state.ts b/packages/shell-api/src/shell-internal-state.ts index 0f12e094e7..ba44acd0a5 100644 --- a/packages/shell-api/src/shell-internal-state.ts +++ b/packages/shell-api/src/shell-internal-state.ts @@ -41,6 +41,7 @@ export interface AutocompleteParameters { topology: () => Topologies; connectionInfo: () => ConnectInfo | undefined; getCollectionCompletionsForCurrentDb: (collName: string) => Promise; + getDatabaseCompletions: (dbName: string) => Promise; } export interface OnLoadResult { @@ -158,6 +159,7 @@ export default class ShellInternalState { this.fetchConnectionInfo().catch(err => this.messageBus.emit('mongosh:error', err)); // Pre-fetch for autocompletion. this.currentDb._getCollectionNamesForCompletion().catch(err => this.messageBus.emit('mongosh:error', err)); + this.currentDb._mongo._getDatabaseNamesForCompletion().catch(err => this.messageBus.emit('mongosh:error', err)); return newDb; } @@ -313,6 +315,17 @@ export default class ShellInternalState { } throw err; } + }, + getDatabaseCompletions: async(dbName: string): Promise => { + try { + const dbNames = await this.currentDb._mongo._getDatabaseNamesForCompletion(); + return dbNames.filter((name) => name.startsWith(dbName)); + } catch (err) { + if (err.code === ShellApiErrors.NotConnected || err.codeName === 'Unauthorized') { + return []; + } + throw err; + } } }; } From 5069b54723fe52974cccee150d65013bc043468a Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Thu, 29 Apr 2021 18:02:03 +0200 Subject: [PATCH 2/5] fixup --- packages/autocomplete/index.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/autocomplete/index.spec.ts b/packages/autocomplete/index.spec.ts index 39bc968159..d4576d9ee2 100644 --- a/packages/autocomplete/index.spec.ts +++ b/packages/autocomplete/index.spec.ts @@ -531,7 +531,7 @@ describe('completer.completer', () => { it('completes commands like exit', async() => { const i = 'exi'; expect(await completer(noParams, i)) - .to.deep.equal([['exit'], i, 'exclusive']); + .to.deep.equal([['exit'], i]); }); }); }); From 6e1b4a3e34157da6229b2db6f59dc9eeddb115dc Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Fri, 30 Apr 2021 10:41:35 +0200 Subject: [PATCH 3/5] fixup --- .../src/autocompleter/shell-api-autocompleter.spec.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/browser-runtime-core/src/autocompleter/shell-api-autocompleter.spec.ts b/packages/browser-runtime-core/src/autocompleter/shell-api-autocompleter.spec.ts index 8c2c6dddb4..178eac5f09 100644 --- a/packages/browser-runtime-core/src/autocompleter/shell-api-autocompleter.spec.ts +++ b/packages/browser-runtime-core/src/autocompleter/shell-api-autocompleter.spec.ts @@ -9,7 +9,8 @@ const standalone440 = { is_data_lake: false, server_version: '4.4.0' }), - getCollectionCompletionsForCurrentDb: () => ['bananas'] + getCollectionCompletionsForCurrentDb: () => ['bananas'], + getDatabaseCompletions: () => ['databaseOne'] }; describe('Autocompleter', () => { @@ -43,6 +44,14 @@ describe('Autocompleter', () => { completion: 'db.bananas' }); }); + + it('returns database names after use', async() => { + const completions = await autocompleter.getCompletions('use da'); + + expect(completions).to.deep.contain({ + completion: 'use databaseOne' + }); + }); }); }); From bcd022a79f473b4413d395b417b7025f8541898e Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Fri, 30 Apr 2021 12:33:22 +0200 Subject: [PATCH 4/5] fixup --- packages/cli-repl/src/mongosh-repl.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli-repl/src/mongosh-repl.ts b/packages/cli-repl/src/mongosh-repl.ts index 44d70c71d9..218bcd5e59 100644 --- a/packages/cli-repl/src/mongosh-repl.ts +++ b/packages/cli-repl/src/mongosh-repl.ts @@ -137,8 +137,7 @@ class MongoshNodeRepl implements EvaluationListener { this.insideAutoComplete = true; try { // Merge the results from the repl completer and the mongosh completer. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [ [replResults], [mongoshResults, _, mongoshResultsExclusive] ] = await Promise.all([ + const [ [replResults], [mongoshResults,, mongoshResultsExclusive] ] = await Promise.all([ (async() => await origReplCompleter(text) || [[]])(), (async() => await mongoshCompleter(text))() ]); From 98a60297fea4f6a5268a68c531903de8843ed733 Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Fri, 30 Apr 2021 15:07:33 +0200 Subject: [PATCH 5/5] add comment --- packages/shell-api/src/decorators.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/shell-api/src/decorators.ts b/packages/shell-api/src/decorators.ts index 8689e36544..a418fadfa1 100644 --- a/packages/shell-api/src/decorators.ts +++ b/packages/shell-api/src/decorators.ts @@ -369,6 +369,8 @@ export function returnsPromise(_target: any, _propertyKey: string, descriptor: P nonAsyncFunctionsReturningPromises.push(orig.name); } } +// This is use to mark functions that are executable in the shell in a POSIX-shell-like +// fashion, e.g. `show foo` which is translated into a call to `show('foo')`. export function directShellCommand(_target: any, _propertyKey: string, descriptor: PropertyDescriptor): void { descriptor.value.isDirectShellCommand = true; }