iT邦幫忙

2025 iThome 鐵人賽

DAY 5
0
Modern Web

30 天 Rails 新手村:從工作專案學會 Ruby on Rails系列 第 5

Day 4: ActiveRecord 基礎與資料建模 - 理解 Rails 的資料哲學

  • 分享至 

  • xImage
  •  

如果你來自 Node.js 的世界,你可能習慣了 Sequelize 或 TypeORM 那種需要明確定義每個欄位類型的方式。在 Java 的 Spring Boot 中,你會用 JPA 註解來描述實體關係,每個映射都需要精確配置。Python 的 SQLAlchemy 給你兩種選擇:Core 的細粒度控制或 ORM 的高階抽象。今天我們要探討的是 Rails ActiveRecord 如何用「約定優於配置」的思維,將資料建模變成一種優雅的藝術。

還記得第一次接觸 ORM 時的困惑嗎?為什麼要在程式碼中再定義一次資料庫結構?Rails 的答案令人驚喜:你不需要。透過 migrations,Rails 讓資料庫結構成為程式碼的一部分,而模型則專注於行為和關係。這種分離不是技術上的妥協,而是深思熟慮的設計選擇。

今天的學習將為我們的 LMS 系統奠定資料基礎。我們會設計 User、Course、Enrollment 三個核心模型,理解它們如何透過 ActiveRecord 的魔法相互連結。這不只是學習語法,而是理解 Rails 如何將複雜的業務關係轉化為直觀的程式碼。

一、Active Record 模式:Rails 的資料哲學

為什麼選擇 Active Record 而非 Data Mapper?

在軟體架構的世界裡,有兩大 ORM 模式的流派。Data Mapper 追求純粹的分離:領域物件完全不知道資料庫的存在,持久化邏輯獨立存在。這種方式在理論上很美,但實踐中呢?

# Data Mapper 風格(想像的 Rails)
class User
  attr_accessor :name, :email
  
  def full_name
    "#{first_name} #{last_name}"
  end
end

class UserMapper
  def find(id)
    row = DB.execute("SELECT * FROM users WHERE id = ?", id)
    hydrate_user(row)
  end
  
  def save(user)
    DB.execute("INSERT INTO users...", user.attributes)
  end
end

# Active Record 風格(真實的 Rails)
class User < ApplicationRecord
  def full_name
    "#{first_name} #{last_name}"
  end
end

# 就這樣,CRUD 操作已經內建
user = User.find(1)
user.save

Rails 選擇 Active Record 不是因為它在架構上更純粹,而是因為它在實踐中更有效。DHH(Rails 創造者)相信:在 Web 應用的領域中,資料和行為本來就是緊密相關的。當你說「使用者」時,你同時指的是資料(姓名、信箱)和行為(登入、註冊)。將它們分離只會增加認知負擔。

約定的力量:從混亂到秩序

來看看其他框架如何處理模型定義:

// Sequelize (Node.js) - 需要明確定義一切
const User = sequelize.define('User', {
  firstName: {
    type: DataTypes.STRING,
    allowNull: false
  },
  email: {
    type: DataTypes.STRING,
    unique: true,
    validate: {
      isEmail: true
    }
  }
}, {
  tableName: 'users',
  timestamps: true
});
// Hibernate (Java) - 註解地獄
@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private String firstName;
    
    @Column(unique = true)
    @Email
    private String email;
}

而在 Rails 中?

class User < ApplicationRecord
  # 就這樣,Rails 知道:
  # - 對應到 users 表
  # - id 是主鍵
  # - created_at 和 updated_at 自動維護
  # - 所有欄位自動映射為屬性
end

這不是魔法,是約定。Rails 假設你會遵循合理的命名規範,然後為你處理所有瑣碎的配置。當你需要打破約定時,Rails 也提供了方式,但 80% 的情況下,約定就是你想要的

二、Migrations:資料庫的版本控制

理解 Migration 的本質

如果你來自使用 SQL 腳本管理資料庫的背景,Migrations 可能看起來像是多此一舉。為什麼不直接寫 SQL?讓我們透過一個實際場景理解:

# 20240309100000_create_users.rb
class CreateUsers < ActiveRecord::Migration[7.1]
  def change
    create_table :users do |t|
      t.string :email, null: false
      t.string :name
      t.timestamps  # 自動加入 created_at 和 updated_at
    end
    
    add_index :users, :email, unique: true
  end
end

這個 migration 不只是建立表格,它是:

  1. 可逆的:Rails 知道如何 rollback
  2. 資料庫無關的:同樣的程式碼可以在 PostgreSQL、MySQL、SQLite 上執行
  3. 版本控制的:時間戳確保執行順序
  4. 團隊友善的:其他開發者只需要 rails db:migrate

Migration 的進階技巧

在生產環境中,migration 不只是建立表格這麼簡單:

class AddIndexToUsersEmailSafely < ActiveRecord::Migration[7.1]
  disable_ddl_transaction!  # 關閉交易,允許並行建立索引
  
  def up
    # 在生產環境中不鎖表的方式建立索引
    add_index :users, :email, 
              algorithm: :concurrently,
              if_not_exists: true
  end
  
  def down
    remove_index :users, :email, algorithm: :concurrently
  end
end

這個範例展示了生產級的 migration 考量:

  • disable_ddl_transaction! 允許使用 PostgreSQL 的 CONCURRENTLY
  • if_not_exists 避免重複執行時出錯
  • 分離 updown 方法提供更精確的控制

三、設計 LMS 的資料模型

現在讓我們為 LMS 系統設計核心資料模型。這不是隨意的練習,而是真實系統的基礎。

User 模型:多角色的挑戰

在 LMS 中,同一個使用者可能在不同情境下有不同角色:

# migration
class CreateUsers < ActiveRecord::Migration[7.1]
  def change
    create_table :users do |t|
      t.string :email, null: false
      t.string :encrypted_password, null: false
      t.string :first_name
      t.string :last_name
      t.integer :role, default: 0  # 使用 enum
      t.boolean :active, default: true
      
      t.timestamps
    end
    
    add_index :users, :email, unique: true
    add_index :users, :role  # 方便按角色查詢
  end
end

# model
class User < ApplicationRecord
  # 使用 enum 定義角色,自動產生 student?、teacher? 等方法
  enum role: {
    student: 0,
    teacher: 1,
    admin: 2,
    teaching_assistant: 3
  }
  
  # 關聯
  has_many :enrollments, dependent: :destroy
  has_many :enrolled_courses, through: :enrollments, source: :course
  has_many :taught_courses, class_name: 'Course', foreign_key: 'teacher_id'
  
  # 驗證
  validates :email, presence: true, uniqueness: true, 
            format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :first_name, :last_name, presence: true
  
  # 商業邏輯
  def full_name
    "#{first_name} #{last_name}"
  end
  
  def can_enroll_in?(course)
    return false if enrolled_courses.include?(course)
    return false unless active?
    return false if course.full?
    
    true
  end
end

注意這裡的設計決策:

  • 使用 enum 而非字串儲存角色,效能更好且提供便利方法
  • dependent: :destroy 確保刪除使用者時清理相關資料
  • 分離 enrolled_coursestaught_courses,清楚表達不同關係

Course 模型:階層結構的實作

課程有章節,章節有課時,這種階層結構如何優雅表達?

