Rails Data Layer Deep Dive: Models, Migrations, Active Record, Queries, and Seeding

1. The Rails MVC Architecture and the Role of the Model

Ruby on Rails adopts the Model-View-Controller (MVC) architectural pattern to organize web applications:

  • Model: Encapsulates domain data, business rules, and persistence logic.
  • View: Renders user interfaces and presents data.
  • Controller: Coordinates requests, interacts with models, and selects views.
    This class focuses on the model layer, which bridges Ruby code and the relational database through Active Record. In Rails, each model class typically maps to one database table. Rows become Ruby objects whose attributes correspond to columns, and methods on those objects handle persistence.
    Throughout the explanations, a loan management domain grounds the examples: tables for students, loans, staff_members, units, and item_models. Loans tie borrowers to specific physical units of an item model; staff members may authorize or process loan events.

2. From Relational Design to Rails Schema and Models

A database schema represents tables, columns, indexes, and constraints. Rails mirrors that schema in code via:

  • Migrations: Version-controlled Ruby classes that declare schema changes.
  • Models: Ruby classes (in app/models) that map to tables.
  • schema.rb: An auto-generated snapshot of the current database state.
    Rails conventions minimize configuration:
  • Model class names: Singular, CamelCase (e.g., Loan, ItemModel).
  • Table names: Plural, snake_case (e.g., loans, item_models).
  • Primary key: id (auto-increment integer by default).
  • Foreign keys: singular_model_name_id (e.g., unit_id, item_model_id).
  • Timestamps: created_at and updated_at, auto-managed when t.timestamps is present.
    Inflection matters. Rails relies on English pluralization rules to map between class and table names. Naming models/tables in Spanish often breaks inflection (e.g., “liquidacion” → “liquidacions”). Use English names to ensure reliable mappings, especially for irregular nouns (person → people).

3. Active Record: Pattern and Rails Library

Active Record is both a design pattern and the Rails ORM:

  • Pattern: An object wraps a single row, exposes columns as attributes, and knows how to persist itself (save, update, destroy).
  • Rails library: Provides querying, persistence, validations, associations, callbacks, and more.
    Contrast: Data Mapper (used by other frameworks) keeps persistence separate from domain objects via repositories/mappers. Rails chose Active Record to colocate data access with domain logic for convention-driven productivity.
    In Rails 5+, models inherit from ApplicationRecord, which itself inherits from ActiveRecord::Base. ApplicationRecord serves as a common ancestor to centralize shared behavior.
    Example minimal model:
# app/models/loan.rb
class Loan < ApplicationRecord
end

Even with an empty class, Rails introspects the database table to expose attributes and enable CRUD operations.

4. Managing Schema with Migrations

4.1 Why Migrations Exist

Directly editing the database (e.g., CREATE TABLE in a SQL client) undermines collaboration: changes are not version-controlled, hard to reproduce, and error-prone. Migrations encode schema evolution in code, preserving history and enabling consistent setup across environments.

4.2 Generating and Structuring Migrations

Create migrations with generators:

bin/rails generate migration CreateLoans

Rails places timestamped files under db/migrate, using the timestamp as the migration version (e.g., 20260902083033_create_loans.rb). A typical migration:

class CreateLoans < ActiveRecord::Migration[8.0]
def change
create_table :loans do |t|
t.references :unit
t.references :student
t.references :staff_member
t.date :due_on
t.datetime :returned_at
t.timestamps
end
end
end

Notes:

  • t.references adds a foreign key column (e.g., unit_id) and index by convention.
  • t.timestamps creates created_at and updated_at.
  • Use app-local binaries (bin/rails) to ensure the Rails version from the Gemfile is used.

4.3 Data Types and Column Options

Active Record types map to database types (e.g., PostgreSQL):

  • :string → character varying
  • :text → text
  • :integer → integer
  • :decimal/:numeric → numeric
  • :date → date
  • :datetime → timestamp
  • :boolean → boolean
  • :references → bigint foreign key + index
    Common options:
  • null: false enforces NOT NULL
  • default: value sets default
  • index: true adds an index
    Example:
t.date :due_on, null: false, index: true

4.4 Migration Workflow

  • Generate: bin/rails generate migration ...
  • Edit: Implement change/up/down methods.
  • Migrate: bin/rails db:migrate
    After execution, a migration is “history.” Do not modify an already-run migration; instead, create a new migration to evolve the schema. Rails tracks executed versions in schema_migrations (storing only “up” versions).
    The db/schema.rb file is a snapshot of the current schema, regenerated on each migration. Do not edit it manually.

4.5 Reversible vs. Irreversible Migrations

