Mongoose conditional update with array of objects - node.js

I have a Cart schema in Mongoose including CartItems as an array of objects.
{
_id: string;
items: [
{
product: string;
options: {
size: string;
color: string;
}
quantity: number;
}
]
}
In this case, is it possible to push an item when product and options doesn't match or add quantity when it does?
Here is the code that I have tried which does not work.
Cart.findByIdAndUpdate(_id, {
items: {
$cond: [
{
// Does not work
$elemMatch: {
product,
"options.color": options.color,
"options.size": options.size,
},
},
{
// Should add quantity where it is matched.
},
{
$push: {
product,
productName,
options,
quantity,
},
},
],
},
});

Query
pipeline update requires MongoDB >= 4.2
newProduct is the product you want to add (a js variable)
check if product already exists => add not-exist? field
if not-exists add it in the end
else map to find it and update the quantity
unset the 2 fields newProduct and not-exists
*it does 2 array reads, alternative could be to use $reduce but if you have many products $concatArrays is slow to be inside a reduce, so this is faster solutions (even if reduce would read the array 1 time only)
*you need a method to do update with pipeline, i don't know if mongoose is updated to support it, we are MongoDB 5 so i guess it will be(java is), in worst case you can use updateCommand and call it with runCommand(...)
Test code here
update({"_id" : "1"},
[{"$set":
{"newProduct":
{"product":"p1",
"options":{"size":"s1", "color":"c1"},
"quantity":1}}},
{"$set":
{"not-exists?":
{"$eq":
[{"$filter":
{"input":"$items",
"cond":
{"$and":
[{"$eq":["$$this.product", "$newProduct.product"]},
{"$eq":["$$this.options.size", "$newProduct.options.size"]},
{"$eq":["$$this.options.color", "$newProduct.options.color"]}]}}},
[]]}}},
{"$set":
{"items":
{"$cond":
["$not-exists?", {"$concatArrays":["$items", ["$newProduct"]]},
{"$map":
{"input":"$items",
"in":
{"$cond":
[{"$and":
[{"$eq":["$$this.product", "$newProduct.product"]},
{"$eq":["$$this.options.size", "$newProduct.options.size"]},
{"$eq":["$$this.options.color", "$newProduct.options.color"]}]},
{"$mergeObjects":
["$$this", {"quantity":{"$add":["$$this.quantity", 1]}}]},
"$$this"]}}}]}}},
{"$unset":["not-exists?", "newProduct"]}])
Query2
if you don't want to use update pipeline you can do it with more queries
Check if exists
db.collection.find({
"_id" : "1",
"items": {
"$elemMatch": {
"product": "p1",
"options": {
"size": "s1",
"color": "c1"
}
}
}
})
If not exists
db.collection.update({
"_id": "1"
},
{
"$push": {
"items": "NEWITEM" //put here the new object
}
})
else If exists
db.collection.update({"_id" : "1"},
{
"$inc": {
"items.$[i].quantity": 1
}
},
{
arrayFilters: [
{
"i.product": "p1",
"i.options.size": "s1",
"i.options.color": "c1"
}
]
})

Related

MongoDB aggregation: Check if user has already rated a product

I'm having some trouble coming up with a solution to this problem by using mongo directly instead of node.
I have a collection of products that have several fields, including an array of ratings. The ratings consist of the id of the user who rated it and the score. They look like this:
{
"_id": {
"$oid": "62812fac06f29f5fe874c0db"
},
"model": "fire",
"allRatings": [
{
"user_id": "6282962e3aa8163084776092",
"rating": 9
},
{
"user_id": "62811d520f52b64990a94e99",
"rating": 6
}
],
}
I'd like to return all of the fields, but instead of all the ratings, I just want to return a boolean that shows if the current user, whose ID I have in the code, has already rated the product. I have tried with aggregations, but haven't found any solution that works.
Something like this:
{
"_id": {
"$oid": "62812fac06f29f5fe874c0db"
},
"model": "fire",
"hasRated": true,
}
If I've understood correctly you can use this aggregation query:
This query looks for the object you want (matching by _id) and then, into a $project stage it set the value hasRated if the desired id exists into the array.
db.collection.aggregate([
{
$match: {
_id: ObjectId("62812fac06f29f5fe874c0db")
}
},
{
$project: {
model: 1,
hasRated: {
"$in": [
"6282962e3aa8163084776092",
"$allRatings.user_id"
]
}
}
}
])
Example here
To do in JS you can simply create the object adding the id as a variable (something like this, is pseudocode, not tested):
const id = 'your_id_here'
const agg = [{
$match: {
_id: new ObjectId("62812fac06f29f5fe874c0db")
}
},
{
$project: {
model: 1,
hasRated: {
"$in": [
id,
"$allRatings.user_id"
]
}
}
}]
db.aggregate(agg)

