Skip to main content

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 an id primary key, with the usual auto increment behavior, and any fields that are common to all Animals
  • The dogs table also has an id primary key, but it does not auto-increment, and is instead a foreign key to animals.id, and has any fields that are unique to the Dog entity
  • The cats table also has an id primary key, again it does not auto-increment, and is instead a foreign key to animals.id, and has any fields that are unique to the Cat 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 Animals, 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 Animals (regardless of whether they're Dogs or Cats) 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 Dogs or Cats, 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 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.

What about Single Table Inheritance?

An alternative to Class Table Inheritance (CTI) is Single Table Inheritance (STI), where Dogs and Cats 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:

  1. 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 the id foreign keys to discover the extends 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 says animals.can_bark is only applicable for the Dog subtype, and animals.can_meow is only applicable for the Cat subtype.

  2. 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 all Dogs, because there will be Cat rows in the animals table that just fundamentally cannot have a can_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.

  3. With CTI, we can have foreign keys directly to subtypes.

    For example, we could have a DogCollar entity that had a dog_collars.dog_id foreign key that points only to dogs, and is fundamentally unable to point to Cats.

    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 JOINs 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.