Skip to content

fix: preserve middleware through validation, hydration, and query options - #16512

Draft
AbdelrahmanHafez wants to merge 53 commits into
Automattic:masterfrom
AbdelrahmanHafez:fix/validation-middleware-options
Draft

AbdelrahmanHafez wants to merge 53 commits into
Automattic:masterfrom
AbdelrahmanHafez:fix/validation-middleware-options

Conversation

@AbdelrahmanHafez

@AbdelrahmanHafez AbdelrahmanHafez commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

This PR fixes middleware forwarding, validation, hydration, and query-option bugs.
Examples are independent. Current means before this PR; Expected means after the fix.

Validation hooks ignore suppression in document and bulk operations

Forward selection through parent and nested validation. Schema validators still run.

const calls = [];
const profileSchema = new mongoose.Schema({
  age: { type: Number, validate: age => age >= 0 }
});
const schema = new mongoose.Schema({ profile: profileSchema });
for (const s of [schema, profileSchema]) {
  s.pre('validate', function() { calls.push('pre'); });
  s.post('validate', function() { calls.push('post'); });
}
const User = mongoose.model('User', schema);
const data = { profile: { age: 20 } };

await new User(data).save({ middleware: false });
// Current: validation hooks run. Expected: calls is [].

// The same fix covers these entry points:
await new User(data).validate({ middleware: false });
await User.insertMany([data], { middleware: false });
await User.bulkSave([new User(data)], { middleware: false });
await User.bulkWrite([
  { insertOne: { document: data } },
  { replaceOne: { filter: { 'profile.age': 20 }, replacement: data } }
], { middleware: false });

calls.length = 0;
await new User(data).save({ middleware: { pre: false } });
// Expected: only 'post' entries. { post: false } keeps only 'pre' entries.

await new User({ profile: { age: -1 } }).save({ middleware: false });
// Still throws ValidationError before writing.

Also covers document arrays, Union subdocuments, validation error hooks, and edits to existing children.

Update queries and Model.validate() lose nested validation selection

Pass the owning operation's selection into internal validators.

const calls = [];
const child = new mongoose.Schema({
  age: { type: Number, validate: age => age >= 0 }
});
child.pre('validate', function() { calls.push('pre'); });
child.post('validate', function() { calls.push('post'); });
const User = mongoose.model('User', new mongoose.Schema({ child }));
const data = { child: { age: 20 } };

await User.updateOne({}, { $set: data }, {
  runValidators: true,
  middleware: false
});
// Current: calls is ['pre', 'post']. Expected: [].

await User.validate(data, { middleware: { pre: false } });
// Current: both phases run. Expected: only 'post'.

Also fixes findOneAndUpdate(), replaceOne(), findOneAndReplace(), and nested array $set, $push, and $addToSet validation.
Invalid data still fails. middleware alone does not enable runValidators.

bulkSave() writes documents that fail asynchronous validation

Await one validation pass before save hooks and writes.

const schema = new mongoose.Schema({
  age: { type: Number, validate: async age => age >= 0 }
});
const User = mongoose.model('User', schema);

await User.bulkSave([new User({ age: -1 })]);
// Current: writes the invalid document.
// Expected: throws ValidationError before writing.

The later operation builder skips duplicate validation.
skipValidation: true or validateBeforeSave: false bypasses validation.
Timestamps and sessions retain their behavior.

bulkSave() runs child save hooks under suppression

Forward the selection to child save hooks, including independent phases.

const calls = [];
const child = new mongoose.Schema({ name: String });
child.pre('save', function() { calls.push('pre'); });
child.post('save', function() { calls.push('post'); });
const Group = mongoose.model('Group', new mongoose.Schema({ children: [child] }));

await Group.bulkSave([
  new Group({ children: [{ name: 'Alice' }] })
], { middleware: false });
// Current: calls is ['pre', 'post']. Expected: [].
Repeated bulkSave() calls miss newly added children

Refresh the child cache for each call.

const saved = [];
const child = new mongoose.Schema({ name: String });
child.pre('save', function() { saved.push(this.name); });
const Group = mongoose.model('Group', new mongoose.Schema({ children: [child] }));
const group = new Group({ children: [{ name: 'Alice' }] });

await Group.bulkSave([group]);
group.children.push({ name: 'Bob' });
saved.length = 0;
await Group.bulkSave([group], { validateBeforeSave: false });
// Current: Bob's hook is missing. Expected: both Alice and Bob appear.
bulkSave() adds skipValidation to caller options

Give the operation builder its own options copy.

const User = mongoose.model('User', new mongoose.Schema({ name: String }));
const options = {};
await User.bulkSave([new User({ name: 'Ann' })], options);

Object.hasOwn(options, 'skipValidation');
// Current: true. Expected: false.
Hooks attached directly to queries ignore middleware selection

Filter query-instance callbacks while keeping internal callbacks active.

const calls = [];
const User = mongoose.model('User', new mongoose.Schema({ name: String }));
const query = User.find().setOptions({ middleware: false });
query.pre(function() { calls.push('pre'); });
query.post(function() { calls.push('post'); });

await query;
// Current: calls is ['pre', 'post']. Expected: [].

Phase selection works too. Internal document filters and sessions still apply.

Document write hooks ignore later setOptions() middleware selection

Read effective query options when document and subdocument hooks run.

const calls = [];
const schema = new mongoose.Schema({ name: String });
schema.pre('updateOne', { document: true, query: false }, function() {
  calls.push('document:pre');
});
const User = mongoose.model('User', schema);
const user = await User.create({ name: 'Ann' });

await user.updateOne({ name: 'Bob' }).setOptions({ middleware: false });
// Current: calls is ['document:pre']. Expected: [].
// The update still writes name: 'Bob'.

The same fix covers doc.deleteOne() and independent pre/post selection.

Document writes overwrite later query options with original options

Apply call options once. Retain later query settings and changes made through document-hook arguments.

const User = mongoose.model('User', new mongoose.Schema({ name: String }));
const user = await User.create({ name: 'Ann' });
const query = user.updateOne({ name: 'Bob' }, { comment: 'original' });
await query.setOptions({ comment: 'later' });
// Current: MongoDB receives comment: 'original'.
// Expected: MongoDB receives comment: 'later'.

Also fixes doc.deleteOne(). Hook argument identity, untouched options, sessions, shard filters, and the already-deleted guard remain intact.
Hook changes and removals still apply. strict, updatePipeline, and sort options keep their existing handling.

find() drops middleware selection during result hydration

Pass selection to each returned document's initialization hooks.

const calls = [];
const schema = new mongoose.Schema({ name: String });
schema.pre('init', function() { calls.push('pre'); });
schema.post('init', function() { calls.push('post'); });
const User = mongoose.model('User', schema);
await User.create([{ name: 'Ann' }, { name: 'Bob' }]);

await User.find().setOptions({ middleware: false });
// Current: both phases run for each result. Expected: calls is [].

await User.find().setOptions({ middleware: { pre: false } });
// Expected: only post-init hooks run.

Built-in initialization, defaults, sessions, and population remain active.

Cursors from one query change each other's middleware selection

Capture each cursor's selection before asynchronous pre-find hooks start.

const calls = [];
const schema = new mongoose.Schema({ name: String });
schema.pre('init', function() { calls.push('init'); });
const User = mongoose.model('User', schema);
await User.create([{ name: 'Ann' }, { name: 'Bob' }]);
const shared = User.find().sort({ name: 1 });
const first = shared.cursor({ middleware: true });
await first.next();
const second = shared.cursor({ middleware: false });
await second.next();
calls.length = 0;
await first.next();
// Current: calls is []. Expected: one init hook runs for the next result.
await Promise.all([first.close(), second.close()]);

The snapshot also controls construction and post-find hooks during overlapping cursor creation.
Pre hooks can still change ordinary driver options. Later cursors still inherit the query's current selection.

Suppressing init hooks drops populated virtuals

Keep the built-in virtual initialization hook active.

