Automatically include referenced model in Sequelize on each GET - node.js

So I have user model and admin model and they're associated as user n:1 admin. The code defining the user model as follows:
// users.model.ts
const users = sequelize.define('users', {
...
adminId: {
field: 'admin_id',
type: DataTypes.BIGINT,
allowNull: true
},
...
});
(users as any).associate = function associate(models: any) {
models.users.belongsTo(models.admins);
};
return users;
and the admin model:
// admins.model.ts
const admins = sequelizeClient.define('admins', {
...
});
(admins as any).associate = function associate(models: any) {
models.admins.hasOne(models.users);
};
return admins;
Is it possible to implement some rule in the association, or some Sequelize hook f.e. afterGet that will automatically fetch the referenced record?
I would like to get the admin object as a property of the user object when I query just the User model, f.e. when I call User.findOne(123) it will have the object of the referenced admin record included. Basically telling Sequelize to always do the JOIN when getting the user record. Is that possible in Sequelize or I'll have to write logic separately?

Eventually I figured out that this is done through scopes (docs here and here).
I added this to the users.model.ts:
/*
* When called as `model.scope('<scope_name>').<method>` it will override the default behaviour of Sequelize and
* will add to the query whatever is requested in the scope definition.
*/
users.addScope('includeAdmin', {
include: [{
attributes: ['id', 'name'],
model: sequelize.models.admins,
as: 'admin'
}]
});
Eventually, I will make the following call: User.scope('includeAdmin').findOne(123), at which point Sequelize will automatically JOIN the admins model.
By default the admin entity's properties will be returned as such in the user object:
{
"admin.id": ...,
"admin.name": ...
}
So, if you want to have them as a nested admin object, then you must add nest: true property in the call, as follows: User.scope('includeAdmin').findOne(123, {nest: true})
If I want to make this behaviour default and not call .scope('...'), then when you declare the scope in the .addScope() function, call it 'defaultScope'.

That's why associations are used, to get things. To get the relation as attribute you can use eager loading:
const awesomeCaptain = await Captain.findOne({
where: {
name: "Jack Sparrow"
},
include: Ship
});
// Now the ship comes with it
console.log('Name:', awesomeCaptain.name);
console.log('Skill Level:', awesomeCaptain.skillLevel);
console.log('Ship Name:', awesomeCaptain.ship.name);
console.log('Amount of Sails:', awesomeCaptain.ship.amountOfSails);

Related

Sequelize - How To Design One To One Association?

