MongoDB Query from multiple models / schemas and return in one field - mongodb

I am using Nodejs and MongoDB, mongoose and expressjs, creating a Blog API having users, articles, likes & comments schema. Below are schemas that I use.
const UsersSchema = new mongoose.Schema({
username: { type: String },
email: { type: String },
date_created: { type: Date },
last_modified: { type: Date }
});
const ArticleSchema = new mongoose.Schema({
id: { type: String, required: true },
text: { type: String, required: true },
posted_by: { type: Schema.Types.ObjectId, ref: 'User', required: true },
images: [{ type: String }],
date_created: { type: Date },
last_modified: { type: Date }
});
const CommentSchema = new mongoose.Schema({
id: { type: String, required: true },
commented_by: { type: Schema.Types.ObjectId, ref: 'User', required: true },
article: { type: Schema.Types.ObjectId, ref: 'Article' },
text: { type: String, required: true },
date_created: { type: Date },
last_modified: { type: Date }
});
What I actually need is when I * get collection of articles * I also want to get the number of comments together for each articles. How do I query mongo?

Since you need to query more than one collection, you can use MongoDB's aggregation.
Here: https://docs.mongodb.com/manual/aggregation/
Example:
Article
.aggregate(
{
$lookup: {
from: '<your comments collection name',
localField: '_id',
foreignField: 'article',
as: 'comments'
}
},
{
$project: {
comments: '$comments.commented_by',
text: 1,
posted_by: 1,
images: 1,
date_created: 1,
last_modified: 1
}
},
{
$project: {
hasCommented: {
$cond: {
if: { $in: [ '$comments', '<user object id>' ] },
then: true,
else: false
}
},
commentsCount: { $size: '$comments' },
text: 1,
posted_by: 1,
images: 1,
date_created: 1,
last_modified: 1
}
}
)
The aggregation got a little big but let me try to explain:
First we need to filter the comments after the $lookup. So we $unwind them, making each article contain just one comment object, so we can filter using $match(that's the filter stage, it works just as the <Model>.find(). After filtering the desired's user comments, we $group everything again, $sum: 1 for each comment, using as the grouper _id, the article's _id. And we get the $first result for $text, $images and etc. Later, we $project everything, but now we add hasCommented with a $cond, simply doing: if the $comments is greater than 0(the user has commented, so this will be true, else, false.
MongoDB's Aggregation framework it's awesome and you can do almost whatever you want with your data using it. But be aware that somethings may cost more than others, always read the reference.

Related

How can I populate nested array objects in mongoDB or mongoose?

I have an orders collection where each order has the following shape:
{
"_id": "5252875356f64d6d28000001",
"lineItems": [
{ productId: 'prod_007', quantity: 3 },
{ productId: 'prod_003', quantity: 2 }
]
// other fields omitted
}
I also have a products collection, where each product contains a unique productId field.
How can I populate each lineItem.productId with a matching product from the products collection? Thanks! :)
EDIT: orderSchema and productSchema:
const orderSchema = new Schema({
checkoutId: {
type: String,
required: true,
},
customerId: {
type: String,
required: true,
},
lineItems: {
type: [itemSubSchema],
required: true,
},
});
const itemSubSchema = new Schema(
{
productId: {
type: String,
required: true,
},
quantity: {
type: Number,
required: true,
},
},
{ _id: false }
);
const productSchema = new Schema({
productId: {
type: String,
required: true,
},
name: {
type: String,
required: true,
},
imageURL: {
type: String,
required: true,
},
price: {
type: Number,
default: 0,
},
});
I don't know the exact output you want but I think this is what you are looking for:
The trick here is to use $lookup in an aggregation stage.
First $unwind to deconstruct the array and can merge each id with the other collection.
Then the $lookup itself. This is like a join in SQL. It merges the desired objects with same ids.
Then recreate the population using $mergeObjects to get properties from both collections.
And last re-group objects to get the array again.
db.orders.aggregate([
{
"$unwind": "$lineItems"
},
{
"$lookup": {
"from": "products",
"localField": "lineItems.productId",
"foreignField": "_id",
"as": "result"
}
},
{
"$set": {
"lineItems": {
"$mergeObjects": [
"$lineItems",
{
"$first": "$result"
}
]
}
}
},
{
"$group": {
"_id": "$_id",
"lineItems": {
"$push": "$lineItems"
}
}
}
])
Example here
With this query you have the same intial data but "filled" with the values from the other collection.
Edit: You can also avoid one stage, maybe it is clear with the $set stage but this example do the same as it merge the objects in the $group stage while pushing to the array.
You can use the Mongoose populate method either when you query your documents or as middleware. However, Mongoose only allows normal population on the _id field.
const itemSubSchema = new Schema({
product: {
type: mongoose.Schema.Types.ObjectId,
ref: 'productSchema',
}
});
const order = await orderSchema.find().populate('lineItems.$*.product');
// special populate syntax necessary for nested documents
Using middleware you would still need to reconfigure your item schema to save the _id from products. But this method would automatically call populate each time you query items:
itemSubSchema.pre('find', function(){
this.populate('product');
});
You could also declare your item schema within your order schema to reduce one layer of joining data:
const orderSchema = new Schema({
lineItems: [{
type: {
quantity: {type: Number, required: true},
product: {
type: mongoose.Schema.Types.ObjectId,
required: true,
ref: 'productSchema',
}
},
required: true,
}]
});
const orders = orderSchema.find().populate('lineItems');

