Lesson Tuesday

Let's add a Song class that will give us full CRUD functionality for adding songs to our application. We'll start by adding song.rb to our lib folder and song_spec.rb to our spec folder. The methods listed in the table below will be implemented in almost the exactly same way we added functionality to our Album class.

`Song` class logic, the same as in `Album`

In addition to that, we'll need a few more methods as well:

Additional `Song` class logic, unlike the logic in `Album`

What follows are the two "tables" that we'll haves in our mock database, one for albums, and one for songs. In a SQL database, they actually will be tables.

`Album` id table

`Song` id table

Now if we want to find all the songs that belong to "Giant Steps", we just need to look for songs that have an album_id that matches the id of "Giant Steps." We'll find songs associated with an album using code that looks similar to this:

album = Album.find(id)
songs = Song.find_by_album(album.id)

As long as we have an album's id, it will be straightforward to find songs associated with it. However, we must always make sure that songs have an album_id — otherwise, they'd be in limbo and not connected to an album!

If this isn't fully clear yet, don't worry — we will cover relationships more in the next course section when we start using databases. For now, we want to stay focused on routing.

Tests and Logic for Song CRUD


Many of our Song methods will be similar to Album methods so we won't go through each test and method individually. Here is the code for most of our specs:

spec/song_spec.rb
require 'rspec'
require 'song'
require 'album'
require 'pry'

describe '#Song' do

  before(:each) do
    Album.clear()
    Song.clear()
    @album = Album.new("Giant Steps", nil)
    @album.save()
  end

  describe('#==') do
    it("is the same song if it has the same attributes as another song") do
      song = Song.new("Naima", @album.id, nil)
      song2 = Song.new("Naima", @album.id, nil)
      expect(song).to(eq(song2))
    end
  end

  describe('.all') do
    it("returns a list of all songs") do
      song = Song.new("Giant Steps", @album.id, nil)
      song.save()
      song2 = Song.new("Naima", @album.id, nil)
      song2.save()
      expect(Song.all).to(eq([song, song2]))
    end
  end

  describe('.clear') do
    it("clears all songs") do
      song = Song.new("Giant Steps", @album.id, nil)
      song.save()
      song2 = Song.new("Naima", @album.id, nil)
      song2.save()
      Song.clear()
      expect(Song.all).to(eq([]))
    end
  end

  describe('#save') do
    it("saves a song") do
      song = Song.new("Naima", @album.id, nil)
      song.save()
      expect(Song.all).to(eq([song]))
    end
  end

  describe('.find') do
    it("finds a song by id") do
      song = Song.new("Giant Steps", @album.id, nil)
      song.save()
      song2 = Song.new("Naima", @album.id, nil)
      song2.save()
      expect(Song.find(song.id)).to(eq(song))
    end
  end

  describe('#update') do
    it("updates an song by id") do
      song = Song.new("Naima", @album.id, nil)
      song.save()
      song.update("Mr. P.C.", @album.id)
      expect(song.name).to(eq("Mr. P.C."))
    end
  end

  describe('#delete') do
    it("deletes an song by id") do
      song = Song.new("Giant Steps", @album.id, nil)
      song.save()
      song2 = Song.new("Naima", @album.id, nil)
      song2.save()
      song.delete()
      expect(Song.all).to(eq([song2]))
    end
  end
end

All of these specs are for methods that we covered for the Album class as well, so they should mostly be review. A few small differences:

  • We save an album in an instance variable in our before(:each) block to DRY up our code;
  • Songs have an additional property that albums don't: an album_id which we set to the value of @album.id;
  • Note that we need to require both our Song and Album class since our specs will rely on both.

Let's get the specs above to pass:

lib/song.rb
class Song
  attr_reader :id
  attr_accessor :name, :album_id

  @@songs = {}
  @@total_rows = 0

  def initialize(name, album_id, id)
    @name = name
    @album_id = album_id
    @id = id || @@total_rows += 1
  end

  def ==(song_to_compare)
    (self.name() == song_to_compare.name()) && (self.album_id() == song_to_compare.album_id())
  end

  def self.all
    @@songs.values
  end

  def save
    @@songs[self.id] = Song.new(self.name, self.album_id, self.id)
  end

  def self.find(id)
    @@songs[id]
  end

  def update(name, album_id)
    self.name = name
    self.album_id = album_id
    @@songs[self.id] = Song.new(self.name, self.album_id, self.id)
  end

  def delete
    @@songs.delete(self.id)
  end

  def self.clear
    @@songs = {}
  end
end

Once again, this should all be review; we wrote the exact same methods for our Album class.

Finding Songs by Album


We also need to add some new logic:

  • Album#songs() will be an instance method for returning a list of an album's songs;
  • We'll also need a Song.find_by_album() class method to actually find those songs that belong to an album;
  • Finally, we'll add Song#album() to return the album associated with a song.

Why do we need both of these methods? Well, it would be convenient to be able to call album.songs() and have access to all of an album's songs. However, the Album class doesn't have access to @@songs — and nor should it. Our Song class will do the filtering for us.

Let's start with the Song.find_by_album() method because Album#songs() will rely on it.

Here's a test for our new method:

spec/song_spec.rb
...
describe '#Song' do
...

  describe('.find_by_album') do
    it("finds songs for an album") do
      album2 = Album.new("Blue", nil)
      album2.save
      song = Song.new("Naima", @album.id, nil)
      song.save()
      song2 = Song.new("California", album2.id , nil)
      song2.save()
      expect(Song.find_by_album(album2.id)).to(eq([song2]))
    end
  end
end

We create two new songs, each associated with a different album. We should expect to find only the song that belongs to album2.

Here's our new method:

lib/song.rb
class Song
  ...
  def self.find_by_album(alb_id)
    songs = []
    @@songs.values.each do |song|
      if song.album_id == alb_id
        songs.push(song)
      end
    end
    songs
  end
end

This method loops through @@songs and pushes items with the correct album_id to an array. It is not a performant method, but it's suitable for our purposes. Fortunately, SQL databases handle these kinds of searches much more efficiently.

Now let's add an Album#songs test:

spec/album_spec.rb
require 'rspec'
require 'album'
require 'song'

describe '#Album' do
...
  describe('#songs') do
    it("returns an album's songs") do
      album = Album.new("Giant Steps", nil)
      album.save()
      song = Song.new("Naima", album.id, nil)
      song.save()
      song2 = Song.new("Cousin Mary", album.id, nil)
      song2.save()
      expect(album.songs).to(eq([song, song2]))
    end
  end
end

Here's the method:

lib/album.rb
class Album
  ...
  def songs
    Song.find_by_album(self.id)
  end
end

It's not ideal that we need to rely on another class to find songs, but it's better than using global variables — and our Album class shouldn't have access to @@songs.

We'll do something similar with our Song#album() method. First, a test:

spec/song_spec.rb
describe '#Song' do
  ...
  describe('#album') do
    it("finds the album a song belongs to") do
      song = Song.new("Naima", @album.id, nil)
      song.save()
      expect(song.album()).to(eq(@album))
    end
  end
end

Here's our new method:

lib/song.rb
class Song
  ...
  def album
    Album.find(self.album_id)
  end
end

Once again, we need to rely on another class. We now have all the backend methods we need to add routing for songs to our record store application. We'll cover that in the next lesson.

Lesson 26 of 37
Last updated August 7, 2022