# migrations
class CreateCourses < ActiveRecord::Migration[7.1]
  def change
    create_table :courses do |t|
      t.string :title, null: false
      t.text :description
      t.references :teacher, foreign_key: { to_table: :users }
      t.integer :status, default: 0
      t.integer :max_students, default: 30
      t.datetime :start_date
      t.datetime :end_date
      
      # 快取欄位,避免重複計算
      t.integer :enrollments_count, default: 0
      t.decimal :average_rating, precision: 3, scale: 2
      
      t.timestamps
    end
    
    add_index :courses, :status
    add_index :courses, [:teacher_id, :status]  # 複合索引
  end
end

# model
class Course < ApplicationRecord
  # 狀態管理
  enum status: {
    draft: 0,
    published: 1,
    archived: 2
  }
  
  # 關聯
  belongs_to :teacher, class_name: 'User'
  has_many :enrollments, dependent: :destroy
  has_many :students, through: :enrollments, source: :user
  has_many :chapters, -> { order(:position) }, dependent: :destroy
  has_many :lessons, through: :chapters
  
  # 驗證
  validates :title, presence: true, length: { minimum: 5, maximum: 100 }
  validates :max_students, numericality: { greater_than: 0 }
  validate :dates_are_logical
  
  # 範圍查詢
  scope :active, -> { published.where('start_date <= ? AND end_date >= ?', Date.current, Date.current) }
  scope :upcoming, -> { published.where('start_date > ?', Date.current) }
  
  # Callbacks - 謹慎使用!
  after_update :notify_students_if_published, if: :published_recently?
  
  # 商業邏輯
  def full?
    enrollments_count >= max_students
  end
  
  def enrollment_rate
    return 0 if max_students.zero?
    (enrollments_count.to_f / max_students * 100).round(2)
  end
  
  private
  
  def dates_are_logical
    return unless start_date && end_date
    
    errors.add(:end_date, "必須在開始日期之後") if end_date <= start_date
  end
  
  def published_recently?
    saved_change_to_status? && published?
  end
  
  def notify_students_if_published
    # 使用背景工作,而非直接在 callback 中執行
    CourseNotificationJob.perform_later(self)
  end
end

關鍵設計要點:

  • Counter Cacheenrollments_count 避免每次都要 COUNT
  • 複合索引[:teacher_id, :status] 優化常見查詢
  • Scope 鏈:可組合的查詢片段
  • Callback 謹慎:只用於資料一致性,複雜邏輯用 Service Objects

Enrollment:不只是關聯表

Enrollment 是 User 和 Course 的橋樑,但它本身也是重要的業務實體:

# migration
class CreateEnrollments < ActiveRecord::Migration[7.1]
  def change
    create_table :enrollments do |t|
      t.references :user, null: false, foreign_key: true
      t.references :course, null: false, foreign_key: true
      t.integer :status, default: 0
      t.decimal :progress, precision: 5, scale: 2, default: 0.0
      t.decimal :grade, precision: 5, scale: 2
      t.datetime :completed_at
      
      t.timestamps
    end
    
    # 確保一個學生不能重複註冊同一門課
    add_index :enrollments, [:user_id, :course_id], unique: true
    add_index :enrollments, :status
  end
end

# model
class Enrollment < ApplicationRecord
  enum status: {
    active: 0,
    completed: 1,
    dropped: 2,
    failed: 3
  }
  
  belongs_to :user, counter_cache: true
  belongs_to :course, counter_cache: true
  
  # 追蹤學習進度的關聯
  has_many :lesson_completions, dependent: :destroy
  has_many :assignment_submissions, dependent: :destroy
  
  validates :user_id, uniqueness: { scope: :course_id, 
            message: "已經註冊這門課程" }
  validates :progress, inclusion: { in: 0..100 }
  
  # 狀態轉換的商業邏輯
  def complete!
    return false unless can_complete?
    
    transaction do
      update!(
        status: 'completed',
        completed_at: Time.current,
        progress: 100.0
      )
      
      # 發放證書
      Certificate.create!(user: user, course: course)
    end
  end
  
  def update_progress!
    total_lessons = course.lessons.count
    completed_lessons = lesson_completions.count
    
    new_progress = (completed_lessons.to_f / total_lessons * 100).round(2)
    update!(progress: new_progress)
    
    complete! if new_progress >= 100
  end
  
  private
  
  def can_complete?
    active? && progress >= 100
  end
end

四、Validations 與 Callbacks:平衡的藝術

何時使用 Validations

Validations 確保資料完整性,但過度使用會讓模型臃腫:

class User < ApplicationRecord
  # ✅ 好的驗證:資料完整性
  validates :email, presence: true, uniqueness: true
  validates :age, numericality: { greater_than_or_equal_to: 18 }, 
            if: :requires_age_verification?
  
  # ❌ 壞的驗證:業務邏輯
  # validate :has_sufficient_credits_for_enrollment
  # 這應該在 Service Object 中處理
  
  # 自定義驗證器
  validate :email_from_allowed_domain
  
  private
  
  def email_from_allowed_domain
    return unless email.present?
    
    allowed_domains = ['edu.tw', 'ac.tw']
    domain = email.split('@').last
    
    unless allowed_domains.any? { |d| domain.ends_with?(d) }
      errors.add(:email, '必須使用教育機構信箱')
    end
  end
end

Callbacks 的正確使用時機

Callbacks 強大但危險,應該只用於維護資料一致性:

class Course < ApplicationRecord
  # ✅ 好的 callback:資料正規化
  before_save :normalize_title
  
  # ✅ 好的 callback:維護關聯資料
  after_create :create_default_chapter
  
  # ❌ 壞的 callback:外部依賴
  # after_create :send_notification_email  # 用 Job
  # after_save :update_search_index        # 用 Job
  # before_destroy :check_business_rules   # 用 Service Object
  
  private
  
  def normalize_title
    self.title = title.strip.squeeze(' ') if title.present?
  end
  
  def create_default_chapter
    chapters.create!(
      title: '課程簡介',
      position: 0
    )
  end
end

Callbacks 反模式警示

  • 不要在 callback 中呼叫外部 API
  • 不要在 callback 中執行耗時操作
  • 不要讓 callback 依賴其他 callback 的執行順序
  • 不要在 callback 中修改其他模型

五、查詢優化:N+1 問題的預防與解決

識別 N+1 問題

這是每個 Rails 開發者的必修課:

# ❌ N+1 問題:1 次查詢課程 + N 次查詢老師
courses = Course.published
courses.each do |course|
  puts "#{course.title} by #{course.teacher.name}"
end

# ✅ 解決方案:使用 includes
courses = Course.published.includes(:teacher)
courses.each do |course|
  puts "#{course.title} by #{course.teacher.name}"
end

# 更複雜的情況:多層關聯
enrollments = Enrollment.active
                        .includes(
                          :user,
                          course: [:teacher, :chapters]
                        )

includes vs preload vs eager_load

理解這三者的差異是進階 Rails 開發者的標誌:

# includes:Rails 自動選擇最佳策略
Course.includes(:students)  # 可能用 preload 或 eager_load

# preload:永遠使用分離的查詢
Course.preload(:students)   # 兩個獨立的 SELECT

# eager_load:永遠使用 LEFT JOIN
Course.eager_load(:students)  # 一個 SELECT with LEFT JOIN

# joins:只 JOIN 不載入關聯資料
Course.joins(:students).where(users: { role: 'student' })

