[pragmaticstudio] Ruby on Rails 6 - part 4

Custom Queries

Exercises

Objective

Look under the hood of almost every Rails app and you’ll find custom queries. They pop up all over the place, and for good reason. It’s very common to want to fetch a subset of data from the database.

Currently our movie listing shows all the movies in the database. Trouble is, the folks in Hollywood often announce upcoming movies before they’re officially released . It’s the oldest marketing trick in the book. You watch an amazing movie trailer only to discover at the end that the movie won’t be released until next Christmas! (Do we sound bitter?)

Anyway, we don’t want our app to get people’s hopes up. So instead of listing all the movies, we only want to list movies that have already been released. You know, the movies you can actually watch! And we’ll order the movies so that the most recently-released movie is at the top of the list. To do that, we’ll need to define a custom query that fetches a subset of movies from the database in the order we want. Then we’ll update the index action to fetch the movies using the custom query method.

1. Create a Not-Yet-Released Movie

All the movies we’ve added so far have been released, so either set the released_on date of one of your movies to a date in the future or create a new movie that hasn’t yet been released.

Here’s a quick example of creating a not-yet-released movie in the console if you want to bypass using the web form:

>> movie = Movie.new(title: "Batman vs. Godzilla", description: "An epic battle between The Caped Crusader and the fire-breathing dinosaur Gojira.", rating: "PG-13", total_gross: 387623910)
>> movie.released_on = 10.days.from_now
>> movie.save

2. Write the Query in the Console

Before jumping straight into the task at hand, let’s spend a minute in the console writing the query we want. We almost always experiment with queries in the console first. It’s faster and easier than plunking the query code directly into an app and then having to refresh the browser to see if it worked.

In the console , the feedback is nearly instantaneous, and that’s a huge win when you’re learning something new. The console is especially useful for playing with queries because it automatically prints the generated SQL. So we suggest always keeping a console session open when you’re working on a Rails application… and using it frequently!

  1. Spark up a console session and start by writing a query that returns only the movies that have been released as of right now (hint!). Don’t worry about the order yet.
  • [ANSWER]
Movie.where("released_on < ?", Time.now)

Double-check that your not-yet-released movie isn’t included in the results.
2. Then extend that query so that the released movies are ordered with the most-recently released movie first.

  • [ANSWER]

Movie.where("released_on < ?", Time.now).order("released_on desc") # or using Ruby symbols: Movie.where("released_on < ?", Time.now).order(released_on: :desc)

Make sure the first element of your resulting array contains the most-recently released movie.

Good! Now let’s use that query in the app…

2. List Released Movies

Returning to our original task, we want the movie index page to only show movies that have been released and order them with the most recently-released movie at the top. We now have the query for doing that in our console session. So where do we put this query code in the app?

Determining what it means for a movie to be “released” is a type of business logic. You could easily imagine a business person deciding to change the definition of “released” in this app to include movies that will be released within the next week, just to whet everyone’s appetite. So it’s important that we encapsulate the criteria for released movies in one place in our app. As a general rule, all database-related stuff goes in the model: the Movie model in this case.

  1. Add a class-level method named released to the Movie class. It should return all the movies that have been released, ordered with the most recently-released movie first.

[ANSWER]

class Movie < ApplicationRecord def self.released where("released_on < ?", Time.now).order("released_on desc") end end
  1. Then change the index action in your MoviesController class so that only released movies are displayed in the movie listing.

    [ANSWER]

def index @movies = Movie.released end
  1. To check your work, browse to http://localhost:3000 to make sure only the released movies are shown in the movie listing and ordered properly.

Solution

The full solution for this exercise is in the querying directory of the code bundle.

Bonus Round

Specifying Where Conditions

The where method accepts conditions in several forms.

First, we’ve seen how the conditions can be specified as a string with a placeholder:

Movie.where("released_on < ?", Time.now)

The first argument is a conditions string, and any additional arguments will replace the question marks (?) in it.

Using a placeholder (?) makes sure that the result of Time.now is automatically converted to the proper native database type. You might think you could just interpolate the result of Time.now into the string, like this:

Movie.where("released_on < #{Time.now}")

But that gives an error because Time.now returns a datetime type that’s not compatible with database. So when specifying conditions with dates, you must use a placeholder.

However, you don’t have to use a placeholder for conditions with standard values. For example, the following works because 225000000 is a value that the database recognizes:

Movie.where("total_gross < 225000000")

But the following won’t work because 225_000_000 is a Ruby-specific representation of the same number:

Movie.where("total_gross < 225_000_000")

In that case, you would need to use a placeholder so that the value is properly converted:

Movie.where("total_gross < ?", 225_000_000)

The where method also accepts a hash of conditions. For example, to find all the movies where the rating is “PG-13”, you can use

>> Movie.where(rating: "PG-13")

Notice rating is a Ruby symbol representing a hash key and “PG-13” is the corresponding value which is used to qualify the condition.

That’s basically a shortcut for doing this:

Movie.where("rating = 'PG-13'")

Experiment With More Queries

We’ve seen that the Movie model inherits a number of methods for querying the database from its parent class ApplicationRecord. As a recap of the query methods we’ve used so far, here’s a practice drill of sorts:

  1. Spark up a console session and start with a gimme: Find the movie that has the id 2.
    [ANSWER]
>> Movie.find(2)
  1. How would you find the movie that has the title “Iron Man”? (If you’ve changed your movies, just try a different title.)

[ANSWER]

>> Movie.find_by(title: "Iron Man")
  1. Now count all the movies in the database. At this point you may have added and deleted movies, so just make sure the count is correct.

[ANSWER]

>> Movie.count
  1. Then call the query method that finds all the movies.

[ANSWER]

>> Movie.all
  1. Now order all the movies by their total gross, with the lowest grossing movie first.

[ANSWER]

>> Movie.order("total_gross") # or >> Movie.order("total_gross asc") # or >> Movie.order(total_gross: :asc)
  1. Flip it around. Reverse the order so the highest-grossing movie is first in the array.