const Author = mongoose.model('Author', new mongoose.Schema({ name: String }));
const schema = new mongoose.Schema({ authorId: mongoose.Schema.Types.ObjectId });
schema.virtual('author', {
  ref: 'Author', localField: 'authorId', foreignField: '_id', justOne: true
});
const Post = mongoose.model('Post', schema);
const authorId = new mongoose.Types.ObjectId();
const post = Post.hydrate({
  authorId,
  author: { _id: authorId, name: 'Ann' }
}, null, { hydratedPopulatedDocs: true, middleware: false });

post.author instanceof Author;
// Current: false; author is missing. Expected: true.

Array virtuals and their existing population metadata remain intact too.

Related virtual documents lose middleware selection during hydration

Forward selection when hydrating single and array virtual values.

const calls = [];
const authorSchema = new mongoose.Schema({ name: String });
authorSchema.post('init', function() { calls.push('author:post'); });
const Author = mongoose.model('Author', authorSchema);
const schema = new mongoose.Schema({ authorId: mongoose.Schema.Types.ObjectId });
schema.virtual('author', {
  ref: 'Author', localField: 'authorId', foreignField: '_id', justOne: true
});
const Post = mongoose.model('Post', schema);
const authorId = new mongoose.Types.ObjectId();

Post.hydrate({ authorId, author: { _id: authorId, name: 'Ann' } }, null, {
  hydratedPopulatedDocs: true,
  middleware: { post: false }
});
// Current: calls is ['author:post']. Expected: [].

Each path retains its existing hydration options and population behavior.

Default-created children run init hooks under suppression

Pass selection through default creation, casting, and child initialization.

const calls = [];
const member = new mongoose.Schema({ name: String });
member.pre('init', function() { calls.push('member:init'); });
const Team = mongoose.model('Team', new mongoose.Schema({
  members: { type: [member], default: [{ name: 'Ann' }] }
}));

const team = Team.hydrate({}, null, { middleware: false });
// Current: calls contains 'member:init'. Expected: [].
team.members[0].name; // 'Ann' in both cases.

Also covers nested defaults and default single-nested children. Default values and change tracking retain their existing behavior.

watch() sends middleware to MongoDB and omits it during hydration

Remove the driver option and retain it for Mongoose hydration.

const calls = [];
const schema = new mongoose.Schema({ name: String });
schema.pre('init', function() { calls.push('pre'); });
schema.post('init', function() { calls.push('post'); });
const User = mongoose.model('User', schema);
const stream = User.watch([], {
  hydrate: true,
  fullDocument: 'updateLookup',
  middleware: { pre: false }
});

// After a matching write:
const change = await stream.next();
// Current: MongoDB rejects the unknown $changeStream.middleware field.
// Expected: fullDocument is a User, and calls is ['post'].
await stream.close();

The fix covers event listeners and callback or promise next() calls.
Disabled hydration, events without fullDocument, and valid driver options retain their behavior.

aggregate.explain() ignores options changed by pre hooks

Copy driver options after enabled pre-aggregate hooks finish.

const schema = new mongoose.Schema({ name: String });
schema.pre('aggregate', function() {
  this.options.comment = 'from-hook';
  this.options.allowDiskUse = true;
});
const User = mongoose.model('User', schema);

await User.aggregate([{ $match: {} }])
  .option({ comment: 'original', middleware: { post: false } })
  .explain();
// Current driver options: comment: 'original', no allowDiskUse.
// Expected driver options: comment: 'from-hook', allowDiskUse: true.

The driver copy excludes middleware; the aggregate retains its selection.

Operation options do not reach createModel hooks

Forward selection into document construction, including replacements and discriminators.

const calls = [];
const schema = new mongoose.Schema({ name: String });
schema.pre('createModel', function() { calls.push('construct'); });
const User = mongoose.model('User', schema);
await User.collection.insertOne({ name: 'Ann' });

await User.find({}, null, { middleware: false });
await User.create([{ name: 'Bob' }], { middleware: false });
await User.insertMany([{ name: 'Carol' }], { middleware: false });
// Current: construction hooks run. Expected: calls is [].

Also covers findOne(), cursors, hydrate(), insertOne(), replacements, and bulk inserts/replacements.
{ pre: false } skips these synchronous hooks; { post: false } keeps them enabled.
Options cannot undo construction completed before the operation receives them.

