mongodb-js / mongoose-autopopulate

Always populate() certain fields in your mongoose schemas
Apache License 2.0
222 stars 36 forks source link

How can I specify that the auto-populated document should not include or calculate its virtual properties? #121

Open KamilTheDev opened 2 months ago

KamilTheDev commented 2 months ago

How can I specify that the auto-populated document should not include or calculate its virtual properties?

For example, I have a clan member schema that includes the User document. I only want to auto-populate the User's name, avatar, avatarFrame and nothing more.

I also had the issue that since the User itself has autopopulate fields (inventory for example), those were being included despite not meant to be. I fixed that by setting maxDepth: 1, not sure if that's the intended fix.

Though that now means since the virtual fields are still trying to be included, it causes an error since it's only intended to be used when the User is fully autopopulated (not needed in this case.)

Member schema:

const memberSchema = new mongoose.Schema({
  user: {
    type: mongoose.Schema.Types.ObjectId,
    ref: "User",

    autopopulate: {
      select: "name avatar avatarFrame",
      maxDepth: 1, // fix User inventory being autopopulated and included
    },
  },

  rank: {
    type: String,
    default: "member",
  },

});

const clanSchema = new mongoose.Schema(
  {
    name: {
      type: String,
      required: [true, "Please add a name value"],
      unique: true,
    },

    members: [memberSchema],
  },

  {
    timestamps: true,
    toJSON: { virtuals: true },
    toObject: { virtuals: true },
  }
);

clanSchema.virtual("membersCount").get(function () {
  return this.members.length;
});

User schema:

const userSchema = mongoose.Schema(
  {
    // Account
    name: {
      type: String,
      required: [true, "Please add a name"],
    },

    avatar: {
      type: String,
      default: "default",
    },

    avatarFrame: {
      type: String,
      default: "default",
    },

    bio: {
      type: String,
      default: "default",
    },

    // Inventory - not meant to be auto-populated/included in memberSchema
    inventory: {
      type: [
        {
          type: mongoose.Schema.Types.ObjectId,
          ref: "InventoryItem",
          autopopulate: true,
        },
      ],
      default: [],
    },
    
  },

  {
    timestamps: true,
  }
);

userSchema.virtual("exampleVirtual").get(function () {
  return "hello world"; // does things with this.inventory - not meant to be included/calculated in memberSchema
});
vkarpov15 commented 2 months ago

The key detail is that virtuals aren't included until they are accessed. In this case, exampleVirtual would only execute when you explicitly use clan.user.exampleVirtual;, or when you execute a function that accesses exampleVirtual, like toObject() or toJSON(). So you have a couple of options:

  1. Adjust exampleVirtual to return undefined if !this.$populated('inventory')
  2. Skip exampleVirtual when serializing using clan.toObject({ virtuals: { pathsToSkip: ['user.exampleVirtual'] } })

Do either of those help?

KamilTheDev commented 2 months ago
  1. Adjust exampleVirtual to return undefined if !this.$populated('inventory')

Yes, if I were to check if inventory is defined before proceeding in exampleVirtual, there won't be an error. However, I don't know if I like this approach because it might cause silent errors if I make a mistake somewhere else where virtuals and inventory was meant to be populated but didn't.

2. Skip exampleVirtual when serializing using clan.toObject({ virtuals: { pathsToSkip: ['user.exampleVirtual'] } })

Trying user.exampleVirtual didn't work, but I was able to do this with let clanObj = playerClan.toObject({ virtuals: { pathsToSkip: ["exampleVirtual"] } }). However, this means I'd have to constantly maintain the list of virtuals the User has. Doing virtuals: false would be better, but of course it also removes the clanSchema vrituals (membersCount).

I wanted to add this behavior directly to the clanSchema so I don't have to remember to do that every time I'm fetching a clan in my code, but it has no effect on User virtuals (even trying to completely disable all virtuals).

I forgot to include in my example User schema, that also includes toJSON: { virtuals: true }, toObject: { virtuals: true },. If I removed that from User schema, then the following would work, but otherwise this doesn't work (I think the User schema settings are treated as more important):

const clanSchema = new mongoose.Schema(
  {
    name: {
      type: String,
      required: [true, "Please add a name value"],
      unique: true,
    },

    members: [memberSchema],
  },

  {
    timestamps: true,
    toJSON: { virtuals: false },
    toObject: { virtuals: false },
  }
);

Anyhow, my ideal solution would be to directly specify in the clanSchema or memberSchema itself, that I do not want to have any User virtuals included. This is so:

  1. I don't have to remember to apply the same toObject settings every time I fetch a clan in code.
  2. It makes it clear directly in the clan schema that User virtuals are not wanted.
  3. Makes it easier to change behavior, e.g. If I decided to include virtuals/inventory in the future.

I would love to just be able to specify a virtuals option in the autopopulate settings like:

 user: {
  type: mongoose.Schema.Types.ObjectId,
  ref: "User",

  autopopulate: {
    select: "name avatar avatarFrame",
    maxDepth: 1,
    virtuals: false,
  },
},
vkarpov15 commented 1 month ago

How about autopulating user with lean set? autopopulate: { select: "...", maxDepth: 1, lean: true }. That will skip all virtuals on User.

The issue is that virtuals are computed properties defined on the User model's prototype, so fully disabling them would require instantiating the document with a different constructor.

Another potential option is that Mongoose could support setting default toObject() and toJSON() options on a per-document basis. Something like const user = new User({ name: 'test' }, null, { toObject: { virtuals: false }, toJSON: { virtuals: false } }), so plugins like mongoose-autopopulate could just instantiate the populated doc in a way that virtuals are excluded in toObject() by default. Would that help?