RUBY · STEP 4 · MODELS

Ruby · Rails: models and migrations

Create a Postgres database for a Rails 8.1 API, write and run migrations, define an Active Record model, seed and query it from the console, then expose full CRUD through `resources` routes and a controller with strong parameters.

Step 4 of 5Railsv8.1© Arthur Bernier Jrrewritten · verified18 code blocks1355 words

What you will learn#

  • How to set the database settings
  • How to create models and migrations
  • How to use models to add, update, and delete records

Setup#

  • Create a new project: rails new pets --api -d postgresql, then cd pets

  • Set up your database settings in config/database.yml exactly as you did in the routing lesson (only needed if your local Postgres wants a username/password)

  • Run bin/rails db:create to create your database

Models and Migrations#

Let's define our terms:

  • migrations are directions on how to modify a database by adding, updating and deleting tables and their fields. They are Ruby files in db/migrate, numbered by timestamp so they run in order.

  • models are classes bound to a particular table in our database, with many built-in methods for adding, updating and deleting records in that table (and related ones).

Creating a migration#

Conventions are pretty important in Rails. Rails hopes that if you follow its conventions you will configure very little and can focus on building your application. In migrations, a name like CreateTurtles signals to Rails that this migration creates a new turtles table:

SH
bin/rails generate migration CreateTurtles

This generates a file in db/migrate named <timestamp>_create_turtles.rb:

RB
class CreateTurtles < ActiveRecord::Migration[8.1]  def change    create_table :turtles do |t|       t.timestamps    end  endend

t.timestamps adds created_at and updated_at columns that Rails maintains for you; an auto-incrementing id primary key is added without being asked. Now all we have to do is specify what fields our table should have:

RB
class CreateTurtles < ActiveRecord::Migration[8.1]  def change    create_table :turtles do |t|      t.string :name       t.timestamps    end  endend

WHY the [8.1]: a migration is pinned to the Rails version that wrote it, so an old migration keeps behaving the way it did even after you upgrade Rails.

Now let's run our migration with bin/rails db:migrate.

Our table was created and a new file, db/schema.rb, was written. It sketches out how our application sees the tables in the database; it is generated from the database after every migration, so you never edit it by hand. If you make a mistake, bin/rails db:rollback undoes the last migration.

Modifying a Table#

Maybe we should also track the ages of our turtles, but our first migration has already run and can't run again against the same database. Let's create a new migration for the change. Pass the column along and Rails writes the body for us:

SH
bin/rails generate migration AddAgeToTurtles age:integer

The Add…To… name tells the generator which table; the new migration looks like this:

RB
class AddAgeToTurtles < ActiveRecord::Migration[8.1]  def change    add_column :turtles, :age, :integer  endend

Run bin/rails db:migrate again and inspect the changes to your schema.rb.

Creating our Model#

Before we can begin adding data to our turtles table we need a model: the class we go through to add, update and delete records. Create a file in app/models named in the singular — lowercase filename, CamelCase class — so for the turtles table the model is Turtle.

app/models/turtle.rb

RB
class Turtle < ApplicationRecordend

That's it! Turtle inherits all the necessary functionality from ApplicationRecord (which inherits it from ActiveRecord::Base) and, as long as we followed the naming conventions, it all just works together. Rails reads the column names and types from the database, so name and age become attributes without a line of code.

