-
-
Notifications
You must be signed in to change notification settings - Fork 3.9k
feat(schema): support for union types #15574
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 7 commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
bde97c1
feat(schema): support for union types
vkarpov15 858b0fa
test: fix tests
vkarpov15 6f2ac19
Merge branch '8.18' into vkarpov15/gh-10894
vkarpov15 1e3bc99
Update lib/options/schemaUnionOptions.js
vkarpov15 ec77420
fix lint
vkarpov15 aef5be6
Merge branch 'vkarpov15/gh-10894' of github.com:Automattic/mongoose i…
vkarpov15 fc197a4
feat(types): add support for union types
vkarpov15 43a72ca
Update lib/schema/union.js
vkarpov15 56ac298
fix lint from copilot suggestion
vkarpov15 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) { | ||
vkarpov15 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
}); | ||
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.