diff --git a/lib/options/schemaUnionOptions.js b/lib/options/schemaUnionOptions.js new file mode 100644 index 0000000000..1fcbc658e9 --- /dev/null +++ b/lib/options/schemaUnionOptions.js @@ -0,0 +1,32 @@ +'use strict'; + +const SchemaTypeOptions = require('./schemaTypeOptions'); + +/** + * The options defined on a Union schematype. + * + * @api public + * @inherits SchemaTypeOptions + * @constructor SchemaUnionOptions + */ + +class SchemaUnionOptions extends SchemaTypeOptions {} + +const opts = require('./propertyOptions'); + +/** + * If set, specifies the types that this union can take. Mongoose will cast + * the value to one of the given types. + * + * If not set, Mongoose will not cast the value to any specific type. + * + * @api public + * @property of + * @memberOf SchemaUnionOptions + * @type {Function|Function[]|string|string[]} + * @instance + */ + +Object.defineProperty(SchemaUnionOptions.prototype, 'of', opts); + +module.exports = SchemaUnionOptions; diff --git a/lib/schema.js b/lib/schema.js index 46e93890e3..6ec1c37f99 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -1525,7 +1525,7 @@ Object.defineProperty(Schema.prototype, 'base', { * * @param {String} path * @param {Object} obj constructor - * @param {Object} options + * @param {Object} options schema options * @api private */ @@ -1539,7 +1539,6 @@ Schema.prototype.interpretAsType = function(path, obj, options) { return clone; } - // If this schema has an associated Mongoose object, use the Mongoose object's // copy of SchemaTypes re: gh-7158 gh-6933 const MongooseTypes = this.base != null ? this.base.Schema.Types : Schema.Types; @@ -1740,7 +1739,10 @@ Schema.prototype.interpretAsType = function(path, obj, options) { 'https://bit.ly/mongoose-schematypes for a list of valid schema types.'); } - const schemaType = new MongooseTypes[name](path, obj); + if (name === 'Union') { + obj.parentSchema = this; + } + const schemaType = new MongooseTypes[name](path, obj, options); if (schemaType.$isSchemaMap) { createMapNestedSchemaType(this, schemaType, path, obj, options); diff --git a/lib/schema/index.js b/lib/schema/index.js index 2d2e99211d..6c35d47360 100644 --- a/lib/schema/index.js +++ b/lib/schema/index.js @@ -12,6 +12,8 @@ exports.Buffer = require('./buffer'); exports.Date = require('./date'); exports.Decimal128 = exports.Decimal = require('./decimal128'); exports.DocumentArray = require('./documentArray'); +exports.Double = require('./double'); +exports.Int32 = require('./int32'); exports.Map = require('./map'); exports.Mixed = require('./mixed'); exports.Number = require('./number'); @@ -19,8 +21,7 @@ exports.ObjectId = require('./objectId'); exports.String = require('./string'); exports.Subdocument = require('./subdocument'); exports.UUID = require('./uuid'); -exports.Double = require('./double'); -exports.Int32 = require('./int32'); +exports.Union = require('./union'); // alias diff --git a/lib/schema/union.js b/lib/schema/union.js new file mode 100644 index 0000000000..b99194d25a --- /dev/null +++ b/lib/schema/union.js @@ -0,0 +1,105 @@ +'use strict'; + +/*! + * ignore + */ + +const SchemaUnionOptions = require('../options/schemaUnionOptions'); +const SchemaType = require('../schemaType'); + +const firstValueSymbol = Symbol('firstValue'); + +/*! + * ignore + */ + +class Union extends SchemaType { + constructor(key, options, schemaOptions = {}) { + super(key, options, 'Union'); + if (!options || !Array.isArray(options.of) || options.of.length === 0) { + throw new Error('Union schema type requires an array of types'); + } + this.schemaTypes = options.of.map(obj => options.parentSchema.interpretAsType(key, obj, schemaOptions)); + } + + cast(val, doc, init, prev, options) { + let firstValue = firstValueSymbol; + let lastError; + // Loop through each schema type in the union. If one of the schematypes returns a value that is `=== val`, then + // use `val`. Otherwise, if one of the schematypes casted successfully, use the first successfully casted value. + // Finally, if none of the schematypes casted successfully, throw the error from the last schema type in the union. + // The `=== val` check is a workaround to ensure that the original value is returned if it matches one of the schema types, + // avoiding cases like where numbers are casted to strings or dates even if the schema type is a number. + for (let i = 0; i < this.schemaTypes.length; ++i) { + try { + const casted = this.schemaTypes[i].cast(val, doc, init, prev, options); + if (casted === val) { + return casted; + } + if (firstValue === firstValueSymbol) { + firstValue = casted; + } + } catch (error) { + lastError = error; + } + } + if (firstValue !== firstValueSymbol) { + return firstValue; + } + throw lastError; + } + + // Setters also need to be aware of casting - we need to apply the setters of the entry in the union we choose. + applySetters(val, doc, init, prev, options) { + let firstValue = firstValueSymbol; + let lastError; + // Loop through each schema type in the union. If one of the schematypes returns a value that is `=== val`, then + // use `val`. Otherwise, if one of the schematypes casted successfully, use the first successfully casted value. + // Finally, if none of the schematypes casted successfully, throw the error from the last schema type in the union. + // The `=== val` check is a workaround to ensure that the original value is returned if it matches one of the schema types, + // avoiding cases like where numbers are casted to strings or dates even if the schema type is a number. + for (let i = 0; i < this.schemaTypes.length; ++i) { + try { + let castedVal = this.schemaTypes[i]._applySetters(val, doc, init, prev, options); + if (castedVal == null) { + castedVal = this.schemaTypes[i]._castNullish(castedVal); + } else { + castedVal = this.schemaTypes[i].cast(castedVal, doc, init, prev, options); + } + if (castedVal === val) { + return castedVal; + } + if (firstValue === firstValueSymbol) { + firstValue = castedVal; + } + } catch (error) { + lastError = error; + } + } + if (firstValue !== firstValueSymbol) { + return firstValue; + } + throw lastError; + } + + clone() { + const schematype = super.clone(); + + schematype.schemaTypes = this.schemaTypes.map(schemaType => schemaType.clone()); + return schematype; + } +} + +/** + * This schema type's name, to defend against minifiers that mangle + * function names. + * + * @api public + */ +Union.schemaName = 'Union'; + +Union.defaultOptions = {}; + +Union.prototype.OptionsConstructor = SchemaUnionOptions; + +module.exports = Union; diff --git a/test/schema.union.test.js b/test/schema.union.test.js new file mode 100644 index 0000000000..8bf30c265c --- /dev/null +++ b/test/schema.union.test.js @@ -0,0 +1,137 @@ +'use strict'; + +const start = require('./common'); +const util = require('./util'); + +const assert = require('assert'); + +const mongoose = start.mongoose; +const Schema = mongoose.Schema; + +describe('Union', function() { + let db; + + before(async function() { + db = await start().asPromise(); + }); + + after(async function() { + await db.close(); + }); + + afterEach(() => db.deleteModel(/Test/)); + afterEach(() => util.clearTestData(db)); + afterEach(() => util.stopRemainingOps(db)); + + it('basic functionality should work', async function() { + const schema = new Schema({ + test: { + type: 'Union', + of: [Number, String] + } + }); + const TestModel = db.model('Test', schema); + + const doc1 = new TestModel({ test: 1 }); + assert.strictEqual(doc1.test, 1); + await doc1.save(); + + const doc1FromDb = await TestModel.collection.findOne({ _id: doc1._id }); + assert.strictEqual(doc1FromDb.test, 1); + + const doc2 = new TestModel({ test: 'abc' }); + assert.strictEqual(doc2.test, 'abc'); + await doc2.save(); + + const doc2FromDb = await TestModel.collection.findOne({ _id: doc2._id }); + assert.strictEqual(doc2FromDb.test, 'abc'); + }); + + it('should report last cast error', async function() { + const schema = new Schema({ + test: { + type: 'Union', + of: [Number, Boolean] + } + }); + const TestModel = db.model('Test', schema); + + const doc1 = new TestModel({ test: 'taco tuesday' }); + assert.strictEqual(doc1.test, undefined); + await assert.rejects( + doc1.save(), + 'ValidationError: test: Cast to Boolean failed for value "taco tuesday" (type string) at path "test" because of "CastError"' + ); + }); + + it('should cast for query', async function() { + const schema = new Schema({ + test: { + type: 'Union', + of: [Number, Date] + } + }); + const TestModel = db.model('Test', schema); + + const doc1 = new TestModel({ test: 1 }); + assert.strictEqual(doc1.test, 1); + await doc1.save(); + + let res = await TestModel.findOne({ test: 1 }); + assert.strictEqual(res.test, 1); + + res = await TestModel.findOne({ test: '1' }); + assert.strictEqual(res.test, 1); + + await TestModel.create({ test: new Date('2025-06-01') }); + res = await TestModel.findOne({ test: '2025-06-01' }); + assert.strictEqual(res.test.valueOf(), new Date('2025-06-01').valueOf()); + }); + + it('should cast updates', async function() { + const schema = new Schema({ + test: { + type: 'Union', + of: [Number, Date] + } + }); + const TestModel = db.model('Test', schema); + + const doc1 = new TestModel({ test: 1 }); + assert.strictEqual(doc1.test, 1); + await doc1.save(); + + let res = await TestModel.findOneAndUpdate({ _id: doc1._id }, { test: '1' }, { returnDocument: 'after' }); + assert.strictEqual(res.test, 1); + + res = await TestModel.findOneAndUpdate({ _id: doc1._id }, { test: new Date('2025-06-01') }, { returnDocument: 'after' }); + assert.strictEqual(res.test.valueOf(), new Date('2025-06-01').valueOf()); + }); + + it('should handle setters', async function() { + const schema = new Schema({ + test: { + type: 'Union', + of: [ + Number, + { + type: String, + trim: true + } + ] + } + }); + const TestModel = db.model('Test', schema); + + const doc1 = new TestModel({ test: 1 }); + assert.strictEqual(doc1.test, 1); + await doc1.save(); + + const doc2 = new TestModel({ test: ' bbb ' }); + assert.strictEqual(doc2.test, 'bbb'); + await doc2.save(); + + const doc2FromDb = await TestModel.collection.findOne({ _id: doc2._id }); + assert.strictEqual(doc2FromDb.test, 'bbb'); + }); +}); diff --git a/test/schematype.test.js b/test/schematype.test.js index 725e21966a..3955bdc910 100644 --- a/test/schematype.test.js +++ b/test/schematype.test.js @@ -266,7 +266,7 @@ describe('schematype', function() { }); const typesToTest = Object.values(mongoose.SchemaTypes). - filter(t => t.name !== 'SchemaSubdocument' && t.name !== 'SchemaDocumentArray'); + filter(t => t.name !== 'SchemaSubdocument' && t.name !== 'SchemaDocumentArray' && t.name !== 'Union'); typesToTest.forEach((type) => { it(type.name + ', when given a default option, set its', () => { diff --git a/test/types/schema.test.ts b/test/types/schema.test.ts index 17b66d3ed6..5867f1b45d 100644 --- a/test/types/schema.test.ts +++ b/test/types/schema.test.ts @@ -1920,3 +1920,33 @@ function gh15536() { const user3 = new UserModelNameRequiredCustom({ name: null }); expectType(user3.name); } + +function gh10894() { + function autoInferred() { + const schema = new Schema({ + testProp: { + type: 'Union', + of: [String, Number] + } + }); + const TestModel = model('Test', schema); + + type InferredDocType = InferSchemaType; + expectType({} as InferredDocType['testProp']); + + const doc = new TestModel({ testProp: 42 }); + expectType(doc.testProp); + + const toObject = doc.toObject(); + expectType(toObject.testProp); + + const schemaDefinition = { + testProp: { + type: 'Union', + of: ['String', 'Number'] + } + } as const; + type RawDocType = InferRawDocType; + expectType({} as RawDocType['testProp']); + } +} diff --git a/types/inferrawdoctype.d.ts b/types/inferrawdoctype.d.ts index 9cee6747d8..0045660ed3 100644 --- a/types/inferrawdoctype.d.ts +++ b/types/inferrawdoctype.d.ts @@ -35,6 +35,10 @@ declare module 'mongoose' { TypeKey >; + type UnionToRawPathType = T[number] extends infer U + ? ResolveRawPathType + : never; + /** * Same as inferSchemaType, except: * @@ -109,11 +113,12 @@ declare module 'mongoose' { IfEquals extends true ? Buffer : PathValueType extends MapConstructor | 'Map' ? Map> : IfEquals extends true ? Map> : - PathValueType extends ArrayConstructor ? any[] : - PathValueType extends typeof Schema.Types.Mixed ? any: - IfEquals extends true ? any: - IfEquals extends true ? any: - PathValueType extends typeof SchemaType ? PathValueType['prototype'] : - PathValueType extends Record ? InferRawDocType : - unknown; + PathValueType extends 'Union' | 'union' | typeof Schema.Types.Union ? Options['of'] extends readonly any[] ? UnionToRawPathType : never : + PathValueType extends ArrayConstructor ? any[] : + PathValueType extends typeof Schema.Types.Mixed ? any: + IfEquals extends true ? any: + IfEquals extends true ? any: + PathValueType extends typeof SchemaType ? PathValueType['prototype'] : + PathValueType extends Record ? InferRawDocType : + unknown; } diff --git a/types/inferschematype.d.ts b/types/inferschematype.d.ts index 521bd5f3b9..48e549b7c1 100644 --- a/types/inferschematype.d.ts +++ b/types/inferschematype.d.ts @@ -203,6 +203,10 @@ TypeHint */ type PathEnumOrString['enum']> = T extends ReadonlyArray ? E : T extends { values: any } ? PathEnumOrString : T extends Record ? V : string; +type UnionToType = T[number] extends infer U + ? ResolvePathType + : never; + type IsSchemaTypeFromBuiltinClass = T extends (typeof String) ? true : T extends (typeof Number) @@ -314,11 +318,12 @@ type ResolvePathType extends true ? Buffer : PathValueType extends MapConstructor | 'Map' ? Map> : IfEquals extends true ? Map> : - PathValueType extends ArrayConstructor ? any[] : - PathValueType extends typeof Schema.Types.Mixed ? any: - IfEquals extends true ? any: - IfEquals extends true ? any: - PathValueType extends typeof SchemaType ? PathValueType['prototype'] : - PathValueType extends Record ? ObtainDocumentType : - unknown, + PathValueType extends 'Union' | 'union' | typeof Schema.Types.Union ? Options['of'] extends readonly any[] ? UnionToType : never : + PathValueType extends ArrayConstructor ? any[] : + PathValueType extends typeof Schema.Types.Mixed ? any: + IfEquals extends true ? any: + IfEquals extends true ? any: + PathValueType extends typeof SchemaType ? PathValueType['prototype'] : + PathValueType extends Record ? ObtainDocumentType : + unknown, TypeHint>; diff --git a/types/schematypes.d.ts b/types/schematypes.d.ts index 1312657144..8a4434df35 100644 --- a/types/schematypes.d.ts +++ b/types/schematypes.d.ts @@ -188,7 +188,7 @@ declare module 'mongoose' { _id?: boolean; /** If set, specifies the type of this map's values. Mongoose will cast this map's values to the given type. */ - of?: Function | SchemaDefinitionProperty; + of?: Function | SchemaDefinitionProperty | (Function | SchemaDefinitionProperty)[]; /** If true, uses Mongoose's default `_id` settings. Only allowed for ObjectIds */ auto?: boolean; @@ -569,6 +569,13 @@ declare module 'mongoose' { static defaultOptions: Record; } + class Union extends SchemaType { + static schemaName: 'Union'; + + /** Default options for this SchemaType */ + static defaultOptions: Record; + } + class UUID extends SchemaType { /** This schema type's name, to defend against minifiers that mangle function names. */ static schemaName: 'UUID';