On this page

On this page

ORM Relationships

Database tables often relate to one another. A post has many comments; a comment belongs to a post; a user may share many roles through a pivot. Bunyad’s models express those links as relationships you define once and reuse for lazy loads, eager loads, existence queries, and aggregates.

Relationships live on the model via static relations (preferred) or instance methods. Loaded results are typed properties you declare on the class. Relation queries go through related('name') (or the legacy instance method). Import models from @/ and the ORM from @bunyad/orm.

import { Model, type OrmCollection } from "@bunyad/orm";
import Post from "@/Models/Post.ts";

export default class User extends Model {
  static table = "users";

  declare posts: OrmCollection<Post>;

  static relations = {
    posts: (m: User) => m.hasMany(Post),
  };
}

const user = await User.with("posts").find(1);
for (const post of user!.posts) {
  // ...
}

Supported relationship kinds:

  • One to one (hasOne / belongsTo)
  • One to many (hasMany / belongsTo)
  • Many to many (belongsToMany)
  • Has many through (hasManyThrough)
  • Polymorphic one to one / one to many (morphOne / morphMany / morphTo)
  • Polymorphic many to many (morphToMany)

Defining relationships

Register factories on static relations. Each factory receives the parent model instance and returns a relation object. Declare the property you will read after with() / load():

app/Models/User.ts
import { Model, type OrmCollection } from "@bunyad/orm";
import Phone from "@/Models/Phone.ts";
import Post from "@/Models/Post.ts";

export default class User extends Model {
  static table = "users";

  declare phone: Phone | null;
  declare posts: OrmCollection<Post>;

  static relations = {
    phone: (m: User) => m.hasOne(Phone),
    posts: (m: User) => m.hasMany(Post),
  };
}

After an eager load, read the declared property. To run the relation query (create related rows, attach pivots, associate), call related:

const user = await User.with("posts").find(1);
user!.posts; // OrmCollection<Post>

const relation = user!.related("posts"); // HasMany
await relation.create({ title: "Hello" });

Instance methods named like the relation still work and are useful when you prefer user.posts() over user.related("posts"):

export default class User extends Model {
  static table = "users";

  posts() {
    return this.hasMany(Post);
  }
}

await user.posts().create({ title: "Hello" });
const hello = await user.posts().where("title", "Hello").first();

Relation objects forward common query methods (where, orderBy, limit, count, …) onto a constrained related query, so user.posts().where(…).orderBy(…).get() works the same as chaining on Model.query().

One to one / has one

A one-to-one link: a User has one Phone. Define hasOne on the parent:

app/Models/User.ts
import { Model } from "@bunyad/orm";
import Phone from "@/Models/Phone.ts";

export default class User extends Model {
  static table = "users";

  declare phone: Phone | null;

  static relations = {
    phone: (m: User) => m.hasOne(Phone),
  };
}

By convention the related table stores {parent_singular}_id (here user_id). Override the foreign key and local key when needed:

m.hasOne(Phone, "foreign_key");
m.hasOne(Phone, "foreign_key", "local_key");

Load or query:

const user = await User.with("phone").find(1);
user!.phone;

const phone = await (await User.find(1))!.related("phone").first();

HasOne exposes get(), first(), create(attributes), and touch(). get() and first() both return a single model or null.

Inverse: belongs to

On Phone, define belongsTo so you can reach the owning user:

app/Models/Phone.ts
import { Model } from "@bunyad/orm";
import User from "@/Models/User.ts";

export default class Phone extends Model {
  static table = "phones";

  declare user: User | null;

  static relations = {
    user: (m: Phone) => m.belongsTo(User),
  };
}

Convention: foreign key {related_singular}_id on the child (user_id). Pass custom keys as needed:

m.belongsTo(User, "foreign_key");
m.belongsTo(User, "foreign_key", "owner_key");

BelongsTo supports get() / first(), associate(model), dissociate(), and touch():

const phone = await Phone.find(1);
phone!.related("user").associate(await User.find(2));
await phone!.save();

phone!.related("user").dissociate();
await phone!.save();

One to many / has many

A parent with many children — for example a post and its comments:

app/Models/Post.ts
import { Model, type OrmCollection } from "@bunyad/orm";
import Comment from "@/Models/Comment.ts";

export default class Post extends Model {
  static table = "posts";

  declare comments: OrmCollection<Comment>;

  static relations = {
    comments: (m: Post) => m.hasMany(Comment),
  };
}

Convention foreign key: {parent_singular}_id (post_id). Override with:

m.hasMany(Comment, "foreign_key");
m.hasMany(Comment, "foreign_key", "local_key");
const post = await Post.with("comments").find(1);
for (const comment of post!.comments) {
  // ...
}

await post!.related("comments").create({ body: "Nice post" });
const first = await post!.related("comments").first();

HasMany provides get() (returns OrmCollection), first(), create(), and touch().

Has one of many

When a parent has many related rows but you want the “latest”, “oldest”, or aggregate winner as a hasOne, chain latestOfMany, oldestOfMany, or ofMany on hasOne (or morphOne):