Query.clone() shares Mongoose and population options

Copy mutable options while preserving model, connection, session, and callback identities.

const User = mongoose.model('User', new mongoose.Schema({ name: String }));
await User.create({ name: 'Ann' });
const original = User.findOne({ name: 'Ann' }).lean();
const copy = original.clone().lean(false);
const [originalResult, copiedResult] = await Promise.all([original, copy]);

originalResult instanceof User;
// Current: true. Expected: false.
copiedResult instanceof User; // true in both cases.

Nested population settings also stay separate.

Public types reject supported validation, hydration, and construction options

Declare the missing options and synchronous hook overload without weakening result types.

import { Schema, model } from 'mongoose';
const schema = new Schema({ name: String });
const User = model('User', schema);

// Current: these calls fail type checking. Expected: they compile.
await User.bulkSave([new User({ name: 'Ann' })], {
  validateModifiedOnly: true,
  skipValidation: false,
  middleware: { pre: false }
});
User.hydrate({ name: 'Ann' }, null, { middleware: false });
User.watch([], { hydrate: true, middleware: { pre: false } });
await User.validate({ name: 'Ann' }, { middleware: false });
schema.pre('createModel', function() {});

Invalid middleware values still fail type checking. The default this type for createModel is unknown because input is uncast.

Explicit custom method/static types reject supportsMiddlewareOption

Preserve the opt-in flag, function properties, parameter types, return types, and receivers.

import { Model, Schema } from 'mongoose';
interface TaskData { name: string }
interface TaskMethods { runTask(value: string): Promise<string> }
interface TaskStatics { findTask(value: string): Promise<string> }
type TaskModel = Model<TaskData, {}, TaskMethods> & TaskStatics;

const schema = new Schema<TaskData, TaskModel, TaskMethods, {}, {}, TaskStatics>({ name: String });
schema.methods.runTask = async function(value) { return this.name + value; };
schema.statics.findTask = async function(value) { return value; };

schema.methods.runTask.supportsMiddlewareOption = true;
schema.statics.findTask.supportsMiddlewareOption = true;
// Current: TS2339 for both assignments. Expected: both compile.
The middleware guide awaits an empty aggregation pipeline

Use a stage that does not depend on pre hooks.

// Current example: throws 'Aggregate has empty pipeline'.
await Model.aggregate([]).option({ middleware: false });

// Corrected example: executes with user hooks skipped.
await Model.aggregate([{ $match: {} }]).option({ middleware: false });

The adjacent cursor example uses the same pipeline for consistency. It did not fail the empty-pipeline execution guard.

@AbdelrahmanHafez AbdelrahmanHafez changed the title fix(model): honor middleware options during save and insertMany validation fix: propagate middleware options through document validation Sep 15, 2026
@AbdelrahmanHafez AbdelrahmanHafez changed the title fix: propagate middleware options through document validation fix: honor middleware options in validation and query instance hooks Sep 16, 2026
@AbdelrahmanHafez AbdelrahmanHafez changed the title fix: honor middleware options in validation and query instance hooks fix: honor middleware options in validation, query hooks, and hydration Sep 16, 2026
@AbdelrahmanHafez AbdelrahmanHafez changed the title fix: honor middleware options in validation, query hooks, and hydration fix: honor middleware selection and restore bulkSave validation Sep 16, 2026
@AbdelrahmanHafez AbdelrahmanHafez changed the title fix: honor middleware selection and restore bulkSave validation fix: preserve middleware behavior and restore bulkSave validation Sep 16, 2026
@AbdelrahmanHafez AbdelrahmanHafez changed the title fix: preserve middleware behavior and restore bulkSave validation fix: preserve middleware, bulkSave validation, and query clone options Sep 16, 2026
@AbdelrahmanHafez AbdelrahmanHafez changed the title fix: preserve middleware, bulkSave validation, and query clone options fix: preserve middleware, query options, and bulkSave validation Sep 20, 2026
@AbdelrahmanHafez AbdelrahmanHafez changed the title fix: preserve middleware, query options, and bulkSave validation fix: preserve middleware through validation, hydration, and query options Sep 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant