mongoose subdocument sorting - node.js

I have an article schema that has a subdocument comments which contains all the comments i got for this particular article.
What i want to do is select an article by id, populate its author field and also the author field in comments. Then sort the comments subdocument by date.
the article schema:
var articleSchema = new Schema({
title: { type: String, default: '', trim: true },
body: { type: String, default: '', trim: true },
author: { type: Schema.ObjectId, ref: 'User' },
comments: [{
body: { type: String, default: '' },
author: { type: Schema.ObjectId, ref: 'User' },
created_at: { type : Date, default : Date.now, get: getCreatedAtDate }
}],
tags: { type: [], get: getTags, set: setTags },
image: {
cdnUri: String,
files: []
},
created_at: { type : Date, default : Date.now, get: getCreatedAtDate }
});
static method on article schema: (i would love to sort the comments here, can i do that?)
load: function (id, cb) {
this.findOne({ _id: id })
.populate('author', 'email profile')
.populate('comments.author')
.exec(cb);
},
I have to sort it elsewhere:
exports.load = function (req, res, next, id) {
var User = require('../models/User');
Article.load(id, function (err, article) {
var sorted = article.toObject({ getters: true });
sorted.comments = _.sortBy(sorted.comments, 'created_at').reverse();
req.article = sorted;
next();
});
};
I call toObject to convert the document to javascript object, i can keep my getters / virtuals, but what about methods??
Anyways, i do the sorting logic on the plain object and done.
I am quite sure there is a lot better way of doing this, please let me know.

I could have written this out as a few things, but on consideration "getting the mongoose objects back" seems to be the main consideration.
So there are various things you "could" do. But since you are "populating references" into an Object and then wanting to alter the order of objects in an array there really is only one way to fix this once and for all.
Fix the data in order as you create it
If you want your "comments" array sorted by the date they are "created_at" this even breaks down into multiple possibilities:
It "should" have been added to in "insertion" order, so the "latest" is last as you note, but you can also "modify" this in recent ( past couple of years now ) versions of MongoDB with $position as a modifier to $push :
Article.update(
{ "_id": articleId },
{
"$push": { "comments": { "$each": [newComment], "$position": 0 } }
},
function(err,result) {
// other work in here
}
);
This "prepends" the array element to the existing array at the "first" (0) index so it is always at the front.
Failing using "positional" updates for logical reasons or just where you "want to be sure", then there has been around for an even "longer" time the $sort modifier to $push :
Article.update(
{ "_id": articleId },
{
"$push": {
"comments": {
"$each": [newComment],
"$sort": { "$created_at": -1 }
}
}
},
function(err,result) {
// other work in here
}
);
And that will "sort" on the property of the array elements documents that contains the specified value on each modification. You can even do:
Article.update(
{ },
{
"$push": {
"comments": {
"$each": [],
"$sort": { "$created_at": -1 }
}
}
},
{ "multi": true },
function(err,result) {
// other work in here
}
);
And that will sort every "comments" array in your entire collection by the specified field in one hit.
Other solutions are possible using either .aggregate() to sort the array and/or "re-casting" to mongoose objects after you have done that operation or after doing your own .sort() on the plain object.
Both of these really involve creating a separate model object and "schema" with the embedded items including the "referenced" information. So you could work upon those lines, but it seems to be unnecessary overhead when you could just sort the data to you "most needed" means in the first place.
The alternate is to make sure that fields like "virtuals" always "serialize" into an object format with .toObject() on call and just live with the fact that all the methods are gone now and work with the properties as presented.
The last is a "sane" approach, but if what you typically use is "created_at" order, then it makes much more sense to "store" your data that way with every operation so when you "retrieve" it, it stays in the order that you are going to use.

You could also use JavaScript's native Array sort method after you've retrieved and populated the results:
// Convert the mongoose doc into a 'vanilla' Array:
const articles = yourArticleDocs.toObject();
articles.comments.sort((a, b) => {
const aDate = new Date(a.updated_at);
const bDate = new Date(b.updated_at);
if (aDate < bDate) return -1;
if (aDate > bDate) return 1;
return 0;
});

As of the current release of MongoDB you must sort the array after database retrieval. But this is easy to do in one line using _.sortBy() from Lodash.
https://lodash.com/docs/4.17.15#sortBy
comments = _.sortBy(sorted.comments, 'created_at').reverse();