[ANSWER]

>> Movie.order("total_gross desc") # or >> Movie.order(total_gross: :desc)
  1. OK, how would you find all the flop movies where (hint!) the total gross is less than $225M?

[ANSWER]

>> Movie.where("total_gross < ?", 225_000_000) # or >> Movie.where("total_gross < 225000000")
  1. What about finding all the hit movies with a total gross greater than or equal to $300M?

[ANSWER]

>> Movie.where("total_gross >= ?", 300_000_000) # or >> Movie.where("total_gross >= 300000000")
  1. Now chain together two query methods to find all the hit movies and order them so that the highest-grossing movie is first.

[ANSWER]

>> Movie.where("total_gross >= 300000000").order("total_gross desc")
  1. What about counting all the hit movies?

[ANSWER]

>> Movie.where("total_gross >= 300000000").count
  1. How about finding all the movies where the rating is “PG”?

[ANSWER]

>> Movie.where(rating: "PG")
  1. The not method generates a SQL NOT condition. Use it to find all the movies where the rating is not “PG”.

[ANSWER]

>> Movie.where.not(rating: "PG")
  1. The not method can also accept an array to ensure multiple values are not in a field. Find all the movies where the rating is not “PG” or “PG-13”.

[ANSWER]

>> Movie.where.not(rating: ["PG", "PG-13"])

Great job!

Printing the Generated SQL

Sometimes you want to see the SQL a query method will generate without actually running the query. To do that, you can tack on a to_sql method call, like so:

>> Movie.where("total_gross >= 300000000").order("total_gross desc").to_sql

And prints the generated SQL but doesn’t run the query:

=> "SELECT "movies".* FROM "movies" WHERE (total_gross >= 300000000) ORDER BY total_gross desc"

Handy!

More Custom Query Methods

If you want to flex your query muscles, try declaring custom query methods that return movies matching the following conditions:

  • hit movies: movies with at least $300M total gross, ordered with the highest grossing movie first
  • flop movies: movies with less than $225M total gross, ordered with the lowest grossing movie first
  • recently added movies: the last three movies that have been created, ordered with the most recently-added movie first. Hint: you need to limit the result to 3 records.
    [ANSWER]
def self.hits where("total_gross >= 300000000").order(total_gross: :desc) end def self.flops where("total_gross < 22500000").order(total_gross: :asc) end def self.recently_added order("created_at desc").limit(3) end

Test each query method in the console. (If you already have an active console session, you’ll need to use reload!.) Use the to_sql method to print out the generated SQL for each query. And for fun, chain a few of the query methods together and print out the generated SQL.

[ANSWER]


>> Movie.hits.to_sql
=> "SELECT "movies".* FROM "movies" WHERE (total_gross >= 300000000) ORDER BY total_gross desc"

>> Movie.flops

>> Movie.flops.to_sql
=> "SELECT "movies".* FROM "movies" WHERE (total_gross < 22500000) ORDER BY total_gross asc"

>> Movie.recently_added

>> Movie.recently_added.to_sql
=> "SELECT "movies".* FROM "movies" ORDER BY created_at desc LIMIT 3"

>> Movie.recently_added.flops
>> Movie.recently_added.hits

>> Movie.released.flops
>> Movie.released.hits

>> Movie.released.hits.to_sql
=> "SELECT "movies".* FROM "movies" WHERE (released_on < '2019-06-11 21:05:03.596359') AND (total_gross >= 300000000) ORDER BY released_on desc, "movies"."total_gross" DESC"

You can experiment with using these query methods in the index action of your MoviesController, though we’ll continue to use the released query in subsequent exercises.

Wrap Up

Now you know how to query the database any way you please! The really nifty part is that the query methods generate the actual SQL and run the database query for you. Basically, all the queries you could perform with standard SQL can be expressed using these high-level query methods in your models.

The machinery that makes all this possible is another gem that’s included with Rails called ActiveRelation (ARel). It’s a query generation library that knows how to generate SQL for all the supported databases. So just like with migrations, no matter which database you use—SQLite, MySQL, PostgreSQL, Oracle, or the like—the queries that ARel generates will run on any of them. That’s one less thing for us to worry about!

In the next section we’ll add another migration, which will give us an opportunity to see how migrations are reversible…

Migrations Revisited

Exercises

Objective

As our app has evolved and you’ve had a chance to play around with movies in the web interface, you probably noticed that we’re missing some important information. Any respectable online movie app needs to show a movie’s poster image. It would also be handy to know the movie’s director and duration.

We’re faced with adding new fields to the database, and that always means we need a new migration. Now, we already know how to generate new migration files, so that part is easy. But any time you migrate the database, you also need to think about how it will affect the other parts of the app. Generally speaking, adding new fields has a ripple effect, and you’ll need to make the necessary adjustments. This exercise gives us a good opportunity to work through that process.

To complete this exercise we’ll need to draw upon (practice!) a lot of things we’ve done so far:

  1. Generate a new migration file that adds more fields to the movies database table.
  2. Change the movie index and show templates to display the new movie fields.
  3. Change the form to include form elements for the new fields.
  4. Update the existing movies in the database to have values for the new fields.

Again, much of this will be review, but adding new migrations is so common that it’s good to put all this together so we’re really comfortable with the steps. We’ll also learn some new things about migrations along the way.

1. Add More Database Fields

Our first task is to add the following new fields and types to the movies database:

name type
director string
duration string
image_file_name string
  1. Use the migration generator to generate a migration named AddMoreFieldsToMovies that adds columns for the fields and types listed above to the movies table. You can either generate it all in one fell swoop using the naming conventions, or generate the file and then edit the change method to add the appropriate columns.
    [HIDE]