使用原則:

  • 預設使用 includes
  • 資料量大時用 preload 避免記憶體爆炸
  • 需要在關聯上加條件時用 eager_load
  • 只需要過濾不需要關聯資料時用 joins

六、實踐練習:建構你的 LMS 資料層

現在讓我們動手實作前面學到的概念。這個練習分為兩個部分:基礎練習讓你熟悉 Rails 的基本操作,進階挑戰則深入探討複雜的資料結構設計。

基礎練習:建立核心資料模型(預計 30 分鐘)

練習目標
這個練習將幫助你理解 Rails 的 migration 機制,體驗 ActiveRecord 關聯的便利性,並實際感受「約定優於配置」帶來的開發效率。我們會建立一個簡化版的 LMS 系統,包含使用者、課程和註冊三個核心模型。

Step 1: 建立新的 Rails API 專案

首先,讓我們從零開始建立專案。選擇 API 模式是因為現代應用大多採用前後端分離架構:

# 建立專案,使用 PostgreSQL 作為資料庫
rails new lms_system --api --database=postgresql
cd lms_system

# 建立資料庫
rails db:create

Step 2: 產生核心模型

Rails 的 generator 是強大的工具,它不只產生模型檔案,還會自動建立對應的 migration:

# 產生 User 模型
# 注意 :uniq 會自動加上唯一索引
rails generate model User \
  email:string:uniq \
  first_name:string \
  last_name:string \
  role:integer \
  active:boolean

# 產生 Course 模型
# references 會自動建立外鍵關聯
rails generate model Course \
  title:string \
  description:text \
  teacher:references \
  status:integer \
  max_students:integer \
  enrollments_count:integer \
  start_date:datetime \
  end_date:datetime

# 產生 Enrollment 模型
rails generate model Enrollment \
  user:references \
  course:references \
  status:integer \
  progress:decimal \
  grade:decimal \
  completed_at:datetime

Step 3: 修改 Migrations 加入必要約束

產生的 migration 只是起點,我們需要加入更多的約束來確保資料完整性。這是許多初學者忽略但極為重要的步驟:

# db/migrate/xxx_create_users.rb
class CreateUsers < ActiveRecord::Migration[7.1]
  def change
    create_table :users do |t|
      t.string :email, null: false  # null: false 確保必填
      t.string :first_name, null: false
      t.string :last_name, null: false
      t.integer :role, default: 0, null: false  # 預設為學生
      t.boolean :active, default: true, null: false
      
      t.timestamps
    end
    
    # 索引對查詢效能至關重要
    add_index :users, :email, unique: true  # 唯一索引防止重複
    add_index :users, :role  # 加速按角色查詢
    add_index :users, :active  # 加速查詢活躍使用者
  end
end

# db/migrate/xxx_create_courses.rb
class CreateCourses < ActiveRecord::Migration[7.1]
  def change
    create_table :courses do |t|
      t.string :title, null: false
      t.text :description
      # foreign_key 確保參照完整性
      t.references :teacher, null: false, foreign_key: { to_table: :users }
      t.integer :status, default: 0, null: false
      t.integer :max_students, default: 30
      t.integer :enrollments_count, default: 0, null: false  # counter cache
      t.datetime :start_date
      t.datetime :end_date
      
      t.timestamps
    end
    
    add_index :courses, :status
    # 複合索引優化多條件查詢
    add_index :courses, [:teacher_id, :status]
    add_index :courses, [:start_date, :end_date]
  end
end

# db/migrate/xxx_create_enrollments.rb
class CreateEnrollments < ActiveRecord::Migration[7.1]
  def change
    create_table :enrollments do |t|
      t.references :user, null: false, foreign_key: true
      t.references :course, null: false, foreign_key: true
      t.integer :status, default: 0, null: false
      # precision 和 scale 定義小數精度
      t.decimal :progress, precision: 5, scale: 2, default: 0.0
      t.decimal :grade, precision: 5, scale: 2
      t.datetime :completed_at
      
      t.timestamps
    end
    
    # 複合唯一索引防止重複註冊
    add_index :enrollments, [:user_id, :course_id], unique: true
    add_index :enrollments, :status
    add_index :enrollments, :completed_at
  end
end

Step 4: 實作完整的模型邏輯

現在讓我們為模型加入關聯、驗證和商業邏輯。這是 ActiveRecord 真正發揮威力的地方:

# app/models/user.rb
class User < ApplicationRecord
  # Enum 讓狀態管理變得優雅
  # Rails 會自動產生 student?、teacher? 等便利方法
  enum role: {
    student: 0,
    teacher: 1,
    admin: 2,
    teaching_assistant: 3
  }
  
  # 關聯定義了模型間的關係
  has_many :enrollments, dependent: :destroy
  has_many :enrolled_courses, 
           through: :enrollments, 
           source: :course
  
  # 反向關聯需要明確指定
  has_many :taught_courses, 
           class_name: 'Course', 
           foreign_key: 'teacher_id',
           dependent: :restrict_with_exception  # 防止誤刪有課程的老師
  
  # 驗證確保資料品質
  validates :email, 
            presence: true, 
            uniqueness: { case_sensitive: false },
            format: { with: URI::MailTo::EMAIL_REGEXP }
  
  validates :first_name, :last_name, 
            presence: true,
            length: { minimum: 2, maximum: 50 }
  
  # Scope 讓查詢更語意化
  scope :active, -> { where(active: true) }
  scope :students, -> { where(role: 'student') }
  scope :teachers, -> { where(role: 'teacher') }
  
  # 商業邏輯方法封裝了領域知識
  def full_name
    "#{first_name} #{last_name}"
  end
  
  def display_name
    "#{full_name} (#{role.humanize})"
  end
  
  def can_teach?
    teacher? || admin?
  end
  
  def enrolled_in?(course)
    enrolled_courses.include?(course)
  end
  
  # 複雜的商業規則
  def can_enroll_in?(course)
    return false if enrolled_in?(course)
    return false unless active?
    return false if course.full?
    return false unless course.published?
    return false if course.past?
    
    true
  end
end

# app/models/course.rb
class Course < ApplicationRecord
  # 狀態機制
  enum status: {
    draft: 0,
    published: 1,
    archived: 2
  }
  
  # 關聯設定
  belongs_to :teacher, 
             class_name: 'User',
             inverse_of: :taught_courses
  
  has_many :enrollments, dependent: :destroy
  has_many :students, 
           through: :enrollments, 
           source: :user
  
  # 多層級的驗證
  validates :title, 
            presence: true,
            length: { minimum: 5, maximum: 100 }
  
  validates :max_students, 
            numericality: { 
              greater_than: 0, 
              less_than_or_equal_to: 1000 
            }
  
  # 自定義驗證器處理複雜邏輯
  validate :validate_dates_logic
  validate :teacher_can_teach
  
  # Callback 只用於資料處理,不做業務邏輯
  before_save :normalize_title
  
  # 豐富的 Scope 提供查詢介面
  scope :active, -> { 
    published.where(
      'start_date <= ? AND end_date >= ?', 
      Date.current, 
      Date.current
    ) 
  }
  
  scope :upcoming, -> { 
    published.where('start_date > ?', Date.current) 
  }
  
  scope :past, -> { 
    where('end_date < ?', Date.current) 
  }
  
  # 查詢方法可以鏈接
  scope :available, -> { active.joins(:enrollments).group('courses.id').having('COUNT(enrollments.id) < courses.max_students') }
  
  # 封裝的商業邏輯
  def full?
    enrollments_count >= max_students
  end
  
  def available_slots
    max_students - enrollments_count
  end
  
  def enrollment_percentage
    return 0.0 if max_students.zero?
    (enrollments_count.to_f / max_students * 100).round(2)
  end
  
  def active?
    published? && 
    start_date <= Date.current && 
    end_date >= Date.current
  end
  
  def past?
    end_date < Date.current
  end
  
  private
  
  def normalize_title
    self.title = title.strip.squeeze(' ') if title.present?
  end
  
  def validate_dates_logic
    return unless start_date && end_date
    
    if end_date <= start_date
      errors.add(:end_date, '必須在開始日期之後')
    end
    
    if start_date < Date.current && new_record?
      errors.add(:start_date, '不能是過去的日期')
    end
  end
  
  def teacher_can_teach
    return unless teacher
    
    unless teacher.can_teach?
      errors.add(:teacher, '必須是教師或管理員')
    end
  end
