Associations connect two models by a foreign key and generate a set of
instance helper methods (getX, setX, addX, createX, countX, hasX
and friends) on the source model. prorm supports four association types —
hasOne, hasMany, belongsTo, and belongsToMany — declared by calling the
matching method on a model returned from prorm.define(...).Every association is registered on the source model so that later helpers,
eager loading (include), and foreign-key constraint generation during
sync() can all resolve it. This guide covers each type, the generated
mixin methods, junction tables for many-to-many, eager loading, and how
foreign keys are inferred and constrained.The four shapes#
Which side holds the foreign key is the thing to get right, because it decides
which table is written when you set the association:| Association | Foreign key lives on | Extra table |
|---|
hasOne | the target | — |
hasMany | the target | — |
belongsTo | the source | — |
belongsToMany | neither | a join table |
hasOne and belongsTo describe the same physical relationship from opposite
ends — declare both when you want traversal in both directions.The blog schema used through the rest of this page, with its columns:Defining associations#
Associations are defined after the models exist. Each call takes the target
model and an options object:import { Prorm, DataTypes } from 'ts-prorm-orm';
const prorm = new Prorm({ dialect: 'sqlite', storage: ':memory:' });
const User = prorm.define('User', {
id: { type: DataTypes.INTEGER(), primaryKey: true, autoIncrement: true },
name: { type: DataTypes.STRING() },
});
const Post = prorm.define('Post', {
id: { type: DataTypes.INTEGER(), primaryKey: true, autoIncrement: true },
title: { type: DataTypes.STRING() },
userId: { type: DataTypes.INTEGER() }, // foreign key column
});
User.hasMany(Post); // a user has many posts
Post.belongsTo(User); // a post belongs to a user
await prorm.connect();
await prorm.sync();
[!IMPORTANT]
Declare every model and every association before the tables are created.
define() on a connection that is already open creates its table straight
away, before the association exists, and sync() then sees the table and
leaves it alone — so the REFERENCES clause is never emitted. The safe
order is: define models → declare associations → connect() → sync(), as
above.
With the blog models and Post.belongsTo(User, { foreignKey: 'userId', as: 'author' }),
sync() emits the foreign key inline on the column, or, on MySQL, MariaDB
and TiDB, as a table-level FOREIGN KEY:[!NOTE]
InnoDB parses a column-level REFERENCES clause and then ignores it, so the
MySQL family writes the table-level form, which InnoDB enforces (in
CREATE TABLE and in ADD COLUMN). Before 4.9.2 these engines got the
column-level clause and created no foreign key at all; tables created by an
older release have none until you add it with addConstraint or a
migration.
The options accepted by every association type are:| Option | Purpose |
|---|
as | Alias for the association; drives the helper method names and the include key. |
foreignKey | Foreign key column name. A string, an array (composite key), or { name, allowNull }. |
sourceKey | Key on the source model. Defaults to the primary key. |
targetKey | Key on the target model. Defaults to id. |
onDelete / onUpdate | Referential action for the FOREIGN KEY constraint (CASCADE, SET NULL, RESTRICT, NO ACTION, SET DEFAULT). See Foreign keys and constraints. |
constraints | Whether to emit the foreign-key constraint. |
through | For belongsToMany only: the junction table name, model, or { model, otherKey, ... }. |
Alias and foreign-key defaults#
When you omit as, the alias is derived from the target model name:hasOne / belongsTo use the lower-cased target name (User -> user).
hasMany / belongsToMany append s (Post -> posts).
When you omit foreignKey, it is inferred as a lowerCamelCase column name:belongsTo and hasOne: <targetName>Id (e.g. Post.belongsTo(User) -> userId).
hasMany and belongsToMany: <sourceName>Id (e.g. User.hasMany(Post) -> userId).
Because the inferred name is always lowerCamelCase, define the matching
column with the same casing (userId, not UserId). For hasOne the
foreign key lives on the target table, so an explicit foreignKey is often
clearer than relying on the default.[!TIP]
The inferred key follows the model name, not the alias:
Post.belongsTo(User, { as: 'author' }) still reads posts.userId
(SELECT * FROM "users" WHERE "id" = <post.userId>). If the column is really
called authorId, say so with foreignKey: 'authorId' on both ends.
belongsTo#
belongsTo places the foreign key on the source model. The source row
stores the target's primary key.Post.belongsTo(User, { foreignKey: 'userId', as: 'author' });
const article = await Post.findByPk(1);
const author = await article.getAuthor(); // SELECT * FROM "users" WHERE "id" = 1 LIMIT 1
await article.setAuthor(user); // sets article.userId, saves
await article.unsetAuthor(); // clears the foreign key
const created = await article.createAuthor({ name: 'Ada' }); // create + link
const has = await article.hasAuthor(); // boolean
const n = await article.countAuthor(); // 0 or 1
Generated methods (alias author): getAuthor, setAuthor, unsetAuthor,
createAuthor, hasAuthor, countAuthor. setAuthor accepts an instance,
a raw key value, or null (the same as unsetAuthor()).hasOne#
hasOne is the inverse one-to-one: the foreign key lives on the target
model, pointing back at the source.const Profile = prorm.define('Profile', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
bio: DataTypes.STRING,
userId: DataTypes.INTEGER,
});
User.hasOne(Profile, { foreignKey: 'userId' });
const ada = await User.findByPk(1);
const profile = await ada.getProfile(); // findOne on Profile
await ada.setProfile(profile); // updates Profile.userId
const created = await ada.createProfile({ bio: 'hi' });
const has = await ada.hasProfile(); // boolean
const n = await ada.countProfile(); // 0 or 1
Generated methods (alias profile): getProfile, setProfile,
createProfile, hasProfile, countProfile.hasMany#
hasMany is one-to-many. The foreign key lives on the target, and the
helpers operate on collections.User.hasMany(Post, { foreignKey: 'userId', as: 'posts' });
const ada = await User.findByPk(1);
const posts = await ada.getPosts(); // findAll on Post
await ada.setPosts([postA, postB]); // replaces the whole set
await ada.addPosts([postC, postD]); // links additional rows
await ada.addPost(postE); // singular form
const post = await ada.createPost({ title: 'Hi' }); // create + link
const count = await ada.countPosts();
const any = await ada.hasPosts(); // any linked?
const both = await ada.hasPosts([postC, postD]); // all linked?
const one = await ada.hasPost(postE); // singular form
Generated methods (alias posts): getPosts, setPosts, addPosts,
addPost, createPost, countPosts, hasPosts, hasPost. Singular forms
are derived by stripping a trailing s (or ies -> y).The SQL each helper sends, on SQLite, for user 1:| Call | SQL |
|---|
user.getPosts() | SELECT * FROM "posts" WHERE "userId" = 1; |
user.createPost({ title: 'Four' }) | INSERT INTO "posts" ("title", "userId") VALUES ('Four', 1); |
user.countPosts() | SELECT COUNT(*) AS "count" FROM "posts" WHERE "userId" = 1; |
user.setPosts([p1]) | UPDATE "posts" SET "userId" = NULL WHERE "userId" = 1;
UPDATE "posts" SET "userId" = 1 WHERE "id" = 1; |
setPosts does not delete anything: it detaches every current post (nulling
its foreign key) and then attaches the ones you pass.[!WARNING]
Because setPosts nulls the foreign key of the posts it drops, it fails if
posts.userId is NOT NULL — and on a nullable column it leaves orphaned
posts behind. When the children cannot exist without a parent, delete them
explicitly (Post.destroy({ where: { userId: ada.id, id: { [Op.notIn]: keep } } }))
instead of using setPosts.
There is no removePost/removePosts helper on a hasMany; detach one child
by clearing its key yourself:await Post.update({ userId: null }, { where: { id: postA.id, userId: ada.id } });
belongsToMany#
belongsToMany is many-to-many, backed by a junction (through) table that
holds one row per link. The source's key is the foreignKey; the target's
key on the junction is the otherKey.const Role = prorm.define('Role', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
name: DataTypes.STRING,
});
User.belongsToMany(Role, {
through: 'UserRoles', // junction table name (or a model)
foreignKey: 'userId', // column on the junction pointing at User
otherKey: 'roleId', // column on the junction pointing at Role
});
const ada = await User.findByPk(1);
const roles = await ada.getRoles(); // two queries, via the junction
await ada.setRoles([roleA, roleB]); // replace all links
await ada.addRoles([roleC]); // insert junction rows
await ada.addRole(roleD); // singular form
await ada.removeRole(roleB); // delete one junction row
const count = await ada.countRoles(); // counts junction rows
const one = await ada.hasRole(roleC);
Generated methods (alias roles): getRoles, setRoles, addRoles,
addRole, removeRole, countRoles, hasRole.getRoles() reads the junction first and then the targets:[!CAUTION]
addRole / addRoles insert a junction row every time they are called —
linking the same pair twice gives two rows, and getRoles() then returns the
role twice. Guard with hasRole() first, or give the junction a composite
unique key so the database refuses the duplicate:
export async function grantRole(user: any, role: any) {
if (await ada.hasRole(role)) return false; // idempotent: already granted
await ada.addRole(role);
return true;
}
The through table#
through accepts three shapes:// 1. A table name — a junction model is generated for you.
User.belongsToMany(Role, { through: 'UserRoles' });
// 2. A model you defined yourself (extra columns allowed).
const UserRole = prorm.define('UserRole', {
userId: DataTypes.INTEGER,
roleId: DataTypes.INTEGER,
grantedAt: DataTypes.DATE,
});
User.belongsToMany(Role, { through: UserRole });
// 3. A ThroughOptions object.
User.belongsToMany(Role, {
through: { model: UserRole, otherKey: 'roleId' },
});
When no through model is supplied, prorm synthesizes one whose table name
defaults to <Source><Target> (e.g. UserRole) with an id and the two key
columns — the foreignKey pointing at the source and the otherKey pointing
at the target. The otherKey defaults to <targetName>Id (e.g. roleId) and
can be set with the top-level otherKey option or through.otherKey. The
generated junction carries no REFERENCES and no unique key (see the CREATE
TABLE "UserRoles" above); define the junction yourself when you want either.Eager loading with include#
Instead of calling getX() per row, pass include to findAll /
findOne / findByPk to load associated rows in one pass. Each associated
result is attached under the association alias (and mirrored on
dataValues): a single object (or null) for belongsTo/hasOne, an
array for hasMany/belongsToMany.const users = await User.findAll({
include: [{ model: Post }],
});
for (const user of users) {
console.log(user.name, user.posts); // Post[] under the `posts` alias
}
An include is not a JOIN. Prorm runs the parent query, collects the keys, and
fetches the children with one IN (…) query per level, then stitches the rows
together in memory:{"id":1,"title":"One","published":true,"userId":1,"author":{"id":1,"name":"Ada"}}
Because the parent query runs on its own, limit / offset page the parents
exactly — there is no row multiplication to correct for.Use as when a model is included under a custom alias, and where,
attributes, and order to shape the associated rows:const users = await User.findAll({
include: [
{
model: Post,
as: 'posts',
where: { published: true }, // filter associated rows
attributes: ['id', 'title', 'userId'], // select a subset of columns
order: [['createdAt', 'DESC']], // order the associated rows
},
],
});
where, attributes, and order are applied per-included association.
Top-level limit / offset still page the parent query.An include with a where is required by default: it filters the parents
too, so only users with at least one matching post come back. Prorm finds the
matching children first, then loads just those parents. Pass
required: false to keep every parent and filter only the attached children.
Recorded on SQLite, with Ada (one published post), Grace (a draft) and Linus
(no posts):With limit, the page is taken from the parents that survived the filter
(SELECT * FROM "users" WHERE "id" IN (1) LIMIT 10), so a page of ten users
really is ten users. Keep the foreign key in attributes when you narrow the
child columns: without userId Prorm cannot attach a child to its parent.Nested includes#
Includes nest to any depth. Each level's fetched rows become the parent for
the next, so grandchildren load too:const users = await User.findAll({
include: [
{
model: Post,
include: [{ model: Comment }], // user -> posts -> comments
},
],
});
Including belongsToMany#
Many-to-many includes resolve through the junction table automatically
using the association's foreignKey and otherKey. You can still narrow
the joined target rows with where, attributes, and order:const users = await User.findAll({
include: [
{
model: Role,
where: { name: 'admin' },
},
],
});
Unfiltered, that is three queries — parents, junction, targets:Foreign keys and constraints#
During sync(), a belongsTo / hasOne / hasMany contributes a
REFERENCES clause to the column that holds the key, pointing at the
targetKey (default id), as shown in the CREATE TABLE tabs above:Post.belongsTo(User, {
foreignKey: 'userId',
targetKey: 'id',
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
});
[!IMPORTANT]
When both sides of a relationship declare the same key (User.hasMany(Post)
and Post.belongsTo(User)), the reference gets the actions either side
gives: onDelete from one and onUpdate from the other combine, and two
different values for the same action make sync() throw an
AssociationError rather than pick one. Until the release after 4.9.4, the
association declared first set the reference and the other side's actions
were dropped; before 4.9.2, no association's onDelete / onUpdate reached
the DDL. For a table created by an older release, add the constraint
yourself:import { ConstraintType, ReferentialAction } from 'ts-prorm-orm';
await Post.addConstraint('posts_user_fk', {
type: ConstraintType.ForeignKey,
fields: ['userId'],
references: { table: 'users', fields: ['id'] },
onDelete: ReferentialAction.Cascade,
});
Composite foreign keys are supported by passing an array to foreignKey
(and, if needed, targetKey):OrderItem.belongsTo(Order, {
foreignKey: ['orderId', 'customerId'],
targetKey: ['id', 'customerId'],
});
The object form of foreignKey carries constraint metadata such as
allowNull:Post.belongsTo(User, {
foreignKey: { name: 'userId', allowNull: false },
});
Custom primary keys#
The helpers resolve the source instance's key from the model's declared
primary key attribute — it does not have to be named id. A model keyed on
sku works the same way; the association reads whichever attribute is
marked primaryKey (falling back to id/uuid when none is declared).const Product = prorm.define('Product', {
sku: { type: DataTypes.STRING, primaryKey: true },
name: DataTypes.STRING,
});
Product.hasMany(Review, { foreignKey: 'productSku' });
Next: Eager loading — fetching related rows without the N+1 problem. Modified at 2026-10-04 00:40:16