Relationships

Define, load, and write through model relationships using hasOne, hasMany, belongsTo, and manyToMany.

Overview

Relationships are declared on the static relations property of a model. Each key is the relationship name and the value is a Relation created by one of the four helper functions exported from @tekir/db.

All helpers accept a lazy model factory (() => ModelClass) as the first argument. This avoids circular import issues between models that reference each other.

import { BaseModel, hasOne, hasMany, belongsTo, manyToMany, type Relation } from '@tekir/db'

export class User extends BaseModel {
  static table = 'users'

  static relations: Record<string, Relation> = {
    profile:  hasOne(() => Profile),
    posts:    hasMany(() => Post),
    roles:    manyToMany(() => Role, { pivotTable: 'user_roles' })
  }

  declare id: number
  declare name: string
  declare email: string
  declare profile: Profile | null
  declare posts: Post[]
  declare roles: Role[]
}

export class Post extends BaseModel {
  static table = 'posts'

  static relations: Record<string, Relation> = {
    user: belongsTo(() => User)
  }

  declare id: number
  declare title: string
  declare userId: number
  declare user: User | null
}

With @tekir/db-decorators, the same relationships:

// Same models using @tekir/db-decorators
import { BaseModel } from '@tekir/db'
import { HasOne, HasMany, BelongsTo, ManyToMany } from '@tekir/db-decorators'

export class User extends BaseModel {
  static table = 'users'

  @HasOne(() => Profile) declare profile: Profile | null
  @HasMany(() => Post) declare posts: Post[]
  @ManyToMany(() => Role, { pivotTable: 'user_roles' }) declare roles: Role[]

  declare id: number
  declare name: string
  declare email: string
}

export class Post extends BaseModel {
  static table = 'posts'

  @BelongsTo(() => User) declare user: User | null

  declare id: number
  declare title: string
  declare userId: number
}

hasOne

Use hasOne when this model is the parent and the related model holds the foreign key (a one-to-one relationship). By convention the foreign key on the related table is <singularTableName>Id.

import { hasOne } from '@tekir/db'

export class User extends BaseModel {
  static table = 'users'

  static relations: Record<string, Relation> = {
    // Convention: foreignKey defaults to '<singularTable>Id' on the related model
    // For User -> Profile, that is 'userId' on the profiles table.
    profile: hasOne(() => Profile),

    // Override the foreign key explicitly
    address: hasOne(() => Address, { foreignKey: 'ownerId' }),

    // Override both foreign key and local key
    avatar: hasOne(() => Avatar, { foreignKey: 'userId', localKey: 'id' })
  }
}

hasMany

Use hasMany when this model is the parent and the related table holds many rows referencing it. The foreign key convention is the same as hasOne.

import { hasMany } from '@tekir/db'

export class User extends BaseModel {
  static table = 'users'

  static relations: Record<string, Relation> = {
    // Convention: foreignKey defaults to '<singularTable>Id' on the related model
    // For User -> Post, that is 'userId' on the posts table.
    posts: hasMany(() => Post),

    // Override the foreign key
    comments: hasMany(() => Comment, { foreignKey: 'authorId' })
  }
}

belongsTo

Use belongsTo when this model holds the foreign key (the inverse of hasOne or hasMany). By convention the foreign key is the relation name followed by Id (e.g. relation user looks for userId on this table).

import { belongsTo } from '@tekir/db'

export class Post extends BaseModel {
  static table = 'posts'

  static relations: Record<string, Relation> = {
    // Convention: foreignKey defaults to '<relationName>Id'
    // For 'user', that is 'userId' on this table.
    user: belongsTo(() => User),

    // Override the foreign key
    author: belongsTo(() => User, { foreignKey: 'authorId' })
  }
}

manyToMany

Use manyToMany when both models relate to each other through a pivot table. You must create the pivot table yourself via a migration.

import { manyToMany } from '@tekir/db'

export class User extends BaseModel {
  static table = 'users'

  static relations: Record<string, Relation> = {
    roles: manyToMany(() => Role, {
      // The join / pivot table
      pivotTable: 'user_roles',

      // Foreign key on the pivot pointing to THIS model (default: '<singularTable>Id' → 'userId')
      pivotForeignKey: 'userId',

      // Foreign key on the pivot pointing to the RELATED model (default: '<relatedSingular>Id' → 'roleId')
      pivotRelatedForeignKey: 'roleId'
    })
  }
}