end

# app/models/enrollment.rb
class Enrollment < ApplicationRecord
  # 註冊狀態管理
  enum status: {
    active: 0,
    completed: 1,
    dropped: 2,
    failed: 3
  }
  
  # counter_cache 自動更新計數
  belongs_to :user, counter_cache: true
  belongs_to :course, counter_cache: true
  
  # 複合唯一驗證
  validates :user_id, 
            uniqueness: { 
              scope: :course_id,
              message: '已經註冊過這門課程'
            }
  
  validates :progress, 
            numericality: { 
              greater_than_or_equal_to: 0,
              less_than_or_equal_to: 100
            }
  
  validates :grade, 
            numericality: { 
              greater_than_or_equal_to: 0,
              less_than_or_equal_to: 100
            },
            allow_nil: true
  
  # Callback 確保業務規則
  before_create :check_course_availability
  after_update :check_completion, if: :saved_change_to_progress?
  
  # 查詢介面
  scope :in_progress, -> { active.where('progress < 100') }
  scope :need_grading, -> { completed.where(grade: nil) }
  scope :recent, -> { order(created_at: :desc) }
  
  # 狀態轉換方法
  def complete!
    return false unless can_complete?
    
    transaction do  # 確保原子性
      self.status = 'completed'
      self.completed_at = Time.current
      self.progress = 100.0
      save!
      
      # 可以在這裡觸發其他動作
      # Certificate.create!(user: user, course: course)
    end
  end
  
  def drop!(reason = nil)
    return false unless active?
    
    self.status = 'dropped'
    save!
  end
  
  def passed?
    completed? && grade.present? && grade >= 60
  end
  
  def grade_letter
    return 'N/A' unless grade.present?
    
    case grade
    when 90..100 then 'A'
    when 80..89 then 'B'
    when 70..79 then 'C'
    when 60..69 then 'D'
    else 'F'
    end
  end
  
  private
  
  def check_course_availability
    if course.full?
      errors.add(:course, '已額滿')
      throw :abort  # 阻止儲存
    end
  end
  
  def check_completion
    complete! if progress >= 100 && active?
  end
  
  def can_complete?
    active? && progress >= 100
  end
end

Step 5: 建立豐富的種子資料

好的種子資料對開發和測試都很重要。讓我們建立一個完整的測試環境:

# db/seeds.rb
puts "🌱 開始建立種子資料..."
puts "⚠️  清除現有資料..."

# 清除現有資料(注意順序,避免外鍵約束錯誤)
Enrollment.destroy_all
Course.destroy_all
User.destroy_all

puts "\n👤 建立使用者..."

# 建立管理員
admin = User.create!(
  email: 'admin@lms.edu.tw',
  first_name: 'System',
  last_name: 'Admin',
  role: 'admin'
)
puts "  ✅ 管理員:#{admin.email}"

# 建立老師
teachers = []
teacher_names = [
  ['王', '小明', 'ming.wang'],
  ['李', '美玲', 'meiling.li'],
  ['張', '大衛', 'david.zhang']
]

teacher_names.each do |last, first, email_prefix|
  teacher = User.create!(
    email: "#{email_prefix}@lms.edu.tw",
    first_name: first,
    last_name: last,
    role: 'teacher'
  )
  teachers << teacher
  puts "  ✅ 老師:#{teacher.full_name}"
end

puts "\n📚 建立課程..."

# 課程資料
course_templates = [
  {
    title: 'Ruby on Rails 從入門到精通',
    description: '學習如何使用 Rails 建構現代化的 Web 應用程式',
    max_students: 30
  },
  {
    title: '資料結構與演算法',
    description: '深入理解電腦科學的核心概念',
    max_students: 25
  },
  {
    title: '前端開發實戰',
    description: 'React、Vue 和現代前端框架完整教學',
    max_students: 35
  },
  {
    title: '資料庫設計與優化',
    description: '從關聯式資料庫到 NoSQL 的完整介紹',
    max_students: 20
  },
  {
    title: 'DevOps 與雲端部署',
    description: 'Docker、Kubernetes 和 CI/CD 實踐',
    max_students: 25
  },
  {
    title: '機器學習入門',
    description: '使用 Python 探索 AI 的世界',
    max_students: 40
  }
]

courses = []
course_templates.each_with_index do |template, index|
  teacher = teachers[index % teachers.length]
  
  # 隨機設定課程時間
  start_offset = rand(-30..60)
  duration = rand(30..120)
  
  course = Course.create!(
    title: template[:title],
    description: template[:description],
    teacher: teacher,
    status: ['draft', 'published', 'published', 'published'].sample,
    max_students: template[:max_students],
    start_date: Date.current + start_offset.days,
    end_date: Date.current + (start_offset + duration).days
  )
  
  courses << course
  status_icon = course.published? ? '🟢' : '🟡'
  puts "  #{status_icon} #{course.title} (#{teacher.full_name})"
end

puts "\n👥 建立學生並註冊課程..."

# 建立更多學生
students = []
30.times do |i|
  student = User.create!(
    email: "student#{i+1}@example.com",
    first_name: ['小華', '小美', '小強', '小芳', '大明', '阿傑'].sample,
    last_name: ['王', '李', '張', '陳', '林', '黃'].sample,
    role: 'student',
    active: [true, true, true, false].sample  # 少數非活躍學生
  )
  students << student
end

puts "  ✅ 建立了 #{students.count} 位學生"

# 模擬真實的註冊模式
published_courses = courses.select(&:published?)

students.each do |student|
  next unless student.active?
  
  # 每個學生註冊 1-3 門課
  num_courses = [1, 1, 2, 2, 2, 3].sample
  selected_courses = published_courses.sample(num_courses)
  
  selected_courses.each do |course|
    next if course.full?
    
    # 模擬不同的學習進度
    progress = case rand(1..10)
               when 1..3 then 0  # 剛開始
               when 4..6 then rand(10..50)  # 進行中
               when 7..8 then rand(51..90)  # 快完成
               when 9..10 then 100  # 已完成
               end
    
    enrollment = Enrollment.create!(
      user: student,
      course: course,
      progress: progress,
      status: progress >= 100 ? 'completed' : 'active'
    )
    
    # 如果完成了,給予成績
    if enrollment.completed?
      enrollment.update!(
        grade: rand(60..100),
        completed_at: rand(1..30).days.ago
      )
    end
  end
end

