Skip to content

Relations

jsorm uses explicit relation builders that describe the exact SQL relationship between two models. Each builder generates the appropriate join and mutation SQL without hidden eager loading or lazy proxies.

BuilderSQL relationshipWhen to use
t.belongsTo(Model)Foreign key on this tableUser has roleId column
t.hasOne(Model)Foreign key on other tableProfile has userId column
t.hasMany(Model)Foreign key on other tablePost has authorId column
t.manyToMany(Model)Junction tablePosts ↔ Tags via post_tags
import { defineModel, t } from "jsorm";
/*
* Use lazy model resolvers (() => Model)
* to safely support circular and inverse relations.
*/
export const Role = defineModel("roles", {
id: t.number().primary(),
name: t.string().unique(),
// one role → many users
users: t.hasMany(() => User),
});
export const Profile = defineModel("profiles", {
id: t.number().primary(),
bio: t.string().optional(),
// one profile → belongs to one user
user: t.belongsTo(() => User, {
constraintName: "fk_profiles_user_id",
onUpdate: "cascade",
onDelete: "cascade",
}).index(),
});
export const Tag = defineModel("tags", {
id: t.number().primary(),
name: t.string().unique(),
// many tags ↔ many posts
posts: t.manyToMany(() => Post),
});
export const Post = defineModel("posts", {
id: t.number().primary(),
title: t.string(),
// many posts ↔ many tags
tags: t.manyToMany(() => Tag),
// many posts → one user
user: t.belongsTo(() => User, {
constraintName: "fk_posts_user_id",
onUpdate: "cascade",
onDelete: "set null",
}).index(),
});
export const User = defineModel("users", {
id: t.number().primary(),
name: t.string(),
// many users → one role
role: t.belongsTo(() => Role, {
constraintName: "fk_users_role_id",
onUpdate: "cascade",
onDelete: "restrict",
}).index(),
// one user → one profile
profile: t.hasOne(() => Profile),
// one user → many posts
posts: t.hasMany(() => Post),
});

Include relations in select to load them as nested objects:

const users = await jsorm.get(User, {
select: {
id: true,
name: true,
role: { name: true },
profile: { bio: true },
posts: {
title: true,
tags: { name: true },
},
},
});
// Typed: Array<{ id: number; name: string; role: { name: string }; ... }>

Use nested where to filter by relation fields:

const admins = await jsorm.get(User, {
select: { name: true },
where: {
role: { name: { eq: 'admin' } },
posts: { title: { contains: 'release' } },
},
});
await jsorm.update(User, {
data: {
role: { connect: 1 },
},
where: { id: 5 },
});
await jsorm.update(User, {
data: {
profile: {
create: { bio: 'Builder from Day 1' },
},
},
where: { id: 5 },
});
await jsorm.update(Post, {
data: {
tags: {
connect: [1, 2, 3],
disconnect: [4],
},
},
where: { id: 10 },
});
await jsorm.insert(User, {
name: 'Alice',
role: { connect: 1 },
profile: {
create: { bio: 'Builder' },
},
posts: {
create: [
{ title: 'First post', tags: { connect: [1, 2] } },
],
},
});

Configure onUpdate and onDelete on belongsTo to control database-level referential integrity:

role: t.belongsTo(Role, {
onUpdate: 'cascade', // 'cascade' | 'restrict' | 'set-null' | 'no-action'
onDelete: 'restrict',
}),
  1. Use the relation builder that matches the real data shape — don’t use hasMany when the relationship is manyToMany.
  2. Configure onUpdate and onDelete intentionally rather than relying on database defaults.
  3. Keep relation mutations close to write operations instead of scattering pivot logic manually.
  4. Always include relation fields explicitly in select — jsorm never loads relations implicitly.