diff --git a/packages/autocomplete/index.ts b/packages/autocomplete/index.ts index 1e9edfaaf5..a7960ca009 100644 --- a/packages/autocomplete/index.ts +++ b/packages/autocomplete/index.ts @@ -57,6 +57,7 @@ async function completer(params: AutocompleteParameters, line: string): Promise< const AGG_CURSOR_COMPLETIONS = shellSignatures.AggregationCursor.attributes as TypeSignatureAttributes; const COLL_CURSOR_COMPLETIONS = shellSignatures.Cursor.attributes as TypeSignatureAttributes; const RS_COMPLETIONS = shellSignatures.ReplicaSet.attributes as TypeSignatureAttributes; + const CONFIG_COMPLETIONS = shellSignatures.ShellConfig.attributes as TypeSignatureAttributes; const SHARD_COMPLETE = shellSignatures.Shard.attributes as TypeSignatureAttributes; // keep initial line param intact to always return in return statement @@ -132,6 +133,10 @@ async function completer(params: AutocompleteParameters, line: string): Promise< const hits = filterShellAPI( params, RS_COMPLETIONS, elToComplete, splitLine); return [hits.length ? hits : [], line]; + } else if (firstLineEl.match(/\bconfig\b/) && splitLine.length === 2) { + const hits = filterShellAPI( + params, CONFIG_COMPLETIONS, elToComplete, splitLine); + return [hits.length ? hits : [], line]; } return [[], line]; diff --git a/packages/browser-runtime-electron/src/electron-runtime.spec.ts b/packages/browser-runtime-electron/src/electron-runtime.spec.ts index 0c765ae910..01edde5e99 100644 --- a/packages/browser-runtime-electron/src/electron-runtime.spec.ts +++ b/packages/browser-runtime-electron/src/electron-runtime.spec.ts @@ -24,7 +24,7 @@ describe('Electron runtime', function() { evaluationListener = sinon.createStubInstance(class FakeListener {}); evaluationListener.onPrint = sinon.stub(); electronRuntime = new ElectronRuntime(serviceProvider, messageBus); - electronRuntime.setEvaluationListener(evaluationListener); + electronRuntime.setEvaluationListener(evaluationListener as any); }); it('can evaluate simple js', async() => { diff --git a/packages/cli-repl/src/cli-repl.spec.ts b/packages/cli-repl/src/cli-repl.spec.ts index 0fff6f7a94..93ddb715c9 100644 --- a/packages/cli-repl/src/cli-repl.spec.ts +++ b/packages/cli-repl/src/cli-repl.spec.ts @@ -155,6 +155,12 @@ describe('CliRepl', () => { expect.fail('expected error'); }); + it('returns the list of available config options when asked to', () => { + expect(cliRepl.listConfigOptions()).to.deep.equal([ + 'batchSize', 'enableTelemetry', 'inspectDepth', 'historyLength' + ]); + }); + context('loading JS files from disk', () => { it('allows loading a file from the disk', async() => { const filenameA = path.resolve(__dirname, '..', 'test', 'fixtures', 'load', 'a.js'); diff --git a/packages/cli-repl/src/cli-repl.ts b/packages/cli-repl/src/cli-repl.ts index bcb991bea1..7ea1d1126f 100644 --- a/packages/cli-repl/src/cli-repl.ts +++ b/packages/cli-repl/src/cli-repl.ts @@ -15,7 +15,7 @@ import { CliReplErrors } from './error-codes'; import { MongocryptdManager } from './mongocryptd-manager'; import MongoshNodeRepl, { MongoshNodeReplOptions } from './mongosh-repl'; import setupLoggerAndTelemetry from './setup-logger-and-telemetry'; -import { MongoshBus, UserConfig } from '@mongosh/types'; +import { MongoshBus, CliUserConfig } from '@mongosh/types'; import { once } from 'events'; import { createWriteStream, promises as fs } from 'fs'; import path from 'path'; @@ -51,8 +51,8 @@ class CliRepl { cliOptions: CliOptions; mongocryptdManager: MongocryptdManager; shellHomeDirectory: ShellHomeDirectory; - configDirectory: ConfigManager; - config: UserConfig = new UserConfig(); + configDirectory: ConfigManager; + config: CliUserConfig = new CliUserConfig(); input: Readable; output: Writable; logId: string; @@ -75,13 +75,13 @@ class CliRepl { this.onExit = options.onExit; this.shellHomeDirectory = new ShellHomeDirectory(options.shellHomePaths); - this.configDirectory = new ConfigManager( + this.configDirectory = new ConfigManager( this.shellHomeDirectory) .on('error', (err: Error) => this.bus.emit('mongosh:error', err)) - .on('new-config', (config: UserConfig) => + .on('new-config', (config: CliUserConfig) => this.bus.emit('mongosh:new-user', config.userId, config.enableTelemetry)) - .on('update-config', (config: UserConfig) => + .on('update-config', (config: CliUserConfig) => this.bus.emit('mongosh:update-user', config.userId, config.enableTelemetry)); this.mongocryptdManager = new MongocryptdManager( @@ -146,11 +146,8 @@ class CliRepl { return this.analytics; }); - this.config = { - userId: new bson.ObjectId().toString(), - enableTelemetry: true, - disableGreetingMessage: false - }; + this.config.userId = new bson.ObjectId().toString(); + this.config.enableTelemetry = true; try { this.config = await this.configDirectory.generateOrReadConfig(this.config); } catch (err) { @@ -312,11 +309,11 @@ class CliRepl { return this.shellHomeDirectory.roamingPath('mongosh_repl_history'); } - async getConfig(key: K): Promise { + async getConfig(key: K): Promise { return this.config[key]; } - async setConfig(key: K, value: UserConfig[K]): Promise { + async setConfig(key: K, value: CliUserConfig[K]): Promise<'success'> { this.config[key] = value; if (key === 'enableTelemetry') { this.bus.emit('mongosh:update-user', this.config.userId, this.config.enableTelemetry); @@ -326,6 +323,12 @@ class CliRepl { } catch (err) { this.warnAboutInaccessibleFile(err, this.configDirectory.path()); } + return 'success'; + } + + listConfigOptions(): string[] { + const keys = Object.keys(this.config) as (keyof CliUserConfig)[]; + return keys.filter(key => key !== 'userId' && key !== 'disableGreetingMessage'); } async verifyNodeVersion(): Promise { diff --git a/packages/cli-repl/src/config-directory.ts b/packages/cli-repl/src/config-directory.ts index 9797d03b66..bd81582eed 100644 --- a/packages/cli-repl/src/config-directory.ts +++ b/packages/cli-repl/src/config-directory.ts @@ -77,7 +77,7 @@ export class ConfigManager extends EventEmitter { try { const config: Config = JSON.parse(await fd.readFile({ encoding: 'utf8' })); this.emit('update-config', config); - return config; + return { ...defaultConfig, ...config }; } catch (err) { this.emit('error', err); return defaultConfig; diff --git a/packages/cli-repl/src/format-output.ts b/packages/cli-repl/src/format-output.ts index 5b5c59ba21..37d3676249 100644 --- a/packages/cli-repl/src/format-output.ts +++ b/packages/cli-repl/src/format-output.ts @@ -16,6 +16,8 @@ type EvaluationResult = { type FormatOptions = { colors: boolean; depth?: number; + maxArrayLength?: number; + maxStringLength?: number; }; /** @@ -34,11 +36,11 @@ export default function formatOutput(evaluationResult: EvaluationResult, options const { value, type } = evaluationResult; if (type === 'Cursor' || type === 'AggregationCursor') { - return formatCursor(value, options); + return formatCursor(value, { ...options, maxArrayLength: Infinity }); } if (type === 'CursorIterationResult') { - return formatCursorIterationResult(value, options); + return formatCursorIterationResult(value, { ...options, maxArrayLength: Infinity }); } if (type === 'Help') { @@ -96,7 +98,12 @@ Use db.getCollection('system.profile').find() to show raw profile entries.`, 'ye } if (type === 'ExplainOutput' || type === 'ExplainableCursor') { - return formatSimpleType(value, { ...options, depth: Infinity }); + return formatSimpleType(value, { + ...options, + depth: Infinity, + maxArrayLength: Infinity, + maxStringLength: Infinity + }); } return formatSimpleType(value, options); @@ -165,12 +172,18 @@ export function formatError(error: Error, options: FormatOptions): string { return result; } +function removeUndefinedValues(obj: T) { + return Object.fromEntries(Object.entries(obj).filter(keyValue => keyValue[1] !== undefined)); +} + function inspect(output: any, options: FormatOptions): any { - return util.inspect(output, { + return util.inspect(output, removeUndefinedValues({ showProxy: false, colors: options.colors ?? true, - depth: options.depth ?? 6 - }); + depth: options.depth ?? 6, + maxArrayLength: options.maxArrayLength, + maxStringLength: options.maxStringLength + })); } function formatCursor(value: any, options: FormatOptions): any { diff --git a/packages/cli-repl/src/mongosh-repl.spec.ts b/packages/cli-repl/src/mongosh-repl.spec.ts index 74b8cdb635..636d1bf8e6 100644 --- a/packages/cli-repl/src/mongosh-repl.spec.ts +++ b/packages/cli-repl/src/mongosh-repl.spec.ts @@ -41,7 +41,7 @@ describe('MongoshNodeRepl', () => { const cp = stubInterface(); cp.getHistoryFilePath.returns(path.join(tmpdir.path, 'history')); cp.getConfig.callsFake(async(key: string) => config[key]); - cp.setConfig.callsFake(async(key: string, value: any) => { config[key] = value; }); + cp.setConfig.callsFake(async(key: string, value: any) => { config[key] = value; return 'success'; }); cp.exit.callsFake(((code) => bus.emit('test-exit-event', code)) as any); ioProvider = cp; @@ -418,6 +418,41 @@ describe('MongoshNodeRepl', () => { }); } }); + + context('with modified config values', () => { + it('controls inspect depth', async() => { + input.write('config.set("inspectDepth", 2)\n'); + await waitEval(bus); + expect(output).to.include('Setting "inspectDepth" has been changed'); + + output = ''; + input.write('({a:{b:{c:{d:{e:{f:{g:{h:{}}}}}}}}})\n'); + await waitEval(bus); + expect(stripAnsi(output).replace(/\s+/g, ' ')).to.include('{ a: { b: { c: [Object] } } }'); + + input.write('config.set("inspectDepth", 4)\n'); + await waitEval(bus); + output = ''; + input.write('({a:{b:{c:{d:{e:{f:{g:{h:{}}}}}}}}})\n'); + await waitEval(bus); + expect(stripAnsi(output).replace(/\s+/g, ' ')).to.include('{ a: { b: { c: { d: { e: [Object] } } } } }'); + }); + + it('controls history length', async() => { + input.write('config.set("historyLength", 2)\n'); + await waitEval(bus); + + let i = 2; + while (!output.includes('65536')) { + input.write(`${i} + ${i}\n`); + await waitEval(bus); + i *= 2; + } + + const { history } = mongoshRepl.runtimeState().repl as any; + expect(history).to.have.lengthOf(2); + }); + }); }); context('with fake TTY', () => { diff --git a/packages/cli-repl/src/mongosh-repl.ts b/packages/cli-repl/src/mongosh-repl.ts index 970c791478..9cd309c9f4 100644 --- a/packages/cli-repl/src/mongosh-repl.ts +++ b/packages/cli-repl/src/mongosh-repl.ts @@ -1,11 +1,10 @@ import completer from '@mongosh/autocomplete'; import { MongoshCommandFailed, MongoshInternalError, MongoshWarning } from '@mongosh/errors'; import { changeHistory } from '@mongosh/history'; -import i18n from '@mongosh/i18n'; import type { ServiceProvider, AutoEncryptionOptions } from '@mongosh/service-provider-core'; import { EvaluationListener, ShellCliOptions, ShellInternalState, OnLoadResult } from '@mongosh/shell-api'; import { ShellEvaluator, ShellResult } from '@mongosh/shell-evaluator'; -import type { MongoshBus, UserConfig } from '@mongosh/types'; +import type { MongoshBus, CliUserConfig, ConfigProvider } from '@mongosh/types'; import askpassword from 'askpassword'; import { Console } from 'console'; import { once } from 'events'; @@ -25,10 +24,8 @@ export type MongoshCliOptions = ShellCliOptions & { quiet?: boolean; }; -export type MongoshIOProvider = { +export type MongoshIOProvider = ConfigProvider & { getHistoryFilePath(): string; - getConfig(key: K): Promise; - setConfig(key: K, value: UserConfig[K]): Promise; exit(code: number): Promise; readFileUTF8(filename: string): Promise<{ contents: string, absolutePath: string }>; startMongocryptd(): Promise; @@ -72,6 +69,7 @@ class MongoshNodeRepl implements EvaluationListener { ioProvider: MongoshIOProvider; onClearCommand?: EvaluationListener['onClearCommand']; insideAutoComplete: boolean; + inspectDepth = 0; constructor(options: MongoshNodeReplOptions) { this.input = options.input; @@ -95,6 +93,8 @@ class MongoshNodeRepl implements EvaluationListener { await this.greet(mongodVersion); await this.printStartupLog(internalState); + this.inspectDepth = await this.getConfig('inspectDepth'); + const repl = asyncRepl.start({ start: prettyRepl.start, input: this.lineByLineInput as unknown as Readable, @@ -104,7 +104,7 @@ class MongoshNodeRepl implements EvaluationListener { breakEvalOnSigint: true, preview: false, asyncEval: this.eval.bind(this), - historySize: 1000, // Same as the old shell. + historySize: await this.getConfig('historyLength'), wrapCallbackError: (err: Error) => Object.assign(new MongoshInternalError(err.message), { stack: err.stack }), ...this.nodeReplOptions @@ -262,9 +262,9 @@ class MongoshNodeRepl implements EvaluationListener { text += `Using MongoDB: ${mongodVersion}\n`; text += `${this.clr('Using Mongosh Beta', ['bold', 'yellow'])}: ${version}\n`; text += `${MONGOSH_WIKI}\n`; - if (!await this.ioProvider.getConfig('disableGreetingMessage')) { + if (!await this.getConfig('disableGreetingMessage')) { text += `${TELEMETRY_GREETING_MESSAGE}\n`; - await this.ioProvider.setConfig('disableGreetingMessage', true); + await this.setConfig('disableGreetingMessage', true); } this.output.write(text); } @@ -381,16 +381,6 @@ class MongoshNodeRepl implements EvaluationListener { return this.formatOutput({ type: result.type, value: result.printable }); } - async toggleTelemetry(enabled: boolean): Promise { - await this.ioProvider.setConfig('enableTelemetry', enabled); - - if (enabled) { - return i18n.__('cli-repl.cli-repl.enabledTelemetry'); - } - - return i18n.__('cli-repl.cli-repl.disabledTelemetry'); - } - onPrint(values: ShellResult[]): void { const joined = values.map((value) => this.writer(value)).join(' '); this.output.write(joined + '\n'); @@ -419,11 +409,12 @@ class MongoshNodeRepl implements EvaluationListener { return clr(text, style, this.getFormatOptions()); } - getFormatOptions(): { colors: boolean } { + getFormatOptions(): { colors: boolean, depth: number } { const output = this.output as WriteStream; return { colors: this._runtimeState?.repl?.useColors ?? - (output.isTTY && output.getColorDepth() > 1) + (output.isTTY && output.getColorDepth() > 1), + depth: this.inspectDepth }; } @@ -451,6 +442,24 @@ class MongoshNodeRepl implements EvaluationListener { return this.ioProvider.exit(0); } + async getConfig(key: K): Promise { + return this.ioProvider.getConfig(key); + } + + async setConfig(key: K, value: CliUserConfig[K]): Promise<'success' | 'ignored'> { + if (key === 'historyLength' && this._runtimeState) { + (this.runtimeState().repl as any).historySize = value; + } + if (key === 'inspectDepth') { + this.inspectDepth = +value; + } + return this.ioProvider.setConfig(key, value); + } + + listConfigOptions(): Promise | string[] { + return this.ioProvider.listConfigOptions(); + } + async startMongocryptd(): Promise { return this.ioProvider.startMongocryptd(); } diff --git a/packages/i18n/src/locales/en_US.ts b/packages/i18n/src/locales/en_US.ts index cb105f9b2e..656a1feec7 100644 --- a/packages/i18n/src/locales/en_US.ts +++ b/packages/i18n/src/locales/en_US.ts @@ -150,6 +150,19 @@ const translations: Catalog = { } }, }, + ShellConfig: { + help: { + description: 'Shell configuration methods', + attributes: { + get: { + description: 'Get a configuration value with config.get(key)' + }, + set: { + description: 'Change a configuration value with config.set(key, value)' + } + } + }, + }, AggregationCursor: { help: { description: 'Aggregation Class', diff --git a/packages/node-runtime-worker-thread/src/child-process-evaluation-listener.ts b/packages/node-runtime-worker-thread/src/child-process-evaluation-listener.ts index aa57f33362..c27695312d 100644 --- a/packages/node-runtime-worker-thread/src/child-process-evaluation-listener.ts +++ b/packages/node-runtime-worker-thread/src/child-process-evaluation-listener.ts @@ -17,8 +17,14 @@ export class ChildProcessEvaluationListener { onPrint(values) { return workerRuntime.evaluationListener?.onPrint?.(values); }, - toggleTelemetry(enabled) { - return workerRuntime.evaluationListener?.toggleTelemetry?.(enabled); + setConfig(key, value) { + return workerRuntime.evaluationListener?.setConfig?.(key, value) ?? Promise.resolve('ignored'); + }, + getConfig(key) { + return workerRuntime.evaluationListener?.getConfig?.(key) as any; + }, + listConfigOptions() { + return workerRuntime.evaluationListener?.listConfigOptions?.() as any; }, onClearCommand() { return workerRuntime.evaluationListener?.onClearCommand?.(); diff --git a/packages/node-runtime-worker-thread/src/child-process-proxy.ts b/packages/node-runtime-worker-thread/src/child-process-proxy.ts index d7be5ab3ff..b8d0e5aee9 100644 --- a/packages/node-runtime-worker-thread/src/child-process-proxy.ts +++ b/packages/node-runtime-worker-thread/src/child-process-proxy.ts @@ -95,7 +95,7 @@ exposeAll(worker, process); const evaluationListener = Object.assign( createCaller( - ['onPrint', 'onPrompt', 'toggleTelemetry', 'onClearCommand', 'onExit'], + ['onPrint', 'onPrompt', 'getConfig', 'setConfig', 'listConfigOptions', 'onClearCommand', 'onExit'], process ), { diff --git a/packages/node-runtime-worker-thread/src/worker-runtime.spec.ts b/packages/node-runtime-worker-thread/src/worker-runtime.spec.ts index 37a154b185..35c5943c4b 100644 --- a/packages/node-runtime-worker-thread/src/worker-runtime.spec.ts +++ b/packages/node-runtime-worker-thread/src/worker-runtime.spec.ts @@ -169,6 +169,14 @@ describe('worker', () => { describe('shell-api results', () => { const testServer = startTestServer('shared'); const db = `test-db-${Date.now().toString(16)}`; + let exposed: Exposed; + + afterEach(() => { + if (exposed) { + exposed[close](); + exposed = null; + } + }); type CommandTestRecord = | [string | string[], string] @@ -334,6 +342,12 @@ describe('worker', () => { } it(`"${command}" should return ${resultType} result`, async() => { + // Without this dummy evaluation listener, a request to getConfig() + // from the shell leads to a never-resolved Promise. + exposed = exposeAll({ + getConfig() {} + }, worker); + const { init, evaluate } = caller; await init(await testServer.connectionString(), {}, {}); @@ -460,13 +474,17 @@ describe('worker', () => { onPrompt() { return '123'; }, - toggleTelemetry() {}, + getConfig() {}, + setConfig() {}, + listConfigOptions() { return []; }, onRunInterruptible() {} }; spySandbox.spy(evalListener, 'onPrint'); spySandbox.spy(evalListener, 'onPrompt'); - spySandbox.spy(evalListener, 'toggleTelemetry'); + spySandbox.spy(evalListener, 'getConfig'); + spySandbox.spy(evalListener, 'setConfig'); + spySandbox.spy(evalListener, 'listConfigOptions'); spySandbox.spy(evalListener, 'onRunInterruptible'); return evalListener; @@ -514,8 +532,8 @@ describe('worker', () => { }); }); - describe('toggleTelemetry', () => { - it('should be called when shell evaluates `enableTelemetry` or `disableTelemetry`', async() => { + describe('getConfig', () => { + it('should be called when shell evaluates `config.get()`', async() => { const { init, evaluate } = caller; const evalListener = createSpiedEvaluationListener(); @@ -523,11 +541,38 @@ describe('worker', () => { await init('mongodb://nodb/', {}, { nodb: true }); - await evaluate('enableTelemetry()'); - expect(evalListener.toggleTelemetry).to.have.been.calledWith(true); + await evaluate('config.get("key")'); + expect(evalListener.getConfig).to.have.been.calledWith('key'); + }); + }); + + describe('setConfig', () => { + it('should be called when shell evaluates `config.set()`', async() => { + const { init, evaluate } = caller; + const evalListener = createSpiedEvaluationListener(); + + exposed = exposeAll(evalListener, worker); + + await init('mongodb://nodb/', {}, { nodb: true }); + + await evaluate('config.set("key", "value")'); + expect(evalListener.setConfig).to.have.been.calledWith('key', 'value'); + }); + }); + + describe('listConfigOptions', () => { + it('should be called when shell evaluates `config[asPrintable]`', async() => { + const { init, evaluate } = caller; + const evalListener = createSpiedEvaluationListener(); + + exposed = exposeAll(evalListener, worker); + + await init('mongodb://nodb/', {}, { nodb: true }); - await evaluate('disableTelemetry()'); - expect(evalListener.toggleTelemetry).to.have.been.calledWith(false); + await evaluate(` + var JSSymbol = Object.getOwnPropertySymbols(Array.prototype)[0].constructor; + config[JSSymbol.for("@@mongosh.asPrintable")]()`); + expect(evalListener.listConfigOptions).to.have.been.calledWith(); }); }); diff --git a/packages/node-runtime-worker-thread/src/worker-runtime.ts b/packages/node-runtime-worker-thread/src/worker-runtime.ts index 555690d448..be603f51ec 100644 --- a/packages/node-runtime-worker-thread/src/worker-runtime.ts +++ b/packages/node-runtime-worker-thread/src/worker-runtime.ts @@ -46,7 +46,9 @@ const evaluationListener = createCaller( [ 'onPrint', 'onPrompt', - 'toggleTelemetry', + 'getConfig', + 'setConfig', + 'listConfigOptions', 'onClearCommand', 'onExit', 'onRunInterruptible' diff --git a/packages/shell-api/src/aggregation-cursor.spec.ts b/packages/shell-api/src/aggregation-cursor.spec.ts index 1177fdaa65..26e27a8e94 100644 --- a/packages/shell-api/src/aggregation-cursor.spec.ts +++ b/packages/shell-api/src/aggregation-cursor.spec.ts @@ -42,7 +42,8 @@ describe('AggregationCursor', () => { bufferedCount() { return 0; } }; cursor = new AggregationCursor({ - _serviceProvider: { platform: ReplPlatform.CLI } + _serviceProvider: { platform: ReplPlatform.CLI }, + _batchSize: () => 20 } as any, wrappee); }); @@ -71,7 +72,9 @@ describe('AggregationCursor', () => { }); describe('Cursor Internals', () => { - const mongo = {} as any; + const mongo = { + _batchSize: () => 20 + } as any; describe('#close', () => { let spCursor: StubbedInstance; let shellApiCursor; diff --git a/packages/shell-api/src/aggregation-cursor.ts b/packages/shell-api/src/aggregation-cursor.ts index e3e707d66d..02d8b9acd9 100644 --- a/packages/shell-api/src/aggregation-cursor.ts +++ b/packages/shell-api/src/aggregation-cursor.ts @@ -13,7 +13,7 @@ import type { Document } from '@mongosh/service-provider-core'; import { CursorIterationResult } from './result'; -import { asPrintable, DEFAULT_BATCH_SIZE } from './enums'; +import { asPrintable } from './enums'; import { iterate, validateExplainableVerbosity, markAsExplainOutput } from './helpers'; @shellApiClassDefault @@ -22,7 +22,7 @@ export default class AggregationCursor extends ShellApiClass { _mongo: Mongo; _cursor: ServiceProviderAggregationCursor; _currentIterationResult: CursorIterationResult | null = null; - _batchSize = DEFAULT_BATCH_SIZE; + _batchSize: number | null = null; constructor(mongo: Mongo, cursor: ServiceProviderAggregationCursor) { super(); @@ -32,7 +32,7 @@ export default class AggregationCursor extends ShellApiClass { async _it(): Promise { const results = this._currentIterationResult = new CursorIterationResult(); - await iterate(results, this._cursor, this._batchSize); + await iterate(results, this._cursor, this._batchSize ?? await this._mongo._batchSize()); results.cursorHasMore = !this.isExhausted(); return results; } diff --git a/packages/shell-api/src/change-stream-cursor.ts b/packages/shell-api/src/change-stream-cursor.ts index 79a824ece2..85b3f5333e 100644 --- a/packages/shell-api/src/change-stream-cursor.ts +++ b/packages/shell-api/src/change-stream-cursor.ts @@ -12,7 +12,7 @@ import { ResumeToken } from '@mongosh/service-provider-core'; import { CursorIterationResult } from './result'; -import { asPrintable, DEFAULT_BATCH_SIZE } from './enums'; +import { asPrintable } from './enums'; import { MongoshInvalidInputError, MongoshRuntimeError, @@ -29,7 +29,7 @@ export default class ChangeStreamCursor extends ShellApiClass { _cursor: ChangeStream; _currentIterationResult: CursorIterationResult | null = null; _on: string; - _batchSize = DEFAULT_BATCH_SIZE; + _batchSize: number | null = null; constructor(cursor: ChangeStream, on: string, mongo: Mongo) { super(); @@ -43,7 +43,7 @@ export default class ChangeStreamCursor extends ShellApiClass { throw new MongoshRuntimeError('ChangeStreamCursor is closed'); } const result = this._currentIterationResult = new CursorIterationResult(); - return iterate(result, this._cursor, this._batchSize); + return iterate(result, this._cursor, this._batchSize ?? await this._mongo._batchSize()); } /** diff --git a/packages/shell-api/src/cursor.spec.ts b/packages/shell-api/src/cursor.spec.ts index e4dde7c271..71ab6c3b1d 100644 --- a/packages/shell-api/src/cursor.spec.ts +++ b/packages/shell-api/src/cursor.spec.ts @@ -46,7 +46,8 @@ describe('Cursor', () => { bufferedCount() { return 0; } }; cursor = new Cursor({ - _serviceProvider: { platform: ReplPlatform.CLI } + _serviceProvider: { platform: ReplPlatform.CLI }, + _batchSize: () => 20 } as any, wrappee); }); @@ -78,7 +79,9 @@ describe('Cursor', () => { }); }); describe('Cursor Internals', () => { - const mongo = {} as any; + const mongo = { + _batchSize: () => 20 + } as any; describe('#addOption', () => { let spCursor: StubbedInstance; let shellApiCursor; diff --git a/packages/shell-api/src/cursor.ts b/packages/shell-api/src/cursor.ts index 6500dffb2e..c28b805555 100644 --- a/packages/shell-api/src/cursor.ts +++ b/packages/shell-api/src/cursor.ts @@ -12,8 +12,7 @@ import { import { ServerVersions, asPrintable, - CURSOR_FLAGS, - DEFAULT_BATCH_SIZE + CURSOR_FLAGS } from './enums'; import { FindCursor as ServiceProviderCursor, @@ -38,7 +37,7 @@ export default class Cursor extends ShellApiClass { _cursor: ServiceProviderCursor; _currentIterationResult: CursorIterationResult | null = null; _tailable = false; - _batchSize = DEFAULT_BATCH_SIZE; + _batchSize: number | null = null; constructor(mongo: Mongo, cursor: ServiceProviderCursor) { super(); @@ -55,7 +54,7 @@ export default class Cursor extends ShellApiClass { async _it(): Promise { const results = this._currentIterationResult = new CursorIterationResult(); - await iterate(results, this._cursor, this._batchSize); + await iterate(results, this._cursor, this._batchSize ?? await this._mongo._batchSize()); results.cursorHasMore = !this.isExhausted(); return results; } diff --git a/packages/shell-api/src/enums.ts b/packages/shell-api/src/enums.ts index 688753078b..30e9adb616 100644 --- a/packages/shell-api/src/enums.ts +++ b/packages/shell-api/src/enums.ts @@ -30,4 +30,3 @@ export const asPrintable = Symbol.for('@@mongosh.asPrintable'); export const namespaceInfo = Symbol.for('@@mongosh.namespaceInfo'); export const ADMIN_DB = 'admin'; -export const DEFAULT_BATCH_SIZE = 20; diff --git a/packages/shell-api/src/mongo.ts b/packages/shell-api/src/mongo.ts index 752bb2951f..aeb09fcbdb 100644 --- a/packages/shell-api/src/mongo.ts +++ b/packages/shell-api/src/mongo.ts @@ -127,6 +127,10 @@ export default class Mongo extends ShellApiClass { this.__serviceProvider = sp; } + async _batchSize(): Promise { + return await this._internalState.shellApi.config.get('batchSize'); + } + /** * Internal method to determine what is printed for this class. */ diff --git a/packages/shell-api/src/shell-api.spec.ts b/packages/shell-api/src/shell-api.spec.ts index c87f329d90..da88080050 100644 --- a/packages/shell-api/src/shell-api.spec.ts +++ b/packages/shell-api/src/shell-api.spec.ts @@ -531,15 +531,15 @@ describe('ShellApi', () => { }); } describe('enableTelemetry', () => { - it('calls .toggleTelemetry() with true', () => { + it('calls .setConfig("enableTelemetry") with true', () => { internalState.context.enableTelemetry(); - expect(evaluationListener.toggleTelemetry).to.have.been.calledWith(true); + expect(evaluationListener.setConfig).to.have.been.calledWith('enableTelemetry', true); }); }); describe('disableTelemetry', () => { - it('calls .toggleTelemetry() with false', () => { + it('calls .setConfig("enableTelemetry") with false', () => { internalState.context.disableTelemetry(); - expect(evaluationListener.toggleTelemetry).to.have.been.calledWith(false); + expect(evaluationListener.setConfig).to.have.been.calledWith('enableTelemetry', false); }); }); describe('passwordPrompt', () => { @@ -629,6 +629,59 @@ describe('ShellApi', () => { }); }); } + + describe('config', () => { + context('with a full-config evaluation listener', () => { + let store; + let config; + + beforeEach(() => { + config = internalState.context.config; + store = {}; + evaluationListener.setConfig.callsFake(async(key, value) => { + if (key === 'unavailable' as any) return 'ignored'; + store[key] = value; + return 'success'; + }); + evaluationListener.getConfig.callsFake(async key => store[key]); + evaluationListener.listConfigOptions.callsFake(() => Object.keys(store)); + }); + + it('can get/set/list config keys', async() => { + const value = { structuredData: 'value' }; + expect(await config.set('somekey', value)).to.equal('Setting "somekey" has been changed'); + expect(await config.get('somekey')).to.deep.equal(value); + expect((await toShellResult(config)).printable).to.deep.equal( + new Map([['somekey', value]])); + }); + + it('will fall back to defaults', async() => { + expect(await config.get('batchSize')).to.equal(20); + }); + + it('rejects setting unavailable config keys', async() => { + expect(await config.set('unavailable', 'value')).to.equal('Option "unavailable" is not available in this environment'); + }); + }); + + context('with a no-config evaluation listener', () => { + let config; + + beforeEach(() => { + config = internalState.context.config; + }); + + it('will work with defaults', async() => { + expect(await config.get('batchSize')).to.equal(20); + expect((await toShellResult(config)).printable).to.deep.equal( + new Map([['batchSize', 20], ['enableTelemetry', false]] as any)); + }); + + it('rejects setting all config keys', async() => { + expect(await config.set('somekey', 'value')).to.equal('Option "somekey" is not available in this environment'); + }); + }); + }); }); }); diff --git a/packages/shell-api/src/shell-api.ts b/packages/shell-api/src/shell-api.ts index 5397b5b982..8215168564 100644 --- a/packages/shell-api/src/shell-api.ts +++ b/packages/shell-api/src/shell-api.ts @@ -9,6 +9,7 @@ import { ShellResult, directShellCommand } from './decorators'; +import { asPrintable } from './enums'; import Mongo from './mongo'; import Database from './database'; import { CommandResult, CursorIterationResult } from './result'; @@ -20,9 +21,52 @@ import { DBQuery } from './deprecated'; import { promisify } from 'util'; import { ClientSideFieldLevelEncryptionOptions } from './field-level-encryption'; import { dirname } from 'path'; +import { ShellUserConfig } from '@mongosh/types'; +import i18n from '@mongosh/i18n'; const internalStateSymbol = Symbol.for('@@mongosh.internalState'); +@shellApiClassDefault +@hasAsyncChild +class ShellConfig extends ShellApiClass { + _internalState: ShellInternalState; + defaults: Readonly; + + constructor(internalState: ShellInternalState) { + super(); + this._internalState = internalState; + this.defaults = Object.freeze(new ShellUserConfig()); + } + + @returnsPromise + async set(key: K, value: ShellUserConfig[K]): Promise { + assertArgsDefinedType([key], ['string'], 'config.set'); + const { evaluationListener } = this._internalState; + const result = await evaluationListener.setConfig?.(key, value); + if (result !== 'success') { + return `Option "${key}" is not available in this environment`; + } + + return `Setting "${key}" has been changed`; + } + + @returnsPromise + async get(key: K): Promise { + assertArgsDefinedType([key], ['string'], 'config.get'); + const { evaluationListener } = this._internalState; + return await evaluationListener.getConfig?.(key) ?? this.defaults[key]; + } + + async [asPrintable](): Promise> { + const { evaluationListener } = this._internalState; + const keys = (await evaluationListener.listConfigOptions?.() ?? Object.keys(this.defaults)) as (keyof ShellUserConfig)[]; + return new Map( + await Promise.all( + keys.map( + async key => [key, await this.get(key)] as const))); + } +} + @shellApiClassDefault @hasAsyncChild export default class ShellApi extends ShellApiClass { @@ -31,12 +75,14 @@ export default class ShellApi extends ShellApiClass { [internalStateSymbol]: ShellInternalState; public DBQuery: DBQuery; loadCallNestingLevel: number; + config: ShellConfig; constructor(internalState: ShellInternalState) { super(); this[internalStateSymbol] = internalState; this.DBQuery = new DBQuery(); this.loadCallNestingLevel = 0; + this.config = new ShellConfig(internalState); } get internalState(): ShellInternalState { @@ -153,13 +199,19 @@ export default class ShellApi extends ShellApiClass { @returnsPromise @platforms([ ReplPlatform.CLI ] ) async enableTelemetry(): Promise { - return await this.internalState.evaluationListener.toggleTelemetry?.(true); + const result = await this.internalState.evaluationListener.setConfig?.('enableTelemetry', true); + if (result === 'success') { + return i18n.__('cli-repl.cli-repl.enabledTelemetry'); + } } @returnsPromise @platforms([ ReplPlatform.CLI ] ) async disableTelemetry(): Promise { - return await this.internalState.evaluationListener.toggleTelemetry?.(false); + const result = await this.internalState.evaluationListener.setConfig?.('enableTelemetry', false); + if (result === 'success') { + return i18n.__('cli-repl.cli-repl.disabledTelemetry'); + } } @returnsPromise diff --git a/packages/shell-api/src/shell-internal-state.ts b/packages/shell-api/src/shell-internal-state.ts index ac5e862202..6ea358d7df 100644 --- a/packages/shell-api/src/shell-internal-state.ts +++ b/packages/shell-api/src/shell-internal-state.ts @@ -9,7 +9,7 @@ import { TopologyDescription, TopologyTypeId } from '@mongosh/service-provider-core'; -import type { ApiEvent, MongoshBus } from '@mongosh/types'; +import type { ApiEvent, MongoshBus, ConfigProvider, ShellUserConfig } from '@mongosh/types'; import { EventEmitter } from 'events'; import redactInfo from 'mongodb-redact'; import ChangeStreamCursor from './change-stream-cursor'; @@ -56,19 +56,12 @@ export interface OnLoadResult { evaluate(): Promise; } -export interface EvaluationListener { +export interface EvaluationListener extends Partial> { /** * Called when print() or printjson() is run from the shell. */ onPrint?: (value: ShellResult[]) => Promise | void; - /** - * Called when enableTelemetry() or disableTelemetry() is run from the shell. - * The return value may be a Promise. Its value is printed as the result of - * the call. - */ - toggleTelemetry?: (enabled: boolean) => any; - /** * Called when e.g. passwordPrompt() is called from the shell. */ @@ -184,7 +177,7 @@ export default class ShellInternalState { setCtx(contextObject: any): void { this.context = contextObject; contextObject.toIterator = toIterator; - Object.assign(contextObject, this.shellApi); // currently empty, but in the future we may have properties + Object.assign(contextObject, this.shellApi); for (const name of Object.getOwnPropertyNames(ShellApi.prototype)) { if (toIgnore.concat(['hasAsyncChild', 'help']).includes(name) || typeof (this.shellApi as any)[name] !== 'function') { @@ -214,7 +207,8 @@ export default class ShellInternalState { const apiObjects = { db: signatures.Database, rs: signatures.ReplicaSet, - sh: signatures.Shard + sh: signatures.Shard, + config: signatures.ShellConfig } as any; Object.assign(apiObjects, signatures.ShellApi.attributes); delete apiObjects.Mongo; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 458b8c2e11..92688b8f06 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -106,8 +106,20 @@ export interface MongoshBus { emit(event: K, ...args: MongoshBusEventsMap[K] extends (...args: infer P) => any ? P : never): unknown; } -export class UserConfig { - userId = ''; +export class ShellUserConfig { + batchSize = 20; enableTelemetry = false; +} + +export class CliUserConfig extends ShellUserConfig { + userId = ''; disableGreetingMessage = false; + inspectDepth = 6; + historyLength = 1000; +} + +export interface ConfigProvider { + getConfig(key: K): Promise; + setConfig(key: K, value: T[K]): Promise<'success' | 'ignored'>; + listConfigOptions(): string[] | Promise; }