ORM: Relationships
See how to create relations between models in Athenna Framework.
Defining relationships
Database tables are often related to one another. For example, a blog post may have many comments or an order could be related to the user who placed it. Athenna makes managing and working with these relationships easy, and supports a variety of common relationships that are defined as properties on your model classes using annotations:
import {
Column,
HasOne,
HasMany,
BaseModel,
BelongsTo,
BelongsToMany,
type Relation,
} from '@athenna/database'
import { Course } from '#src/models/Course'
import { Profile } from '#src/models/Profile'
import { Comment } from '#src/models/Comment'
import { Customer } from '#src/models/Customer'
import { UsersCourses } from '#src/models/UsersCourses'
export class User extends BaseModel {
@Column()
public id: number
@HasOne(() => Profile)
public profile: Relation<Profile>
@HasMany(() => Comment)
public comments: Relation<Comment[]>
@BelongsTo(() => Customer)
public customer: Relation<Customer>
@BelongsToMany(() => Course, () => UsersCourses)
public courses: Relation<Course[]>
}
Check the full documentation of relationships annotations:
On this documentation you will see how to setup the following relationships using the above annotations:
One To One
A one-to-one relationship is a very basic type of database relationship.
For example, a User
model might be associated with one Phone
model.
To define this relationship, we will place a phone
property on the User
model and annotate it with the @HasOne()
annotation:
import { Phone } from '#src/models/Phone'
import type { Relation } from '@athenna/database'
import { Column, BaseModel, HasOne, type Relation } from '@athenna/database'
export class User extends BaseModel {
@Column()
public id: number
@HasOne(() => Phone)
public phone: Relation<Phone>
}
The first argument passed to the @HasOne()
annotation is closure
that return the related model class. Once the relationship is defined,
we may retrieve the related record using the with()
method of the
model query builder:
const { phone } = await User.query()
.with('phone')
.find()
Since all relationships are extending BaseModel
, you may add further
constraints to the relationship query by adding a closure as second argument
of the with()
method:
const { phone } = await User.query()
.with('phone')
.find()
// Or
const user = await User.find()
await user.load('phone', query => query.where('ddd', '+55'))
console.log(user.phone)
Athenna determines the foreign key of the relationship based on the
parent model name. In this case, the Phone
model is automatically
assumed to have a userId
foreign key. If you wish to override this
convention, you may pass a second argument to the @HasOne()
annotation:
@HasOne(() => Phone, { foreignKey: 'foreignKey' })
public phone: Relation<Phone>
Additionally, Athenna assumes that the foreign key should have a
value matching the primary key column of the parent. In other words,
Athenna will look for the value of the user's id column in the userId
column of the Phone
record. If you would like the relationship to
use a primary key value other than id
, you may pass a primaryKey
property to the options in the second argument:
@HasOne(() => Phone, {
primaryKey: 'primaryKey',
foreignKey: 'foreignKey'
})
public phone: Relation<Phone>
Defining the inverse of the relationship
So, we can access the Phone
model from our User
model.
Next, let's define a relationship on the Phone
model that
will let us access the user that owns the phone. We can define
the inverse of a @HasOne()
relationship using the @BelongsTo()
annotation:
import { User } from '#src/models/User'
import type { Relation } from '@athenna/database'
import { Column, BelongsTo, BaseModel } from '@athenna/database'
export class Phone extends BaseModel {
@Column()
public id: number
@Column()
public userId: number
@BelongsTo(() => User)
public user: Relation<User>
}
When invoking the with('user')
method, Athenna will attempt to find
a User
model that has an id which matches the userId
column on the
Phone
model.
Athenna determines the foreign key name by examining the name of the
relationship model and suffixing it with Id
. So, in this case,
Athenna assume that the Phone
model has a userId
column. However,
if the foreign key on the Phone
model is not userId
, you may pass
a custom key name as the second argument to the @BelongsTo()
annotation:
@BelongsTo(() => User, { foreignKey: 'foreignKey' })
public user: Relation<User>
If the parent model does not use id
as its primary key, or you wish
to find the associated model using a different column, you may pass a
third argument to the @BelongsTo()
method specifying the parent
table's custom key:
@BelongsTo(() => User, {
primaryKey: 'primaryKey',
foreignKey: 'foreignKey'
})
public user: Relation<User>
One To Many
A one-to-many relationship is used to define relationships where a
single model is the parent to one or more child models. For example,
a blog post may have an infinite number of comments. Like all other
Athenna relationships, one-to-many relationships are defined by
defining a @HasMany()
annotation in your model class property:
import { Comment } from '#src/models/Comment'
import type { Relation } from '@athenna/database'
import { Column, BaseModel, HasMany } from '@athenna/database'
export class Post extends BaseModel {
@Column()
public id: number
@HasMany(() => Comment)
public comments: Relation<Comment[]>
}
Remember, Athenna will automatically determine the proper foreign
key column for the Comment
model. By convention, Athenna will take
the "camelCase" name of the parent model and suffix it with Id
. So,
in this example, Athenna will assume the foreign key column on the
Comment
model is postId
.
Once the relationship method has been defined, we can access the
data of related comments
by using the with()
method:
const { comments } = await Post.query()
.with('comments')
.find()
Since all relationships are extending BaseModel
, you may add further
constraints to the relationship query by adding a closure as second argument
of the with()
method:
const { comments } = await Post.query()
.with('comments', query => query.where('title', 'foo'))
.find()
// Or
const post = await Post.find()
await post.load('comments', query => query.where('title', 'foo'))
console.log(post.comments)
Like the @HasOne()
annotation, you may also override the foreign and
primary keys by passing the additional options second argument to the
@HasOne()
annotation:
@HasMany(() => Comment, {
foreignKey: 'foreignKey',
primaryKey: 'primaryKey'
})
public comments: Relation<Comment[]>
Defining the inverse of the relationship (Many To One)
Now that we can access all of a post's comments, let's define a
relationship to allow a comment to access its parent post. To
define the inverse of a @HasMany()
relationship, define a
relationship property on the child model annotated with the
@BelongsTo()
annotation:
import { Post } from '#src/models/Post'
import type { Relation } from '@athenna/database'
import { Column, BaseModel, BelongsTo } from '@athenna/database'
export class Comment extends BaseModel {
@Column()
public id: number
@Column()
public postId: number
@BelongsTo(() => Post)
public post: Relation<Post>
}
And just like the other relationships, once we have defined it in
our class, we can retrieve a comment's parent post by using the
with()
and load()
methods:
const { post } = await Comment.query()
.with('post', query => query.where('title', 'foo'))
.find()
// Or
const comment = await Comment.find()
await comment.load('post', query => query.where('title', 'foo'))
console.log(comment.post)
In the example above, Athenna will attempt to find a Post model
that has an id which matches the postId
column on the Comment model.
Athenna determines the foreign key name by examining the name of the
relationship model and suffixing it with Id
. So, in this case,
Athenna assume that the Comment
model has a postId
column. However,
if the foreign key on the Comment
model is not postId
, you may pass
a custom key name as the second argument to the @BelongsTo()
annotation:
@BelongsTo(() => Post, { foreignKey: 'foreignKey' })
public post: Relation<Post[]>
If your parent model does not use id
as its primary key, or you
wish to find the associated model using a different column, you
may pass a primaryKey
option specifying your parent table's
custom key:
@BelongsTo(() => Post, {
foreignKey: 'foreignKey',
primaryKey: 'primaryKey'
})
public post: Relation<Post[]>
Many To Many
Many-to-many relations are slightly more complicated than one-to-one and one-to-many relationships. An example of a many-to-many relationship is a user that has many roles and those roles are also shared by other users in the application. For example, a user may be assigned the role of "Author" and "Editor"; however, those roles may also be assigned to other users as well. So, a user has many roles and a role has many users.
Table structure
To define this relationship, three database tables are needed: users
,
roles
, and roles_users
. The roles_users
table is derived from the
alphabetical order of the related model names and contains userId
and roleId
columns. This table is used as an intermediate table
linking the users and roles.
Remember, since a role can belong to many users, we cannot simply
place a userId
column on the roles
table. This would mean that a
role could only belong to a single user. In order to provide support
for roles being assigned to multiple users, the roles_users
table
is needed. We can summarize the relationship's table structure like so:
users
id - integer
name - string
roles
id - integer
name - string
roles_users
userId - integer
roleId - integer
Model structure
Many-to-many relationships are defined by defining a property annotated
with the @BelongsToMany()
annotation. But before we check how to define
this annotation, we need to first create our RolesUsers
pivot model:
import { Role } from '#src/models/Role'
import { User } from '#src/models/User'
import type { Relation } from '@athenna/database'
import { Column, BaseModel, BelongsTo } from '@athenna/database'
export class RolesUsers extends BaseModel {
@Column()
public id: number
@Column()
public roleId: number
@Column()
public userId: number
@BelongsTo(() => Role)
public role: Relation<Role>
@BelongsTo(() => User)
public user: Relation<User>
}
And now let's define the roles relation on our User
model. The first
argument passed to the annotations is a closure that returns the related
model class and the second argument is a closure that returns the pivot
model:
import { Role } from '#src/models/Role'
import type { Relation } from '@athenna/database'
import { RolesUsers } from '#src/models/RolesUsers'
import { Column, BaseModel, BelongsToMany } from '@athenna/database'
export class User extends BaseModel {
@Column()
public id: number
@Column()
public name: string
@BelongsToMany(() => Role, () => RolesUsers)
public roles: Relation<Role[]>
}
Once the relationship is defined, you may access the user's roles using
the with()
or load()
methods:
const user = await User.query()
.with('roles', query => query.select('id'))
.find()
// Or
const user = await User.find()
await user.load('roles', query => query.select('id'))
console.log(user.roles)
To determine the table name of the relationship's pivot table,
Athenna will call the table()
method of the pivot model. However, you
are free to override this convention. You may do so by passing a third
argument to the @BelongsToMany()
annotation:
@BelongsToMany(() => Role, () => RolesUsers, {
pivotTable: 'roles_users'
})
public roles: Relation<Roles[]>
In addition to customizing the name of the pivot table, you may also customize the column names of the keys on the table by passing additional arguments to the options:
@BelongsToMany(() => Role, () => RolesUsers, {
pivotTable: 'roles_users',
primaryKey: 'id',
foreignKey: 'userId',
relationPrimaryKey: 'id',
relationForeignKey: 'roleId'
})
public roles: Relation<Roles[]>
Defining the inverse of the relationship
To define the "inverse" of a many-to-many relationship, is basically
the same process of defining on the parent model. To complete our
user / role example, let's define the users
property on the Role
model:
import { User } from '#src/models/User'
import type { Relation } from '@athenna/database'
import { RolesUsers } from '#src/models/RolesUsers'
import { Column, BaseModel, BelongsToMany } from '@athenna/database'
export class Role extends BaseModel {
@Column()
public id: number
@Column()
public name: string
@BelongsToMany(() => User, () => RolesUsers)
public users: Relation<User[]>
}
As you can see, the relationship is defined exactly the same as its User
model counterpart with the exception of referencing the User
model.
Since we're reusing the @BelongsToMany()
annotation, all of the usual
table and key customization options are available when defining the
"inverse" of many-to-many relationships.
Retrieving pivot table data
As you have already learned, working with many-to-many relations requires the presence of a pivot table and it pivot model, meaning that if you need to retrieve the data from the pivot table, you can simply use the pivot model:
const user = await User.find()
const role = await Role.find()
const rolesUsers = await RolesUsers.query()
.where('userId', user.id)
.where('roleId', role.id)
.findMany()
Eager loading
Eager loading alleviates the "N + 1" query problem. To illustrate the N + 1
query problem, consider a Book
model that "belongs to" to an Author
model:
import { Author } from '#src/models/Author'
import type { Relation } from '@athenna/database'
import { Column, BelongsTo, BaseModel } from '@athenna/database'
export class Book extends BaseModel {
@Column()
public id: number
@BelongsTo(() => Author)
public author: Relation<Author>
}
Now, let's retrieve all books and their authors:
const books = await Book.findMany()
for (const book of books) {
await book.load('author')
console.log(book.author.name)
}
This loop will execute one query to retrieve all of the books within the database table, then another query for each book in order to retrieve the book's author. So, if we have 25 books, the code above would run 26 queries: one for the original book, and 25 additional queries to retrieve the author of each book.
Thankfully, we can use eager loading to reduce this operation to just two
queries. When building a query, you may specify which relationships should
be eager loaded using the with()
method:
const books = await Book.query()
.with('author') 👈
.findMany()
for (const book of books) {
console.log(book.author.name)
}
For this operation, only two queries will be executed - one query to retrieve all of the books and one query to retrieve all of the authors for all of the books:
select * from books
select * from authors where id in (1, 2, 3, 4, 5, ...)
Eager loading multiple relationships
Sometimes you may need to eager load several different relationships. To do
so, just concat multiple with()
calls:
const books = await Book.query()
.with('author') 👈
.with('publisher') 👈
.findMany()
Nested eager loading
To eager load a relationship's relationships, you may use "dot" syntax. For example, let's eager load all of the book's authors and all of the author's personal contacts:
const books = await Book.query()
.with('author.contacts') 👈
.findMany()
Closures set as second argument of the with()
method will be ignored when using
"dot" syntax to eager load relationship's relationships.
Check the relationship's relationships constraints documentation section to see how you could have more power under the constraints added to each of your relationships.
Constraining eager loads
Sometimes you may wish to eager load a relationship but also specify additional
query conditions for the eager loading query. You can accomplish this by passing
a closure as second argument to the with()
function that adds additional
constraints to the eager loading query:
const users = await User.query()
.with('posts', query => query.whereLike('title', '%code%')) 👈
.findMany()
In this example, Athenna will only eager load posts where the post's title
column contains the word code
. You may call other query builder methods to
further customize the eager loading operation:
const users = await User.query()
.with('posts', query => query.orderBy('createdAt', 'desc')) 👈
.findMany()
Relationship's relationships constraints
When using the "dot" syntax to eager load relationship's relationships the closure set as second argument will always be ignored:
const books = await Book.query()
// ❌ Load `author` and `contacts` of author,
// but doesn't query only cellphones contacts.
.with('author.contacts', query => query.where('isCellphone', true))
.findMany()
To have full power under the constraints that you want to add to your queries you can
call with()
method inside of your closure:
const books = await Book.query()
.with('author', query => {
query
.where('name', 'Lenon')
.with('contacts', query => {
query.where('isCellphone', true)
})
})
.findMany()
Inserting & updating related models
Since all of your relationships extends the BaseModel
, you can use methods like
save()
to create your relationships:
const author = await Author.find()
author.book = new Book()
author.book.authorId = author.id
await author.book.save()
Or even to update it:
const book = await Book.query()
.with('author')
.find()
book.author.name = 'Lenon'
await book.author.save()
Creating many-to-many relationships
When working with many-to-many relationships you need to use the pivot model to create the relation between the other two models:
import { User } from '#src/models/User'
import { Role } from '#src/models/Role'
import { RolesUsers } from '#src/models/RolesUsers'
const user = await User.find()
const role = await Role.find()
const rolesUsers = new RolesUsers()
rolesUsers.userId = user.id
rolesUsers.roleId = role.id
await rolesUsers.save()
// Or simply
await RolesUsers.create({ userId: user.id, roleId: role.id })