static relations = {
  latestOrder: (m: User) => m.hasOne(Order, "user_id").latestOfMany(),
  oldestOrder: (m: User) => m.hasOne(Order, "user_id").oldestOfMany(),
  bestOrder: (m: User) => m.hasOne(Order, "user_id").ofMany("total", "max"),
};

latestOfMany / oldestOfMany default the compared column to the related model’s primary key. Soft-deleted related rows are excluded from the of-many subquery when the related model uses soft deletes. These relations eager-load efficiently with with("latestOrder").

Has many through

Reach a distant relation through an intermediate model. Example: a country has many posts through users:

import { Model, type OrmCollection } from "@bunyad/orm";
import Post from "@/Models/Post.ts";
import User from "@/Models/User.ts";

export default class Country extends Model {
  static table = "countries";

  declare posts: OrmCollection<Post>;

  static relations = {
    posts: (m: Country) => m.hasManyThrough(Post, User),
  };
}

Key arguments, in order: related model, through model, then optional firstKey, secondKey, localKey, secondLocalKey:

m.hasManyThrough(
  Post,
  User,
  "country_id", // users.country_id → countries.id
  "user_id", // posts.user_id → users.id
  "id",
  "id",
);

Defaults: firstKey = {parent_singular}_id, secondKey = {through_singular}_id, localKey / secondLocalKey = each model’s primary key.

HasManyThrough supports get() and first(), and eager loads with Country.with("posts").

Many to many / belongs to many

Users and roles (or posts and tags) share a pivot table. Define belongsToMany:

app/Models/User.ts
import { Model, type OrmCollection } from "@bunyad/orm";
import Role from "@/Models/Role.ts";

export default class User extends Model {
  static table = "users";

  declare roles: OrmCollection<Role>;

  static relations = {
    roles: (m: User) => m.belongsToMany(Role),
  };
}

By default the pivot table name is the two singular table names sorted alphabetically and joined with _ (for example role_user). Foreign keys default to {parent_singular}_id and {related_singular}_id. Pass them explicitly when your schema differs:

m.belongsToMany(Role, "role_user", "user_id", "role_id");

Pivot helpers on BelongsToMany:

const user = await User.find(1);
const roles = user!.related("roles");

await roles.attach([1, 2]);
await roles.detach(1);
await roles.detach(); // all
await roles.sync([2, 3]); // detach all, then attach
await roles.syncWithoutDetaching([4]); // attach missing only
await roles.toggle([2, 5]); // attach missing, detach present

const collection = await roles.get();

Extra pivot columns: call withPivot when defining the relation. Those columns (plus the pivot foreign keys) are available on each related model as model.pivot:

roles() {
  return this.belongsToMany(Role).withPivot("active", "created_by");
}

const roles = await user.roles().get();
roles.all()[0]?.pivot?.active;

withPivot applies to lazy get() / first() and to eager with("roles").

For composite / non-incrementing pivot rows as their own model, extend Pivot from @bunyad/orm (incrementing = false, timestamps = false by default).

Polymorphic relationships

Morph one / morph many / morph to

A commentable image or a set of comments may belong to more than one parent type. On the parent:

static relations = {
  image: (m: Post) => m.morphOne(Image, "imageable"),
  comments: (m: Post) => m.morphMany(Comment, "commentable"),
};

That stores imageable_type / imageable_id (or commentable_type / commentable_id). Override type, id, and local key columns when needed:

m.morphMany(Comment, "commentable", "commentable_type", "commentable_id", "id");

On the child, define morphTo:

static relations = {
  commentable: (m: Comment) => m.morphTo("commentable"),
};

MorphTo resolves the parent through the morph map (see below), and supports associate / dissociate:

comment.related("commentable").associate(post);
comment.related("commentable").dissociate();

MorphOne also supports ofMany / latestOfMany / oldestOfMany, same as HasOne.

Morph to many

Polymorphic many-to-many (for example contacts and roles via model_has_roles):

roles() {
  return this.morphToMany(Role, "contact", {
    table: "model_has_roles",
    foreignPivotKey: "model_id",
    relatedPivotKey: "role_id",
    morphTypes: ["contact", "customer", "supplier"],
    pivotTenantKey: "tenant_id",
  });
}

The third argument may be a pivot table string or an options bag (MorphToManyOptions): table, foreignPivotKey, relatedPivotKey, morphTypeColumn, morphTypes, pivotTenantKey, parentTenantKey.

MorphToMany supports the same attach / detach / sync / syncWithoutDetaching / toggle / get API as belongsToMany, scoped by morph type (and optional tenant).

Custom polymorphic types

Register aliases so stored *_type values stay stable:

import { morphMap } from "@bunyad/orm";
import Post from "@/Models/Post.ts";
import Video from "@/Models/Video.ts";

morphMap({
  post: Post,
  video: Video,
});

morphTypeFor uses the alias when present; otherwise the model class name. resolveMorphType throws if the type string is unknown — call morphMap during boot for every type you persist.

Querying relations

Relationship methods vs loaded properties

