Class Table Inheritance
Joist supports Class Table Inheritance, which allows inheritance/subtyping of entities (like class Dog extends Animal
), by automatically mapping single/logical polymorphic entities across separate per-subtype/physical SQL tables.
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 class table inheritance, we represent this in Postgres by having three separate tables: animals
, dogs
, and cats
.
- The
animals
table has anid
primary key, with the usual auto increment behavior, and any fields that are common to allAnimal
s - The
dogs
table also has anid
primary key, but it does not auto-increment, and is instead a foreign key toanimals.id
, and has any fields that are unique to theDog
entity - The
cats
table also has anid
primary key, again it does not auto-increment, and is instead a foreign key toanimals.id
, and has any fields that are unique to theCat
entity
If you're using Joist's migration-utils
, this might look like:
createEntityTable(b, "animals", {
name: "text",
})
createSubTable(b, "animals", "dogs", {
can_bark: "boolean",
});
createSubTable(b, "animals", "cats", {
can_meow: "boolean",
});
Entity Representation
When joist-codegen
sees that dogs.id
is actually a foreign key to animals.id
, Joist will ensure that the Dog
model extends the Animal
model.
Note that because of the codegen entities, which contain the getter/setter boilerplate, it will actually end up looking like:
// in AnimalCodegen.ts
abstract class AnimalCodegen extends BaseEntity {
name: string;
}
// in Animal.ts
class Animal extends AnimalCodegen {
// any custom logic
}
// in DogCodegen.ts
abstract class DogCodegen extends Animal {
can_bark: boolean;
}
// in Dog.ts
class Dog extends DogCodegen {
// any custom logic
}
And when you load several Animal
s, Joist will automatically probe the dogs
and cats
tables (by using a LEFT OUTER JOIN
to each subtype table) and create entities of the right type:
const [a1, a2] = await em.loadAll(Animal, ["a:1", "a:2"]);
// If a1 was saved as a dog, it will be a Dog
expect(a1).toBeInstanceOf(Dog);
// if a2 was saved as a cit, it will be a Cat
expect(a2).toBeInstanceOf(Cat);
Similarly, if you save a Dog
entity, Joist will automatically split the entity's data across both tables, putting the name
into animals
and can_bark
into dogs
, with the same id
value for both rows:
const dog = em.create(Dog, {
name: "doge",
can_bark: true,
});
// Generates both `INSERT INTO animals ...` and
// `INSERT INTO dogs ...`.
await em.flush();
Tagged Ids
Currently, 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 Animal
s (regardless of whether they're Dog
s or Cat
s) use the a
tag.
Joist might someday support per-subtype tags, but it would be complicated b/c we don't always know the subtype of an id; e.g. if there is a pet_owners.animal_id
foreign key, and it points to either Dog
s or Cat
s, when loading the row PetOwner:123
it's impossible to know if the tagged id its animal_id
value should be d:1
or c:1
without first probing the dogs
and cats
tables, which takes extra SQL calls to do. So for now it's simplest/most straightforward to just share the same tag across the subtypes.
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 Animal
s must be either Dog
s or Cat
s.
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.
What about Single Table Inheritance?
An alternative to Class Table Inheritance (CTI) is Single Table Inheritance (STI), where Dog
s and Cat
s don't have their own tables, but have their subtype-specific fields stored directly on the animals
table (e.g. both animals.can_bark
and animals.can_meow
would be columns directly in the animals
table even though, for dogs, the can_meow
column is not applicable).
Joist currently does not support STI, generally because CTI has several pros:
With CTI, the database schema makes it obvious what the class hierarchy should be.
Given how schema-driven Joist's
joist-codegen
is, it's very convenient to have the per-type fields already split out (into separate tables) and then to use theid
foreign keys to discover theextends
relationships.With STI, this sort of "obvious" visibility does not exist, and we'd have to encode the type hierarchy in
joist-config.json
, i.e. some sort of mapping that saysanimals.can_bark
is only applicable for theDog
subtype, andanimals.can_meow
is only applicable for theCat
subtype.With CTI, the schema is safer, because the subtype-only columns can have not-null constraints.
With STI, even if
can_bark
is required for allDog
s, because there will beCat
rows in theanimals
table that just fundamentally cannot have acan_bark
value, the column must be nullable.Which is fine if it's already nullable, but if you wanted it to be non-null, now we have to encode in
joist-config.json
that it is technically required, and rely on Joist's runtime code to enforce it.With CTI, we can have foreign keys directly to subtypes.
For example, we could have a
DogCollar
entity that had adog_collars.dog_id
foreign key that points only todogs
, and is fundamentally unable to point toCat
s.With STI, it's not possible in the database to represent/enforce that FKs are only valid for a specific subtype.
That said, the pro of STI is that you don't need LEFT OUTER JOIN
s to load entities, b/c all data for all subtypes is a single table, so Joist could likely support STI someday, it just does not currently.
But Isn't Inheritance Bad Design?
Yes, inheritance can be abused, particularly with deep inheritance hierarchies and/or just "bad design".
But when you have a situation that fits it well, it can be an appropriate/valid way to design a schema, at your own choice/discretion.
If it helps, inheritance can also be thought of Abstract Data Types, which as a design pattern is generally considered a modern/"good" approach for accurately & type-safely modeling values that have different fields based on their current kind/type.
ADTs also focus just on the per-kind/per-type data attributes, and less on the polymorphic behavior of methods encoded/implemented within the class hierarchy which was the focus of traditional OO-based inheritance.
When using inheritance with Joist entities, you can pick whichever approach you prefer: either more "just data" ADT-ish inheritance or "implementation-hiding methods" OO-ish inheritance.