Node JS - Mongoose - How to properly join the results from two collections by ID

Viewed 172

I have the following Schemas for tracking Habits, and a user's progress

HABIT SCHEMA

const HabitSchema = new mongoose.Schema({
  user: {
    type: mongoose.Schema.Types.ObjectId,
    ref: "User",
  },
  title: {
    type: String,
    required: true,
  },
  type: {
    type: String,
    enum: ["Goal", "Limit"],
    default: "Goal",
    required: true,
  },
  dateStarted: {
    type: Date,
    default: Date.now,
    required: true,
  },
});

PROGRESS SCHEMA: each time a user clicks that they completed a habit for a given period (day, week, month etc) a document will be created for that period, to indicate that it was done.

const ProgressSchema = new mongoose.Schema({
  habit: {
    type: mongoose.Schema.Types.ObjectId,
    ref: "Habit",
  },
  period: {
    type: Date,
    default: Date.now,
    required: true,
  },
  value: {
    type: Number,
    default: 1,
    required: true,
  },
});

When I get all of a user's habits, I want to also get all progress documents per habit. I want to join these objects before returning.

Here's what I have attempted:

let habits = await Habit.find({ user: req.user.id }).sort({
  dateStarted: -1,
});

habits = await Promise.all(
  habits.map(async (h, i) => {
    try {
      const progress = await Progress.find({ habit: h._id });

      return { ...h, progress };
    } catch (e) {
      res.status(500).send("Server Error");
    }
  })
);
res.json({ habits });

This gives the error: CastError: Cast to ObjectId failed for value "undefined" at path "_id" for model "Habit"

What would be the correct way to accomplish this?

3 Answers

I still don't understand why the error came up considering that I used await/async so the line should not have been running before an id was retrieved (the error was saying ._id is undefined when it clearly wasn't).

After trying lots of things, here is what finally worked:

try {
  await Promise.all(
    habits.map(async (h, i) => {
      let progress = await Progress.find({ habit: h._id });
      let newHabit = h;
      newHabit._doc.progresses = progress;
      return newHabit;
    })
  );
} catch (error) {
  console.log(error);
  res.status(500).send("Server Error");
}

Once I finally got the await Progress.find({ habit: h._id }); to work, I also found out that I can't just add to a mongoose retrieved object with a spread operator. The object has many properties, and the fields from the database are actually contained within the ._doc property of the object: newHabit._doc.progresses = progress (but this has nothing to do with the error I was getting). This makes me think there is a better way for adding fields to a document found by mongoose .find().

A better solution would be to use .lean() since you're not running any function like .save so you don't need the additional items that the find query returns.

let habits = await Habit.find({ user: req.user.id }).sort({
  dateStarted: -1,
}).lean();

habits = await Promise.all(
  habits.map(async (h, i) => {
    try {
      const progress = await Progress.find({ habit: h._id }).lean();

      return { ...h, progress };
    } catch (e) {
      res.status(500).send("Server Error");
    }
  })
);
res.json({ habits });

And still better solution would be to use ref and populate

I haven't used Mongoose, however, in vanilla MongoDB you can use the aggregation framework like so to do this:

db.Habbit.aggregate([
  { $lookup: {
    from: "Progress",
    localField: "_id",
    foreignField: "habbit",
    as: "Progresses"
  } }
])

Yet more, you can add a step to discard all but the latest progress instead of returning them all. However, I'll leave this for another question.

Related