Skip to content

Virtuals do not get hydrated wth aggregations #15627

@ziedHamdi

Description

@ziedHamdi

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

  1. We create two models that point to the same collection parents:
    • ParentVirtual: has a virtual at path items.detail (no real schema path)
    • ParentReal: declares a real path at items.detail: Schema.Types.Mixed
  2. We insert a parent doc with items: [{ item: childId }] and a child doc in the children collection.
  3. We run an aggregation on parents to $lookup and then $addFields to set items.detail (merging the looked up child doc into the items[]).
  4. We hydrate the aggregation result with each model:
    • Hydration with ParentVirtual.hydrate() drops items.detail (undefined) — virtual path conflicts / unknown path is not preserved.
    • Hydration with ParentReal.hydrate() preserves items.detail (defined) — real path retains aggregation-added field.

Run

Ensure you have MongoDB running locally and mongoose installed in your project.

node bug/repro.js

By 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    new featureThis change adds new functionality, like a new method or class

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions