How to toggle an element in array in mongoDB - mongodb

I have an array of string (ObjectIds) in mongoDB.
I want to toggle an element in that array: If that element exists in the array, remove it, otherwise push that element.
Example:
arr = ["5b608c0769698c3990c64ef3","5b608c0769698c3990c64ef4"]
element = ["5b608c0769698c3990c64ef3"]
Final array:
arr = ["5b608c0769698c3990c64ef4"]
My use-case:
I am creating a blog website and in the Blog Schema I am storing the id of each user who has liked that blog, so that I can highlight the like button when blog is shown to the user.

If you are updating the document, you can use the pipeline within the update. But this feature is available with MongoDB version 4.2 or later.
db.collection.update(
{ },
[
{
$set: {
arr: {
$cond: [ { $in: [ element, "$arr" ] },
{ $setDifference: [ "$arr", [ element ] ] },
{ $concatArrays: [ "$arr", [ element ] ] }
]
}
}
}
]
)
NOTE: Assuming the variable element is a string value.
If you are just querying you can use the following aggregation:
db.collection.aggregate([
{
$addFields: {
arr: {
$cond: [ { $in: [ element, "$arr" ] },
{ $setDifference: [ "$arr", [ element ] ] },
{ $concatArrays: [ "$arr", [ element ] ] }
]
}
}
}
] )
But, if you are using MongoDB version earlier than 4.2, then you can use the aggregate output from above to update the document:
db.collection.aggregate( [
// aggregation pipeine from above ...
] ).forEach( doc => db.collection.updateOne( { _id: doc._id }, { $set: { arr: doc.arr } } ) )

Its maybe not the best solution, but a easy one. You can add your element to the array with $addToSet and check with nModified if you modified the array. 0 means it already exist, and 1 mean you added new Element to it.
So you use mongoose as you said then you have a Model lets say Post. I dont know witch backend langauge you use, but i will show it with node.js here:
let result = await Post.updateOne({ _id: "someId" }, {
$addToSet: {
arr: element
}
})
if(result.nModified === 0){
//0 means, no modifikation, that means its already liked
await Post.updateOne({ _id: "someId" }, {
$pull: {
arr: element
}
})
}
response.send("Done");

Related

How to update a property of the last object of a list in mongo