rails g migration AddMoreFieldsToMovies director:string duration:string image_file_name:string
  1. Then open the generated migration file and set the default value of the image_file_name column to “placeholder.png”. Otherwise, if you don’t explicitly set a default, then the image_file_name column will have a default value of nil which won’t work.
    Here’s what you want the migration file to look like:
class AddMoreFieldsToMovies < ActiveRecord::Migration[6.0]
  def change
    add_column :movies, :director, :string
    add_column :movies, :duration, :string
    add_column :movies, :image_file_name, :string, default: "placeholder.png"
  end
end
  1. Then apply the new migration.

[ANSWER]

rails db:migrate

You should get the following output:

==  AddMoreFieldsToMovies: migrating ==========================================
-- add_column(:movies, :director, :string)
   -> 0.0056s
-- add_column(:movies, :duration, :string)
   -> 0.0027s
-- add_column(:movies, :image_file_name, :string, {:default=>"placeholder.png"})
   -> 0.0029s
==  AddMoreFieldsToMovies: migrated (0.0118s) =================================
  1. Now try running the migration again, and this time you shouldn’t get any output because all the migrations have already run.
  2. Just to see where things stand, display the status of the migrations.
    [ANSWER]
rails db:migrate:status

You should see all the migrations marked as “up”:

Status   Migration ID    Migration Name
--------------------------------------------------
   up     20190502122806  Create movies
   up     20190506213706  Add fields to movies
   up     20190611211855  Add more fields to movies

2. Undo It

Mistakes happen, and sometimes you’ll need to “undo” a migration. For example, every Rails developer has at one point misspelled the name of a column when generating a migration and not noticed it until the migration was already applied. And once you run the migration, you can’t then just edit the migration and run the migration again because Rails knows the migration has already been run. In those unfortunate cases, you need to “undo” the last migration. Thankfully, that’s really easy because migrations are reversible.

  1. Just for kicks, roll back the last migration that was applied by typing:
rails db:rollback

You should get the following output:

==  AddMoreFieldsToMovies: reverting ==========================================
-- remove_column(:movies, :image_file_name, :string, {:default=>"placeholder.png"})
   -> 0.0356s
-- remove_column(:movies, :duration, :string)
   -> 0.0379s
-- remove_column(:movies, :director, :string)
   -> 0.0124s
== AddMoreFieldsToMovies: reverted (0.0902s) ===================

Hey, that’s pretty clever! Notice that it reverted the migration by calling the remove_column method for each of the fields. The change method in the migration file figures out the reverse of add_column is remove_column, so we don’t have to do anything extra to get a reversible migration. Huzzah!Note, however, that not all migrations are reversible. For example, if you had a change method that dropped a table, the migration wouldn’t be able to figure out how to recreate the table. In those cases you can define separate up and down methods in the migration file for finer control. The up method is run when the migration is applied, and the down method is run when the migration is reversed.Finally, it’s important to realize that reversing a migration may cause data loss. If a column is removed, for example, all the data that was in that table’s column is now gone forever. In development that’s not usually a big deal, but in production it could be disasterous ! Just something to keep in mind. :slight_smile:
2. To see the current state of the migration, go ahead and check the migration status again.
[ANSWER]

rails db:migrate:status

You should see the reversed migration is now marked as “down”:

Status   Migration ID    Migration Name
--------------------------------------------------
   up     20190502122806  Create movies
   up     20190506213706  Add fields to movies
   down   20190611211855  Add more fields to movies
  1. At this point, if we needed to fix a mistake, we’d just edit the migration file and then re-run the revised migration. We don’t have any reason to edit the migration file in this case, so go ahead and re-run the migration.

[ANSWER]

rails db:migrate

And now all the migrations should be marked as “up” again:

Status   Migration ID    Migration Name
--------------------------------------------------
   up     20190502122806  Create movies
   up     20190506213706  Add fields to movies
   up     20190611211855  Add more fields to movies

Excellent—that takes care of the database changes for this feature!

3. Update the Form

Now that we’ve changed the database schema, we need to update the view templates to reflect those changes.

  1. Let’s start with the form. Browse to a movie’s show page and click the “Edit” link. You should totally expect not to see the new fields in the form.
  2. Fix that by updating the form partial to include text fields for director, duration, and image_file_name.
    [ANSWER]
<%= f.label :director %>
<%= f.text_field :director %>

<%= f.label :duration %>
<%= f.text_field :duration %>

<%= f.label :image_file_name %>
<%= f.text_field :image_file_name %>

Refresh the form and you should see text fields for the new fields. You won’t see any values for the director or duration fields because those values are nil by default in the database. The image_file_name field, however, should be filled with “placeholder.png” since that’s the default value.
3. Before you can submit data for these new form fields, back in your MoviesController you’ll need to add the new fields to the list of permitted parameters. Otherwise, the values for the new fields will get ignored.
[ANSWER]

def movie_params
  params.require(:movie).
    permit(:title, :description, :rating, :released_on, :total_gross,
            :director, :duration, :image_file_name)
end
  1. Now use your spiffy new form to update all the movies to have values for the fields we added. You can find example movie info on IMDb or Wikipedia, for example. In an earlier exercise we copied some sample movie poster image files into the flix/app/assets/images directory.

Here are some examples you can paste into the console if you want to bypass using the web form.

[CODE]

movie = Movie.find_by(title: "Avengers: Endgame")
movie.director = "Anthony Russo"
movie.duration = "181 min"
movie.image_file_name = "avengers-end-game.png"
movie.save

movie = Movie.find_by(title: "Captain Marvel")
movie.director = "Anna Boden"
movie.duration = "124 min"
movie.image_file_name = "captain-marvel.png"
movie.save

movie = Movie.find_by(title: "Black Panther")
movie.director = "Ryan Coogler"
movie.duration = "134 min"
movie.image_file_name = "black-panther.png"
movie.save

movie = Movie.find_by(title: "Wonder Woman")
movie.director = "Patty Jenkins"
movie.duration = "141 min"
movie.image_file_name = "wonder-woman.png"
movie.save

