@holoyan/adonisjs-polymorphic
v0.1.1
Published
Polymorphic relations (morphOne, morphMany, morphTo) for AdonisJS Lucid ORM
Maintainers
Readme
@holoyan/adonisjs-polymorphic
Polymorphic relations for AdonisJS Lucid ORM — morphOne, 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
@MorphMapdecorator used by this package.
Table of Contents
- Installation
- What are polymorphic relations?
- morphOne
- morphMany
- morphTo
- Global morph map with @MorphMap
- Options reference
Installation
npm install @holoyan/adonisjs-polymorphicRegister the service provider by running the configure command:
node ace configure @holoyan/adonisjs-polymorphicThis 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 rowThis 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) // 3Comments 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 commentsAd-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.idCreate 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.idDissociate from parent:
await comment.related('commentable' as any).dissociate()
// comment.commentableType is now null
// comment.commentableId is now nullGlobal 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 | nullPriority 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 |