puts "\n📊 資料統計:"
puts "=" * 50
puts "總使用者數:#{User.count}"
puts "  管理員:#{User.where(role: 'admin').count}"
puts "  老師:#{User.teachers.count}"
puts "  學生:#{User.students.count} (活躍: #{User.students.active.count})"
puts "\n課程總數:#{Course.count}"
puts "  草稿:#{Course.draft.count}"
puts "  已發布:#{Course.published.count}"
puts "  已歸檔:#{Course.archived.count}"
puts "\n註冊總數:#{Enrollment.count}"
puts "  進行中:#{Enrollment.active.count}"
puts "  已完成:#{Enrollment.completed.count}"
puts "  已退選:#{Enrollment.dropped.count}"
puts "\n平均每門課註冊人數:#{(Enrollment.count.to_f / Course.published.count).round(2)}"
puts "平均完成率:#{(Enrollment.completed.count.to_f / Enrollment.count * 100).round(2)}%"
puts "=" * 50
puts "✨ 種子資料建立完成!"

Step 6: 執行並在 Console 中測試

現在讓我們執行 migration、載入種子資料,並在 Rails Console 中測試我們的模型:

# 執行 migration
rails db:migrate

# 載入種子資料
rails db:seed

# 進入 Rails Console
rails console

在 Console 中,我們可以測試各種功能:

# 測試基本查詢
puts "系統中有 #{User.count} 位使用者"
puts "其中 #{User.students.count} 位是學生"

# 測試關聯
teacher = User.teachers.first
puts "\n#{teacher.full_name} 老師的課程:"
teacher.taught_courses.each do |course|
  puts "  - #{course.title} (#{course.students.count}/#{course.max_students} 學生)"
end

# 測試 N+1 問題
puts "\n測試 N+1 問題:"

# 錯誤示範 - 會產生 N+1 查詢
puts "錯誤方式(觀察 SQL 輸出):"
Course.published.limit(3).each do |course|
  puts "#{course.title} - 老師: #{course.teacher.full_name}"
end

# 正確做法 - 使用 includes
puts "\n正確方式(只有兩個查詢):"
Course.published.includes(:teacher).limit(3).each do |course|
  puts "#{course.title} - 老師: #{course.teacher.full_name}"
end

# 測試商業邏輯
student = User.students.active.first
course = Course.published.first

puts "\n測試註冊邏輯:"
if student.can_enroll_in?(course)
  puts "#{student.full_name} 可以註冊 #{course.title}"
else
  puts "#{student.full_name} 不能註冊 #{course.title}"
end

# 測試 Scope 鏈
puts "\n找出可以註冊的活躍課程:"
Course.active.available.each do |course|
  puts "  #{course.title}: #{course.available_slots} 個名額"
end

# 測試複雜查詢
puts "\n完成率最高的課程:"
Course.published
      .joins(:enrollments)
      .where(enrollments: { status: 'completed' })
      .group('courses.id')
      .order('COUNT(enrollments.id) DESC')
      .limit(3)
      .each do |course|
  completed = course.enrollments.completed.count
  total = course.enrollments.count
  rate = (completed.to_f / total * 100).round(2)
  puts "  #{course.title}: #{rate}%"
end

常見問題排解

如果遇到問題,以下是一些常見的錯誤和解決方法:

  1. 資料庫連線錯誤:確認 PostgreSQL 正在執行,並檢查 config/database.yml
  2. Migration 失敗:可能是外鍵約束問題,確認刪除順序正確
  3. Validation 錯誤:檢查種子資料是否符合所有驗證規則
  4. N+1 警告:安裝 Bullet gem 來自動偵測 (gem 'bullet', group: :development)

進階挑戰:實作完整課程結構(預計 1 小時)

挑戰目標
這個進階挑戰將帶你深入理解複雜的資料關係設計。我們要建立課程的完整階層結構,包含章節(Chapter)和課時(Lesson),並實作學習進度追蹤系統。這將展示 Rails 如何優雅地處理多層級的資料關係。

Step 1: 建立章節和課時模型

# 產生 Chapter 模型
rails generate model Chapter \
  course:references \
  title:string \
  description:text \
  position:integer

# 產生 Lesson 模型  
rails generate model Lesson \
  chapter:references \
  title:string \
  content:text \
  content_type:integer \
  duration:integer \
  position:integer

# 產生 LessonCompletion 模型
rails generate model LessonCompletion \
  user:references \
  lesson:references \
  enrollment:references \
  completed_at:datetime

Step 2: 設計精巧的 Migrations

# db/migrate/xxx_create_chapters.rb
class CreateChapters < ActiveRecord::Migration[7.1]
  def change
    create_table :chapters do |t|
      t.references :course, null: false, foreign_key: true
      t.string :title, null: false
      t.text :description
      t.integer :position, null: false, default: 0
      
      t.timestamps
    end
    
    # 複合唯一索引確保同一課程內位置不重複
    add_index :chapters, [:course_id, :position], unique: true
  end
end

# db/migrate/xxx_create_lessons.rb
class CreateLessons < ActiveRecord::Migration[7.1]
  def change
    create_table :lessons do |t|
      t.references :chapter, null: false, foreign_key: true
      t.string :title, null: false
      t.text :content
      t.integer :content_type, default: 0, null: false
      t.integer :duration  # 影片長度(秒)
      t.integer :position, null: false, default: 0
      t.boolean :required, default: true  # 是否必修
      
      t.timestamps
    end
    
    add_index :lessons, [:chapter_id, :position], unique: true
    add_index :lessons, :content_type
    add_index :lessons, :required
  end
end

# db/migrate/xxx_create_lesson_completions.rb
class CreateLessonCompletions < ActiveRecord::Migration[7.1]
  def change
    create_table :lesson_completions do |t|
      t.references :user, null: false, foreign_key: true
      t.references :lesson, null: false, foreign_key: true
      t.references :enrollment, null: false, foreign_key: true
      t.datetime :completed_at, null: false
      t.integer :time_spent  # 花費時間(秒)
      
      t.timestamps
    end
    
    # 防止重複完成
    add_index :lesson_completions, 
              [:user_id, :lesson_id], 
              unique: true,
              name: 'index_lesson_completions_on_user_and_lesson'
  end
end

Step 3: 實作進階模型邏輯

現在讓我們實作這些模型的完整邏輯,包含排序、進度追蹤等複雜功能:

# app/models/chapter.rb
class Chapter < ApplicationRecord
  belongs_to :course
  has_many :lessons, -> { order(:position) }, dependent: :destroy
  
  validates :title, presence: true
  validates :position, 
            uniqueness: { scope: :course_id },
            numericality: { greater_than_or_equal_to: 0 }
  
  # 自動設定位置
  before_create :set_position
  
  # 排序功能
  def move_up
    return if first?
    
    transaction do
      previous_chapter.increment!(:position)
      decrement!(:position)
    end
  end
  
  def move_down
    return if last?
    
    transaction do
      next_chapter.decrement!(:position)
      increment!(:position)
    end
  end
  
  def first?
    position == 0
  end
  
  def last?
    self == course.chapters.order(:position).last
  end
  
  # 統計方法
  def total_duration
    lessons.where(content_type: 'video').sum(:duration)
  end
  
  def completion_rate_for(user)
    total = lessons.required.count
    return 100.0 if total.zero?
    
    completed = lessons.required
                      .joins(:lesson_completions)
                      .where(lesson_completions: { user: user })
                      .count
    
    (completed.to_f / total * 100).round(2)
  end
  
  private
  
  def set_position
    self.position = course.chapters.maximum(:position).to_i + 1
  end
  
  def previous_chapter
    course.chapters.find_by(position: position - 1)
  end
  
  def next_chapter
    course.chapters.find_by(position: position + 1)
  end
