Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions lib/options/schemaUnionOptions.js
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 5 additions & 3 deletions lib/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/

Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions lib/schema/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@ 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');
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

Expand Down
107 changes: 107 additions & 0 deletions lib/schema/union.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
'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');
}
if (options && Array.isArray(options.of)) {
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;
137 changes: 137 additions & 0 deletions test/schema.union.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
2 changes: 1 addition & 1 deletion test/schematype.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
30 changes: 30 additions & 0 deletions test/types/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1920,3 +1920,33 @@ function gh15536() {
const user3 = new UserModelNameRequiredCustom({ name: null });
expectType<string>(user3.name);
}

function gh10894() {
function autoInferred() {
const schema = new Schema({
testProp: {
type: 'Union',
of: [String, Number]
}
});
const TestModel = model('Test', schema);

type InferredDocType = InferSchemaType<typeof schema>;
expectType<string | number | null | undefined>({} as InferredDocType['testProp']);

const doc = new TestModel({ testProp: 42 });
expectType<string | number | null | undefined>(doc.testProp);

const toObject = doc.toObject();
expectType<string | number | null | undefined>(toObject.testProp);

const schemaDefinition = {
testProp: {
type: 'Union',
of: ['String', 'Number']
}
} as const;
type RawDocType = InferRawDocType<typeof schemaDefinition>;
expectType<string | number | null | undefined>({} as RawDocType['testProp']);
}
}
Loading