How to select Specific attributes in an embedded mongo document - mongodb

I have a mongo document similar to following structure:
{
id: '111eef8b94d3e91f4c7d22a37deb4aad',
description: 'Secret Project',
title: 'secret project',
students: [
{ _id: '123', name: 'Alex', primary_subject: 'Math', address: 'xxxxx', dob: '1989-10-10', gender: 'F', nationality: 'German' },
{ _id: '124', name: 'Emanuel', primary_subject: 'Physics', address: 'yyyyyy', dob: '1988-05-07', gender: 'M', nationality: 'French' },
{ _id: '242', name: 'Mike', primary_subject: 'Chemistry', address: 'zzzz', dob: '1990-02-02', gender: 'M', nationality: 'English' }
]
}
I need to fetch specific attributes. For example want to fetch only name, primary_subject, nationality attributes.
Using the below mongo query, I am able to fetch all attributes.
db.student_projects.aggregate({
$project: {
"students": {
$filter: {
input: "$students",
as: "st",
cond: {
$eq: [ "$$st._id", "242" ]
}
},
}
}
},
{ $unwind: { path: "$students", preserveNullAndEmptyArrays: false } }
).pretty();
Above query fetches all attributes of the matching student. But in my case I need just 3 attributes.

Use $map to reshape the output array:
db.student_projects.aggregate({
$project: {
"students": {
$map: {
input: {
$filter: {
input: "$students",
as: "st",
cond: {
$eq: [ "$$st._id", "242" ]
}
}
},
in: {
name: "$$this.name",
primary_subject: "$$this.primary_subject",
nationality: "$$this.nationality"
}
}
}
}
},
{ $unwind: { path: "$students", preserveNullAndEmptyArrays: false } }
).pretty();
Just like it's other language counterparts, "reshaphing" arrays is what $map does.
If you want to get "fancy" with a longer list of "included" fields than "excluded", then there are some modern operators from later releases of MongoDB 3.6 and above which can help here:
db.student_projects.aggregate({
$project: {
"students": {
$map: {
input: {
$filter: {
input: "$students",
as: "st",
cond: {
$eq: [ "$$st._id", "242" ]
}
}
},
in: {
$arrayToObject: {
$filter: {
input: { $objectToArray: "$$this" },
cond: {
"$not": {
"$in": [ "$$this.k", [ "_id", "address", "dob" ] ]
}
}
}
}
}
}
}
}
},
{ $unwind: "$students" }
).pretty();
The $objectToArray transforms to "key/value" pairs of k and v representing the object keys and values. From this "array" you can $filter the results on the k values for those you don't want. The $in allows comparison to a "list", and the $not negates the comparison value.
Finally you can transform the "array" back into the object form via $arrayToObject.
And of course you could always simply $project after the $unwind:
db.student_projects.aggregate({
$project: {
"students": {
$filter: {
input: "$students",
as: "st",
cond: {
$eq: [ "$$st._id", "242" ]
}
},
}
}
},
{ $unwind: "$students" },
{ $project: {
"students": {
"name": "$students.name",
"primary_subject": "$students.primary_subject",
"nationality": "$students.nationality"
}
}
).pretty();
And if you don't want the "students" key, then just remove it:
{ $project: {
"name": "$students.name",
"primary_subject": "$students.primary_subject",
"nationality": "$students.nationality"
}
Or use $replaceRoot from the original $map version:
db.student_projects.aggregate({
$project: {
"students": {
$map: {
input: {
$filter: {
input: "$students",
as: "st",
cond: {
$eq: [ "$$st._id", "242" ]
}
}
},
in: {
name: "$$this.name",
primary_subject: "$$this.primary_subject",
nationality: "$$this.nationality"
}
}
}
}
},
{ $unwind: "$students" },
{ $replaceRoot: { newRoot: "$students" } }
).pretty();
But for that matter you could have also done a $match after the $unwind instead of even using $filter. It's generally more efficient to work with arrays in place though, and a lot of the time you don't need the $unwind at all, so it's good practice to get used to the methods that manipulate arrays.
Of course if the result with $replaceRoot or similar was really what you were looking for, then it would be far more advisable to not use embedded documents in an array at all. If your intended access pattern uses those embedded documents "separately" to the main document most of the time, then you really should consider keeping them in their own collection instead. This avoids the "aggregation overhead" and is a simple query and projection to return the data.
N.B The $unwind operator "defaults" to preserveNullAndEmptyArrays: false. So the original form does not need that specified, nor the path key. It's shorter to write this way unless you specifically intend to preserve those null and empty results.

Related

How to remove a field of an array's nested object that has an empty string value using mongodb aggregation?

So far, after i tried, i came up with solution where i am able to remove the whole object inside of the array if that object has field with empty value. That does not work in my case. I only need to remove the field and keep rest of the object. In this case, "Comment" field is the one having empty values occasionally. Thanks in advance!
Structure:
someArray: [
{
field1:"value",
field2:"value",
Comment:"",
Answer:"",
},
{
field1:"value",
field2:"value",
Comment:"",
Answer:"",
}]
Code:
$project: {
someArray: {
$filter: {
input: "$someArray", as: "array",
cond: { $ne: [ "$$array.Comment", ""]}}}}
Use $map to loop over the array elements.For each array element where comment is not an empty string, return whole element, otherwise return the document excluding comment field. Like this:
db.collection.aggregate([
{
"$project": {
someArray: {
$map: {
input: "$someArray",
as: "element",
in: {
$cond: {
if: {
$eq: [
"",
"$$element.Comment"
]
},
then: {
field1: "$$element.field1",
field2: "$$element.field2"
},
else: "$$element"
}
}
}
}
}
},
])
Here, is the working link.
Here is a solution where an array's nested object can have multiple fields and these need not be referred in the aggregation. Removes the nested object's field with value as an empty string (""):
db.collection.aggregate([
{
$set: {
someArray: {
$map: {
input: '$someArray',
as: 'e',
in: {
$let: {
vars: {
temp_var: {
$filter: {
input: { $objectToArray: '$$e' },
cond: { $ne: [ '', '$$this.v' ] },
}
}
},
in: {
$arrayToObject: '$$temp_var'
}
}
}
}
}
}
},
])
Solution from Charchit Kapoor works only if your array has exactly
{
field1: ...
field2: ...
Comment:""
}
But it does not work for arbitrary fields. I was looking for more generic solution, my first idea was this:
db.collection.aggregate([
{
"$project": {
someArray: {
$map: {
input: "$someArray",
in: {
$cond: {
if: { $eq: ["$$this.Comment", ""] },
then: { $mergeObjects: ["$$this", { Comment: "$$REMOVE" }] },
else: "$$this"
}
}
}
}
}
}
])
but it does not work.
I ended on this one:
db.collection.aggregate([
{
"$project": {
someArray: {
$map: {
input: "$someArray",
in: {
$cond: {
if: { $eq: ["", "$$this.Comment"] },
then: {
$arrayToObject: {
$filter: {
input: {
$map: {
input: { $objectToArray: "$$this" },
as: "element",
in: { $cond: [{ $eq: ["$$element.k", "Comment"] }, null, "$$element"] }
}
},
as: "filter",
cond: "$$filter" // removes null's from array
}
}
},
else: "$$this"
}
}
}
}
}
}
])
Mongo Playground

MongoDB - Aggregate get specific objects in an array

How can I get only objects in the sales array matching with 2021-10-14 date ?
My aggregate query currently returns all objects of the sales array if at least one is matching.
Dataset Documents
{
"name": "#0",
"sales": [{
"date": "2021-10-14",
"price": 3.69,
},{
"date": "2021-10-15",
"price": 2.79,
}]
},
{
"name": "#1",
"sales": [{
"date": "2021-10-14",
"price": 1.5,
}]
}
Aggregate
{
$match: {
sales: {
$elemMatch: {
date: '2021-10-14',
},
},
},
},
{
$group: {
_id: 0,
data: {
$push: '$sales',
},
},
},
{
$project: {
data: {
$reduce: {
input: '$data',
initialValue: [],
in: {
$setUnion: ['$$value', '$$this'],
},
},
},
},
}
Result
{"date": "2021-10-14","price": 3.69},
{"date": "2021-10-15","price": 2.79},
{"date": "2021-10-14","price": 1.5}
Result Expected
{"date": "2021-10-14","price": 3.69},
{"date": "2021-10-14","price": 1.5}
You actually need to use a $replaceRoot or $replaceWith pipeline which takes in an expression that gives you the resulting document filtered using $arrayElemAt (or $first) and $filter from the sales array:
[
{ $match: { 'sales.date': '2021-10-14' } },
{ $replaceWith: {
$arrayElemAt: [
{
$filter: {
input: '$sales',
cond: { $eq: ['$$this.date', '2021-10-14'] }
}
},
0
]
} }
]
OR
[
{ $match: { 'sales.date': '2021-10-14' } },
{ $replaceRoot: {
newRoot: {
$arrayElemAt: [
{
$filter: {
input: '$sales',
cond: { $eq: ['$$this.date', '2021-10-14'] }
}
},
0
]
}
} }
]
Mongo Playground
In $project stage, you need $filter operator with input as $reduce operator to filter the documents.
{
$project: {
data: {
$filter: {
input: {
$reduce: {
input: "$data",
initialValue: [],
in: {
$setUnion: [
"$$value",
"$$this"
],
}
}
},
cond: {
$eq: [
"$$this.date",
"2021-10-14"
]
}
}
}
}
}
Sample Mongo Playground
How about using $unwind:
.aggregate([
{$match: { sales: {$elemMatch: {date: '2021-10-14'} } }},
{$unwind: '$sales'},
{$match: {'sales.date': '2021-10-14'}},
{$project: {date: '$sales.date', price: '$sales.price', _id: 0}}
])
This will separate the sales into different documents, each containing only one sale, and allow you to match conditions easily.
See: https://docs.mongodb.com/manual/reference/operator/aggregation/unwind/

MongoDB $lookup and $map array of objects

I'm trying to do this for days, but can't find any success
I'm using MongoDB, and I tried to do it with many pipeline steps but I couldn't find a way.
I have a players collection, each player contains an items array
{
"_id": ObjectId("5fba17c1c4566e57fafdcd7e"),
"username": "moshe",
"items": [
{
"_id": ObjectId("5fbb5ac178045a985690b5fd"),
"equipped": false,
"itemId": "5fbb5ab778045a985690b5fc"
}
]
}
I have an items collection where there is more information about each item
in the player items array.
{
"_id": ObjectId("5fbb5ab778045a985690b5fc"),
"name": "Axe",
"damage": 4,
"defense": 6
}
My goal is to have a player document with all the information about the item inside his items array, so it will look like that:
{
"_id": ObjectId("5fba17c1c4566e57fafdcd7e"),
"username": "moshe",
"items": [
{
"_id": ObjectId("5fbb5ac178045a985690b5fd"),
"equipped": false,
"itemId": "5fbb5ab778045a985690b5fc",
"name": "Axe",
"damage": 4,
"defense": 6
}
]
}
$unwind deconstruct items array
$lookup to join items collection, pass itemsId into let after converting it to object id using $toObjectId and pass items object,
$match itemId condition
$mergeObject merge items object and $$ROOT object and replace to root using $replaceRoot
$group reconstruct items array again, group by _id and get first username and construct items array
db.players.aggregate([
{ $unwind: "$items" },
{
$lookup: {
from: "items",
let: {
itemId: { $toObjectId: "$items.itemId" },
items: "$items"
},
pipeline: [
{ $match: { $expr: { $eq: ["$_id", "$$itemId" ] } } },
{ $replaceRoot: { newRoot: { $mergeObjects: ["$$items", "$$ROOT"] } } }
],
as: "items"
}
},
{
$group: {
_id: "$_id",
username: { $first: "$username" },
items: { $push: { $first: "$items" } }
}
}
])
Playground
Second option using $map, and without $unwind,
$addFields for items convert itemId string to object type id using $toObjectId and $map
$lookup to join items collection
$project to show required fields, and merge items array and itemsCollection using $map to iterate loop of items array $filter to get matching itemId and $first to get first object from return result, $mergeObject to merge current object and returned object from $first
db.players.aggregate([
{
$addFields: {
items: {
$map: {
input: "$items",
in: {
$mergeObjects: ["$$this", { itemId: { $toObjectId: "$$this.itemId" } }]
}
}
}
}
},
{
$lookup: {
from: "items",
localField: "items.itemId",
foreignField: "_id",
as: "itemsCollection"
}
},
{
$project: {
username: 1,
items: {
$map: {
input: "$items",
as: "i",
in: {
$mergeObjects: [
"$$i",
{
$first: {
$filter: {
input: "$itemsCollection",
cond: { $eq: ["$$this._id", "$$i.itemId"] }
}
}
}
]
}
}
}
}
}
])
Playground
First I'd strongly suggest that you should store the items.itemId as ObjectId, not strings.
Then another simple solution can be:
db.players.aggregate([
{
$lookup: {
from: "items",
localField: "items.itemId",
foreignField: "_id",
as: "itemsDocuments",
},
},
{
$addFields: {
items: {
$map: {
input: { $zip: { inputs: ["$items", "$itemsDocuments"] } },
in: { $mergeObjects: "$$this" },
},
},
},
},
{ $unset: "itemsDocuments" },
])

Accessing a random field using other field value

I have a document like this:
{
value: "field2",
field1: [ ... ],
field2: [ ... ],
...
}
Where value will be the value of one of the fields in the document. and many different fields are possible for one document.
I want to match a document. fetch the relevant field only and them do some calculations on it.
For example I want to do:
{
$unwind: "$value"
}
And get the results of field2 unwinded.
How can I do this?
It's a little bit "hacky" but you can achieve this using operators like $objectToArray and $filter like so:
db.collection.aggregate([
{
$addFields: {
"values": {
$arrayElemAt: [
{
$filter: {
input: {
$objectToArray: "$$ROOT"
},
as: "field",
cond: {
$eq: [
"$$field.k",
"$value"
]
}
}
},
0
]
}
}
},
{
$unwind: "$values.v"
},
{
$replaceRoot: {
newRoot: "$values.v"
}
},
])
MongoPlayground

Get key of the value based on a condition from unnamed nested array of objects in Mongo DB aggregate

I have a document structures like this :
{
"_id": xxxxx,
"Name": "John Doe",
"Grades":[
{
"Physics":89,
},
{
"Math":45
},
{
"Chemistry":57
}
]
}
I would like to project grades as an array of only the subjects that have over 60.
I tried this but this didn't work:
$arrayElemAt: [{ $objectToArray: { $gte: ['$hhEthGrp',60] } }, 0]
You definitely need $objectToArray to access a values for unknown keys but you also need $filter for outer array and $anyElementTrue along with $map to determine where there's any value for unknown key which has value over 60:
db.collection.aggregate([
{
$addFields: {
Grades: {
$filter: {
input: "$Grades",
cond: {
$let: {
vars: { kv: { $objectToArray: "$$this" } }
in: {
$anyElementTrue: {
$map: {
input: "$$kv.v",
in: {
$gt: [ "$$this", 60 ]
}
}
}
}
}
}
}
}
}
}
])
Mongo Playground