end

# app/models/lesson.rb
class Lesson < ApplicationRecord
  belongs_to :chapter
  has_one :course, through: :chapter
  has_many :lesson_completions, dependent: :destroy
  has_many :completed_by_users, 
           through: :lesson_completions, 
           source: :user
  
  # 定義內容類型
  enum content_type: {
    video: 0,
    article: 1,
    quiz: 2,
    assignment: 3,
    external_resource: 4,
    live_session: 5
  }
  
  validates :title, presence: true
  validates :position, 
            uniqueness: { scope: :chapter_id },
            numericality: { greater_than_or_equal_to: 0 }
  
  validates :duration, 
            numericality: { greater_than: 0 },
            if: :video?
  
  # Scopes
  scope :videos, -> { where(content_type: 'video') }
  scope :required, -> { where(required: true) }
  scope :optional, -> { where(required: false) }
  
  before_create :set_position
  
  # 使用者相關方法
  def completed_by?(user)
    lesson_completions.exists?(user: user)
  end
  
  def mark_as_completed_by(user)
    enrollment = user.enrollments.find_by(course: course)
    return false unless enrollment&.active?
    
    completion = lesson_completions.find_or_initialize_by(
      user: user,
      enrollment: enrollment
    )
    
    if completion.new_record?
      completion.completed_at = Time.current
      completion.save!
      
      # 非同步更新進度
      UpdateEnrollmentProgressJob.perform_later(enrollment)
    end
    
    completion
  end
  
  # 統計方法
  def completion_percentage
    total_students = course.students.count
    return 0 if total_students.zero?
    
    completed_count = lesson_completions.count
    (completed_count.to_f / total_students * 100).round(2)
  end
  
  def average_time_spent
    return 0 if lesson_completions.empty?
    
    lesson_completions.average(:time_spent).to_i
  end
  
  # 取得下一個課時
  def next_lesson
    # 同章節的下一個
    next_in_chapter = chapter.lessons
                            .where('position > ?', position)
                            .order(:position)
                            .first
    
    return next_in_chapter if next_in_chapter
    
    # 下一章節的第一個
    next_chapter = course.chapters
                        .where('position > ?', chapter.position)
                        .order(:position)
                        .first
    
    next_chapter&.lessons&.first
  end
  
  def previous_lesson
    # 同章節的上一個
    prev_in_chapter = chapter.lessons
                            .where('position < ?', position)
                            .order(position: :desc)
                            .first
    
    return prev_in_chapter if prev_in_chapter
    
    # 上一章節的最後一個
    prev_chapter = course.chapters
                        .where('position < ?', chapter.position)
                        .order(position: :desc)
                        .first
    
    prev_chapter&.lessons&.last
  end
  
  private
  
  def set_position
    self.position = chapter.lessons.maximum(:position).to_i + 1
  end
end

# app/models/lesson_completion.rb
class LessonCompletion < ApplicationRecord
  belongs_to :user
  belongs_to :lesson
  belongs_to :enrollment
  
  validates :user_id, 
            uniqueness: { 
              scope: :lesson_id,
              message: '已經完成這個課時'
            }
  
  validates :completed_at, presence: true
  
  # 確保使用者有註冊該課程
  validate :user_enrolled_in_course
  
  # 計算花費時間
  before_save :calculate_time_spent
  
  # 完成後更新進度
  after_create :update_enrollment_progress
  after_destroy :update_enrollment_progress
  
  # Scopes
  scope :recent, -> { order(completed_at: :desc) }
  scope :today, -> { where(completed_at: Date.current.all_day) }
  scope :this_week, -> { where(completed_at: Date.current.beginning_of_week..Date.current.end_of_week) }
  
  private
  
  def user_enrolled_in_course
    return if enrollment&.user == user
    
    errors.add(:user, '必須註冊該課程才能完成課時')
  end
  
  def calculate_time_spent
    # 如果有開始時間記錄,計算實際花費時間
    if respond_to?(:started_at) && started_at.present?
      self.time_spent = (completed_at - started_at).to_i
    end
  end
  
  def update_enrollment_progress
    UpdateEnrollmentProgressJob.perform_later(enrollment)
  end
end

# 更新 Enrollment 模型,加入完整的進度追蹤
class Enrollment < ApplicationRecord
  # ... 原有程式碼 ...
  
  has_many :lesson_completions, dependent: :destroy
  has_many :completed_lessons, through: :lesson_completions, source: :lesson
  
  # 更新進度的核心方法
  def update_progress!
    total_required = course.lessons.required.count
    return if total_required.zero?
    
    completed_required = lesson_completions
                        .joins(:lesson)
                        .where(lessons: { required: true })
                        .count
    
    new_progress = (completed_required.to_f / total_required * 100).round(2)
    
    # 更新進度並檢查是否完成
    transaction do
      update!(progress: new_progress)
      
      if new_progress >= 100 && active?
        complete!
      end
    end
  end
  
  # 取得下一個應該學習的課時
  def next_lesson
    completed_lesson_ids = lesson_completions.pluck(:lesson_id)
    
    course.lessons
          .required
          .joins(:chapter)
          .where.not(id: completed_lesson_ids)
          .order('chapters.position', 'lessons.position')
          .first
  end
  
  # 取得某章節的學習進度
  def chapter_progress(chapter)
    total = chapter.lessons.required.count
    return 100.0 if total.zero?
    
    completed = lesson_completions
               .joins(:lesson)
               .where(lessons: { chapter_id: chapter.id, required: true })
               .count
    
    (completed.to_f / total * 100).round(2)
  end
  
  # 產生學習報告
  def learning_report
    {
      enrolled_at: created_at,
      last_activity: lesson_completions.maximum(:completed_at),
      progress: progress,
      total_time_spent: lesson_completions.sum(:time_spent),
      completed_lessons: completed_lessons.count,
      total_lessons: course.lessons.required.count,
      chapters: course.chapters.map do |chapter|
        {
          title: chapter.title,
          progress: chapter_progress(chapter),
          lessons_completed: lesson_completions
                            .joins(:lesson)
                            .where(lessons: { chapter_id: chapter.id })
                            .count,
          total_lessons: chapter.lessons.required.count
        }
      end
    }
  end
  
  # 視覺化的進度顯示
  def progress_bar
    filled = (progress / 10).to_i
    empty = 10 - filled
    "▓" * filled + "░" * empty + " #{progress}%"
  end
end