Related

MongoDB: add object to subarray if not exists

I searched many questions here and other articles on the web, but they all seem to describe somehow different cases from what I have at hand.
I have User schema:
{
username: { type: String },
lessons: [
{
lesson: { type: String },
result: { type: String }
}
]
}
I want to add new element into lessons or skip, if there is already one with same values, therefore I use addToSet:
const dbUser = await User.findOne({ username })
dbUser.lessons.addToSet({ lesson, result: JSON.stringify(result) })
await dbUser.save()
However it makes what seems to be duplicates:
// first run
[
{
_id: 60c80418f2bcfe5fb8f501c1,
lesson: '60c79d81cf1f57221c05fdac',
result: '{"correct":2,"total":2}'
}
]
// second run
[
{
_id: 60c80418f2bcfe5fb8f501c1,
lesson: '60c79d81cf1f57221c05fdac',
result: '{"correct":2,"total":2}'
},
{
_id: 60c80470f2bcfe5fb8f501c2,
lesson: '60c79d81cf1f57221c05fdac',
result: '{"correct":2,"total":2}'
}
]
At this point I see that it adds _id and thus treats them as different entries (while they are identical).
What is my mistake and what should I do in order to fix it? I can change lessons structure or change query - whatever is easier to implement.
You can create sub-documents avoid _id. Just add _id: false to your subdocument declaration.
const userSchema = new Schema({
username: { type: String },
lessons: [
{
_id: false,
lesson: { type: String },
result: { type: String }
}
]
});
This will prevent the creation of an _id field in your subdoc, and you can add a new element to the lesson or skip it with the addToSet operator as you did.

How to join two collections in mongoose

