diff --git a/.evergreen/.install_node b/.evergreen/.install_node index f0e8f43e99..4c3984aabd 100644 --- a/.evergreen/.install_node +++ b/.evergreen/.install_node @@ -1,7 +1,8 @@ +set -e export NODE_JS_VERSION='12.18.4' +export NVM_DIR="$HOME/.nvm" curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.3/install.sh | bash -export NVM_DIR="$HOME/.nvm" echo "Setting NVM environment home: $NVM_DIR" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" diff --git a/.evergreen/.setup_env b/.evergreen/.setup_env index d0f0e8a05b..89a193e284 100755 --- a/.evergreen/.setup_env +++ b/.evergreen/.setup_env @@ -1,3 +1,4 @@ +set -e export NVM_DIR="$HOME/.nvm" echo "Setting NVM environment home: $NVM_DIR" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" diff --git a/packages/browser-repl/src/components/shell-output-line.spec.tsx b/packages/browser-repl/src/components/shell-output-line.spec.tsx index 9fa42dbe58..d6c921640a 100644 --- a/packages/browser-repl/src/components/shell-output-line.spec.tsx +++ b/packages/browser-repl/src/components/shell-output-line.spec.tsx @@ -149,7 +149,7 @@ describe('', () => { } }} />); - expect(wrapper.text()).to.include('---'); + expect(wrapper.find('hr')).to.have.lengthOf(1); expect(wrapper.text()).to.include('metadata'); }); diff --git a/packages/browser-repl/src/components/shell-output-line.tsx b/packages/browser-repl/src/components/shell-output-line.tsx index 2ae0fe5124..8a3d86b961 100644 --- a/packages/browser-repl/src/components/shell-output-line.tsx +++ b/packages/browser-repl/src/components/shell-output-line.tsx @@ -13,9 +13,9 @@ import { ShowCollectionsOutput } from './types/show-collections-output'; import { CursorOutput } from './types/cursor-output'; import { CursorIterationResultOutput } from './types/cursor-iteration-result-output'; import { ObjectOutput } from './types/object-output'; +import { StatsResultOutput } from './types/stats-result-output'; import { SimpleTypeOutput } from './types/simple-type-output'; import { ErrorOutput } from './types/error-output'; -import { inspect } from './utils/inspect'; import { ShowProfileOutput } from './types/show-profile-output'; const styles = require('./shell-output-line.less'); @@ -47,6 +47,10 @@ export class ShellOutputLine extends Component { return
{value}
; } + if (typeof value === 'string' && type !== null) { + return ; + } + if (this.isPrimitiveOrFunction(value)) { return ; } @@ -60,10 +64,7 @@ export class ShellOutputLine extends Component { } if (type === 'StatsResult') { - const res = Object.keys(value).map(c => { - return `${c}\n${inspect(value[c])}`; - }).join('\n---\n'); - return ; + return ; } if (type === 'ListCommandsResult)') { diff --git a/packages/browser-repl/src/components/shell-output.spec.tsx b/packages/browser-repl/src/components/shell-output.spec.tsx index c5fd6a994f..0c79c16689 100644 --- a/packages/browser-repl/src/components/shell-output.spec.tsx +++ b/packages/browser-repl/src/components/shell-output.spec.tsx @@ -17,6 +17,12 @@ describe('', () => { expect(wrapper.find(ShellOutputLine)).to.have.lengthOf(1); }); + it('renders no output lines if only one with a value of undefined is passed', () => { + const line1: ShellOutputEntry = { type: 'output', value: undefined }; + const wrapper = shallow(); + expect(wrapper.find(ShellOutputLine)).to.have.lengthOf(0); + }); + it('pass the entry to the output line as prop', () => { const line1: ShellOutputEntry = { type: 'output', value: 'line 1' }; const wrapper = shallow(); diff --git a/packages/browser-repl/src/components/shell-output.tsx b/packages/browser-repl/src/components/shell-output.tsx index 743d969a76..dd2821bd97 100644 --- a/packages/browser-repl/src/components/shell-output.tsx +++ b/packages/browser-repl/src/components/shell-output.tsx @@ -18,6 +18,6 @@ export class ShellOutput extends Component { }; render(): JSX.Element[] { - return this.props.output.map(this.renderLine); + return this.props.output.filter(entry => entry.value !== undefined).map(this.renderLine); } } diff --git a/packages/browser-repl/src/components/types/simple-type-output.tsx b/packages/browser-repl/src/components/types/simple-type-output.tsx index 67be70e23b..31888b91b2 100644 --- a/packages/browser-repl/src/components/types/simple-type-output.tsx +++ b/packages/browser-repl/src/components/types/simple-type-output.tsx @@ -5,15 +5,18 @@ import { inspect } from '../utils/inspect'; interface SimpleTypeOutputProps { value: any; + raw?: boolean; } export class SimpleTypeOutput extends Component { static propTypes = { - value: PropTypes.any + value: PropTypes.any, + raw: PropTypes.bool }; render(): JSX.Element { - return (); + const asString = this.props.raw ? this.props.value : inspect(this.props.value); + return (); } } diff --git a/packages/browser-repl/src/components/types/stats-result-output.tsx b/packages/browser-repl/src/components/types/stats-result-output.tsx new file mode 100644 index 0000000000..52ea9964a1 --- /dev/null +++ b/packages/browser-repl/src/components/types/stats-result-output.tsx @@ -0,0 +1,27 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { ObjectOutput } from './object-output'; + +interface StatsResultOutputProps { + value: Record; +} + +export class StatsResultOutput extends Component { + static propTypes = { + value: PropTypes.any + }; + + render(): JSX.Element { + const result: JSX.Element[] = []; + for (const [ key, value ] of Object.entries(this.props.value)) { + if (result.length > 0) { + result.push(
); + } + result.push(
+

{key}

+ +
); + } + return
{result}
; + } +} diff --git a/packages/browser-repl/src/components/utils/inspect.spec.ts b/packages/browser-repl/src/components/utils/inspect.spec.ts index 9162e5e402..1f7f1984ab 100644 --- a/packages/browser-repl/src/components/utils/inspect.spec.ts +++ b/packages/browser-repl/src/components/utils/inspect.spec.ts @@ -37,13 +37,46 @@ describe('inspect', () => { inspect(undefined) ).to.equal('undefined'); }); + + it('inspects Dates', () => { + expect( + inspect(new Date('2020-11-06T14:26:29.131Z')) + ).to.equal('2020-11-06T14:26:29.131Z'); + }); }); context('with BSON types', () => { it('inspects ObjectId', () => { expect( inspect(new bson.ObjectId('0000007b3db627730e26fd0b')) - ).to.equal('ObjectID("0000007b3db627730e26fd0b")'); + ).to.equal('ObjectId("0000007b3db627730e26fd0b")'); + }); + + it('inspects UUID', () => { + expect( + inspect(new bson.Binary('abcdefghiklmnopq', 4)) + ).to.equal('UUID("61626364-6566-6768-696b-6c6d6e6f7071")'); + }); + + it('inspects nested ObjectId', () => { + expect( + inspect({ p: new bson.ObjectId('0000007b3db627730e26fd0b') }) + ).to.equal('{ p: ObjectId("0000007b3db627730e26fd0b") }'); + }); + + it('inspects nested UUID', () => { + expect( + inspect({ p: new bson.Binary('abcdefghiklmnopq', 4) }) + ).to.equal('{ p: UUID("61626364-6566-6768-696b-6c6d6e6f7071") }'); + }); + + it('does not require BSON types to be instances of the current bson library', () => { + expect( + inspect({ + _bsontype: 'ObjectID', + toHexString() { return '0000007b3db627730e26fd0b'; } + }) + ).to.equal('ObjectId("0000007b3db627730e26fd0b")'); }); }); @@ -56,4 +89,13 @@ describe('inspect', () => { }); }); }); + + context('with frozen objects with _bsontype properties', () => { + expect( + () => inspect(Object.freeze({ + _bsontype: 'ObjectID', + toHexString() { return '0000007b3db627730e26fd0b'; } + })) + ).not.to.throw; + }); }); diff --git a/packages/browser-repl/src/components/utils/inspect.ts b/packages/browser-repl/src/components/utils/inspect.ts index 7402f3b5f2..17485b1254 100644 --- a/packages/browser-repl/src/components/utils/inspect.ts +++ b/packages/browser-repl/src/components/utils/inspect.ts @@ -1,50 +1,85 @@ import { inspect as utilInspect } from 'util'; -type BSONBaseType = { _bsontype: string }; +import { bsonStringifiers } from '@mongosh/service-provider-core'; -const formatBsonType = (value: BSONBaseType): any => ({ - inspect(): string { - return `${value._bsontype}(${(JSON.stringify(value))})`; - } -}); +// At the time of writing, the Compass dist package contains what appear to be +// 155 different copies of the 'bson' module. It is impractical to attach +// our inspection methods to each of those copies individually, like we do when +// we are inside cli-repl. +// Instead, we look for values with a _bsontype property inside the object graph +// before printing them here, and attach inspection methods to each of them +// individually. +// This is not particularly fast, but should work just fine for user-facing +// interfaces like printing shell output in the browser. -function isBsonType(value: any): boolean { - return !!(value && value._bsontype); -} +const customInspect = utilInspect.custom || 'inspect'; +const visitedObjects = new WeakSet(); -function isObject(value: any): boolean { - return !!(value && typeof value === 'object' && !Array.isArray(value)); +function tryAddInspect(obj: any, stringifier: (this: any) => string): void { + try { + Object.defineProperty(obj, customInspect, { + writable: true, + configurable: true, + enumerable: false, + value: function() { + try { + return stringifier.call(this); + } catch (err) { + console.warn('Could not inspect bson object', { obj: this, err }); + return utilInspect(this, { customInspect: false }); + } + } + }); + } catch (err) { + console.warn('Could not add inspect key to object', { obj, err }); + } } -function formatProperty(value: any): any { - if (isObject(value) && isBsonType(value)) { - return formatBsonType(value); +function isDate(value: any): boolean { + try { + Date.prototype.getTime.call(value); + return true; + } catch { + return false; } - - return value; } -function formatObject(object: any): any { - const viewObject: any = {}; - for (const key of Object.keys(object)) { - viewObject[key] = formatProperty(object[key]); +function attachInspectMethods(obj: any): void { + if ((typeof obj !== 'object' && typeof obj !== 'function') || obj === null) { + // Ignore primitives + return; } - return viewObject; -} -function toViewValue(value: any): any { - if (isBsonType(value)) { - return formatBsonType(value); + if (visitedObjects.has(obj)) { + return; + } + visitedObjects.add(obj); + + // Traverse the rest of the object graph. + attachInspectMethods(Object.getPrototypeOf(obj)); + const properties = Object.getOwnPropertyDescriptors(obj); + for (const { value } of Object.values(properties)) { + attachInspectMethods(value); } - if (isObject(value)) { - return formatObject(value); + // Add obj[util.inspect.custom] if it does not exist and we can provide it. + const bsontype = obj._bsontype; + const stringifier = bsonStringifiers[bsontype]; + if (bsontype && + stringifier && + !(properties as any)[customInspect] && + !Object.isSealed(obj)) { + tryAddInspect(obj, stringifier); + } else if (isDate(obj)) { + tryAddInspect(obj, function(this: Date): string { + return this.toISOString(); + }); } - return value; } export function inspect(value: any): string { - const viewValue = toViewValue(value); - const stringifiedValue = utilInspect(viewValue, { + attachInspectMethods(value); + + const stringifiedValue = utilInspect(value, { customInspect: true, depth: 1000, breakLength: 0 diff --git a/packages/service-provider-core/src/index.ts b/packages/service-provider-core/src/index.ts index 162aea82af..b1fb2ec810 100644 --- a/packages/service-provider-core/src/index.ts +++ b/packages/service-provider-core/src/index.ts @@ -17,6 +17,7 @@ import generateUri, { Scheme } from './uri-generator'; const DEFAULT_DB = 'test'; import * as bson from 'bson'; import ServiceProviderBulkOp, { ServiceProviderBulkFindOp, BulkBatch } from './bulk'; +import makePrintableBson, { bsonStringifiers } from './printable-bson'; export { ServiceProvider, @@ -42,6 +43,8 @@ export { DEFAULT_DB, ServiceProviderCore, bson, + makePrintableBson, + bsonStringifiers, ServiceProviderBulkFindOp, ServiceProviderBulkOp, BulkBatch diff --git a/packages/service-provider-core/src/printable-bson.spec.ts b/packages/service-provider-core/src/printable-bson.spec.ts new file mode 100644 index 0000000000..57604092cb --- /dev/null +++ b/packages/service-provider-core/src/printable-bson.spec.ts @@ -0,0 +1,64 @@ +import { expect } from 'chai'; +import * as bson from 'bson'; +import { inspect } from 'util'; +import { makePrintableBson } from './'; + +describe('BSON printers', function() { + before('make BSON objects printable', function() { + makePrintableBson(bson); + }); + + // Enable after https://github.com/mongodb/js-bson/pull/412 + xit('formats ObjectIDs correctly', function() { + expect(inspect(new bson.ObjectId('5fa5694f88211043b23c7f11'))) + .to.equal('ObjectId("5fa5694f88211043b23c7f11")'); + }); + + it('formats DBRefs correctly', function() { + expect(inspect(new bson.DBRef('a', new bson.ObjectId('5f16b8bebe434dc98cdfc9cb'), 'db'))) + .to.equal('DBRef("a", "5f16b8bebe434dc98cdfc9cb", "db")'); + }); + + it('formats MinKey and MaxKey correctly', function() { + expect(inspect(new bson.MinKey())).to.equal('{ "$minKey" : 1 }'); + expect(inspect(new bson.MaxKey())).to.equal('{ "$maxKey" : 1 }'); + }); + + it('formats NumberInt correctly', function() { + expect(inspect(new bson.Int32(32))).to.equal('NumberInt(32)'); + }); + + it('formats NumberLong correctly', function() { + expect(inspect(bson.Long.fromString('64'))).to.equal('NumberLong("64")'); + }); + + it('formats NumberDecimal correctly', function() { + expect(inspect(bson.Decimal128.fromString('1'))).to.equal('NumberDecimal("1")'); + }); + + it('formats Timestamp correctly', function() { + expect(inspect(new bson.Timestamp(1, 100))).to.equal('Timestamp(1, 100)'); + }); + + it('formats Symbol correctly', function() { + expect(inspect(new bson.BSONSymbol('abc'))).to.equal('"abc"'); + }); + + it('formats Code correctly', function() { + expect(inspect(new bson.Code('abc'))).to.equal('{ "code" : "abc" }'); + }); + + it('formats BinData correctly', function() { + expect(inspect(new bson.Binary('abc'))).to.equal('BinData(0, "YWJj")'); + }); + + it('formats UUIDs correctly', function() { + expect(inspect(new bson.Binary(Buffer.from('0123456789abcdef0123456789abcdef', 'hex'), 4))) + .to.equal('UUID("01234567-89ab-cdef-0123-456789abcdef")'); + }); + + it('formats MD5s correctly', function() { + expect(inspect(new bson.Binary(Buffer.from('0123456789abcdef0123456789abcdef', 'hex'), 5))) + .to.equal('MD5("0123456789abcdef0123456789abcdef")'); + }); +}); diff --git a/packages/service-provider-core/src/printable-bson.ts b/packages/service-provider-core/src/printable-bson.ts index 37a6cdef3b..04d22e051a 100644 --- a/packages/service-provider-core/src/printable-bson.ts +++ b/packages/service-provider-core/src/printable-bson.ts @@ -1,85 +1,64 @@ -import BSON from 'bson'; +import * as BSON from 'bson'; +const inspectCustom = Symbol.for('nodejs.util.inspect.custom'); -/** - * This method modifies the BSON class passed in as argument. This is required so that - * we can have the driver return our BSON classes without having to write our own serializer. - * @param {Object} bson - */ -export default function(bson?: any): void { - if (!bson) { - bson = BSON; - } - const toString = require('util').inspect.custom || 'inspect'; - - bson.ObjectId.prototype[toString] = function(): string { +export const bsonStringifiers: Record string> = { + ObjectID: function(): string { return `ObjectId("${this.toHexString()}")`; - }; - bson.ObjectId.prototype.asPrintable = bson.ObjectId.prototype[toString]; + }, - bson.DBRef.prototype[toString] = function(): string { + DBRef: function(): string { // NOTE: if OID is an ObjectId class it will just print the oid string. return `DBRef("${this.namespace}", "${ this.oid === undefined || this.oid.toString === undefined ? this.oid : this.oid.toString() }"${this.db ? `, "${this.db}"` : ''})`; - }; - bson.DBRef.prototype.asPrintable = bson.DBRef.prototype[toString]; + }, - bson.MaxKey.prototype[toString] = function(): string { + MaxKey: function(): string { return '{ "$maxKey" : 1 }'; - }; - bson.MaxKey.prototype.asPrintable = bson.MaxKey.prototype[toString]; + }, - bson.MinKey.prototype[toString] = function(): string { + MinKey: function(): string { return '{ "$minKey" : 1 }'; - }; - bson.MinKey.prototype.asPrintable = bson.MinKey.prototype[toString]; + }, - bson.Timestamp.prototype[toString] = function(): string { + Timestamp: function(): string { return `Timestamp(${this.getLowBits().toString()}, ${this.getHighBits().toString()})`; - }; - bson.Timestamp.prototype.asPrintable = bson.Timestamp.prototype[toString]; + }, // The old shell could not print Symbols so this was undefined behavior - if ('Symbol' in bson) { - bson.Symbol.prototype[toString] = function(): string { - return `"${this.valueOf()}"`; - }; - } - if ('BSONSymbol' in bson) { - bson.BSONSymbol.prototype[toString] = function(): string { - return `"${this.valueOf()}"`; - }; - } + Symbol: function(): string { + return `"${this.valueOf()}"`; + }, + + BSONSymbol: function(): string { + return `"${this.valueOf()}"`; + }, - bson.Code.prototype[toString] = function(): string { + Code: function(): string { const j = this.toJSON(); return `{ "code" : "${j.code}"${j.scope ? `, "scope" : ${JSON.stringify(j.scope)}` : ''} }`; - }; - bson.Code.prototype.asPrintable = bson.Code.prototype[toString]; + }, - bson.Decimal128.prototype[toString] = function(): string { + Decimal128: function(): string { return `NumberDecimal("${this.toString()}")`; - }; - bson.Decimal128.prototype.asPrintable = bson.Decimal128.prototype[toString]; + }, - bson.Int32.prototype[toString] = function(): string { + Int32: function(): string { return `NumberInt(${this.valueOf()})`; - }; - bson.Int32.prototype.asPrintable = bson.Int32.prototype[toString]; + }, - bson.Long.prototype[toString] = function(): string { + Long: function(): string { return `NumberLong("${this.toString()}")`; - }; - bson.Long.prototype.asPrintable = bson.Long.prototype[toString]; + }, - bson.Binary.prototype[toString] = function(): string { + Binary: function(): string { const asBuffer = this.value(true); switch (this.sub_type) { - case bson.Binary.SUBTYPE_MD5: + case BSON.Binary.SUBTYPE_MD5: return `MD5("${asBuffer.toString('hex')}")`; - case bson.Binary.SUBTYPE_UUID: + case BSON.Binary.SUBTYPE_UUID: if (asBuffer.length === 16) { // Format '0123456789abcdef0123456789abcdef' into // '01234567-89ab-cdef-0123-456789abcdef'. @@ -94,6 +73,34 @@ export default function(bson?: any): void { default: return `BinData(${this.sub_type}, "${asBuffer.toString('base64')}")`; } - }; - bson.Binary.prototype.asPrintable = bson.Binary.prototype[toString]; + }, +}; +bsonStringifiers.ObjectId = bsonStringifiers.ObjectID; + +/** + * This method modifies the BSON class passed in as argument. This is required so that + * we can have the driver return our BSON classes without having to write our own serializer. + * @param {Object} bson + */ +export default function(bson?: any): void { + if (!bson) { + bson = BSON; + } + + for (const [ key, stringifier ] of Object.entries(bsonStringifiers)) { + if (!(key in bson)) { + continue; + } + const cls = bson[key]; + for (const key of [inspectCustom, 'inspect']) { + try { + (cls as any).prototype[key] = stringifier; + } catch { + // This may fail because bson.ObjectId.prototype[toString] can exist as a + // read-only property. https://github.com/mongodb/js-bson/pull/412 takes + // care of this. In the CLI repl and Compass this still works fine, because + // those are on bson@1.x. + } + } + } } diff --git a/packages/shell-api/src/shell-bson.ts b/packages/shell-api/src/shell-bson.ts index f8b0de76b4..ab55caac0e 100644 --- a/packages/shell-api/src/shell-bson.ts +++ b/packages/shell-api/src/shell-bson.ts @@ -1,6 +1,6 @@ import { ALL_PLATFORMS, ALL_SERVER_VERSIONS, ALL_TOPOLOGIES, ServerVersions } from './enums'; import Help from './help'; -import { bson as BSON } from '@mongosh/service-provider-core'; +import { bson as BSON, makePrintableBson } from '@mongosh/service-provider-core'; import { MongoshInternalError, MongoshInvalidInputError } from '@mongosh/errors'; import { assertArgsDefined, assertArgsType } from './helpers'; @@ -36,7 +36,9 @@ export default function constructShellBson(bson: any): any { if (bson === undefined) { bson = BSON; } + makePrintableBson(bson); const oldBSON = 'Symbol' in bson; + const helps: any = {}; [ 'Binary', 'Code', 'DBRef', 'Decimal128', 'Int32', 'Long', 'MaxKey', 'MinKey', 'ObjectId', 'Timestamp', 'Map'