Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
5 changes: 3 additions & 2 deletions lib/document.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
225 changes: 225 additions & 0 deletions lib/schema/union.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const SchemaUnionOptions = require('../options/schemaUnionOptions');
const SchemaType = require('../schemaType');

const firstValueSymbol = Symbol('firstValue');
const castSchemaTypeSymbol = Symbol('mongoose#castSchemaType');

/*!
* ignore
Expand All @@ -20,11 +21,23 @@ 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;
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.
// Finally, if none of the schematypes casted successfully, throw the error from the last schema type in the union.
Expand All @@ -34,16 +47,44 @@ 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 (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];
}
} catch (error) {
lastError = error;
}
}

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;
Expand All @@ -52,7 +93,14 @@ 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);

// 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.
Expand All @@ -67,16 +115,44 @@ 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 (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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

bestMatchScore = score;
bestMatch = castedVal;
bestMatchSchemaType = this.schemaTypes[i];
}
}

if (firstValue === firstValueSymbol) {
firstValue = castedVal;
firstSchemaType = this.schemaTypes[i];
}
} catch (error) {
lastError = error;
}
}

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;
Expand All @@ -88,6 +164,155 @@ 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 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);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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;

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;
}

// 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) {
const schemaType = this.schemaTypes[i];
if (schemaType.schema && schemaType.schema === subdocSchema) {
return schemaType.doValidateSync(value, scope, options);
}
}
}

// 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) {
const schemaType = this.schemaTypes[i];

const err = schemaType.doValidateSync(value, scope, options);
if (!err) {
return null;
}

validationErrors.push(err);
}

return validationErrors[0];
}
}

/**
Expand Down
Loading