aggregate nested array of objects using mongoose - node.js

I have the following model and I want to query a specific user on _id field and populate the inbox.messages array with the necessary data that matches the corresponding _id field in the users model and more importantly i also want to group each message by the 'from' field and return that result
const UserSchema = new Schema({
username: {
type: String,
required: true,
},
blockedUsers: {
users: [
{
userId: {type: Schema.Types.ObjectId, ref: 'User', required: true },
}
]
},
favorites: {
users: [
{
userId: {type: Schema.Types.ObjectId, ref: 'User', required: true },
}
]
},
profileViews: {
views: [
{
userId: {type: Schema.Types.ObjectId, ref: 'User', required: true },
date: {type: Date}
}
]
},
inbox: {
messages: [
{
messageId: {type: Schema.Types.ObjectId},
from: {type: Schema.Types.ObjectId, ref: 'User', required: true },
content: {type: String, required: true},
date: {type: Date}
}
]
},
images: {
"imagePaths": [
{
imageId: {type: Schema.Types.ObjectId},
path: { type: String, required: true},
date: {type: Date}
}
],
}
})
what I have so far
let incomingId = '5e29fd75fdfd5320d0e42bc4';
let myUser = await User.aggregate([
{ $match: {"_id": mongoose.Types.ObjectId(incomingId) }},
{ $lookup: { }}
])
Not sure exactly what to put in the $lookup field or if this is even correct.
As a sample I would like the documents to look like:
[
{
"from": "5e240f7480a24e07d832c7bd",
"username":"hable0",
"images": {
imagePaths: [
'images/2020-09-24-Z_34234342_12.jpg'
],
},
"inbox": {
"messages": [
{
"messageId": "5e2a110a21c64d63f451e39e",
"content": "Message content",
"date": "2020-01-23T21:32:58.126Z"
},
{
"messageId": "5e2a111321c64d63f451e3a0",
"content": "Message content",
"date": "2020-01-23T21:33:07.378Z"
},
{
"messageId": "5e2a112321c64d63f451e3a2",
"content": "Message content",
"date": "2020-01-23T21:33:23.036Z"
}
]
}
}
]

You could try the following pipeline with aggregate().
Find the document that matches the id
Unwind inbox.messages
Group by from field
Perform a $lookup to get another document
Perform a $unwind to destruct the array
Specify fields to be included in the output
let myUser = await User.aggregate([
{
$match: { "_id": mongoose.Types.ObjectId(incomingId) }
},
{
$unwind: "$inbox.messages"
},
{
$group: {
_id: { from: "$inbox.messages.from" },
messages: {
$push: {
messageId: "$inbox.messages.messageId"
// Add more info of the message here as needed
}
}
},
},
{
$lookup: {
from: "User",
localField: "_id.from",
foreignField: "_id",
as: "extraUserInfo"
}
},
{
$unwind: "$extraUserInfo"
},
{
$project: {
_id: 0,
from: "$_id.from",
inbox: { messages: "$messages" },
username: "$extraUserInfo.username",
images: "$extraUserInfo.images"
}
}
]);
Sample output:
{
"from": "user1",
"inbox": {
"messages": [{
"messageId": "message1-from-user1"
}]
},
"username": "user1-username",
"images": {
"imagePaths": ["image-path-user1"]
}
} {
"from": "user2",
"inbox": {
"messages": [{
"messageId": "message1-from-user2"
}, {
"messageId": "message2-from-user2"
}, {
"messageId": "message3-from-user2"
}]
},
"username": "user2-username",
"images": {
"imagePaths": ["image-path-user2"]
}
} {
"from": "user3",
"inbox": {
"messages": [{
"messageId": "message1-from-user3"
}, {
"messageId": "message2-from-user3"
}]
},
"username": "user3-username",
"images": {
"imagePaths": ["image-path-user3"]
}
}
Hope this answers part of your question. Though I'm not very clear how you would like to populate the messages array with the user info who sent the messages. But you can perform a $lookup() with a pipeline after $group() operation to attach additional info from the sender to the result.
Read more about $unwind, $group, $project and $lookup.

