I am confused with Sails.js waterline one-to-one association logic - node.js

So the reason why im confused, because I am a PHP developer and used Laravel and FuelPHP alot
What i dont really understand is the association it self.
What i mean, i wanted to create a basic hasOne / BelongsTo logic, with the following
User has one profile
Profile belongs to an user
I am used to the following build up (Laravel style)
Users table
id | username | email | password
---------------------------------------
1 | My Username | My email | 1234568
Users_profile table
user_id | first_name | last_name
----------------------------------------
1 | My First name | My Last name
Then i just defined models this way
User model
class Users extends Eloquent
{
public function profile()
{
return $this->hasOne('profile');
}
}
Profile model
class Profile extends Eloquent
{
protected $tableName = 'users_profile';
protected $primaryKey = 'user_id';
public function user()
{
return $this->belongsTo('User');
}
}
And it just works, because the return $this->hasOne('profile'); will auto check for the user_id
Tried the same in Sails.js (in the sails way)
User model
module.exports = {
attributes: {
username: {
type: 'string',
unique: true,
required: true
},
email: {
type: 'string',
unique: true,
email: true,
required: true
},
password: {
type: 'string',
required: true
},
profile: {
model: "profile",
}
},
};
Profile model
module.exports = {
tableName: 'user_profile',
autoPK: false,
autoCreatedAt: false,
autoUpdateddAt: false,
attributes: {
user_id: {
type: 'integer',
primaryKey: true
},
first_name: {
type: 'string',
},
last_name: {
type: 'string',
},
user: {
model: "user"
}
}
};
And reading from the documentation now i have to update my table this way
id | username | email | password | profile
-------------------------------------------------
1 | My Username | My email | 1234568 | 1
user_id | first_name | last_name | user |
-----------------------------------------------
1 | My First name | My Last name | 1
So i need to store 2 more id's again, and i do not really get why.
Than i read further tried to use via did not work (noted that is for collections)
So, anybody could give me a logic example for a Laravelis style?
Foud nothing about this in the docs (a more easier way), because in my opinion if the user will have more relations, this will cause and ID hell (just my aopinion)

It is a known issue that Sails doesn't fully support one-to-one associations; you have to set the foreign key on whichever side you want to be able to populate from. That is, if you want to have User #1 linked to Profile #1 and be able to do User.find(1).populate('profile'), you would set the profile attribute of User #1, but that doesn't automatically mean that doing Profile.find(1).populate('user') will work. This is as opposed to many-to-many relationships in Sails, where adding the link on one side is sufficient. That's because to-many relationships use a join table, whereas to-one relationships do not.
The reason this hasn't been a priority in Sails is that one-to-one relationships are usually not really useful. Unless you have a really compelling reason for not doing so, you're better off just merging the two models into one.
In any case, if it's something you really need, you can use the .afterCreate lifecycle callback to ensure a bi-directional link, for example in User.js:
module.exports = {
attributes: {...},
afterCreate: function(values, cb) {
// If a profile ID was specified, or a profile was created
// along with the user...
if (values.profile) {
// Update the profile in question with the user's ID
return Profile.update({id: values.profile}, {user: values.id}).exec(cb);
}
// Otherwise just return
return cb();
}
};
You could add a similar .afterCreate() to Profile.js to handle updating the affected user when a profile was created.

Related

Automatically include referenced model in Sequelize on each GET

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);

Why does Sequelize add extra columns to SELECT query?