Aggregate data and populate in one request

I am a bit puzzled by populate in MongoDB.
I've got a Schema:
import { Schema, Document, model } from "mongoose";
export interface ProductGroupType {
id: Schema.Types.ObjectId,
title: String,
name: String,
description: String,
}
const ProductGroupSchema: Schema<Document<ProductGroupType>> = new Schema({
title: { type: String, trim: true },
name: { type: String, trim: true },
description: { type: String, trim: true },
}, { collection: "productGroups", timestamps: true });
export const ProductGroupModel = model('ProductGroup', ProductGroupSchema);
and products
import { Schema, Document, model } from "mongoose";
import { plugin as autocomplete } from 'mongoose-auto-increment';
const ProductSchema: Schema<Document<IProduct>> = new Schema({
article: Number,
name: String,
category: { type: Schema.Types.ObjectId, ref: 'ProductCategory' },
group: { type: Schema.Types.ObjectId, ref: 'ProductGroup' },
price: { type: Number, default: 0 },
discount: { type: Number, default: 0 },
stock: {
available: { type: Number, default: 0 },
reserved: { type: Number, default: 0 },
},
images: [Object],
description: String,
productDetails: Object,
}, { collection: "products", timestamps: true });
ProductSchema.plugin(autocomplete, {
model: 'Product',
field: 'article',
startAt: 10000,
});
export const ProductModel = model('Product', ProductSchema);
I need to make a request and group on the MongoDB side data by the field 'group'.
I can make this like this:
await ProductModel.aggregate([
{ $match: { category: Types.ObjectId(queryCategory.id) } },
{
$group: {
_id: '$group',
products: {
$push: {
id: '$_id',
name: '$name',
article: '$article',
price: '$price',
discount: '$discount',
description: '$description',
group: '$groupName',
}
},
count: { $sum: 1 },
}
},
]);
but the output here is:
[
{ _id: 61969583ad32e113f87d0e99, products: [ [Object] ], count: 1 },
{
_id: 61993fff452631090bfff750,
products: [ [Object], [Object] ],
count: 2
}
]
almost what I need but I've been playing around with population and I cannot make it work with Aggregation framework.
I already tried to use the 'lookup' operator but it returns an empty array and doesn't want to work.
That's how I wanted to make it work:
const products: Array<IProduct> = await ProductModel.aggregate([
{ $match: { category: Types.ObjectId(queryCategory.id) } },
{
$group: {
_id: '$group',
products: {
$push: {
id: '$_id',
name: '$name',
article: '$article',
price: '$price',
discount: '$discount',
description: '$description',
group: '$groupName',
}
},
count: { $sum: 1 },
}
},
{
$lookup: {
"from": "productGroups",
"localField": "group",
"foreignField": "_id",
"as": "groupName"
},
},
]);
Is it possible to get the same result as I've got now but populate in the same query group field?
So far the only way I've managed to populate it like this as the second request:
await ProductGroupModel.populate( products.map( (product: any) => {
return {
_id: new ProductGroupModel(product),
products: product.products,
count: product.count,
}
} ), { "path": "_id" } )
In a MongoDB aggregation pipeline, the $group stage passes along only those field explicitly declared in the stage.
In the same pipeline you show, the documents passed along by the $group stage would contain the fields:
_id
products
count
When the exector arrives a the $lookup stage, none of the documents contain a field named group.
However, the value previously contained in the group field still exists, in the _id field.
In the $lookup stage, use
"localField": "_id",
to find documents based on that value.

