diff --git a/lib/aggregation_cursor.js b/lib/aggregation_cursor.js index 2eeca0d381..e1bff13ef6 100644 --- a/lib/aggregation_cursor.js +++ b/lib/aggregation_cursor.js @@ -281,6 +281,16 @@ AggregationCursor.prototype.unwind = function(field) { return this; }; +/** + * Return the cursor logger + * @method + * @return {Logger} return the cursor logger + * @ignore + */ +AggregationCursor.prototype.getLogger = function() { + return this.logger; +}; + AggregationCursor.prototype.get = AggregationCursor.prototype.toArray; /** diff --git a/lib/collection.js b/lib/collection.js index 35dd0fd14f..e1d9accbf7 100644 --- a/lib/collection.js +++ b/lib/collection.js @@ -2073,4 +2073,14 @@ Collection.prototype.initializeOrderedBulkOp = function(options) { return ordered(this.s.topology, this, options); }; +/** + * Return the db logger + * @method + * @return {Logger} return the db logger + * @ignore + */ +Collection.prototype.getLogger = function() { + return this.s.db.s.logger; +}; + module.exports = Collection; diff --git a/lib/command_cursor.js b/lib/command_cursor.js index 38ff27b83d..50afb6f85b 100644 --- a/lib/command_cursor.js +++ b/lib/command_cursor.js @@ -216,6 +216,16 @@ CommandCursor.prototype.maxTimeMS = function(value) { return this; }; +/** + * Return the cursor logger + * @method + * @return {Logger} return the cursor logger + * @ignore + */ +CommandCursor.prototype.getLogger = function() { + return this.logger; +}; + CommandCursor.prototype.get = CommandCursor.prototype.toArray; /** diff --git a/lib/cursor.js b/lib/cursor.js index 6f77982217..559dc305b4 100644 --- a/lib/cursor.js +++ b/lib/cursor.js @@ -1039,6 +1039,16 @@ Cursor.prototype._read = function() { }); }; +/** + * Return the cursor logger + * @method + * @return {Logger} return the cursor logger + * @ignore + */ +Cursor.prototype.getLogger = function() { + return this.logger; +}; + Object.defineProperty(Cursor.prototype, 'readPreference', { enumerable: true, get: function() { diff --git a/lib/db.js b/lib/db.js index da539b6070..c652f6e109 100644 --- a/lib/db.js +++ b/lib/db.js @@ -900,6 +900,16 @@ Db.prototype.watch = function(pipeline, options) { return new ChangeStream(this, pipeline, options); }; +/** + * Return the db logger + * @method + * @return {Logger} return the db logger + * @ignore + */ +Db.prototype.getLogger = function() { + return this.s.logger; +}; + /** * Db close event * diff --git a/lib/gridfs-stream/index.js b/lib/gridfs-stream/index.js index ef16e14898..9d64a847a0 100644 --- a/lib/gridfs-stream/index.js +++ b/lib/gridfs-stream/index.js @@ -322,6 +322,16 @@ GridFSBucket.prototype.drop = function(callback) { }); }; +/** + * Return the db logger + * @method + * @return {Logger} return the db logger + * @ignore + */ +GridFSBucket.prototype.getLogger = function() { + return this.s.db.s.logger; +}; + /** * @ignore */ diff --git a/lib/mongo_client.js b/lib/mongo_client.js index ab9c6b14af..8aa7fe6307 100644 --- a/lib/mongo_client.js +++ b/lib/mongo_client.js @@ -458,4 +458,14 @@ MongoClient.prototype.watch = function(pipeline, options) { return new ChangeStream(this, pipeline, options); }; +/** + * Return the mongo client logger + * @method + * @return {Logger} return the mongo client logger + * @ignore + */ +MongoClient.prototype.getLogger = function() { + return this.s.options.logger; +}; + module.exports = MongoClient; diff --git a/lib/utils.js b/lib/utils.js index f9766782cc..ea79f4b29f 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -606,6 +606,82 @@ function decorateWithReadConcern(command, coll) { } } +const emitProcessWarning = msg => process.emitWarning(msg, 'DeprecationWarning'); +const emitConsoleWarning = msg => console.error(msg); +const emitDeprecationWarning = process.emitWarning ? emitProcessWarning : emitConsoleWarning; + +/** + * Default message handler for generating deprecation warnings. + * + * @param {string} name function name + * @param {string} option option name + * @return {string} warning message + * @ignore + * @api private + */ +function defaultMsgHandler(name, option) { + return `${name} option [${option}] is deprecated and will be removed in a later version.`; +} + +/** + * Deprecates a given function's options. + * + * @param {object} config configuration for deprecation + * @param {string} config.name function name + * @param {Array} config.deprecatedOptions options to deprecate + * @param {number} config.optionsIndex index of options object in function arguments array + * @param {function} [config.msgHandler] optional custom message handler to generate warnings + * @param {function} fn the target function of deprecation + * @return {function} modified function that warns once per deprecated option, and executes original function + * @ignore + * @api private + */ +function deprecateOptions(config, fn) { + if (process.noDeprecation === true) { + return fn; + } + + const msgHandler = config.msgHandler ? config.msgHandler : defaultMsgHandler; + + const optionsWarned = new Set(); + function deprecated() { + const options = arguments[config.optionsIndex]; + + // ensure options is a valid, non-empty object, otherwise short-circuit + if (!isObject(options) || Object.keys(options).length === 0) { + return fn.apply(this, arguments); + } + + config.deprecatedOptions.forEach(deprecatedOption => { + if (options.hasOwnProperty(deprecatedOption) && !optionsWarned.has(deprecatedOption)) { + optionsWarned.add(deprecatedOption); + const msg = msgHandler(config.name, deprecatedOption); + emitDeprecationWarning(msg); + if (this && this.getLogger) { + const logger = this.getLogger(); + if (logger) { + logger.warn(msg); + } + } + } + }); + + return fn.apply(this, arguments); + } + + // These lines copied from https://github.com/nodejs/node/blob/25e5ae41688676a5fd29b2e2e7602168eee4ceb5/lib/internal/util.js#L73-L80 + // The wrapper will keep the same prototype as fn to maintain prototype chain + Object.setPrototypeOf(deprecated, fn); + if (fn.prototype) { + // Setting this (rather than using Object.setPrototype, as above) ensures + // that calling the unwrapped constructor gives an instanceof the wrapped + // constructor. + deprecated.prototype = fn.prototype; + } + + return deprecated; +} + module.exports = { filterOptions, mergeOptions, @@ -629,5 +705,6 @@ module.exports = { resolveReadPreference, isPromiseLike, decorateWithCollation, - decorateWithReadConcern + decorateWithReadConcern, + deprecateOptions }; diff --git a/package.json b/package.json index 41a6f0cc92..bfc98a417b 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "istanbul": "^0.4.5", "jsdoc": "3.5.5", "lodash.camelcase": "^4.3.0", + "mocha-sinon": "^2.1.0", "mongodb-extjson": "^2.1.1", "mongodb-mock-server": "^1.0.0", "mongodb-test-runner": "^1.1.18", diff --git a/test/functional/deprecate_warning_tests.js b/test/functional/deprecate_warning_tests.js new file mode 100644 index 0000000000..726d246f42 --- /dev/null +++ b/test/functional/deprecate_warning_tests.js @@ -0,0 +1,125 @@ +'use strict'; +const exec = require('child_process').exec; +const chai = require('chai'); +const expect = chai.expect; +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +require('mocha-sinon'); +chai.use(sinonChai); + +const utils = require('../tools/utils'); +const ClassWithLogger = utils.ClassWithLogger; +const ClassWithoutLogger = utils.ClassWithoutLogger; +const ClassWithUndefinedLogger = utils.ClassWithUndefinedLogger; +const ensureCalledWith = utils.ensureCalledWith; + +describe('Deprecation Warnings', function() { + beforeEach(function() { + this.sinon.stub(console, 'error'); + }); + + const defaultMessage = ' is deprecated and will be removed in a later version.'; + + it('node --no-deprecation flag should suppress all deprecation warnings', { + metadata: { requires: { node: '>=6.0.0' } }, + test: function(done) { + exec( + 'node --no-deprecation ./test/tools/deprecate_warning_test_program.js', + (err, stdout, stderr) => { + expect(err).to.be.null; + expect(stdout).to.be.empty; + expect(stderr).to.be.empty; + done(); + } + ); + } + }); + + it('node --trace-deprecation flag should print stack trace to stderr', { + metadata: { requires: { node: '>=6.0.0' } }, + test: function(done) { + exec( + 'node --trace-deprecation ./test/tools/deprecate_warning_test_program.js', + (err, stdout, stderr) => { + expect(err).to.be.null; + expect(stdout).to.be.empty; + expect(stderr).to.not.be.empty; + + // split stderr into separate lines, trimming the first line to just the warning message + const split = stderr.split('\n'); + const warning = split + .shift() + .split(')')[1] + .trim(); + + // ensure warning message matches expected + expect(warning).to.equal( + 'DeprecationWarning: testDeprecationFlags option [maxScan]' + defaultMessage + ); + + // ensure each following line is from the stack trace, i.e. 'at config.deprecatedOptions.forEach.deprecatedOption' + split.pop(); + split.forEach(s => { + expect(s.trim()).to.match(/^at/); + }); + + done(); + } + ); + } + }); + + it('node --throw-deprecation flag should throw error when deprecated function is called', { + metadata: { requires: { node: '>=6.0.0' } }, + test: function(done) { + exec( + 'node --throw-deprecation ./test/tools/deprecate_warning_test_program.js this_arg_should_never_print', + (err, stdout, stderr) => { + expect(stderr).to.not.be.empty; + expect(err).to.not.be.null; + expect(err) + .to.have.own.property('code') + .that.equals(1); + + // ensure stdout is empty, i.e. that the program threw an error before reaching the console.log statement + expect(stdout).to.be.empty; + done(); + } + ); + } + }); + + it('test behavior for classes with an associated logger', function() { + const fakeClass = new ClassWithLogger(); + const logger = fakeClass.getLogger(); + const stub = sinon.stub(logger, 'warn'); + + fakeClass.f({ maxScan: 5, snapshot: true }); + fakeClass.f({ maxScan: 5, snapshot: true }); + expect(stub).to.have.been.calledTwice; + ensureCalledWith(stub, [ + 'f option [maxScan] is deprecated and will be removed in a later version.', + 'f option [snapshot] is deprecated and will be removed in a later version.' + ]); + }); + + it('test behavior for classes without an associated logger', function() { + const fakeClass = new ClassWithoutLogger(); + + function func() { + fakeClass.f({ maxScan: 5, snapshot: true }); + } + + expect(func).to.not.throw(); + }); + + it('test behavior for classes with an undefined logger', function() { + const fakeClass = new ClassWithUndefinedLogger(); + + function func() { + fakeClass.f({ maxScan: 5, snapshot: true }); + } + + expect(func).to.not.throw(); + }); +}); diff --git a/test/tools/deprecate_warning_test_program.js b/test/tools/deprecate_warning_test_program.js new file mode 100644 index 0000000000..4876f92627 --- /dev/null +++ b/test/tools/deprecate_warning_test_program.js @@ -0,0 +1,28 @@ +'use strict'; + +// prevent this file from being imported; it is only for use in functional/deprecate_warning_tests.js +if (require.main !== module) { + return; +} + +const deprecateOptions = require('../../lib/utils.js').deprecateOptions; + +const testDeprecationFlags = deprecateOptions( + { + name: 'testDeprecationFlags', + deprecatedOptions: ['maxScan', 'snapshot', 'fields'], + optionsIndex: 0 + }, + options => { + if (options) options = null; + } +); + +testDeprecationFlags({ maxScan: 0 }); + +// for tests that throw error on calling deprecated fn - this should never happen; stdout should be empty +if (process.argv[2]) { + console.log(process.argv[2]); +} + +process.nextTick(() => process.exit()); diff --git a/test/tools/utils.js b/test/tools/utils.js new file mode 100644 index 0000000000..8955aa9054 --- /dev/null +++ b/test/tools/utils.js @@ -0,0 +1,64 @@ +'use strict'; + +const Logger = require('mongodb-core').Logger; +const deprecateOptions = require('../../lib/utils').deprecateOptions; +const chai = require('chai'); +const expect = chai.expect; +const sinonChai = require('sinon-chai'); +chai.use(sinonChai); + +function makeTestFunction(config) { + const fn = options => { + if (options) options = null; + }; + return deprecateOptions(config, fn); +} + +function ensureCalledWith(stub, args) { + args.forEach(m => expect(stub).to.have.been.calledWith(m)); +} + +// creation of class with a logger +function ClassWithLogger() { + this.logger = new Logger('ClassWithLogger'); +} + +ClassWithLogger.prototype.f = makeTestFunction({ + name: 'f', + deprecatedOptions: ['maxScan', 'snapshot', 'fields'], + optionsIndex: 0 +}); + +ClassWithLogger.prototype.getLogger = function() { + return this.logger; +}; + +// creation of class without a logger +function ClassWithoutLogger() {} + +ClassWithoutLogger.prototype.f = makeTestFunction({ + name: 'f', + deprecatedOptions: ['maxScan', 'snapshot', 'fields'], + optionsIndex: 0 +}); + +// creation of class where getLogger returns undefined +function ClassWithUndefinedLogger() {} + +ClassWithUndefinedLogger.prototype.f = makeTestFunction({ + name: 'f', + deprecatedOptions: ['maxScan', 'snapshot', 'fields'], + optionsIndex: 0 +}); + +ClassWithUndefinedLogger.prototype.getLogger = function() { + return undefined; +}; + +module.exports = { + makeTestFunction, + ensureCalledWith, + ClassWithLogger, + ClassWithoutLogger, + ClassWithUndefinedLogger +}; diff --git a/test/unit/deprecate_warning_tests.js b/test/unit/deprecate_warning_tests.js new file mode 100644 index 0000000000..62143c6f64 --- /dev/null +++ b/test/unit/deprecate_warning_tests.js @@ -0,0 +1,261 @@ +'use strict'; +const deprecateOptions = require('../../lib/utils').deprecateOptions; +const chai = require('chai'); +const expect = chai.expect; +const sinonChai = require('sinon-chai'); +require('mocha-sinon'); +chai.use(sinonChai); + +const makeTestFunction = require('../tools/utils').makeTestFunction; +const ensureCalledWith = require('../tools/utils').ensureCalledWith; + +describe('Deprecation Warnings', function() { + let messages = []; + const deprecatedOptions = ['maxScan', 'snapshot', 'fields']; + const defaultMessage = ' is deprecated and will be removed in a later version.'; + + before(function() { + if (process.emitWarning) { + process.on('warning', warning => { + messages.push(warning.message); + }); + } + return; + }); + + beforeEach(function() { + this.sinon.stub(console, 'error'); + }); + + afterEach(function() { + messages.length = 0; + }); + + describe('Mult functions with same options', function() { + beforeEach(function() { + const f1 = makeTestFunction({ + name: 'f1', + deprecatedOptions: deprecatedOptions, + optionsIndex: 0 + }); + const f2 = makeTestFunction({ + name: 'f2', + deprecatedOptions: deprecatedOptions, + optionsIndex: 0 + }); + f1({ maxScan: 5 }); + f2({ maxScan: 5 }); + }); + + it('multiple functions with the same deprecated options should both warn', { + metadata: { requires: { node: '>=6.0.0' } }, + test: function(done) { + process.nextTick(() => { + expect(messages).to.deep.equal([ + 'f1 option [maxScan]' + defaultMessage, + 'f2 option [maxScan]' + defaultMessage + ]); + expect(messages).to.have.a.lengthOf(2); + done(); + }); + } + }); + + it('multiple functions with the same deprecated options should both warn', { + metadata: { requires: { node: '<6.0.0' } }, + test: function(done) { + ensureCalledWith(console.error, [ + 'f1 option [maxScan]' + defaultMessage, + 'f2 option [maxScan]' + defaultMessage + ]); + expect(console.error).to.have.been.calledTwice; + done(); + } + }); + }); + + describe('Empty options object', function() { + beforeEach(function() { + const f = makeTestFunction({ + name: 'f', + deprecatedOptions: deprecatedOptions, + optionsIndex: 0 + }); + f({}); + }); + + it('should not warn if empty options object passed in', { + metadata: { requires: { node: '>=6.0.0' } }, + test: function(done) { + process.nextTick(() => { + expect(messages).to.have.a.lengthOf(0); + done(); + }); + } + }); + + it('should not warn if empty options object passed in', { + metadata: { requires: { node: '<6.0.0' } }, + test: function(done) { + expect(console.error).to.have.not.been.called; + done(); + } + }); + }); + + describe('Custom Message Handler', function() { + beforeEach(function() { + const customMsgHandler = (name, option) => { + return 'custom msg for function ' + name + ' and option ' + option; + }; + + const f = makeTestFunction({ + name: 'f', + deprecatedOptions: deprecatedOptions, + optionsIndex: 0, + msgHandler: customMsgHandler + }); + + f({ maxScan: 5, snapshot: true, fields: 'hi' }); + }); + + it('should use user-specified message handler', { + metadata: { requires: { node: '>=6.0.0' } }, + test: function(done) { + process.nextTick(() => { + expect(messages).to.deep.equal([ + 'custom msg for function f and option maxScan', + 'custom msg for function f and option snapshot', + 'custom msg for function f and option fields' + ]); + expect(messages).to.have.a.lengthOf(3); + done(); + }); + } + }); + + it('should use user-specified message handler', { + metadata: { requires: { node: '<6.0.0' } }, + test: function(done) { + ensureCalledWith(console.error, [ + 'custom msg for function f and option maxScan', + 'custom msg for function f and option snapshot', + 'custom msg for function f and option fields' + ]); + expect(console.error).to.have.been.calledThrice; + done(); + } + }); + }); + + describe('Warn once', function() { + beforeEach(function() { + const f = makeTestFunction({ + name: 'f', + deprecatedOptions: deprecatedOptions, + optionsIndex: 0 + }); + f({ maxScan: 5, fields: 'hi' }); + f({ maxScan: 5, fields: 'hi' }); + }); + + it('each function should only warn once per deprecated option', { + metadata: { requires: { node: '>=6.0.0' } }, + test: function(done) { + process.nextTick(() => { + expect(messages).to.deep.equal([ + 'f option [maxScan]' + defaultMessage, + 'f option [fields]' + defaultMessage + ]); + expect(messages).to.have.a.lengthOf(2); + done(); + }); + } + }); + + it('each function should only warn once per deprecated option', { + metadata: { requires: { node: '<6.0.0' } }, + test: function(done) { + ensureCalledWith(console.error, [ + 'f option [maxScan]' + defaultMessage, + 'f option [fields]' + defaultMessage + ]); + expect(console.error).to.have.been.calledTwice; + done(); + } + }); + }); + + describe('Maintain functionality', function() { + beforeEach(function() { + const config = { + name: 'f', + deprecatedOptions: ['multiply', 'add'], + optionsIndex: 0 + }; + + const operateBy2 = (options, num) => { + if (options.multiply === true) { + return num * 2; + } + if (options.add === true) { + return num + 2; + } + }; + + const f = deprecateOptions(config, operateBy2); + + const mult = f({ multiply: true }, 5); + const add = f({ add: true }, 5); + + expect(mult).to.equal(10); + expect(add).to.equal(7); + }); + + it('wrapped functions should maintain original functionality', { + metadata: { requires: { node: '>=6.0.0' } }, + test: function(done) { + process.nextTick(() => { + expect(messages).to.deep.equal([ + 'f option [multiply]' + defaultMessage, + 'f option [add]' + defaultMessage + ]); + expect(messages).to.have.a.lengthOf(2); + done(); + }); + } + }); + + it('wrapped functions should maintain original functionality', { + metadata: { requires: { node: '<6.0.0' } }, + test: function(done) { + ensureCalledWith(console.error, [ + 'f option [multiply]' + defaultMessage, + 'f option [add]' + defaultMessage + ]); + expect(console.error).to.have.been.calledTwice; + done(); + } + }); + }); + + it('optionsIndex pointing to undefined should not error', function(done) { + const f = makeTestFunction({ + name: 'f', + deprecatedOptions: deprecatedOptions, + optionsIndex: 0 + }); + expect(f).to.not.throw(); + done(); + }); + + it('optionsIndex not pointing to object should not error', function(done) { + const f = makeTestFunction({ + name: 'f', + deprecatedOptions: deprecatedOptions, + optionsIndex: 0 + }); + expect(() => f('not-an-object')).to.not.throw(); + done(); + }); +});