diff --git a/lib/schema/index.js b/lib/schema/index.js index 999bef7c693..f3eb9851ea6 100644 --- a/lib/schema/index.js +++ b/lib/schema/index.js @@ -29,6 +29,8 @@ exports.Decimal128 = exports.Decimal = require('./decimal128'); exports.Map = require('./map'); +exports.UUID = require('./uuid'); + // alias exports.Oid = exports.ObjectId; diff --git a/lib/schema/uuid.js b/lib/schema/uuid.js new file mode 100644 index 00000000000..b621fa942d8 --- /dev/null +++ b/lib/schema/uuid.js @@ -0,0 +1,329 @@ +/*! + * Module dependencies. + */ + +'use strict'; + +const MongooseBuffer = require('../types/buffer'); +const SchemaType = require('../schematype'); +const CastError = SchemaType.CastError; +const utils = require('../utils'); +const isBsonType = require('../helpers/isBsonType'); +const handleBitwiseOperator = require('./operators/bitwise'); + +const UUID_FORMAT = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i; +const Binary = MongooseBuffer.Binary; + +/** + * Helper function to convert the input hex-string to a buffer + * @param {String} hex The hex string to convert + * @returns {Buffer} The hex as buffer + * @api private + */ + +function hex2buffer(hex) { + // use buffer built-in function to convert from hex-string to buffer + const buff = Buffer.from(hex, 'hex'); + return buff; +} + +/** + * Helper function to convert the buffer input to a string + * @param {Buffer} buf The buffer to convert to a hex-string + * @returns {String} The buffer as a hex-string + * @api private + */ + +function binary2hex(buf) { + // use buffer built-in function to convert from buffer to hex-string + const hex = buf.toString('hex'); + return hex; +} + +/** + * Convert a String to Binary + * @param {String} uuidStr The value to process + * @returns {MongooseBuffer} The binary to store + * @api private + */ + +function stringToBinary(uuidStr) { + // Protect against undefined & throwing err + if (typeof uuidStr !== 'string') uuidStr = ''; + const hex = uuidStr.replace(/[{}-]/g, ''); // remove extra characters + const bytes = hex2buffer(hex); + const buff = new MongooseBuffer(bytes); + buff._subtype = 4; + + return buff; +} + +/** + * Convert binary to a uuid string + * @param {Buffer|Binary|String} uuidBin The value to process + * @returns {String} The completed uuid-string + * @api private + */ +function binaryToString(uuidBin) { + // i(hasezoey) dont quite know why, but "uuidBin" may sometimes also be the already processed string + let hex; + if (typeof uuidBin !== 'string') { + hex = binary2hex(uuidBin); + const uuidStr = hex.substring(0, 8) + '-' + hex.substring(8, 8 + 4) + '-' + hex.substring(12, 12 + 4) + '-' + hex.substring(16, 16 + 4) + '-' + hex.substring(20, 20 + 12); + return uuidStr; + } + return uuidBin; +} + +/** + * UUIDv1 SchemaType constructor. + * + * @param {String} key + * @param {Object} options + * @inherits SchemaType + * @api public + */ + +function SchemaUUID(key, options) { + SchemaType.call(this, key, options, 'UUID'); + this.getters.push(binaryToString); +} + +/** + * This schema type's name, to defend against minifiers that mangle + * function names. + * + * @api public + */ +SchemaUUID.schemaName = 'UUID'; + +SchemaUUID.defaultOptions = {}; + +/*! + * Inherits from SchemaType. + */ +SchemaUUID.prototype = Object.create(SchemaType.prototype); +SchemaUUID.prototype.constructor = SchemaUUID; + +/*! + * ignore + */ + +SchemaUUID._cast = function(value) { + if (value === null) { + return value; + } + + function newBuffer(initbuff) { + const buff = new MongooseBuffer(initbuff); + buff._subtype = 4; + return buff; + } + + if (typeof value === 'string') { + if (UUID_FORMAT.test(value)) { + return stringToBinary(value); + } else { + throw new CastError(SchemaUUID.schemaName, value, this.path); + } + } + + if (Buffer.isBuffer(value)) { + return newBuffer(value); + } + + if (value instanceof Binary) { + return newBuffer(value.value(true)); + } + + // Re: gh-647 and gh-3030, we're ok with casting using `toString()` + // **unless** its the default Object.toString, because "[object Object]" + // doesn't really qualify as useful data + if (value.toString && value.toString !== Object.prototype.toString) { + if (UUID_FORMAT.test(value.toString())) { + return stringToBinary(value.toString()); + } + } + + throw new CastError(SchemaUUID.schemaName, value, this.path); +}; + +/** + * Sets a default option for all UUID instances. + * + * #### Example: + * + * // Make all UUIDs have `required` of true by default. + * mongoose.Schema.UUID.set('required', true); + * + * const User = mongoose.model('User', new Schema({ test: mongoose.UUID })); + * new User({ }).validateSync().errors.test.message; // Path `test` is required. + * + * @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 + */ + +SchemaUUID.set = SchemaType.set; + +/** + * Get/set the function used to cast arbitrary values to UUIDs. + * + * #### Example: + * + * // Make Mongoose refuse to cast UUIDs with 0 length + * const original = mongoose.Schema.Types.UUID.cast(); + * mongoose.UUID.cast(v => { + * assert.ok(typeof v === "string" && v.length > 0); + * return original(v); + * }); + * + * // Or disable casting entirely + * mongoose.UUID.cast(false); + * + * @param {Function} [caster] + * @return {Function} + * @function get + * @static + * @api public + */ + +SchemaUUID.cast = function cast(caster) { + if (arguments.length === 0) { + return this._cast; + } + if (caster === false) { + caster = this._defaultCaster; + } + this._cast = caster; + + return this._cast; +}; + +/*! + * ignore + */ + +SchemaUUID._checkRequired = v => v != null; + +/** + * Override the function the required validator uses to check whether a string + * passes the `required` check. + * + * @param {Function} fn + * @return {Function} + * @function checkRequired + * @static + * @api public + */ + +SchemaUUID.checkRequired = SchemaType.checkRequired; + +/** + * Check if the given value satisfies a required validator. + * + * @param {Any} value + * @return {Boolean} + * @api public + */ + +SchemaUUID.prototype.checkRequired = function checkRequired(value) { + return UUID_FORMAT.test(value); +}; + +/** + * Casts to UUID + * + * @param {Object} value + * @param {Object} doc + * @param {Boolean} init whether this is an initialization cast + * @api private + */ + +SchemaUUID.prototype.cast = function(value, doc, init) { + if (SchemaType._isRef(this, value, doc, init)) { + if (isBsonType(value, 'UUID')) { + return value; + } + + return this._castRef(value, doc, init); + } + + let castFn; + if (typeof this._castFunction === 'function') { + castFn = this._castFunction; + } else if (typeof this.constructor.cast === 'function') { + castFn = this.constructor.cast(); + } else { + castFn = SchemaUUID.cast(); + } + + try { + return castFn(value); + } catch (error) { + throw new CastError(SchemaUUID.schemaName, value, this.path, error, this); + } +}; + +/*! + * ignore + */ + +function handleSingle(val) { + return this.cast(val); +} + +/*! + * ignore + */ + +function handleArray(val) { + return val.map((m) => { + return this.cast(m); + }); +} + +SchemaUUID.prototype.$conditionalHandlers = +utils.options(SchemaType.prototype.$conditionalHandlers, { + $bitsAllClear: handleBitwiseOperator, + $bitsAnyClear: handleBitwiseOperator, + $bitsAllSet: handleBitwiseOperator, + $bitsAnySet: handleBitwiseOperator, + $all: handleArray, + $gt: handleSingle, + $gte: handleSingle, + $in: handleArray, + $lt: handleSingle, + $lte: handleSingle, + $ne: handleSingle, + $nin: handleArray +}); + +/** + * Casts contents for queries. + * + * @param {String} $conditional + * @param {any} val + * @api private + */ + +SchemaUUID.prototype.castForQuery = function($conditional, val) { + let handler; + if (arguments.length === 2) { + handler = this.$conditionalHandlers[$conditional]; + if (!handler) + throw new Error('Can\'t use ' + $conditional + ' with UUID.'); + return handler.call(this, val); + } else { + return this.cast($conditional); + } +}; + +/*! + * Module exports. + */ + +module.exports = SchemaUUID; diff --git a/test/schema.uuid.test.js b/test/schema.uuid.test.js new file mode 100644 index 00000000000..ee06af9b3b7 --- /dev/null +++ b/test/schema.uuid.test.js @@ -0,0 +1,108 @@ +'use strict'; + +const start = require('./common'); +const util = require('./util'); + +const bson = require('bson'); + +const assert = require('assert'); + +const mongoose = start.mongoose; +const Schema = mongoose.Schema; + +describe('SchemaUUID', function() { + let Model; + let TestSchema; + let db; + + before(async function() { + TestSchema = new Schema({ x: { type: mongoose.Schema.Types.UUID }, y: [{ type: mongoose.Schema.Types.UUID }] }); + db = await start().asPromise(); + }); + + after(async function() { + await db.close(); + }); + + beforeEach(async() => { + await db.deleteModel(/.*/); + Model = db.model('Test', TestSchema); + }); + afterEach(() => util.clearTestData(db)); + afterEach(() => util.stopRemainingOps(db)); + + it('basic functionality should work', async function() { + const doc = new Model({ x: '09190f70-3d30-11e5-8814-0f4df9a59c41' }); + assert.ifError(doc.validateSync()); + assert.ok(typeof doc.x === 'string'); + assert.strictEqual(doc.x, '09190f70-3d30-11e5-8814-0f4df9a59c41'); + await doc.save(); + + const query = Model.findOne({ x: '09190f70-3d30-11e5-8814-0f4df9a59c41' }); + assert.ok(typeof query._conditions.x === 'string'); + + const res = await query; + assert.ifError(res.validateSync()); + assert.ok(typeof res.x === 'string'); + assert.strictEqual(res.x, '09190f70-3d30-11e5-8814-0f4df9a59c41'); + + // check that the data is actually a buffer in the database with the correct subtype + const col = db.client.db(db.name).collection(Model.collection.name); + const rawDoc = await col.findOne({ x: new bson.Binary(Buffer.from('09190f703d3011e588140f4df9a59c41', 'hex'), 4) }); + assert.ok(rawDoc); + assert.ok(rawDoc.x instanceof bson.Binary); + assert.strictEqual(rawDoc.x.sub_type, 4); + }); + + it('should throw error in case of invalid string', function() { + const doc = new Model({ x: 'invalid' }); + const res = doc.validateSync(); + assert.ok(res !== null && res !== undefined); + const errors = res.errors; + assert.strictEqual(Object.keys(errors).length, 1); + assert.ok(errors.x instanceof mongoose.Error.CastError); + }); + + it('should work with $in and $nin and $all', async function() { + const doc1 = new Model({ y: ['f8010af3-bc2c-45e6-85c6-caa30c4a7d34', 'c6f59133-4f84-45a8-bc1d-8f172803e4fe', 'df1309e0-58c5-427a-b22f-6c0fc445ccc0'] }); + const doc2 = new Model({ y: ['13d51406-cd06-4fc2-93d1-4fad9b3eecd7', 'f004416b-e02a-4212-ac77-2d3fcf04898b', '5b544b71-8988-422b-a4df-bf691939fe4e'] }); + + await doc1.save(); + await doc2.save(); + + // test $in + const foundDocIn = await Model.find({ y: { $in: ['f8010af3-bc2c-45e6-85c6-caa30c4a7d34'] } }); + assert.ok(foundDocIn); + assert.strictEqual(foundDocIn.length, 1); + assert.ok(foundDocIn[0].y); + assert.strictEqual(foundDocIn[0].y.length, 3); + assert.strictEqual(foundDocIn[0].y[0], 'f8010af3-bc2c-45e6-85c6-caa30c4a7d34'); + assert.strictEqual(foundDocIn[0].y[1], 'c6f59133-4f84-45a8-bc1d-8f172803e4fe'); + assert.strictEqual(foundDocIn[0].y[2], 'df1309e0-58c5-427a-b22f-6c0fc445ccc0'); + + // test $nin + const foundDocNin = await Model.find({ y: { $nin: ['f8010af3-bc2c-45e6-85c6-caa30c4a7d34'] } }); + assert.ok(foundDocNin); + assert.strictEqual(foundDocNin.length, 1); + assert.ok(foundDocNin[0].y); + assert.strictEqual(foundDocNin[0].y.length, 3); + assert.strictEqual(foundDocNin[0].y[0], '13d51406-cd06-4fc2-93d1-4fad9b3eecd7'); + assert.strictEqual(foundDocNin[0].y[1], 'f004416b-e02a-4212-ac77-2d3fcf04898b'); + assert.strictEqual(foundDocNin[0].y[2], '5b544b71-8988-422b-a4df-bf691939fe4e'); + + // test for $all + const foundDocAll = await Model.find({ y: { $all: ['13d51406-cd06-4fc2-93d1-4fad9b3eecd7', 'f004416b-e02a-4212-ac77-2d3fcf04898b'] } }); + assert.ok(foundDocAll); + assert.strictEqual(foundDocAll.length, 1); + assert.ok(foundDocAll[0].y); + assert.strictEqual(foundDocAll[0].y.length, 3); + assert.strictEqual(foundDocAll[0].y[0], '13d51406-cd06-4fc2-93d1-4fad9b3eecd7'); + assert.strictEqual(foundDocAll[0].y[1], 'f004416b-e02a-4212-ac77-2d3fcf04898b'); + assert.strictEqual(foundDocAll[0].y[2], '5b544b71-8988-422b-a4df-bf691939fe4e'); + }); + + // the following are TODOs based on SchemaUUID.prototype.$conditionalHandlers which are not tested yet + it('should work with $bits* operators'); + it('should work with $all operator'); + it('should work with $lt, $lte, $gt, $gte operators'); +});