movie = Movie.find_by(title: "Avengers: Infinity War")
movie.director = "Anthony Russo"
movie.duration = "149 min"
movie.image_file_name = "avengers-infinity-war.png"
movie.save

movie = Movie.find_by(title: "Green Lantern")
movie.director = "Martin Campbell"
movie.duration = "114 min"
movie.image_file_name = "green-lantern.png"
movie.save

movie = Movie.find_by(title: "Fantastic Four")
movie.director = "Josh Trank"
movie.duration = "100 min"
movie.image_file_name = "fantastic-four.png"
movie.save

movie = Movie.find_by(title: "Iron Man")
movie.director = "Jon Favreau"
movie.duration = "126 min"
movie.image_file_name = "ironman.png"
movie.save

movie = Movie.find_by(title: "Superman")
movie.director = "Richard Donner"
movie.duration = "143 min"
movie.image_file_name = "superman.png"
movie.save

movie = Movie.find_by(title: "Spider-Man")
movie.director = "Sam Raimi"
movie.duration = "121 min"
movie.image_file_name = "spiderman.png"
movie.save

movie = Movie.find_by(title: "Batman")
movie.director = "Tim Burton"
movie.duration = "126 min"
movie.image_file_name = "batman.png"
movie.save

movie = Movie.find_by(title: "Catwoman")
movie.director = "Jean-Christophe 'Pitof' Comar"
movie.duration = "101 min"
movie.image_file_name = "catwoman.png"
movie.save

Did you notice that we didn’t have to change the form that’s shown on the new page? Remember, it simply renders the form partial we just changed. How’s that for being agile?

4. Update Show and Index Templates

As you updated each movie, you probably noticed that the new fields aren’t showing up on either the show page or index page. But you saw that coming from a mile away, right?

  1. Update the show.html.erb template to display the new fields. Use the image_tag helper to show the image corresponding to the name in the image_file_name field.
    [ANSWER]
<section class="movie-details">
  <div class="image">
    <%= image_tag @movie.image_file_name %>
  </div>
  <div class="details">
   ...
   <table>
      <tr>
        <th>Director:</th>
        <td><%= @movie.director %></td>
      </tr>
      <tr>
        <th>Duration:</th>
        <td><%= @movie.duration %></td>
      </tr>
      <tr>
        <th>Total Gross:</th>
        <td><%= total_gross(@movie) %></td>
      </tr>
    </table>
  </div>
</section>

Refresh and you should see all the values you entered in the form, plus a snazzy movie poster image (if you entered one)!
2. Then update the index.html.erb template to display the movie’s poster image. (We’ll only show the director and duration on the show page.)
[ANSWER]

<section class="movie">
  <div class="image">
    <%= image_tag movie.image_file_name %>
    </div>
    <div class="summary">
      ...
    </div>
  </section>
  1. Refresh to see your rockin’ new movie listings!

Bonus Round

Update/Add Seed Data

At this point we have a handful of example movies in our database with values for all the fields. Sometime down the road you may want to recreate your database from scratch and automatically “seed” it with these same example movies.

In an earlier exercise, we copied a prepared seeds.rb file into your flix/db directory, overwriting the existing seeds.rb file. Then we used this file to prime (seed) your database with example movies. Unlike a migration file which changes the underlying database structure , the seeds.rb file simply populates the database with data .

But if you take a peek at flix/db/seeds.rb, you’ll notice that the example movies don’t have values for the new fields: director, duration, and image_file_name. So you may want to update that file to include values for the new fields we added in this exercise.

To save you time, you can go ahead and copy/paste the following code into the flix/db/seeds.rb file:
[ANSWER]