Related

How can I populate the fields after running the aggregation mongodb

I am trying to populate the fields in the result that I got from running the $geoNear aggregation but I don't know how can I do it. I tried $lookup but it gave me the same result.
const users = await locations.aggregate([
{
$geoNear: {
near: {
type: "Point",
coordinates: req.body.coordinates,
},
maxDistance: req.body.maxDistance,
distanceField: "dist.calculated",
spherical: true,
},
{
$lookup: {
from: "users",
localField: "userId", // getting empty array of users
foreignField: "_id",
as: "users",
},
}, // getting all users data
{
$project: {
_id: 1,
name: 1,
profession: 1,
},
}, // only getting `_id`
},
]);
Result:
{
{
"_id": "63555c4f29820cf3c7667eb5",
"userId": "63555c2629820cf3c7667eac",
"location": {
"coordinates": [
2.346688,
48.858888
],
"type": "Point"
},
"createdAt": "2022-10-23T15:22:55.820Z",
"updatedAt": "2022-10-23T16:08:59.979Z",
"__v": 2,
"dist": {
"calculated": 42.95013302539912
}
}
}
I want to populate the name field from the users schema and to do so I have used the ref of the users schema in the locations schema.
users schema:
{
name: {
type: String,
required: true,
},
email: {
type: String,
required: true,
unique: true,
index: true,
},
}
location Schema:
{
userId: {
type: mongoose.Schema.ObjectId,
ref: "users",
required: true,
},
location: {
type: {
type: String,
enum: ["Point"],
default: "Point",
},
coordinates: {
type: [Number],
default: [0, 0],
},
},
},
How can I populate fields in my aggregation result?
Also, How can I remove all the fields and output the names and email only from the users schema?
You should include your $lookup stage as following:
{
$lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "user",
}
},
{
"$replaceRoot": {
"newRoot": {
"$arrayElemAt": [
"$user",
0
]
}
}
},
{
$project: {
_id: 1,
name: 1,
profession: 1
}
}
It seems like you tried to match an ObjectId to a String (property name), which will never match. Using the aggregation step above you should receive a populated user array containing exactly one entry. After that you can project the result and transform the array to a plain object (if needed).

MongoDB, sort the results by the number of matching elements between two arrays

