Lesson Weekend

Now that we've covered class methods and class variables, we're ready to build the backend logic for our record store. Let's consider the methods we'll need to implement CRUD functionality:

The Record Store's table of methods for CRUD

READ: Backend Logic for Showing All Albums


Our mock database will be a hash. In addition to being efficient, hashes are a common data structure used in databases. (An array is much more inefficient.) We'll use a class variable for our mock database so any method inside our Album class can access it. It's now time to add the following code to album.rb in your Sinatra application.

lib/album.rb
class Album
  @@albums = {}

  def initialize(name)
    @name = name
  end
end

As we discussed in previous lessons, we are using the class variable @@albums = {} to mock a database. We've also added an initialize method. For the sake of simplicity, our albums just have a name. You are welcome to add more properties later when you build out the application further, but for now, it's best to just keep this simple so we can focus on core concepts.

It's time to write our first test. We'll add a method to return all of the results from our database. These results will be returned in the form of an array. In the lesson Class Variables, we discussed why Album.all() returns an array even though @@albums is a hash. A quick reminder: the .values() method, when called on a hash, returns an array.

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

describe '#Album' do
  describe('.all') do
    it("returns an empty array when there are no albums") do
      expect(Album.all).to(eq([]))
    end
  end
end

Since we don't have any albums yet, we just expect Album.all to equal an empty array. Here's the code to make it pass:

lib/album.rb
class Album
  @@albums = {}

  ...

  def self.all
    @@albums.values()
  end
end

CREATE: Backend Logic for Creating Albums


We're ready to start adding albums, which means we need a save() method. Let's add another test:

spec/album_spec.rb
require('rspec')
require('album')

describe '#Album' do
  describe('.all') do
    it("returns an empty array when there are no albums") do
      expect(Album.all).to(eq([]))
    end
  end

  describe('#save') do
    it("saves an album") do
      album = Album.new("Giant Steps", nil) # nil added as second argument
      album.save()
      album2 = Album.new("Blue", nil) # nil added as second argument
      album2.save()
      expect(Album.all).to(eq([album, album2]))
    end
  end
end

We've added a second argument nil when instantiating each new album. This is because we are about to add another attribute to our initialize method.

Our new test is actually covering two things. First, it tests whether we can save albums to our mock database. Second, it verifies that Album.all() works for multiple items. Generally, we want our test to cover just one thing, but in this case, we will be feeding two birds with one scone. Now let's add code to make it pass.

We'll imitate a serial ID from a SQL database. In a SQL database, each row has a unique ID. A new entry will never have the same ID as a previous row, even if that previous row has been deleted. This is another concept we covered in Intermediate JavaScript. Databases can do this in several ways. One is a serial ID where the value always increments. Let's add a #save() method as well as functionality to serialize albums in our mock database.

lib/album.rb
class Album
  attr_reader :id, :name # Our new save method will need reader methods.

  @@albums = {}
  # We've added a class variable (below) to keep track of total rows
  # and increment the value when an Album is added.
  @@total_rows = 0 

  def initialize(name, id) # We've added id as a second parameter.
    @name = name
    @id = id || @@total_rows += 1  # We've added code to handle the id.
  end

  def self.all
    @@albums.values()
  end

  def save
    @@albums[self.id] = Album.new(self.name, self.id)
  end
end

We add an attribute reader for id and name because our save() method needs to be able to read both of these attributes. Note that we haven't included parentheses for attr_reader's arguments here. Ruby is often very flexible about parentheses and we can use a space instead of parentheses to denote an argument. The attr_reader method has the exact same functionality either way.

Next, we'll add another class variable: @@total_rows = 0. This is our mock database taking care of bookkeeping for us.

In our initialize method, we add an @id and set its value to @id = id || @@total_rows += 1. What's going on here? If id has a value, it will become the value of @id. However, if id is nil (which is falsy), the value of @id will be @@total_rows += 1. This is very important because we don't want a new row to be added to our mock database if an album already has an id.

Let's quickly reiterate what the || means in this context since it's an important little feature of Ruby. We are using the || to help determine the value that will be assigned to the @id variable. Let's take a look at a couple of pseudocoded examples to clarify what the || does.

# This is pseudocode.

variable = truthy || do_something