(bin/rails generate model Turtle name:string age:integer creates the model file and the matching migration in one go — now that you've seen both halves, that's the command you'll normally reach for.)

Later on, if you create related models you can declare the relationships here, which adds methods like turtle.owner and turtle.meals:

RB
class Turtle < ApplicationRecord  belongs_to :owner  has_many :meals  has_one :shellend

Relationship documentation is linked at the bottom of this lesson.

Seeding the Table#

Let's add some turtles. To handle seed data there is db/seeds.rb, where you can write any Ruby you like and run it with bin/rails db:seed:

db/seeds.rb

RB
# Create 3 turtles; p prints each saved record (with its new id) for informative terminal outputp Turtle.create(name: "Dilbert", age: 5)p Turtle.create(name: "Filbert", age: 5)p Turtle.create(name: "Norbert", age: 5) puts "3 turtles added"

create builds the object and saves it in one call. If a validation fails it quietly returns the unsaved object; create! raises instead, which is usually what you want in a seed file.

Confirming Our Turtles were added to the Database#

We can enter the database shell with bin/rails dbconsole — Rails reads database.yml and starts psql with the right arguments.

Run the SQL query select * from turtles; (quit the psql shell with \q).

The other way to check is the Rails console, bin/rails console, which loads your whole app into an interactive Ruby shell:

  • turtles = Turtle.all saves all the turtles in a variable called turtles (an ActiveRecord::Relation, an array-like collection that runs its SQL lazily)

  • turtles[0], turtles[1] and turtles[2] show each turtle individually

  • Turtle.find(1) looks one up by id (raising ActiveRecord::RecordNotFound if it doesn't exist); Turtle.where(age: 5) filters; Turtle.find_by(name: "Norbert") returns the first match or nil

Type exit to leave the console.

Using the Model#

So we have our model up and running, now let's wire it all together and connect our model to a controller.

Head over to config/routes.rb and add the following inside the draw block:

RB
resources :turtles

The resources macro generates all the conventional CRUD routes for a model. That one line is the equivalent of typing:

RB
get "/turtles", to: "turtles#index"post "/turtles", to: "turtles#create"get "/turtles/:id", to: "turtles#show"patch "/turtles/:id", to: "turtles#update"put "/turtles/:id", to: "turtles#update"delete "/turtles/:id", to: "turtles#destroy"

(It also adds new and edit routes that HTML forms use; an API never hits them. resources :turtles, except: [:new, :edit] leaves them out, and bin/rails routes shows you the result either way.)

Nice that we didn't have to type all that. The routes refer to a turtles controller, so Rails expects app/controllers/turtles_controller.rb with a TurtlesController class — generate it:

SH
bin/rails generate controller Turtles
RB
class TurtlesController < ApplicationControllerend

Now let's create our actions for our routes, one at a time.

get "/turtles", to: "turtles#index"#

RB
class TurtlesController < ApplicationController  # GET /turtles  def index    # render all of the turtles as JSON    render json: Turtle.all  endend

Run your server and check out /turtles in the browser. render json: calls to_json on the relation, which gives you an array of every column of every turtle.

get "/turtles/:id", to: "turtles#show"#

RB
class TurtlesController < ApplicationController  # GET /turtles  def index    render json: Turtle.all  end   # GET /turtles/:id  def show    # get the id from params, query the turtle, render it as JSON    turtle = Turtle.find(params[:id])    render json: turtle  endend

Try out /turtles/1 in the browser! Then try /turtles/999: find raises RecordNotFound, which Rails turns into a 404 for you (in development you see the error page with the details).

post "/turtles", to: "turtles#create"#

A couple of things to notice...

  • The properties that match our model arrive in params grouped under a turtle key — either because the client nested them that way or because wrap_parameters did it for a JSON body (see the routing lesson).

  • params is locked: Rails refuses to mass-assign it to a model until you say which keys are allowed. This is strong parameters, and it is why a stray "admin": true in a request body can't set a column you didn't intend.

  • We'll need the same allow-list again in update, so it goes in a private helper method.

RB
class TurtlesController < ApplicationController  # GET /turtles  def index    render json: Turtle.all  end   # GET /turtles/:id  def show    turtle = Turtle.find(params[:id])    render json: turtle  end   # POST /turtles  def create    # build a turtle from the permitted params    turtle = Turtle.new(turtle_params)     if turtle.save      # 201 Created, with the saved turtle (now with an id) as JSON      render json: turtle, status: :created    else      # 422 with the validation errors      render json: turtle.errors, status: :unprocessable_content    end  end   # helper methods live under the private label; they are not routable actions  private   # require a "turtle" key and permit only name and age inside it  def turtle_params    params.expect(turtle: [:name, :age])  endend

Send a POST to /turtles with the JSON body {"turtle": {"name": "Gilbert", "age": 2}} and the Content-Type: application/json header. If the turtle key is missing or isn't an object, expect answers 400 Bad Request before your code runs. (:unprocessable_content is the current name for 422; older tutorials write :unprocessable_entity, which still works.)

put "/turtles/:id", to: "turtles#update" & patch "/turtles/:id", to: "turtles#update"#

RB
  # PATCH/PUT /turtles/:id  def update    # query the turtle    turtle = Turtle.find(params[:id])     # update it with the permitted params; update returns true or false    if turtle.update(turtle_params)      render json: turtle    else      render json: turtle.errors, status: :unprocessable_content    end  end

update assigns the attributes, runs validations and saves in one call. PATCH with only {"turtle": {"age": 3}} changes just the age; that's why Rails routes both PUT and PATCH here.

delete "/turtles/:id", to: "turtles#destroy"#

RB
  # DELETE /turtles/:id  def destroy    turtle = Turtle.find(params[:id])    # destroy removes the row and runs the model's callbacks (delete would skip them)    turtle.destroy    # 204 No Content: success, nothing to send back    head :no_content  end

One more Helper Function!#

We have full CRUD, but show, update and destroy all repeat the same two lines: grab the id, find the turtle. Let's move that into a helper and have Rails run it before those three actions with before_action:

RB
class TurtlesController < ApplicationController  # run set_turtle before show, update and destroy (and nothing else)  before_action :set_turtle, only: %i[show update destroy]   # GET /turtles  def index    render json: Turtle.all  end   # GET /turtles/:id  def show    render json: @turtle  end   # POST /turtles  def create    turtle = Turtle.new(turtle_params)     if turtle.save      render json: turtle, status: :created    else      render json: turtle.errors, status: :unprocessable_content    end  end   # PATCH/PUT /turtles/:id  def update    if @turtle.update(turtle_params)      render json: @turtle    else      render json: @turtle.errors, status: :unprocessable_content    end  end   # DELETE /turtles/:id  def destroy    @turtle.destroy    head :no_content  end   private   # permit only name and age inside the "turtle" key  def turtle_params    params.expect(turtle: [:name, :age])  end   # for show/update/destroy: find the turtle once and store it in an instance variable  def set_turtle    @turtle = Turtle.find(params[:id])  endend

%i[show update destroy] is Ruby shorthand for an array of symbols, and the action names must match exactly — the original version of this lesson listed delete instead of destroy, so the callback never ran for deletes. Instance variables (@turtle) are how a before_action hands data to the action that follows it.

You have full CRUD! Compare it with what bin/rails generate scaffold writes for you tomorrow — it is this controller, line for line. On to the lab!


Resources to Learn More#


See also