How to use full text search in referenced documents?

I know this was asked before, but I can't find an answer that works for me.
I have some documents, which have reference to another document, like users and orders:
Users model:
import mongoose from '../database/index.js';
import mongoosePaginate from 'mongoose-paginate-v2';
const UsersSchema = new mongoose.Schema({
user_id: {
type: String,
required: true,
},
email: {
type: String,
required: true,
unique: true,
},
firstName: {
type: String,
default: '',
},
lastName: {
type: String,
default: '',
},
orders: [
{
type: mongoose.Schema.Types.ObjectId,
ref: 'Orders',
},
],
recipients: [
{
type: mongoose.Schema.Types.ObjectId,
ref: 'Recipients',
},
],
createdAt: {
type: Date,
default: Date.now,
required: true,
select: false,
},
});
UsersSchema.index({ email: 'text', firstName: 'text', lastName: 'text' });
UsersSchema.plugin(mongoosePaginate);
UsersSchema.set('toJSON', {
virtuals: true,
});
const Users = mongoose.model('Users', UsersSchema);
export default Users;
Orders model:
import mongoose from '../database/index.js';
import mongoosePaginate from 'mongoose-paginate-v2';
import Users from './users.js';
import OrderStatus from '../enums/OrderStatusEnum.js';
const OrdersSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Users',
required: true,
},
total: {
type: Number,
required: true,
},
status: {
type: String,
enum: OrderStatus.values(),
required: true,
default: OrderStatus.CREATED,
},
payment: {
type: mongoose.Schema.Types.ObjectId,
ref: 'PaymentMethods',
required: true,
},
shortId: {
type: String,
required: true,
},
createdAt: {
type: Date,
default: Date.now,
required: true,
},
});
OrdersSchema.index({ shortId: 'text' });
OrdersSchema.plugin(mongoosePaginate);
OrdersSchema.pre('remove', function (next) {
Users.update({ orders: this._id }, { $pull: { orders: this._id } }).exec();
next();
});
OrdersSchema.set('toJSON', {
virtuals: true,
});
const Orders = mongoose.model('Orders', OrdersSchema);
export default Orders;
I can use the $text search to query full text search from orders or users:
const orders = await Orders.paginate(
{ $text: { $search: query.filter.q } },
query.options
);
return orders;
But this will only make a search on the orders index. If, i.e., I would like to search for the order of the user whose first name is Joe, how can I make it also search in the user.firstName field, since this is a reference from Orders to Users?
I know I can't populate the fields and then search on all model, but I'm not sure how to achieve what I'm looking for.
Thanks in advance
Because full text search query must be the first stage in the aggregation pipeline, it is not currently possible to perform full text search in two collections as part of the same query.
You'll need to reorganize your data.
my requirement was to find orders of users matched with their names, partial or full-text search. the user id is ref into orders doc.
from mongodb playground:
https://mongoplayground.net/p/ASOSFvfURXW
db.inventory.aggregate([
{
"$lookup": {
"from": "orders",
"localField": "orderId",
"foreignField": "_id",
"as": "order_docs"
}
},
{
"$unwind": "$order_docs"
},
{
"$match": {
"order_docs.item": {
"$regex": "pec",
"$options": "i"
}
}
}
])

GraphQL Mutation Updating Users Followers with Mongoose/MongodDB - $set is empty error

