mongoose recursive populate

2019-01-14 08:21发布

问题:

I have been searching for a while and I didn't find any good answer. I have n-deep tree that I am storing in DB and I would like to populate all parents so in the end I get the full tree

node
 -parent
  -parent
    .
    .
    -parent

So far I populate to level 2, and as I mentioned I need to get to level n.

Node.find().populate('parent').exec(function (err, items) {
   if (!err) {
     Node.populate(items, {path: 'parent.parent'}, function (err, data) {
       return res.send(data);
     });
   } else {
     res.statusCode = code;
     return res.send(err.message);
   }
 });

回答1:

Just don't :)

There is no good way to do that. Even if you do some map-reduce, it will have terrible performance and problems with sharding if you have it or will ever need it.

Mongo as NoSQL database is really great for storing tree documents. You can store whole tree and then use map-reduce to get some particular leafs from it if you don't have a lot of "find particular leaf" queries. If this doesn't work for you, go with two collections:

  1. Simplified tree structure: {_id: "tree1", tree: {1: [2, {3: [4, {5: 6}, 7]}]}}. Numbers are just IDs of nodes. This way you'll get whole document in one query. Then you just extract all ids and run second query.

  2. Nodes: {_id: 1, data: "something"}, {_id: 2, data: "something else"}.

Then you can write simple recurring function which will replace node ids from first collection with data from second. 2 queries and simple client-side processing.

Small update:

You can extend second collection to be a little more flexible:

{_id: 2, data: "something", children:[3, 7], parents: [1, 12, 13]}

This way you'll be able to start your search from any leaf. And then, use map-reduce to get to the top or to the bottom of this part of tree.



回答2:

you can do this now (with https://www.mongodb.com/blog/post/introducing-version-40-mongoose-nodejs-odm)

var mongoose = require('mongoose');
// mongoose.Promise = require('bluebird'); // it should work with native Promise
mongoose.connect('mongodb://......');

var NodeSchema = new mongoose.Schema({
    children: [{type: mongoose.Schema.Types.ObjectId, ref: 'Node'}],
    name: String
});

var autoPopulateChildren = function(next) {
    this.populate('children');
    next();
};

NodeSchema
.pre('findOne', autoPopulateChildren)
.pre('find', autoPopulateChildren)

var Node = mongoose.model('Node', NodeSchema)
var root=new Node({name:'1'})
var header=new Node({name:'2'})
var main=new Node({name:'3'})
var foo=new Node({name:'foo'})
var bar=new Node({name:'bar'})
root.children=[header, main]
main.children=[foo, bar]

Node.remove({})
.then(Promise.all([foo, bar, header, main, root].map(p=>p.save())))
.then(_=>Node.findOne({name:'1'}))
.then(r=>console.log(r.children[1].children[0].name)) // foo

simple alternative, without Mongoose:

function upsert(coll, o){ // takes object returns ids inserted
    if (o.children){
        return Promise.all(o.children.map(i=>upsert(coll,i)))
            .then(children=>Object.assign(o, {children})) // replace the objects children by their mongo ids
            .then(o=>coll.insertOne(o))
            .then(r=>r.insertedId);
    } else {
        return coll.insertOne(o)
            .then(r=>r.insertedId);
    }
}

var root = {
    name: '1',
    children: [
        {
            name: '2'
        },
        {
            name: '3',
            children: [
                {
                    name: 'foo'
                },
                {
                    name: 'bar'
                }
            ]
        }
    ]
}
upsert(mycoll, root)


const populateChildren = (coll, _id) => // takes a collection and a document id and returns this document fully nested with its children
  coll.findOne({_id})
    .then(function(o){
      if (!o.children) return o;
      return Promise.all(o.children.map(i=>populateChildren(coll,i)))
        .then(children=>Object.assign(o, {children}))
    });


const populateParents = (coll, _id) => // takes a collection and a document id and returns this document fully nested with its parents, that's more what OP wanted
  coll.findOne({_id})
    .then(function(o){
      if (!o.parent) return o;
      return populateParents(coll, o.parent))) // o.parent should be an id
        .then(parent => Object.assign(o, {parent})) // replace that id with the document
    });


回答3:

Another approach is to take advantage of the fact that Model.populate() returns a promise, and that you can fulfill a promise with another promise.

You can recursively populate the node in question via:

Node.findOne({ "_id": req.params.id }, function(err, node) {
  populateParents(node).then(function(){
    // Do something with node
  });
});

populateParents could look like the following:

var Promise = require('bluebird');

function populateParents(node) {
  return Node.populate(node, { path: "parent" }).then(function(node) {
    return node.parent ? populateParents(node.parent) : Promise.fulfill(node);
  });
}

It's not the most performant approach, but if your N is small this would work.



回答4:

Now with Mongoose 4 this can be done. Now you can recurse deeper than a single level.

Example

User.findOne({ userId: userId })
    .populate({ 
        path: 'enrollments.course',
        populate: {
            path: 'playlists',
            model: 'Playlist',
            populate: {
                path: 'videos',
                model: 'Video'
            }
        } 
    })
    .populate('degrees')
    .exec()

You can find the official documentation for Mongoose Deep Populate from here.



回答5:

I tried @fzembow's solution but it seemed to return the object from the deepest populated path. In my case I needed to recursively populate an object, but then return the very same object. I did it like that:

// Schema definition
const NodeSchema = new Schema({
        name: { type: String, unique: true, required: true },
        parent: { type: Schema.Types.ObjectId, ref: 'Node' },
    });

const Node =  mongoose.model('Node', NodeSchema);





// method
const Promise = require('bluebird');

const recursivelyPopulatePath = (entry, path) => {
    if (entry[path]) {
        return Node.findById(entry[path])
            .then((foundPath) => {
                return recursivelyPopulatePath(foundPath, path)
                    .then((populatedFoundPath) => {
                        entry[path] = populatedFoundPath;
                        return Promise.resolve(entry);
                    });
            });
    }
    return Promise.resolve(entry);
};


//sample usage
Node.findOne({ name: 'someName' })
        .then((category) => {
            if (category) {
                recursivelyPopulatePath(category, 'parent')
                    .then((populatedNode) => {
                        // ^^^^^^^^^^^^^^^^^ here is your object but populated recursively
                    });
            } else {
                ...
            }
        })

Beware it's not very efficient. If you need to run such query often or at deep levels, then you should rethink your design