I am creating two models, the first one is User and the second one is Portfolio. When a user is creating a Portfolio (where a user can only have one Portfolio), I want it to have reference to the user who is creating it, and every time that user's data is fetched, I want it to also fetching their portfolio data if any.
I am trying to use hasOne to create portfolio_id inside User tables, with the skeleton generated using sequelize init command, but it is not working. I cannot find a column the name protfolio_id if I don't put it inside the user migration file. Is that how it is supposed to be?
How should I design the models? Should I include the portfolio_id in User tables and include user_id in Portfolio table, or is there a best way to do it?
And which associations method should I use, hasOne or belongsTo?
First of all make sure that you are calling Model.associate for each model. This will run queries for all the relationships.
You can define the relationships in the associate method as follows:
// user.js (User Model definition)
module.exports = (sequelize, dataTypes) => {
const { STRING } = dataTypes
const User = sequelize.define("user", {
username: { type: STRING }
})
User.associate = models => {
User.hasOne(models.Portfolio, { foreignKey: "userId" }) // If only one portfolio per user
User.hasMany(models.Portfolio) // if many portfolios per user
}
return User
}
// portfolio.js (Portfolio Model definition)
module.exports = (sequelize, dataTypes) => {
const { STRING } = dataTypes
const Portfolio = sequelize.define("portfolio", {
portfolioName: { type: STRING }
})
Portfolio.associate = models => {
Portfolio.belongsTo(models.User, { foreignKey: "userId" })
}
return Portfolio
}
hasOne stores the foreignKey in the target model. So this relationship will add a foreign key userId to the Portfolio model.
belongsTo stores the key in the current model and references the primary key of the target model. In this case the Portfolio.belongsTo will add userId in the Portfolio model which will reference the primary key of User model.
Notice how both these relationships do the same thing, they add userId to the Portfolio model. Its better to define this in both models for your last use case:
I want it to have reference to the user who is creating it, and every time that user's data is fetched, I want it to also fetching their portfolio data if any.
Accessing related models:
In sequelize fetching a related model together with the main model is called Eager Loading. Read more about it here.
Now for your use case if you want to fetch the portfolio if any, while fetching user, do the following:
var userWithPortfolio = await User.findAll({include: [models.Portfolio]};
// Or you may also use include: {all: true} to include all related models.
var userWithPortfolio = await User.findAll({include: {all: true}};
/*
Output:
userWithPortfolio = {
username: "xyz",
portfolio: {
portfolioName: "xyz"
}
}
*/

How can I save an association returned from a fineOne with sequelize.js?

I have:
let foundConversation = await req.db.models.Conversation.findOne({
where: {
id: ConversationId
},
include: [{
model: req.db.models.Audio
}]
})
This returns a Conversation and an Audio attached to that conversation.
When I do:
foundConversation.Audio.processingData.stuff = requestObj
await foundConversation.Audio.save()
it doesn't save the Audio data. Is there any way for me to do this?
I'd rather not do a separate class .update since I want foundConversation to continue to have the updated Audio data for further processing.
Update, my associations:
Models.audio.belongsTo(Models.conversation, {
allowNull: true,
onDelete: 'CASCADE'
})
Models.conversation.hasOne(Models.audio)
I could tell better if you share the model definitions too..
It could be one of the two issues:
There is no foundConversation.Audio. Maybe because of incorrect name you used Models.audio and models.Conversation Notice different in capitalisation.
foundConversation.Audio.processingData.stuff This indicates you are trying to change a property within a property of a model.. Are you using JSON Columns?
Also i noticed you are injecting db in express request (via a middleware) req.db.models. Ideally it should not be a property of request and should be accessed via a service: dbService.models

Sequelize. Right way to populate data and remove keys from output?

How to remove some fields of model from find* (Like password, token)?
I think overriding toJSON() function (Like here https://stackoverflow.com/a/27979695/6119618) is not a good way, because i sometimes need this field for password validation or token for checking etc..
Is there something like as .select('+token') as mongoose has?
And another question i think it's fit this topic.
How to remove generated by through fields from find* output?
When i call User.find() it responds { id: 0, name: 'somename', UserProjectsTie: { /* complex object of many-to-many relation table */ } }
To exclude an attribute from find*:
Model.findAll({
attributes: { exclude: ['baz'] }
});
To make this the default behavior, use a scope:
const Model = sequelize.define('Model', {
// Attributes
}, {
defaultScope: {
attributes: { exclude: ['baz'] }
}
});
Unless I'm mistaken the 'through' should only show up when using 'include'. To get rid of through in that case:
Model.findAll({
include: [{association: 'OtherModels', through: {attributes: []}}]
});

Validation errors in custom instance or static methods in a Mongoose model

I have a basic Mongoose model with a Meeting and Participants array:
var MeetingSchema = new Schema({
description: {
type: String
},
maxNumberOfParticipants: {
type: Number
},
participants: [ {
type: Schema.ObjectId,
ref: 'User'
} ]
});
Let's say I want to validate that the number of participants added doesn't exceed the maxNumberOfParticipants for that meeting.
I've thought through a few options:
Custom Validator - which I can't do because I have to validate one attribute (participants length) against another (maxNumberOfParticipants).
Middleware - i.e., pre-save. I can't do this either because my addition of participants occurs via a findOneAndUpdate (and these don't get called unless I use save).
Add validation as part of my addParticipants method. This seems reasonable, but I'm not sure how to pass back a validation error from the model.
Note that I don't want to implement the validation in the controller (express, MEAN.js stack) because I'd like to keep all logic and validations on the model.
Here is my addParticipants method:
MeetingSchema.methods.addParticipant = function addParticipant(params, callback) {
var Meeting = mongoose.model('Meeting');
if (this.participants.length == this.maxNumberOfParticipants) {
// since we already have the max length then don't add one more
return ????
}
return Meeting.findOneAndUpdate({ _id: this.id },
{ $addToSet: { participants: params.id } },
{new: true})
.populate('participants', 'displayName')
.exec(callback);
};
Not sure how to return a validation error in this case or even if this pattern is a recommended approach.
I wouldn't think that's it's common practice for this to be done at the mongoose schema level. Typically you will have something in between the function getting called and the database layer (your schema) that performs some kind of validation (such as checking max count). You would want your database layer to be in charge of just doing simple/basic data manipulation that way you don't have to worry about any extra dependencies when/if anything else calls it. This may mean you'd need to go with route 1 that you suggested, yes you would need to perform a database request to find out what your current number of participants but I think it the long run it will help you :)

How to access attributes of an embedded document (DBRef-like) from a Schema method with Mongoose?

I am building a nodejs app with express and mongoose/mongodb.
In order to manage user roles (a user may have 0-n roles), I decided to implement a User schema and a separate Role schema and tie them together like DBRefs and using mongoose populate capability to get from one to the other easily. I chose this structure because I thought this was the best way to answer things like: "Get me a list of users that have X role" or "Get me a list of users whose Y role is set to expire soon".
These are stripped down versions of my schemas for role and user:
RoleSchema = new Schema {
_user: { type: Schema.ObjectId, ref: 'User' }
type: String
expiration: { type: Date, default : '0' }
}
UserSchema = new Schema {
email: { type: String, index: { unique: true }}
roles : [{ type: Schema.ObjectId, ref: 'Role' }]
}
With this, I am able to create/fetch/populate users and roles from within my express scripts without a problem. When I want to add a role to a user, I create the role, save it and then push it to the roles array of the user and then save the user. So, what happens in mongoDB is that each schema has its own collection, and they point to each other through an id field.
Now, what I want to do next is implement boolean-type methods on my schema to check for role-related issues, so that I can do things like
if(user.isActiveSubscriber())
I thought that I would be able to accomplish this simply by adding a method to my User schema, like this:
UserSchema.method "isActiveSubscriber", ->
result = false
now = new Date()
#roles.forEach (role) ->
result = true if role.type is "SUBSCRIBER" and role.expiration > now
result
My problem is that roles are coming out with empty attributes. I suppose it makes sense, since my user collection only has the id of the role but the actual attributes are stored in another collection.
So, here go the questions:
a) Is there a way to load the roles attributes from within my user schema method?
I tried calling populate inside the method, but got an error that the user object doesnt know any populate method. I also tried doing a Role.findById() from inside the method but also get an error (tried with Role and RoleSchema)
b) In case there is not a way ... should I simply add the code to check in my scripts? I hate having to put this kind of logic mixed with application logic/flow. Is there a better option?
c) Was it a bad idea to separate these collections in two? Am I completely missing the point of NoSQL? Would it be better if roles were simply an array of embedded documents stored as part of the user collection?
Let me answer your questions:
a) What you can do is to load roles inside your call. Assuming you did
Role = mongoose.model('Role', RoleSchema)
you just need to run
Role.where('_id').in(user.roles).run( (err, res) ->
/* you have roles in res now */
)
this however is an asynchronous operation, which requires callback to be passed to your method, i.e.
UserSchema.method "isActiveSubscriber", (callback) ->
now = new Date()
if #roles.length is 0
return callback(false)
if #roles[0].type
/* roles are loaded,
maybe there is some other fancy way to do the check */
result = false
#roles.forEach (role) ->
if role.type is "SUBSCRIBER" and role.expiration > now
result = true
callback(result)
else
/* we have to load roles */
Role.where('_id').in(#roles).run( (err, res) =>
/* you have roles in res now */
/* you may want to populate the field,
note => sign in this function definition */
#roles = res
result = false
res.forEach (role) ->
if role.type is "SUBSCRIBER" and role.expiration > now
result = true
callback(result)
)
Now for a user user you can call it like this
user.isActiveSubscriber( (result) ->
/* do something with result */
)
The problem is that the operation is asynchronous and it forces additional callback nesting in your code (this will be pain if you want to check for roles for say 100 users, you will need some asynchronous calls handling library like async). So I advice populating this field whenever you load users and use the code you showed us. You can add static method (maybe you can even override default find method? I'm not sure about this though) for this.
b+c) The other option is to store roles as strings in your collection and hardcode possible roles in app. This will be simplier to implement, however adding/removing new role will be a pain in the $%^&*. The way you are doing this (i.e. via references) is fine and I think that the only thing you need is to populate the field whenever you search for users and everything will be fine.
The approach adopted is within the purview of how mongoose implements DBRef-like behavior using populate. However some things seem to have been missed out. Not sure if these details have already been take care and not shown for brevity. Will run through the complete set of steps:
RoleSchema = new Schema {
type: String
expiration: { type: Date, default : '0' }
}
UserSchema = new Schema {
email: { type: String, index: { unique: true }}
_roles : [{ type: Schema.ObjectId, ref: 'Role' }] // array of pointers for roles
}
Role = mongo.model('Role', 'RoleSchema');
User = mongo.model('User', 'UserSchema');
var life_sub = new Role({ type: 'LIFE_SUBSCRIBER', .... });
var daily_sub = new Role({ type: 'DAILY_SUBSCRIBER', .... });
var monthly_sub = new Role({ type: 'MONTHLY_SUBSCRIBER', .... });
var jane = new User({email: 'jane#ab.com', _roles: [daily_sub, monthly_sub]});
Add users as required. Next to find the specified user and the corresponding set of roles use:
User
.find({email:'jane#ab.com'})
.populate('_roles[]')
.run(function(err, user)
Now user is an array of pointers corresponding to the roles of the user specified. Iterate through the array and find each role for the user and the corresponding expiration date. Compare with Date.now() to arrive at which roles have expired for the particular user.
Hope this helps.

Resources