Remove Embedded Documents in an Array in MongoDB with mongoose (updateOne - $pull) not work

I have an app with MongoDB (Mongoose) in NodeJs.
In a collection I have this type of documents, defined by weeks:
{
"_id":
{"$oid":"617f3f51f883fab2de3e7260"},
"endDate":{"$date":"2021-11-07T23:59:59.000Z"},
"startDate":{"$date":"2021-11-01T00:00:00.000Z"},
"wastes":[
{"timestamp":{"$date":"2021-11-01T01:00:58.000Z"},"duration":780},
{"timestamp":{"$date":"2021-11-01T01:00:58.000Z"},"duration":1140},
{"timestamp":{"$date":"2021-11-01T03:00:58.000Z"},"duration":540},
{"timestamp":{"$date":"2021-11-01T07:00:58.000Z"},"duration":540},
{"timestamp":{"$date":"2021-11-01T09:00:58.000Z"},"duration":960},
{"timestamp":{"$date":"2021-11-01T09:00:58.000Z"},"duration":1140},
{"timestamp":{"$date":"2021-11-01T15:00:58.000Z"},"duration":180},
{"timestamp":{"$date":"2021-11-01T15:00:58.000Z"},"duration":540}
...
]}
I have a function that finds wastes with the same timestamp, for example "2021-11-01T01:00:58.000Z", gives the longest duration for this timestamp.
I want to delete all entries with that timestamp:
{"timestamp":{"$date":"2021-11-01T01:00:58.000Z"},"duration":780},
{"timestamp":{"$date":"2021-11-01T01:00:58.000Z"},"duration":1140}
And insert only the one with the highest duration:
{"timestamp":{"$date":"2021-11-01T01:00:58.000Z"},"duration":1140}
I'm using updateOne with $pull and $push, but it doesn't work.
let query = {
startDate: new Date(startDayWeek),
};
let deleteProjection = {
$pull: {
wastes: { timestamp: new Date(timestampDeleteInsertion) },
},
};
let insertProjection = {
$push: { wastes: insertRegisterForTimestamp },
};
//Delete
await coleccion.updateOne(query, deleteProjection);
//Insertion
await coleccion.updateOne(query, insertProjection);
I have also tried with {upsert: false}, {multi: true}.
If I use the same commands in the MongoDB Compass shell, it works without problems:
//Delete
db.coleccion.updateOne({startDate: ISODate('2021-11-01T00:00:00')}, {$pull: {'wastes': {timestamp: ISODate('2021-11-01T01:00:58.000Z')}}})
//Insertion
db.coleccion.updateOne({startDate: ISODate('2021-11-01T00:00:00')}, {$push: {'wastes': {'timestamp':ISODate('2021-11-01T01:00:58.000Z'), 'duration': 1140}}})
You can achieve expected behaviour with Updates with Aggregation Pipeline
The aggregation will consists of 3 steps:
find out the max duration using $reduce; stored the result into a field
$filter the wastes array by keeping only elements not equal to the selected timestamp or the duration is not the max duration
$unset the helper field created in step 1
db.collection.update({},
[
{
$addFields: {
maxDuration: {
"$reduce": {
"input": "$wastes",
"initialValue": null,
"in": {
"$cond": {
"if": {
$and: [
{
$eq: [
"$$this.timestamp",
{
"$date": "2021-11-01T01:00:58.000Z"
}
]
},
{
$gt: [
"$$this.duration",
"$$value"
]
}
]
},
"then": "$$this.duration",
"else": "$$value"
}
}
}
}
}
},
{
$set: {
wastes: {
$filter: {
input: "$wastes",
as: "w",
cond: {
$or: [
{
$ne: [
"$$w.timestamp",
{
"$date": "2021-11-01T01:00:58.000Z"
}
]
},
{
$eq: [
"$$w.duration",
"$maxDuration"
]
}
]
}
}
}
}
},
{
"$unset": "maxDuration"
}
])
Here is the Mongo playground for your reference.
I have the same issue with the updateOne and pull command, if use the updateOne with push, it works.
In the mongo shell or in the compass, both situations (push/pull) works, but with mongoose, it finds the criteria but don't update/modify.
Result
{
"acknowledged" : true,
"matchedCount" : 1.0,
"modifiedCount" : 0.0
}

