Destroying Records

Exercises

Objective

Our web interface is coming together nicely. We can create, read, and update movies in the browser. So what's left? Well, there's currently no way to delete a movie via the web. To do that, we need to:

  • Generate a "Delete" link on the show page

  • Define a destroy action in the MoviesController that deletes the movie from the database

Visually, here's what we want to do:

Notice that we don't need to add a new route to handle deleting movies. The resource routing conventions we used in a previous exercise already have that route set up for us. So given everything we've learned so far, this task is well within your reach...

1. Add a Delete Link

We'll start by generating a "Delete" link on the show page. To do that, we'll enlist the help of a route helper method.

  1. Start by reviewing all the routes we have so far:

    Helper         HTTP Verb        Path                  Controller#Action
    root_path         GET        /
    movies_path       GET        /movies(.:format)           movies#index
                      POST       /movies(.:format)           movies#create
    new_movie_path    GET        /movies/new(.:format)       movies#new
    edit_movie_path   GET        /movies/:id/edit(.:format)  movies#edit
    movie_path        GET        /movies/:id(.:format)       movies#show
                      PUT        /movies/:id(.:format)       movies#update
                      PATCH      /movies/:id(.:format)       movies#update
                      DELETE     /movies/:id(.:format)       movies#destroy
    

    Whew! This is ending up as a checklist of to-do items, and we're almost done. The only route we haven't implemented is on the last line. According to that route, a DELETE request for /movies/:id maps to the destroy action in the MoviesController. (Yeah, it's confusing that the action isn't called delete, but what can we say.)

    So, what's the name of the route helper method that generates link matching that route? Well, there's no helper in the leftmost column, but if you look up that column you'll run into the helper movie_path. Unless otherwise specified, route names get inherited from one row to the next. So the name of the route helper method to delete a movie is movie_path.

  2. At the bottom of the show page, generate the "Delete" link using the helper method.

    The route has an :id placeholder—we have to identify the movie we want to delete—so you have to pass the @movie object as a parameter.

    <section class="admin">
      <%= link_to 'Edit', edit_movie_path(@movie), class: 'button' %>
      <%= link_to "Delete", movie_path(@movie), class: 'button' %>
    </section>
    
  3. Now go ahead and refresh the show page and click the "Delete" link. You should end up right back on the show page. But it should have tried to run a not-yet-defined destroy action and failed, right? Hmm... something's not quite right.

    This is one of those times when the easiest way to diagnose what's going on is to watch the log file. Open the console window where your app is running, hit Return a few times to create a bunch of white space in the scrolling log, and then in the browser click the "Delete" link again. In the log file you should see an incoming request that looks like this:

    Started GET "/movies/1" for ::1 at 2019-06-03 16:04:32 -0600
    Processing by MoviesController#show as HTML
    

    The URL /movies/1 looks right, but notice that the HTTP verb is GET. And according to the routes, a GET request for /movies/:id maps to the show action. So the router is doing exactly what we told it to do! The problem is the link we generated isn't triggering the delete route.

  4. Returning your attention to the defined routes, notice that the show and destroy routes share a lot in common:

    movie_path   GET      /movies/:id(.:format)    movies#show
                 DELETE   /movies/:id(.:format)    movies#destroy
    

    Interestingly, both of these routes recognize the same requested URL: /movies/:id. As well, they both have the same route helper method: movie_path. The only difference between these two routes is the HTTP verb (in the second column from the left). To show a movie we need to send a GET request and to delete a movie we need to send a DELETE request.

  5. So how exactly do we send a DELETE request? Unfortunately, browsers can't send DELETE requests natively, so Rails fakes them using some JavaScript.

    Change the "Delete" link to trigger the delete route by setting the method option to :delete, like so:

    <section class="admin">
      <%= link_to 'Edit', edit_movie_path(@movie), class: 'button' %>
      <%= link_to 'Delete', movie_path(@movie), class: 'button',
                             method: :delete, data: { confirm: 'Are you sure?' } %>
    </section>
    

    Now we're calling link_to with an optional third parameter that's a hash of options. The method option is used to override the HTTP method. We've also used the confirm option to pop up a JavaScript confirmation dialog when the link is clicked. That way movies don't accidentally get deleted.

  6. To see what that generated, refresh the page and view the source. Search for the "Delete" link and you should see this little nugget:

    <a class="button" data-confirm="Are you sure?" rel="nofollow" data-method="delete" href="/movies/14">Delete</a>

    Notice how the link_to options got turned into data- attributes and values. For example, the data-method attribute has a value of delete. That tells Rails that this link should send a DELETE request rather than a GET request.

  7. Finally, click the "Delete" link again and this time a confirmation dialog should pop up. Click "OK" and you should see the following error:

    Unknown action
    
    The action 'destroy' could not be found for MoviesController
    

    Perfect! Now we know we have the "Delete" link properly configured to trigger the delete route.