# 更新 Course 模型加入章節支援
class Course < ApplicationRecord
  # ... 原有程式碼 ...
  
  has_many :chapters, -> { order(:position) }, dependent: :destroy
  has_many :lessons, through: :chapters
  
  # 建立預設課程結構
  def create_default_structure!
    transaction do
      # 建立導論章節
      intro_chapter = chapters.create!(
        title: '課程導論',
        description: '認識本課程的目標與內容',
        position: 0
      )
      
      # 加入導論課時
      intro_chapter.lessons.create!([
        {
          title: '歡迎來到本課程',
          content: '課程介紹與學習目標說明',
          content_type: 'video',
          duration: 300,
          position: 0
        },
        {
          title: '如何有效學習',
          content: '學習策略與平台使用指南',
          content_type: 'article',
          position: 1
        },
        {
          title: '課前評估',
          content: '了解你的起點',
          content_type: 'quiz',
          position: 2,
          required: false
        }
      ])
      
      # 建立主要內容章節
      main_chapter = chapters.create!(
        title: '核心內容',
        description: '課程主要知識點',
        position: 1
      )
      
      # 建立總結章節
      conclusion_chapter = chapters.create!(
        title: '課程總結',
        description: '回顧與展望',
        position: 2
      )
      
      conclusion_chapter.lessons.create!(
        title: '課程回顧與下一步',
        content: '總結所學並規劃未來學習路徑',
        content_type: 'video',
        duration: 600,
        position: 0
      )
    end
  end
  
  # 統計方法
  def total_duration
    lessons.videos.sum(:duration)
  end
  
  def formatted_duration
    total_seconds = total_duration
    hours = total_seconds / 3600
    minutes = (total_seconds % 3600) / 60
    
    if hours > 0
      "#{hours}小時#{minutes}分鐘"
    else
      "#{minutes}分鐘"
    end
  end
  
  def average_completion_rate
    return 0 if enrollments.empty?
    
    enrollments.average(:progress).to_f.round(2)
  end
  
  # 產生課程大綱
  def syllabus
    chapters.map do |chapter|
      {
        title: chapter.title,
        description: chapter.description,
        duration: chapter.total_duration,
        lessons: chapter.lessons.map do |lesson|
          {
            title: lesson.title,
            type: lesson.content_type,
            duration: lesson.duration,
            required: lesson.required?
          }
        end
      }
    end
  end
end

Step 4: 建立豐富的測試資料

# db/seeds_advanced.rb
puts "🏗️  建立進階課程結構..."

# 選擇一個課程來建立完整結構
course = Course.published.first || Course.first
unless course
  puts "❌ 沒有找到課程,請先執行基礎種子資料"
  exit
end

puts "\n📚 為課程《#{course.title}》建立章節結構"

# 定義完整的課程結構
course_structure = [
  {
    title: '第一章:基礎概念',
    description: '建立紮實的基礎知識',
    lessons: [
      { 
        title: '1.1 課程介紹與學習目標',
        type: 'video',
        duration: 600,
        content: '歡迎來到本課程!在這個影片中,我們將介紹...'
      },
      { 
        title: '1.2 開發環境設置',
        type: 'article',
        content: '在開始學習之前,我們需要準備好開發環境...'
      },
      { 
        title: '1.3 第一個 Hello World',
        type: 'video',
        duration: 900,
        content: '讓我們開始寫第一個程式...'
      },
      { 
        title: '1.4 練習:環境測試',
        type: 'assignment',
        content: '請完成以下練習來確認環境設置正確...',
        required: false
      }
    ]
  },
  {
    title: '第二章:核心概念',
    description: '深入理解核心技術',
    lessons: [
      { 
        title: '2.1 基礎理論',
        type: 'video',
        duration: 1200,
        content: '這一章我們要學習的核心概念包括...'
      },
      { 
        title: '2.2 實作範例詳解',
        type: 'video',
        duration: 1500,
        content: '透過實際的範例,我們來理解...'
      },
      { 
        title: '2.3 常見問題與解答',
        type: 'article',
        content: '在學習過程中,你可能會遇到這些問題...'
      },
      { 
        title: '2.4 延伸閱讀資源',
        type: 'external_resource',
        content: '以下是一些推薦的外部資源...',
        required: false
      },
      { 
        title: '2.5 章節測驗',
        type: 'quiz',
        content: '測試你對本章內容的理解...'
      }
    ]
  },
  {
    title: '第三章:進階技巧',
    description: '掌握進階開發技巧',
    lessons: [
      { 
        title: '3.1 效能優化策略',
        type: 'video',
        duration: 1800,
        content: '如何讓你的程式跑得更快...'
      },
      { 
        title: '3.2 設計模式應用',
        type: 'article',
        content: '在實際開發中如何應用設計模式...'
      },
      { 
        title: '3.3 實戰:建構小型專案',
        type: 'assignment',
        content: '運用所學知識完成一個小型專案...'
      }
    ]
  },
  {
    title: '第四章:實戰應用',
    description: '整合所學知識',
    lessons: [
      { 
        title: '4.1 專案架構設計',
        type: 'video',
        duration: 2100,
        content: '如何設計一個可擴展的專案架構...'
      },
      { 
        title: '4.2 團隊協作最佳實踐',
        type: 'article',
        content: '在團隊中如何有效協作...'
      },
      { 
        title: '4.3 部署與維運',
        type: 'video',
        duration: 1500,
        content: '將應用部署到生產環境...'
      },
      { 
        title: '4.4 期末專案',
        type: 'assignment',
        content: '綜合運用所有知識完成期末專案...'
      }
    ]
  },
  {
    title: '第五章:總結與展望',
    description: '回顧與未來學習方向',
    lessons: [
      { 
        title: '5.1 課程總結',
        type: 'video',
        duration: 600,
        content: '回顧我們學習的內容...'
      },
      { 
        title: '5.2 進階學習資源',
        type: 'article',
        content: '如果你想繼續深入學習...'
      },
      { 
        title: '5.3 結業證書',
        type: 'quiz',
        content: '完成最終測驗獲得證書...'
      }
    ]
  }
]

# 建立章節和課時
course_structure.each_with_index do |chapter_data, chapter_index|
  chapter = course.chapters.create!(
    title: chapter_data[:title],
    description: chapter_data[:description],
    position: chapter_index
  )
  
  puts "\n  📖 #{chapter.title}"
  
  chapter_data[:lessons].each_with_index do |lesson_data, lesson_index|
    lesson = chapter.lessons.create!(
      title: lesson_data[:title],
      content: lesson_data[:content],
      content_type: lesson_data[:type],
      duration: lesson_data[:duration],
      required: lesson_data.fetch(:required, true),
      position: lesson_index
    )
    
    icon = case lesson.content_type
           when 'video' then '🎬'
           when 'article' then '📄'
           when 'quiz' then '❓'
           when 'assignment' then '📝'
           else '🔗'
           end
    
    required_mark = lesson.required? ? '' : ' (選修)'
    puts "    #{icon} #{lesson.title}#{required_mark}"
  end
end

puts "\n✅ 課程結構建立完成:"
puts "  章節數:#{course.chapters.count}"
puts "  課時數:#{course.lessons.count}"
puts "  必修課時:#{course.lessons.required.count}"
puts "  選修課時:#{course.lessons.optional.count}"
puts "  總時長:#{course.formatted_duration}"

# 模擬學生的學習進度
puts "\n👨‍🎓 模擬學生學習進度..."

# 取得已註冊這門課的學生
enrollments = course.enrollments.active.includes(:user).limit(10)

if enrollments.empty?
  puts "  ⚠️  沒有學生註冊這門課"
