Lesson Weekend

There is one more thing we need to update. In a one-to-many relationship, it is fairly common to delete the "many" whenever the "one" is destroyed. This is because the many are dependent on the one. All Sales Vinyl sells records, not mp3s, so the store only wants Songs that belong to Albums. If we were to delete an Album without removing its dependent Songs, we'd end up with orphan Songs in our database that don't belong to an Album. They can clutter our database and potentially wreak havoc on our application if we try to access them.

Let's update the delete method in our Album class to destroy all dependent Songs when an Album is deleted.

First, we'll write a test:

spec/album_spec.rb
describe('#delete') do
  it("deletes all songs belonging to a deleted album") do
    album = Album.new({:name => "A Love Supreme", :id => nil})
    album.save()
    song = Song.new({:name => "Naima", :album_id => album.id, :id => nil})
    song.save()
    album.delete()
    expect(Song.find(song.id)).to(eq(nil))
  end
end

Our test creates an Album and Song, deletes the Album instance and then verifies the Song instance no longer exists when we try to find it by id. Remember that when we remove a row from a database, that id will never be used again. This is an important way that databases keep their integrity. Also note that we want our Song.find method to return nil if there are no results.

However, if we run the test to make sure we have a meaningful fail, there is an error:

Failure/Error: (self.name() == song_to_compare.name()) && (self.album_id() == song_to_compare.album_id())

     NoMethodError:
       undefined method `name' for nil:NilClass
     # ./lib/song.rb:13:in `=='
     # ./spec/album_spec.rb:89:in `block (3 levels) in <top (required)>'

This isn't actually a meaningful fail so we should fix it. We can trace the error back to the == method in our Song class:

lib/song.rb
def ==(song_to_compare)
    (self.name() == song_to_compare.name()) && (self.album_id() == song_to_compare.album_id())
end

The issue is fairly straightforward. nil doesn't have a name method, nor should it, so the application throws an error when we try to pass nil in as an argument for song_to_compare.

This is a quick fix:

lib/song.rb
def ==(song_to_compare)
  if song_to_compare != nil
    (self.name() == song_to_compare.name()) && (self.album_id() == song_to_compare.album_id())
  else
    false
  end
end

Now we'll get a meaningful fail and we can move onto the next step. Let's update the delete method for our Album class:

lib/album.rb
def delete
  DB.exec("DELETE FROM albums WHERE id = #{@id};")
  DB.exec("DELETE FROM songs WHERE album_id = #{@id};") # new code
end

When we run our tests again, we get a new error:

#Album #delete deletes all songs that belong to an album
     Failure/Error: name = song.fetch("name")

     NoMethodError:
       undefined method `fetch' for nil:NilClass
     # ./lib/song.rb:38:in `find'
     # ./spec/album_spec.rb:89:in `block (3 levels) in <top (required)>'

Getting repeated errors can often be a frustrating process, especially for beginning coders, because it can be difficult to tell if the new error is a step back or a step forward. Why is our test throwing an error in yet another method? Let's take a closer look at the stack trace, which states that the error originates in this method:

lib/song.rb
 def self.find(id)
  song = DB.exec("SELECT * FROM songs WHERE id = #{id};").first
  name = song.fetch("name")
  album_id = song.fetch("album_id").to_i
  id = song.fetch("id").to_i
  Song.new({:name => name, :album_id => album_id, :id => id})
end

The error is actually very similar to the previous error. nil doesn't have a fetch method. If we look at the previous line of code, this makes sense — the song no longer exists so our SQL statement returns an empty hash. When we call the first method on an empty hash, it returns nil. However, we want to return nil if our Song.find() method doesn't find any results. Let's update our method to fix the error and ensure our method returns nil if there is no song with that particular id:

lib/song.rb
def self.find(id)
  song = DB.exec("SELECT * FROM songs WHERE id = #{id};").first
  if song
    name = song.fetch("name")
    album_id = song.fetch("album_id").to_i
    id = song.fetch("id").to_i
    Song.new({:name => name, :album_id => album_id, :id => id})
  else
    nil
  end
end

Now our test passes. In addition to writing a test, we had to update three methods in two classes to get the test passing. In the process, we've exposed another potential issue in our application. What if a user tries to navigate to the URL of an Album that doesn't exist? Try running the application and entering a URL like this: http://localhost:4567/albums/221124. We'll get the same undefined method 'fetch' for nil:NilClass' error that we just got for our Song class when passing nil into the Song.find() method.

This illustrates an important point. Everything can seem to be working just fine in an application but there can be less obvious bugs like this one. Always try to consider the full extent of a user's possible behaviors as well as reasonable inputs that might be passed into a method. Write tests to cover this behavior and make sure they pass. Integration tests can also help expose these bugs as well. Ultimately, as an application expands, it's very difficult to catch everything.

In the case of this new bug, the solution is very similar to the code we just added to the Song class. We'll leave the bug fix for you to solve.

Example of #delete() Method That Destroys Dependents


def delete
  DB.exec("DELETE FROM albums WHERE id = #{@id};")
  DB.exec("DELETE FROM songs WHERE album_id = #{@id};")
end

Lesson 10 of 29
Last updated August 7, 2022