Designing a Flexible URL & Content Model
Website Rebuild with Astro
Chapter 2 · Designing a Flexible URL & Content Model
Every sibling course reached the same design independently: a real parent_id foreign key plus a precomputed full_path string, kept in sync. This chapter reaches it a fifth time — with Drizzle ORM, a genuinely new TypeScript-first ORM not yet used anywhere on this site, deliberately not a second course built on Prisma.
The Self-Referencing Schema
parentId's own references() call needs a function returning pages.id, not a direct reference — because pages can't be referenced from inside its own definition while TypeScript is still evaluating it. Prisma's own schema.prisma never hits this, since it's a separate declarative DSL file, not real TypeScript being type-checked as it's written. This is a genuine, small cost of Drizzle's own "the schema is real code" design.
Verifying onDelete: 'restrict' — Which Layer, Confirmed
references(..., { onDelete: 'restrict' }) generates a genuine SQL FOREIGN KEY ... ON DELETE RESTRICT clause in the migration itself — a real, database-enforced constraint, exactly like Laravel's own restrictOnDelete(). This is a different layer entirely from Rails' own dependent: :restrict_with_error (an application-level ActiveRecord callback) or Django's own PROTECT (an ORM-level check before deletion) — a raw SQL DELETE bypassing Drizzle entirely would still be refused here, the same guarantee Laravel's own database constraint provides.
No Lifecycle Hooks — A Genuine, Significant Difference
computeFullPath has to be called explicitly by every function that creates or updates a page — there's no before_save-equivalent silently doing it automatically. This is a real, significant architectural difference, not a smaller version of the same idea.
Five ORMs, Compared Honestly
| Django | Laravel | Rails | Astro (Drizzle) | |
|---|---|---|---|---|
| Delete-protection layer | Application-level (PROTECT) | Database-level (restrictOnDelete()) | Application-level (dependent: :restrict_with_error) | Database-level (onDelete: 'restrict') |
| Lifecycle hooks? | Yes — signals | Yes — model events | Yes — callbacks (before_save) | No — explicit helper functions only |
Migrations
drizzle-kit generate diffs schema.ts against the current migration history and writes a new SQL migration file; drizzle-kit migrate applies it.
Hands-On Exercises
Define the self-referencing pages table in Drizzle, including the function-based workaround needed for parentId's own circular reference to pages.id.
📄 View solutionWrite and test computeFullPath(), confirming it correctly builds a nested path for a page with a real parent, and returns the bare slug for a page with no parent.
📄 View solutionConfirm onDelete: 'restrict' is a real database-level constraint by attempting a raw SQL DELETE (bypassing Drizzle entirely) against a page that still has children, and observing the database itself refuse it.
📄 View solutionChapter 2 Quick Reference
references((): AnyMySqlColumn => pages.id, ...)— the function-based workaround for a self-referencing foreign keyonDelete: 'restrict'— a real database-level constraint, matching Laravel's own layer- No lifecycle hooks — Drizzle's own deliberate, minimal design;
full_pathis computed by an explicit helper, not a callback drizzle-kit generate/migrate— Drizzle's own migration workflow- Next chapter: Routing — Astro's Own Catch-All, Now Database-Backed