I have this mutation set up:
followUser: {
type: UserType,
args: {
_id: { type: GraphQLString },
firebaseUid: { type: GraphQLString },
following: { type: new GraphQLList(GraphQLString)},
},
resolve(parentValue, { firebaseUid, _id, following}) {
const update = {
$set: { "following": [firebaseUid] },
$push: { "following": { firebaseUid } }
}
return UserSchema.findOneAndUpdate(
{ _id },
update,
{new: true, upsert: true}
)
}
},
I'm trying to add new followers into my graphql user's collection. My user model:
const UserSchema = new Schema(
{
firebaseUid: String,
following: [{ type: Schema.Types.ObjectId, ref: 'User' }],
followers: [{ type: Schema.Types.ObjectId, ref: 'User' }],
},
{ timestamps: true }
);
module.exports = mongoose.model("User", UserSchema);
So at first, the user doesn't have any followers, so it won't have that field yet. When user adds someone to their friends list, thats when the field will appear in mongodb. Right now I'm getting this error:
"message": "'$set' is empty. You must specify a field like so: {$set: {<field>: ...}}",
I'm not sure if I'm doing the $set correctly.
The UserType
const UserType = new GraphQLObjectType({
name: "User",
fields: () => ({
_id: { type: GraphQLString },
firebaseUid: { type: GraphQLString },
following: { type: new GraphQLList(GraphQLString) },
followers: { type: new GraphQLList(GraphQLString) },
...
})
});
edit:
current mongodb data collection:
_id: ObjectId("5e5c24111c9d4400006d0001")
name: "Mr. Smith"
username: "mrsmith"
after running the update
_id: ObjectId("5e5c24111c9d4400006d0001")
name: "Mr. Smith"
username: "mrsmith"
following: ["fdsaduybfeaf323dfa"] // <-- this gets added
Currently mongooses validator is rejecting the update. To fix this you need the following:
You only need to $push since it will automatically create an array if the property does not exist
You should remove the extra { } around the firebaseUid in the $push because otherwise the following array will contain objects with a firebaseUid property instead of directly containing the Uid (or would if the schema validator allowed it)
Mongo ObjectIds can only be converted from strings when they are 12-byte hexadecimal, and firebaseUid is not, so the schema should be typed to String instead of ObjectId as the validator will reject the field for update otherwise.

Mongoose: Trying to use .virtual method to rename

I have to rename the name of the field when using populate.
const CategorySchema = new Schema(
{
name: {
type: String,
unique: true
},
featured: {
type: Boolean,
default: true
},
image: String,
active: {
type: Boolean,
default: true
},
subCategoryIds: [{ type: Schema.Types.ObjectId, ref: 'SubCategory' }]
},
{
timestamps: true
}
);
export default mongoose.model('Category', CategorySchema);
This is my Category Schema.
And here is my SubCategory Schema
const SubCategory = new Schema(
{
name: {
type: String,
unique: true
},
active: {
type: Boolean,
default: true
},
categoryId: { type: Schema.Types.ObjectId, ref: 'Category' },
productIds: [{ type: Schema.Types.ObjectId, ref: 'Product' }]
},
{
timestamps: true
}
);
SubCategory.virtual('category', {
ref: 'Category',
localField: 'categoryId',
foreignField: '_id'
});
export default mongoose.model('SubCategory', SubCategory);
And here I have a filed categoryId, when using populate, I want it to be 'category', So I used virtual to create 'category`.
and implemented this
const subCategories = await SubCategory.find({}).populate('category');
But unfortunately it isn't working, It returns the normal subCategory object and there is no category present.
Am I missing something?
Why dont you use Mongodb aggregation pipeline, instead of using mongoose virtuals, You can use $lookup and change catergoryId to category while populating.
Try this:
const subCategories = await SubCategory.aggregate([{
$lookup : {
from : "categories",
localField : "categoryId",
foreginField : "_id",
as : "category"
},{
$unwind : "$category"
}])
localField says which field to populate, from tells monogdb which collection to populate from, foreignField tells mongodb which field to match it for population, and as is used for the field in which result will be stored,
$unwind is used in the next stage, because $lookup returns an array, we need to convert it to category object
Read Mongodb $lookup documentation for more info.