// Pivot table schema (create via migration):
// user_roles (userId INTEGER REFERENCES users(id), roleId INTEGER REFERENCES roles(id))
  • pivotTable: name of the join table (required)
  • pivotForeignKey: FK on the pivot pointing to this model (default: <singularTable>Id)
  • pivotRelatedForeignKey: FK on the pivot pointing to the related model (default: <relatedSingular>Id)

Loading Relations

tekir does not auto-join or lazy-load relations. You must explicitly request them. There are three APIs:

instance.load()

Load a relation onto an instance you already have. Mutates the instance in place and returns this.

// Declare relation properties on your model for type safety:
export class User extends BaseModel {
  // ...
  declare posts: Post[]
  declare profile: Profile | null
}

// instance.load(relationName): eager-load onto an existing instance
const user = await User.findOrFail(1)
await user.load('posts')
console.log(user.posts)  // Post[]

// load() with a constraint callback to filter the related query
await user.load('posts', (q) => {
  q.where('status', 'published')
  q.orderBy('createdAt', 'desc')
})

Model.preload()

Static version of load(). Useful when you want to keep loading logic at the class level.

// User.preload(instance, name), static version of load()
const user = await User.findOrFail(1)
await User.preload(user, 'posts')

// Preload multiple relations in sequence
await User.preload(user, 'profile')
await User.preload(user, 'posts')

// Preload with constraint
await User.preload(user, 'posts', (q) => q.where('status', 'published'))

Model.preloadAll()

Preload a relation onto an array of instances. Calls preload() for each instance individually.

// User.preloadAll(instances, name), preload onto an array of instances
// Makes one query per instance (N+1 is a known limitation; batch support is planned)
const users = await User.all()
await User.preloadAll(users, 'posts')

// Each user now has .posts populated
for (const user of users) {
  console.log(user.posts)
}

// Preload with constraint on all instances
await User.preloadAll(users, 'posts', (q) => q.where('status', 'published'))

The related(name) method on an instance returns a small builder with three methods: query(), create(), and createMany(). It automatically scopes all operations to the current instance's foreign key value.

const user = await User.findOrFail(1)

// related(name).query(): returns a Drizzle query builder scoped to this parent
const publishedPosts = await user.related('posts').query()
  .where('status', 'published')
  .all()

// related(name).create(values): insert a related record with the FK pre-filled
const post = await user.related('posts').create({
  title:  'My Post',
  body:   'Hello world',
  status: 'draft'
})
// post.userId === user.id automatically

// related(name).createMany(values): insert multiple related records
const posts = await user.related('posts').createMany([
  { title: 'Post A', body: '...' },
  { title: 'Post B', body: '...' }
])

withDefault

When a hasOne or belongsTo relation finds no matching row, it normally assigns null. Pass withDefault to return a fallback value instead.

// withDefault, return a fallback when no related record is found
// Pass true for an empty object, or an object of default values.

export class User extends BaseModel {
  static relations: Record<string, Relation> = {
    // Returns {} if profile doesn't exist
    profile: hasOne(() => Profile, { withDefault: true }),

    // Returns a populated default if settings don't exist
    settings: hasOne(() => Settings, {
      withDefault: { theme: 'light', notifications: true }
    }),

    // belongsTo also supports withDefault
    category: belongsTo(() => Category, { withDefault: { name: 'Uncategorised' } })
  }
}

touches

The touches static array lists belongsTo relation names whose parent's autoUpdate timestamp columns should be refreshed whenever this model is saved.

// When a Post is saved, its parent User's updatedAt is automatically refreshed.
export class Post extends BaseModel {
  static table = 'posts'
  static touches = ['user']  // relation name that points to the parent

  static relations: Record<string, Relation> = {
    user: belongsTo(() => User)
  }
}

// Now any time a post is created, updated, or saved:
const post = await Post.findOrFail(1)
post.title = 'Updated Title'
await post.save()
// → Also updates users.updatedAt for the post's userId

// This works for any belongsTo relation listed in touches.

Under the hood, after save()completes, tekir reads each listed relation, resolves the parent's primary key, and issues a targeted UPDATE <parentTable> SET updatedAt = ? WHERE id = ?.