Movie.create!([
  {
    title: 'Avengers: Endgame',
    description:
    %{
      After the devastating events of Avengers: Infinity War, the universe
      is in ruins. With the help of remaining allies, the Avengers assemble
      once more in order to undo Thanos' actions and restore order to the universe.
    }.squish,
    released_on: "2019-04-26",
    rating: 'PG-13',
    total_gross: 1_223_641_414,
    director: 'Anthony Russo',
    duration: '181 min',
    image_file_name: 'avengers-end-game.png'
  },
  {
    title: 'Captain Marvel',
    description:
    %{
      Carol Danvers becomes one of the universe's most powerful heroes when Earth is caught in the middle of a galactic war between two alien races.
    }.squish,
    released_on: "2019-03-08",
    rating: 'PG-13',
    total_gross: 1_110_662_849,
    director: 'Anna Boden',
    duration: '124 min',
    image_file_name: 'captain-marvel.png'
  },
  {
    title: 'Black Panther',
    description:
    %{
      T'Challa, heir to the hidden but advanced kingdom of Wakanda, must step forward to lead his people into a new future and must confront a challenger from his country's past.
    }.squish,
    released_on: "2018-02-16",
    rating: 'PG-13',
    total_gross: 1_346_913_161,
    director: 'Ryan Coogler',
    duration: '134 min',
    image_file_name: 'black-panther.png'
  },
  {
    title: 'Avengers: Infinity War',
    description:
    %{
      The Avengers and their allies must be willing to sacrifice all in an attempt to defeat the powerful Thanos before his blitz of devastation and ruin puts an end to the universe.
    }.squish,
    released_on: "2018-04-27",
    rating: 'PG-13',
    total_gross: 2_048_359_754,
    director: 'Anthony Russo',
    duration: '149 min',
    image_file_name: 'avengers-infinity-war.png'
  },
  {
    title: 'Green Lantern',
    description:
    %{
      Reckless test pilot Hal Jordan is granted an alien ring that bestows him with otherworldly powers that inducts him into an intergalactic police force, the Green Lantern Corps.
    }.squish,
    released_on: "2011-06-17",
    rating: 'PG-13',
    total_gross: 219_851_172,
    director: 'Martin Campbell',
    duration: '114 min',
    image_file_name: 'green-lantern.png'
  },
  {
    title: 'Fantastic Four',
    description:
    %{
      Four young outsiders teleport to an alternate and dangerous universe which alters their physical form in shocking ways. The four must learn to harness their new abilities and work together to save Earth from a former friend turned enemy.
    }.squish,
    released_on: "2015-08-07",
    rating: 'PG-13',
    total_gross: 168_257_860,
    director: 'Josh Trank',
    duration: '100 min',
    image_file_name: 'fantastic-four.png'
  },
  {
    title: 'Iron Man',
    description:
    %{
      When wealthy industrialist Tony Stark is forced to build an
      armored suit after a life-threatening incident, he ultimately
      decides to use its technology to fight against evil.
    }.squish,
    released_on: "2008-05-02",
    rating: 'PG-13',
    total_gross: 585_366_247,
    director: 'Jon Favreau',
    duration: '126 min',
    image_file_name: 'ironman.png'
  },
  {
    title: 'Superman',
    description:
    %{
      An alien orphan is sent from his dying planet to Earth, where
      he grows up to become his adoptive home's first and greatest
      super-hero.
    }.squish,
    released_on: "1978-12-15",
    rating: 'PG',
    total_gross: 300_451_603,
    director: 'Richard Donner',
    duration: '143 min',
    image_file_name: 'superman.png'
  },
  {
    title: 'Spider-Man',
    description:
    %{
      When bitten by a genetically modified spider, a nerdy, shy, and
      awkward high school student gains spider-like abilities that he
      eventually must use to fight evil as a superhero after tragedy
      befalls his family.
    }.squish,
    released_on: "2002-05-03",
    rating: 'PG-13',
    total_gross: 825_025_036,
    director: 'Sam Raimi',
    duration: '121 min',
    image_file_name: 'spiderman.png'
  },
  {
    title: 'Batman',
    description:
    %{
      The Dark Knight of Gotham City begins his war on crime with his
      first major enemy being the clownishly homicidal Joker.
    }.squish,
    released_on: "1989-06-23",
    rating: 'PG-13',
    total_gross: 411_348_924,
    director: 'Tim Burton',
    duration: '126 min',
    image_file_name: 'batman.png'
  },
  {
    title: "Catwoman",
    description:
    %{
      Patience Philips seems destined to spend her life apologizing for taking up space. Despite her artistic ability she has a more than respectable career as a graphic designer.
    }.squish,
    released_on: "2004-07-23",
    rating: "PG-13",
    total_gross: 82_102_379,
    director: "Jean-Christophe 'Pitof' Comar",
    duration: "101 min",
    image_file_name: "catwoman.png"
  },
  {
    title: "Wonder Woman",
    description:
    %{
      When a pilot crashes and tells of conflict in the outside world, Diana, an Amazonian warrior in training, leaves home to fight a war, discovering her full powers and true destiny.
    }.squish,
    released_on: "2017-06-02",
    rating: "PG-13",
    total_gross: 821_847_012,
    director: "Patty Jenkins",
    duration: "141 min",
    image_file_name: "wonder-woman.png"
  }
])

Notice that it uses the create! method instead of the usual create method. The ! version of create raises an exception if a record can’t be created because it’s invalid (more on that later). Basically, it means we’ll get a heads-up if our seed data is out of whack.

Also notice that we’re passing the create! method an array, where each array element is a hash of movie attributes. This creation style isn’t specific to seeding data. It’s just a handy way to create a bunch of records in one fell swoop.

Don’t do this now since we already have example movies in the database, but if at some point you recreate the database from scratch and want to populate it with example movies, you would run the following task:

rails db:seed

We don’t want to run the task now because it doesn’t automatically clear out existing records in the database. Rather, it’s an additive process. So if you were to run the task now, you’d end up adding five more (duplicate) movies to the database.

If instead you want to start from scratch, you can run rails db:reset. This task drops and re-creates the database, applies all the migrations, and populates the database with the seed data in db/seeds.rb. Or, if you just want to “replant” all the seed data, you can run rails db:seed:replant which removes all the data from all the database tables and re-seeds the database tables with the seed data in db/seeds.rb.

Solution

The full solution for this exercise is in the migrations-revisited directory of the code bundle.

Wrap Up

This exercise was a good opportunity to work through all the steps to accommodate a new migration. Whether you’re adding, renaming, or deleting columns, you’ll need to go through these same high-level steps:

  • generate and apply the migration
  • add values for the new fields, if necessary
  • update all the affected templates

Before you dive into making the changes, you might consider creating a quick and dirty checklist of what’s impacted. Don’t worry about this being fancy; it’s simply a way to think through all the moving parts. We regularly use the backs of envelopes, napkins, and receipts for these kinds of scribbles.

It’s interesting to note that controllers are typically not affected by new migrations, with the exception of the permitted parameter list. As middlemen, controllers just pass the data from models to views without regard for the details of the data. And that’s exactly as it should be! The MVC design is all about keeping concerns separated so that changes don’t ripple across the entire application.

Model Validations

Exercises

Objective

While creating and updating movies using the web forms, you may have noticed that you can create or update a movie with a blank title. In fact, the app will happily accept other not-so-desirable values, such as “Lots of Money” for the total gross amount. That’s a big problem! Without validating what gets entered in the web forms, our database can quickly become corrupted with bad (invalid) data which can also trigger nasty little bugs.

To prevent bad data from getting into the database, we’ll declare validations . Validations are a type of business rule: they ensure the integrity of your application data and prevent it from being saved in an invalid state. Since models are the gatekeeper to application data, the movie-related validations belong in our Movie model.

Over the next two exercises, we’ll need to make changes in the model, view, and controller:

  • In the Movie model , we’ll declare reasonable data validations.
  • Then we’ll change the create and update controller actions to handle the cases where the movie wasn’t saved in the database because it’s not valid.
  • Finally in the view (the form) we’ll display any validation errors to give the customer specific feedback so they can make the necessary corrections.

1. Declare Movie Validations

