From 59c420af5049a32c884dd9e87b9edb72a3616104 Mon Sep 17 00:00:00 2001 From: codervipul775 Date: Mon, 10 Nov 2025 23:45:10 +0530 Subject: [PATCH 1/7] fix: add validation support for Union schema types --- lib/document.js | 5 +- lib/schema/union.js | 152 +++++++++++++++ test/schema.union.validation.test.js | 270 +++++++++++++++++++++++++++ 3 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 test/schema.union.validation.test.js diff --git a/lib/document.js b/lib/document.js index 15764c75687..8f21b7770f1 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 b99194d25a0..1d001d6f9c9 100644 --- a/lib/schema/union.js +++ b/lib/schema/union.js @@ -20,11 +20,21 @@ 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 lastError; + let bestMatch = null; + let bestMatchScore = -1; + + const isObject = val != null && typeof val === 'object' && !Array.isArray(val); + // 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. @@ -36,6 +46,18 @@ class Union extends SchemaType { if (casted === val) { return casted; } + + if (isObject && casted != null && typeof casted === 'object') { + const inputKeys = Object.keys(val); + const preservedFields = inputKeys.filter(key => key in casted && key !== '_id').length; + const score = preservedFields; + + if (score > bestMatchScore) { + bestMatchScore = score; + bestMatch = casted; + } + } + if (firstValue === firstValueSymbol) { firstValue = casted; } @@ -43,6 +65,11 @@ class Union extends SchemaType { lastError = error; } } + + if (bestMatch !== null) { + return bestMatch; + } + if (firstValue !== firstValueSymbol) { return firstValue; } @@ -53,6 +80,11 @@ class Union extends SchemaType { applySetters(val, doc, init, prev, options) { let firstValue = firstValueSymbol; let lastError; + let bestMatch = null; + let bestMatchScore = -1; + + const isObject = val != null && typeof val === 'object' && !Array.isArray(val); + // 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. @@ -69,6 +101,19 @@ class Union extends SchemaType { if (castedVal === val) { return castedVal; } + + + if (isObject && castedVal != null && typeof castedVal === 'object') { + const inputKeys = Object.keys(val); + const preservedFields = inputKeys.filter(key => key in castedVal && key !== '_id').length; + const score = preservedFields; + + if (score > bestMatchScore) { + bestMatchScore = score; + bestMatch = castedVal; + } + } + if (firstValue === firstValueSymbol) { firstValue = castedVal; } @@ -76,6 +121,11 @@ class Union extends SchemaType { lastError = error; } } + + if (bestMatch !== null) { + return bestMatch; + } + if (firstValue !== firstValueSymbol) { return firstValue; } @@ -88,6 +138,108 @@ 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); + } + + 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); + } + } + } + const validationErrors = []; + let callbackCalled = false; + let completed = 0; + + for (let i = 0; i < this.schemaTypes.length; ++i) { + const schemaType = this.schemaTypes[i]; + + schemaType.doValidate(value, (err) => { + if (callbackCalled) { + return; + } + + completed++; + + if (!err) { + callbackCalled = true; + return fn(null); + } + + validationErrors.push(err); + + if (completed === this.schemaTypes.length) { + callbackCalled = true; + return fn(validationErrors[0]); + } + }, scope, options); + } + }.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; + } + + 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); + } + } + } + + const validationErrors = []; + + for (let i = 0; i < this.schemaTypes.length; ++i) { + const schemaType = this.schemaTypes[i]; + + const err = schemaType.doValidateSync(value, scope, options); + if (!err) { + return null; + } + + validationErrors.push(err); + } + + return validationErrors[0]; + } } /** diff --git a/test/schema.union.validation.test.js b/test/schema.union.validation.test.js new file mode 100644 index 00000000000..6247897a3c8 --- /dev/null +++ b/test/schema.union.validation.test.js @@ -0,0 +1,270 @@ +'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() { + const SubSchema1 = new mongoose.Schema({ + price: { type: Number, required: true }, + title: { type: String }, + isThisSchema1: { type: Boolean } + }); + + const SubSchema2 = new mongoose.Schema({ + description: { type: String, required: true }, + title: { type: String }, + isThisSchema2: { type: Boolean } + }); + + const TestSchema = new mongoose.Schema({ + product: { + type: mongoose.Schema.Types.Union, + of: [SubSchema1, SubSchema2] + } + }); + + const TestModel = db.model('Test', TestSchema); + + // Test 1: Missing required fields for both schemas should fail + const doc1 = new TestModel({ + product: { + title: 'string', + isThisSchema1: true, + isThisSchema2: true + } + }); + + const err1 = await doc1.save().then(() => null, err => err); + assert.ok(err1, 'Should have validation error'); + assert.ok(err1.errors['product.price'] || err1.errors['product.description']); + + // Test 2: Valid SubSchema1 (with price) should succeed + const doc2 = new TestModel({ + product: { + price: 100, + title: 'Valid Product', + isThisSchema1: true + } + }); + + await doc2.save(); + assert.ok(doc2._id); + assert.strictEqual(doc2.product.price, 100); + + // Test 3: Valid SubSchema2 (with description) should succeed + const doc3 = new TestModel({ + product: { + description: 'A description', + title: 'Valid Product 2', + isThisSchema2: true + } + }); + + await doc3.save(); + assert.ok(doc3._id); + assert.strictEqual(doc3.product.description, 'A description'); + }); + + it('should validate required fields in arrays of unions', 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({ + products: [{ + type: mongoose.Schema.Types.Union, + of: [SubSchema1, SubSchema2] + }] + }); + + const TestModel = db.model('Test', TestSchema); + + // Test with missing required fields + const doc1 = new TestModel({ + products: [ + { title: 'Missing both price and description' } + ] + }); + + const err1 = await doc1.save().then(() => null, err => err); + assert.ok(err1, 'Should have validation error'); + assert.ok(err1.errors['products.0.price'] || err1.errors['products.0.description']); + + // Test with valid data + const doc2 = new TestModel({ + products: [ + { price: 50, title: 'Product 1' }, + { description: 'Product 2 desc', title: 'Product 2' } + ] + }); + + await doc2.save(); + assert.ok(doc2._id); + assert.strictEqual(doc2.products[0].price, 50); + assert.strictEqual(doc2.products[1].description, 'Product 2 desc'); + }); + + it('should validate custom validators in union schemas', async function() { + const SubSchema1 = new mongoose.Schema({ + price: { + type: Number, + required: true, + validate: { + validator: function(v) { + return v > 0; + }, + message: 'Price must be positive' + } + } + }); + + const SubSchema2 = new mongoose.Schema({ + description: { + type: String, + required: true, + minlength: 5 + } + }); + + const TestSchema = new mongoose.Schema({ + product: { + type: mongoose.Schema.Types.Union, + of: [SubSchema1, SubSchema2] + } + }); + + const TestModel = db.model('Test', TestSchema); + + // Test with invalid price + const doc1 = new TestModel({ + product: { + price: -10 + } + }); + + const err1 = await doc1.save().then(() => null, err => err); + assert.ok(err1, 'Should have validation error'); + assert.ok(err1.errors['product'] || err1.errors['product.price']); + + // Test with invalid description length + const doc2 = new TestModel({ + product: { + description: 'abc' + } + }); + + const err2 = await doc2.save().then(() => null, err => err); + assert.ok(err2, 'Should have validation error'); + assert.ok(err2.errors['product'] || err2.errors['product.description']); + + // Test with valid data + const doc3 = new TestModel({ + product: { + price: 100 + } + }); + + await doc3.save(); + assert.ok(doc3._id); + }); + + it('should work with validateSync', function() { + const SubSchema1 = new mongoose.Schema({ + price: { type: Number, required: true } + }); + + const SubSchema2 = new mongoose.Schema({ + description: { type: String, required: true } + }); + + const TestSchema = new mongoose.Schema({ + product: { + type: mongoose.Schema.Types.Union, + of: [SubSchema1, SubSchema2] + } + }); + + const TestModel = db.model('Test', TestSchema); + + // Test with missing required fields + const doc1 = new TestModel({ + product: { + title: 'No price or description' + } + }); + + const err1 = doc1.validateSync(); + assert.ok(err1, 'Should have validation error'); + assert.ok(err1.errors['product.price'] || err1.errors['product.description']); + + // Test with valid data + const doc2 = new TestModel({ + product: { + price: 100 + } + }); + + const err2 = doc2.validateSync(); + assert.ifError(err2); + }); + + it('should not skip validation for arbitrary fields', 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); + + // Arbitrary fields should still be filtered out, but validation should still fail + const doc = new TestModel({ + product: { + title: 'string', + arbitraryNeverSave: true, + isThisSchema1: true, + isThisSchema2: true + } + }); + + const err = await doc.save().then(() => null, err => err); + assert.ok(err, 'Should have validation error even with arbitrary fields'); + assert.ok(err.errors['product.price'] || err.errors['product.description']); + }); +}); From 1116f3b9e0987bac0deada543bdf838bbf9a14a0 Mon Sep 17 00:00:00 2001 From: Vipul Date: Tue, 11 Nov 2025 07:28:26 +0530 Subject: [PATCH 2/7] Fixed the extra spaces --- lib/schema/union.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/schema/union.js b/lib/schema/union.js index 1d001d6f9c9..2ed93b2b2a1 100644 --- a/lib/schema/union.js +++ b/lib/schema/union.js @@ -102,7 +102,6 @@ class Union extends SchemaType { return castedVal; } - if (isObject && castedVal != null && typeof castedVal === 'object') { const inputKeys = Object.keys(val); const preservedFields = inputKeys.filter(key => key in castedVal && key !== '_id').length; From 5d2f0529ab7262df4c3e73fc60a79af5a99bbcad Mon Sep 17 00:00:00 2001 From: Vipul Date: Tue, 11 Nov 2025 21:51:32 +0530 Subject: [PATCH 3/7] Refactor validation test for arbitrary fields Updated the test to reflect the removal of arbitrary fields from subdocuments on save, ensuring validation is correctly applied. --- test/schema.union.validation.test.js | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/test/schema.union.validation.test.js b/test/schema.union.validation.test.js index 6247897a3c8..701fa5bde3b 100644 --- a/test/schema.union.validation.test.js +++ b/test/schema.union.validation.test.js @@ -233,7 +233,7 @@ describe('Union validation', function() { assert.ifError(err2); }); - it('should not skip validation for arbitrary fields', async function() { + it('should remove arbitrary fields from subdocs on save', async function() { const SubSchema1 = new mongoose.Schema({ price: { type: Number, required: true }, title: { type: String } @@ -253,18 +253,22 @@ describe('Union validation', function() { const TestModel = db.model('Test', TestSchema); - // Arbitrary fields should still be filtered out, but validation should still fail + // 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: { - title: 'string', - arbitraryNeverSave: true, - isThisSchema1: true, - isThisSchema2: true + price: 20, + title: 'Product with extra field', + arbitraryNeverSave: true } }); - const err = await doc.save().then(() => null, err => err); - assert.ok(err, 'Should have validation error even with arbitrary fields'); - assert.ok(err.errors['product.price'] || err.errors['product.description']); + 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); }); }); From 974c1ef2468a7627dab6db40cae499c671a5bf01 Mon Sep 17 00:00:00 2001 From: Vipul Date: Fri, 14 Nov 2025 07:34:03 +0530 Subject: [PATCH 4/7] Add tests for Union schema type validation Added tests to validate casting and validation of Union schema type. --- test/schema.union.validation.test.js | 40 ++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/schema.union.validation.test.js b/test/schema.union.validation.test.js index 701fa5bde3b..6dc96ecdec4 100644 --- a/test/schema.union.validation.test.js +++ b/test/schema.union.validation.test.js @@ -271,4 +271,44 @@ describe('Union validation', function() { // 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'); + }); }); From 5343cdc1f7db6ce646b7a2d8e5784ccfdb407c2b Mon Sep 17 00:00:00 2001 From: Vipul Date: Fri, 14 Nov 2025 07:34:46 +0530 Subject: [PATCH 5/7] Add castSchemaTypeSymbol for schema type tracking --- lib/schema/union.js | 74 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/lib/schema/union.js b/lib/schema/union.js index 2ed93b2b2a1..93dcaabd7ac 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 @@ -29,9 +30,11 @@ class Union extends SchemaType { cast(val, doc, init, prev, options) { let firstValue = firstValueSymbol; + let firstSchemaType = null; let lastError; let bestMatch = null; let bestMatchScore = -1; + let bestMatchSchemaType = null; const isObject = val != null && typeof val === 'object' && !Array.isArray(val); @@ -44,6 +47,9 @@ 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; } @@ -55,11 +61,13 @@ class Union extends SchemaType { if (score > bestMatchScore) { bestMatchScore = score; bestMatch = casted; + bestMatchSchemaType = this.schemaTypes[i]; } } if (firstValue === firstValueSymbol) { firstValue = casted; + firstSchemaType = this.schemaTypes[i]; } } catch (error) { lastError = error; @@ -67,10 +75,16 @@ class Union extends SchemaType { } if (bestMatch !== null) { + if (bestMatch != null && typeof bestMatch === 'object' && bestMatch.$__ != null) { + bestMatch.$__[castSchemaTypeSymbol] = bestMatchSchemaType; + } return bestMatch; } if (firstValue !== firstValueSymbol) { + if (firstValue != null && typeof firstValue === 'object' && firstValue.$__ != null) { + firstValue.$__[castSchemaTypeSymbol] = firstSchemaType; + } return firstValue; } throw lastError; @@ -79,9 +93,11 @@ 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; let bestMatch = null; let bestMatchScore = -1; + let bestMatchSchemaType = null; const isObject = val != null && typeof val === 'object' && !Array.isArray(val); @@ -99,6 +115,9 @@ 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; } @@ -110,11 +129,13 @@ class Union extends SchemaType { if (score > bestMatchScore) { bestMatchScore = score; bestMatch = castedVal; + bestMatchSchemaType = this.schemaTypes[i]; } } if (firstValue === firstValueSymbol) { firstValue = castedVal; + firstSchemaType = this.schemaTypes[i]; } } catch (error) { lastError = error; @@ -122,10 +143,16 @@ class Union extends SchemaType { } if (bestMatch !== null) { + if (bestMatch != null && typeof bestMatch === 'object' && bestMatch.$__ != null) { + bestMatch.$__[castSchemaTypeSymbol] = bestMatchSchemaType; + } return bestMatch; } if (firstValue !== firstValueSymbol) { + if (firstValue != null && typeof firstValue === 'object' && firstValue.$__ != null) { + firstValue.$__[castSchemaTypeSymbol] = firstSchemaType; + } return firstValue; } throw lastError; @@ -157,6 +184,12 @@ class Union extends SchemaType { 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) { @@ -166,6 +199,24 @@ class Union extends SchemaType { } } } + + // For non-subdoc values, try to cast with each schema type to determine which one to validate with + let schemaTypeToValidate = null; + for (let i = 0; i < this.schemaTypes.length; ++i) { + try { + this.schemaTypes[i].cast(value); + schemaTypeToValidate = this.schemaTypes[i]; + break; + } catch (err) { + // Continue trying other schema types + } + } + + if (schemaTypeToValidate) { + return schemaTypeToValidate.doValidate(value, fn, scope, options); + } + + // Fallback: try all and return first success const validationErrors = []; let callbackCalled = false; let completed = 0; @@ -214,6 +265,12 @@ class Union extends SchemaType { return; } + // Check if we stored which schema type was used during casting + 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) { @@ -224,6 +281,23 @@ class Union extends SchemaType { } } + // For non-subdoc values, try to cast with each schema type to determine which one to validate with + let schemaTypeToValidate = null; + for (let i = 0; i < this.schemaTypes.length; ++i) { + try { + this.schemaTypes[i].cast(value); + schemaTypeToValidate = this.schemaTypes[i]; + break; + } catch (err) { + // Continue trying other schema types + } + } + + if (schemaTypeToValidate) { + return schemaTypeToValidate.doValidateSync(value, scope, options); + } + + // Fallback: try all and return first success const validationErrors = []; for (let i = 0; i < this.schemaTypes.length; ++i) { From d0bfe633bc81bd15193ab6c19a1d6f7824f9af64 Mon Sep 17 00:00:00 2001 From: Vipul Date: Sat, 15 Nov 2025 13:39:28 +0530 Subject: [PATCH 6/7] Refactor casting logic in union schema --- lib/schema/union.js | 150 ++++++++++++-------------------------------- 1 file changed, 40 insertions(+), 110 deletions(-) diff --git a/lib/schema/union.js b/lib/schema/union.js index 93dcaabd7ac..ebde64c400b 100644 --- a/lib/schema/union.js +++ b/lib/schema/union.js @@ -32,11 +32,6 @@ class Union extends SchemaType { let firstValue = firstValueSymbol; let firstSchemaType = null; let lastError; - let bestMatch = null; - let bestMatchScore = -1; - let bestMatchSchemaType = null; - - const isObject = val != null && typeof val === 'object' && !Array.isArray(val); // 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. @@ -53,18 +48,6 @@ class Union extends SchemaType { return casted; } - if (isObject && casted != null && typeof casted === 'object') { - const inputKeys = Object.keys(val); - const preservedFields = inputKeys.filter(key => key in casted && key !== '_id').length; - const score = preservedFields; - - if (score > bestMatchScore) { - bestMatchScore = score; - bestMatch = casted; - bestMatchSchemaType = this.schemaTypes[i]; - } - } - if (firstValue === firstValueSymbol) { firstValue = casted; firstSchemaType = this.schemaTypes[i]; @@ -74,14 +57,8 @@ class Union extends SchemaType { } } - if (bestMatch !== null) { - if (bestMatch != null && typeof bestMatch === 'object' && bestMatch.$__ != null) { - bestMatch.$__[castSchemaTypeSymbol] = bestMatchSchemaType; - } - return bestMatch; - } - if (firstValue !== firstValueSymbol) { + // Store which schema type was used for this cast if (firstValue != null && typeof firstValue === 'object' && firstValue.$__ != null) { firstValue.$__[castSchemaTypeSymbol] = firstSchemaType; } @@ -95,11 +72,6 @@ class Union extends SchemaType { let firstValue = firstValueSymbol; let firstSchemaType = null; let lastError; - let bestMatch = null; - let bestMatchScore = -1; - let bestMatchSchemaType = null; - - const isObject = val != null && typeof val === 'object' && !Array.isArray(val); // 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. @@ -121,18 +93,6 @@ class Union extends SchemaType { return castedVal; } - if (isObject && castedVal != null && typeof castedVal === 'object') { - const inputKeys = Object.keys(val); - const preservedFields = inputKeys.filter(key => key in castedVal && key !== '_id').length; - const score = preservedFields; - - if (score > bestMatchScore) { - bestMatchScore = score; - bestMatch = castedVal; - bestMatchSchemaType = this.schemaTypes[i]; - } - } - if (firstValue === firstValueSymbol) { firstValue = castedVal; firstSchemaType = this.schemaTypes[i]; @@ -142,13 +102,6 @@ class Union extends SchemaType { } } - if (bestMatch !== null) { - if (bestMatch != null && typeof bestMatch === 'object' && bestMatch.$__ != null) { - bestMatch.$__[castSchemaTypeSymbol] = bestMatchSchemaType; - } - return bestMatch; - } - if (firstValue !== firstValueSymbol) { if (firstValue != null && typeof firstValue === 'object' && firstValue.$__ != null) { firstValue.$__[castSchemaTypeSymbol] = firstSchemaType; @@ -200,50 +153,32 @@ class Union extends SchemaType { } } - // For non-subdoc values, try to cast with each schema type to determine which one to validate with - let schemaTypeToValidate = null; + // 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 { - this.schemaTypes[i].cast(value); - schemaTypeToValidate = this.schemaTypes[i]; - break; - } catch (err) { - // Continue trying other schema types + 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 (schemaTypeToValidate) { - return schemaTypeToValidate.doValidate(value, fn, scope, options); + if (matchedSchemaType) { + return matchedSchemaType.doValidate(value, fn, scope, options); } - // Fallback: try all and return first success - const validationErrors = []; - let callbackCalled = false; - let completed = 0; - - for (let i = 0; i < this.schemaTypes.length; ++i) { - const schemaType = this.schemaTypes[i]; - - schemaType.doValidate(value, (err) => { - if (callbackCalled) { - return; - } - - completed++; - - if (!err) { - callbackCalled = true; - return fn(null); - } - - validationErrors.push(err); - - if (completed === this.schemaTypes.length) { - callbackCalled = true; - return fn(validationErrors[0]); - } - }, 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); } @@ -265,7 +200,7 @@ class Union extends SchemaType { return; } - // Check if we stored which schema type was used during casting + // 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); @@ -281,37 +216,32 @@ class Union extends SchemaType { } } - // For non-subdoc values, try to cast with each schema type to determine which one to validate with - let schemaTypeToValidate = null; + // 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 { - this.schemaTypes[i].cast(value); - schemaTypeToValidate = this.schemaTypes[i]; - break; - } catch (err) { - // Continue trying other schema types + 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 (schemaTypeToValidate) { - return schemaTypeToValidate.doValidateSync(value, scope, options); - } - - // Fallback: try all and return first success - const validationErrors = []; - - for (let i = 0; i < this.schemaTypes.length; ++i) { - const schemaType = this.schemaTypes[i]; - - const err = schemaType.doValidateSync(value, scope, options); - if (!err) { - return null; - } - - validationErrors.push(err); + if (matchedSchemaType) { + return matchedSchemaType.doValidateSync(value, scope, options); } - return validationErrors[0]; + // If no schema type can cast the value, return an error + return new Error(`Value ${value} does not match any schema type in union`); } } From df32490412e05002e4b161b82ee1f6627ae00fa9 Mon Sep 17 00:00:00 2001 From: Vipul Date: Sat, 15 Nov 2025 13:40:44 +0530 Subject: [PATCH 7/7] Refactor union schema validation tests 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. --- test/schema.union.validation.test.js | 165 ++++++++++++--------------- 1 file changed, 76 insertions(+), 89 deletions(-) diff --git a/test/schema.union.validation.test.js b/test/schema.union.validation.test.js index 6dc96ecdec4..fc3a031f9c6 100644 --- a/test/schema.union.validation.test.js +++ b/test/schema.union.validation.test.js @@ -23,46 +23,35 @@ describe('Union validation', function() { afterEach(() => util.stopRemainingOps(db)); it('should validate required fields in union schemas', async function() { - const SubSchema1 = new mongoose.Schema({ + // Test primitive | subdocument union + const SubSchema = new mongoose.Schema({ price: { type: Number, required: true }, - title: { type: String }, - isThisSchema1: { type: Boolean } - }); - - const SubSchema2 = new mongoose.Schema({ - description: { type: String, required: true }, - title: { type: String }, - isThisSchema2: { type: Boolean } + title: { type: String } }); const TestSchema = new mongoose.Schema({ product: { type: mongoose.Schema.Types.Union, - of: [SubSchema1, SubSchema2] + of: [Number, SubSchema] } }); const TestModel = db.model('Test', TestSchema); - // Test 1: Missing required fields for both schemas should fail + // Test 1: Number value should succeed const doc1 = new TestModel({ - product: { - title: 'string', - isThisSchema1: true, - isThisSchema2: true - } + product: 42 }); - const err1 = await doc1.save().then(() => null, err => err); - assert.ok(err1, 'Should have validation error'); - assert.ok(err1.errors['product.price'] || err1.errors['product.description']); + await doc1.save(); + assert.ok(doc1._id); + assert.strictEqual(doc1.product, 42); - // Test 2: Valid SubSchema1 (with price) should succeed + // Test 2: Valid subdocument (with required price) should succeed const doc2 = new TestModel({ product: { price: 100, - title: 'Valid Product', - isThisSchema1: true + title: 'Valid Product' } }); @@ -70,67 +59,65 @@ describe('Union validation', function() { assert.ok(doc2._id); assert.strictEqual(doc2.product.price, 100); - // Test 3: Valid SubSchema2 (with description) should succeed + // Test 3: Invalid subdocument (missing required price) should fail const doc3 = new TestModel({ product: { - description: 'A description', - title: 'Valid Product 2', - isThisSchema2: true + title: 'Invalid Product' } }); - await doc3.save(); - assert.ok(doc3._id); - assert.strictEqual(doc3.product.description, 'A description'); + 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() { - const SubSchema1 = new mongoose.Schema({ + // Test array of primitive | subdocument unions + const SubSchema = 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({ products: [{ type: mongoose.Schema.Types.Union, - of: [SubSchema1, SubSchema2] + of: [Number, SubSchema] }] }); - const TestModel = db.model('Test', TestSchema); + const TestModel = db.model('TestArray', TestSchema); - // Test with missing required fields + // Test 1: Array with mix of numbers and valid subdocuments should succeed const doc1 = new TestModel({ products: [ - { title: 'Missing both price and description' } + 42, + { price: 100, title: 'Product 1' }, + 99 ] }); - const err1 = await doc1.save().then(() => null, err => err); - assert.ok(err1, 'Should have validation error'); - assert.ok(err1.errors['products.0.price'] || err1.errors['products.0.description']); + 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 with valid data + // Test 2: Array with invalid subdocument (missing required price) should fail const doc2 = new TestModel({ products: [ - { price: 50, title: 'Product 1' }, - { description: 'Product 2 desc', title: 'Product 2' } + 42, + { title: 'Invalid Product' } ] }); - await doc2.save(); - assert.ok(doc2._id); - assert.strictEqual(doc2.products[0].price, 50); - assert.strictEqual(doc2.products[1].description, 'Product 2 desc'); + 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() { - const SubSchema1 = new mongoose.Schema({ + // Test primitive | subdocument union with custom validators + const SubSchema = new mongoose.Schema({ price: { type: Number, required: true, @@ -140,52 +127,45 @@ describe('Union validation', function() { }, message: 'Price must be positive' } - } - }); - - const SubSchema2 = new mongoose.Schema({ - description: { - type: String, - required: true, - minlength: 5 - } + }, + title: { type: String } }); const TestSchema = new mongoose.Schema({ product: { type: mongoose.Schema.Types.Union, - of: [SubSchema1, SubSchema2] + of: [String, SubSchema] } }); - const TestModel = db.model('Test', TestSchema); + const TestModel = db.model('TestValidator', TestSchema); - // Test with invalid price + // Test 1: String value should succeed const doc1 = new TestModel({ - product: { - price: -10 - } + product: 'simple string' }); - const err1 = await doc1.save().then(() => null, err => err); - assert.ok(err1, 'Should have validation error'); - assert.ok(err1.errors['product'] || err1.errors['product.price']); + await doc1.save(); + assert.ok(doc1._id); + assert.strictEqual(doc1.product, 'simple string'); - // Test with invalid description length + // Test 2: Invalid price (negative) should fail const doc2 = new TestModel({ product: { - description: 'abc' + 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'] || err2.errors['product.description']); + assert.ok(err2.errors['product.price']); - // Test with valid data + // Test 3: Valid subdocument should succeed const doc3 = new TestModel({ product: { - price: 100 + price: 100, + title: 'Valid Product' } }); @@ -194,43 +174,50 @@ describe('Union validation', function() { }); it('should work with validateSync', function() { - const SubSchema1 = new mongoose.Schema({ - price: { type: Number, required: true } - }); - - const SubSchema2 = new mongoose.Schema({ - description: { type: String, required: true } + // 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: [SubSchema1, SubSchema2] + of: [Number, SubSchema] } }); - const TestModel = db.model('Test', TestSchema); + const TestModel = db.model('TestSync', TestSchema); - // Test with missing required fields + // Test 1: Number value should pass const doc1 = new TestModel({ - product: { - title: 'No price or description' - } + product: 42 }); const err1 = doc1.validateSync(); - assert.ok(err1, 'Should have validation error'); - assert.ok(err1.errors['product.price'] || err1.errors['product.description']); + assert.ifError(err1); - // Test with valid data + // Test 2: Valid subdocument should pass const doc2 = new TestModel({ product: { - price: 100 + 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() {