Rich text is a polymorphic record

Free preview 4 min to read

Every Rails model that stores plain text keeps it in a column on its own table: a body column, a description column, one per field. ActionText takes a different approach. It stores rich content in a single, shared table called action_text_rich_texts, and links each row back to its owner through a polymorphic association.

The table behind every has_rich_text

Run bin/rails db and describe the table with \d. Its columns, summarized:

\d action_text_rich_texts
Column      | Type     | Notes
------------|----------|-------------------------------
id          | bigint   | primary key
name        | varchar  | the attribute name ("content")
body        | text     | the actual HTML
record_type | varchar  | "Community::Comment"
record_id   | bigint   | the comment's id
created_at  | datetime |
updated_at  | datetime |

Three columns do the work. name is the attribute you declared in the model: when you write has_rich_text :content, the string "content" lands here. record_type and record_id form the polymorphic foreign key, pointing back to whichever model owns this piece of rich text.

Two models, one table

This app has two models that use ActionText. The first is Enrollment::LessonNote:

# app/models/enrollment/lesson_note.rb (trimmed)
module Enrollment
  class LessonNote < ApplicationRecord
    has_rich_text :content
  end
end

The second is Community::Comment:

# app/models/community/comment.rb (trimmed)
module Community
  class Comment < ApplicationRecord
    has_rich_text :content
  end
end

Both call has_rich_text :content, and both store their rich text in the same action_text_rich_texts table. The polymorphic key keeps them apart: a note's rich text has record_type: "Enrollment::LessonNote", and a comment's has record_type: "Community::Comment".

What has_rich_text creates

Calling has_rich_text :content on a model does three things behind the scenes:

  1. Defines a has_one association, has_one :rich_text_content, class_name: "ActionText::RichText", so the model gets a .content accessor that returns an ActionText::RichText record, not a plain string.
  2. Defines a content reader that builds the record when none exists yet, and a content= writer that sets its body, so note.content = "Hello" reads like a regular attribute.
  3. Declares that has_one with autosave: true and dependent: :destroy, so saving the parent saves the rich text and destroying the parent destroys it.

You never touch the action_text_rich_texts table directly. You write note.content = "<p>Bold idea</p>", save the note, and ActionText handles the join.

Reading: ActionText::Content wraps the HTML

Reading comment.content doesn't give you a raw string. It gives you an ActionText::RichText record whose .body attribute is an ActionText::Content object. That object wraps the raw HTML and exposes helpers:

comment = Community::Comment.first
comment.content              # => ActionText::RichText record
comment.content.body         # => ActionText::Content object
comment.content.to_s         # => the HTML, wrapped in <div class="trix-content">
comment.content.to_plain_text # => "Bold idea" (stripped of tags)

The distinction matters for validation. This app's Community::Comment validates length on the plain text:

def content_present_and_within_limit
  text = content.present? ? content.to_plain_text.strip : ""
  return errors.add(:content, :blank) if text.empty? && !content&.body&.attachments&.any?
  errors.add(:content, :too_long, count: MAX_LENGTH) if text.length > MAX_LENGTH
end

Validating to_plain_text.length instead of the raw HTML length is deliberate. A bold pair, <strong></strong>, adds 17 characters of markup that the student never typed and that shouldn't count against their limit.

The migration you already ran

ActionText ships an install generator (bin/rails action_text:install) that creates the migration. This app's version lives at db/migrate/20260830180001_create_action_text_tables.action_text.rb. It creates one table, action_text_rich_texts, with the unique index on [record_type, record_id, name] that enforces one rich text per name per record.

Where this course goes

This first lesson covered the storage layer, the polymorphic table that holds every piece of rich text in the app. The remaining six lessons build upward:

  • Lesson 2: the Trix editor, what it renders, and how rich_text_area connects the form to storage.
  • Lesson 3: sanitization, meaning which HTML tags ActionText keeps and which it strips.
  • Lesson 4: attachments, and how Trix and ActiveStorage work together to embed files.
  • Lesson 5: rendering and styling, displaying rich text safely and making it look right.
  • Lesson 6: swapping the editor, and how this app runs Lexxy in Trix's place on the same storage layer.
  • Lesson 7: putting it all together, the full request lifecycle from keystroke to rendered page.
Lesson 1 of 7 · free

Keep going with lesson 2

Trix: the default editor

Trix renders a custom element that writes a fixed set of HTML tags into a hidden form input.

A free account also gives you the quizzes, your progress and notes, and the discussion.

Type to start searching...