Let’s start in the Movie model and work our way back out to the web interface. We’ll use the built-in validations that are included with Rails. As you work through this section you might find it helpful to peek at the documentation for the validates method.

Use built-in model validations to enforce the following rules about a movie:

  1. Values for the fields title, released_on, and duration must be present.
    [ANSWER]
validates :title, :released_on, :duration, presence: true
  1. The description field must have a minimum of 25 characters.

[ANSWER]

validates :description, length: { minimum: 25 }
  1. The total_gross field must be a number greater than or equal to 0.

[ANSWER]

validates :total_gross, numericality: { greater_than_or_equal_to: 0 }
  1. The image_file_name field must be formatted so that the file name has at least one word character and a “jpg” or “png” extension. The regular expression syntax for this one is kinda tricky, so go ahead and copy in the following:
validates :image_file_name, format: {
  with: /\w+\.(jpg|png)\z/i,
  message: "must be a JPG or PNG image"
}
  1. Finally, the rating field must have one of the following values: “G”, “PG”, “PG-13”, “R”, or “NC-17”. Go ahead and add a Ruby constant named RATINGS that contains an array of strings like so:
RATINGS = %w(G PG PG-13 R NC-17)

Can you find a built-in validation that validates whether a value is included in (hint!) a specified array?

[ANSWER]

validates :rating, inclusion: { in: RATINGS }
  1. To make selecting a rating easier in the web interface, change the rating form field in app/views/movies/_form.html.erb to use a drop-down to select the rating from the list of possible ratings, like so:
 <%= f.select :rating, Movie::RATINGS, prompt: "Pick one" %>

Then browse to either the create or edit form and you should now see a drop-down of options for selecting a movie rating. We’ve got our belt and suspenders on now! The form only allows you to select specific ratings and the model validates the rating, too.

2. Experiment with Validations

Now that we have the validations declared in the Movie model, let’s try to save an invalid movie using the console and see what happens…

  1. Fire up a Rails console to experiment in.
  2. In the console, instantiate a new Movie object without a title.
    [ANSWER]
>> movie = Movie.new
  1. Now try to save the invalid Movie object to the database.

[ANSWER]

>> movie.save

The result should be false.
It’s important to understand what happened here. When you call the save method, it automatically runs all the validations. If a validation fails, a corresponding message is added to the model object’s errors collection. And if the errors collection contains any messages, then the save is abandoned and false is returned.In short, the failed validations are preventing the model from being saved, which is exactly what we want!
4. We know our movie is invalid because the save method returned false, so there must be some errors waiting for us.Inspect the errors by accessing the errors collection.
[ANSWER]

>> movie.errors

There’s quite a bit of information in the output. To dig down into the actual error messages, tack on a call to full_messages to get an array of error messages.

[ANSWER]

>> movie.errors.full_messages

You can even turn that into a fairly readable English sentence by also tacking on the to_sentence method.

[ANSWER]

>> movie.errors.full_messages.to_sentence
  1. How would you get only the error messages that are associated with the :title attribute?

[ANSWER]

>> movie.errors[:title]
  1. Finally, create a valid movie object and save it away.

[ANSWER]

>> movie.title = "Hulk"
>> movie.description = "Bruce Banner transforms into a raging green monster when he gets angry."
>> movie.released_on = "2003-06-20"
>> movie.duration = "138 min"
>> movie.total_gross = 113_107_712
>> movie.image_file_name = "hulk.png"
>> movie.rating = "PG-13"

>> movie.save

This time the result should be true. The validations ran again, but this time they all passed so no errors were found in the errors collection. Consequently, the movie was inserted in the database and the call to save returned true.
7. Since the save was successful, the movie’s errors collection must be empty. Just for practice, confirm that by asking the movie if it has any errors.

[ANSWER]

>> movie.errors.any?

Solution

The full solution for this exercise is in the model-validations directory of the code bundle.

Wrap Up

Perfect! Now whenever you try to create or save a Movie, Rails will run all the validations. The movie only gets saved if the errors collection is empty. So our Movie model now dutifully prevents bad data from getting into our database.

As you build your own app, keep model validations in mind every time you write a migration and add new fields to your database. Rails includes a bunch of common validations, but you can also create your own custom validations when necessary.

Now that we have that foundation, we’ll move up into the web interface to handle cases where users enter invalid data.

Handling Validation Errors

Exercises

Objective

Now that we have validations in the Movie model, let’s turn our attention to the user interface. We still need to:

  • Change the create action to redisplay the form when a user tries to create an invalid movie.
  • Display validation errors to help the user fix the problem.
  • Change the update action to handle validation errors when editing an existing movie.

1. Handle Errors During Create

The create action currently assumes that calling save always successfully creates the movie in the database:

def create
  @movie = Movie.new(movie_params)
  @movie.save
  redirect_to @movie
end

But that’s wishful thinking! Now that we have validations, save could return either true or false. So we need to update the action to handle both cases.

  1. Change the create action so that if the save call returns true (the model was saved), the action redirects to the movie’s detail page. If the movie fails to save, redisplay the new form populated with any valid data so the user can try again.
    [ANSWER]
def create
  @movie = Movie.new(movie_params)
  if @movie.save
    redirect_to @movie
  else
    render :new
  end
end
  1. Back in your browser, create a valid movie. The movie should get created and you should end up getting redirected to the new movie’s detail page. That’s the happy path!
  2. Now try creating an invalid movie by entering a title, but leaving the rest of the form blank. You should see the form redisplayed. The valid title you entered should be populated in the title field. The other (invalid) fields should be highlighted in red and yellow.
  3. How did those invalid fields get highlighted? Rails automatically wraps any form elements that contain invalid data in an HTML div with the class attribute set to field_with_errors. (Take a peek at the the page source. Rails won’t mind.)And in the custom.scss file we have CSS rules that style field_with_errors accordingly. If you’d like to change the colors of invalid fields, just search for field_with_errors in the custom.scss file.

