07. Polymorphic relationships

A review can belong to a publisher or an author. Its relationship identity has two parts: the resource type and ID. A publisher and an author can share an ID without sharing reviews.

The child declares belongsToPolymorphic with allowed target types and the two logical storage fields. Each parent declares a reverse hasMany with via pointing to that child relationship. Insert these blocks in order into the starting script, using a fresh database.

Define the relationships and filters

await api.addResource('publishers', {
  schema: { name: { type: 'string', required: true, indexed: true } },
  relationships: { reviews: { type: 'hasMany', target: 'reviews', via: 'reviewable' } },
  searchSchema: {
    reviewComment: { type: 'string', actualField: 'reviews.comment', filterOperator: 'contains' }
  }
})
await api.addResource('authors', {
  schema: { name: { type: 'string', required: true, indexed: true } },
  relationships: { reviews: { type: 'hasMany', target: 'reviews', via: 'reviewable' } },
  searchSchema: {
    reviewComment: { type: 'string', actualField: 'reviews.comment', filterOperator: 'contains' }
  }
})
await api.addResource('reviews', {
  schema: {
    rating: { type: 'number', required: true, min: 1, max: 5 },
    comment: { type: 'string', required: true, indexed: true },
    reviewable_type: { type: 'string', required: true },
    reviewable_id: { type: 'id', required: true }
  },
  relationships: {
    reviewable: {
      belongsToPolymorphic: {
        types: ['publishers', 'authors'],
        typeField: 'reviewable_type',
        idField: 'reviewable_id'
      }
    }
  },
  searchSchema: {
    reviewableName: {
      type: 'string',
      polymorphicField: 'reviewable',
      targetFields: { publishers: 'name', authors: 'name' },
      filterOperator: 'contains'
    }
  }
})
await api.resources.publishers.createKnexTable()
await api.resources.authors.createKnexTable()
await api.resources.reviews.createKnexTable()

Target name and review comment fields declare indexed: true for cross-table filtering. reviewableName explicitly maps each allowed target type to its search field. The two reverse reviewComment filters search only reviews of the corresponding parent type.

Write plain records and JSON:API documents

const publisher = await api.resources.publishers.post({
  data: { id: '1', name: 'French Books' }
})
const author = await api.resources.authors.post({
  data: { id: '1', name: 'Victor Hugo' }
})
const publisherReview = await api.resources.reviews.post({
  data: {
    rating: 4, comment: 'Reliable publisher',
    reviewable: { _type: 'publishers', id: publisher.id }
  }
})
const authorReviewDocument = await api.resources.reviews.post({
  format: 'jsonapi',
  document: {
    data: {
      type: 'reviews',
      attributes: { rating: 5, comment: 'Excellent author' },
      relationships: {
        reviewable: { data: { type: 'authors', id: author.id } }
      }
    }
  }
})

Plain polymorphic input uses { _type, id } under the relationship alias. JSON:API uses { type, id } under data.relationships.reviewable.data. Do not write reviewable_type or reviewable_id as public attributes. Those logical fields describe storage; the public relationship represents them together. Plain output uses _type to identify the related resource type as well.

Include either direction

const publisherWithReviews = await api.resources.publishers.get({
  id: publisher.id, queryParams: { include: ['reviews'] }
})
const authorWithReviews = await api.resources.authors.get({
  id: author.id, queryParams: { include: ['reviews'] }
})
const reviewsWithTargets = await api.resources.reviews.query({
  queryParams: { include: ['reviewable'], sort: ['id'] }
})
const reviewDocument = await api.resources.reviews.query({
  format: 'jsonapi',
  queryParams: { include: ['reviewable'], sort: ['id'] }
})
console.log('Publisher reviews:', publisherWithReviews.reviews)
console.log('Author reviews:', authorWithReviews.reviews)
console.log('Embedded targets:', reviewsWithTargets.data)
console.log('JSON:API targets:', reviewDocument.included)

Each parent has exactly its own review, despite both having ID 1. Plain included targets contain _type, ID and selected attributes. The JSON:API included array contains both target resources, distinguished by type and id; it must not merge them merely because their IDs match.

Filter forward and backward

const reviewsOfVictor = await api.resources.reviews.query({
  queryParams: { filters: { reviewableName: 'Victor' } }
})
const reliablePublishers = await api.resources.publishers.query({
  queryParams: { filters: { reviewComment: 'Reliable' } }
})
const reliableAuthors = await api.resources.authors.query({
  queryParams: { filters: { reviewComment: 'Reliable' } }
})
console.log('Reviews of Victor:', reviewsOfVictor.data)
console.log('Reliable publishers:', reliablePublishers.data)
console.log('Reliable authors:', reliableAuthors.data)

The forward filter finds the author review. The reverse filter finds French Books; the same filter on authors returns no records. These are declared search contracts, not arbitrary client-selected SQL paths. Filtering does not itself request included resource attributes.

Move a review to a different target type

await api.resources.reviews.patchRelationship({
  id: publisherReview.id,
  relationshipName: 'reviewable',
  relationshipData: { type: 'authors', id: author.id }
})
const publisherLinkage = await api.resources.publishers.getRelationship({
  id: publisher.id, relationshipName: 'reviews'
})
const authorLinkage = await api.resources.authors.getRelationship({
  id: author.id, relationshipName: 'reviews'
})
console.log('Publisher linkage after move:', publisherLinkage.data)
console.log('Author linkage after move:', authorLinkage.data)

The target ID stays 1 but its type changes. The publisher then has no reviews, and the author has both. Relationship-only writes take relationshipData and return undefined. Because this schema makes the type and ID fields required, the relationship cannot be cleared to null. Use a nullable relationship design when reviews are allowed to remain unassigned.

HTTP representation

For a server running the completed example, read either side with:

curl --globoff 'http://localhost:3000/api/authors/1?include=reviews'
curl --globoff 'http://localhost:3000/api/reviews?include=reviewable'
curl --globoff 'http://localhost:3000/api/reviews?filter[reviewableName]=Victor'

HTTP uses JSON:API type/ID linkage. To move a review, PATCH /api/reviews/{id}/relationships/reviewable with a body such as {"data":{"type":"publishers","id":"1"}} and content type application/vnd.api+json. Use returned IDs for an existing database.