Mongoose: how to use aggregate and find together - node.js

How can I use aggregate and find together in Mongoose?
i.e I have the following schema:
const schema = new Mongoose.Schema({
created: { type: Date, default: Date.now() },
name: { type: String, default: 'development' }
followers: [{ type: Mongoose.Schema.ObjectId, ref: 'Users'}]
...
})
export default Mongoose.model('Locations', schema)
How can I query the users with only the fields name and followers_count.
followers_count: the length of followers .
There, I know we can use select to get only the field name.
How can we get the count of followers?

For MongoDB 3.6 and greater, use the $expr operator which allows the use of aggregation expressions within the query language:
var followers_count = 30;
db.locations.find({
"$expr": {
"$and": [
{ "$eq": ["$name", "development"] },
{ "$gte": [{ "$size": "$followers" }, followers_count ]}
]
}
});
For non-compatible versions, you can use both the $match and $redact pipelines to query your collection. For example, if you want to query the locations collection where the name is 'development' and followers_count is greater than 30, run the following aggregate operation:
const followers_count = 30;
Locations.aggregate([
{ "$match": { "name": "development" } },
{
"$redact": {
"$cond": [
{ "$gte": [ { "$size": "$followers" }, followers_count ] },
"$$KEEP",
"$$PRUNE"
]
}
}
]).exec((err, locations) => {
if (err) throw err;
console.log(locations);
})
or within a single pipeline as
Locations.aggregate([
{
"$redact": {
"$cond": [
{
"$and": [
{ "$eq": ["$name", "development"] },
{ "$gte": [ { "$size": "$followers" }, followers_count ] }
]
},
"$$KEEP",
"$$PRUNE"
]
}
}
]).exec((err, locations) => {
if (err) throw err;
console.log(locations);
})
The above will return the locations with just the _id references from the users. To return the users documents as means to "populate" the followers array, you can then append the $lookup pipeline.
If the underlying Mongo server version is 3.4 and newer, you can run the pipeline as
let followers_count = 30;
Locations.aggregate([
{ "$match": { "name": "development" } },
{
"$redact": {
"$cond": [
{ "$gte": [ { "$size": "$followers" }, followers_count ] },
"$$KEEP",
"$$PRUNE"
]
}
},
{
"$lookup": {
"from": "users",
"localField": "followers",
"foreignField": "_id",
"as": "followers"
}
}
]).exec((err, locations) => {
if (err) throw err;
console.log(locations);
})
else you would need to $unwind the followers array before applying $lookup and then regroup with $group pipeline after that:
let followers_count = 30;
Locations.aggregate([
{ "$match": { "name": "development" } },
{
"$redact": {
"$cond": [
{ "$gte": [ { "$size": "$followers" }, followers_count ] },
"$$KEEP",
"$$PRUNE"
]
}
},
{ "$unwind": "$followers" },
{
"$lookup": {
"from": "users",
"localField": "followers",
"foreignField": "_id",
"as": "follower"
}
},
{ "$unwind": "$follower" },
{
"$group": {
"_id": "$_id",
"created": { "$first": "$created" },
"name": { "$first": "$name" },
"followers": { "$push": "$follower" }
}
}
]).exec((err, locations) => {
if (err) throw err;
console.log(locations);
})

