Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ jobs:
- name: Typecheck example app
run: pnpm -C examples/blog typecheck

# Lint must run before migrations: `node ace migration:run` regenerates
# database/schema.ts without formatting, which would fail prettier
# database/schema.ts is regenerated unformatted by migrations and is
# prettier-ignored, so lint and migration order no longer matters
- name: Lint example app
run: pnpm -C examples/blog lint

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ Models without a resource class serialize automatically, with the type, attribut
| [What is JSON:API?](./docs/what-is-jsonapi.md) | The ideas behind the spec |
| [Reading data](./docs/reading-data.md) | Resources and types, customizing, `include`, sparse fieldsets, sorting, pagination, filtering |
| [Writing data](./docs/writing-data.md) | Create, update and delete from JSON:API documents, relationship endpoints |
| [Polymorphism](./docs/polymorphism.md) | Mixed-type relationships: the database shapes, trade-offs, and single-table inheritance |
| [Links](./docs/links.md) | Route-driven URL generation, API versioning, casing |
| [Errors & negotiation](./docs/errors.md) | Error documents, `handlesErrors()`, media type rules |
| [Low-level building blocks](./docs/low-level.md) | Serializing outside a request: commands, jobs, tests |
Expand Down
2 changes: 1 addition & 1 deletion docs/low-level.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ const registry = await app.container.make(JsonApiRegistry)
const input = deserializeResourceDocument(Article, registry, payload, {
allowClientIds: false,
})
await verifyRelatedExist(Article, input.references) // 404-style JsonApiException if missing
await verifyRelatedExist(Article, input.references, registry) // 404-style JsonApiException if missing