I have two Schema defined as below:
var WorksnapsTimeEntry = BaseSchema.extend({
student: {
type: Schema.ObjectId,
ref: 'Student'
},
timeEntries: {
type: Object
}
});
var StudentSchema = BaseSchema.extend({
firstName: {
type: String,
trim: true,
default: ''
// validate: [validateLocalStrategyProperty, 'Please fill in your first name']
},
lastName: {
type: String,
trim: true,
default: ''
// validate: [validateLocalStrategyProperty, 'Please fill in your last name']
},
displayName: {
type: String,
trim: true
},
municipality: {
type: String
}
});
And I would like to loop thru each student and show it's time entries. So far I have this code which is obviously not right as I still dont know how do I join WorksnapTimeEntry schema table.
Student.find({ status: 'student' })
.populate('student')
.exec(function (err, students) {
if (err) {
return res.status(400).send({
message: errorHandler.getErrorMessage(err)
});
}
_.forEach(students, function (student) {
// show student with his time entries....
});
res.json(students);
});
Any one knows how do I achieve such thing?
As of version 3.2, you can use $lookup in aggregation pipeline to perform left outer join.
Student.aggregate([{
$lookup: {
from: "worksnapsTimeEntries", // collection name in db
localField: "_id",
foreignField: "student",
as: "worksnapsTimeEntries"
}
}]).exec(function(err, students) {
// students contain WorksnapsTimeEntries
});
You don't want .populate() here but instead you want two queries, where the first matches the Student objects to get the _id values, and the second will use $in to match the respective WorksnapsTimeEntry items for those "students".
Using async.waterfall just to avoid some indentation creep:
async.waterfall(
[
function(callback) {
Student.find({ "status": "student" },{ "_id": 1 },callback);
},
function(students,callback) {
WorksnapsTimeEntry.find({
"student": { "$in": students.map(function(el) {
return el._id
})
},callback);
}
],
function(err,results) {
if (err) {
// do something
} else {
// results are the matching entries
}
}
)
If you really must, then you can .populate("student") on the second query to get populated items from the other table.
The reverse case is to query on WorksnapsTimeEntry and return "everything", then filter out any null results from .populate() with a "match" query option:
WorksnapsTimeEntry.find().populate({
"path": "student",
"match": { "status": "student" }
}).exec(function(err,entries) {
// Now client side filter un-matched results
entries = entries.filter(function(entry) {
return entry.student != null;
});
// Anything not populated by the query condition is now removed
});
So that is not a desirable action, since the "database" is not filtering what is likely the bulk of results.
Unless you have a good reason not to do so, then you probably "should" be "embedding" the data instead. That way the properties like "status" are already available on the collection and additional queries are not required.
If you are using a NoSQL solution like MongoDB you should be embracing it's concepts, rather than sticking to relational design principles. If you are consistently modelling relationally, then you might as well use a relational database, since you won't be getting any benefit from the solution that has other ways to handle that.
It is late but will help many developers.
Verified with
"mongodb": "^3.6.2",
"mongoose": "^5.10.8",
Join two collections in mongoose
ProductModel.find({} , (err,records)=>{
if(records)
//reurn records
else
// throw new Error('xyz')
})
.populate('category','name') //select only category name joined collection
//.populate('category') // Select all detail
.skip(0).limit(20)
//.sort(createdAt : '-1')
.exec()
ProductModel Schema
const CustomSchema = new Schema({
category:{
type: Schema.ObjectId,
ref: 'Category'
},
...
}, {timestamps:true}, {collection: 'products'});
module.exports = model('Product',CustomSchema)
Category model schema
const CustomSchema = new Schema({
name: { type: String, required:true },
...
}, {collection: 'categories'});
module.exports = model('Category',CustomSchema)

How to insert data into subdocument using $push in query, instead of retrieving doc and saving it back

Edit: this was actually working
As the Mongoose - Subdocs: "Adding subdocs" documentation says, we can add a subdoc using the push method (i.e. parent.children.push({ name: 'Liesl' });)
But I want to go further, and would like to use the $push operator to insert subdocuments.
I have two Schemas: the ThingSchema:
var ThingSchema = mongoose.Schema({
name: {
type: String,
required: true
},
description: {
type: String
}
});
and the BoxSchema, the main document that has an array of subdocuments (things) of ThingSchema:
var BoxSchema = new mongoose.Schema({
name: {
type: String,
required: true
},
description: {
type: String
},
things: {
type: [ThingSchema]
}
});
var BoxModel = mongoose.model('Box', BoxSchema);
I need every subdocument in things to have unique names - that is, that it would be impossible to insert a new document into this array that has a name value that already exists in the subdocs.
I'm trying to do something like:
var thingObj = ... // the 'thing' object to be inserted
BoxModel.update({
_id: some_box_id, // a valid 'box' ObjectId
"things.name": { "$ne": thingObj.name }
},
{
$push: { things: thingObj}
},
function(err) {
if (err) // handle err
...
});
but not getting any desired results.
What would be the correct way to add a ThingSchema subdocument into BoxSchema's thing array using the $push operator to do so in the query (must not add the subdoc if there's another subdoc named the same), instead of the Mongoose Docs way?
Edit: this is actually the issue
I made a mistake, the code above works as expected but now the problem I have is that when thingObj does not match the ThingSchema, an empty object is inserted into the things array:
// now thingObj is trash
var thingObj = { some: "trash", more: "trash" };
When executing the query given the above trash object, the following empty object is inserted into the subdocs array:
{ _id: ObjectId("an_obj_id") }
What I want this case, when the thingObj doesn't match the ThingSchema, is nothing to be added.
$addToSet adds something unique to the array (as in it checks for duplicates). But it only works for primitives.
What you should do is put things into their own collection and make a unique index on name. Then, make this change
things: {
type: [{type: ObjectId, ref: 'thingscollection'}]
}
this way you can do
BoxModel.update({
_id: some_box_id, // a valid 'box' ObjectId
"things": { "$ne": thingObj._id }
},
{
$addToSet: { things: thingObj._id}
},
function(err) {
if (err) // handle err
...
});
And when you fetch use .populate on things to get the full documents in there.
It's not exactly how you want it, but that's a design that might achieve what you're aiming for.

How to find a record using dot notation & update the value in a schema using mongoose

