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
1 change: 1 addition & 0 deletions lib/error/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ msg.DocumentNotFoundError = null;
msg.general = {};
msg.general.default = 'Validator failed for path `{PATH}` with value `{VALUE}`';
msg.general.required = 'Path `{PATH}` is required.';
msg.general.allowNull = 'Path `{PATH}` does not allow null values.';

msg.Number = {};
msg.Number.min = 'Path `{PATH}` ({VALUE}) is less than minimum allowed value ({MIN}).';
Expand Down
14 changes: 14 additions & 0 deletions lib/options/schemaTypeOptions.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,20 @@ Object.defineProperty(SchemaTypeOptions.prototype, 'cast', opts);

Object.defineProperty(SchemaTypeOptions.prototype, 'required', opts);

/**
* Controls whether this path may be set to `null`. By default, Mongoose allows
* `null` for non-required paths. Set `allowNull: false` to allow `undefined`
* but disallow `null`.
*
* @api public
* @property allowNull
* @memberOf SchemaTypeOptions
* @type {boolean}
* @instance
*/

Object.defineProperty(SchemaTypeOptions.prototype, 'allowNull', opts);

/**
* The default value for this path. If a function, Mongoose executes the function
* and uses the return value as the default.
Expand Down
61 changes: 57 additions & 4 deletions lib/schemaType.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,15 +200,16 @@ SchemaType.prototype._createJSONSchemaTypeDefinition = function _createJSONSchem
(this.options.required == null ?
(options?._defaultRequired === true || this.path === '_id') :
this.options.required && typeof this.options.required !== 'function');
const allowNull = this.options.allowNull !== false;

if (useBsonType) {
if (isRequired) {
if (isRequired || !allowNull) {
return { bsonType };
}
return { bsonType: [bsonType, 'null'] };
}

if (isRequired) {
if (isRequired || !allowNull) {
return { type };
}
return { type: [type, 'null'] };
Expand Down Expand Up @@ -1142,6 +1143,12 @@ SchemaType.prototype.required = function(required, message) {

const _this = this;
this.isRequired = true;
this.originalRequiredValue = required;

if (typeof this.originalRequiredValue !== 'function' &&
(utils.hasUserDefinedProperty(this.options, 'allowNull') || this.allowNullValidator != null)) {
throw new MongooseError('Path "' + this.path + '" may not have `allowNull` specified when `required` is true');
}

this.requiredValidator = function(v) {
const cachedRequired = this?.$__?.cachedRequired;
Expand All @@ -1166,8 +1173,6 @@ SchemaType.prototype.required = function(required, message) {

return _this.checkRequired(v, this);
};
this.originalRequiredValue = required;

if (typeof required === 'string') {
message = required;
required = undefined;
Expand All @@ -1183,6 +1188,53 @@ SchemaType.prototype.required = function(required, message) {
return this;
};

/**
* Adds a validator that disallows `null` for this path without making the path
* required. `undefined` values still pass validation.
*
* #### Example:
*
* const schema = new Schema({
* name: { type: String, allowNull: false }
* });
*
* new Model({ name: undefined }).validateSync(); // OK
* new Model({ name: null }).validateSync(); // ValidationError
*
* @param {boolean} allowNull
* @return {SchemaType} this
* @api public
*/

SchemaType.prototype.allowNull = function(allowNull) {
if (arguments.length > 0 && this.isRequired && typeof this.originalRequiredValue !== 'function') {
throw new MongooseError('Path "' + this.path + '" may not have `allowNull` specified when `required` is true');
}

this.validators = this.validators.filter(function(v) {
return v.validator !== this.allowNullValidator;
}, this);

if (allowNull !== false) {
delete this.options.allowNull;
delete this.allowNullValidator;
return this;
}

this.options.allowNull = false;
this.allowNullValidator = function(v) {
return v !== null;
};

this.validators.push({
validator: this.allowNullValidator,
message: MongooseError.messages.general.allowNull,
type: 'allowNull'
});

return this;
Comment thread
vkarpov15 marked this conversation as resolved.
};

