Designing a Flexible URL & Content Model

Website Rebuild with Ruby on Rails

Chapter 2 · Designing a Flexible URL & Content Model

Every sibling course in this series reached the same conclusion independently: a plain adjacency list (just a parent_id) makes reads slow at depth, a pure materialized path with no parent_id makes reparenting awkward, a nested-set model makes every insert expensive, and a closure table adds a whole second table for a problem this site's own read-heavy, write-rare traffic doesn't need solved that heavily. The adjacency-list-plus-materialized-path hybrid — both a parent_id foreign key and a precomputed full_path string, kept in sync — wins a fourth independent time here, this time via ActiveRecord.

The Migration

# db/migrate/XXXXXX_create_pages.rb class CreatePages < ActiveRecord::Migration[7.1] def change create_table :pages do |t| t.string :slug, null: false t.string :full_path, null: false t.string :title, null: false t.text :body t.references :parent, foreign_key: { to_table: :pages }, null: true t.timestamps end add_index :pages, :full_path, unique: true end end

t.references :parent, foreign_key: { to_table: :pages } is a single line that both creates the parent_id column and adds a real database foreign-key constraint pointing back at the same table — a self-relationship, in one terse call.

The Model: belongs_to, has_many, and the Deferred Cascade Cost

# app/models/page.rb class Page < ApplicationRecord belongs_to :parent, class_name: 'Page', optional: true has_many :children, class_name: 'Page', foreign_key: :parent_id, dependent: :restrict_with_error before_save :compute_full_path private def compute_full_path self.full_path = parent ? "#{parent.full_path}/#{slug}" : slug end end

The before_save callback recomputes full_path from the current parent and slug every time a page is saved — but only for the page being saved. If a page's parent changes, every one of its own descendants' full_path values goes stale, since nothing here walks the tree. Fixing that is deliberately deferred — the real cost gets paid in Chapter 10, exactly where every sibling course paid the identical cost.

Verifying dependent: :restrict_with_error Against Laravel's restrictOnDelete()

Same category as Django, genuinely different from Laravel
dependent: :restrict_with_error is implemented as an ActiveRecord callback — when page.destroy is called, Rails checks whether children exist and, if so, adds an error and aborts, entirely in application code, before any DELETE statement reaches the database. That puts it in the same general category as Django's own ORM-level PROTECT check. Laravel's restrictOnDelete(), by contrast, is a genuine database schema constraint — enforced by the database engine itself, regardless of how the delete is attempted. A raw SQL DELETE bypassing ActiveRecord entirely would skip Rails' own check; it could never bypass Laravel's.
A real backstop, verified honestly
t.references :parent, foreign_key: ... still adds a genuine foreign-key constraint at the database level. MySQL's own default behavior for a foreign key with no explicit ON DELETE clause is effectively RESTRICT — so even if Rails' own application-level check were somehow bypassed, the database itself would still refuse the delete, just with a raw MySQL error instead of a friendly ActiveRecord validation message. The friendly check and the database's own default behavior work as two real layers, not one.

Four Tree Strategies, One Recurring Winner

StrategyReal Cost at This Site's Scale
Plain adjacency listReading a full path at depth needs one query per level
Pure materialized pathReparenting means no structured foreign key to lean on
Nested setEvery insert renumbers a wide range of sibling rows
Closure tableAn entire second table, for a scale this site doesn't have
Adjacency list + materialized path (chosen)One extra column kept in sync; the cascade cost is real but deferred to Chapter 10

Hands-On Exercises

Exercise 1

Explain the adjacency-list-plus-materialized-path hybrid this course arrives at a fourth independent time, and name the two pieces of information stored on each Page row that make it work.

📄 View solution
Exercise 2

Explain the real technical difference between Rails' dependent: :restrict_with_error and Laravel's restrictOnDelete(), specifically which layer — application or database — each one operates at.

📄 View solution
Exercise 3

Explain why full_path is computed inside a before_save callback rather than being set directly by whatever code creates or updates a Page.

📄 View solution

Chapter 2 Quick Reference

  • t.references :parent, foreign_key: { to_table: :pages } — a self-relationship in one line, with a real FK constraint
  • belongs_to / has_many ... dependent: :restrict_with_error — the self-referencing relationship and its delete guard
  • before_save :compute_full_path — recomputes the moved/created page's own path, not its descendants'
  • Deferred cascade cost — descendant paths go stale on reparent; paid for real in Chapter 10
  • Verified nuance — Rails' delete guard is an application-level callback (like Django's PROTECT), not a database constraint (like Laravel's restrictOnDelete()) — though MySQL's own default FK behavior still backstops it
  • Next chapter: Routing — Rails' Own routes.rb DSL