javascriptdatabasemongodbmongoosenosql

how to use Mongoose populate on subdocument on the same model?


This is my product model.

const featureHeaderSchema = new Schema({
    header: {
        type: String,
        required: true,
    },
});
const featureSchema = new Schema({
    title: {
        type: String,
        required: true,
    },
    parentHeader: { type: Schema.Types.ObjectId, ref: "product.featureHeaders" }, // not sure if this is correct
    parentFeature: { type: Schema.Types.ObjectId, ref: "product.features" },  // not sure if this is correct
});
const productSchema = new Schema(
    {
        title: {
            type: String,
            required: true,
        },
        thumbnail: String,
        description: String,
        manufacture: { type: Schema.Types.ObjectId, ref: "user" },
        featureHeaders: {
            type: [featureHeaderSchema],
        },
        features: {
            type: [featureSchema],
        },
    },
    { timestamps: true }
);
const productModel = mongoose.model("product", productSchema);

I wanted to find a product by its id and get parentHeader's title and parentFeature's title with populate.

I tried to get product info by product ID and populate on parentHeader and parentFeature so I could have their titles on my output.

I tried this query:

const output = await productModel
    .findById(newProduct._id)
    .populate("features.parentFeature")
    .exec();

but I get this error: MissingSchemaError: Schema hasn't been registered for model "product.features".


Solution

  • Firstly, what your trying to do isn't possible with populate. That's because the ref property in the schema declaration is for referencing a model, not a path. It's used to tell mongoose which model you want to use to perform the $lookup and mongoose expects that model to correspond to a collection in your database.

    Secondly, you are using subdocuments in your productSchema for the featureHeaders and features arrays so populate won't work for you regardless.

    Thankfully you can fix this and get populate working for you. Although your naming convention and use case is a little confusing, hopefully this will help to explain how it's done:

    1. FeatureHeader and Feature need to have their own collection, not be subdocuments so you need to create models for them.
    import mongoose from "mongoose";
    const featureHeaderSchema = new mongoose.Schema({
        header: {
            type: String,
            required: true,
        }
    });
    // You need create the FeatureHeader model
    const FeatureHeader = mongoose.model("FeatureHeader", featureHeaderSchema);
    
    const featureSchema = new mongoose.Schema({
        title: {
            type: String,
            required: true,
        },
        parentHeader: { 
           type: mongoose.Schema.Types.ObjectId, 
           ref: "FeatureHeader" //< Reference the FeatureHeader model
        }, 
        parentFeature: { 
           type: mongoose.Schema.Types.ObjectId, 
           ref: "Feature" //< You can reference the same model (i.e self)
        }
    });
    // You need create the Feature model
    const Feature = mongoose.model("Feature", featureSchema);
    
    const productSchema = new mongoose.Schema({
       title: {
          type: String,
          required: true,
       },
       thumbnail: String,
       description: String,
       manufacture: { 
          type: mongoose.Schema.Types.ObjectId, 
          ref: "User" //< Capitalise the model name
       },
       featureHeaders: [{
          type: mongoose.Schema.Types.ObjectId,
          ref: "FeatureHeader", //< Reference the FeatureHeader model
       }],
       features: [{
          type: mongoose.Schema.Types.ObjectId,
          ref: "Feature", //< Reference the Feature model
       }],
    },
    { timestamps: true });
    // You need create the Feature model
    const Product = mongoose.model("Product", productSchema);
    
    1. FeatureHeader and Feature need to be saved separately in their own collection in order to be referenced .
    const newFeatureOne = await Feature.create({
       title: 'Marvel',
    });
    
    const newFeatureHeader = await FeatureHeader.create({
       header: 'Superhero'
    });
    
    const newFeatureTwo = await Feature.create({
       title: 'Repulsor Beams',
       parentHeader: newFeatureHeader._id,
       parentFeature: newFeatureOne._id
    });
    
    const newProduct = await Product.create({
       title: 'Repulsor Beams',
       //...
       //... 
       featureHeaders: [newFeatureHeader._id],
       features: [newFeatureTwo._id],
    });
    
    1. Now you can call populate to replace the ObjectId with their corresponding document:
    const id = req.body.id; //< Could be req.params.id or however you get the id
    
    const product = await Product.findById(id)
       .populate("featureHeaders")
       .populate("features");
    

    Note: Depending on your design and how the models are scoped you may need to specify the path and/or the model properties of the populate options object like so:

    const id = req.body.id; //< Could be req.params.id or however you get the id
    
    const product = await Product.findById(id)
       .populate({path: "featureHeaders", model: "FeatureHeader"})
       .populate({path: "features", model: "Feature"});
    

    Hopefully all of that makes sense and if you have any questions then please ask in the comments.