When i want to get some records with joined data from the referenced tables, Sequelize adds the reference columns twice: the normal one and a copy of them, written just a little bit different.
This is my model:
module.exports = function(sequelize, DataTypes) {
return sequelize.define('result', {
id: {
type: DataTypes.INTEGER(10),
allowNull: false,
primaryKey: true,
autoIncrement: true
},
test_id: {
type: DataTypes.INTEGER(10),
allowNull: false,
references: {
model: 'test',
key: 'id'
}
},
item_id: {
type: DataTypes.INTEGER(10),
allowNull: false,
references: {
model: 'item',
key: 'id'
}
},
}, // and many other fields
{
tableName: 'result',
timestamps: false, // disable the automatic adding of createdAt and updatedAt columns
underscored:true
});
}
In my repository I have a method, which gets the result with joined data. And I defined the following associations:
const Result = connection.import('../../models/storage/result');
const Item = connection.import('../../models/storage/item');
const Test = connection.import('../../models/storage/test');
Result.belongsTo(Test, {foreignKey: 'test_id'});
Test.hasOne(Result);
Result.belongsTo(Item, {foreignKey: 'item_id'});
Item.hasOne(Result);
// Defining includes for JOIN querys
var include = [{
model: Item,
attributes: ['id', 'header_en']
}, {
model: Test,
attributes: ['label']
}];
var getResult = function(id) {
return new Promise((resolve, reject) => { // pass result
Result.findOne({
where: { id : id },
include: include,
// attributes: ['id',
// 'test_id',
// 'item_id',
// 'result',
// 'validation'
// ]
}).then(result => {
resolve(result);
});
});
}
The function produces the following query:
SELECT `result`.`id`, `result`.`test_id`, `result`.`item_id`, `result`.`result`, `result`.`validation`, `result`.`testId`, `result`.`itemId`, `item`.`id` AS `item.id`, `item`.`title` AS `item.title`, `test`.`id` AS `test.id`, `test`.`label` AS `test.label` FROM `result` AS `result` LEFT OUTER JOIN `item` AS `item` ON `result`.`item_id` = `item`.`id` LEFT OUTER JOIN `test` AS `test` ON `result`.`test_id` = `test`.`id` WHERE `result`.`id` = '1';
Notice the extra itemId, testId it wants to select from the result table. I don't know where this happens. This produces:
Unhandled rejection SequelizeDatabaseError: Unknown column 'result.testId' in 'field list'
It only works when i specify which attributes to select.
EDIT: my tables in the database already have references to other tables with item_id and test_id. Is it then unnecessary to add the associations again in the application code like I do?
A result always has one item and test it belongs to.
How can i solve this?
Thanks in advance,
Mike
SOLUTION:
Result.belongsTo(Test, {foreignKey: 'test_id'});
// Test.hasMany(Result);
Result.belongsTo(Item, {foreignKey: 'item_id'});
// Item.hasOne(Result);
Commenting out the hasOne, hasMany lines did solve the problem. I think I messed it up by defining the association twice. :|
Sequelize uses these column name by adding an id to the model name by default. If you want to stop it, there is an option that you need to specify.
underscored: true
You can specify this property on application level and on model level.
Also, you can turn off the timestamps as well. You need to use the timestamp option.
timestamps: false
Although your solution fixes your immediate problem, it is ultimately not what you should be doing, as the cause of your problem is misunderstood there. For example, you MUST make that sort of association if making a Super Many-to-Many relationship (which was my problem that I was trying to solve when I found this thread). Fortunately, the Sequelize documentation addresses this under Aliases and custom key names.
Sequelize automatically aliases the foreign key unless you tell it specifically what to use, so test_id becomes testId, and item_id becomes itemId by default. Since those fields are not defined in your Result table, Sequelize assumes they exist when generating the insert set, and fails when the receiving table turns out not to have them! So your issue is less associating tables twice than it is that one association is assuming extra, non-existing fields.
I suspect a more complete solution for your issue would be the following:
Solution
Result.belongsTo(Test, {foreignKey: 'test_id'});
Test.hasMany(Result, {foreignKey: 'test_id'});
Result.belongsTo(Item, {foreignKey: 'item_id'});
Item.hasOne(Result, {foreignKey: 'item_id'});
A similar solution fixed my nearly identical problem with some M:N tables.

Sequelize updating nested associations

