fix: add validation support for Union schema types#15734
fix: add validation support for Union schema types#15734codervipul775 wants to merge 7 commits intoAutomattic:masterfrom
Conversation
There was a problem hiding this comment.
Pull Request Overview
This PR adds validation support for Union schema types in Mongoose, enabling proper validation of required fields and custom validators within union schemas and their array elements.
- Implements
doValidateanddoValidateSyncmethods for the Union SchemaType to validate values against union member schemas - Adds validation path optimization checks to prevent Union types from being skipped during validation
- Includes best match scoring logic to select the most appropriate schema when casting union values
Reviewed Changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| lib/schema/union.js | Implements validation methods (doValidate, doValidateSync) and enhances casting logic with best match scoring for object values |
| lib/document.js | Adds instance !== 'Union' checks to validation path optimization to ensure Union types are not skipped during validation |
| test/schema.union.validation.test.js | Comprehensive test coverage for Union validation including required fields, custom validators, array validation, and synchronous validation |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
lib/schema/union.js
Outdated
| return castedVal; | ||
| } | ||
|
|
||
|
|
There was a problem hiding this comment.
[nitpick] Extra blank line should be removed for consistency with the codebase style.
test/schema.union.validation.test.js
Outdated
| assert.ifError(err2); | ||
| }); | ||
|
|
||
| it('should not skip validation for arbitrary fields', async function() { |
There was a problem hiding this comment.
It makes no sense to perform an additional test to see if the same incorrect input fails with an extra field. Instead, it needs to check that the arbitrary field has been removed from the object. It is better to not error out on creation and check the saved document to see if the arbitrary field was removed.
There was a problem hiding this comment.
Thanks @timheerwagen for the review. I have made the necessary changes. Please have a look into it
Updated the test to reflect the removal of arbitrary fields from subdocuments on save, ensuring validation is correctly applied.
vkarpov15
left a comment
There was a problem hiding this comment.
I agree that right now unions of objects are a bit tough to work with, and this general idea represents an improvement.
However, we need a way to tie which schematypes, if any, pass casting before running validation. This PR completely decouples the casting logic and validation logic, so if a value casts as one member of the union and validates as another member of the union then it goes through. For example, the following case succeeds because 12 casts as a number and then passes the string's validation (no validation):
const mongoose = require('mongoose');
const { inspect } = require('util');
const TestSchema = new mongoose.Schema({
product: {
type: mongoose.Schema.Types.Union,
of: [{ type: Number, required: true, min: 44 }, String],
},
});
const TestModel = mongoose.model("test", TestSchema);
const main = async () => {
await mongoose.connect('mongodb://127.0.0.1:27017/mongoose_test');
const data = await TestModel.create({
product: 12,
});
console.log(inspect(data, { depth: Infinity }));
process.exit();
};
main();Added tests to validate casting and validation of Union schema type.
|
@vkarpov15 Thank you for the detailed feedback! You're absolutely right about the coupling issue between casting and validation. I've updated the implementation to tie casting and validation together: Changes made: Added tracking mechanism: Introduced a castSchemaTypeSymbol to store which schema type was used during casting Modified cast() and applySetters(): Now store the schema type that successfully cast the value on subdocuments Updated doValidate() and doValidateSync(): First check if a schema type was stored during casting and use that for validation Value 12 casts as Number and fails validation (12 < 44) This ensures that whichever schema type successfully casts a value is also the one that validates it, preventing the validation bypass issue you identified. Please review it. |
vkarpov15
left a comment
There was a problem hiding this comment.
Overall I'm not convinced this PR is heading in the right direction, there are a lot of fundamental gaps here. With the way unions are currently implemented I don't think supporting unions of subdocuments is practical, embedded discriminators are a better choice for that use case.
I'd rather fogure out a way to better support validation for unions of 2 primitive types, or union of primitive and object. The attempts to reconcile different object types seems to be unrelated to the issue of using the correct union schematype's validations.
lib/schema/union.js
Outdated
| const preservedFields = inputKeys.filter(key => key in castedVal && key !== '_id').length; | ||
| const score = preservedFields; | ||
|
|
||
| if (score > bestMatchScore) { |
There was a problem hiding this comment.
Going for a best match scoring is a bit overkill IMO, this sort of pattern will be difficult to explain and document. I would prefer to keep more of a "first entry in the union that matches" approach.
| afterEach(() => util.clearTestData(db)); | ||
| afterEach(() => util.stopRemainingOps(db)); | ||
|
|
||
| it('should validate required fields in union schemas', async function() { |
There was a problem hiding this comment.
Given the complexity of objects in union types, I think we might just recommend using embedded discriminators instead.
lib/schema/union.js
Outdated
| let schemaTypeToValidate = null; | ||
| for (let i = 0; i < this.schemaTypes.length; ++i) { | ||
| try { | ||
| this.schemaTypes[i].cast(value); |
There was a problem hiding this comment.
The problem is that the value was already casted, so we're not casting the original value here.
Because we're not getting the original value passed in to cast(), we also can't replicate the "check if casting returns the same value, and if so use that value" behavior here.
Refactor union schema tests to use a single subdocument schema and update validation logic. Adjust test cases to reflect changes in required fields and expected outcomes.
|
Hey @vkarpov15 Thank you for the detailed feedback! I've implemented all the requested changes: Changes Made: Removed best-match scoring - Eliminated bestMatch, bestMatchScore, and preservedFields logic entirely. Now using simple "first match wins" strategy as suggested. Fixed re-casting issue - Validation now uses a hybrid approach: For subdocuments: Uses the stored castSchemaTypeSymbol to validate against the same schema type that was used during casting (no re-casting) Number | SubSchema (primitive + object) All 6 Union validation tests passing Let me know if there's anything else you'd like me to adjust! |
vkarpov15
left a comment
There was a problem hiding this comment.
The test suite is great and there's some good work in this PR, but we should try an approach which stores the schema type for a given union path in the parent document's $__, e.g. looking at this.$__.unionTypeForPath to get the schema type associated with a specific union path, instead of re-casting when running validation. We just need to make sure that works correctly with arrays of unions and maps of unions.
|
|
||
| // For primitives, we need to determine which schema type accepts the value by attempting to cast. | ||
| // We can't store metadata on primitives, so we re-cast to find the matching schema type. | ||
| let matchedSchemaType = null; |
There was a problem hiding this comment.
I really don't like re-casting as part of validation. For one thing, while casting is almost always going to be deterministic, there's no guarantee that casting the casted value will give you the same result back. This also breaks the convention that casting and validation are separate.
| } | ||
|
|
||
| // Check if we stored which schema type was used during casting | ||
| if (value && value.$__ && value.$__[castSchemaTypeSymbol]) { |
There was a problem hiding this comment.
I also don't like relying on $__[castSchemaTypeSymbol] here because we need to make sure we clear castSchemaTypeSymbol when setting to another document, e.g. doc1.value = doc2.value should override doc2's castSchemaTypeSymbol. This kind of dangling state tends to cause bugs.
| } | ||
| this.schemaTypes = options.of.map(obj => options.parentSchema.interpretAsType(key, obj, schemaOptions)); | ||
|
|
||
| this.validators.push({ |
There was a problem hiding this comment.
I presume this is so Mongoose doesn't skip unions when getting paths to validate? If so, I'd prefer to add an explicit exception to avoid unions instead of adding a fake validator.
This PR implements validation for Union schema types to properly validate required fields and custom validators in union schemas and their array elements.
Breaking Changes: None
Test Results: All 3946 tests passing with 5 new Union validation tests
This commit message follows best practices with:
Clear prefix (fix:) indicating the type of change
Concise subject line summarizing the main change
Detailed body explaining what was changed and why
List of specific files and modifications
Reference to the issue being fixed
Test coverage information