diff --git a/lib/document.js b/lib/document.js index 15764c7568..8f21b7770f 100644 --- a/lib/document.js +++ b/lib/document.js @@ -2808,13 +2808,14 @@ function _getPathsToValidate(doc, pathsToValidate, pathsToSkip, isNestedValidate // Optimization: if primitive path with no validators, or array of primitives // with no validators, skip validating this path entirely. - if (!_pathType.caster && _pathType.validators.length === 0 && !_pathType.$parentSchemaDocArray) { + if (!_pathType.caster && _pathType.validators.length === 0 && !_pathType.$parentSchemaDocArray && _pathType.instance !== 'Union') { paths.delete(path); } else if (_pathType.$isMongooseArray && !_pathType.$isMongooseDocumentArray && // Skip document arrays... !_pathType.$embeddedSchemaType.$isMongooseArray && // and arrays of arrays _pathType.validators.length === 0 && // and arrays with top-level validators - _pathType.$embeddedSchemaType.validators.length === 0) { + _pathType.$embeddedSchemaType.validators.length === 0 && + _pathType.$embeddedSchemaType.instance !== 'Union') { paths.delete(path); } } diff --git a/lib/schema/union.js b/lib/schema/union.js index b99194d25a..ebde64c400 100644 --- a/lib/schema/union.js +++ b/lib/schema/union.js @@ -8,6 +8,7 @@ const SchemaUnionOptions = require('../options/schemaUnionOptions'); const SchemaType = require('../schemaType'); const firstValueSymbol = Symbol('firstValue'); +const castSchemaTypeSymbol = Symbol('mongoose#castSchemaType'); /*! * ignore @@ -20,11 +21,18 @@ class Union extends SchemaType { throw new Error('Union schema type requires an array of types'); } this.schemaTypes = options.of.map(obj => options.parentSchema.interpretAsType(key, obj, schemaOptions)); + + this.validators.push({ + validator: () => true, + type: 'union' + }); } cast(val, doc, init, prev, options) { let firstValue = firstValueSymbol; + let firstSchemaType = null; 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. @@ -34,16 +42,26 @@ class Union extends SchemaType { try { const casted = this.schemaTypes[i].cast(val, doc, init, prev, options); if (casted === val) { + if (casted != null && typeof casted === 'object' && casted.$__ != null) { + casted.$__[castSchemaTypeSymbol] = this.schemaTypes[i]; + } return casted; } + if (firstValue === firstValueSymbol) { firstValue = casted; + firstSchemaType = this.schemaTypes[i]; } } catch (error) { lastError = error; } } + if (firstValue !== firstValueSymbol) { + // Store which schema type was used for this cast + if (firstValue != null && typeof firstValue === 'object' && firstValue.$__ != null) { + firstValue.$__[castSchemaTypeSymbol] = firstSchemaType; + } return firstValue; } throw lastError; @@ -52,7 +70,9 @@ class Union extends SchemaType { // 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 firstSchemaType = null; 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. @@ -67,16 +87,25 @@ class Union extends SchemaType { castedVal = this.schemaTypes[i].cast(castedVal, doc, init, prev, options); } if (castedVal === val) { + if (castedVal != null && typeof castedVal === 'object' && castedVal.$__ != null) { + castedVal.$__[castSchemaTypeSymbol] = this.schemaTypes[i]; + } return castedVal; } + if (firstValue === firstValueSymbol) { firstValue = castedVal; + firstSchemaType = this.schemaTypes[i]; } } catch (error) { lastError = error; } } + if (firstValue !== firstValueSymbol) { + if (firstValue != null && typeof firstValue === 'object' && firstValue.$__ != null) { + firstValue.$__[castSchemaTypeSymbol] = firstSchemaType; + } return firstValue; } throw lastError; @@ -88,6 +117,132 @@ class Union extends SchemaType { schematype.schemaTypes = this.schemaTypes.map(schemaType => schemaType.clone()); return schematype; } + + /** + * Validates the value against all schema types in the union. + * The value must successfully validate against at least one schema type. + * + * @api private + */ + doValidate(value, fn, scope, options) { + if (options && options.skipSchemaValidators) { + return fn(null); + } + + SchemaType.prototype.doValidate.call(this, value, function(error) { + if (error) { + return fn(error); + } + if (value == null) { + return fn(null); + } + + // Check if we stored which schema type was used during casting + if (value && value.$__ && value.$__[castSchemaTypeSymbol]) { + const schemaType = value.$__[castSchemaTypeSymbol]; + return schemaType.doValidate(value, fn, scope, options); + } + + if (value && value.schema && value.$__) { + const subdocSchema = value.schema; + for (let i = 0; i < this.schemaTypes.length; ++i) { + const schemaType = this.schemaTypes[i]; + if (schemaType.schema && schemaType.schema === subdocSchema) { + return schemaType.doValidate(value, fn, scope, options); + } + } + } + + // 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; + for (let i = 0; i < this.schemaTypes.length; ++i) { + try { + const casted = this.schemaTypes[i].cast(value, scope, false, null, options); + if (casted === value) { + // This schema type accepts the value as-is + matchedSchemaType = this.schemaTypes[i]; + break; + } + if (matchedSchemaType == null) { + // First schema type that successfully casts the value + matchedSchemaType = this.schemaTypes[i]; + } + } catch (error) { + // This schema type can't cast the value, try the next one + } + } + + if (matchedSchemaType) { + return matchedSchemaType.doValidate(value, fn, scope, options); + } + + // If no schema type can cast the value, return an error + return fn(new Error(`Value ${value} does not match any schema type in union`)); + }.bind(this), scope, options); + } + + /** + * Synchronously validates the value against all schema types in the union. + * The value must successfully validate against at least one schema type. + * + * @api private + */ + doValidateSync(value, scope, options) { + if (!options || !options.skipSchemaValidators) { + const schemaTypeError = SchemaType.prototype.doValidateSync.call(this, value, scope); + if (schemaTypeError) { + return schemaTypeError; + } + } + + if (value == null) { + return; + } + + // Check if we stored which schema type was used during casting (for subdocuments) + if (value && value.$__ && value.$__[castSchemaTypeSymbol]) { + const schemaType = value.$__[castSchemaTypeSymbol]; + return schemaType.doValidateSync(value, scope, options); + } + + if (value && value.schema && value.$__) { + const subdocSchema = value.schema; + for (let i = 0; i < this.schemaTypes.length; ++i) { + const schemaType = this.schemaTypes[i]; + if (schemaType.schema && schemaType.schema === subdocSchema) { + return schemaType.doValidateSync(value, scope, options); + } + } + } + + // 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; + for (let i = 0; i < this.schemaTypes.length; ++i) { + try { + const casted = this.schemaTypes[i].cast(value, scope, false, null, options); + if (casted === value) { + // This schema type accepts the value as-is + matchedSchemaType = this.schemaTypes[i]; + break; + } + if (matchedSchemaType == null) { + // First schema type that successfully casts the value + matchedSchemaType = this.schemaTypes[i]; + } + } catch (error) { + // This schema type can't cast the value, try the next one + } + } + + if (matchedSchemaType) { + return matchedSchemaType.doValidateSync(value, scope, options); + } + + // If no schema type can cast the value, return an error + return new Error(`Value ${value} does not match any schema type in union`); + } } /** diff --git a/test/schema.union.validation.test.js b/test/schema.union.validation.test.js new file mode 100644 index 0000000000..fc3a031f9c --- /dev/null +++ b/test/schema.union.validation.test.js @@ -0,0 +1,301 @@ +'use strict'; + +const start = require('./common'); +const util = require('./util'); + +const assert = require('assert'); + +const mongoose = start.mongoose; + +describe('Union validation', 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('should validate required fields in union schemas', async function() { + // Test primitive | subdocument union + const SubSchema = new mongoose.Schema({ + price: { type: Number, required: true }, + title: { type: String } + }); + + const TestSchema = new mongoose.Schema({ + product: { + type: mongoose.Schema.Types.Union, + of: [Number, SubSchema] + } + }); + + const TestModel = db.model('Test', TestSchema); + + // Test 1: Number value should succeed + const doc1 = new TestModel({ + product: 42 + }); + + await doc1.save(); + assert.ok(doc1._id); + assert.strictEqual(doc1.product, 42); + + // Test 2: Valid subdocument (with required price) should succeed + const doc2 = new TestModel({ + product: { + price: 100, + title: 'Valid Product' + } + }); + + await doc2.save(); + assert.ok(doc2._id); + assert.strictEqual(doc2.product.price, 100); + + // Test 3: Invalid subdocument (missing required price) should fail + const doc3 = new TestModel({ + product: { + title: 'Invalid Product' + } + }); + + const err3 = await doc3.save().then(() => null, err => err); + assert.ok(err3, 'Should have validation error'); + assert.ok(err3.errors['product.price']); + }); + + it('should validate required fields in arrays of unions', async function() { + // Test array of primitive | subdocument unions + const SubSchema = new mongoose.Schema({ + price: { type: Number, required: true }, + title: { type: String } + }); + + const TestSchema = new mongoose.Schema({ + products: [{ + type: mongoose.Schema.Types.Union, + of: [Number, SubSchema] + }] + }); + + const TestModel = db.model('TestArray', TestSchema); + + // Test 1: Array with mix of numbers and valid subdocuments should succeed + const doc1 = new TestModel({ + products: [ + 42, + { price: 100, title: 'Product 1' }, + 99 + ] + }); + + await doc1.save(); + assert.ok(doc1._id); + assert.strictEqual(doc1.products[0], 42); + assert.strictEqual(doc1.products[1].price, 100); + assert.strictEqual(doc1.products[2], 99); + + // Test 2: Array with invalid subdocument (missing required price) should fail + const doc2 = new TestModel({ + products: [ + 42, + { title: 'Invalid Product' } + ] + }); + + const err2 = await doc2.save().then(() => null, err => err); + assert.ok(err2, 'Should have validation error'); + assert.ok(err2.errors['products.1.price']); + }); + + it('should validate custom validators in union schemas', async function() { + // Test primitive | subdocument union with custom validators + const SubSchema = new mongoose.Schema({ + price: { + type: Number, + required: true, + validate: { + validator: function(v) { + return v > 0; + }, + message: 'Price must be positive' + } + }, + title: { type: String } + }); + + const TestSchema = new mongoose.Schema({ + product: { + type: mongoose.Schema.Types.Union, + of: [String, SubSchema] + } + }); + + const TestModel = db.model('TestValidator', TestSchema); + + // Test 1: String value should succeed + const doc1 = new TestModel({ + product: 'simple string' + }); + + await doc1.save(); + assert.ok(doc1._id); + assert.strictEqual(doc1.product, 'simple string'); + + // Test 2: Invalid price (negative) should fail + const doc2 = new TestModel({ + product: { + price: -10, + title: 'Invalid Product' + } + }); + + const err2 = await doc2.save().then(() => null, err => err); + assert.ok(err2, 'Should have validation error'); + assert.ok(err2.errors['product.price']); + + // Test 3: Valid subdocument should succeed + const doc3 = new TestModel({ + product: { + price: 100, + title: 'Valid Product' + } + }); + + await doc3.save(); + assert.ok(doc3._id); + }); + + it('should work with validateSync', function() { + // Test primitive | subdocument union with validateSync + const SubSchema = new mongoose.Schema({ + price: { type: Number, required: true }, + title: { type: String } + }); + + const TestSchema = new mongoose.Schema({ + product: { + type: mongoose.Schema.Types.Union, + of: [Number, SubSchema] + } + }); + + const TestModel = db.model('TestSync', TestSchema); + + // Test 1: Number value should pass + const doc1 = new TestModel({ + product: 42 + }); + + const err1 = doc1.validateSync(); + assert.ifError(err1); + + // Test 2: Valid subdocument should pass + const doc2 = new TestModel({ + product: { + price: 100, + title: 'Valid' + } + }); + + const err2 = doc2.validateSync(); + assert.ifError(err2); + + // Test 3: Invalid subdocument (missing required price) should fail + const doc3 = new TestModel({ + product: { + title: 'No price' + } + }); + + const err3 = doc3.validateSync(); + assert.ok(err3, 'Should have validation error'); + assert.ok(err3.errors['product.price']); + }); + + it('should remove arbitrary fields from subdocs on save', async function() { + const SubSchema1 = new mongoose.Schema({ + price: { type: Number, required: true }, + title: { type: String } + }); + + const SubSchema2 = new mongoose.Schema({ + description: { type: String, required: true }, + title: { type: String } + }); + + const TestSchema = new mongoose.Schema({ + product: { + type: mongoose.Schema.Types.Union, + of: [SubSchema1, SubSchema2] + } + }); + + const TestModel = db.model('Test', TestSchema); + + // Save a valid document that includes an arbitrary field. The arbitrary + // field should be stripped according to schema strictness when the + // document is persisted. + const doc = new TestModel({ + product: { + price: 20, + title: 'Product with extra field', + arbitraryNeverSave: true + } + }); + + await doc.save(); + + const found = await TestModel.findById(doc._id).lean().exec(); + assert.ok(found, 'Saved document should be found'); + // The arbitrary field should not be present on the saved subdocument. + assert.strictEqual(found.product.arbitraryNeverSave, undefined); + }); + + it('should validate using the same schema type that was used for casting', async function() { + // This test ensures that casting and validation are tied together. + // If a value casts as one type, it must validate against that same type's validators. + const TestSchema = new mongoose.Schema({ + product: { + type: mongoose.Schema.Types.Union, + of: [{ type: Number, required: true, min: 44 }, String] + } + }); + + const TestModel = db.model('Test', TestSchema); + + // Test 1: value 12 should cast as Number and fail validation (12 < 44) + const doc1 = new TestModel({ + product: 12 + }); + + const err1 = await doc1.save().then(() => null, err => err); + assert.ok(err1, 'Should have validation error for number less than min'); + assert.ok(err1.errors['product']); + + // Test 2: value 50 should cast as Number and pass validation (50 > 44) + const doc2 = new TestModel({ + product: 50 + }); + + await doc2.save(); + assert.ok(doc2._id); + assert.strictEqual(doc2.product, 50); + + // Test 3: string value should cast and validate as String + const doc3 = new TestModel({ + product: 'hello' + }); + + await doc3.save(); + assert.ok(doc3._id); + assert.strictEqual(doc3.product, 'hello'); + }); +});