In the example above, the part before the || is truthy. (This could be a number, a string, or anything else that's truthy in Ruby.) The value assigned to the variable will be the part before the ||. The code after || won't run.

However, this example is different:

# This is pseudocode.

variable = falsy || do_something

The value before the || is falsy. There aren't many things that are falsy in Ruby but nil is one of them. If the value before the || is falsy, the part after the || will be executed (do_something) and the value of the variable will be whatever do_something returns.

This is a really helpful feature of Ruby, since there are many situations where a value might evaluate to nil or something else that's falsy — and we might want to run some other code so the value of the assigned variable doesn't end up being falsy, too. After all, it would really muck up our mock databases if some of the albums had an id attribute of nil.

Let's return to our Album code. Each value in our hash will be an Album object. In a Ruby application that uses an actual SQL database, Ruby will "translate" a SQL row into a Ruby object so it can properly be handled in the application. By making each hash value a Ruby object, we are somewhat imitating this process — when we retrieve an album from our mock database, it will already be a Ruby object. This is actually really important — after all, if they aren't Album objects, we won't be able to call Album methods on them!

This should also clarify why we have the line @id = id || @@total_rows += 1. Otherwise we'd be incrementing our id every time an album is saved in our mock database. However, we've already instantiated a new Album before calling our #save() method, which means we'd be incrementing the id twice each time we create and save a new album.

The #save() method itself is relatively simple:

...
  def save
    @@albums[self.id] = Album.new(self.name, self.id)
  end
...

We instantiate a new album object that holds the album's name and id. We could also store other values here, such as the cost of the album, release year, and so on. Note we don't need to use self in this method; for instance, we could say @@albums[id] instead of @@albums[self.id] because Ruby can understand an implicit instead of an explicit self. However, this can be confusing for new developers so it's best to just include self for now.

Let's run our tests. Unfortunately, our new test fails. Let's take a look at the output:

#Album #save saves an album
     Failure/Error: expect(Album.all).to(eq([album, album2]))

       expected: [#<Album:0x007fe45ea687f0 @name="Giant Steps", @id=1>, #<Album:0x007fe45ea68778 @name="Blue", @id=2>]
            got: [#<Album:0x007fe45ea687c8 @name="Giant Steps", @id=1>, #<Album:0x007fe45ea68750 @name="Blue", @id=2>]

This is one of the issues we discussed in our previous lesson on testing. Because these are two different objects, Ruby doesn't know they are supposed to be the same album. This is where we need to overwrite the == operator. Here's a test along with the passing code:

spec/album_spec.rb
...
  describe('#==') do
    it("is the same album if it has the same attributes as another album") do
      album = Album.new("Blue", nil)
      album2 = Album.new("Blue", nil)
      expect(album).to(eq(album2))
    end
  end
...
lib/album.rb
...
  def ==(album_to_compare)
    self.name() == album_to_compare.name()
  end
...

Now our tests should all pass.

Let's make one other change to our tests now. If we were to switch the order of our tests around so that our #save() method is tested first, we'll get a failure. Let's take a look:

Failures:

  1) #Album .all returns an empty array when there are no albums
     Failure/Error: expect(Album.all).to(eq([]))

       expected: []
            got: [#<Album:0x007fcb7b8aa590 @name="Giant Steps", @id=1>, #<Album:0x007fcb7b8aa518 @name="Blue", @id=2>]

       (compared using ==)

       Diff:
       @@ -1,2 +1,3 @@
       -[]
       +[#<Album:0x007fcb7b8aa590 @name="Giant Steps", @id=1>,
       + #<Album:0x007fcb7b8aa518 @name="Blue", @id=2>]

Our test fails because @@albums is no longer empty. In other words, the value of @@albums is persisting and isn't cleared. We need a method that will clear @@albums between each test — this is the other issue we discussed in the previous lesson on testing.

DELETE: Clearing All Albums


Here's a test for Album.clear() along with the code to make it pass. (Note that the test won't actually pass until we add one more piece of functionality in just a moment.)

spec/album_spec.rb
...
  describe('.clear') do
    it("clears all albums") do
      album = Album.new("Giant Steps", nil)
      album.save()
      album2 = Album.new("Blue", nil)
      album2.save()
      Album.clear()
      expect(Album.all).to(eq([]))
    end
  end
...
lib/album.rb
...
  def self.clear
    @@albums = {}
    @@total_rows = 0
  end
...

Our Album.clear() method just needs to reset the value of @@albums to an empty hash. Note that we also reset @@total_rows as well. (We should test this thoroughly and ensure ids truly start from 1 again but we're moving on for the sake of brevity.)

In order to actually get our new test to pass, we need to actually clear our mock database between each test. As we discussed in the last lesson, we can do this with a before(:each) block in our spec file. Let's add one of those now.

spec/album_spec.rb
...
  describe '#Album' do

    before(:each) do
      Album.clear()
    end

    # Other tests go here...
  end
...

At this point, our tests should all be passing.

READ: Backend Logic for Locating a Specific Album


We're ready to create a .find() method. This method will allow us to locate a specific album. This will allow us to route to the album's specific detail once we connect our backend logic to our Sinatra application. Currently, albums just have a name, but in a real world application, it would be typical for the album's detail page to include more information such as album art, a track list, and perhaps a summary. Our .find() method will also be necessary for updating or deleting a specific album as well. Here's the test and the method to make it pass:

spec/album_spec.rb
...
  describe('.find') do
    it("finds an album by id") do
      album = Album.new("Giant Steps", nil)
      album.save()
      album2 = Album.new("Blue", nil)
      album2.save()
      expect(Album.find(album.id)).to(eq(album))
    end
  end
...
lib/album.rb
...
  def self.find(id)
    @@albums[id]
  end
...

Once again, using a hash makes this easy. All we need is to find the corresponding key in the hash. As a counterpoint, if we stored our albums in an array instead, we'd have to iterate through the array until we found the correct album. That's slow and involves extra code. Hashes are awesome because it's super-fast and easy to add, remove, and find key-value pairs.

UPDATE: Backend Logic for Updating an Album


We're ready to add a test for our #update() method. This method will update a specific album.

spec/album_spec.rb
describe('#update') do
  it("updates an album by id") do
    album = Album.new("Giant Steps", nil)
    album.save()
    album.update("A Love Supreme")
    expect(album.name).to(eq("A Love Supreme"))
  end
end

We start by creating a new album. Then we save and update it. The name should be changed accordingly. Here's the method:

lib/album.rb
class Album
  ...

  def update(name)
    @name = name
  end

Updating the album is as simple as just updating the value of the instance variable @name.

DELETE: Backend Logic for Deleting an Album


Finally, we need a #delete() method for deleting a single instance of an Album. Here's our test:

spec/album_spec.rb
...
  describe('#delete') do
    it("deletes an album by id") do
      album = Album.new("Giant Steps", nil)
      album.save()
      album2 = Album.new("Blue", nil)
      album2.save()
      album.delete()
      expect(Album.all).to(eq([album2]))
    end
  end
...

We create two albums and then delete one of them. Why not create just one album? The problem with doing that is we wouldn't be able to verify that our delete() method isn't inadvertently deleting all of our albums.

Here is the code to make it pass:

lib/album.rb
class Album
  ...
  def delete
    @@albums.delete(self.id)
  end
end

Once again, Ruby's Hash class provides a handy method for us: a built-in delete() method that removes a key-value pair.

We've covered a lot of ground in this lesson. In the process of building out the backend logic for CRUD functionality in our application, we've also gotten more practice with testing, working with hashes, and using class methods and variables. That's a nice thing about working with Sinatra — almost everything we do will be plain old Ruby.

All the code so far is in the example repo below. Disregard the code in app.rb and the views folder for now. We'll get to that soon.

Example GitHub Repo for Record Store

Methods Needed for CRUD:


Action Method Class or Instance method? Description
CREATE .save() Instance method We need a method to save a single instance of an album to our mock database.
READ .all() Class method This method will return a list of all albums from our mock database.
READ .find() Class method In order to look up more information about a single instance of an album, we will need to be able to find it from the list of albums. We'll also need our find() method for updating and deleting albums as well.
UPDATE .update() Instance method This method will update a single instance of an album in our mock database.
DELETE .delete() Instance method This method will delete a single instance of an album from our mock database.
DELETE .clear() Class method This method will empty our mock database. (We'll use this method for testing purposes.)

General Setup


Mock Database

  • We can create a mock database in a class like this (@@albums is the mock database — it is just a class variable):
class Album
  @@albums = {}
end

.all Method

  • We can use a basic hash method to retrieve values from our mock database for an Album.all() method:
lib/album.rb
...
  def self.all
    @@albums.values()
  end
...

#save Method

To mock saving records in a database, we need to add a row number (the id of a record) when it is saved:

lib/album.rb
class Album
  attr_reader :id, :name #Our new save method will need reader methods.
  ...
  @@total_rows = 0 # We've added a class variable to keep track of total rows and increment the value when an Album is added.

  def initialize(name, id) # We've added id as a second parameter.
    @name = name
    @id = id || @@total_rows += 1  # We've added code to handle the id.
  end

  def save
    @@albums[self.id] = Album.new(self.name, self.id)
  end
end

.clear Method


We need a method to clear all records in our mock database (for testing purposes):

lib/album.rb
...
  def self.clear
    @@albums = {}
    @@total_rows = 0
  end
...

.find Method


We need to be able to search our mock database to find a specific record:

lib/album.rb
def self.find(id)
  @@albums[id]
end

#update Method


We need to be able to update specific records in our mock database:

lib/album.rb
class Album
  attr_accessor :name # Any field we want to update needs to have read/write with attr_accessor.

  ...

  def update(name)
    self.name = name
    @@albums[self.id] = Album.new(self.name, self.id)
  end

#delete() Method


We also need to be able to delete a specific record. We can just use a built-in hash method:

lib/album.rb
class Album
  ...
  def delete
    @@albums.delete(self.id)
  end
end

Lesson 11 of 37
Last updated August 7, 2022