Most operations in change are reversible (create_table ↔ drop_table; add_column ↔ remove_column). For non-reversible steps or complex data transformations, define explicit up and down. Example:

class UpdateLoanState < ActiveRecord::Migration[8.0]
def up
execute("UPDATE loans SET state = 'returned' WHERE returned_at IS NOT NULL")
end
def down
raise ActiveRecord::IrreversibleMigration
end
end

4.6 Rolling Back

Use bin/rails db:rollback to revert the last migration. Rails removes its version from schema_migrations and updates schema.rb. Prefer creating forward migrations for corrections once changes are shared; rollback is safest immediately after a local mistake.

4.7 Common Database Tasks

  • bin/rails db:create: create DB from config/database.yml
  • bin/rails db:migrate: run pending migrations
  • bin/rails db:migrate:status: show migration states
  • bin/rails db:rollback: revert last migration
  • bin/rails db:seed: run seed script
  • bin/rails db:prepare: create if missing, then migrate; ensures an up-to-date DB in dev/test

5. Primary Keys, Conventions, and Schema Synchronization

A primary key uniquely identifies each row. Rails defaults to an auto-increment integer id:

  • Unique and indexed.
  • Not explicitly needed in migrations unless opting out.
  • Deleted ids are not reused; the next insert advances the sequence.
    You may add unique columns (e.g., a national id like RUT), but Rails still uses id as the canonical primary key unless configured otherwise (e.g., UUIDs).
    Models and tables must stay conceptually in sync: when you create a table, define the corresponding model so the app can query and persist data in idiomatic Ruby.
    The schema.rb file captures table definitions. Sometimes id is implicit in the dump, but Rails still creates it behind the scenes.

6. Active Record Fundamentals: Creating, Saving, Updating, and Deleting

6.1 Creating Records

Two-step (instantiate then persist):

loan = Loan.new(unit_id: 4, student_id: 7, due_on: Date.current + 7.days)
loan.save # returns true on success, false on validation failure

One-step:

loan = Loan.create(unit_id: 4, student_id: 7, due_on: Date.current + 7.days)

Bang variants raise on failure (useful in seeds, background jobs):

Loan.create!(unit_id: 4, student_id: 7, due_on: Date.current + 7.days)

Timestamps:

  • created_at: set at insertion.
  • updated_at: set at insertion and each update.
  • Auto-managed when t.timestamps exists.

6.2 Reading Records

By primary key:

Loan.find(4)             # raises ActiveRecord::RecordNotFound if absent
Loan.find_by(id: 4) # returns nil if absent

By attributes:

Loan.find_by(asset_tag: "XYZ123")  # first match or nil

Multiple records:

Loan.where(returned_at: nil)        # returns ActiveRecord::Relation

6.3 Query Composition, Lazy Loading, and Generated SQL

ActiveRecord::Relation chains are lazily evaluated. Rails builds a single SQL query executed when data is needed (iteration, to_a, etc.).
Example:

open_loans   = Loan.where(returned_at: nil)
overdue = open_loans.where("due_on < ?", Date.current)
top_ten = overdue.order(:due_on).limit(10)
top_ten.each { |loan| puts loan.id } # triggers one SQL query

Conceptual SQL:

SELECT "loans".* FROM "loans"
WHERE "loans"."returned_at" IS NULL
AND (due_on < '2026-09-02')
ORDER BY "loans"."due_on" ASC
LIMIT 10

Counting:

Loan.count
Loan.where("due_on < ?", Date.current).count

6.4 Updating Records

Two-step:

loan = Loan.find(42)
loan.returned_at = Time.current
loan.save

One-step:

Loan.find(42).update(returned_at: Time.current)
Loan.find(42).update!(returned_at: Time.current) # raises on failure

6.5 Deleting Records

Loan.find(42).destroy  # runs callbacks

Use delete to bypass callbacks only when you intentionally skip business logic; destroy is preferred to preserve invariants.

7. Preventing SQL Injection with Parameterized Queries

SQL injection arises when user input is interpolated directly into SQL strings:
Insecure:

user_input = "' OR 1=1; --"
Loan.where("asset_tag = '#{user_input}'") # dangerous

Secure alternatives:

  • Question mark placeholders:
    Loan.where("due_on < ?", Date.current)
  • Named placeholders:
    Loan.where("asset_tag = :tag AND student_id = :student", { tag: "XYZ123", student: 7 })
  • Hash equality (most common):
    Loan.where(asset_tag: params[:tag])

Rails binds parameters as data using placeholders ($1, $2, …), avoiding injection and enabling prepared statement reuse. Reading the generated SQL and placeholders in logs helps debugging and performance tuning.

