mongodb $lookup for nested object in array with projection - node.js

I'm having a problem using $lookup in my aggregation pipeline.
I have 2 collections, members & messages
members :
{_id, FirstName, LastName, Email, ...}
messages
{
_id:ObjectId('xxx'),
createdBy:ObjectId(''),
...
threads:[
{ message:'' , attachments:[] , from:ObjectId , to:[{status:'Read' , recipient:ObjectId}] }]
}
What I'm trying to do is,
lookup for each recipient in : to:[{status:'Read' , recipient:ObjectId}] and populate name and email from members collection.
I tried many different things like this one;
//
db.messages.aggregate([
{
'$lookup': {
'from': 'members',
'let': {
'memberId': '$threads.to.recipient'
},
'pipeline': [
{
'$match': {
'$expr': {
'$eq': [
'$$memberId', '$members._id'
]
}
}
},
{$project: {FirstName: 1, _id: 1, LastName: 1, Email: 1}}
],
'as': 'members'
}
}
]
Many different queries including this one always return [] for members ('as': 'members').
Just to test I tired with mongoose and .populate('threads.to.recipient','FirstName') worked perfectly. But I cannot use mongoose for this I have to use MongoDB's native nodejs driver.
any advice would be greatly appreciated on this...

You have to use $unwind to flatten the structure of threads array before performing $lookup
db.messages.aggregate([
{
$unwind: "$threads"
},
{
$unwind: "$threads.to"
},
{
$lookup: {
from: "members",
let: {
memberId: "$threads.to.recipient"
},
as: "members",
pipeline: [
{
$match: {
$expr: {
$eq: [
"$$memberId",
"$_id"
]
}
}
},
{
$project: {
FirstName: 1,
_id: 1,
LastName: 1,
Email: 1
}
}
]
}
}
])
See the working example in MongoDB Playground
If you don't want to use $unwind, just try the below query:
db.messages.aggregate([
{
"$lookup": {
"from": "members",
"localField": "threads.to.recipient",
"foreignField": "_id",
"as": "members"
}
}
])
See the working example in MongoDB Playground

Related

How get all objects from an array of Id's in mongoose with express nodejs?

I'm developing a Chat application where I'm saving the conversation list in the DB like this;
Conversations Collection:
{
members: [ "123", "456" ]
...rest
}
and User Collection:
{
_id: ObjectId( "123" ), name: "anyone"
},
{
_id: ObjectId( "456" ), name: "someone"
}
I want the result to be like:
{
members: [
{_id: ObjectId( "123" ), name: "anyone"},
{_id: ObjectId( "456" ), name: someone"}
]
}
I know aggregation with lookup is the rescue but can't find a way how to fetch all ids from that member's array because in the future it can be 20 to 30 ids. If it is a simple field then I'm able to fetch but with an array, I can't.
I have tried this
db.conversations.aggregate([
{
"$lookup": {
"from": "users",
"localField": "members",
"foreignField": "_id",
"as": "members_details"
}
}
])
but it returns members_details: [ ]
You could use the following query to accomplish what you want. You'll need to use $lookup since you are wanting to gather data from a different collection then the one you are currently querying.
You can check out a live demo here
Here is an updated live demo
Database
db={
"conversations": [
{
members: [
123,
456
]
}
],
"users": [
{
"_id": 123,
"name": "foo"
},
{
"_id": 456,
"name": "bar"
}
]
}
Query
db.conversations.aggregate([
{
"$lookup": {
"from": "users",
"localField": "members",
"foreignField": "_id",
"as": "members"
}
},
{
$project: {
_id: 0,
members: 1
}
}
])
Result
[
{
"members": [
{
"_id": 123,
"name": "foo"
},
{
"_id": 456,
"name": "bar"
}
]
}
]
Update
See new live demo here
New Query
db.conversations.aggregate([
{
$unwind: "$members"
},
{
"$lookup": {
"from": "users",
"as": "membersFlat",
"let": {
memberObjectId: {
"$toObjectId": "$members"
}
},
pipeline: [
{
$match: {
$expr: {
$eq: [
"$$memberObjectId",
"$_id"
]
}
}
}
]
}
},
{
$group: {
_id: null,
members: {
$push: {
"_id": {
$first: "$membersFlat._id"
},
"name": {
$first: "$membersFlat.name"
}
}
}
}
},
{
$project: {
_id: 0
}
}
])
New Result
[
{
"members": [
{
"_id": ObjectId("124578987898787845658574"),
"name": "foo"
},
{
"_id": ObjectId("124578986532124578986532"),
"name": "bar"
}
]
}
]
Since your members id is string in conversation collection, you can convert it to object id and the join to users collection,
$addFields to update members array
$map to iterate loop of members array
$toObjectId to convert string object id type to object id type
db.conversations.aggregate([
{
$addFields: {
members: {
$map: {
input: "$members",
in: {
$toObjectId: "$$this"
}
}
}
}
},
{
$lookup: {
from: "users",
localField: "members",
foreignField: "_id",
as: "members"
}
}
])
Playground
You can use a normal .find() like this:
const members = [
ObjectId('4ed3ede8844f0f351100000c'),
ObjectId('4ed3f117a844e0471100000d'),
ObjectId('4ed3f18132f50c491100000e')
]
const docs = await model.find({
'_id': { $in: members }
}).exec()
Install mongoose-autopopulate. And in your model code
import {Schema, model} from 'mongoose';
const modelSchema = new Schema({
...
chats: [{type: Schema.Types.ObjectId, ref: "Chat", autopopulate: true}]
})
modelSchema.plugin(require('mongoose-autopopulate'));
...

Mongodb lookup aggregation not getting all field values

I have two collections which are of schema like
driver
_id:5f9c1d897ea5e246945cd73a
agent_name:"Ratnabh Kumar Rai"
agent_email:"ra2614#gmail.com"
agent_phone:"70****63331"
and
reviews
_id:5f9d54cb3a3ee10c6829c0a4
order_id:"5f9d40f096e4a506e8684aba"
user_id:"5f9bcb66f7a5bf4be0ad9973"
driver_id:"5f9c1d897ea5e246945cd73a"
rating:3
text:""
so i want to calculate the avg driver rating . I tried to use lookup aggregation so that i can get all details and then later calculate the sum...what i did was
let doc = await db
.collection("drivers")
.aggregate([
{
$project: {
_id: {
$toString: "$_id",
},
},
},
{
$lookup: {
from: "reviews",
localField: "_id",
foreignField: "driver_id",
as: "driver_info",
},
},
{
$project: {
agent_email: 1,
orderReview: "$driver_info",
},
},
])
.toArray();
But i am getting result as
{
_id: '5f9d63eb8737e82fbc193dd9',
orderReview: [ [Object], [Object], [Object], [Object], [Object] ]
}
which is partially correct as i also need to get details from my localfield collection that is drivers field, as of now you can see i am only getting id of driver in my projection i also did "agent_email:1" but not getting email
You're actually only projecting _id in the first pipeline and hence only _id is passed to further pipelines, If you need email in further pipelines you need to project it too
let doc = await db.collection("drivers").aggregate([
{
$project: {
_id: {
$toString: "$_id",
},
agent_email: "$agent_email"
},
},
{
$lookup: {
from: "reviews",
localField: "_id",
foreignField: "driver_id",
as: "driver_info",
},
},
{
$project: {
agent_email: 1,
orderReview: "$driver_info",
},
},
])
MongoDB PlayGround : https://mongoplayground.net/p/h7D-tYJ7sLU
[Update]
I realized that you're doing this for getting average and if you need it to be done in a single aggregate query, here it is how you can do it.
Using unwind operator you can flat the reviews array as objects and then group by _id and use the $avg aggregation operator
db.collection("drivers").aggregate([
{
$project: {
_id: {
$toString: "$_id",
},
agent_email: "$agent_email"
},
},
{
$lookup: {
from: "reviews",
localField: "_id",
foreignField: "driver_id",
as: "driver_info",
},
},
// Makes the driver info flat with other information
{
"$unwind": "$driver_info"
},
{
// Now you can group them
$group: {
_id: "$_id",
// Calculates avg on rating field
avg: {
"$avg": "$driver_info.rating"
},
// To make email field appear in next pipeline.
// You can do it for other fields too like Name, etc
agent_email: {
$first: "$agent_email"
}
}
},
{
$project: {
// select the fields you want to display
agent_email: 1,
avg: 1
},
},
])
MonogoDb playground Link

Mongoose join two collections and get referenced data in two properties

I'm doing a simple follow friend functionality.
Please see my codes below:
Following schema:
{
"userId": { type: String },
"followers": [{ "followerId": type: String }],
"followings": [{ "followingId": type: String }],
}
User schema:
{
"fullName": { type: String }
}
Note: user 8 has 1 follower and 2 followings.
Now, my expected output should be like this:
"userId": 8,
"followers": [
{
"followerId": 4,
"fullName": "Rose Marriott",
},
{
"followerId": 5,
"fullName": "James Naismith",
}
],
"followings": [
{
"followingId": 1,
"fullName": "Russell Oakham",
},
{
"followingId": 5,
"fullName": "James Naismith",
}
]
This is what I tried so far:
db.followings.aggregate([
{ $unwind: "$followers" },
{
$lookup: {
from: "users",
localField: "followers.followerId",
foreignField: "_id",
as: "users"
}
},
{
$addFields:
{
users: { $arrayElemAt: ["$users", 0] },
},
},
{ $unwind: "$followings" },
{
$lookup: {
from: "users",
localField: "followings.followingId",
foreignField: "_id",
as: "users2"
}
},
{
$addFields:
{
users2: { $arrayElemAt: ["$users2", 0] },
},
},
{ $match: {"userId": mongoose.Types.ObjectId(userId) } },
{
$group: {
_id: "$_id",
userId: { $first: "$userId" },
followers: {
$push: {
followerId: "$followers.followerId",
fullName: "$users.fullName",
}
},
followings: {
$push: {
followingId: "$followings.followingId",
fullName: "$users2.fullName",
}
}
}
}
]);
But I'm getting 2 followers and 2 followings. I wonder what's causing this issue. Appreciate any help. Thanks!
You can try,
$addFields to make a unique array called userIds form both arrays followers and followings, $setUnion to get unique ids,
$lookup with users collection
$project to show fields,
followers get fullName, $map to iterate loop of followers and get the name of followerId from users array using $reduce and $cond
followings get fullName, $map to iterate loop of followings and get the name of followingId from users array using $reduce and $cond
db.followings.aggregate([
{
$addFields: {
userIds: {
$setUnion: [
{
$map: {
input: "$followers",
in: "$$this.followerId"
}
},
{
$map: {
input: "$followings",
in: "$$this.followingId"
}
}
]
}
}
},
{
$lookup: {
from: "users",
localField: "userIds",
foreignField: "_id",
as: "users"
}
},
{
$project: {
userId: 1,
followers: {
$map: {
input: "$followers",
as: "f",
in: {
$mergeObjects: [
"$$f",
{
fullName: {
$reduce: {
input: "$users",
initialValue: "",
in: {
$cond: [
{ $eq: ["$$this._id", "$$f.followerId"] },
"$$this.fullName",
"$$value"
]
}
}
}
}
]
}
}
},
followings: {
$map: {
input: "$followings",
as: "f",
in: {
$mergeObjects: [
"$$f",
{
fullName: {
$reduce: {
input: "$users",
initialValue: "",
in: {
$cond: [
{ $eq: ["$$this._id", "$$f.followingId"] },
"$$this.fullName",
"$$value"
]
}
}
}
}
]
}
}
}
}
}
])
Playground
when working with relations on Mongoose you should create the relationship based on a unique id, and then reference the document. In your case, it would be:
followingSchema = new mongoose.Schema({
{
"followers": [{type: mongoose.Schema.types.ObjectId, ref="User"}],
"followings": [{type: mongoose.Schema.types.ObjectId, ref="User"}],
}
})
userSchema = new mongoose.Schema({
fullname: String,
})
be aware that the userId will be created automatically by Mongoose with a field called _id. so, the end result of creating a new following documents would be:
{
_id: "klajdsfñalkdjf" //random characters created by mongoose,
followers: ["adfadf134234", "adfadte345"] //same as before, these are Ids of the users randomly generated by mongoose
followers: ["adfadf134234", "adfadte345"]
}
{
_id: adfadf134234,
fullName: "alex",
}
now, because there is no use for us to have a random number as information in the fields of following and followers in the following object, we can now use the method .populate() that can be used over the document itself to transform those Ids into actual information. You can see more about it here: mongoose documentation
our final result would be something like this:
{
_id: "añfakdlsfja",
followers : [{_id: "adlfadsfj", fullName: "alex"}],
following : [{_id: "adfadfad" , fullName: "other"}, {_id: "dagadga", fullName: "another"}]
}

Mongodb lookup with match in aggregate returns an empty array

This is my user document
{
"_id":"02a33b9a-284c-4869-885e-d46981fdd679",
"context":{
"email":"someemail#gmail.com",
"firstName":"John",
"lastName":"Smith",
"company":[
"e2467c93-114b-4613-a842-f311a8c537b3"
],
},
}
and a company document
{
"_id":"e2467c93-114b-4613-a842-f311a8c537b3",
"context":{
"name":"Coca Cola",
"image":"someimage",
},
};
This is my query for users
let users = await Persons.aggregate(
[{$project:
{
name: {$concat: ['$context.firstName', ' ', '$context.lastName']},
companyId: {$arrayElemAt: ['$context.company', 0]}}
},
{$match: {name: searchRegExp}},
{$lookup: {from: 'companies', let: {company_id: {$arrayElemAt: ['$context.company', 0]}}, pipeline:
[
{
$match: {
$expr: {
$eq: ['$_id', '$$company_id']
}
}
},
{
$project: {name: '$context.name'}
}
],
as: 'company'}}
]).toArray()
When I run this query I get company field as an empty array, what am I doing wrong here?
Your first pipeline stage $project only outputs _id, name and companyId so then when you're trying to refer to $context.company in your $lookup there will be an empty value. You can use $addFields instead:
{
$addFields: {
name: {
$concat: [
"$context.firstName",
" ",
"$context.lastName"
]
},
companyId: {
$arrayElemAt: [
"$context.company",
0
]
}
}
}
Mongo Playground
When you add field companyId: {$arrayElemAt: ['$context.company', 0]}}, then you can use the simple version of $lookup. There is no need to set it twice, once as companyId: ... and in let: {company_id: ...}
db.user.aggregate([
{
$addFields: {
name: { $concat: ["$context.firstName", " ", "$context.lastName"] },
companyId: { $arrayElemAt: ["$context.company", 0] }
}
},
{
$lookup: {
from: "company",
localField: "companyId",
foreignField: "_id",
as: "company"
}
}
])

Display array of documents as objects with id as key

Is there a way to use project or group in mongo to convert an array of documents or subdocuments into an object with documents ObjectId as key and document/subdocument as value?
For example:
const book = Schema({
name: {type: String},
authors: [{type: mongoose.Schema.Types.ObjectId, ref: 'Author'}]
})
const author = Schema({name: {type: String}})
If you query for all books:
Book.find({}).populate('authors').lean().exec()
then you get:
[{
id: 10,
name: 'Book 1',
authors: [{
id: 1,
name: 'Author1'
}]
},...]
but I would like to have this:
[{
id: 10,
name: 'Book 1',
authors: {
1: {id: 1, name: 'Author 1'}
}
},...]
I know that iterating over the objects after querying from mongo will do it but I guess that running the query at mongo is more efficient.
The main consideration here is that the "keys" you want are actually ObjectId values as defined in your schema and not really a "string", which is actually a requirement for a JavaScript Object since all "keys" must be a "string". For JavaScript this really is not much of an issue since JavScript will "stringify" any argument specified as a "key", but it does matter for BSON, which is what MongoDB actually "speaks"
So you can do this with MongoDB, but you need MongoDB 4.x at least in order to support the $convert aggregation operator or it's shortcut method $toString. This also means that rather than populate(), you actually use MongoDB $lookup:
let results = await Books.aggregate([
{ "$lookup": {
"from": Author.collection.name,
"localField": "authors",
"foreignField": "_id",
"as": "authors"
}},
{ "$addFields": {
"authors": {
"$arrayToObject": {
"$map": {
"input": "$authors",
"in": { "k": { "$toString": "$$this._id" }, "v": "$$this" }
}
}
}
}}
])
Or if you prefer the alternate syntax:
let results = await Books.aggregate([
{ "$lookup": {
"from": "authors",
"let": { "authors": "$authors" },
"pipeline": [
{ "$match": { "$expr": { "$in": [ "$_id", "$$authors" ] } } },
{ "$project": {
"_id": 0,
"k": { "$toString": "$_id" },
"v": "$$ROOT"
}}
],
"as": "authors"
}},
{ "$addFields": {
"authors": { "$arrayToObject": "$authors" }
}}
])
Which would return something like:
{
"_id" : ObjectId("5c7f046a7cefb8bff9304af8"),
"name" : "Book 1",
"authors" : {
"5c7f042e7cefb8bff9304af7" : {
"_id" : ObjectId("5c7f042e7cefb8bff9304af7"),
"name" : "Author 1"
}
}
}
So the $arrayToObject does the actual "Object" output where you supply it an array of objects with k and v properties corresponding to key and value. But it must have a valid "string" in "k" which is why you $map over the array content to reformat it first.
Or as the alternate, you can $project within the pipeline argument of $lookup instead of using $map later for exactly the same thing.
With client side JavaScript, the translation is a similar process:
let results = await Books.aggregate([
{ "$lookup": {
"from": Author.collection.name,
"localField": "authors",
"foreignField": "_id",
"as": "authors"
}},
/*
{ "$addFields": {
"authors": {
"$arrayToObject": {
"$map": {
"input": "$authors",
"in": { "k": { "$toString": "$$this._id" }, "v": "$$this" }
}
}
}
}}
*/
])
results = results.map(({ authors, ...rest }) =>
({
...rest,
"authors": d.authors.reduce((o,e) => ({ ...o, [e._id.valueOf()]: e }),{})
})
)
Or with populate()
let results = await Book.find({}).populate("authors");
results = results.map(({ authors, ...rest }) =>
({
...rest,
"authors": (!authors) ? {} : authors.reduce((o,e) => ({ ...o, [e._id.toString()]: e }),{})
})
)
NOTE however that populate() and $lookup are really quite different. MongoDB $lookup is a single request to the server that returns one response. Using populate() actually invokes multiple queries and does the "joining" in client side JavaScript code even if it hides what it is doing from you.

Resources