/**
* Set the model that this path refers to. This is the option that [populate](https://mongoosejs.com/docs/populate.html)
* looks at to determine the foreign collection it should query.
Expand Down Expand Up @@ -1802,6 +1854,7 @@ SchemaType.prototype.clone = function() {
const schematype = new this.constructor(this.path, options, this.instance, this.parentSchema);
schematype.validators = this.validators.slice();
if (this.requiredValidator !== undefined) schematype.requiredValidator = this.requiredValidator;
if (this.allowNullValidator !== undefined) schematype.allowNullValidator = this.allowNullValidator;
if (this.defaultValue !== undefined) schematype.defaultValue = this.defaultValue;
if (this.$immutable !== undefined && this.options.immutable === undefined) {
schematype.$immutable = this.$immutable;
Expand Down
38 changes: 38 additions & 0 deletions test/model.discriminator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,44 @@ describe('model', function() {
assert.equal(gender.options.default, 'F');
});

it('allows discriminator schema to override required true with required false and allowNull false', function() {
const baseSchema = new Schema({
name: { type: String, required: true }
});
const Base = db.model('Test1', baseSchema);
const Child = Base.discriminator('Child', new Schema({
name: { type: String, required: false, allowNull: false }
}));

assert.equal(Base.schema.path('name').isRequired, true);
assert.equal(Child.schema.path('name').isRequired, false);
assert.equal(Child.schema.path('name').validators.length, 1);
assert.equal(Child.schema.path('name').validators[0].type, 'allowNull');

assert.ifError(new Child({}).validateSync());

const err = new Child({ name: null }).validateSync();
assert.ok(err);
assert.ok(err.errors['name']);
assert.equal(err.errors['name'].kind, 'allowNull');
});

it('allows discriminator schema to override allowNull false with allowNull true', function() {
const baseSchema = new Schema({
name: { type: String, allowNull: false }
});
const Base = db.model('Test2', baseSchema);
const Child = Base.discriminator('Child', new Schema({
name: { type: String, allowNull: true }
}));

assert.equal(Base.schema.path('name').validators.length, 1);
assert.equal(Base.schema.path('name').validators[0].type, 'allowNull');
assert.equal(Child.schema.path('name').validators.length, 0);

assert.ifError(new Child({ name: null }).validateSync());
});

it('inherits methods', function() {
const employee = new Employee();
assert.strictEqual(employee.getFullName, PersonSchema.methods.getFullName);
Expand Down
26 changes: 26 additions & 0 deletions test/model.findOneAndUpdate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1110,6 +1110,32 @@ describe('model: findOneAndUpdate:', function() {
assert.equal(error.errors.topping.message, 'Validator failed for path `topping` with value `bacon`');
});

it('applies allowNull validators with runValidators', async function() {
const schema = new Schema({
name: { type: String, allowNull: false }
});
const Model = db.model('Test', schema);
const doc = await Model.create({ name: 'test' });
const updateOptions = { runValidators: true, new: true };

let err = await Model.findOneAndUpdate(
{ _id: doc._id },
{ $set: { name: null } },
updateOptions
).then(() => null, err => err);

assert.ok(err);
assert.ok(err.errors['name']);
assert.equal(err.errors['name'].kind, 'allowNull');

err = await Model.findOneAndUpdate(
{ _id: doc._id },
{ $set: { name: undefined } },
updateOptions
).then(() => null, err => err);
assert.equal(err, null);
});

it('validators handle $unset and $setOnInsert', async function() {
const s = new Schema({
steak: { type: String, required: true },
Expand Down
25 changes: 25 additions & 0 deletions test/model.updateOne.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3547,6 +3547,31 @@ describe('model: updateOne: ', function() {
assert.equal(validateNameCalls, 1);
});

it('applies allowNull validators with updateOne and runValidators', async function() {
const schema = new Schema({
name: { type: String, allowNull: false }
});
const Model = db.model('Test', schema);
const doc = await Model.create({ name: 'test' });

let err = await Model.updateOne(
{ _id: doc._id },
{ $set: { name: null } },
{ runValidators: true }
).then(() => null, err => err);

assert.ok(err);
assert.ok(err.errors['name']);
assert.equal(err.errors['name'].kind, 'allowNull');

err = await Model.updateOne(
{ _id: doc._id },
{ $set: { name: undefined } },
{ runValidators: true }
).then(() => null, err => err);
assert.equal(err, null);
});

it('casts top level $each (gh-15642)', async function() {
const schema = new Schema({ tags: [String] });
const Model = db.model('Test', schema);
Expand Down
104 changes: 104 additions & 0 deletions test/schema.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1956,6 +1956,72 @@ describe('schema', function() {
assert.equal(schema.path('name').validators.length, 0);
});

it('clones allowNull validators', function() {
const schema = new Schema({ name: { type: String, allowNull: false } });
const otherSchema = schema.clone();
const Model = db.model('Test', otherSchema);

assert.equal(otherSchema.path('name').validators.length, 1);
assert.equal(otherSchema.path('name').validators[0].type, 'allowNull');

const doc = new Model({ name: null });
const err = doc.validateSync();

assert.ok(err);
assert.ok(err.errors['name']);
assert.equal(err.errors['name'].kind, 'allowNull');
});

it('clones allowNull validators so they can be removed independently', function() {
const schema = new Schema({ name: { type: String, allowNull: false } });
const otherSchema = schema.clone();

otherSchema.path('name').allowNull(true);

assert.equal(otherSchema.path('name').validators.length, 0);
assert.equal(schema.path('name').validators.length, 1);
assert.equal(schema.path('name').validators[0].type, 'allowNull');
});

it('keeps allowNull options in sync when changed at runtime', function() {
const schema = new Schema({ name: String });
const schemaType = schema.path('name');

schemaType.allowNull(false);
assert.equal(schemaType.options.allowNull, false);
assert.deepStrictEqual(schema.toJSONSchema(), {
type: 'object',
required: ['_id'],
properties: {
_id: {
type: 'string'
},
name: {
type: 'string'
}
}
});

const otherSchema = schema.clone();
assert.equal(otherSchema.path('name').options.allowNull, false);
assert.deepStrictEqual(otherSchema.toJSONSchema(), schema.toJSONSchema());

schemaType.allowNull(true);
assert.ok(!Object.hasOwn(schemaType.options, 'allowNull'));
assert.deepStrictEqual(schema.toJSONSchema(), {
type: 'object',
required: ['_id'],
properties: {
_id: {
type: 'string'
},
name: {
type: ['string', 'null']
}
}
});
});

it('correctly copies all child schemas (gh-7537)', function() {
const l3Schema = new Schema({ name: String });
const l2Schema = new Schema({ l3: l3Schema });
Expand Down Expand Up @@ -3513,6 +3579,44 @@ describe('schema', function() {
assert.ok(!validate({}));
});

it('omits null from optional allowNull false paths', function() {
const schema = new Schema({
name: { type: String, allowNull: false },
age: Number
}, { autoCreate: false, autoIndex: false });

assert.deepStrictEqual(schema.toJSONSchema({ useBsonType: true }), {
required: ['_id'],
properties: {
_id: {
bsonType: 'objectId'
},
name: {
bsonType: 'string'
},
age: {
bsonType: ['number', 'null']
}
}
});

assert.deepStrictEqual(schema.toJSONSchema(), {
type: 'object',
required: ['_id'],
properties: {
_id: {
type: 'string'
},
name: {
type: 'string'
},
age: {
type: ['number', 'null']
}
}
});
});

it('handles all primitive data types', async function() {
const schema = new Schema({
num: Number,
Expand Down
Loading
Loading