Sort with Mongoose by the number of respected conditions - node.js

I want to make a search function with mongoose, and I have to be able to make a research with multiple fields (with Mongoose, in NodeJS).
So, I do something like this :
const result = await myModel.find({
$or [{condition1: "value1"}, {condition2: "value2"}, etc...]
});
But, I want to sort the result by the number of condition the object returned have. Like :
If I have 2 conditions, I want to display first the objects respecting the 2 conditions, then the objects respecting the 1st condition, and finally the objects respecting the 2nd condition.
Do you guys know how I can do this? :)
Thanks in advance !
================EDIT================
This is the new search function :
/**
* Search function which returns users matching jobs and skills.
*
* #param {Array[String]} jobs
* #param {Array[String]} skills
* #return {Array[UserModel]} users
*/
async search(jobs, skills) {
// Normalized arrays of jobs, skills and fields (to use it in a mongoose request).
const jobSkills = [];
const associatedSkills = [];
const fields = [];
for (const job of jobs) {
jobSkills.push({
$cond: [
{
$eq: ["$jobSkills", job],
},
2,
0,
],
});
fields.push({
jobSkills: job,
});
}
for (const skill of skills) {
associatedSkills.push({
$cond: [
{
$eq: ["$associatedSkills", skill],
},
1,
0,
],
});
fields.push({
associatedSkills: skill,
});
}
// Request to find users matching jobs and skills.
const users = await UserModel.aggregate([
{
$match: {
$or: fields,
},
},
{
$addFields: {
sortField: {
$sum: jobSkills.concat(associatedSkills),
},
},
},
{
$sort: {
sortField: -1,
},
},
]);
return users;
}
Aggregation Log :
Aggregate {
_pipeline: [
{ '$match': [Object] },
{ '$addFields': [Object] },
{ '$sort': [Object] }
],
_model: Model { User },
options: {}
}

In general, a document either matches a query predicate or it doesn't. There isn't really a concept of one document matching "better" than another. So it looks like you'll want to generate a custom value in a new field and sort on that. This will need to be done via an aggregation.
So after the $match, we'll want an $addFields stage that effectively duplicates the query predicates. For each one it will be wrapped in a conditional statement ($cond) where we add 1 for a match or 0 otherwise, e.g.:
{
$cond: [
{
$eq: [
"$condition1",
"value1"
]
},
1,
0
]
}
Then there will be a $sum pulling them together to generate the final score to sort on.
Taken together, the aggregation will look something like this:
db.collection.aggregate([
{
$match: {
$or: [
{
condition1: "value1"
},
{
condition2: "value2"
}
]
}
},
{
$addFields: {
sortField: {
"$sum": [
{
$cond: [
{
$eq: [
"$condition1",
"value1"
]
},
1,
0
]
},
{
$cond: [
{
$eq: [
"$condition2",
"value2"
]
},
1,
0
]
}
]
}
}
},
{
$sort: {
"sortField": -1
}
}
])
Playground demonstration here

Related

How to get particular details from nested object from MongoDB

I'm saving data for a NestJs based web app in MongoDB.
My MongoDB Data looks like this
"gameId": "1a2b3c4d5e"
"rounds": [
{
"matches": [
{
"match_id": "1111abc1111",
"team1": {
"team_id": "team8",
"score": 0
},
"team2": {
"team_id": "team2",
"score": 0
}
},
{
"match_id": "2222abc2222",
"team1": {
"team_id": "team6",
"score": 0
},
"team2": {
"team_id": "team5",
"score": 0
}
},
]
}
]
Here we have gameId for each game and inside each game, there are many rounds and many matches. Each match has match_id. How can I get a particular match info and edit it based on gameId & match_id?
(N.B: I'm willing to update score based on match_id)
I've tried something like this
const matchDetails = await this.gameModel.findOne({
gameId: gameId,
rounds: { $elemMatch: { match_id: match_id } },
});
But this doesn't work and returns null. How to do this correctly?
The problem is that you're applying the elemMatch on the rounds array, but it should be on rounds.matches. Changing your query to the following will fix the problem:
const matchDetails = await this.gameModel.findOne({
gameId: gameId,
"rounds.matches": { $elemMatch: { match_id: match_id } },
});
EDIT:
To only get a specific matching element, you can use a simple aggregation with $unwind and $filter:
db.collection.aggregate([
{
"$match": {
"gameId": gameId,
"rounds.matches": { $elemMatch: { match_id: match_id } }
}
},
{
"$unwind": "$rounds"
},
{
$project: {
match: {
$filter: {
input: "$rounds.matches",
as: "match",
cond: {
$eq: [
"$$match.match_id",
match_id
]
}
}
},
_id: 0
}
}
])
Example on mongoplayground.

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
}