How to remove _id from aggregate $match & $group query

I am trying to display unique records grouped by the particular slug passed in.
My output in postman looks like this though:
"subcats": [
{
"_id": {
"subcategory": {
"_id": "5d2b42c47b454712f4db7c37",
"name": "shirts"
}
}
}
]
when my desired output is:
"subcats": [
{
"_id": "5d2b42c47b454712f4db7c37",
"name": "shirts"
}
]
An example of a product in the database:
"_id": "5d39eff7a48e6e30ace831dc",
"name": "A colourful shirt",
"description": "A nice colourful t-shirt",
"category": {
"_id": "5d35faa67b19e32ab3dc91ec",
"name": "clothing",
"catSlug": "clothing"
},
"subcategory": {
"_id": "5d2b42c47b454712f4db7c37",
"name": "shirts",
"catSlug": "shirts"
},
"price": 19
}
I don't want that top level _id there with everything nested inside of it.
I tried using $project but then I just end up with an empty array.
const products = await Product.find({ "category.catSlug": catslug }).select({
name: 1,
description: 1,
price: 1,
category: 1
});
const subcats = await Product.aggregate([
{ $match: { "category.catSlug": catslug } },
{ $group: { _id: { subcategory: "$subcategory" } } }
{ $project: { _id: 0, name: 1 } }
]);
Promise.all([products, subcats]);
res.status(200).json({
products,
subcats
});
What I have understood from your question is you need product by category and all sub categories as a list.
Below is the aggregation:
db.getCollection('test').aggregate([
{ $match: { "category.catSlug": "clothing" } },
{ $group: { _id: '$subcategory.name', subCategory: { $first : "$subcategory" }}},
{ $group: { _id: null, subcats: { $push: {_id: "$subCategory._id", name: "$subCategory.name" }}}},
{ $project: {_id: 0, subcats: 1}}
]);
Note: You can directly push "$subcategory" but it will have catSlug inside object
Output:
{
"_id" : null,
"subcats" : [{
"_id" : "5d2b42c47b454712f4db7c37",
"name" : "shirts"
},
{
"_id" : "5d2b42c47b454712f4db7c37",
"name" : "pents"
}]
}
Hope this help!
Hi you need to assign the required data in other fields while projection then define the _id to 0 as given below
{$project:{subcat_data:'$_id.subcategory'},_id:0}

findOne() returns entire document, instead of a single object

