Push if not present or update a nested array mongoose [duplicate] - node.js

I have documents that looks something like that, with a unique index on bars.name:
{ name: 'foo', bars: [ { name: 'qux', somefield: 1 } ] }
. I want to either update the sub-document where { name: 'foo', 'bars.name': 'qux' } and $set: { 'bars.$.somefield': 2 }, or create a new sub-document with { name: 'qux', somefield: 2 } under { name: 'foo' }.
Is it possible to do this using a single query with upsert, or will I have to issue two separate ones?
Related: 'upsert' in an embedded document (suggests to change the schema to have the sub-document identifier as the key, but this is from two years ago and I'm wondering if there are better solutions now.)

No there isn't really a better solution to this, so perhaps with an explanation.
Suppose you have a document in place that has the structure as you show:
{
"name": "foo",
"bars": [{
"name": "qux",
"somefield": 1
}]
}
If you do an update like this
db.foo.update(
{ "name": "foo", "bars.name": "qux" },
{ "$set": { "bars.$.somefield": 2 } },
{ "upsert": true }
)
Then all is fine because matching document was found. But if you change the value of "bars.name":
db.foo.update(
{ "name": "foo", "bars.name": "xyz" },
{ "$set": { "bars.$.somefield": 2 } },
{ "upsert": true }
)
Then you will get a failure. The only thing that has really changed here is that in MongoDB 2.6 and above the error is a little more succinct:
WriteResult({
"nMatched" : 0,
"nUpserted" : 0,
"nModified" : 0,
"writeError" : {
"code" : 16836,
"errmsg" : "The positional operator did not find the match needed from the query. Unexpanded update: bars.$.somefield"
}
})
That is better in some ways, but you really do not want to "upsert" anyway. What you want to do is add the element to the array where the "name" does not currently exist.
So what you really want is the "result" from the update attempt without the "upsert" flag to see if any documents were affected:
db.foo.update(
{ "name": "foo", "bars.name": "xyz" },
{ "$set": { "bars.$.somefield": 2 } }
)
Yielding in response:
WriteResult({ "nMatched" : 0, "nUpserted" : 0, "nModified" : 0 })
So when the modified documents are 0 then you know you want to issue the following update:
db.foo.update(
{ "name": "foo" },
{ "$push": { "bars": {
"name": "xyz",
"somefield": 2
}}
)
There really is no other way to do exactly what you want. As the additions to the array are not strictly a "set" type of operation, you cannot use $addToSet combined with the "bulk update" functionality there, so that you can "cascade" your update requests.
In this case it seems like you need to check the result, or otherwise accept reading the whole document and checking whether to update or insert a new array element in code.

if you dont mind changing the schema a bit and having a structure like so:
{ "name": "foo", "bars": { "qux": { "somefield": 1 },
"xyz": { "somefield": 2 },
}
}
You can perform your operations in one go.
Reiterating 'upsert' in an embedded document for completeness

I was digging for the same feature, and found that in version 4.2 or above, MongoDB provides a new feature called Update with aggregation pipeline.
This feature, if used with some other techniques, makes possible to achieve an upsert subdocument operation with a single query.
It's a very verbose query, but I believe if you know that you won't have too many records on the subCollection, it's viable. Here's an example on how to achieve this:
const documentQuery = { _id: '123' }
const subDocumentToUpsert = { name: 'xyz', id: '1' }
collection.update(documentQuery, [
{
$set: {
sub_documents: {
$cond: {
if: { $not: ['$sub_documents'] },
then: [subDocumentToUpsert],
else: {
$cond: {
if: { $in: [subDocumentToUpsert.id, '$sub_documents.id'] },
then: {
$map: {
input: '$sub_documents',
as: 'sub_document',
in: {
$cond: {
if: { $eq: ['$$sub_document.id', subDocumentToUpsert.id] },
then: subDocumentToUpsert,
else: '$$sub_document',
},
},
},
},
else: { $concatArrays: ['$sub_documents', [subDocumentToUpsert]] },
},
},
},
},
},
},
])

There's a way to do it in two queries - but it will still work in a bulkWrite.
This is relevant because in my case not being able to batch it is the biggest hangup. With this solution, you don't need to collect the result of the first query, which allows you to do bulk operations if you need to.
Here are the two successive queries to run for your example:
// Update subdocument if existing
collection.updateMany({
name: 'foo', 'bars.name': 'qux'
}, {
$set: {
'bars.$.somefield': 2
}
})
// Insert subdocument otherwise
collection.updateMany({
name: 'foo', $not: {'bars.name': 'qux' }
}, {
$push: {
bars: {
somefield: 2, name: 'qux'
}
}
})
This also has the added benefit of not having corrupted data / race conditions if multiple applications are writing to the database concurrently. You won't risk ending up with two bars: {somefield: 2, name: 'qux'} subdocuments in your document if two applications run the same queries at the same time.

Related

Using $if Condition with $or for updating multiple MongoDB fields in one go

I am trying to make the following update
if offer == {} OR offer.eta < now THEN offer = new_offer_obj ELSE offer = offer
AND another simultaneous update
if offer.eta < now THEN offer_list.push(offer) ELSE offer_list = offer_list
--- ALL of the above in a one go ---
Inside a MongoDB doc, offer is an object with fields:
offer: {info: {...}, eta: "<ISO-Time-String>"}
offer_list: is simply an array list of the above offer Object
offer_list: []
--- BELOW this is what I tried ---
Here is the code I tried after referencing other answers on SO. This does not work correctly. Not sure what is wrong here.
const now = new Date().getTime()
const res = await collection.update(
{ "_id": 12345 },
{
$set: {
offer: { $cond: { $if: { $or:[ {$lt:["$offer.eta", now]}, {$eq:["$offer", {}]}] }}, then: new_offer_obj, else:"$offer" },
offer_list: {
$cond: [{ $lt:["$offer.eta", now]},
{$push: { offer_list: "$offer" }},
"$offer_list" ]
}
}
},
{
"returnDocument" : "after",
"returnOriginal" : false
}
)
For offer I tried using one type of syntax and for offer_list I tried using another type to experiment which one works. It'd be great if someone could help.
There are a lot of conditions and assumptions in this question, among them trying to compare ISO8601 strings against real dates, whether offer_list is initialized to [] or if it is unset at the start, etc. So the solution below "works" but should not be considered bulletproof. It is mostly a matter of using the pipeline form of update and getting the date comparison working. Given input like this:
now = new ISODate('2022-02-26');
new_offer_obj = {info: {"NEW":"OFFER"}, eta: "2022-06-02T00:00:00Z"};
var r = [
{
'desc':"empty initial offer so this gets JUST new offer obj",
'offer': {}
// does not matter if offer_list is [] or not
}
,{
'desc':"offer is not empty but eta > now so NOTHING HAPPENS",
'offer': {info: {a:1}, eta: "2022-02-27T00:00:00Z"}
}
,{
'desc':"offer not empty AND eta < now so replace offer with new one and push existing onto offer list",
'offer': {info: {a:1}, eta: "2022-02-02T00:00:00Z"},
'offer_list': ['foo']
}
];
then this update:
rc = db.foo.update(
{},
[
// If no offer.eta, assume a very distant date; then convert in either case to real datetime:
{$addFields: {XD: {$toDate: {$ifNull:['$offer.eta','9999-01-01']}}} },
{$set: {
offer: { $cond: [
{$or:[{$lt:['$XD',now]},{$eq:["$offer",{}]}]},
new_offer_obj,
'$offer'
]
},
offer_list: { $cond: [
{ $lt:['$XD', now]},
// $offer is an object so wrap with [] to make an array of 1:
{$concatArrays: [ "$offer_list", ['$offer'] ]},
"$offer_list"
]
}
}},
{$unset: 'XD'}
],
{multi:true}
);
produces these mods:
{ "nMatched" : 3, "nUpserted" : 0, "nModified" : 2 }
{
"_id" : 0,
"desc" : "empty initial offer so this gets JUST new offer obj",
"offer" : {
"info" : {
"NEW" : "OFFER"
},
"eta" : "2022-06-02T00:00:00Z"
}
}
{
"_id" : 1,
"desc" : "offer is not empty but eta > now so NOTHING HAPPENS",
"offer" : {
"info" : {
"a" : 1
},
"eta" : "2022-02-27T00:00:00Z"
}
}
{
"_id" : 2,
"desc" : "offer not empty AND eta < now so replace offer with new one and push existing onto offer list",
"offer" : {
"info" : {
"NEW" : "OFFER"
},
"eta" : "2022-06-02T00:00:00Z"
},
"offer_list" : [
"foo",
{
"info" : {
"a" : 1
},
"eta" : "2022-02-02T00:00:00Z"
}
]
}
An interesting alternative is available if using v4.4 or higher. Starting in v4.4, the $merge stage can write back to the same collection that is being processed, which can turn an aggregate function into a "giant" update function:
c=db.foo.aggregate([
{$addFields: {XD: {$toDate: {$ifNull:['$offer.eta','9999-01-01']}}} },
{$project: {
offer: { $cond: [
{$or:[{$lt:['$XD',now]},{$eq:["$offer",{}]}]},
new_offer_obj,
'$offer'
]
},
offer_list: { $cond: [
{ $lt:['$XD', now]},
{$concatArrays: [ "$offer_list", ['$offer'] ]},
"$offer_list"
]
}
}},
// Write the docs back onto foo. Because of the $project above, only _id,
// offer, and offer_list are flowing through the pipeline now. We will
// match on _id (fast!) and just like $mergeObjects, we will merger
// the new offer and offer_list on top of the existing material.
// We set 'whenNotMatched' to 'fail' because there are no normal conditions where we
// cannot match on the same _id coming through the pipeline:
//
{$merge: {
into: "foo",
on: [ "_id" ],
whenMatched: "merge",
whenNotMatched: "fail"
}}
]);

mongoDB projection on array in an object

I have this document structure in the collection:
{"_id":"890138075223711744",
"guildID":"854557773990854707",
"name":"test-lab",
"game": {
"usedWords":["akşam","elma","akım"]
}
}
What is the most efficient way to get its fields except the array (it can be really large), and at the same time, see if an item exists in the array ?
I tried this:
let query = {_id: channelID}
const options = { sort: { name: 1 }, projection: { name: 1, "game.usedWords": { $elemMatch: { word}}}}
mongoClient.db(db).collection("channels").findOne(query, options);
but I got the error: "$elemMatch can not be used on nested fields"
If I've understood correctly you can use this query:
Using positional operator $ you can return only the matched word.
db.collection.find({
"game.usedWords": "akşam"
},
{
"name": 1,
"game.usedWords.$": 1
})
Example here
The output is only name and the matched word (also _id which is returned by default)
[
{
"_id": "890138075223711744",
"game": {
"usedWords": [
"akşam"
]
},
"name": "test-lab"
}
]

Mongoose - Query deeply nested Objects

I currently have a problem where I have to update entries in a deeply nested Document. Now to simplify my problem I have this example. Let's assume I store cars in my MongoDB. A Document would look like this
{
Make: "BMW",
Model: "3Series",
Wheels: [
{
_id: someObjectId
Size: "19 inch",
Screws: [
{
_id: someObjectId
Type : "M15x40"
},
{
_id: someObjectId
Type : "M15x40"
}
]
}
]
}
Now if I want to update a specific Wheel, my code would look somewhat like this
CarModel.findOneAndUpdate({
"_id": CarId, "Wheels._id": WheelId
}, {
"$set" : {
"Wheels.$.Size": NewSize
}
})
Now this works. But I am pretty lost on how I would update an specific screw as I am going through 2 Arrays. Any Idea how I could make this work?
You need arrayFilters functionality to define the path for more than one nested array:
CarModel.findOneAndUpdate(
{ "_id": CarId },
{ $set: { "Wheels.$[wheel].Screws.$[screw].Type": "something" } },
{ arrayFilters: [ { 'wheel._id': WheelId }, { 'screw._id': screwId } ] })

mongodb remove document if array count zero after $pull in a single query

I have a requirement where my comments schema looks like the following
{
"_id": 1,
"comments": [
{ "userId": "123", "comment": "nice" },
{ "userId": "124", "comment": "super"}
]
}
I would like to pull the elements based on the userId field.
I am doing the following query
comments.update({},{$pull:{comments:{userId:"123"}}})
My requirement is that if the array length became zero after the pull operator I need to remove the entire document for some reason.Is there a away to do this in a single query?
PS:I am using the mongodb driver.Not the mongoose
If I'm reading your question right, after the $pull, if the comments array is empty (zero length), then remove the document ({ _id: '', comments: [] }).
This should remove all documents where the comments array exists and is empty:
comments.remove({ comments: { $exists: true, $size: 0 } })
I had a similar requirement and used this (using mongoose though):
await Attributes.update({}, { $pull: { values: { id: { $in: valueIds } } } }, { multi: true })
await Attributes.remove({ values: { $exists: true, $size: 0 } })
Not sure if it's possible to do this in one operation or not.
You can use middlewares for this.
http://mongoosejs.com/docs/middleware.html
Write a pre/post update method in mongodb to check your condition.

Use mongoose / mongodb to $pull nested data from an array

say I have this array property ('articles') on a Mongoose schema:
articles: [
{
kind: 'bear',
hashtag: 'foo'
},
{
kind: 'llama',
hashtag: 'baz',
},
{
kind: 'sheep',
hashtag: 'bar',
}
]
how can I use
$pull https://docs.mongodb.org/manual/reference/operator/update/pull/
to remote objects from this array by checking the value of hashtag to see if it matches a pattern?
For example, if I want to remove an object in the articles array where hashtag='foo'.
My best guess is the following, but it doesn't seem to work:
var data = {
"$pull": {
"articles": {
"$elemMatch": {
"hashtag": "foo"
}
}
}
};
Model.update({},data); //doesn't quite work
this one seems to work:
var data = {
"$pull": {
"articles": {
"hashtag": 'foo'
}
}
};
Model.update({},data); //seems to work
if you have a better solution or if you can show an alternate solution please provide an answer thank you
The $pull operator is basically a "mini query" in itself, and as such operators like $elemMatch become irrelevant as the "query" is directly performed on each array member anyway.
As such:
Model.update(
{},
{ "$pull": { "articles": { "hashtag": "foo" }},
{ "multi": true },
function(err,numAffected) {
// handle result here
}
)
So it is looking for the matching properties within the ( correct ) specified array in all array documents, and then removing them where there is a match.
The .update() also just returns the number of affected documents, and is usually used with { "multi": true } when you expect more than one document to be updated.
If you are just looking for "one" document, or expect the modified document in response, then use .findOneAndUpdate() instead:
Model.findOneAndUpdate(
{},
{ "$pull": { "articles": { "hashtag": "foo" }},
{ "new": true },
function(err,numAffected) {
// handle result here
}
)
Though not really practical without any selection criteria, since this just updates the first document found in the collection.

Resources