diff --git a/.eslintrc.js b/.eslintrc.js index 5377c64c6c2..213f2d57c1b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -116,7 +116,8 @@ module.exports = { }, env: { node: true, - es6: true + es6: true, + es2020: true }, rules: { 'comma-style': 'error', diff --git a/docs/schematypes.md b/docs/schematypes.md index 1c3fb9a34f7..37525c969b6 100644 --- a/docs/schematypes.md +++ b/docs/schematypes.md @@ -54,6 +54,7 @@ Check out [Mongoose's plugins search](http://plugins.mongoosejs.io) to find plug - [Map](#maps) - [Schema](#schemas) - [UUID](#uuid) +- [BigInt](#bigint)

Example

@@ -632,6 +633,21 @@ const schema = new mongoose.Schema({ }); ``` +

BigInt

+ +Mongoose supports [JavaScript BigInts](https://thecodebarbarian.com/an-overview-of-bigint-in-node-js.html) as a SchemaType. +BigInts are stored as [64-bit integers in MongoDB (BSON type "long")](https://www.mongodb.com/docs/manual/reference/bson-types/). + +```javascript +const questionSchema = new Schema({ + answer: BigInt +}); +const Question = mongoose.model('Question', questionSchema); + +const question = new Question({ answer: 42n }); +typeof question.answer; // 'bigint' +``` +

Getters

Getters are like virtuals for paths defined in your schema. For example, diff --git a/docs/tutorials/lean.md b/docs/tutorials/lean.md index 8edd3438ba7..baa830f8fe5 100644 --- a/docs/tutorials/lean.md +++ b/docs/tutorials/lean.md @@ -10,6 +10,7 @@ In this tutorial, you'll learn more about the tradeoffs of using `lean()`. * [Lean and Populate](#lean-and-populate) * [When to Use Lean](#when-to-use-lean) * [Plugins](#plugins) +* [BigInts](#bigints)

Using Lean

@@ -140,3 +141,12 @@ schema.virtual('lowercase', function() { this.get('name'); // Crashes because `this` is not a Mongoose document. }); ``` + +## BigInts + +By default, the MongoDB Node driver converts longs stored in MongoDB into JavaScript numbers, **not** [BigInts](https://thecodebarbarian.com/an-overview-of-bigint-in-node-js.html). +Set the `useBigInt64` option on your `lean()` queries to inflate longs into BigInts. + +```acquit +[require:Lean Tutorial.*bigint] +``` \ No newline at end of file diff --git a/lib/cast/bigint.js b/lib/cast/bigint.js new file mode 100644 index 00000000000..7191311d647 --- /dev/null +++ b/lib/cast/bigint.js @@ -0,0 +1,31 @@ +'use strict'; + +const assert = require('assert'); + +/** + * Given a value, cast it to a BigInt, or throw an `Error` if the value + * cannot be casted. `null` and `undefined` are considered valid. + * + * @param {Any} value + * @return {Number} + * @throws {Error} if `value` is not one of the allowed values + * @api private + */ + +module.exports = function castBigInt(val) { + if (val == null) { + return val; + } + if (val === '') { + return null; + } + if (typeof val === 'bigint') { + return val; + } + + if (typeof val === 'string' || typeof val === 'number') { + return BigInt(val); + } + + assert.ok(false); +}; diff --git a/lib/query.js b/lib/query.js index 2e93d237503..84ccc5068cb 100644 --- a/lib/query.js +++ b/lib/query.js @@ -1525,12 +1525,13 @@ Query.prototype.getOptions = function() { * - [timestamps](https://mongoosejs.com/docs/guide.html#timestamps): If `timestamps` is set in the schema, set this option to `false` to skip timestamps for that particular update. Has no effect if `timestamps` is not enabled in the schema options. * - overwriteDiscriminatorKey: allow setting the discriminator key in the update. Will use the correct discriminator schema if the update changes the discriminator key. * - * The following options are only for `find()`, `findOne()`, `findById()`, `findOneAndUpdate()`, and `findByIdAndUpdate()`: + * The following options are only for `find()`, `findOne()`, `findById()`, `findOneAndUpdate()`, `findOneAndReplace()`, `findOneAndDelete()`, and `findByIdAndUpdate()`: * * - [lean](https://mongoosejs.com/docs/api/query.html#Query.prototype.lean()) * - [populate](https://mongoosejs.com/docs/populate.html) * - [projection](https://mongoosejs.com/docs/api/query.html#Query.prototype.projection()) * - sanitizeProjection + * - useBigInt64 * * The following options are only for all operations **except** `updateOne()`, `updateMany()`, `deleteOne()`, and `deleteMany()`: * diff --git a/lib/schema.js b/lib/schema.js index 7b5ef0ece89..a9014caadcc 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -2665,6 +2665,8 @@ module.exports = exports = Schema; * - [Date](https://mongoosejs.com/docs/schematypes.html#dates) * - [ObjectId](https://mongoosejs.com/docs/schematypes.html#objectids) | Oid * - [Mixed](https://mongoosejs.com/docs/schematypes.html#mixed) + * - [UUID](https://mongoosejs.com/docs/schematypes.html#uuid) + * - [BigInt](https://mongoosejs.com/docs/schematypes.html#bigint) * * Using this exposed access to the `Mixed` SchemaType, we can use them in our schema. * diff --git a/lib/schema/bigint.js b/lib/schema/bigint.js new file mode 100644 index 00000000000..24cff4ea9ed --- /dev/null +++ b/lib/schema/bigint.js @@ -0,0 +1,221 @@ +'use strict'; + +/*! + * Module dependencies. + */ + +const CastError = require('../error/cast'); +const SchemaType = require('../schematype'); +const castBigInt = require('../cast/bigint'); +const utils = require('../utils'); + +/** + * BigInt SchemaType constructor. + * + * @param {String} path + * @param {Object} options + * @inherits SchemaType + * @api public + */ + +function SchemaBigInt(path, options) { + SchemaType.call(this, path, options, 'BigInt'); +} + +/** + * This schema type's name, to defend against minifiers that mangle + * function names. + * + * @api public + */ +SchemaBigInt.schemaName = 'BigInt'; + +SchemaBigInt.defaultOptions = {}; + +/*! + * Inherits from SchemaType. + */ +SchemaBigInt.prototype = Object.create(SchemaType.prototype); +SchemaBigInt.prototype.constructor = SchemaBigInt; + +/*! + * ignore + */ + +SchemaBigInt._cast = castBigInt; + +/** + * Sets a default option for all BigInt instances. + * + * #### Example: + * + * // Make all bigints required by default + * mongoose.Schema.BigInt.set('required', true); + * + * @param {String} option The option you'd like to set the value for + * @param {Any} value value for option + * @return {undefined} + * @function set + * @static + * @api public + */ + +SchemaBigInt.set = SchemaType.set; + +/** + * Get/set the function used to cast arbitrary values to booleans. + * + * #### Example: + * + * // Make Mongoose cast empty string '' to false. + * const original = mongoose.Schema.BigInt.cast(); + * mongoose.Schema.BigInt.cast(v => { + * if (v === '') { + * return false; + * } + * return original(v); + * }); + * + * // Or disable casting entirely + * mongoose.Schema.BigInt.cast(false); + * + * @param {Function} caster + * @return {Function} + * @function get + * @static + * @api public + */ + +SchemaBigInt.cast = function cast(caster) { + if (arguments.length === 0) { + return this._cast; + } + if (caster === false) { + caster = this._defaultCaster; + } + this._cast = caster; + + return this._cast; +}; + +/*! + * ignore + */ + +SchemaBigInt._checkRequired = v => v != null; + +/** + * Override the function the required validator uses to check whether a value + * passes the `required` check. + * + * @param {Function} fn + * @return {Function} + * @function checkRequired + * @static + * @api public + */ + +SchemaBigInt.checkRequired = SchemaType.checkRequired; + +/** + * Check if the given value satisfies a required validator. + * + * @param {Any} value + * @return {Boolean} + * @api public + */ + +SchemaBigInt.prototype.checkRequired = function(value) { + return this.constructor._checkRequired(value); +}; + +/** + * Casts to bigint + * + * @param {Object} value + * @param {Object} model this value is optional + * @api private + */ + +SchemaBigInt.prototype.cast = function(value) { + let castBigInt; + if (typeof this._castFunction === 'function') { + castBigInt = this._castFunction; + } else if (typeof this.constructor.cast === 'function') { + castBigInt = this.constructor.cast(); + } else { + castBigInt = SchemaBigInt.cast(); + } + + try { + return castBigInt(value); + } catch (error) { + throw new CastError('BigInt', value, this.path, error, this); + } +}; + +/*! + * ignore + */ + +SchemaBigInt.$conditionalHandlers = utils.options(SchemaType.prototype.$conditionalHandlers, { + $gt: handleSingle, + $gte: handleSingle, + $lt: handleSingle, + $lte: handleSingle +}); + +/*! + * ignore + */ + +function handleSingle(val, context) { + return this.castForQuery(null, val, context); +} + +/** + * Casts contents for queries. + * + * @param {String} $conditional + * @param {any} val + * @api private + */ + +SchemaBigInt.prototype.castForQuery = function($conditional, val, context) { + let handler; + if ($conditional != null) { + handler = SchemaBigInt.$conditionalHandlers[$conditional]; + + if (handler) { + return handler.call(this, val); + } + + return this.applySetters(null, val, context); + } + + return this.applySetters(val, context); +}; + +/** + * + * @api private + */ + +SchemaBigInt.prototype._castNullish = function _castNullish(v) { + if (typeof v === 'undefined') { + return v; + } + const castBigInt = typeof this.constructor.cast === 'function' ? + this.constructor.cast() : + SchemaBigInt.cast(); + if (castBigInt == null) { + return v; + } + return v; +}; + +/*! + * Module exports. + */ + +module.exports = SchemaBigInt; diff --git a/lib/schema/index.js b/lib/schema/index.js index f3eb9851ea6..4bd4d8d5934 100644 --- a/lib/schema/index.js +++ b/lib/schema/index.js @@ -5,30 +5,19 @@ 'use strict'; -exports.String = require('./string'); - -exports.Number = require('./number'); - -exports.Boolean = require('./boolean'); - -exports.DocumentArray = require('./documentarray'); - -exports.Subdocument = require('./SubdocumentPath'); - exports.Array = require('./array'); - +exports.Boolean = require('./boolean'); +exports.BigInt = require('./bigint'); exports.Buffer = require('./buffer'); - exports.Date = require('./date'); - -exports.ObjectId = require('./objectid'); - -exports.Mixed = require('./mixed'); - exports.Decimal128 = exports.Decimal = require('./decimal128'); - +exports.DocumentArray = require('./documentarray'); exports.Map = require('./map'); - +exports.Mixed = require('./mixed'); +exports.Number = require('./number'); +exports.ObjectId = require('./objectid'); +exports.String = require('./string'); +exports.Subdocument = require('./SubdocumentPath'); exports.UUID = require('./uuid'); // alias diff --git a/lib/types/index.js b/lib/types/index.js index 1f67a5f9b8b..8babdf4b4d1 100644 --- a/lib/types/index.js +++ b/lib/types/index.js @@ -19,4 +19,4 @@ exports.Map = require('./map'); exports.Subdocument = require('./subdocument'); -exports.UUID = require('mongodb').BSON.UUID; +exports.UUID = require('./uuid'); diff --git a/lib/types/uuid.js b/lib/types/uuid.js new file mode 100644 index 00000000000..fc9db855f7d --- /dev/null +++ b/lib/types/uuid.js @@ -0,0 +1,13 @@ +/** + * UUID type constructor + * + * #### Example: + * + * const id = new mongoose.Types.UUID(); + * + * @constructor UUID + */ + +'use strict'; + +module.exports = require('bson').UUID; diff --git a/package.json b/package.json index ee75c8d0026..f20351d273e 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,9 @@ ], "license": "MIT", "dependencies": { - "bson": "^5.0.1", + "bson": "^5.2.0", "kareem": "2.5.1", - "mongodb": "5.1.0", + "mongodb": "5.3.0", "mpath": "0.9.0", "mquery": "5.0.0", "ms": "2.1.3", diff --git a/test/bigint.test.js b/test/bigint.test.js new file mode 100644 index 00000000000..e3d00418e2c --- /dev/null +++ b/test/bigint.test.js @@ -0,0 +1,167 @@ +'use strict'; + +const assert = require('assert'); +const start = require('./common'); + +const mongoose = start.mongoose; +const Schema = mongoose.Schema; + +describe('BigInt', function() { + beforeEach(() => mongoose.deleteModel(/Test/)); + + it('is a valid schema type', function() { + const schema = new Schema({ + myBigInt: BigInt + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + myBigInt: 42n + }); + assert.strictEqual(doc.myBigInt, 42n); + assert.equal(typeof doc.myBigInt, 'bigint'); + }); + + it('casting from strings and numbers', function() { + const schema = new Schema({ + bigint1: { + type: BigInt + }, + bigint2: 'BigInt' + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + bigint1: 42, + bigint2: '997' + }); + assert.strictEqual(doc.bigint1, 42n); + assert.strictEqual(doc.bigint2, 997n); + }); + + it('handles cast errors', async function() { + const schema = new Schema({ + bigint: 'BigInt' + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + bigint: 'foo bar' + }); + assert.strictEqual(doc.bigint, undefined); + + const err = await doc.validate().then(() => null, err => err); + assert.ok(err); + assert.ok(err.errors['bigint']); + assert.equal(err.errors['bigint'].name, 'CastError'); + assert.equal( + err.errors['bigint'].message, + 'Cast to BigInt failed for value "foo bar" (type string) at path "bigint" because of "SyntaxError"' + ); + }); + + it('supports required', async function() { + const schema = new Schema({ + bigint: { + type: BigInt, + required: true + } + }); + const Test = mongoose.model('Test', schema); + + const doc = new Test({ + bigint: null + }); + + const err = await doc.validate().then(() => null, err => err); + assert.ok(err); + assert.ok(err.errors['bigint']); + assert.equal(err.errors['bigint'].name, 'ValidatorError'); + assert.equal( + err.errors['bigint'].message, + 'Path `bigint` is required.' + ); + }); + + describe('MongoDB integration', function() { + let db; + let Test; + + before(async function() { + db = await start(); + + const schema = new Schema({ + myBigInt: BigInt + }); + db.deleteModel(/Test/); + Test = db.model('Test', schema); + }); + + after(async function() { + await db.close(); + }); + + beforeEach(async() => { + await Test.deleteMany({}); + }); + + it('is stored as a long in MongoDB', async function() { + await Test.create({ myBigInt: 42n }); + + const doc = await Test.findOne({ myBigInt: { $type: 'long' } }); + assert.ok(doc); + assert.strictEqual(doc.myBigInt, 42n); + }); + + it('becomes a bigint with lean using useBigInt64', async function() { + await Test.create({ myBigInt: 7n }); + + const doc = await Test. + findOne({ myBigInt: 7n }). + setOptions({ useBigInt64: true }). + lean(); + assert.ok(doc); + assert.strictEqual(doc.myBigInt, 7n); + }); + + it('can query with comparison operators', async function() { + await Test.create([ + { myBigInt: 1n }, + { myBigInt: 2n }, + { myBigInt: 3n }, + { myBigInt: 4n } + ]); + + let docs = await Test.find({ myBigInt: { $gte: 3n } }).sort({ myBigInt: 1 }); + assert.equal(docs.length, 2); + assert.deepStrictEqual(docs.map(doc => doc.myBigInt), [3n, 4n]); + + docs = await Test.find({ myBigInt: { $lt: 3n } }).sort({ myBigInt: -1 }); + assert.equal(docs.length, 2); + assert.deepStrictEqual(docs.map(doc => doc.myBigInt), [2n, 1n]); + }); + + it('supports populate()', async function() { + const parentSchema = new Schema({ + child: { + type: BigInt, + ref: 'Child' + } + }); + const childSchema = new Schema({ + _id: BigInt, + name: String + }); + const Parent = db.model('Parent', parentSchema); + const Child = db.model('Child', childSchema); + + const { _id } = await Parent.create({ child: 42n }); + await Child.create({ _id: 42n, name: 'test-bigint-populate' }); + + const doc = await Parent.findById(_id).populate('child'); + assert.ok(doc); + assert.equal(doc.child.name, 'test-bigint-populate'); + assert.equal(doc.child._id, 42n); + }); + }); +}); diff --git a/test/docs/lean.test.js b/test/docs/lean.test.js index 51ca5458cff..4512c7ced7f 100644 --- a/test/docs/lean.test.js +++ b/test/docs/lean.test.js @@ -203,4 +203,35 @@ describe('Lean Tutorial', function() { assert.equal(group.members[1].name, 'Kira Nerys'); // acquit:ignore:end }); + + it('bigint', async function() { + const Person = mongoose.model('Person', new mongoose.Schema({ + name: String, + age: BigInt + })); + // acquit:ignore:start + await Person.deleteMany({}); + // acquit:ignore:end + // Mongoose will convert `age` to a BigInt + const { age } = await Person.create({ name: 'Benjamin Sisko', age: 37 }); + typeof age; // 'bigint' + + // By default, if you store a document with a BigInt property in MongoDB and you + // load the document with `lean()`, the BigInt property will be a number + let person = await Person.findOne({ name: 'Benjamin Sisko' }).lean(); + typeof person.age; // 'number' + // acquit:ignore:start + assert.equal(typeof person.age, 'number'); + assert.equal(person.age, 37); + // acquit:ignore:end + + // Set the `useBigInt64` option to opt in to converting MongoDB longs to BigInts. + person = await Person.findOne({ name: 'Benjamin Sisko' }). + setOptions({ useBigInt64: true }). + lean(); + typeof person.age; // 'bigint' + // acquit:ignore:start + assert.equal(typeof person.age, 'bigint'); + // acquit:ignore:end + }); }); diff --git a/test/schema.uuid.test.js b/test/schema.uuid.test.js index 864c276ebb0..02728a44296 100644 --- a/test/schema.uuid.test.js +++ b/test/schema.uuid.test.js @@ -149,6 +149,28 @@ describe('SchemaUUID', function() { await pop.save(); }); + + it('handles built-in UUID type (gh-13103)', async function() { + const schema = new Schema({ + _id: { + type: Schema.Types.UUID + } + }, { _id: false }); + + db.deleteModel(/Test/); + const Test = db.model('Test', schema); + + const uuid = new mongoose.Types.UUID(); + let { _id } = await Test.create({ _id: uuid }); + assert.ok(_id); + assert.equal(typeof _id, 'string'); + assert.equal(_id, uuid.toString()); + + ({ _id } = await Test.findById(uuid)); + assert.ok(_id); + assert.equal(typeof _id, 'string'); + assert.equal(_id, uuid.toString()); + }); // the following are TODOs based on SchemaUUID.prototype.$conditionalHandlers which are not tested yet it('should work with $bits* operators'); diff --git a/test/types/lean.test.ts b/test/types/lean.test.ts index c9b922b541b..5a0d6aaaabd 100644 --- a/test/types/lean.test.ts +++ b/test/types/lean.test.ts @@ -125,3 +125,21 @@ async function _11767() { // expectError(examFound2Obj.questions[0].populated); expectType(examFound2Obj.questions[0].answers); } + +async function gh13010() { + const schema = new Schema({ + name: { required: true, type: Map, of: String } + }); + + const CountryModel = model('Country', schema); + + await CountryModel.create({ + name: { + en: 'Croatia', + ru: 'Хорватия' + } + }); + + const country = await CountryModel.findOne().lean().orFail().exec(); + expectType>(country.name); +} diff --git a/types/index.d.ts b/types/index.d.ts index 39fb9eadfa8..377fe916d09 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -560,13 +560,13 @@ declare module 'mongoose' { export type UpdateQuery = _UpdateQuery & AnyObject; export type FlattenMaps = { - [K in keyof T]: T[K] extends Map - ? AnyObject : T[K] extends TreatAsPrimitives + [K in keyof T]: T[K] extends Map + ? Record : T[K] extends TreatAsPrimitives ? T[K] : FlattenMaps; }; export type actualPrimitives = string | boolean | number | bigint | symbol | null | undefined; - export type TreatAsPrimitives = actualPrimitives | NativeDate | RegExp | symbol | Error | BigInt | Types.ObjectId; + export type TreatAsPrimitives = actualPrimitives | NativeDate | RegExp | symbol | Error | BigInt | Types.ObjectId | Buffer | Function; export type SchemaDefinitionType = T extends Document ? Omit> : T; diff --git a/types/query.d.ts b/types/query.d.ts index 15b973bbb4f..d08a1e345cc 100644 --- a/types/query.d.ts +++ b/types/query.d.ts @@ -162,6 +162,7 @@ declare module 'mongoose' { */ timestamps?: boolean | QueryTimestampsConfig; upsert?: boolean; + useBigInt64?: boolean; writeConcern?: mongodb.WriteConcern; [other: string]: any; @@ -430,7 +431,7 @@ declare module 'mongoose' { j(val: boolean | null): this; /** Sets the lean option. */ - lean[] : Require_id>(val?: boolean | any): QueryWithHelpers; + lean>[] : Require_id>>(val?: boolean | any): QueryWithHelpers; /** Specifies the maximum number of documents the query will return. */ limit(val: number): this;