2. Display Validation Error Messages

At this point our controller is doing its job. Unfortunately, the user doesn’t have a lot of clues as to why the movie wasn’t created. To give them some actionable feedback, we need to display the actual error messages. Displaying information is a view’s job, and in this case it makes sense to show the errors at the top of the form.

  1. In the _form partial just inside the form_with block, start by simply displaying the errors as an English sentence just like we did earlier in the console.
    [ANSWER]
<%= form_with(model: movie, local: true) |f| %>
  <%= movie.errors.full_messages.to_sentence %>
  ...
<% end %>
  1. Then back in the browser try creating an invalid movie again and you should see the error messages displayed at the top of the form. It’s not very pretty output, but at least we’re communicating better with the user.
  2. Instead of displaying the errors as a sentence, now we want to display the error messages in a neatly-formatted list with a bit of custom styling. We’ll want to display errors this way on other forms we’ll create later on in the course. Sounds like a great opportunity to practice what we learned earlier about partials!First, create an app/views/shared directory. Then inside that directory create an _errors.html.erb partial and paste in the following code:
<% if object.errors.any? %>
  <section class="errors">
    <h2>
      Oops! Your form could not be saved.
    </h2>
    <h3>
      Please correct the following
      <%= pluralize(object.errors.size, "error") %>:
    </h3>
    <ul>
      <% object.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
    </ul>
  </section>
<% end %>

Notice that it assumes that a local variable named object references an ActiveRecord model object. And if that object has any errors, it displays the number of errors and iterates through all the error messages to generated a styled list. Groovy!
4. Then at the top of the _form.html.erb partial, render the _errors.html.erb partial (yup, partials can render other partials). When rendering the _errors.html.erb partial, you’ll need to assign the movie object to the object variable.
[ANSWER]

<%= form_with(model: movie, local: true) do |f| %>
  <%= render "shared/errors", object: movie %>
   ...
<% end %>
  1. Then try creating an invalid movie again in the browser. This time you should get a snazzy list of validation errors displayed at the top of the form. Don’t care for our taste in colors? No problem. Just search for errors in the custom.scss file and customize to your heart’s content!

3. Handle Errors During Update

OK, so we have error handling in place when creating new movies, but what about editing existing movies? The update action currently assumes that calling update always successfully updates the movie attributes in the database:

def update
  @movie = Movie.find(params[:id])
  @movie.update(movie_params)
  redirect_to @movie
end

Similar to calling save, calling update returns either true or false depending on whether the object is valid. So we need to add a conditional here, too.

  1. Change the update action so that if the update call returns true (the model was updated), the action redirects to the movie’s detail page. If the movie fails to update, redisplay the edit form populated with any valid data so the user can give it another go.
def update
  @movie = Movie.find(params[:id])
  if @movie.update(movie_params)
    redirect_to @movie
  else
    render :edit
  end
end
  1. Then, back in your browser, edit a movie and enter valid information. You should get redirected to the movie’s detail page and see the updated information. So far, so good!
  2. Then try updating a movie by entering a blank title and a negative total gross value, but leaving the other fields intact. You should see the form redisplayed with the list of validation errors at the top. (Remember, the edit template uses the same form partial as the new template.) In the form itself, the title and total gross fields should be highlighted in red, but all the other fields should have their original values.

The Flash

Exercises

Objective

In the previous exercise we gave users feedback when they tried to enter invalid data via forms. But sometimes it’s nice to give them short status messages even when things go well. Indeed, it’s often the case that web apps need to flash a message up on the page after a specific action takes place. We can easily do that with something Rails calls a flash (shocking, we know).

In this exercise our goal is to flash the following three messages after their respective actions:

  • “Movie successfully updated!”
  • “Movie successfully created!”
  • “Movie successfully deleted!”

We’ll make quick work of this. We’ll have it done in a flash! (OK, we’ll stop now.) The first one takes a couple extra steps just to get everything set up, but then it’s easy-peasy from there.

1. Flash on Update

First, when a movie has been successfully updated we want to give the user some confirmation that it happened. You know, something upbeat like “Movie successfully updated!”.

  1. In the update action, use the flash object directly to assign the status message to the :notice key. Remember, the flash object acts just like a Ruby hash, so it needs a key (an arbitrary type, in this case :notice) and a value (the message you want displayed).
    [ANSWER]
def update
  @movie = Movie.find(params[:id])
  if @movie.update(movie_params)
    flash[:notice] = "Movie successfully updated!"
    redirect_to @movie
  else
    render :edit
  end
end