In my project I use MongoDB as a database (specifically the mongoose driver for typescript, but this shouldn't matter) and I have a collection of posts that follow this schema:
export const PostSchema = new Schema({
author: { type: Types.ObjectId, required: true, ref: 'User' },
text: { type: String, required: true },
tags: [{ type: Types.ObjectId, required: true, ref: 'Tag' }],
location: { type: PointSchema, required: true },
}
export const PointSchema = new Schema({
type: {
type: String,
enum: ['Point'],
required: true,
},
coordinates: {
type: [Number],
required: true,
},
locationName: {
type: String,
required: true,
},
});
My question is if it is possible to write a query (I think an aggregation is needed) that returns all posts that meet a condition (such as that the position must be at a specific distance) and orders the results by a specific array of tags passed as an argument (in my case the array varies from user to user and represents their interests).
That is, choosing the array ["sport", "music", "art"] as an example, I would like a query that retrieves from the database all the posts that meet a certain condition (irrelevant in this question) and orders the results so that first are documents whose array of tags share elements with the array ["sport", "music", "art"], and only at the end the documents without any correspondence.
That is, something like this:
[
{
_id: "507f191e810c19729de860ea",
tags: ["sport", "art", "tennis"] // 2 matches
},
{
_id: "507f191e810c1975de860eg",
tags: ["sport", "food"] // 1 matches
},
{
_id: "607f191e810c19729de860ea",
tags: ["animals", "art"] // 1 matches
},
{
_id: "577f191e810c19729de860ea",
tags: ["animals", "zoo"] //0 matches
}
]
if your collection looks like this:
[
{
"author": "John",
"tags": [
ObjectId("60278ce8b370ff29b83226e2"), // Sport
ObjectId("60278ce8b370ff29b83226e8"), // Music
ObjectId("60278ce8b370ff29b83226e5"), // Food
]
},
{
"author": "Dheemanth",
"tags": [
ObjectId("60278ce8b370ff29b83226e7"), // Tech
ObjectId("60278ce8b370ff29b83226e5"), // Food
ObjectId("60278ce8b370ff29b83226e2") // Sport
]
},
{
"author": "Niccolo",
"tags": [
ObjectId("60278ce8b370ff29b83226e2"), // Sport
ObjectId("60278ce8b370ff29b83226e8"), // Music
ObjectId("60278ce8b370ff29b83226e3") // Art
]
}
]
then this is the solution:
db.posts.aggregate([
{
$lookup: {
from: "tags",
let: { "tags": "$tags" },
pipeline: [
{
$match: {
$expr: { $in: ["$_id", "$$tags"] }
}
}
],
as: "tags"
}
},
{
$addFields: {
"tagCount": {
$size: {
$filter: {
input: "$tags",
as: "tag",
cond: { $in: ["$$tag.name", ["sport", "music", "art"]] }
}
}
}
}
},
{
$sort: { tagCount: -1 }
},
{
$project: {
_id: 1,
tags: "$tags.name"
}
}
])
Output:
[
{
"_id": ObjectId("60278e14b370ff29b83226eb"),
"tags": ["sport", "art", "music"]
},
{
"_id": ObjectId("60278e14b370ff29b83226e9"),
"tags": ["sport", "food", "music"]
},
{
"_id": ObjectId("60278e14b370ff29b83226ea"),
"tags": ["sport", "food", "tech"]
}
]

Mongoose join additional collection to two aggregated collections

This is a new requirement I need to implement in my personal project. I successfully joined two collections using aggregate (previous question). Now, I have a new collection that I need to join. Please see codes below:
Student model:
{
fullname: { type: String, required: true },
email: { type: String, required: true },
}
Exams model:
{
test: { type: String, required: false },
top10: [
type: {
studentId: { type: String, required: true },
score: { type: Number, required: false },
}
]
}
Aggregated collections:
db.exams.aggregate([
{ $unwind: "$top10" },
{
$lookup: {
from: "students",
localField: "top10.studentId",
foreignField: "_id",
as: "students"
}
},
{ $addFields: { students: { $arrayElemAt: ["$students", 0] } } },
{
$group: {
_id: "$_id",
test: { $first: "$test" },
students: {
$push: {
studentId: "$top10.studentId",
score: "$top10.score",
fullname: "$students.fullname"
}
}
}
}
])
Now, the 3rd collection is called Teacher.
Teacher model:
{
fullName: { type: String, required: false },
studentId: { type: String, required: false }
}
Expected output should be:
{
"test": "Sample Test #1",
"students": [
{
"studentId": "5f22ef443f17d8235332bbbe",
"fullname": "John Smith",
"score": 11,
"teacher": "Dr. Hofstadter"
},
{
"studentId": "5f281ad0838c6885856b6c01",
"fullname": "Erlanie Jones",
"score": 9,
"teacher": "Mr. Roberts"
},
{
"studentId": "5f64add93dc79c0d534a51d0",
"fullname": "Krishna Kumar",
"score": 5,
"teacher": "Ms. Jamestown"
}
]
}
Appreciate kind of help. Thanks!
You can add these 2 more pipelines before the $group pipeline,
$lookup join collection with teacher
$addFields to get convert array to object
$group to add teacher name in students array
{
$lookup: {
from: "teacher",
localField: "top10.studentId",
foreignField: "studentId",
as: "students.teacher"
}
},
{
$addFields: {
"students.teacher": { $arrayElemAt: ["$students.teacher", 0] }
}
},
{
$group: {
_id: "$_id",
test: { $first: "$test" },
students: {
$push: {
studentId: "$top10.studentId",
score: "$top10.score",
fullname: "$students.fullname",
teacher: { $ifNull: ["$students.teacher.fullname", ""] }
}
}
}
}
Playground

I need to aggregate two collections based on userIds but couldn't manage it

I want to aggregate the collections (Review and Account) below but couldn't manage it properly so I needed to ask you guys.
Current Review Collection is written below
{
lawyerId: { type: mongoose.Schema.Types.ObjectId },
reviews: [
{
userId: { type: mongoose.Schema.Types.ObjectId, unique: true },
message: { type: String },
rate: { type: Number },
createdAt: { type: Date, default: Date.now },
},
],
}
If you recommend Review Collection can be refactored like this
{
lawyerId: { type: mongoose.Schema.Types.ObjectId },
userId: { type: mongoose.Schema.Types.ObjectId },
message: { type: String },
rate: { type: Number },
createdAt: { type: Date, default: Date.now },
}
Account Collection
{
_id: { type: mongoose.Schema.Types.ObjectId}
email: { type: String, unique: true },
firstName: { type: String },
lastName: { type: String },
},
The expected result of fetching reviews
{
averageRate: 3.2,
reviews: [
{
firstName: 'Jack',
lastName: 'Harden',
message: 'I dont like it',
rate: 2,
createdAt: '2020-01-01T14:58:23.330+00:00'
},
{
firstName: 'Takeshi',
lastName: 'San',
message: 'Thats nice',
rate: 5,
createdAt: '2020-03-02T10:45:10.120+00:00'
}
],
}
You should be able to achieve this using an aggregation.
You can view a live demo here, which allows you to run this query.
The Query:
// Assuming we are searching for an lawyerId of 3
db.review.aggregate([
{
$match: {
lawyerId: 3
}
},
{
$lookup: {
from: "account",
localField: "userId",
foreignField: "_id",
as: "user"
}
},
{
$unwind: "$user"
},
{
$group: {
_id: "$lawyerId",
averageRate: {
$avg: "$rate"
},
reviews: {
$push: {
createdAt: "$createdAt",
firstName: "$user.firstName",
lastName: "$user.lastName",
message: "$message",
rate: "$rate"
}
}
}
},
{ // *******************************************
$project: { // *******************************************
_id: 0, // If you comment out/remove all of these lines
averageRate: 1, // then the return also contains the 'lawyerId',
reviews: 1 // as '_id', which I would find useful...
} // *******************************************
} // *******************************************
])
The Results:
The query from above, using the data set from above, produces the following results:
[
{
"averageRate": 3.25,
"reviews": [
{
"createdAt": ISODate("2015-02-28T00:00:00Z"),
"firstName": "First",
"lastName": "Uno",
"message": "Message meh",
"rate": 3
},
{
"createdAt": ISODate("2015-02-28T00:00:00Z"),
"firstName": "Second",
"lastName": "Dos",
"message": "Message blah",
"rate": 4
},
{
"createdAt": ISODate("2015-02-28T00:00:00Z"),
"firstName": "First",
"lastName": "Uno",
"message": "Message foo",
"rate": 4
},
{
"createdAt": ISODate("2015-02-28T00:00:00Z"),
"firstName": "Third",
"lastName": "Tres",
"message": "Message bar",
"rate": 2
}
]
}
]
The Dataset:
In Mongo Playground, you can build out databases with multiple collections, this explains the data structure:
db={ // <---- Database 'db'
"account": [ // <---- Collection 'account'
{
_id: 21,
email: "first.uno#gmail.com",
firstName: "First",
lastName: "Uno"
},
{
_id: 22,
email: "second.dos#yahoo.com",
firstName: "Second",
lastName: "Dos"
},
{
_id: 23,
email: "third.tres#hotmail.com",
firstName: "Third",
lastName: "Tres"
}
],
"review": [ // <---- Collection 'review'
{
lawyerId: 3,
userId: 21,
message: "Message meh",
rate: 3,
createdAt: ISODate("2015-02-28T00:00:00Z")
},
{
lawyerId: 3,
userId: 22,
message: "Message blah",
rate: 4,
createdAt: ISODate("2015-02-28T00:00:00Z")
},
{
lawyerId: 3,
userId: 21,
message: "Message foo",
rate: 4,
createdAt: ISODate("2015-02-28T00:00:00Z")
},
{
lawyerId: 3,
userId: 23,
message: "Message bar",
rate: 2,
createdAt: ISODate("2015-02-28T00:00:00Z")
}
]
}
You can try this pipeline to get all reviews from review collection:
db.reviews.aggregate([
{
$lookup: {
from: "accounts",
localField: "userId",
foreignField: "_id",
as: "user"
}
},
{
$unwind: "$user"
},
{
$addFields: {
"firstName": "$user.firstName",
"lastName": "$user.lastName"
}
},
{
$group: {
"_id": null,
"average_rate": {
$avg: "$rate"
},
"reviews": {
$push: "$$ROOT"
}
}
},
{
$unset: [
"_id",
"reviews._id",
"reviews.user",
"reviews.userId",
"reviews.lawyerId"
]
}
])
Results:
[
{
"average_rate": 3.5,
"reviews": [
{
"createdAt": "Some Review Date",
"firstName": "Jack",
"lastName": "Harden",
"message": "I dont like it",
"rate": 2
},
{
"createdAt": "Some Review Date",
"firstName": "Takeshi",
"lastName": "San",
"message": "That's nice",
"rate": 5
}
]
}
]
Demo here

Mongodb aggregate pipeline to return multiple fields with $lookup from array

I'm trying to get a list of sorted comments by createdAt from a Post doc where an aggregate pipeline would be used to populate the owner of a comment in comments field with displayName and profilePhoto fields.
Post Schema:
{
_owner: { type: Schema.Types.ObjectId, ref: 'User', required: true },
...
comments: [
{
_owner: { type: Schema.Types.ObjectId, ref: 'User' },
createdAt: Number,
body: { type: String, maxlength: 200 }
}
]
}
User schema:
{
_id: '123abc'
profilePhoto: String,
displayName: String,
...
}
What I want to return:
[
{
"_id": "5bb5e99e040bf10b884b9653",
"_owner": {
"_id": "5bb51a97fb250722d4f5d5e1",
"profilePhoto": "https://...",
"displayName": "displayname"
},
"createdAt": 1538648478544,
"body": "Another comment"
},
{
"_id": "5bb5e96686f1973274c03880",
"_owner": {
"_id": "5bb51a97fb250722d4f5d5e1",
"profilePhoto": "https://...",
"displayName": "displayname"
},
"createdAt": 1538648422471,
"body": "A new comment"
}
]
I have some working code that goes from aggregate to get sorted comments first, then I populate separately but I want to be able to get this query just by using aggregate pipeline.
Current solution looks like this:
const postComments = await Post.aggregate([
{ $match: { _id: mongoose.Types.ObjectId(postId) } },
{ $unwind: '$comments' },
{ $limit: 50 },
{ $skip: 50 * page },
{ $sort: { 'comments.createdAt': -1 } },
{$replaceRoot: {newRoot: '$comments'}},
{
$project: {
_owner: 1,
createdAt: 1,
body: 1
}
}
]);
await Post.populate(postComments, {path: 'comments._owner', select: 'profilePhoto displayName' } )
You can try below aggregation
const postComments = await Post.aggregate([
{ "$match": { "_id": mongoose.Types.ObjectId(postId) } },
{ "$unwind": "$comments" },
{ "$lookup": {
"from": "users",
"localField": "comments._owner",
"foreignField": "_id",
"as": "comments._owner"
}},
{ "$unwind": "$comments._owner" },
{ "$replaceRoot": { "newRoot": "$comments" }},
{ "$sort": { "createdAt": -1 } }
{ "$limit": 50 }
])

Resources