npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@holoyan/adonisjs-polymorphic

v0.1.1

Published

Polymorphic relations (morphOne, morphMany, morphTo) for AdonisJS Lucid ORM

Readme

@holoyan/adonisjs-polymorphic

Polymorphic relations for AdonisJS Lucid ORMmorphOne, morphMany, and morphTo.

| Package version | AdonisJS version | |---|---| | v0.x | v6 + v7 |


How can you support me?

It's simple — just star this repository. That is enough to keep me motivated to maintain this package.


Related packages

  • @holoyan/adonisjs-permissions — Role & permission system for AdonisJS. Supports multi-model ACL, resource-level permissions, scopes (multi-tenancy), and events.
  • @holoyan/morph-map-js — The framework-agnostic morph map registry that powers the @MorphMap decorator used by this package.

Table of Contents


Installation

npm install @holoyan/adonisjs-polymorphic

Register the service provider by running the configure command:

node ace configure @holoyan/adonisjs-polymorphic

This automatically adds the provider to your adonisrc.ts.


What are polymorphic relations?

A polymorphic relation lets a single child model belong to more than one parent model using a shared pair of columns — a type column and an id column.

images
  id
  url
  imageable_type   ← 'posts' | 'videos'
  imageable_id     ← id of the parent row

This means a single images table can store thumbnails for both posts and videos without needing separate post_images and video_images tables.


morphOne

A parent model has one polymorphic child.

Database migration

await schema.createTable('images', (table) => {
  table.increments('id')
  table.string('url').notNullable()
  table.string('imageable_type').notNullable()
  table.integer('imageable_id').notNullable()
  table.index(['imageable_type', 'imageable_id'])
})

Model setup

// app/models/image.ts
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { morphTo } from '@holoyan/adonisjs-polymorphic'
import Post from '#models/post'
import Video from '#models/video'

export default class Image extends BaseModel {
  @column({ isPrimary: true })
  declare id: number

  @column()
  declare url: string

  @column()
  declare imageableType: string

  @column()
  declare imageableId: number

  @morphTo({ name: 'imageable', morphMap: { posts: () => Post, videos: () => Video } })
  declare imageable: Post | Video | null
}
// app/models/post.ts
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { morphOne } from '@holoyan/adonisjs-polymorphic'
import Image from '#models/image'

export default class Post extends BaseModel {
  @column({ isPrimary: true })
  declare id: number

  @column()
  declare title: string

  @morphOne(() => Image, { name: 'imageable' })
  declare image: Image | null
}
// app/models/video.ts
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { morphOne } from '@holoyan/adonisjs-polymorphic'
import Image from '#models/image'

export default class Video extends BaseModel {
  @column({ isPrimary: true })
  declare id: number

  @column()
  declare title: string

  @morphOne(() => Image, { name: 'imageable' })
  declare image: Image | null
}

Querying

Eager load (preload):

const post = await Post.query()
  .preload('image' as any)
  .firstOrFail()

console.log(post.image)         // Image | null
console.log(post.image?.url)    // 'photo.jpg'

Preload multiple parents at once:

const posts = await Post.query()
  .preload('image' as any) as Post[]

// One SQL query — no N+1
// SELECT * FROM images WHERE imageable_type = 'posts' AND imageable_id IN (1, 2, 3)

Ad-hoc query:

const image = await post.related('image' as any)
  .query()
  .firstOrFail()

Writing

Create a related image:

// imageableType and imageableId are set automatically
const image = await post.related('image' as any)
  .create({ url: 'photo.jpg' })

Save an existing image instance:

const image = new Image()
image.url = 'photo.jpg'

await post.related('image' as any).save(image)

Find or create:

const image = await post.related('image' as any)
  .firstOrCreate({ url: 'photo.jpg' })

Update or create:

const image = await post.related('image' as any)
  .updateOrCreate({ imageableId: post.id }, { url: 'new-photo.jpg' })

morphMany

A parent model has many polymorphic children. Works exactly like morphOne but returns an array.

Database migration

await schema.createTable('comments', (table) => {
  table.increments('id')
  table.text('body').notNullable()
  table.string('commentable_type').nullable()
  table.integer('commentable_id').nullable()
  table.index(['commentable_type', 'commentable_id'])
})

Model setup

// app/models/comment.ts
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { morphTo } from '@holoyan/adonisjs-polymorphic'
import Post from '#models/post'
import Video from '#models/video'

export default class Comment extends BaseModel {
  @column({ isPrimary: true })
  declare id: number

  @column()
  declare body: string

  @column()
  declare commentableType: string

  @column()
  declare commentableId: number

  @morphTo({ name: 'commentable', morphMap: { posts: () => Post, videos: () => Video } })
  declare commentable: Post | Video | null
}
// app/models/post.ts
import { morphOne, morphMany } from '@holoyan/adonisjs-polymorphic'
import Image from '#models/image'
import Comment from '#models/comment'

export default class Post extends BaseModel {
  // ...

  @morphOne(() => Image, { name: 'imageable' })
  declare image: Image | null

  @morphMany(() => Comment, { name: 'commentable' })
  declare comments: Comment[]
}

Querying

Eager load:

const post = await Post.query()
  .preload('comments' as any)
  .firstOrFail()

console.log(post.comments)          // Comment[]
console.log(post.comments.length)   // 3

Comments are isolated by type — a post only gets its own comments, not a video's:

const post = await Post.query().preload('comments' as any).firstOrFail()
const video = await Video.query().preload('comments' as any).firstOrFail()

// Each only sees their own comments

Ad-hoc query with additional constraints:

const recentComments = await post.related('comments' as any)
  .query()
  .orderBy('created_at', 'desc')
  .limit(5)

Writing

Create one:

const comment = await post.related('comments' as any)
  .create({ body: 'Great post!' })

console.log(comment.commentableType)  // 'posts'
console.log(comment.commentableId)    // post.id

Create many:

await post.related('comments' as any).createMany([
  { body: 'First comment' },
  { body: 'Second comment' },
])

Save an existing instance:

const comment = new Comment()
comment.body = 'Hello'

await post.related('comments' as any).save(comment)

Save many:

await post.related('comments' as any).saveMany([comment1, comment2])

morphTo

The child side of a polymorphic relation. A comment belongs to either a Post or a Video.

Querying

Preload the parent:

const comment = await Comment.query()
  .preload('commentable' as any)
  .firstOrFail()

if (comment.commentable instanceof Post) {
  console.log('belongs to a post:', comment.commentable.title)
} else if (comment.commentable instanceof Video) {
  console.log('belongs to a video:', comment.commentable.title)
}

Preload mixed parent types in one query:

// All comments in one query, parents resolved in two queries (posts + videos)
const comments = await Comment.query()
  .preload('commentable' as any) as Comment[]

Ad-hoc query:

const parent = await comment.related('commentable' as any)
  .query()
  .firstOrFail()

Writing

Associate with a parent:

const post = await Post.findOrFail(1)
await comment.related('commentable' as any).associate(post)

// comment.commentableType is now 'posts'
// comment.commentableId is now post.id

Dissociate from parent:

await comment.related('commentable' as any).dissociate()

// comment.commentableType is now null
// comment.commentableId is now null

Global morph map with @MorphMap

When you have many morphTo relations, repeating morphMap: { posts: () => Post, videos: () => Video } on each one gets tedious. Use the @MorphMap decorator from @holoyan/morph-map-js (bundled as a dependency) to register each model once globally.

Setup

Decorate each parent model with its alias:

// app/models/post.ts
import { MorphMap } from '@holoyan/morph-map-js'

@MorphMap('posts')
export default class Post extends BaseModel {
  // ...
}
// app/models/video.ts
import { MorphMap } from '@holoyan/morph-map-js'

@MorphMap('videos')
export default class Video extends BaseModel {
  // ...
}

Now morphTo relations can omit the morphMap option entirely:

// app/models/comment.ts
import { morphTo } from '@holoyan/adonisjs-polymorphic'

export default class Comment extends BaseModel {
  @column()
  declare commentableType: string

  @column()
  declare commentableId: number

  // No morphMap needed — resolved from global registry at query time
  @morphTo({ name: 'commentable' })
  declare commentable: Post | Video | null
}

Adding a new parent type (e.g. Podcast) only requires one change:

@MorphMap('podcasts')
export default class Podcast extends BaseModel {}

All existing morphTo relations pick it up automatically.

Alias vs table name

The @MorphMap alias is also used as the morphValue stored in the type column. This lets you decouple the alias from the table name:

@MorphMap('post')        // alias stored in type column
export default class Post extends BaseModel {
  static table = 'posts' // actual DB table
}
@morphOne(() => Image, { name: 'imageable' })
// morphValue will be 'post' (from @MorphMap), not 'posts' (from table)

Ensuring models are registered at boot time

The global registry is populated when a model file is imported. To guarantee the registry is fully populated before any request, seeder, or test query runs, register your parent models in config/polymorphic.ts (published automatically by node ace configure):

// config/polymorphic.ts
import { defineConfig } from '@holoyan/adonisjs-polymorphic'

export default defineConfig({
  morphModels: [
    () => import('#models/post'),
    () => import('#models/video'),
    () => import('#models/podcast'),  // add new parent models here
  ],
})

The service provider imports all listed models during boot() — before the app serves any request, before seeders run, before tests execute. This completely eliminates any load order concerns.

Every time you add a new model decorated with @MorphMap, add it to this list.

Explicit morphMap always wins

You can always override the registry on a per-relation basis:

@morphTo({
  name: 'commentable',
  morphMap: { posts: () => Post },  // only posts, ignores registry
})
declare commentable: Post | null

Priority order

| What's set | morphTo resolution | morphOne/morphMany morphValue | |---|---|---| | Explicit morphMap option | Used directly | — | | morphValue option | — | Used directly | | @MorphMap on model | Registry fallback | Registry alias | | Nothing | Error at query time | model.table |


Options reference

@morphOne(relatedModel, options)

| Option | Type | Default | Description | |---|---|---|---| | name | string | required | Prefix for the type/id columns on the related model. 'imageable'imageableType + imageableId | | localKey | string | primary key | Attribute on the parent used to match against the id column | | morphValue | string | @MorphMap alias or model.table | Value stored in the type column to identify this parent | | serializeAs | string \| null | relation name | Key used during serialization. null excludes it | | onQuery | (query) => void | — | Hook to add default constraints to every query on this relation |

@morphMany(relatedModel, options)

Same options as @morphOne.

@morphTo(options)

| Option | Type | Default | Description | |---|---|---|---| | name | string | relation name | Prefix used to derive the type/id attribute names on this model | | morphMap | Record<string, () => Model> | global registry | Maps type strings to model factories. Optional when @MorphMap is used | | typeKey | string | ${name}Type | Explicit attribute name for the type column if it doesn't follow the naming convention | | idKey | string | ${name}Id | Explicit attribute name for the id column if it doesn't follow the naming convention | | serializeAs | string \| null | relation name | Key used during serialization. null excludes it | | onQuery | (query) => void | — | Hook to add default constraints |