Is it possible to have polymorphic Ember Data Models?

Suppose I have the following model definitions:

// models/person.ts
import Model, { attr, hasMany, belongsTo } from '@ember-data/model';

enum Role {
  STUDENT = "STUDENT",
  TEACHER = "TEACHER",
}

export default class Person extends Model{
  @attr("string") declare name: string
  @attr("role") declare role: Role
  @attr("json") declare data: string
}

// models/student.ts
import Person from "./person"

export default  class Student extends Person {
   
  get clubMemberships(): string[] {
    return []
  }
}

// models/teacher.ts
import Person from "./person"

export default class Teacher extends Person {
   get officeHoursSchedule(): Date[]{
     
     return []
   }
}

Unfortunately, the Back-end I’m consuming only has one table called persons and this table stores all model specific data like office hours schedule and club memberships in a json field.

I want to be able to make a call to the persons table to get all the persons but then depending on the role of each returned person, I want to invoke a different model. Is this possible and is it even a valid ember data pattern?

So I’m thinking of something along these lines.

// adapters/person.ts
import RESTAdapter from '@ember-data/adapter/rest';
import PersonModel from "../models/person"

class Person extends RESTAdapter {
  async findAll(): EmberArray<TeacherModel | StudentModel | PersonModel>  {
    const url = this.buildURL('person', null);
    const res = await this.ajax(url, 'GET');

    const modified = {
      persons: res.persons.map(this.getModelFromRole),
    };
    return modified;
  }

  getModelFromRole(person: PersonModel){
    switch(person.role){
      case Role.STUDENT:
        return // invoke student model here
      case Role.TEACHER:
        return  // invoke teacher model here
      default:
        return  // invoke person model here
    }
  }
}

Hi @canrose, this is definitely possible, and you’re on the right track. My team has a polymorphic model called “classification” which we use in a similar fashion.

As for how to “invoke” the models… there are a number of ways you could approach it, but i think the easiest is probably overriding the modelNameFromPayloadKey method on your Person serializer to return the role rather than the “people” or “person” or “persons” payload key (whatever your backend returns) . Then in each subclass (student/teacher/etc) adapter you’ll want to customize pathForType and possible some of the urlFor* methods to ensure that when you’re making requests with type “student”, for example, that it gets routed back to the /people endpoint and uses an appropriate role filter (if applicable) rather than trying to make a request to /students.

Another thing to note is that if you ever make a relationship to one of these models you can either make it a relationship to, say, “student” or you can make it a polymorphic relationship to “person”:

@belongsTo('student')
student;

@belongsTo('person', { polymorphic: true })
owner;

Hi @dknutsen thank you for your response.

I’m a little confused about your modelNameFromKey suggestion.

Overwriting the key makes sense if I’m making a request to fetch a single record at a time (ie. findRecord)

But if I make a request to return all the persons (ie. findAll), I want to make one single request to the Back-end, and receive a response that is mix of Teachers and Students. So I still receive the payload inside the “persons” key, but by normalizing the response or some other computation based on the role field, the payload will be a mix of Teachers and Student

Or perhaps I am misunderstanding your reply?

Ah I see what you mean, yeah sorry I’m getting wires crossed. I’d probably still approach it via the serializer though, my MO is usually “use the serializer to massage the data how you wish the backend presented it” more or less. Overriding store/adapter methods seems potentially more brittle.

So perhaps you could override normalizeArrayResponse so when it gets a payload such as:

{
  persons: [...]
}

it returns a payload like:

{
  students: [... persons filtered by role==='STUDENT'],
  teachers: [... persons filtered by role==='TEACHER']
}

and ideally you could write this logic without having to hard code roles, e.g.:

  return payload.persons.reduce((hash, person) => {
    const pluralizedModelName = pluralize(person.role.toLowerCase());
    const rolePersons = hash[pluralizedModelName] || [];
    hash[pluralizedModelName] = [...rolePersons, person];
    return hash;
  }, { });

but I’m not 100% if that works and there are probably more efficient/elegant ways to write it I’m just spitballing

1 Like

I ended up doing this:

normalizeArrayResponse(store, primaryModelClass, payload, id, requestType) {
    const res = this._super(store, primaryModelClass, payload, id, requestType);

    for (let i = 0; i < res.data.length; i++) {
      if (res.data[i].attributes.role === Roles.STUDENT) {
        res.data[i].type = 'student';
      }
      if (res.data[i].attributes.role === Roles.TEACHER) {
        res.data[i].type = 'teacher';
      }
    }

    return res;
  },

Not sure it’s the most elegant way but it seems to be invoking the right modal each time.

However, if I want to return all persons inside a person key, looks like I need to do something like this (from your previous responses) which I’m not too excited about.

ie.

let persons = [...this.store.peekAll('teacher'), ...this.store.peekAll('student')];

Either way, thanks for the help!

2 Likes