I'm trying to query this set of data using findOne():
{
"_id": {
"$oid": "5c1a4ba1482bf501ed20ae4b"
},
"wardrobe": {
"items": [
{
"type": "T-shirt",
"colour": "Gray",
"material": "Wool",
"brand": "Filson",
"_id": "5c1a4b7d482bf501ed20ae4a"
},
{
"type": "T-shirt",
"colour": "White",
"material": "Acrylic",
"brand": "H&M",
"_id": "5c1a4b7d482bf501ed20ae4a"
}
]
},
"tokens": [],
"email": "another#new.email",
"password": "$2a$10$quEXGjbEMX.3ERdjPabIIuMIKu3zngHDl26tgRcCiIDBItSnC5jda",
"createdAt": {
"$date": "2018-12-19T13:46:09.365Z"
},
"updatedAt": {
"$date": "2018-12-19T13:47:30.123Z"
},
"__v": 2
}
I want to return a single object from the items array using _Id as a filter. This is how I'm doing that:
exports.deleteItem = (req, res, next) => {
User.findOne({ 'wardrobe.items': { $elemMatch: { "_id": "5c1a4b7d482bf501ed20ae4a",} } }, (err, item) => {
console.log(item);
if (err) {
return console.log("error: " + err);
}
res.redirect('/wardrobe');
});
};
However, console.log(item) returns the whole document—like so:
{ wardrobe: { items: [ [Object], [Object] ] },
tokens: [],
_id: 5c1a4ba1482bf501ed20ae4b,
email: 'another#new.email',
password:
'$2a$10$quEXGjbEMX.3ERdjPabIIuMIKu3zngHDl26tgRcCiIDBItSnC5jda',
createdAt: 2018-12-19T13:46:09.365Z,
updatedAt: 2018-12-19T13:47:30.123Z,
__v: 2 }
I want to eventually use this to delete single items, so I need to filter to the single object from the subdocument.
Concerning your question:
MongoDB always returns the full object matching your query, unless you add a projection specifying which fields should be returned.
If you really want to only return a nested object, you could use the aggregation pipeline with the $replaceRoot operator like this:
User.aggregate([
// you can directly query for array fields instead of $elemMatching them
{ $match: { 'wardrobe.items._id': "5c1a4b7d482bf501ed20ae4a"}}},
// this "lifts" the fields wardrobe up and makes it the new root
{ $replaceRoot: {newRoot: '$wardrobe'}
// this "splits" the array into separate objects
{ $unwind: '$items'},
// this'll remove all unwanted elements
{ $match: { 'items._id': "5c1a4b7d482bf501ed20ae4a" },
},
])
This should return only the wanted items.
A note though: If you plan to remove elements from arrays anyways, I'd rather suggest you have a look at the $pull operation, which can remove an element from an array if it matches a certain condition:
https://docs.mongodb.com/manual/reference/operator/update/pull/
User.update(
{ 'wardrobe.items._id': "5c1a4b7d482bf501ed20ae4a"},
{ $pull: { 'wardrobe.items': {_id: "5c1a4b7d482bf501ed20ae4a"}},
{ multi: true }
)

Search by Date within an Object in Array

I have the following Mongoose schema:
let ExerciserSchema = new Schema({
username: {
type: String,
required: true
},
exercises: [{
desc: String,
duration: Number,
date: {
type: Date,
default: new Date()
}
}]
});
I want to search by username and limit the exercise results to a date range.
I tried this lookup function:
let user = await Exerciser.find(
{ "username": name },
{ "exercises.date": { "$gte": from }},
{ "exercises.date": { "$lte": to }}
).exec((err, data) => {
if (err) {
res.json({ Error: "Data not found" })
return done(err);
}
else {
res.json(data);
return done(null, data);
}
});
However, it's logging an error and not returning the data.
MongoError: Unsupported projection option: exercises.date: { $gte: new Date(1526342400000) }
I realize from that error it appears like my date is being searched for in milliseconds, but I console.log it right before I run the above function and it's in date mode, which is what I think I want: 2018-05-01T00:00:00.000Z
How can I make this work so that I can search by a date range given my Schema? I can change the format of the date in the Schema if necessary. I'd just like the simplest solution. Thanks for your help.
You're query is wrong. You were trying to write an AND condition, but you separated documents instead of putting everything into one. This means the "second" argument to Model.find() was interpreted as a a "projection of fields", hence the error:
MongoError: Unsupported projection option:
So it's not a "schema problem" but that you sent the wrong arguments to the Model.find() method
Also you need $elemMatch for multiple conditions on elements within an array:
// Use a try..catch block with async/await of Promises
try {
let user = await Exerciser.find({
"username": name,
"exercises": {
"$elemMatch": { "date": { "$gte": from, "$lte": to } }
}
});
// work with user
} catch(e) {
// handle any errors
}
Most importantly you don't await a callback. You either await the Promise like I am showing here or simply pass in the callback instead. Not both.
Exerciser.find({
"username": name,
"exercises": {
"$elemMatch": { "date": { "$gte": from, "$lte": to } }
}
}).exec((err,user) => {
// the rest
})
FYI, what you were attempting to do was this:
Exerciser.find({
"$and": [
{ "username": name },
{ "exercises.date": { "$gte": from }},
{ "exercises.date": { "$lte": to }}
]
)
But that is actually still incorrect since without the $elemMatch the $gte and $lte applies to ALL elements of the array and not just a single one. So the incorrect results would show if ANY array item was less than the date but not necessarily greater than.
For array elements the $elemMatch enforces the "between" of the two conditions.
I managed to get it. This answer matches by username, and filters exercises so they are between the dates with variable names to and from. This is what I had wanted.
let user = Exerciser.aggregate([
{ $match: { "username": id }},
{ $project: { // $project passes along the documents with the requested fields to the next stage in the pipeline
exercises: { $filter: {
input: "$exercises",
as: "exercise",
cond: { $and: [
{ $lte: [ "$$exercise.date", to ] },
{ $gte: [ "$$exercise.date", from ] },
]}
}},
username: 1, // include username in returned data
_id: 0
}}
])
Result:
[
{
"username": "scott",
"exercises": [
{
"desc": "Situps",
"duration": 5,
"_id": "5af4790fd9a9c80c11aac696",
"date": "2018-04-30T00:00:00.000Z"
},
{
"desc": "Situps",
"duration": 10,
"_id": "5afb3f03e12e38020d059e67",
"date": "2018-05-01T00:00:00.000Z"
},
{
"desc": "Pushups",
"duration": 8,
"_id": "5afc08aa9259ed008e7e0895",
"date": "2018-05-02T00:00:00.000Z"
}
]
}
]

Resources