Single Table Inheritance
Joist supports Single Table Inheritance, which allows inheritance/subtyping of entities (like class Dog extends Animal), by automatically mapping multiple logical polymorphic entities (Dog, Cat, and Animal) into a single physical SQL table (animals).
Database Representation
Section titled “Database Representation”For example, lets say we have a Dog entity and a Cat entity, and we want them to both extend the Animal entity.
For single table inheritance, we represent this in Postgres by having a single table: animals.
- The
animalstable has all columns forAnimals,Dogs, orCats - A discriminator column, i.e.
type_id, tells Joist whether a given row is aDogor aCat- We currently require the discriminator field to be an enum column
- Any
Dog-only columns are configured injoist-config.json - Any
Cat-only columns are configured injoist-config.json - Any
Dog- orCat-only columns must be nullable
Thejoist-config.json might look like:
{ "entities": { "Animal": { "fields": { "type": { "stiDiscriminator": { "DOG": "Dog", "CAT": "Cat" } }, "canBark": { "stiType": "Dog" }, "canMeow": { "stiType": "Cat", "notNull": true } }, "tag": "a" }, "DogPack": { "relations": { "leader": { "stiType": "Dog" } }, "tag": "dp" } }}Entity Representation
Section titled “Entity Representation”When joist-codegen sees the above joist-config.json setup, Joist will ensure that the Dog model extends the Animal model, and the generated files will look like:
// in Dog.tsclass Dog extends DogCodegen { // any custom logic}
// in DogCodegen.tsabstract class DogCodegen extends Animal { can_bark: boolean;}
// in Animal.tsclass Animal extends AnimalCodegen { // any custom logic}
// in AnimalCodegen.tsabstract class AnimalCodegen extends BaseEntity { name: string;}And when you load several Animals, Joist will automatically read the type_id column and create the respective subtype:
const [a1, a2] = await em.loadAll(Animal, ["a:1", "a:2"]);// If a1 was saved as a dog, it will be a Dogexpect(a1).toBeInstanceOf(Dog);// if a2 was saved as a cat, it will be a Catexpect(a2).toBeInstanceOf(Cat);SubType Configuration
Section titled “SubType Configuration”Due to STI’s lack of schema-based encoding (see Pros/Cons section below), you may often need to manually configure the joist-config.json to give Joist hints about which subtype a given column/relation should be/belongs to.
For example, instead of the DogPack.leader relation (from the dog_packers.leader_id FK) being typed as Animal (which is the animals table that the leader_id FK points to in the database schema), you want it to be typed as Dog because you know all DogPack leader’s should be Dogs.
These hints in joist-config.json generally look like:
- Adding an
stiDiscriminatormapping to thetypefield that Joist will use to know “which subtype is this?” - Adding
stiType: "Dog"orstiType: "Cat"to any column/field (likecanBarkorcanMeow) in theanimalstable that should be limited to a specific subtype- The value of
"Dog"or"Cat"should match a name in thestiDiscriminatormapping - Currently, we only support a field being in a single subtype
- The value of
- Adding
notNull: trueto any fields that you want Joist to enforce as not null- For example, if you want
canMeowto be required for allCats, you can addnotNull: trueto thecanMeowfield - Without an explicit
notNullset, we assume subtype fields are nullable, which is how they’re represented in the database - See the “Pros/Cons” section later for why this can’t be encoded in the database
- For example, if you want
- On any FKs that point to your base type, add
stiType: "SubType"to indicate that the FK is only valid for the given subtype.- See the
DogPackexample in the above example config
- See the
Tagged Ids
Section titled “Tagged Ids”Subtypes share the same tagged id as the base type.
For example, dog1.id returns a:1 because the Dog’s base type is Animal, and all Animals (regardless of whether they’re Dogs or Cats) use the a tag.
Abstract Base Types
Section titled “Abstract Base Types”If you’d like to enforce that base type is abstract, i.e. that users cannot instantiate Animal, they must instantiate either a Dog or Cat, then you can mark Animal as abstract in the joist-config.json file:
{ "entities": { "Animal": { "tag": "a", "abstract": true } }}You also need to manually update the Animal.ts file to make the class abstract:
export abstract class Animal extends AnimalCodegen {}After this, Joist will enforce that all Animals must be either Dogs or Cats.
For example, if an em.load(Animal, "a:1") finds a row only in the animals table, and no matching row in the dogs or cats table, then the em.load method will fail with an error message.
Pros/Cons to Single Table Inheritance
Section titled “Pros/Cons to Single Table Inheritance”Between Single Table Inheritance (STI) and Class Table Inheritance (CTI), Joist generally recommends using CTI over STI for the following reasons:
-
With CTI, the database schema makes it obvious what the class hierarchy should be.
Given the schema itself already has the per-type fields split out (into separate tables), there is very little configuration for CTI, and instead the generated entities are basically “automatically correct”.
With STI, this schema-based encoding does not exist, so we have to configure items like the discriminator value, and which fields belong to which subtype, in the
joist-config.json. This is doable, but tedious. -
With CTI, the schema is safer, because the subtype-only columns can have not-null constraints.
With STI, if we want
can_barkto be required for allDogs, we cannot use acan_bark boolean NOT NULLin the schema, because theanimalstable will also haveCatrows that fundamentally don’t havecan_barkvalues.Instead, we have to indicate in
joist-config.jsonthat Joist should enforce model-level not-null constraints, which is okay, but not as good as database-level enforcement. -
With CTI, we can have foreign keys point directly to subtypes.
For example, we could have a
DogPackentity with adog_packs.leader_idforeign key that references thedogssubtype table, and so points only toDogs, and is fundamentally unable to point toCats (even at the database level, this is enforced b/c thedogstable will not have any ids ofCatentities).With STI, it’s not possible in the database to represent/enforce that FKs are only valid for a specific subtype (
dog_packs.leader_idcan only point to theanimalstable).
That said, the pro of STI is that you don’t need LEFT OUTER JOINs to load entities (see the CTI docs), b/c all data for all subtypes is a single table.
When to Choose STI/CTI
Section titled “When to Choose STI/CTI”To application code, the STI and CTI approach can look near identical, because both approaches result in the same Dog, Cat, and Animal type hierarchy.
But, generally Joist recommends:
- Use CTI when the polymorphism is an integral part of your domain model, i.e. you have “true”
CatandDogentities as separate concepts you want to model in your domain - Use STI when the polymorphism is for a transient implementation detail, i.e. migrating your
Catmodel to aCatV2model.
And, either way, use both approaches judiciously; in a system of 50-100 entities, you should probably be using CTI/STI only a handful of times.