Now, back in your browser, edit a movie and hit submit.

  1. Hey, where’s our confirmation? Unfortunately, the flash message isn’t displayed anywhere. :-(We set the flash message in the controller action, but now we need to actually display the flash message. That sounds like something a view template should do. But which view template? The update action ends up redirecting to the show action which then renders the show template. So displaying the flash in the show template would work, but it’s a short-sighted solution.Ideally, we want to be able to set a flash message in any controller action and have it displayed at the top of the resulting page. For that reason, it’s better to render the flash messages in the application-wide layout file. Say, that sounds familiar!
  2. In the application.html.erb layout file, render the flash message just inside the content div. Don’t worry about stylin’ it, just get something working.
    [ANSWER]
<div class="content">
  <%= flash[:notice] %>

  <%= yield %>
</div>
  1. Now edit a movie again, and this time you should see the flash message displayed at the top of the movie’s detail page. Reload the page and the flash should disappear .That makes sense. We don’t want the flash message showing up every time we view a movie’s detail page. We only want the message to appear after the user has updated the movie. The flash message (the value of the hash) is cleared after every request. It’s like a flash in the pan! (Sorry, couldn’t resist.)
  2. Want to add a little color and styling to the flash message? No problem. Click the answer for a version that displays the flash notice message in a styled paragraph if a notice message exists. It also displays any alert flash messages.
    [ANSWER]
<% if flash[:notice] %>
  <div class="flash notice">
    <%= flash[:notice] %>
  </div>
<% end %>

<% if flash[:alert] %>
  <div class="flash alert">
    <%= flash[:alert] %>
  </div>
<% end %>

Now after editing a movie you’ll see a greenish, centered flash message at the top. It’s green because we have CSS rules for flashes. Green’s not your favorite flash color? No worries. Just search for flash in the custom.scss file. You’ll find specific rules for notice and alert messages. Feel free to change the colors!
6. To keep everything in the layout file at basically the same conceptual level, let’s put all the flash stuff in a partial, too.
First, in the app/views/layouts directory, create a _flash.html.erb partial file. Then cut the flash code out of the application.html.erb layout file and paste it into the new _flash.html.erb partial.Then, back in the layout file, render the layouts/flash partial.
[ANSWER]

<div class="content">
  <%= render "layouts/flash" %>

  <%= yield %>
</div>
  1. Finally, setting notice flashes is so common that Rails provides a shortcut. You can pass an option to redirect_to to automatically set the appropriate flash before the redirection happens.

Change your update action to use the shortcut like this:

def update
  @movie = Movie.find(params[:id])
  if @movie.update(movie_params)
    redirect_to @movie, notice: "Movie successfully updated!"
  else
    render :edit
  end
end

Give it a try in the browser. Very flashy indeed!

2. Flash on Create

Next we want to flash “Movie successfully created!” when a movie has been successfully created. Given what you’ve learned, you should be able to do this blindfolded!

  1. In the create action, assign the notice message to the flash object.
    [ANSWER]
def create
  @movie = Movie.new(movie_params)
  if @movie.save
    redirect_to @movie, notice: "Movie successfully created!"
  else
    render :new
  end
end
  1. In the browser, create a movie and the flash message should glow green at the top of the resulting page.
  2. We didn’t have to change a view template for this to work. Why?
    [ANSWER]
Flash messages are being rendered in the layout file, so any action can set a flash and it will get picked up by the layout.

3. Flash on Destroy

Try destroying a movie in the browser and you’ll notice there’s no visual feedback that it actually happened. Let’s wrap up this exercise by fixing that, and adding a red twist…

  1. Change the destroy action to display a flash message when a movie is destroyed. This time make it an alert instead of a notice, since destroying stuff can be kinda alarming.
    [ANSWER]
def destroy
  @movie = Movie.find(params[:id])
  @movie.destroy
  redirect_to movies_url, alert: "Movie successfully deleted!"
end
  1. Remember that your _flash partial displays both notice and alert messages, so you don’t need to make any changes to display alert messages.
  2. Double-check that it works in the browser by destroying a movie. You should see a reddish flash as a confirmation.
  3. If you want to re-populate the database with all the example movies, use:
rails db:seed:replant

This task removes all the data from the database tables and “replants” the database with the seed data in db/seeds.rb.

Solution

The full solution for this exercise is in the flash directory of the code bundle.

Bonus Round

Flexible Flashes

This solution works good, but it has a limitation: the layout file currently only displays notice and alert flashes. You might want something a bit more flexible that can display arbitrary types of flash messages in a more generic way. It’s a nice-to-have thing, so we went ahead and cooked up a solution for you.

Change your _flash.html.erb partial file to display flash messages like this:

<% flash.each do |type, message| %>
  <%= content_tag(:div, message, class: "flash #{type}") %>
<% end %>

Don’t let this throw you. It just iterates through all the flash keys and values, and uses the content_tag helper to generate a styled div tag for each message. That way, whatever you put in the flash will get displayed with a little style. Notice that by using an iterator we don’t need a conditional because the iterator only runs for the keys that are in the flash.

Make sure to remove the div tags we added earlier to render the notice and alert flashes separately. Otherwise you’ll get duplicate flash messages!

Give it a whirl in the browser just to make sure the same green and red flashes show up!

Custom Flash Types

By default, Rails supports setting the :notice and :alert flash types when calling the redirect_to method. But sometimes you want to conveniently set a custom flash type when redirecting. For example, suppose you wanted to set a :danger flash type like so:

redirect_to movies_url, danger: "Danger, Will Robinson!"

To do that, you’ll need to register the :danger flash type:

  1. Add the following line inside of the ApplicationController class, which is defined in the app/controllers/application_controller.rb file:
class ApplicationController < ActionController::Base
  add_flash_types(:danger)
end

The ApplicationController is the base (parent) class that all other controllers inherit from (subclass), so anything you put in here applies to all controllers.
2. As an example of how you might use your custom flash type, comment out the code currently in the destroy action in your MoviesController and instead just redirect with a danger message, like so:

def destroy
  ...
  redirect_to movies_url, danger: "I'm sorry, Dave, I'm afraid I can't do that!"
end
  1. Now try to delete a movie and you should see the (unstyled) danger message flashed at the top of the page. Before the redirect happened, Rails automatically assigned the message to flash[:danger]. Then after the redirect happened the message got displayed because your flexible _flash.html.erb partial iterates through all the flash keys and values. You saw that coming, right?
  2. Finally, add some style to danger messages. You’ll need to define a CSS rule in custom.scss that matches the CSS classes flash and danger.
    [ANSWER]
.flash {
  /* existing styles here */

  &.danger {
    background-color: #FFA715;
  }
}
  1. Remember to revert your destroy action back to its original form before moving on!

Wrap Up

News flash: our app is now giving lots of good feedback! Flashes are kinda fun, but don’t go overboard with them. It’s typical to show flash messages to confirm the success of the create, update, and destroy actions as we’ve done in this exercise. Flashes are also useful for things like a “Welcome back!” message after a user has logged in and a “So long!” message when they log out. But try not to get too flashy, ok?

Now that we have the movie side of the house in good shape, it’s time to shift gears and work on adding reviews for each movie. That’s up next! But first, you deserve a break, and you knew we couldn’t resist recommending this movie.