I am using mongoose to perform CRUD operation on my db. This is how my model looks.
var EmployeeSchema = new Schema({
name: String,
description: {
type: String,
default: 'No description'
},
department: [],
lastUpdated: {
type: Date,
default: Date.now
}
});
The department can contains array of object like this.
[
{
"id" : "55ba28f680dec4383eeebf97",
"text" : "Sales",
"topParentId" : "55ba28f680dec4383eeebf8b",
"topParentText" : "XYZ"
},
{
"id" : "55ba28f680dec4383eeebf98",
"text" : "IT",
"topParentId" : "55ba28f680dec4383eeebf8b",
"topParentText" : "XYZ"
},
{
"id" : "55ba28f680dec4383eeebf94",
"text" : "Marketing",
"topParentId" : "55ba28f680dec4383eeebccc",
"topParentText" : "ABC"
}
]
Now I need to find all the employee where department.id = '55ba28f680dec4383eeebf94' and then I need to update the text of the object.
Employee.find({'department.id': '55ba28f680dec4383eeebf94'}, function(err, Employees) {
_.each(Employees, function (emp) {
_.each(emp.department, function (dept) {
if(dept.id === '55ba28f680dec4383eeebf94'){
dept.text = 'XXXXX'; // How to update the employee to save the updated text
}
});
});
});
What is the right way to save the employee with updated text for that department?
Iterating is code is not a "sharp" way to do this. It is better to use the MongoDB update operators, especially since there is no schema defined for the array items here, so no rules to worry about:
Employee.update(
{'department.id': '55ba28f680dec4383eeebf94'},
{ "$set": { "department.$.text": "XXXXX" },
function(err,numAffected) {
// handling in here
}
);
The $set is the important part, otherwise you overwrite the whole object. As is the positional $ operator in the statement, so only the matched ( queried item in the array ) index is updated.
Also see .find**AndUpdate() variants for a way to return the modified object.
I think you can use the update model:
Employee.update({department.id: '55ba28f680dec4383eeebf94'}, {department.text: 'XXXXX'}, {multi: true},
function(err, num) {
console.log("updated "+num);
}
);
First object is the query, what to find: {department.id: '55ba28f680dec4383eeebf94'}, the second one is the update, what to update: {department.text: 'XXXXX'} and the third one is the options to pass to the update, multi means update every records you find: {multi: true}

Using mongoose, how do I filter and then group by?

I am using mongoose and so far the query I use gets me all of the critiques based on a docId. I would like to group this result by distinct editors now. Except, my editors is an object.
This is what my critique query looks like:
Critique.find({docId:req.params.docId}).populate('editor', 'name username').exec(function(err, critiques){
if(err){
console.error("Cannot find critiques with docId: " + critiques.docId);
}
console.log(critiques);
res.jsonp(critiques);
});
This is my model I am querying:
var CritiqueSchema = new Schema({
className : String,
content: String,
eleId: Number,
type: String,
comments: String,
isAccepted: Boolean,
classes: String,
docId:{
type: Schema.ObjectId,
ref: 'Composition'
},
created: {
type: Date,
default: Date.now
},
editor: {
type: Schema.ObjectId,
ref: 'User'
},
});
UPDATE new query:
Critique.aggregate(
[ {$match : {docId : mongoose.Types.ObjectId(req.params.docId)}},
{$group : { _id : "$editor", critiques: { $push: "$$ROOT" } } }
]).exec(function(error, result){
if(!error)console.log(result);
else console.log(error);
});
What you need is $group in the aggregation framework. But aggregation and population don't go along. So you have two options populate and group the results by yourself by writing a loop or you can use $group to group them and then query each editor manually. The second is better as there will no duplication in editor queries whereas in population there will be significant duplication going on.
Critique.aggregate(
[{
$match:
{
docId: ObjectId(req.params.docid)
}
},
{ $group : { _id : "$editor", critiques: { $push: "$$ROOT" } } }
],
function(err,result){
if(!err){
/* result will be of the form:
[{_id:<an editor's objectid>,critiques:[{<critique1 document>},{<critique2 document>}...]}...]
*/
//you will have to manually query for each distinct editor(result[i]._id) which sucks
//because the call will be asynchronous in the loop and you can't send your response without using async library
//another option would be to use the $in operator on an array of the distinct critiques:
var editors = result.map(function(x) { return x._id } );
User.find({_id:{$in:editors}},{'username':1},function(err,editorDocs){
var editor_ids=editorDocs.map(function(x){return x._id})
var index;
for(var i=0;i<result.length;i++){
index=editor_ids.indexOf(result[i]._id);
result[i].editor=editorDocs[index].username;
}
//result is your final result. In the editor field of each object you will have the username of the editor
})
}
})
Check the docs for $$ROOT.

Resources