const article = await Article.create(input.attributes)
```
Expand Down
433 changes: 433 additions & 0 deletions docs/polymorphism.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/reading-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,4 +270,4 @@ This is deliberately explicit and per-query. Visibility is a security concern, a

---

Next: [Writing data](./writing-data.md) · [Links](./links.md) · [Errors & negotiation](./errors.md) · [Reference](./reference.md)
Next: [Writing data](./writing-data.md) · [Polymorphism](./polymorphism.md) · [Links](./links.md) · [Errors & negotiation](./errors.md) · [Reference](./reference.md)
2 changes: 1 addition & 1 deletion docs/writing-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,4 @@ Register a subset of these routes with the `relationshipsOnly` option; see [Sele

---

Next: [Links](./links.md) · [Errors & negotiation](./errors.md) · [Reference](./reference.md)
Next: [Polymorphism](./polymorphism.md) · [Links](./links.md) · [Errors & negotiation](./errors.md) · [Reference](./reference.md)
2 changes: 2 additions & 0 deletions examples/blog/.prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
.adonisjs
node_modules
build
# Regenerated by node ace migration:run without formatting
database/schema.ts
8 changes: 8 additions & 0 deletions examples/blog/app/models/article.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { BelongsTo, HasMany, ManyToMany } from '@adonisjs/lucid/types/relat
import User from '#models/user'
import Comment from '#models/comment'
import Tag from '#models/tag'
import Attachment from '#models/attachment'

export default class Article extends ArticleSchema {
@belongsTo(() => User, { foreignKey: 'authorId' })
Expand All @@ -14,4 +15,11 @@ export default class Article extends ArticleSchema {

@manyToMany(() => Tag, { pivotTable: 'article_tags' })
declare tags: ManyToMany<typeof Tag>

@manyToMany(() => Attachment, { pivotTable: 'article_attachments' })
declare attachments: ManyToMany<typeof Attachment>

/** A belongsTo targeting the STI base: the cover is an image or a video. */
@belongsTo(() => Attachment, { foreignKey: 'coverAttachmentId' })
declare cover: BelongsTo<typeof Attachment>
}
47 changes: 47 additions & 0 deletions examples/blog/app/models/attachment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { AttachmentSchema } from '#database/schema'
import { beforeCreate } from '@adonisjs/lucid/orm'
import type { LucidModel, ModelAdapterOptions } from '@adonisjs/lucid/types/model'
import type { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model'

/**
* The base of a single-table inheritance family. Images and videos share
* the attachments table, told apart by the kind column. Application code
* uses the subclasses; relations that can hold either target this base,
* which stays unscoped so it sees the whole family.
*/
export default class Attachment extends AttachmentSchema {
/** The discriminator value each subclass owns; null on the base. */
static readonly attachmentKind: 'image' | 'video' | null = null

/**
* Scopes subclass queries to their discriminator, so Image.query()
* only ever sees image rows. find/findOrFail/first inherit the scope
* because they build on query().
*/
static query<Model extends LucidModel, Result = InstanceType<Model>>(
this: Model,
options?: ModelAdapterOptions
): ModelQueryBuilderContract<Model, Result> {
const query = super.query(options) as unknown as ModelQueryBuilderContract<Model, Result>
const kind = (this as unknown as typeof Attachment).attachmentKind
if (kind) query.where('kind', kind)
return query
}

/** Rows created through a subclass carry its discriminator. */
@beforeCreate()
static assignKind(row: Attachment) {
const kind = (row.constructor as typeof Attachment).attachmentKind
if (kind && !row.kind) row.kind = kind
}
}

export class Image extends Attachment {
static table = 'attachments'
static readonly attachmentKind = 'image' as const
}

export class Video extends Attachment {
static table = 'attachments'
static readonly attachmentKind = 'video' as const
}
18 changes: 18 additions & 0 deletions examples/blog/app/resources/attachment_resource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Attachment from '#models/attachment'
import ImageResource from '#resources/image_resource'
import VideoResource from '#resources/video_resource'
import { JsonApiResource } from '@evoactivity/jsonapi-adonis'

/**
* The base resource of the STI family. resolveResource maps a row to its
* concrete resource by the discriminator; subtypes is the set of types a
* relation targeting Attachment accepts on writes. Registering this base
* registers both subtype resources with it.
*/
export default class AttachmentResource extends JsonApiResource<Attachment> {
static model = () => Attachment
static subtypes = () => [ImageResource, VideoResource]
static resolveResource(row: Attachment) {
return { image: ImageResource, video: VideoResource }[row.kind]
}
}
7 changes: 7 additions & 0 deletions examples/blog/app/resources/image_resource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Image } from '#models/attachment'
import { JsonApiResource } from '@evoactivity/jsonapi-adonis'

export default class ImageResource extends JsonApiResource<Image> {
static type = 'images'
static model = () => Image
}
7 changes: 7 additions & 0 deletions examples/blog/app/resources/video_resource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Video } from '#models/attachment'
import { JsonApiResource } from '@evoactivity/jsonapi-adonis'

export default class VideoResource extends JsonApiResource<Video> {
static type = 'videos'
static model = () => Video
}
3 changes: 3 additions & 0 deletions examples/blog/config/jsonapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export default defineConfig({
resources: [
() => import('#resources/article_resource'),
() => import('#resources/user_resource'),
() => import('#resources/attachment_resource'),
() => import('#resources/image_resource'),
() => import('#resources/video_resource'),
],

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
protected tableName = 'attachments'

async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').notNullable()
table.string('title').notNullable()
// Discriminator for the single-table inheritance family
table.string('kind').notNullable()
table.string('url').notNullable()

table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
})

this.schema.createTable('article_attachments', (table) => {
table.increments('id').notNullable()
table
.integer('article_id')
.unsigned()
.notNullable()
.references('id')
.inTable('articles')
.onDelete('CASCADE')
table
.integer('attachment_id')
.unsigned()
.notNullable()
.references('id')
.inTable('attachments')
.onDelete('CASCADE')
table.unique(['article_id', 'attachment_id'])

table.timestamp('created_at').nullable()
table.timestamp('updated_at').nullable()
})
}

async down() {
this.schema.dropTable('article_attachments')
this.schema.dropTable(this.tableName)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
protected tableName = 'articles'

async up() {
this.schema.alterTable(this.tableName, (table) => {
table
.integer('cover_attachment_id')
.unsigned()
.nullable()
.references('id')
.inTable('attachments')
.onDelete('SET NULL')
})
}

async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('cover_attachment_id')
})
}
}
59 changes: 37 additions & 22 deletions examples/blog/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { DateTime } from 'luxon'

export class ArticleAttachmentSchema extends BaseModel {
static $columns = ['articleId', 'attachmentId', 'createdAt', 'id', 'updatedAt'] as const
$columns = ArticleAttachmentSchema.$columns
@column()
declare articleId: number
@column()
declare attachmentId: number
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime | null
@column({ isPrimary: true })
declare id: number
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}

export class ArticleTagSchema extends BaseModel {
static $columns = ['articleId', 'createdAt', 'id', 'tagId', 'updatedAt'] as const
$columns = ArticleTagSchema.$columns
Expand All @@ -23,12 +38,14 @@ export class ArticleTagSchema extends BaseModel {
}

export class ArticleSchema extends BaseModel {
static $columns = ['authorId', 'body', 'createdAt', 'id', 'title', 'updatedAt'] as const
static $columns = ['authorId', 'body', 'coverAttachmentId', 'createdAt', 'id', 'title', 'updatedAt'] as const
$columns = ArticleSchema.$columns
@column()
declare authorId: number
@column()
declare body: string
@column()
declare coverAttachmentId: number | null
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
Expand All @@ -39,19 +56,25 @@ export class ArticleSchema extends BaseModel {
declare updatedAt: DateTime | null
}

export class AttachmentSchema extends BaseModel {
static $columns = ['createdAt', 'id', 'kind', 'title', 'updatedAt', 'url'] as const
$columns = AttachmentSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column({ isPrimary: true })
declare id: number
@column()
declare kind: string
@column()
declare title: string
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
@column()
declare url: string
}

export class AuthAccessTokenSchema extends BaseModel {
static $columns = [
'abilities',
'createdAt',
'expiresAt',
'hash',
'id',
'lastUsedAt',
'name',
'tokenableId',
'type',
'updatedAt',
] as const
static $columns = ['abilities', 'createdAt', 'expiresAt', 'hash', 'id', 'lastUsedAt', 'name', 'tokenableId', 'type', 'updatedAt'] as const
$columns = AuthAccessTokenSchema.$columns
@column()
declare abilities: string
Expand All @@ -76,15 +99,7 @@ export class AuthAccessTokenSchema extends BaseModel {
}

export class CommentSchema extends BaseModel {
static $columns = [
'articleId',
'authorId',
'body',
'createdAt',
'id',
'published',
'updatedAt',
] as const
static $columns = ['articleId', 'authorId', 'body', 'createdAt', 'id', 'published', 'updatedAt'] as const
$columns = CommentSchema.$columns
@column()
declare articleId: number
Expand Down
Loading