You can use as the following:
db.locations.aggregate([
{$match:{"your find query"}},
{$project:{"your desired fields"}}
])
In the match you can do stuff like:
{{$match:{name:"whatever"}}
In the project, you can select the fields you want using numbers 0 or 1 like:
{$project:{_id:1,created:0,name:1}}
Which 0 means, do not put and 1 means put.

Related

Optimize mongoDB query to get count of items from separate collection

I have two collections namely "tags" and "bookmarks".
Tags documents:
{
"taggedBookmarksCount": 2,
"taggedNotesCount": 0,
"_id": "627a80e6b12b0dc78b3a6d4b",
"name": "Article"
},
{
"taggedBookmarksCount": 0,
"taggedNotesCount": 0,
"_id": "62797885b479b5906ef6ed43",
"name": "Client"
},
Bookmark Documents:
{
"_id": "627a814db12b0dc78b3a6d54",
"bookmarkTags": [
{
"tagId": "627a814db12b0dc78b3a6d55",
"tag": "Article"
},
{
"tagId": "627a814db12b0dc78b3a6d56",
"tag": "to be read"
}
],
"bookmarkTitle": "Please sorrow of work",
}
Objective is to get the counts of bookmarks for all the tags in the "tags" collection.
Below is my current implementation, which returns the count of bookmarks for each tags.But this query takes around 3 sec to run (REST API response time) for 20 tags.
tags = await Tag.find(
{
userId: req.params.userId
},
{ _id: 1 }
);
tagIds = tags.map(tag => {
return tag._id.toString();
});
const tagCounts = await Promise.all(
tagIds.map(async tagId => {
const count = await Model.aggregate([
{
$match: {
bookmarkTags: {
$elemMatch: {
tagId: tagId
}
}
}
},
{
$group: {
_id: '_id',
count: {
$sum: 1
}
}
}
]);
return { tagId, count: count[0] ? count[0].count : 0 };
})
);
I am assuming its taking longer as I am mapping over all the tags, there are multiple round trips to database.Please suggest an approach to reduce the time of query execution.
You can do as below
db.bookmark.aggregate([
{
"$unwind": "$bookmarkTags" //Reshape tags
},
{
"$lookup": { //Do a join
"from": "tags",
"localField": "bookmarkTags.tagId",
"foreignField": "_id",
"as": "btags"
}
},
{
"$unwind": { //reshape the array elements
path: "$btags",
preserveNullAndEmptyArrays: true
}
},
{
"$group": { // Group tag wise bookmarks
"_id": "$bookmarkTags.tagId",
"docs": {
"$addToSet": "$btags"
}
}
},
{
"$project": { //Get counts, project what you want.
tag_id: "$_id",
"count": {
"$size": "$docs"
},
_id: 0
}
}
])
Playground
If you have given list of tag ids, then you can use it in match stage.
Updated playground
db.bookmark.aggregate([
{
"$unwind": "$bookmarkTags"
},
{
"$lookup": {
"from": "tags",
"localField": "bookmarkTags.tagId",
"foreignField": "_id",
"as": "btags"
}
},
{
"$unwind": {
path: "$btags",
preserveNullAndEmptyArrays: true
}
},
{
"$group": {
"_id": "$btags._id",
"docs": {
"$push": "$btags"
}
}
},
{
"$project": {
tag_id: "$_id",
"count": {
"$size": "$docs"
},
_id: 0
}
}
])

How to update an array in mongodb by using another array as both identifier and value?

Imagine the is a document like this:
{
_id: ObjectID('someIdblahbla')
users: [
{
_id: 'id1',
name: 'name1',
},
{
_id: 'id2',
name: 'name2',
},
{
_id: 'id3',
name: 'name3'
}
]
}
I have an array like this:
const newData = [
{_id: 'id1', name: 'newName1'},
{_id: 'id2', 'name': 'newName2', family:'newFamily2'}
]
what I want is to update the array in the document using the corresponding _id and using it to add/update each element.
so my end result would be like:
{
_id: ObjectID('someIdblahbla')
users: [
{
_id: 'id1',
name: 'newName1',
},
{
_id: 'id2',
name: 'newName2',
family:'newFamily2'
},
{
_id: 'id3',
name: 'name3'
}
]
}
my guess was using The filtered positional operator but I am not sure if it's the correct way to go and how to do it.
thank you for your kind tips beforehand.
There is no straight way to add/update in array, you can use update with aggregation pipeline starting from MongoDB 4.2,
First of all, you need to convert _id from string to objectId type, if you are using mongoose npm you can use mongoose.Types.ObjectId method or if you are using mongodb npm you can use ObjectId method,
let newData = [
{ _id: 'id1', name: 'newName1' },
{ _id: 'id2', 'name': 'newName2', family:'newFamily2' }
];
let newIds = [];
newData = newData.map(n => {
n._id = ObjectId(n._id); // or mongoose.Types.ObjectId(n._id)
newIds.push(n._id); // for checking conditions
return n;
});
You can put query condition, and do below operations,
$map to iterate loop of users array, check condition if user._id is in input newIds then do update operation otherwise do insert operation
update operation:
$filter to iterate loop of input newData and filter already present object from input so we can update it
$arrayElemAt to get first object from above filtered array
$mergeObjects to merge current object with above input object
insert operation:
$filter to iterate loop of newData array and return not present object means new items in array of objects
$concatArrays to concat above new and updated result array
db.collection.updateOne(
{ _id: ObjectId("someIdblahbla") },
[{
$set: {
users: {
$concatArrays: [
{
$map: {
input: "$users",
as: "u",
in: {
$cond: [
{ $in: ["$$u._id", newIds] },
{
$mergeObjects: [
"$$u",
{
$arrayElemAt: [
{
$filter: {
input: newData,
cond: { $eq: ["$$this._id", "$$u._id"] }
}
},
0
]
}
]
},
"$$u"
]
}
}
},
{
$filter: {
input: newData,
cond: { $not: { $in: ["$$this._id", "$users._id"] } }
}
}
]
}
}
}]
)
Playground
Query1 (update(merge objects) existing members, doesn't add new members)
Test code here
Replace
[{"_id": "id1","name": "newName1"},{"_id": "id2","name": "newName2","family": "newFamily2"}] with you array or the driver variable that hold the array
db.collection.update({
"_id": {
"$eq": "1"
}
},
[
{
"$addFields": {
"users": {
"$map": {
"input": "$users",
"as": "user",
"in": {
"$reduce": {
"input": [
{
"_id": "id1",
"name": "newName1"
},
{
"_id": "id2",
"name": "newName2",
"family": "newFamily2"
}
],
"initialValue": "$$user",
"in": {
"$let": {
"vars": {
"old_user": "$$value",
"new_user": "$$this"
},
"in": {
"$cond": [
{
"$eq": [
"$$old_user._id",
"$$new_user._id"
]
},
{
"$mergeObjects": [
"$$old_user",
"$$new_user"
]
},
"$$old_user"
]
}
}
}
}
}
}
}
}
}
])
Query2 (update(merge) if found, else push in the end)
Its like the above but finds the not-existing members,and push them in the end.Its a bit more slower and complicated
Test code here
Replace
[{"_id": "id1","name": "newName1"},{"_id": "id2","name": "newName2","family": "newFamily2"},{"_id": "id4","name": "newName4"}]
with your array or the driver variable that hold the array
db.collection.update({
"_id": {
"$eq": "1"
}
},
[
{
"$addFields": {
"yourarray": [
{
"_id": "id1",
"name": "newName1"
},
{
"_id": "id2",
"name": "newName2",
"family": "newFamily2"
},
{
"_id": "id4",
"name": "newName4"
}
]
}
},
{
"$addFields": {
"new-ids": {
"$setDifference": [
{
"$map": {
"input": "$yourarray",
"as": "u",
"in": "$$u._id"
}
},
{
"$map": {
"input": "$users",
"as": "u",
"in": "$$u._id"
}
}
]
}
}
},
{
"$addFields": {
"users": {
"$concatArrays": [
{
"$map": {
"input": "$users",
"as": "user",
"in": {
"$reduce": {
"input": "$yourarray",
"initialValue": "$$user",
"in": {
"$let": {
"vars": {
"old_user": "$$value",
"new_user": "$$this"
},
"in": {
"$cond": [
{
"$eq": [
"$$old_user._id",
"$$new_user._id"
]
},
{
"$mergeObjects": [
"$$old_user",
"$$new_user"
]
},
"$$old_user"
]
}
}
}
}
}
}
},
{
"$filter": {
"input": "$yourarray",
"as": "u",
"cond": {
"$in": [
"$$u._id",
"$new-ids"
]
}
}
}
]
}
}
},
{
"$unset": [
"yourarray",
"new-ids"
]
}
])

Aggregate with multiple lookup and pipeline return only the last element

I try below code, it's working to lookup value from other collection. But why it only return the last element.
If I omitted the unwind function, It does return all result from the model, but the second lookup will not working as the first lookup return arrays.
My objective is to look up folder that include the model id which represented in templatefolders collection.
const result = await this.dashboardModel
.aggregate([{ $match: filter }])
.lookup({
from: 'templatefolders',
as: 'template',
let: { id: '$_id' },
pipeline: [
{
$match: {
$expr: {
$and: [
{
$eq: ['$dashboardId', '$$id'],
},
{
$eq: ['$deletedAt', null],
},
],
},
},
},
{
$project: {
_id: 1,
folderId: 1,
},
},
],
})
.unwind('template')
.lookup({
from: 'folders',
as: 'folder',
let: { folderId: '$template.folderId' },
pipeline: [
{
$match: {
$expr: {
$and: [
{
$eq: ['$_id', '$$folderId'],
},
{
$eq: ['$deletedAt', null],
},
],
},
},
},
{
$project: {
_id: 1,
name: 1,
},
},
],
})
.unwind('folder')
.exec();
return result;
Result
{
"data": [
{
...(parent field)
"template": {
"_id": "60ab22b03b39e40012b7cc4a",
"folderId": "60ab080b3b39e40012b7cc41"
},
"folder": {
"_id": "60ab080b3b39e40012b7cc41",
"name": "Folder 1"
}
}
],
"meta": {},
"success": true,
"message": "Succesfully get list"
}
I came from Front end background. I hope my question is not a silly one.
Thanks!
EDIT:
dashboard: [{
_id: dashboard1
}]
templatefolders: [{
dashboardId: dashboard1,
folderId: folder123
}]
folders: [{
_id: folder123
}]
You can use $lookup to join collections
$lookup to join two collections .Lookup doc
$unwind to deconstruct the array. Unwind doc
$group to reconstruct the array which we already deconstructed Group doc
Here is the code
db.dashboard.aggregate([
{
"$lookup": {
"from": "templatefolders",
"localField": "_id",
"foreignField": "dashboardId",
"as": "joinDashboard"
}
},
{
"$unwind": "$joinDashboard"
},
{
"$lookup": {
"from": "folders",
"localField": "joinDashboard.folderId",
"foreignField": "_id",
"as": "joinDashboard.joinFolder"
}
},
{
"$group": {
"_id": "$_id",
"joinDashboard": {
"$push": "$joinDashboard"
}
}
}
])
Working Mongo playground

MongoDB Aggregate Pipeline query

I'm using mongoDB 3.6 on node.js 8.11.1 and working with MongoDB Node.js Driver.
I have two collections, 'group' and 'user':
group:
[
{
"_id":1,
"groupName":"group1",
"users":[
{
"userId":1,
"isAdmin":"false"
},
{
"userId":2,
"isAdmin":"true"
}
]
},
{
"_id":2,
"groupName":"group2",
"users":[
{
"userId":2,
"isAdmin":"false"
},
{
"userId":3,
"isAdmin":"true"
}
]
}
]
user:
[
{
"_id":1,
"username":"user1",
"firstname":"a",
"lastname":"aa",
"mobileNo":"+1111111"
},
{
"_id":2,
"username":"user2",
"firstname":"b",
"lastname":"bb",
"mobileNo":"+2222222"
},
{
"_id":3,
"username":"user3",
"firstname":"c",
"lastname":"cc",
"mobileNo":"+3333333"
}
]
I need an aggregate to return something like this:
[
{
"_id":1,
"groupName":"group1",
"members":[
{
"isAdmin":"false",
"username":"user1",
"firstname":"a",
"lastname":"aa"
},
{
"isAdmin":"true",
"username":"user2",
"firstname":"b",
"lastname":"bb"
}
]
},
{
"_id":2,
"groupName":"group2",
"members":[
{
"isAdmin":"false",
"username":"user2",
"firstname":"b",
"lastname":"bb"
},
{
"isAdmin":"true",
"username":"user3",
"firstname":"c",
"lastname":"cc"
}
]
}
]
At "members" in result, "isAdmin" return from "users" at group collection and "username", "firstname" and "lastname" came from user collection
Many thanks,
Milad.
You can try below aggregation from mongodb 3.6 and above
db.group.aggregate([
{ "$unwind": "$users" },
{ "$lookup": {
"from": Users.collection.name,
"let": { "userId": "$users.userId", "isAdmin": "$users.isAdmin" },
"pipeline": [
{ "$match": { "$expr": { "$eq": [ "$_id", "$$userId" ] } } },
{ "$project": { "isAdmin": "$$isAdmin", "username": 1, "firstName": 1, "lastName": 1 }}
],
"as": "members"
}},
{ "$unwind": "$members" },
{ "$group": {
"_id": "$_id",
"members": { "$push": "$members" },
"groupName": { "$first": "$groupName" }
}}
])

Lookup when foreignField is in an Array

I want to lookup from an object to a collection where the foreignField key is embedded into an array of objects. I have:
collection "shirts"
{
"_id" : ObjectId("5a797ef0768d8418866eb0f6"),
"name" : "Supermanshirt",
"price" : 9.99,
"flavours" : [
{
"flavId" : ObjectId("5a797f8c768d8418866ebad3"),
"size" : "M",
"color": "white",
},
{
"flavId" : ObjectId("3a797f8c768d8418866eb0f7"),
"size" : "XL",
"color": "red",
},
]
}
collection "basket"
{
"_id" : ObjectId("5a797ef0333d8418866ebabc"),
"basketName" : "Default",
"items" : [
{
"dateAdded" : 1526996879787.0,
"itemFlavId" : ObjectId("5a797f8c768d8418866ebad3")
}
],
}
My Query:
basketSchema.aggregate([
{
$match: { $and: [{ _id }, { basketName }]},
},
{
$unwind: '$items',
},
{
$lookup:
{
from: 'shirts',
localField: 'items.itemFlavId',
foreignField: 'flavours.flavId',
as: 'ordered_shirts',
},
},
]).toArray();
my expected result:
[{
"_id" : ObjectId("5a797ef0333d8418866ebabc"),
"basketName" : "Default",
"items" : [
{
"dateAdded" : 1526996879787.0,
"itemFlavId" : ObjectId("5a797f8c768d8418866ebad3")
}
],
"ordered_shirts" : [
{
"_id" : ObjectId("5a797ef0768d8418866eb0f6"),
"name" : "Supermanshirt",
"price" : 9.99,
"flavours" : [
{
"flavId" : ObjectId("5a797f8c768d8418866ebad3"),
"size" : "M",
"color": "white",
}
]
}
],
}]
but instead my ordered_shirts array is empty.
How can I use a foreignField if this foreignField is embedded in an array at the other collection?
I am using MongoDB 3.6.4
As commented, it would appear that there is simply something up in your code where you are pointing at the wrong collection. The general case for this is to simply look at the example listing provided below and see what the differences are, since with the data you provide and the correct collection names then your expected result is in fact returned.
Of course where you need to take such a query "after" that initial $lookup stage is not a simple matter. From a structural standpoint, what you have is generally not a great idea since referring "joins" into items within an array means you are always returning data which is not necessarily "related".
There are some ways to combat that, and mostly there is the form of "non-correlated" $lookup introduced with MongoDB 3.6 which can aid in ensuring you are not returning "unnecessary" data in the "join".
I'm working here in the form of "merging" the "sku" detail with the "items" in the basket, so a first form would be:
Optimal MongoDB 3.6
// Store some vars like you have
let _id = ObjectId("5a797ef0333d8418866ebabc"),
basketName = "Default";
// Run non-correlated $lookup
let optimal = await Basket.aggregate([
{ "$match": { _id, basketName } },
{ "$lookup": {
"from": Shirt.collection.name,
"as": "items",
"let": { "items": "$items" },
"pipeline": [
{ "$match": {
"$expr": {
"$setIsSubset": ["$$items.itemflavId", "$flavours.flavId"]
}
}},
{ "$project": {
"_id": 0,
"items": {
"$map": {
"input": {
"$filter": {
"input": "$flavours",
"cond": { "$in": [ "$$this.flavId", "$$items.itemFlavId" ]}
}
},
"in": {
"$mergeObjects": [
{ "$arrayElemAt": [
"$$items",
{ "$indexOfArray": [
"$$items.itemFlavId", "$$this.flavId" ] }
]},
{ "name": "$name", "price": "$price" },
"$$this"
]
}
}
}
}},
{ "$unwind": "$items" },
{ "$replaceRoot": { "newRoot": "$items" } }
]
}}
])
Note that since you are using mongoose to hold details for the models we can use Shirt.collection.name here to read the property from that model with the actual collection name as needed for the $lookup. This helps avoid confusion within the code and also "hard-coding" something like the collection name when it's actually stored somewhere else. In this way should you change the code which registers the "model" in a way which altered the collection name, then this would always retrieve the correct name for use in the pipeline stage.
The main reason you use this form of $lookup with MongoDB 3.6 is because you want to use that "sub-pipeline" to manipulate the foreign collection results "before" they are returned and merged with the parent document. Since we are "merging" the results into the existing "items" array of the basket we use the same field name in argument to "as".
In this form of $lookup you typically still want "related" documents even though it gives you the control to do whatever you want. In this case we can compare the array content from "items" in the parent document which we set as a variable for the pipeline to use with the array under "flavours" in the foreign collection. A logical comparison for the two "sets" of values here where they "intersect" is using the $setIsSubset operator using the $expr so we can compare on a "logical operation".
The main work here is being done in the $project which is simply using $map on the array from the "flavours" array of the foreign document, processed with $filter in comparison to the "items" we passed into the pipeline and essentially re-written in order to "merge" the matched content.
The $filter reduces down the list for consideration to only those which match something present within the "items", and then we can use $indexOfArray and $arrayElemAt in order to extract the detail from the "items" and merge it with each remaining "flavours" entry which matches using the $mergeObjects operator. Noting here that we also take some "parent" detail from the "shirt" as the "name" and "price" fields which are common to the variations in size and color.
Since this is still an "array" within the matched document(s) to the join condition, in order to get a "flat list" of objects suitable for "merged" entries in the resulting "items" of the $lookup we simply apply $unwind, which within the context of matched items left only creates "little" overhead, and $replaceRoot in order to promote the content under that key to the top level.
The result is just the "merged" content listed in the "items" of the basket.
Sub-optimal MongoDB
The alternate approaches are really not that great since all involve returning other "flavours" which do not actually match the items in the basket. This basically involves "post-filtering" the results obtained from the $lookup as opposed to "pre-filtering" which the process above does.
So the next case here would be using methods to manipulate the returned array in order to remove the items which don't actually match:
// Using legacy $lookup
let alternate = await Basket.aggregate([
{ "$match": { _id, basketName } },
{ "$lookup": {
"from": Shirt.collection.name,
"localField": "items.itemFlavId",
"foreignField": "flavours.flavId",
"as": "ordered_items"
}},
{ "$addFields": {
"items": {
"$let": {
"vars": {
"ordered_items": {
"$reduce": {
"input": {
"$map": {
"input": "$ordered_items",
"as": "o",
"in": {
"$map": {
"input": {
"$filter": {
"input": "$$o.flavours",
"cond": {
"$in": ["$$this.flavId", "$items.itemFlavId"]
}
}
},
"as": "f",
"in": {
"$mergeObjects": [
{ "name": "$$o.name", "price": "$$o.price" },
"$$f"
]
}
}
}
}
},
"initialValue": [],
"in": { "$concatArrays": ["$$value", "$$this"] }
}
}
},
"in": {
"$map": {
"input": "$items",
"in": {
"$mergeObjects": [
"$$this",
{ "$arrayElemAt": [
"$$ordered_items",
{ "$indexOfArray": [
"$$ordered_items.flavId", "$$this.itemFlavId"
]}
]}
]
}
}
}
}
},
"ordered_items": "$$REMOVE"
}}
]);
Here I'm still using some MongoDB 3.6 features, but these are not a "requirement" of the logic involved. The main constraint in this approach is actually the $reduce which requires MongoDB 3.4 or greater.
Using the same "legacy" form of $lookup as you were attempting, we still get the desired results as you display but that of course contains information in the "flavours" that does not match the "items" in the basket. In much the same way as shown in the previous listing we can apply $filter here to remove the items which don't match. The same process here uses that $filter output as the input for $map, which again is doing the same "merge" process as before.
Where the $reduce comes in is because the resulting processing where there is an "array" target from $lookup with documents that themselves contain an "array" of "flavours" is that these arrays need to be "merged" into a single array for further processing. The $reduce simply uses the processed output and performs a $concatArrays on each of the "inner" arrays returned to make these results singular. We already "merged" the content, so this becomes the new "merged" "items".
Older Still $unwind
And of course the final way to present ( even though there are other combinations ) is using $unwind on the arrays and using $group to put it back together:
let old = await Basket.aggregate([
{ "$match": { _id, basketName } },
{ "$unwind": "$items" },
{ "$lookup": {
"from": Shirt.collection.name,
"localField": "items.itemFlavId",
"foreignField": "flavours.flavId",
"as": "ordered_items"
}},
{ "$unwind": "$ordered_items" },
{ "$unwind": "$ordered_items.flavours" },
{ "$redact": {
"$cond": {
"if": {
"$eq": [
"$items.itemFlavId",
"$ordered_items.flavours.flavId"
]
},
"then": "$$KEEP",
"else": "$$PRUNE"
}
}},
{ "$group": {
"_id": "$_id",
"basketName": { "$first": "$basketName" },
"items": {
"$push": {
"dateAdded": "$items.dateAdded",
"itemFlavId": "$items.itemFlavId",
"name": "$ordered_items.name",
"price": "$ordered_items.price",
"flavId": "$ordered_items.flavours.flavId",
"size": "$ordered_items.flavours.size",
"color": "$ordered_items.flavours.color"
}
}
}}
]);
Most of this should be pretty self explanatory as $unwind is simply a tool to "flatten" array content into singular document entries. In order to just get the results we want we can use $redact to compare the two fields. Using MongoDB 3.6 you "could" use $expr within a $match here:
{ "$match": {
"$expr": {
"$eq": [
"$items.itemFlavId",
"$ordered_items.flavours.flavId"
]
}
}}
But when it comes down to it, if you have MongoDB 3.6 with it's other features then $unwind is the wrong thing to do here due to all the overhead it will actually add.
So all that really happens is you $lookup then "flatten" the documents and finally $group all related detail together using $push to recreate the "items" in the basket. It "looks simple" and is probably the most easy form to understand, however "simplicity" does not equal "performance" and this would be pretty brutal to use in a real world use case.
Summary
That should cover the explanation of the things you need to do when working with "joins" that are going to compare items within arrays. This probably should lead you on the path of realizing this is not really a great idea and it would be far better to keep your "skus" listed "separately" rather than listing them all related under a single "item".
It also should in part be a lesson that "joins" in general are not a great idea with MongoDB. You really only should define such relations where they are "absolutely necessary". In such a case of "details for items in a basket", then contrary to traditional RDBMS patterns it would actually be far better in terms of performance to simply "embed" that detail from the start. In that way you don't need complicated join conditions just to get a result, which might have saved "a few bytes" in storage but is taking a lot more time than what should have been a simple request for the basket with all the detail already "embedded". That really should be the primary reason why you are using something like MongoDB in the first place.
So if you have to do it, then really you should be sticking with the first form since where you have the available features to use then use them best to their advantage. Whilst other approaches may seem easier, it won't help the application performance, and of course best performance would be embedding to begin with.
A full listing follows for demonstration of the above discussed methods and for basic comparison to prove that the provided data does in fact "join" as long as the other parts of the application set-up are working as they should be. So a model on "how it should be done" in addition to demonstrating the full concepts.
const { Schema, Types: { ObjectId } } = mongoose = require('mongoose');
const uri = 'mongodb://localhost/basket';
mongoose.Promise = global.Promise;
mongoose.set('debug', true);
const basketItemSchema = new Schema({
dateAdded: { type: Number, default: Date.now() },
itemFlavId: { type: Schema.Types.ObjectId }
},{ _id: false });
const basketSchema = new Schema({
basketName: String,
items: [basketItemSchema]
});
const flavourSchema = new Schema({
flavId: { type: Schema.Types.ObjectId },
size: String,
color: String
},{ _id: false });
const shirtSchema = new Schema({
name: String,
price: Number,
flavours: [flavourSchema]
});
const Basket = mongoose.model('Basket', basketSchema);
const Shirt = mongoose.model('Shirt', shirtSchema);
const log = data => console.log(JSON.stringify(data, undefined, 2));
(async function() {
try {
const conn = await mongoose.connect(uri);
// clean data
await Promise.all(Object.entries(conn.models).map(([k,m]) => m.remove()));
// set up data for test
await Basket.create({
_id: ObjectId("5a797ef0333d8418866ebabc"),
basketName: "Default",
items: [
{
dateAdded: 1526996879787.0,
itemFlavId: ObjectId("5a797f8c768d8418866ebad3")
}
]
});
await Shirt.create({
_id: ObjectId("5a797ef0768d8418866eb0f6"),
name: "Supermanshirt",
price: 9.99,
flavours: [
{
flavId: ObjectId("5a797f8c768d8418866ebad3"),
size: "M",
color: "white"
},
{
flavId: ObjectId("3a797f8c768d8418866eb0f7"),
size: "XL",
color: "red"
}
]
});
// Store some vars like you have
let _id = ObjectId("5a797ef0333d8418866ebabc"),
basketName = "Default";
// Run non-correlated $lookup
let optimal = await Basket.aggregate([
{ "$match": { _id, basketName } },
{ "$lookup": {
"from": Shirt.collection.name,
"as": "items",
"let": { "items": "$items" },
"pipeline": [
{ "$match": {
"$expr": {
"$setIsSubset": ["$$items.itemflavId", "$flavours.flavId"]
}
}},
{ "$project": {
"_id": 0,
"items": {
"$map": {
"input": {
"$filter": {
"input": "$flavours",
"cond": { "$in": [ "$$this.flavId", "$$items.itemFlavId" ]}
}
},
"in": {
"$mergeObjects": [
{ "$arrayElemAt": [
"$$items",
{ "$indexOfArray": [
"$$items.itemFlavId", "$$this.flavId" ] }
]},
{ "name": "$name", "price": "$price" },
"$$this"
]
}
}
}
}},
{ "$unwind": "$items" },
{ "$replaceRoot": { "newRoot": "$items" } }
]
}}
])
log(optimal);
// Using legacy $lookup
let alternate = await Basket.aggregate([
{ "$match": { _id, basketName } },
{ "$lookup": {
"from": Shirt.collection.name,
"localField": "items.itemFlavId",
"foreignField": "flavours.flavId",
"as": "ordered_items"
}},
{ "$addFields": {
"items": {
"$let": {
"vars": {
"ordered_items": {
"$reduce": {
"input": {
"$map": {
"input": "$ordered_items",
"as": "o",
"in": {
"$map": {
"input": {
"$filter": {
"input": "$$o.flavours",
"cond": {
"$in": ["$$this.flavId", "$items.itemFlavId"]
}
}
},
"as": "f",
"in": {
"$mergeObjects": [
{ "name": "$$o.name", "price": "$$o.price" },
"$$f"
]
}
}
}
}
},
"initialValue": [],
"in": { "$concatArrays": ["$$value", "$$this"] }
}
}
},
"in": {
"$map": {
"input": "$items",
"in": {
"$mergeObjects": [
"$$this",
{ "$arrayElemAt": [
"$$ordered_items",
{ "$indexOfArray": [
"$$ordered_items.flavId", "$$this.itemFlavId"
]}
]}
]
}
}
}
}
},
"ordered_items": "$$REMOVE"
}}
]);
log(alternate);
// Or really old style
let old = await Basket.aggregate([
{ "$match": { _id, basketName } },
{ "$unwind": "$items" },
{ "$lookup": {
"from": Shirt.collection.name,
"localField": "items.itemFlavId",
"foreignField": "flavours.flavId",
"as": "ordered_items"
}},
{ "$unwind": "$ordered_items" },
{ "$unwind": "$ordered_items.flavours" },
{ "$redact": {
"$cond": {
"if": {
"$eq": [
"$items.itemFlavId",
"$ordered_items.flavours.flavId"
]
},
"then": "$$KEEP",
"else": "$$PRUNE"
}
}},
{ "$group": {
"_id": "$_id",
"basketName": { "$first": "$basketName" },
"items": {
"$push": {
"dateAdded": "$items.dateAdded",
"itemFlavId": "$items.itemFlavId",
"name": "$ordered_items.name",
"price": "$ordered_items.price",
"flavId": "$ordered_items.flavours.flavId",
"size": "$ordered_items.flavours.size",
"color": "$ordered_items.flavours.color"
}
}
}}
]);
log(old);
} catch(e) {
console.error(e)
} finally {
process.exit()
}
})()
And sample output as:
Mongoose: baskets.remove({}, {})
Mongoose: shirts.remove({}, {})
Mongoose: baskets.insertOne({ _id: ObjectId("5a797ef0333d8418866ebabc"), basketName: 'Default', items: [ { dateAdded: 1526996879787, itemFlavId: ObjectId("5a797f8c768d8418866ebad3") } ], __v: 0 })
Mongoose: shirts.insertOne({ _id: ObjectId("5a797ef0768d8418866eb0f6"), name: 'Supermanshirt', price: 9.99, flavours: [ { flavId: ObjectId("5a797f8c768d8418866ebad3"), size: 'M', color: 'white' }, { flavId: ObjectId("3a797f8c768d8418866eb0f7"), size: 'XL', color: 'red' } ], __v: 0 })
Mongoose: baskets.aggregate([ { '$match': { _id: 5a797ef0333d8418866ebabc, basketName: 'Default' } }, { '$lookup': { from: 'shirts', as: 'items', let: { items: '$items' }, pipeline: [ { '$match': { '$expr': { '$setIsSubset': [ '$$items.itemflavId', '$flavours.flavId' ] } } }, { '$project': { _id: 0, items: { '$map': { input: { '$filter': { input: '$flavours', cond: { '$in': [Array] } } }, in: { '$mergeObjects': [ { '$arrayElemAt': [Array] }, { name: '$name', price: '$price' }, '$$this' ] } } } } }, { '$unwind': '$items' }, { '$replaceRoot': { newRoot: '$items' } } ] } } ], {})
[
{
"_id": "5a797ef0333d8418866ebabc",
"basketName": "Default",
"items": [
{
"dateAdded": 1526996879787,
"itemFlavId": "5a797f8c768d8418866ebad3",
"name": "Supermanshirt",
"price": 9.99,
"flavId": "5a797f8c768d8418866ebad3",
"size": "M",
"color": "white"
}
],
"__v": 0
}
]
Mongoose: baskets.aggregate([ { '$match': { _id: 5a797ef0333d8418866ebabc, basketName: 'Default' } }, { '$lookup': { from: 'shirts', localField: 'items.itemFlavId', foreignField: 'flavours.flavId', as: 'ordered_items' } }, { '$addFields': { items: { '$let': { vars: { ordered_items: { '$reduce': { input: { '$map': { input: '$ordered_items', as: 'o', in: { '$map': [Object] } } }, initialValue: [], in: { '$concatArrays': [ '$$value', '$$this' ] } } } }, in: { '$map': { input: '$items', in: { '$mergeObjects': [ '$$this', { '$arrayElemAt': [ '$$ordered_items', [Object] ] } ] } } } } }, ordered_items: '$$REMOVE' } } ], {})
[
{
"_id": "5a797ef0333d8418866ebabc",
"basketName": "Default",
"items": [
{
"dateAdded": 1526996879787,
"itemFlavId": "5a797f8c768d8418866ebad3",
"name": "Supermanshirt",
"price": 9.99,
"flavId": "5a797f8c768d8418866ebad3",
"size": "M",
"color": "white"
}
],
"__v": 0
}
]
Mongoose: baskets.aggregate([ { '$match': { _id: 5a797ef0333d8418866ebabc, basketName: 'Default' } }, { '$unwind': '$items' }, { '$lookup': { from: 'shirts', localField: 'items.itemFlavId', foreignField: 'flavours.flavId', as: 'ordered_items' } }, { '$unwind': '$ordered_items' }, { '$unwind': '$ordered_items.flavours' }, { '$redact': { '$cond': { if: { '$eq': [ '$items.itemFlavId', '$ordered_items.flavours.flavId' ] }, then: '$$KEEP', else: '$$PRUNE' } } }, { '$group': { _id: '$_id', basketName: { '$first': '$basketName' }, items: { '$push': { dateAdded: '$items.dateAdded', itemFlavId: '$items.itemFlavId', name: '$ordered_items.name', price: '$ordered_items.price', flavId: '$ordered_items.flavours.flavId', size: '$ordered_items.flavours.size', color: '$ordered_items.flavours.color' } } } } ], {})
[
{
"_id": "5a797ef0333d8418866ebabc",
"basketName": "Default",
"items": [
{
"dateAdded": 1526996879787,
"itemFlavId": "5a797f8c768d8418866ebad3",
"name": "Supermanshirt",
"price": 9.99,
"flavId": "5a797f8c768d8418866ebad3",
"size": "M",
"color": "white"
}
]
}
]

Resources