Skip to content

Polymorphic Many-to-Many

Extends the existing polymorphic relation set (morphTo / morphMany / morphOne) with morphToMany and morphedByMany — for the classic case where one model attaches to several different parent types via a shared pivot table.

When to use it

The canonical example: Tags that can be attached to either Posts or Videos, all through a single taggables pivot table.

TableColumns
postsid, title, …
videosid, name, …
tagsid, label
taggables (pivot)tag_id, taggable_type, taggable_id

taggable_type discriminates between 'post' / 'video' / …; taggable_id points back at the corresponding parent row.

Schema

ts
schema.createTable('posts', (t) => {
  t.id()
  t.text('title').notNull()
})
schema.createTable('videos', (t) => {
  t.id()
  t.text('name').notNull()
})
schema.createTable('tags', (t) => {
  t.id()
  t.text('label').notNull()
})
schema.createTable('taggables', (t) => {
  t.text('tag_id').notNull()
  t.text('taggable_type').notNull()
  t.text('taggable_id').notNull()
  t.primary('tag_id, taggable_type, taggable_id')
  // Critical: lookup index for the inverse-side queries
  t.index('taggable_type, taggable_id')
})

Pivot tables for polymorphic many-to-many should not have a deleted_at column — same caveat as plain belongsToMany.

Owning side — morphToMany

The model that "has many tags":

ts
class Post extends BaseModel<PostAttrs> {
  static table = 'posts'
  static relations: Record<string, TRelationDefinition> = {
    tags: {
      type: 'morphToMany',
      model: () => Tag,
      pivot: 'taggables',
      morphName: 'taggable',       // → taggable_type + taggable_id on the pivot
      typeValue: 'post',           // stored in taggable_type
      relatedPivotKey: 'tag_id',   // pivot column that points at Tag
    },
  }
}

class Video extends BaseModel<VideoAttrs> {
  static table = 'videos'
  static relations: Record<string, TRelationDefinition> = {
    tags: {
      type: 'morphToMany',
      model: () => Tag,
      pivot: 'taggables',
      morphName: 'taggable',
      typeValue: 'video',          // only thing that differs from Post.tags
      relatedPivotKey: 'tag_id',
    },
  }
}
OptionDescriptionDefault
modelLazy factory for the related (Tag-side) modelrequired
pivotPivot table namerequired
morphNameLogical name; derives <name>_type + <name>_id on the pivotrequired
typeValueValue written into the pivot's type column for this parentrequired
relatedPivotKeyPivot column pointing at the related modelrequired
typeColumnOverride the pivot's type column<morphName>_type
idColumnOverride the pivot's id column<morphName>_id
localKeyColumn on THIS modelthis.primaryKey
relatedKeyColumn on the related (Tag) modelTag.primaryKey

Inverse side — morphedByMany

The Tag model wants to list "all Posts I'm attached to" and "all Videos I'm attached to" — one relation per parent type:

ts
class Tag extends BaseModel<TagAttrs> {
  static table = 'tags'
  static relations: Record<string, TRelationDefinition> = {
    posts: {
      type: 'morphedByMany',
      model: () => Post,
      pivot: 'taggables',
      morphName: 'taggable',
      typeValue: 'post',
      relatedPivotKey: 'tag_id',
    },
    videos: {
      type: 'morphedByMany',
      model: () => Video,
      pivot: 'taggables',
      morphName: 'taggable',
      typeValue: 'video',
      relatedPivotKey: 'tag_id',
    },
  }
}

morphedByMany takes the same options as morphToMany. The model factory returns the parent class for the relation; the resolver joins parent.id to pivot.taggable_id, filtered by taggable_type = typeValue.

Lazy loading

ts
// Owning side
const post = await Post.findOrFail(postId)
const tags = await post.related('tags').get()

// Inverse — Tag → Posts
const tag = await Tag.findOrFail(tagId)
const posts = await tag.related('posts').get()
const videos = await tag.related('videos').get()

Eager loading

ts
// Tags for many posts in one round-trip
const posts = await Post.query().whereIn('id', ids).with(['tags']).get()
posts[0].relations.tags    // Tag[]

// Posts + Videos for many tags
const tags = await Tag.query().with(['posts', 'videos']).get()
tags[0].relations.posts    // Post[]
tags[0].relations.videos   // Video[]

Each morphToMany / morphedByMany with() results in one batched SELECT joining the pivot and applying WHERE pivot.<type> = ? + WHERE pivot.<idCol> IN (?, ?, …).

Existence queries — has / whereHas

ts
// Posts that have at least one tag
const tagged = await Post.query().has('tags').get()

// Tagless posts
const orphans = await Post.query().doesntHave('tags').get()

// Posts tagged 'workers'
const workers = await Post.query()
  .whereHas('tags', (q) => q.whereEq('label', 'workers'))
  .get()

// Inverse: tags attached to a Hello post
const tags = await Tag.query()
  .whereHas('posts', (q) => q.whereLike('title', 'Hello%'))
  .get()

Without whereHas filters, the EXISTS subquery hits the pivot table alone — no join — which is the cheap path.

Attaching and detaching

The ORM doesn't ship a sugar method for managing the pivot itself — write the inserts/deletes against the pivot directly with db.batch():

ts
// Tag p1 with t1, t2 in one batch
await env.DB.batch([
  env.DB.prepare(
    "INSERT INTO taggables (tag_id, taggable_type, taggable_id) VALUES (?, ?, ?)"
  ).bind('t1', 'post', 'p1'),
  env.DB.prepare(
    "INSERT INTO taggables (tag_id, taggable_type, taggable_id) VALUES (?, ?, ?)"
  ).bind('t2', 'post', 'p1'),
])

// Detach
await env.DB
  .prepare("DELETE FROM taggables WHERE tag_id = ? AND taggable_type = ? AND taggable_id = ?")
  .bind('t1', 'post', 'p1')
  .run()

Sugar methods for attach() / detach() / sync() may land in a later release — track the package issues for progress.

Caveats

  • Pivot must not have deleted_at — soft-delete scope on the related model would produce an ambiguous-column error during JOIN.
  • One morphedByMany per parent type on the related-side model. There's no built-in "give me all parents regardless of type" — model that as multiple relations or use a raw query.
  • Pivot extras (timestamps, role, position…) on the pivot aren't reflected in the relation result. If you need them, query the pivot directly or model it as a regular model with belongsTo to both sides.

Released under the MIT License.