2. Implement the Destroy Action

It's all downhill from here...

  1. Implement the destroy action in the MoviesController. Once the movie has been destroyed, redirect to the movie listing page.

    First, find the movie to delete using params[:id] and assign it to an @movie instance variable. Then call the destroy method on the movie object. Finally, redirect to the movie listing page.

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

    Strictly speaking, you don't need to assign the movie to an instance variable since the destroy action never shows the movie. It just redirects. That being the case, you could assign the movie to a local variable. But for consistency, we tend to use an instance variable.

  2. Then go back to the browser and click the "Delete" link again. This time you should end up back on the movie listing page and the movie you deleted shouldn't be displayed in the listing.

  3. Just to see what happened behind the scenes, check the log file (you may need to scroll back a bit). You should see that indeed clicking the link sent a DELETE request and the movie was deleted from the database:

    Started DELETE "/movies/14" for ::1 at 2019-06-03 16:12:51 -0600
    Processing by MoviesController#destroy as HTML
      Parameters: {"authenticity_token"=>"Zd7oa0CPg1P1/ObENZb22VjQHrbJS1loXrxp42lSNdw=", "id"=>"14"}
    Movie Load (0.1ms)  SELECT "movies".* FROM "movies" WHERE "movies"."id" = ? LIMIT ?  [["id", 14], ["LIMIT", 1]]
       (0.0ms)  begin transaction
    Movie Destroy (0.3ms)  DELETE FROM "movies" WHERE "movies"."id" = ?  [["id", 14]   (1.1ms)  commit transaction
    Redirected to http://localhost:3000/movies
    Completed 302 Found in 5ms (ActiveRecord: 1.6ms | Allocations: 2871)
    

    (The HTTP verb is DELETE and the SQL statement uses DELETE, but the conventional Rails action is called destroy. Go figure.)

  4. Finally, use your fancy new web interface to re-create the movie you just deleted! :-)

Solution

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

Bonus Round

Book App

Suppose you had a Book resource and you wanted to create a web interface for creating, reading, updating, and deleting (CRUD) books. To do that, you'd tell the Rails router to generate all the routes using:

resources :books

Given the routing conventions, can you fill in the table below?

Wrap Up

Congratulations, you've now successfully implemented all these routes!

root_path         GET        /
movies_path       GET        /movies(.:format)           movies#index
                  POST       /movies(.:format)           movies#create
new_movie_path    GET        /movies/new(.:format)       movies#new
edit_movie_path   GET        /movies/:id/edit(.:format)  movies#edit
movie_path        GET        /movies/:id(.:format)       movies#show
                  PUT        /movies/:id(.:format)       movies#update
                  PATCH      /movies/:id(.:format)       movies#update
                  DELETE     /movies/:id(.:format)       movies#destroy

Here's the really cool part: All resources follow these same routes and conventions. So once you understand how these eight routes work, you're golden. In your own app, tell the Rails router to generate all the routes for you and then you're ready to step through implementing each route, exactly as we did for movies.

When you boil it down, the only thing that changes is the names of things. That's the power of conventions. And through these conventions, Rails does a lot to help us quickly stand up CRUD-based applications. That gives you more time to focus on the stuff that makes your application unique: the business logic.

And in the next section we'll start adding some of that to our app...