8. Seeding Robust Development Data

8.1 Purpose of Seeds

Development often requires resetting the database:

  • Drop and recreate.
  • Run migrations.
  • Repopulate meaningful data.
    Seeds (db/seeds.rb) encode reproducible scripts to rebuild realistic scenarios and catch UI/logic issues early.

8.2 Structure and Practices

Use create!/save! to fail fast:

# db/seeds.rb
camera = ItemModel.create!(name: "Canon EOS 2000", category: "camera")
3.times do
Unit.create!(item_model_id: camera.id, asset_tag: "CAM-XXXX")
end

Seed edge cases to stress the system:

  • Overdue loans (due_on < today, returned_at nil).
  • Extremely long names/descriptions.
  • Students with zero loans.
  • Data that touches validations and conditional branches.
    Ensure seeded attributes exist in the schema. create! will raise if columns are missing, surfacing mismatches immediately.
    Run seeds:
bin/rails db:seed

9. Working in the Rails Console

Start the console:

bin/rails console
# or
bin/rails c

Sandbox mode (changes roll back on exit):

bin/rails console --sandbox

Occasionally, you may be prompted to load schema metadata:

ItemModel.load_schema  # only if console indicates it

Use the console to:

  • Inspect models and columns.
  • Prototype queries and observe generated SQL.
  • Perform CRUD interactively.
    Examples:
ItemModel.all
ItemModel.find(1)
ItemModel.find_by(name: "ThinkPad T14s")
item = ItemModel.new(name: "ThinkPad T14s", brand: "Lenovo")
item.save
ItemModel.create!(name: "ThinkPad P14", brand: "Lenovo")
p14 = ItemModel.find_by(name: "ThinkPad P14")
p14.update!(name: "ThinkPad P14S")
ItemModel.find(2).destroy

Observe that created_at remains the insertion time and updated_at reflects the last modification, which is useful for audits and debugging.

10. Bringing It Together: Models, Schema, and Application Features

With models adhering to Rails conventions:

  • Controllers retrieve records via queries and pass them to views.
  • Views display attributes and iterate over relations.
  • Models are the natural home for validations, associations, callbacks, and business rules (e.g., Loan belongs_to :unit; Unit has_many :loans).
    Example queries in the loan domain:
  • Pending loans: Loan.where(returned_at: nil).
  • Overdue loans: Loan.where("due_on < ?", Date.current).where(returned_at: nil).
  • Recently returned items: Loan.where.not(returned_at: nil).order(returned_at: :desc).
    The integrity of this flow relies on consistent schema via migrations and idiomatic Active Record usage.

11. Practical Conventions and Tips

  • Use English model/table names to leverage Rails’ inflector.
  • Respect naming conventions: singular CamelCase model; plural snake_case table; foreign keys as singular_model_name_id.
  • Prefer parameterized Active Record query methods to avoid injection.
  • Read generated SQL in logs to verify intent.
  • Use bang methods (create!, update!, save!) in scripts like seeds to avoid silent failures.
  • Do not edit executed migrations; create new ones to evolve the schema.
  • Use bin/rails to lock to the app’s configured Rails version.
  • Timestamps (t.timestamps) are invaluable; include them unless you have a strong reason not to.

12. Code and Command Reference

  • Generate migration:
    bin/rails generate migration CreateLoans
    bin/rails generate migration AddItemModel
  • Example migration:
    class AddItemModel < ActiveRecord::Migration[8.1]
    def change
    create_table :item_models do |t|
    t.string :name, null: false
    t.timestamps
    end
    end
    end
  • Run migrations:
    bin/rails db:migrate
  • Query chain:
    Loan.where(returned_at: nil)
    .where("due_on < ?", Date.current)
    .order(:due_on)
    .limit(10)
  • Update and destroy:
    Loan.find(42).update!(returned_at: Time.current)
    Loan.find(42).destroy
  • Seeds:
    camera = ItemModel.create!(name: "Canon EOS 2000", category: "camera")
    3.times { Unit.create!(item_model_id: camera.id, asset_tag: "CAM-XXXX") }

13. Handling NOT NULL Additions on Populated Tables

Adding a NOT NULL column to a table with existing rows can fail due to missing values. Remedies:

  • Provide a default:
    change_table :item_models do |t|
    t.string :brand, null: false, default: ""
    end
  • Or in disposable development data, recreate the database:
    bin/rails db:drop
    bin/rails db:create
    bin/rails db:migrate
    bin/rails db:seed

Convenient prep:

bin/rails db:prepare

This ensures the database exists and is migrated to the current version as of 2026-09-02.