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.

    For a refresher on the migration generator options, run the rails g migration command.

    rails g migration AddMoreFieldsToMovies director:string duration:string image_file_name:string
  2. 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
    
  3. Then apply the new migration.

    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) =================================
  4. Now try running the migration again, and this time you shouldn't get any output because all the migrations have already run.

  5. Just to see where things stand, display the status of the migrations.

    Show Answer

    Hide 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. :-)

  2. To see the current state of the migration, go ahead and check the migration status again.

    Show Answer

    Hide 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
  3. 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.

    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.

    Show Answer

    Hide 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.

    Show Answer

    Hide Answer

    def movie_params
      params.require(:movie).
        permit(:title, :description, :rating, :released_on, :total_gross,
                :director, :duration, :image_file_name)
    end
    
  4. 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.

    Show Answer

    Hide Answer

    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.

    Show Answer

    Hide 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.)

    Show Answer

    Hide Answer

    <section class="movie">
      <div class="image">
        <%= image_tag movie.image_file_name %>
        </div>
        <div class="summary">
          ...
        </div>
      </section>
    

    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:

Show Answer

Hide 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.

Dive Deeper

To learn more about migrations, refer to the Rails Guides: Migrations.