Get informations from 2 models with Mongoose/NodeJs [duplicate]

I have a collection called article_category which store all article_id belongs to the category with category_id with data format like so.
Collection 1: article_category
{
"article_id": 2015110920343902,
"all_category_id": [5,8,10]
}
Then I have other collection called article which store all my post
Collection 2: article
{
"title": "This is example rows in article collection"
"article_id": 2015110920343902,
},
{
"title": "Something change"
"article_id": 2015110920343903,
},
{
"title": "This is another rows",
"article_id": 2015110920343904,
}
Now I want to perform MongoDB query to find title with regex while category_id must equal to 8. Here is my query but is not work.
db.article.aggregate(
{
$match:
{
title:
{
$regex: /example/
}
}
},
{
$lookup:
{
from: "article_category",
pipeline: [
{ $match: { category_id: 8 } }
],
as: "article_category"
}
}
)
Above query only show the records which match by regex but not match by category_id.
Any idea?
First of all, it is all_category_id, not category_id. Secondly, you don't link articles - all documents will have exactly the same article_category array. Lastly, you probably want to filter out articles that don't have matched category. The conditional pipeline should look more like this:
db.article.aggregate([
{ $match: {
title: { $regex: /example/ }
} },
{ $lookup: {
from: "article_category",
let: {
article_id: "$article_id"
},
pipeline: [
{ $match: {
$expr: { $and: [
{ $in: [ 8, "$all_category_id" ] },
{ $eq: [ "$article_id", "$$article_id" ] }
] }
} }
],
as: "article_category"
} },
{ $match: {
$expr: { $gt: [
{ $size: "$article_category"},
0
] }
} }
] )
UPDATE:
If you don't match article_id, the $lookup will result with identical article_category array to all articles.
Let's say your article_category collection has another document:
{
"article_id": 0,
"all_category_id": [5,8,10]
}
With { $eq: [ "$article_id", "$$article_id" ] } in the pipeline the resulting article_category is
[
{
"article_id" : 2015110920343902,
"all_category_id" : [ 5, 8, 10 ]
}
]
without:
[
{
"article_id" : 2015110920343902,
"all_category_id" : [ 5, 8, 10 ]
},
{
"article_id": 0,
"all_category_id": [ 5, 8, 10 ]
}
]
If the later is what you need, it would be way simpler to make to find requests:
db.article.find({ title: { $regex: /example/ } })
and
db.article_category.find({ all_category_id: 8 })
You've couple of things incorrect here. category_id should be all_category_id. Use the join condition in $lookup and move the $match outside of $lookup stage with $unwind for optimized lookup.
Use $project with exclusion to drop the looked up field from final response.
Something like {$project:{article_category:0}}
Try
db.article.aggregate([
{"$match":{"title":{"$regex":/example/}}},
{"$lookup":{
"from":"article_category",
"localField":"article_id",
"foreignField":"article_id",
"as":"article_category"
}},
{"$unwind":"$article_category"},
{"$match":{"article_category.all_category_id":8}}
])
For uncorrelated subquery try
db.article.aggregate([
{"$match":{"title":{"$regex":/example/}}},
{"$lookup":{
"from":"article_category",
"pipeline":[{"$match":{"all_category_id":8}}],
"as":"categories"
}},
{"$match":{"categories":{"$ne":[]}}}
])

Filter with Object child in mongo using nodejs

I have a saved a collection in my database and I want to filter it using companyId and cameras using ObjectId specific.
In the follow is the collection that a want to get.
{
"_id": ObjectID("5c3b584fa7e1b10155e6325f"),
"companyId": "5c3b5468a7e1b10155e9995b",
"name": "Place Test",
"cameras": {
"0": ObjectID("5c9149e3f054d00028cc9604"),
"1": ObjectID("5c9149e3f054d00028cc9605")
}
}
I'm trying to filter like:
const placeCollection = req.app.locals.db.collection('places')
const place = placeCollection.findOne({
companyId: req.body.companyId,
cameras: { $elemMatch: { $eq: new ObjectId(req.body.cameraId) } }
})
but not working with cameras filter, only with companyId.
Since the keys in cameras are dynamically generated you need $objectToArray operator to check if any value is equal to req.body.cameraId. You can take advantage of $anyElementTrue operator here:
db.col.aggregate([
{
$match: {
$expr: {
$and: [
{
$anyElementTrue: {
$map: {
input: { $objectToArray: "$cameras" },
in: { $eq: [ "$$this.v", new ObjectId(req.body.cameraId) ] }
}
}
},
{ $eq: [ "$companyId", req.body.companyId ] }
]
}
}
}
])
Mongo playground

