A comments table stores commentable_type ('article', 'photo', 'video') alongside commentable_id to point at whichever parent the comment belongs to. What does that design cost, and what alternatives enforce the relationship properly?
answer
- Target table varies per row, so no foreign key is possible
- Orphans on parent delete, no cascade
- Discriminator unvalidated, id meaningless without it
- Arc: nullable FKs + exactly-one-non-null check
- Supertype: shared parent table, one real FK, extensible
basics
~20 sA type-plus-id reference cannot be a foreign key, because the target table varies per row, so nothing prevents orphans, cascades or type strings that do not match a real table. Fix it with an exclusive-arc of nullable foreign keys plus a check, or a shared parent table every commentable type references.
solid answer
~1 minThe pair `(commentable_type, commentable_id)` is a pointer the database cannot follow. A foreign key names one target table at declaration time; here the target depends on a value in the row, so no constraint is possible. Consequences: orphaned comments whenever a parent is deleted, no cascade or restrict behaviour, referential repair jobs instead of guarantees, type strings that drift or get misspelled, and queries that branch per type - fetching a comment with its parent needs either a union of joins or N+1 lookups. Indexing works (`(commentable_type, commentable_id)`), but joins cannot be planned as one relationship. **Alternatives:** 1. **Exclusive arc** - one nullable foreign key column per parent type plus a check that exactly one is non-null. Real constraints, real cascades; the row widens as parent types multiply. 2. **Shared supertype table** - a `commentable(commentable_id)` table; `article`, `photo` and `video` each own a row in it and reference it, and `comment` has a plain foreign key to it. Fully enforced and extensible; costs an extra table, an extra join, and identity allocation through the shared table. 3. **One child table per parent** - `article_comment`, `photo_comment`. Simplest constraints, duplicated structure, and cross-type queries need a union. Choose the arc for few, stable parent types; the supertype when types will keep being added.
code
sql · 9 linesCREATE TABLE comment (
comment_id bigserial PRIMARY KEY,
article_id bigint REFERENCES article(article_id) ON DELETE CASCADE,
photo_id bigint REFERENCES photo(photo_id) ON DELETE CASCADE,
video_id bigint REFERENCES video(video_id) ON DELETE CASCADE,
body text NOT NULL,
CONSTRAINT ck_comment_one_parent
CHECK (num_nonnulls(article_id, photo_id, video_id) = 1)
);go deeper
Say that no foreign key is possible because the target table varies per row, and that orphans follow.
Add the cascade, discriminator-validation and per-type join costs, and describe the exclusive arc concretely.
Compare arc, supertype and table-per-parent with their trade-offs, and describe migrating a populated polymorphic table including orphan triage.
Decide based on how open the parent set is and who owns each type, and set the policy for what integrity guarantee the relationship must carry before new features build on it.
## The pattern A **polymorphic association** stores a discriminator plus an identifier so one child table can attach to several unrelated parents: ``` comment(comment_id, commentable_type, commentable_id, body, ...) ``` where `commentable_type` is `'article'`, `'photo'` or `'video'`. It is popular because it looks economical - one table, one code path - and several ORMs make it a one-line declaration. ## The core defect A foreign key constraint names its target table when it is declared. Here the target is decided by a column value at runtime. There is therefore **no foreign key**, and everything a foreign key provides is gone: - **Orphans are unavoidable.** Delete an article and its comments remain, pointing at an id that no longer resolves. Nothing raises an error. The rows accumulate, consume storage, appear in counts, and eventually surface as a crash or a blank screen. - **No referential actions.** `ON DELETE CASCADE` and `ON DELETE RESTRICT` do not exist for this relationship, so every deleter must remember to clean up children, in the right order, in every code path. - **The discriminator is unvalidated.** Nothing constrains `commentable_type` to real table names unless you add a check listing them - which then needs updating alongside every new type, in a place unrelated to the tables themselves. - **Id collision across types.** `commentable_id = 42` is meaningful only together with the type; a bug that loses or defaults the type silently attaches a comment to the wrong entity, and the values look perfectly valid. ## Query consequences A composite index on `(commentable_type, commentable_id)` makes lookups by parent fast, so the problem is not raw access speed. The problem is joins. Fetching comments with their parents requires either a union of per-type joins or an application loop that fetches each parent by type - the classic N+1. Cross-type reporting ("most-commented items") means unioning the per-type shapes, and each new parent type edits every such query. Optimizers cannot use foreign-key knowledge here either, so estimates for these joins are worse. ## Alternative 1: exclusive arc Give the child one nullable foreign key per possible parent and require exactly one to be populated: ``` comment(comment_id, article_id NULL, photo_id NULL, video_id NULL, body, CHECK (num_nonnulls(article_id, photo_id, video_id) = 1)) ``` Every reference is a real foreign key: orphans are impossible, cascades work per parent, and joins are ordinary left joins that a planner understands. The costs are width and edit cost - each new parent type adds a column, widens the check and touches queries that enumerate the columns. It is the right choice when the number of parent types is small and unlikely to grow, say two to four. ## Alternative 2: shared supertype table Introduce a table representing "things that can be commented on": ``` commentable(commentable_id PK, kind) article(article_id PK REFERENCES commentable(commentable_id), ...) photo (photo_id PK REFERENCES commentable(commentable_id), ...) comment(comment_id PK, commentable_id REFERENCES commentable(commentable_id), ...) ``` Each concrete type takes its identity from the shared table, so a comment's single foreign key is fully enforced and cascades work. Adding a fourth parent type requires no change to `comment` at all - the extensibility the polymorphic version promised, now with integrity. Costs: one extra table, one extra join to reach the concrete parent, identity allocated centrally (a possible insert hotspot at very high rates), and a modest amount of ceremony that has to be maintained - if some path inserts an article without its `commentable` row, the model breaks, so the concrete tables must derive their key from the shared table rather than allocate their own. This is the shape relational modelling has always called a supertype/subtype hierarchy, and it is the general answer when the set of parent types is open. ## Alternative 3: one child table per parent `article_comment`, `photo_comment`, `video_comment`, each with a plain foreign key. Constraints are trivial, per-type queries are the simplest possible, and each table can diverge if the types genuinely need different columns. The cost is duplicated structure and index definitions, and cross-type queries become unions. It suits cases where the child rows are high-volume and per-type behaviour differs anyway. ## Choosing - Few, stable parent types, child rows want one narrow table: **arc**. - Open-ended parent types, integrity required: **supertype**. - High volume per type with diverging attributes: **table per parent**. - Purely non-critical attachments where orphans are harmless and cross-type volume is huge: the polymorphic pair may be tolerable, but only with an explicit orphan-sweeper and a check constraint on the discriminator, and with the decision documented as a deliberate trade. ## Migrating an existing polymorphic table Add the new structure alongside, backfill from the type/id pair, dual-write, move readers per type, then drop the old columns. Backfill is where the accumulated damage shows up: rows whose parent no longer exists and rows with unknown type values have to be triaged before any constraint can be created. That count is also the best argument for the change - it is the number of broken references the design has been silently accepting.
- Why can a foreign key not be declared on the (commentable_type, commentable_id) pair?A foreign key names its target table at declaration time and the engine validates every write against that one table. In a polymorphic association the target is chosen per row by the discriminator value, so there is no single table to name. No standard constraint can express "reference whichever table this column names", which is why integrity has to move into application code or a periodic repair job.
- How do you choose between the exclusive-arc and the shared-supertype alternative?Count the parent types and how likely the set is to grow. An arc is simple and needs no extra join, but every new parent type widens the child table and edits the check constraint, so it suits two to four stable types. The supertype absorbs new types with no change to the child at all, at the cost of an extra table, an extra join to reach the concrete parent, and centralised identity allocation.
saying these in an interview costs you the question
- Claiming an index on (type, id) solves the integrity problem
- Believing an ORM's polymorphic association creates a real database constraint
- Proposing a trigger-based referential check without acknowledging the concurrency and maintenance cost
- Assuming the shared supertype table needs no extra join or identity discipline
- Treating orphaned children as harmless because reads filter them out