Goal API
Read after eager load model.posts (declared property)
Relation object / mutate model.related("posts") or model.posts()
Eager on a query User.with("posts", "roles")
Lazy eager on one model await model.load("posts")
Lazy eager if missing await model.loadMissing("posts")

Nested paths work: with("comments.author"), load("posts.tags").

Check whether a relation is already present with relationLoaded("posts").

Querying relationship existence

Keep parents that have (or lack) related rows:

await User.has("posts").get();
await User.doesntHave("posts").get();

await User.whereHas("posts", (q) => {
  q.where("published", true);
}).get();

await User.whereDoesntHave("posts").get();
await User.orWhereHas("posts").get();
await User.orWhereDoesntHave("posts").get();

whereHas / has work for hasMany, hasOne, belongsTo, belongsToMany, and morphToMany. Belongs-to existence uses an inner join on the parent query; has-many / many-to-many use EXISTS (or an IN subquery where that is cheaper).

Shorthand column constraints:

await User.whereRelation("posts", "published", true).get();
await User.orWhereRelation("posts", "title", "like", "%draft%").get();

Constrain by a related model instance:

const author = await User.find(1);
await Post.whereBelongsTo(author).get();
await Post.whereBelongsTo(author, "author").get();
await Post.orWhereBelongsTo(author).get();

Querying morph to relationships

await Comment.whereMorphedTo("commentable", post).get();
await Comment.whereNotMorphedTo("commentable", post).get();
await Comment.orWhereMorphedTo("commentable", post).get();

await Comment.whereHasMorph("commentable", [Post, Video], (q) => {
  q.where("title", "like", "%news%");
}).get();

await Comment.whereDoesntHaveMorph("commentable", Post).get();
await Comment.hasMorph("commentable", [Post]).get();

Combining existence and eager load

withWhereHas runs whereHas and also eager-loads the relation name:

const shops = await Shop.withWhereHas("items", (q) => {
  q.where("featured", 1);
}).get();

Parents are filtered by the constraint; the eager load fetches the named relation for those parents.

const users = await User.withCount("posts").get();
users.first()!.posts_count;

await User.withCount("posts as post_total").get();
await User.withCount({ products: { as: "productsCount" } }).get();

On an instance or OrmCollection:

await user.loadCount("posts");
await users.loadCount("posts");

Other aggregates

await User.withSum("orders", "total").get(); // orders_sum_total
await User.withAvg("orders", "total").get();
await User.withMin("orders", "total").get();
await User.withMax("orders", "total").get();
await User.withExists("posts").get(); // posts_exists
await User.withAggregate("orders", "total", "sum").get();

await user.loadSum("orders", "total");
await user.loadAvg("orders", "total");
await user.loadMin("orders", "total");
await user.loadMax("orders", "total");
await user.loadExists("posts");

Aliases use relation as alias the same way as counts: withSum("orders as revenue", "total").

Eager loading

Basic eager loading

Avoid the N+1 problem by loading relations up front:

const users = await User.with("posts").get();
const users = await User.with("posts", "roles").get();
const users = await User.with(["posts", "roles"]).get();
const users = await User.with({ posts: true, roles: true }).get();

Constrain the related query while eager-loading:

const users = await User.with({
  posts: (q) => q.where("published", true).orderBy("title"),
}).get();

Nested:

await Post.with("comments.author").get();

Eager loading batches related rows with WHERE IN (chunked for large id lists) for belongs-to, has-many, has-one, belongs-to-many, morph relations, has-many-through, and of-many. Constraints from the with({ relation: fn }) form are applied to each related query.

Lazy eager loading

const user = await User.find(1);
await user!.load("posts", "roles");
await user!.loadMissing("posts"); // skip if already loaded

On a collection returned from get():

const users = await User.all();
await users.load("posts");
await users.loadMissing("roles");

Preventing lazy loading

In development you can forbid resolving unloaded relations through related():

import { Model } from "@bunyad/orm";

Model.preventLazyLoading(true);

Accessing related("posts") on an existing model that has not loaded posts throws LazyLoadingViolationException. Eager loads (with / load) are exempt. Turn it off with Model.preventLazyLoading(false).

Create on has-one / has-many / morph

await user.related("posts").create({ title: "First" });
await user.related("phone").create({ number: "555-0100" });
await post.related("comments").create({ body: "Hi" });

The foreign key (and morph type/id for morph relations) is set for you.

Belongs to: associate / dissociate

post.related("author").associate(user);
await post.save();

post.related("author").dissociate();
await post.save();

Many to many: attach, detach, sync, toggle

See Many to many and Morph to many above. Always await these methods.

Touching parent timestamps

List relation names on static touches. When the child is saved or deleted, Bunyad touches those related models’ updated_at (honoring Model.withoutTouching and timestamps = false on the related class):

export default class Comment extends Model {
  static table = "comments";
  static touches = ["post"];

  static relations = {
    post: (m: Comment) => m.belongsTo(Post),
  };
}

Helpers: model.touches("post"), await model.touchOwners(), and relation touch() on belongs-to / has-one / has-many.

See also

  • ORM — models, queries, and persistence
  • ORM Collections — OrmCollection from get() and relations
  • Collections — base Collection methods inherited by OrmCollection