-
-
Notifications
You must be signed in to change notification settings - Fork 4k
Description
Prerequisites
- I have written a descriptive issue title
- I have searched existing issues to ensure the bug has not already been reported
Mongoose version
8.18.0
Node.js version
v22.12.0
MongoDB server version
8.0.4
Typescript version (if applicable)
No response
Description
Mongoose hydrate() drops aggregation fields that are not declared in the schema (virtual conflict demo)
This minimal repro shows that aggregation-added fields are dropped during hydrate() when:
- The field is not declared as a real schema path, and/or
- A virtual exists at the same path.
As a result, we must declare the field in the schema (as a real path) to preserve it through hydration, even if we only want it to exist in memory.
Files
repro.js— a self-contained script you can run with Node.
How it works
- We create two models that point to the same collection
parents:ParentVirtual: has a virtual at pathitems.detail(no real schema path)ParentReal: declares a real path atitems.detail: Schema.Types.Mixed
- We insert a parent doc with
items: [{ item: childId }]and a child doc in thechildrencollection. - We run an aggregation on
parentsto$lookupand then$addFieldsto setitems.detail(merging the looked up child doc into theitems[]). - We hydrate the aggregation result with each model:
- Hydration with
ParentVirtual.hydrate()dropsitems.detail(undefined) — virtual path conflicts / unknown path is not preserved. - Hydration with
ParentReal.hydrate()preservesitems.detail(defined) — real path retains aggregation-added field.
- Hydration with
Run
Ensure you have MongoDB running locally and mongoose installed in your project.
node bug/repro.jsBy default it connects to mongodb://127.0.0.1:27017/mongoose-virtual-hydrate-test and drops the DB at the start for a clean run.
Expected output (abridged)
Raw aggregation result items[0].detail exists? true
Hydrated with ParentVirtual - items[0].detail exists? false
Hydrated with ParentReal - items[0].detail exists? true
This demonstrates that aggregation fields are lost unless declared as real paths, pushing users to add "real" schema fields for data that should remain in-memory only.
Steps to Reproduce
// Minimal repro: aggregation adds a nested field preserved only when declared as a real schema path
// Run with: node bug/repro.js
import mongoose from 'mongoose';
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/mongoose-virtual-hydrate-test';
// Fresh DB
await mongoose.connect(MONGODB_URI, { dbName: 'mongoose-virtual-hydrate-test' });
await mongoose.connection.dropDatabase();
// Child schema and model
const childSchema = new mongoose.Schema({ name: String }, { collection: 'children' });
const Child = mongoose.model('Child', childSchema);
// Parent with VIRTUAL-only path at items.detail (no real path)
const parentVirtualSchema = new mongoose.Schema(
{
title: String,
items: [
new mongoose.Schema(
{
item: { type: mongoose.Schema.Types.ObjectId, ref: 'Child' },
},
{ _id: false }
),
],
},
{ collection: 'parents' }
);
// Virtual at nested path declaration
parentVirtualSchema.virtual('items.detail');
const ParentVirtual = mongoose.model('ParentVirtual', parentVirtualSchema, 'parents');
// Parent with REAL path at items.detail
const parentRealSchema = new mongoose.Schema(
{
title: String,
items: [
new mongoose.Schema(
{
item: { type: mongoose.Schema.Types.ObjectId, ref: 'Child' },
detail: { type: mongoose.Schema.Types.Mixed }, // real schema path
},
{ _id: false }
),
],
},
{ collection: 'parents' }
);
const ParentReal = mongoose.model('ParentReal', parentRealSchema, 'parents');
// Seed data
const child = await Child.create({ name: 'child-1' });
await ParentReal.create({ title: 'parent-1', items: [{ item: child._id }] });
// Aggregation: lookup child, then add items.detail
const pipeline = [
{
$lookup: {
from: 'children',
let: { itemIds: '$items.item' },
pipeline: [
{ $match: { $expr: { $in: ['$_id', '$$itemIds'] } } },
],
as: 'childDetails',
},
},
{
$addFields: {
items: {
$map: {
input: '$items',
as: 'it',
in: {
$mergeObjects: [
'$$it',
{
detail: {
$arrayElemAt: [
{
$filter: {
input: '$childDetails',
as: 'cd',
cond: { $eq: ['$$cd._id', '$$it.item'] },
},
},
0,
],
},
},
],
},
},
},
},
},
{ $project: { childDetails: 0 } },
];
const raw = await ParentReal.aggregate(pipeline);
const doc = raw[0];
console.log('Raw aggregation result items[0].detail exists? ', !!(doc?.items?.[0]?.detail));
// Hydrate with model that has only virtual (no real path)
const hydratedVirtual = ParentVirtual.hydrate(doc);
console.log('Hydrated with ParentVirtual - items[0].detail exists? ', !!(hydratedVirtual?.items?.[0]?.detail));
// Hydrate with model that has real path
const hydratedReal = ParentReal.hydrate(doc);
console.log('Hydrated with ParentReal - items[0].detail exists? ', !!(hydratedReal?.items?.[0]?.detail));
await mongoose.disconnect();Expected Behavior
Virtuals should behave as real fields in my opinion, because it has the advantage of declaratively saying: "this field will never get saved to db". Using real fields risks to get this data saved unintentionally