Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
155 changes: 155 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,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({
Copy link
Collaborator

Choose a reason for hiding this comment

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

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.

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.
Expand All @@ -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;
Expand All @@ -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.
Expand All @@ -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;
Expand All @@ -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]) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

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.

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;
Copy link
Collaborator

Choose a reason for hiding this comment

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

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.

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

/**
Expand Down
Loading