I have an instance where I have Users and Roles. I have the following:
var User = sequelize.define("Users", {
username: DataTypes.STRING,
password: DataTypes.STRING,
});
var Role = sequelize.define("Role", {
role: DataTypes.STRING
});
var UsersRole = sequelize.define("UsersRole");
User.belongsToMany(Role, {through: UsersRole});
Which creates a UsersRoles table in the DB for me with a UserId and RoleId column. This is all working fine, but now I want to be able to update a users role, I can't work out quite how to do this! I've tried the following with no luck so far:
models.Users.findAll({
where: { id: req.params.id },
include: [{ all: true }]
}).then(function(dbUser){
dbUser[0].Roles[0].updateAttributes({
RoleId: req.body.role,
},
{
where: { UserId : req.params.id }
}
).then(function (result) {...
In summary, all I want to do is be able to change a users role, so update the 'UsersRoles' table and change the RoleId for a given UserId. I can't quite seem to figure out how to get to the UsersRoles table via any sequelize syntax!
I could write some raw SQL but that doesn't feel right?
EDIT
I just want to update a users role, if the table has:
| UserId | RoleId |
-------------------
| 1 | 1 |
I would like to be able to change it to:
| UserId | RoleId |
-------------------
| 1 | 2 |
but I can't quite figure out the code to do this!
There is no need to interact with the join table directly - You can simply do
user.setRoles([newRole]);
http://docs.sequelizejs.com/en/latest/api/associations/belongs-to-many/#setassociationsnewassociations-options-promise
Notice that I'm passing an array, since users can have many roles - set removes all currently assigned roles. If you want to add a new role and keep the existing, use
user.addRole(newRole);
you are updating RoleId but you have not defined it. If you don't define a primary key on a table the by default Sequelize defines a primary key by name if id, so you should do id: req.body.role.

Force pair of attributes to be unique

I have a model which contains relationships between a user and his contacts.
Here is the current code for this model :
module.exports = {
connection: 'mysqlServer',
attributes: {
user: {
model: 'user',
required: true
},
contact: {
model: 'user',
required: true
}
}
};
What I would like is to make the combo user and contact uniques. That means that there may be several identical users and several identical contacts but only one user with a specific contact (ie: we can have user=1, contact=1 and user=1, contact=2, but we can't have user=1, contact=1 and user=1, contact=1).
The unique validation property is not enough to create the validation I want.
Do you have an idea how I should proceed? With custom validation rules maybe?

Is there a way to populate with a projection in Mongoose?

Say I have a collection that contains a field that references documents from another collection like follows:
ClassEnrollment
_id | student | class
---------------------
and classes in the Class collection have the following schema:
_id | className | teacher | building | time | days | classNumber | description
------------------------------------------------------------------------------
If I have a set of 3000 classes I want to populate on the server I might do something like ClassEnrollment.populate(listOfClassEnrollments, {path: 'class'});
In my situation, I don't want the majority of the class fields though, just the name. If I get the list of 3000 classes from the db with all fields, I end up taking a performance hit in the form of network latency (these 3000 classes have to be transferred from the hosted db to the server, which might be 50 MB of raw data if the descriptions are long)
Is there a way to populate the list of class enrollments with just the name through an option to populate (behind the scenes I imagine it would work like a projection, so the db just responds with the class name and _id instead of all the class information)?
You can use the select option in your populate call to do this:
ClassEnrollment.populate(listOfClassEnrollments, {path: 'class', select: 'className'});
To specify multiple fields, use a space-separated list:
ClassEnrollment.populate(
listOfClassEnrollments,
{path: 'class', select: 'className classNumber'}
);
Let's say we have a very simple user & video schemas.
1) USER SCHEMA
import mongoose from "mongoose";
const { Schema, model } = mongoose;
const UserSchema = new Schema({
name: String,
email: String,
password: String,
});
export default model("User", UserSchema);
2) VIDEOS SCHEMA
import mongoose from "mongoose";
const { Schema, model } = mongoose;
const { ObjectId } = Schema.Types;
const VideoSchema = new Schema({
videoOwnerId: { type: ObjectId, ref: "User", required: true },
title: { type: String, required: true },
desc: { type: String, required: true },
});
export default model("Video", VideoSchema);
Then I want to find in Videos Collection all videos by specific user AND at the same time all information about this user(a user document from Users Collection) and use projection on it ( select specific fields )
3) Somewhere in our code (maybe in a controller)
const videos = await Video.find({ videoOwnerId: "someId214121" }).populate("videoOwnerId", "-password");
So to populate with projection you use a populate("videoOwnerId", "-password") method, when the first argument is a field you want to populate, the second argument is a projection.
To get a document with all fields but without password for example
populate("videoOwnerId", "-password")
To get only specific fields that you want(string with fields separated by whitespace)
populate("videoOwnerId", "name email")

Resources