How to pull one instance of an item in an array in MongoDB?

According to the documents:
The $pull operator removes from an existing array all instances of a value or values that match a specified condition.
Is there an option to remove only the first instance of a value? For example:
var array = ["bird","tiger","bird","horse"]
How can the first "bird" be removed directly in an update call?
So you are correct in that the $pull operator does exactly what the documentation says in that it's arguments are in fact a "query" used to match the elements that are to be removed.
If your array content happened to always have the element in the "first" position as you show then the $pop operator does in fact remove that first element.
With the basic node driver:
collection.findOneAndUpdate(
{ "array.0": "bird" }, // "array.0" is matching the value of the "first" element
{ "$pop": { "array": -1 } },
{ "returnOriginal": false },
function(err,doc) {
}
);
With mongoose the argument to return the modified document is different:
MyModel.findOneAndUpdate(
{ "array.0": "bird" },
{ "$pop": { "array": -1 } },
{ "new": true },
function(err,doc) {
}
);
But neither are of much use if the array position of the "first" item to remove is not known.
For the general approach here you need "two" updates, being one to match the first item and replace it with something unique to be removed, and the second to actually remove that modified item.
This is a lot more simple if applying simple updates and not asking for the returned document, and can also be done in bulk across documents. It also helps to use something like async.series in order to avoid nesting your calls:
async.series(
[
function(callback) {
collection.update(
{ "array": "bird" },
{ "$unset": { "array.$": "" } },
{ "multi": true }
callback
);
},
function(callback) {
collection.update(
{ "array": null },
{ "$pull": { "array": null } },
{ "multi": true }
callback
);
}
],
function(err) {
// comes here when finished or on error
}
);
So using the $unset here with the positional $ operator allows the "first" item to be changed to null. Then the subsequent query with $pull just removes any null entry from the array.
That is how you remove the "first" occurance of a value safely from an array. To determine whether that array contains more than one value that is the same though is another question.
It's worth noting that whilst the other answer here is indeed correct that the general approach here would be to $unset the matched array element in order to create a null value and then $pull just the null values from the array, there are better ways to implement this in modern MongoDB versions.
Using bulkWrite()
As an alternate case to submitting two operations to update in sequence as separate requests, modern MongoDB release support bulk operations via the recommended bulkWrite() method which allows those multiple updates to be submitted as a single request with a single response:
collection.bulkWrite(
[
{ "updateOne": {
"filter": { "array": "bird" },
"update": {
"$unset": { "array.$": "" }
}
}},
{ "updateOne": {
"filter": { "array": null },
"update": {
"$pull": { "array": null }
}
}}
]
);
Does the same thing as the answer showing that as two requests, but this time it's just one. This can save a lot of overhead in server communication, so it's generally the better approach.
Using Aggregation Expressions
With the release of MongoDB 4.2, aggregation expressions are now allowed in the various "update" operations of MongoDB. This is a single pipeline stage of either $addFields, $set ( which is an alias of $addFields meant to make these "update" statements read more logically ), $project or $replaceRoot and it's own alias $replaceWith. The $redact pipeline stage also applies here to some degree. Basically any pipeline stage which returns a "reshaped" document is allowed.
collection.updateOne(
{ "array": "horse" },
[
{ "$set": {
"array": {
"$concatArrays": [
{ "$slice": [ "$array", 0, { "$indexOfArray": [ "$array", "horse" ] }] },
{ "$slice": [
"$array",
{ "$add": [{ "$indexOfArray": [ "$array", "horse" ] }, 1] },
{ "$size": "$array" }
]}
]
}
}}
]
);
In this case the manipulation used is to implement the $slice and $indexOfArray operators to essentially piece together a new array which "skips" over the first matched array element. Theses pieces are joined via the $concatArrays operator, returning a new array absent of the first matched element.
This is now probably more effective since the operation which is still a single request is now also a single operation and would incur a little less server overhead.
Of course the only catch is that this is not supported in any release of MongoDB prior to 4.2. The bulkWrite() on the other hand may be a newer API implementation, but the actual underlying calls to the server would apply back to MongoDB 2.6 implementing actual "Bulk API" calls, and even regresses back to earlier versions by the way all core drivers actually implement this method.
Demonstration
As a demonstration, here is a listing of both approaches:
const { Schema } = mongoose = require('mongoose');
const uri = 'mongodb://localhost:27017/test';
const opts = { useNewUrlParser: true, useUnifiedTopology: true };
mongoose.Promise = global.Promise;
mongoose.set('debug', true);
mongoose.set('useCreateIndex', true);
mongoose.set('useFindAndModify', false);
const arrayTestSchema = new Schema({
array: [String]
});
const ArrayTest = mongoose.model('ArrayTest', arrayTestSchema);
const array = ["bird", "tiger", "horse", "bird", "horse"];
const log = data => console.log(JSON.stringify(data, undefined, 2));
(async function() {
try {
const conn = await mongoose.connect(uri, opts);
await Promise.all(
Object.values(conn.models).map(m => m.deleteMany())
);
await ArrayTest.create({ array });
// Use bulkWrite update
await ArrayTest.bulkWrite(
[
{ "updateOne": {
"filter": { "array": "bird" },
"update": {
"$unset": { "array.$": "" }
}
}},
{ "updateOne": {
"filter": { "array": null },
"update": {
"$pull": { "array": null }
}
}}
]
);
log({ bulkWriteResult: (await ArrayTest.findOne()) });
// Use agggregation expression
await ArrayTest.collection.updateOne(
{ "array": "horse" },
[
{ "$set": {
"array": {
"$concatArrays": [
{ "$slice": [ "$array", 0, { "$indexOfArray": [ "$array", "horse" ] }] },
{ "$slice": [
"$array",
{ "$add": [{ "$indexOfArray": [ "$array", "horse" ] }, 1] },
{ "$size": "$array" }
]}
]
}
}}
]
);
log({ aggregateWriteResult: (await ArrayTest.findOne()) });
} catch (e) {
console.error(e);
} finally {
mongoose.disconnect();
}
})();
And the output:
Mongoose: arraytests.deleteMany({}, {})
Mongoose: arraytests.insertOne({ array: [ 'bird', 'tiger', 'horse', 'bird', 'horse' ], _id: ObjectId("5d8f509114b61a30519e81ab"), __v: 0 }, { session: null })
Mongoose: arraytests.bulkWrite([ { updateOne: { filter: { array: 'bird' }, update: { '$unset': { 'array.$': '' } } } }, { updateOne: { filter: { array: null }, update: { '$pull': { array: null } } } } ], {})
Mongoose: arraytests.findOne({}, { projection: {} })
{
"bulkWriteResult": {
"array": [
"tiger",
"horse",
"bird",
"horse"
],
"_id": "5d8f509114b61a30519e81ab",
"__v": 0
}
}
Mongoose: arraytests.updateOne({ array: 'horse' }, [ { '$set': { array: { '$concatArrays': [ { '$slice': [ '$array', 0, { '$indexOfArray': [ '$array', 'horse' ] } ] }, { '$slice': [ '$array', { '$add': [ { '$indexOfArray': [ '$array', 'horse' ] }, 1 ] }, { '$size': '$array' } ] } ] } } } ])
Mongoose: arraytests.findOne({}, { projection: {} })
{
"aggregateWriteResult": {
"array": [
"tiger",
"bird",
"horse"
],
"_id": "5d8f509114b61a30519e81ab",
"__v": 0
}
}
NOTE : The example listing is using mongoose, partly because it was referenced in the other answer given and partly to also demonstrate an important point with the aggregate syntax example. Note the code uses ArrayTest.collection.updateOne() since at the present release of Mongoose ( 5.7.1 at time of writing ) the aggregation pipeline syntax to such updates is being removed by the standard mongoose Model methods.
As such the .collection accessor can be used in order to get the underlying Collection object from the core MongoDB Node driver. This would be required until a fix is made to mongoose which allows this expression to be included.
As mentioned in this Jira this feature will never exist properly.
The approach I recommend using would be via the aggregation pipeline update syntax as proposed in a different answer, however that answer has some edge cases where it fails - for example if the element does not exist in the array, here is a working version for all edge cases.
ArrayTest.updateOne({},
[
{
"$set": {
"array": {
"$concatArrays": [
{
$cond: [
{
$gt: [
{
"$indexOfArray": [
"$array",
"horse"
]
},
0
]
},
{
"$slice": [
"$array",
0,
{
"$indexOfArray": [
"$array",
"horse"
]
}
]
},
[]
]
},
{
"$slice": [
"$array",
{
"$add": [
{
"$indexOfArray": [
"$array",
"horse"
]
},
1
]
},
{
"$size": "$array"
}
]
}
]
}
}
}
])
Mongo Playground

Resources