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.