Skip to content

Relationships API Reference

Relationship helpers are functions imported from @orphnet/d1-eloquent that return query wrappers. They are called inside model methods or inside static eagerLoaders for eager loading.

ts
import { belongsTo, hasMany, hasOne, belongsToMany } from '@orphnet/d1-eloquent'

Eager Loading Setup

To use .with(['relation']) on a query, define the relations in static eagerLoaders on the model. Each key is a relation name; each value is a function that receives the loaded model instance and returns the relationship wrapper.

ts
class Post extends BaseModel<PostAttrs> {
  static table = 'posts'

  static eagerLoaders = {
    comments: (post: Post) => hasMany(Comment, 'post_id').on(post),
    author:   (post: Post) => belongsTo(User, 'user_id').on(post),
  }
}

// Usage
const posts = await Post.query()
  .with(['comments', 'author'])
  .get(env.DB)

// posts[0].relations.comments  → Comment[]
// posts[0].relations.author    → User | null

Unknown relation names throw at runtime

Calling .with(['unknownRelation']) throws a runtime error that names the unknown relation. Verify relation names match the keys in static eagerLoaders.

See BaseModel — eagerLoaders for the configuration reference.

belongsTo(relatedCtor, foreignKey, localKey?)

ts
belongsTo(
  relatedCtor: TModelCtor,
  foreignKey: string,
  localKey?: string
): { first(db: D1Database): Promise<TRelated | null> }

Fetches the parent/owner record. The foreignKey is on the current model; localKey on the related model (defaults to its primaryKey).

Use case: A Comment belongs to a Post (via comment.post_id). A Post belongs to a User (via post.user_id).

ts
class Comment extends BaseModel<CommentAttrs> {
  static table = 'comments'

  async post(db: D1Database) {
    return belongsTo(Post, 'post_id').on(this).first(db)
  }

  async author(db: D1Database) {
    return belongsTo(User, 'user_id').on(this).first(db)
  }
}

const post = await comment.post(env.DB)
// SELECT * FROM posts WHERE id = comment.post_id LIMIT 1

hasMany(relatedCtor, foreignKey, localKey?)

ts
hasMany(
  relatedCtor: TModelCtor,
  foreignKey: string,
  localKey?: string
): {
  get(db: D1Database): Promise<TRelated[]>
  first(db: D1Database): Promise<TRelated | null>
}

Fetches a collection of child records. The foreignKey is on the related model; localKey on the current model (defaults to its primaryKey).

Use case: A User has many Post rows (via post.user_id). A Post has many Comment rows (via comment.post_id).

ts
class User extends BaseModel<UserAttrs> {
  static table = 'users'

  async posts(db: D1Database) {
    return hasMany(Post, 'user_id').on(this).get(db)
  }
}

const posts = await user.posts(env.DB)
// SELECT * FROM posts WHERE user_id = user.id

hasOne(relatedCtor, foreignKey, localKey?)

ts
hasOne(
  relatedCtor: TModelCtor,
  foreignKey: string,
  localKey?: string
): { first(db: D1Database): Promise<TRelated | null> }

Fetches a single child record. Semantically identical to hasMany — the distinction is purely conventional: callers use .first() to signal a one-to-one relationship.

Use case: A User has one Profile (via profile.user_id).

ts
class User extends BaseModel<UserAttrs> {
  static table = 'users'

  async profile(db: D1Database) {
    return hasOne(Profile, 'user_id').on(this).first(db)
  }
}

const profile = await user.profile(env.DB)
// SELECT * FROM profiles WHERE user_id = user.id LIMIT 1

belongsToMany(opts)

ts
belongsToMany(opts: {
  pivot: string
  foreignKey: string
  relatedKey: string
  related: TModelCtor
}): { get(db: D1Database): Promise<TRelated[]> }

Fetches a many-to-many collection via a pivot table. Executes a single JOIN query — no N+1.

OptionDescription
pivotName of the pivot table (e.g. 'post_tags')
foreignKeyColumn in the pivot that references the current model's PK (e.g. 'post_id')
relatedKeyColumn in the pivot that references the related model's PK (e.g. 'tag_id')
relatedThe related model constructor (e.g. Tag)

Use case: A Post belongs to many Tag rows via the post_tags pivot table.

ts
class Post extends BaseModel<PostAttrs> {
  static table = 'posts'

  async tags(db: D1Database) {
    return belongsToMany({
      pivot: 'post_tags',
      foreignKey: 'post_id',
      relatedKey: 'tag_id',
      related: Tag,
    }).on(this).get(db)
  }
}

const tags = await post.tags(env.DB)
// SELECT tags.* FROM tags
//   INNER JOIN post_tags ON post_tags.tag_id = tags.id
//   WHERE post_tags.post_id = post.id

Pivot tables must not have a deleted_at column

When the current model has softDeletes = true, the soft-delete scope appends an unqualified deleted_at IS NULL condition. If the pivot table also has a deleted_at column, the JOIN will produce an "ambiguous column name" SQLite error. Keep pivot tables free of deleted_at columns.

Released under the MIT License.