I would like to update a property of the last objet stored in a list in mongo. For performance reasons, I can not pop the object from the list, then update the property, and then put the objet back. I can not either change the code design as it does not depend on me. In brief am looking for a way to select the last element of a list.
The closest I came to get it working was to use arrayFilters that I found doing research on the subject (mongodb core ticket: https://jira.mongodb.org/browse/SERVER-27089):
db.getCollection("myCollection")
.updateOne(
{
_id: ObjectId('638f5f7fe881c670052a9d08')
},
{
$set: {"theList.$[i].propertyToUpdate": 'NewValueToAssign'}
},
{
arrayFilters: [{'i.type': 'MyTypeFilter'}]
}
)
I use a filter to only update the objets in theList that have their property type evaluated as MyTypeFilter.
What I am looking for is something like:
db.getCollection("maCollection")
.updateOne(
{
_id: ObjectId('638f5f7fe881c670052a9d08')
},
{
$set: {"theList.$[i].propertyToUpdate": 'NewValueToAssign'}
},
{
arrayFilters: [{'i.index': -1}]
}
)
I also tried using "theList.$last.propertyToUpdate" instead of "theList.$[i].propertyToUpdate" but the path is not recognized (since $last is invalid)
I could not find anything online matching my case.
Thank you for your help, have a great day
You want to be using Mongo's pipelined updates, this allows us to use aggregation operators within the update body.
You do however need to consider edge cases that the previous answer does not. (null list, empty list, and list.length == 1)
Overall it looks like so:
db.collection.update({
_id: ObjectId("638f5f7fe881c670052a9d08")
},
[
{
$set: {
list: {
$concatArrays: [
{
$cond: [
{
$gt: [
{
$size: {
$ifNull: [
"$list",
[]
]
}
},
1
]
},
{
$slice: [
"$list",
0,
{
$subtract: [
{
$size: "$list"
},
1
]
}
]
},
[]
]
},
[
{
$mergeObjects: [
{
$ifNull: [
{
$last: "$list"
},
{}
]
},
{
propertyToUpdate: "NewValueToAssign"
}
]
}
]
]
}
}
}
])
Mongo Playground
One option is to use update with pipeline:
db.collection.update(
{_id: ObjectId("638f5f7fe881c670052a9d08")},
[
{$set: {
theList: {
$concatArrays: [
{$slice: ["$theList", 0, {$subtract: [{$size: "$theList"}, 1]}]},
[{$mergeObjects: [{$last: "$theList"}, {propertyToUpdate: "NewValueToAssign"}]}]
]
}
}}
]
)
See how it works on the playground example

Conditionally update/upsert embedded array with findOneAndUpdate in MongoDB

I have a collection in the following format:
[
{
"postId": ObjectId("62dffd0acb17483cf015375f"),
"userId": ObjectId("62dff9584f5b702d61c81c3c"),
"state": [
{
"id": ObjectId("62dffc49cb17483cf0153220"),
"notes": "these are my custom notes!",
"lvl": 3,
},
{
"id": ObjectId("62dffc49cb17483cf0153221"),
"notes": "hello again",
"lvl": 0,
},
]
},
]
My goal is to be able to update and add an element in this array in the following situation:
If the ID of the new element is not in the state array, push the new element in the array
If the ID of the new element is in the state array and its lvl field is 0, update that element with the new information
If the ID of the new element exists in the array, and its lvl field is not 0, then nothing should happen. I will throw an error by seeing that no documents were matched.
Basically, to accomplish this I was thinking about using findOneAndUpdate with upsert, but I am not sure how to tell the query to update the state if lvl is 0 or don't do anything if it is bigger than 0 when the match is found.
For solving (1) this is what I was able to come up with:
db.collection.findOneAndUpdate(
{
"postId": ObjectId("62dffd0acb17483cf015375f"),
"userId": ObjectId("62dff9584f5b702d61c81c3c"),
"state.id": {
"$ne": ObjectId("62dffc49cb17483cf0153222"),
},
},
{
"$push": {"state": {"id": ObjectId("62dffc49cb17483cf0153222"), "lvl": 1}}
},
{
"new": true,
"upsert": true,
}
)
What is the correct way to approach this issue? Should I just split the query into multiple ones?
Edit: as of now I have done this in more than one query (one to fetch the document, then I iterate over its state array to check if the ID exists in it, and then I perform (1), (2) and (3) in a normal if-else clause)
If the ID of the new element exists in the array, and its lvl field is not 0, then nothing should happen. I will throw an error by seeing that no documents where matched.
First thing FYI,
upsert is not possible in the nested array
upsert will not add new elements to the array
upsert can add a new document with the new element
if you want to throw an error if the record does not present then you don't need upsert
Second thing, you can achieve this in one query by using an update with aggregation pipeline in MongoDB 4.2,
Note: Here i must inform you, this query will respond updated document but there will be no flag or any clue if this query fulfilled your first situation or second situation, or the third situation out of 3, you have to check in your client-side code through query response.
check conditions for postId and userId fields only
we are going to update state field under $set stage
check the condition if the provided id is present in state's id?
true, $map to iterate loop of state array
check conditions for id and lvl: 0?
true, $mergeObjects to merge current object with the new information
false, it will not do anything
false, then add that new element in state array, by $concatArrays operator
db.collection.findOneAndUpdate(
{
postId: ObjectId("62dffd0acb17483cf015375f"),
userId: ObjectId("62dff9584f5b702d61c81c3c")
},
[{
$set: {
state: {
$cond: [
{ $in: [ObjectId("62dffc49cb17483cf0153221"), "$state.id"] },
{
$map: {
input: "$state",
in: {
$cond: [
{
$and: [
{ $eq: ["$$this.id", ObjectId("62dffc49cb17483cf0153221")] },
{ $eq: ["$$this.lvl", 0] }
]
},
{
$mergeObjects: [
"$$this",
{
// update your new fields here
"notes": "new note"
}
]
},
"$$this"
]
}
}
},
{
$concatArrays: [
"$state",
[
// add new element
{
"id": ObjectId("62dffc49cb17483cf0153221"),
"lvl": 1
}
]
]
}
]
}
}
}],
{ returnNewDocument: true }
)
Playrgound
Third thing, you can execute 2 update queries,
The first query, for the case: element does not present and it will push a new element in state
let response = db.collection.findOneAndUpdate({
postId: ObjectId("62dffd0acb17483cf015375f"),
userId: ObjectId("62dff9584f5b702d61c81c3c"),
"state.id": { $ne: ObjectId("62dffc49cb17483cf0153221") }
},
{
$push: {
state: {
id: ObjectId("62dffc49cb17483cf0153221"),
lvl: 1
}
}
},
{
returnNewDocument: true
})
The second query on the base of if the response of the above query is null then this query will execute,
This will check state id and lvl: 0 conditions if conditions are fulfilled then execute the update fields operation, it will return null if the document is not found
You can throw if this will return null otherwise do stuff with response data and response success
if (response == null) {
response = db.collection.findOneAndUpdate({
postId: ObjectId("62dffd0acb17483cf015375f"),
userId: ObjectId("62dff9584f5b702d61c81c3c"),
state: {
$elemMatch: {
id: ObjectId("62dffc49cb17483cf0153221"),
lvl: 0
}
}
},
{
$set: {
// add your update fields
"state.$.notes": "new note"
}
},
{
returnNewDocument: true
});
// not found and throw an error
if (response == null) {
return {
// throw error;
};
}
}
// do stuff with "response" data and return result
return {
// success;
};
Note: As per the above options, I would recommend you that I explained in the Third thing that you can execute 2 update queries.
What you're trying became possible with the introduction pipelined updates, here is how I would do it by using $concatArrays to concat the exists state array with the new input and $ifNull in case of an upsert to init the empty value, like so:
const inputObj = {
"id": ObjectId("62dffc49cb17483cf0153222"),
"lvl": 1
};
db.collection.findOneAndUpdate({
"postId": ObjectId("62dffd0acb17483cf015375f"),
"userId": ObjectId("62dff9584f5b702d61c81c3c")
},
[
{
$set: {
state: {
$ifNull: [
"$state",
[]
]
},
}
},
{
$set: {
state: {
$concatArrays: [
{
$map: {
input: "$state",
in: {
$mergeObjects: [
{
$cond: [
{
$and: [
{
$in: [
inputObj.id,
"$state.id"
]
},
{
$eq: [
inputObj.lvl,
0
]
}
]
},
inputObj,
{},
]
},
"$$this"
]
}
}
},
{
$cond: [
{
$not: {
$in: [
inputObj.id,
"$state.id"
]
}
},
[
],
[]
]
}
]
}
}
}
],
{
"new": true,
"upsert": true
})
Mongo Playground
Prior to version 4.2 and the introduction of this feature what you're trying to do was not possible using the naive update syntax, If you are using an older version then you'd have to split this into 2 separate calls, first a findOne to see if the document exists, and only then an update based on that. obviously this can cause stability issue's if you have high update volume.

mongoose findByIdAndUpdate array of object not working

I try to update array of object with mongoose methodes. When i try with vanila JS it worked but with mongoose not.
model:
const exampleSchema = new mongoose.Schema({
arrayOfObjects: [
{ name: String, id: mongoose.Schema.Types.ObjectId },
],
});
find and update by vanila js
const example = await Example.findById(req.body.propertyX);
const validIndex = example.arrayOfObjects.findIndex((v) => v.propertyY === req.body.Y);
if (validIndex === -1) {
example.arrayOfObjects.push({ propertyY: req.body.Y, propertyZ: req.body.Z });
} else {
example.arrayOfObjects[validIndex] = { propertyY: req.body.Y, propertyZ: req.body.Z };
console.log('update');
}
await recipe.save();
but when I try use findByIdAndUpdate , $set methode dont work (even $push not working...push is pushing new object id without req.body fields)
mongoose findByIdAndUpdate
const example = await Example.findByIdAndUpdate(req.body.x, {
// arrayOfObjects: { $push: { propertyY: req.body.Y, propertyX: req.body.X} },
$set: { 'arrayOfObjects.$.propertyY': req.body.Y, 'arrayOfObjects.$.propertyX': req.body.X },
});
The issue is with your understand of the positional operator $, from the docs:
the positional $ operator acts as a placeholder for the first element that matches the query document, and
This means it excepts to find a match in the array based on the query, in your case the query does not contain anything regarding the voted array, so you get the following error:
[The positional operator did not find the match needed from the query.]
So what can we do? actually doing the update you want is not so trivial, it only became possible in recent years with the introduction of pipelined updates which allow you to use aggregation operators in your update body, now we can do what you want like so:
db.collection.findByIdAndUpdate(req.body.postId,
[
{
$set: {
voted: {
$ifNull: [
"$voted",
[]
]
}
}
},
{
$set: {
voted: {
$concatArrays: [
{
$filter: {
input: "$voted",
cond: {
$ne: [
"$$this.voterId",
req.body.userId
]
}
}
},
[
{
$mergeObjects: [
{
$ifNull: [
{
$arrayElemAt: [
{
$filter: {
input: "$voted",
cond: {
$eq: [
"$$this.voterId",
req.body.userId
]
}
}
},
0
]
},
{}
]
},
{
voteRank: req.body.rank,
voterId: req.body.userId
}
]
}
]
]
}
}
}
])
Mongo Playground
You can drop the $mergeObjects operator if you don't need it, I added it incase the object could have additional properties that you want to preserve throughout an update. but probably not the case.
It then simplifies the code a little:
db.collection.findByIdAndUpdate(req.body.postId,
[
{
$set: {
voted: {
$ifNull: [
'$voted',
[],
],
},
},
},
{
$set: {
voted: {
$concatArrays: [
{
$filter: {
input: '$voted',
cond: {
$ne: [
'$$this.voterId',
req.body.userId,
],
},
},
},
[
{
voteRank: req.body.rank,
voterId: req.body.userId
}
],
],
},
},
},
]);

MongoDB push existing field value to another field in an array of objects

If I have a document like this:
db.people.insertOne({name: "Annie", latestScore: 5});
Then based on this answer, I am able to move latestScore to an array field like this:
db.people.updateOne(
{name: 'Annie'},
[
{$set:
{scoreHistory:
{$concatArrays: [
{$ifNull: ["$scoreHistory", []]},
["$latestScore"]
]}
}
},
{ $unset: ["latestScore"] }
]
);
This is the resulting document:
{
_id: ObjectId("61d2737611e48e0d0c30b35b"),
name: 'Annie',
scoreHistory: [ 5 ]
}
Can we perform the same update to objects nested in an array? For example, if the document is like this:
db.people.insertOne({
name: 'Annie',
words: [
{word: "bunny", score: 5},
{word: "pink", score: 5}
]
});
How can we update it to this:
{
name: 'Annie',
words: [
{word: "bunny", scoreHistory: [5]},
{word: "pink", scoreHistory: [5]}
]
}
I know I can iterate and modify the array from the app and update the whole array at once, but I would like to do it using operators like in the first example above.
The website first displays words.word and words.score in rows, clicking on a row shows words.scoreHistory in a popup. I am expecting words array to be less than 500. Any advise on remodelling the schema to simplify the above operation is also welcome!
db.collection.update({
name: "Annie"
},
[
{
$set: {
words: {
$map: {
input: "$words",
as: "m",
in: {
word: "$$m.word",
scoreHistory: {
$concatArrays: [
{
$ifNull: [
"$$m.scoreHistory",
[]
]
},
[
"$$m.score"
]
]
}
}
}
}
}
}
])
mongoplayground

How to avoid adding duplicate objects to an array in MongoDB

this is my schema:
new Schema({
code: { type: String },
toy_array: [
{
date:{
type:Date(),
default: new Date()
}
toy:{ type:String }
]
}
this is my db:
{
"code": "Toystore A",
"toy_array": [
{
_id:"xxxxx", // automatic
"toy": "buzz"
},
{
_id:"xxxxx", // automatic
"toy": "pope"
}
]
},
{
"code": "Toystore B",
"toy_array": [
{
_id:"xxxxx", // automatic
"toy": "jessie"
}
]
}
I am trying to update an object. In this case I want to update the document with code: 'ToystoreA' and add an array of subdocuments to the array named toy_array if the toys does not exists in the array.
for example if I try to do this:
db.mydb.findOneAndUpdate({
code: 'ToystoreA,
/*toy_array: {
$not: {
$elemMatch: {
toy: [{"toy":'woddy'},{"toy":"buzz"}],
},
},
},*/
},
{
$addToSet: {
toy_array: {
$each: [{"toy":'woddy'},{"toy":"buzz"}],
},
},
},
{
new: false,
}
})
they are added and is what I want to avoid.
how can I do it?
[
{
"code": "Toystore A",
"toy_array": [
{
"toy": "buzz"
},
{
"toy": "pope"
}
]
},
{
"code": "Toystore B",
"toy_array": [
{
"toy": "jessie"
}
]
}
]
In this example [{"toy":'woddy'},{"toy":"buzz"}] it should only be added 'woddy' because 'buzz' is already in the array.
Note:when I insert a new toy an insertion date is also inserted, in addition to an _id (it is normal for me).
As you're using $addToSet on an object it's failing for your use case for a reason :
Let's say if your document look like this :
{
_id: 123, // automatically generated
"toy": "buzz"
},
{
_id: 456, // automatically generated
"toy": "pope"
}
and input is :
[{_id: 789, "toy":'woddy'},{_id: 098, "toy":"buzz"}]
Here while comparing two objects {_id: 098, "toy":"buzz"} & {_id: 123, "toy":"buzz"} - $addToSet consider these are different and you can't use $addToSet on a field (toy) in an object. So try below query on MongoDB version >= 4.2.
Query :
db.collection.updateOne({"_id" : "Toystore A"},[{
$addFields: {
toy_array: {
$reduce: {
input: inputArrayOfObjects,
initialValue: "$toy_array", // taking existing `toy_array` as initial value
in: {
$cond: [
{ $in: [ "$$this.toy", "$toy_array.toy" ] }, // check if each new toy exists in existing arrays of toys
"$$value", // If yes, just return accumulator array
{ $concatArrays: [ [ "$$this" ], "$$value" ] } // If No, push new toy object into accumulator
]
}
}
}
}
}])
Test : aggregation pipeline test url : mongoplayground
Ref : $reduce
Note :
You don't need to mention { new: false } as .findOneAndUpdate() return old doc by default, if you need new one then you've to do { new: true }. Also if anyone can get rid of _id's from schema of array objects then you can just use $addToSet as OP was doing earlier (Assume if _id is only unique field), check this stop-mongoose-from-creating-id-property-for-sub-document-array-items.