else
  enrollments.each do |enrollment|
    student = enrollment.user
    
    # 根據現有進度決定要完成多少課時
    lessons_to_complete = if enrollment.progress == 0
                           # 新學生,完成前幾個課時
                           course.lessons.required.limit(rand(1..5))
                         else
                           # 有進度的學生,繼續學習
                           completed_ids = enrollment.lesson_completions.pluck(:lesson_id)
                           course.lessons
                                 .required
                                 .where.not(id: completed_ids)
                                 .limit(rand(1..3))
                         end
    
    lessons_to_complete.each do |lesson|
      # 模擬完成課時
      completion = lesson.lesson_completions.create!(
        user: student,
        enrollment: enrollment,
        completed_at: rand(1..30).days.ago,
        time_spent: lesson.video? ? lesson.duration + rand(-60..120) : rand(300..1800)
      )
    end
    
    # 更新進度
    enrollment.update_progress!
    
    puts "  👤 #{student.full_name}:"
    puts "    進度:#{enrollment.progress_bar}"
    puts "    已完成 #{enrollment.lesson_completions.count}/#{course.lessons.required.count} 個必修課時"
    
    # 顯示下一個要學的課時
    if next_lesson = enrollment.next_lesson
      puts "    下一課:#{next_lesson.title}"
    else
      puts "    🎉 已完成所有必修課時!"
    end
  end
end

puts "\n📊 學習統計:"
puts "  平均完成率:#{course.average_completion_rate}%"
puts "  已完成學生:#{course.enrollments.completed.count}"
puts "  學習中學生:#{course.enrollments.active.where('progress > 0').count}"

# 執行:rails runner db/seeds_advanced.rb

Step 5: 測試進階功能

在 Rails Console 中測試我們的進階功能:

# 測試課程結構
course = Course.published.first
puts "\n📚 課程大綱:#{course.title}"
puts "=" * 50

course.chapters.each do |chapter|
  puts "\n#{chapter.title}"
  puts "  #{chapter.description}"
  puts "  課時數:#{chapter.lessons.count}"
  puts "  總時長:#{chapter.total_duration / 60} 分鐘"
  
  chapter.lessons.each do |lesson|
    icon = case lesson.content_type
           when 'video' then '🎬'
           when 'article' then '📄'
           when 'quiz' then '❓'
           when 'assignment' then '📝'
           else '🔗'
           end
    
    required = lesson.required? ? '[必修]' : '[選修]'
    duration = lesson.video? ? " (#{lesson.duration / 60}分鐘)" : ""
    
    puts "    #{icon} #{lesson.title} #{required}#{duration}"
  end
end

# 測試學習進度追蹤
student = User.students.active.first
enrollment = student.enrollments.active.first

if enrollment
  puts "\n📈 學習進度報告"
  puts "=" * 50
  
  report = enrollment.learning_report
  
  puts "學生:#{student.full_name}"
  puts "課程:#{enrollment.course.title}"
  puts "註冊時間:#{report[:enrolled_at].strftime('%Y-%m-%d')}"
  puts "最後學習:#{report[:last_activity]&.strftime('%Y-%m-%d %H:%M') || '尚未開始'}"
  puts "總體進度:#{enrollment.progress_bar}"
  puts "學習時間:#{(report[:total_time_spent] / 3600.0).round(2)} 小時"
  
  puts "\n章節進度:"
  report[:chapters].each do |chapter|
    puts "  #{chapter[:title]}"
    puts "    完成:#{chapter[:lessons_completed]}/#{chapter[:total_lessons]}"
    puts "    進度:#{chapter[:progress]}%"
  end
  
  # 找出下一個要學習的課時
  if next_lesson = enrollment.next_lesson
    puts "\n下一個學習目標:"
    puts "  📍 #{next_lesson.title}"
    puts "     章節:#{next_lesson.chapter.title}"
    puts "     類型:#{next_lesson.content_type}"
  else
    puts "\n🎉 恭喜!已完成所有必修課時"
  end
end

# 測試課時導航
lesson = course.lessons.first
puts "\n🧭 課時導航測試"
puts "當前課時:#{lesson.title}"

if prev = lesson.previous_lesson
  puts "上一課:#{prev.title}"
else
  puts "上一課:(無,這是第一課)"
end

if next_lesson = lesson.next_lesson
  puts "下一課:#{next_lesson.title}"
else
  puts "下一課:(無,這是最後一課)"
end

# 測試完成課時
puts "\n✅ 測試完成課時"
lesson_to_complete = enrollment.next_lesson

if lesson_to_complete
  puts "準備完成:#{lesson_to_complete.title}"
  
  completion = lesson_to_complete.mark_as_completed_by(student)
  
  if completion
    puts "成功!"
    enrollment.reload
    puts "新進度:#{enrollment.progress}%"
  else
    puts "失敗:可能已經完成或未註冊課程"
  end
end

# 測試效能查詢
puts "\n⚡ 測試查詢效能"

# 使用 includes 避免 N+1
puts "載入課程及所有關聯資料..."
courses = Course.includes(
  :teacher,
  chapters: :lessons
).published

courses.each do |c|
  total_lessons = c.chapters.sum { |ch| ch.lessons.count }
  puts "#{c.title}: #{c.chapters.count} 章節, #{total_lessons} 課時"
end

驗證要點與常見問題

  1. 資料完整性檢查

    • 確認每個章節的 position 是唯一的
    • 確認課時完成記錄不重複
    • 確認進度計算正確
  2. 效能考量

    • 使用 includes 預載所有需要的關聯
    • Counter cache 自動更新
    • 使用背景任務處理進度更新
  3. 邊界情況處理

    • 空課程的處理
    • 沒有必修課時時的進度計算
    • 課時順序調整後的導航

透過這個完整的實踐練習,你不只學會了 ActiveRecord 的基本操作,更理解了如何設計和實作複雜的資料模型。記住,好的資料模型是應用成功的基礎,而 Rails 的 ActiveRecord 讓這個過程變得優雅而高效。

七、總結:ActiveRecord 的哲學

今天我們不只學習了 ActiveRecord 的使用,更理解了它背後的設計哲學。Active Record 模式選擇簡單而非純粹,選擇實用而非理論。這個選擇讓 Rails 成為最高效的 Web 開發框架之一。

今天的核心收穫

知識層面

  • 理解 Active Record 模式與 Data Mapper 的權衡
  • 掌握 Migrations 的版本控制機制
  • 學會設計關聯豐富的資料模型

思維層面

  • 體會「約定優於配置」如何簡化開發
  • 理解何時該用 Callbacks,何時該用 Service Objects
  • 認識到資料模型不只是表格,而是業務邏輯的載體

實踐層面

  • 能夠為複雜系統設計資料模型
  • 知道如何預防和解決 N+1 問題
  • 理解如何在效能和便利性之間取得平衡

自我檢核清單

  • [ ] 能解釋為什麼 Rails 選擇 Active Record 而非 Data Mapper
  • [ ] 理解 Migration 的可逆性和版本控制機制
  • [ ] 能夠設計包含多種關聯的資料模型
  • [ ] 知道 includes、preload、eager_load 的使用時機
  • [ ] 理解 Callbacks 的適用場景和反模式
  • [ ] 完成基礎練習:建立 User、Course、Enrollment 模型
  • [ ] 完成進階挑戰:實作章節、課時和進度追蹤系統

明日預告

明天我們將探討 RESTful 路由設計。如果說今天學習的是資料的靜態結構,那明天就是資料的動態流轉。我們會看到 Rails 如何用七個標準動作,優雅地表達所有的資源操作。準備好了嗎?讓我們繼續這段旅程。


上一篇
Day 3: MVC 架構與 API 模式 - 當 View 消失後的架構重構
下一篇
Day 5: RESTful 路由設計 - 用資源思維重新理解 Web API
系列文
30 天 Rails 新手村:從工作專案學會 Ruby on Rails6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言