Build a Fullstack Rails App: Part Two - Models & Tables

Review & Intro

Please make sure to read the previous article in this series to get an idea of what we are building, an app to manage drawing names from a hat in order to match up people to exchange gifts. That article also explains the technology, frameworks, languages etc. we will be using here, which centers around rails.

If you haven't already, get Rails installed on your system, and as I mentioned in the last article, GoRails.com is a great resource for this:

https://gorails.com/setup/macos/13-ventura for mac

https://gorails.com/setup/windows/11 for windows

https://gorails.com/setup/ubuntu/22.04 for ubuntu

I usually use macOS, but just for fun I tried the windows install guide using WSL (Windows Subsystem for Linux) on my gaming pc, and it works great. A few more steps than if you were on a unix based OS like macOS or Ubuntu, but that's just fine.

If you can run rails -v in your terminal and get something like Rails 7.1.3.2, then you should be good to go!

You will also need some sort of text editor, like VS Code or neovim (among so many others). VS Code is a great choice if you are just getting started with programming. Neovim is a bit more difficult to setup and involves using the terminal and no mouse to navigate. Neovim is cool and what I have been using, but no pressure to use it starting out. VS Code is great starting out and also for experienced devs.

Oh and it seems like a given, but make sure your environment has 'git' installed (all the guides above will have it by default). This is a code versioning system to allow us to save our code step by step and also share it and collaborate with others.

New Rails App Setup

Lets get started with the CLI (command line interface) to generate a rails project. We will be entering all these commands into your terminal.

Navigate to or create a folder you would like to keep your projects in, like ~/code and run this command with something like cd ~/code you might have create that directory first with mkdir ~/code. Once your in the directory you want you code in run:
rails new names-from-hat
rails invokes the rails CLI, new is command to generate a new app and names-from-hat is the name I decided to call it. As part of this command you could give it numerous options, like a database or api-only among others. In this case we will just be using sqlite the default database, no options needed. In fact SQlite has had a recent push to be used more in production apps, checkout this blog post and podcast episode. We will try to stick with default rails as much as possible. Convention over configuration!

Now cd into your new app folder cd names-from-hat/
Create your database with rails db:create
Now we can start our rails server with rails server or rails s !
This will serve our rails app on just our local machine to localhost:3000, just type localhost:3000 into your browser and see what comes up. It should be a nice rails splash screen with some information about the version and stuff at the bottom. Its working! (I hope)

Creating Our Data Models & Migrations

Now that our base app is up and running, we need to do some 'backend' work. In the previous article I outlined what our data schema will look like to start and explained how I decided on it. For convenience I'll put it here as well:

table - hats
id:bigint (rails handles this automatically, will be present on all other tables)
created_at:datetime (rails handles this automatically, will be present on all other tables)
updated_at:datetime (rails handles this automatically, will be present on all other tables)
name:string

table - drawings
hat_id:bigint (references hat table), required
name:string, required, not null unique index on name and hat_id, so people won't get their drawings mixed up.

table - names
hat_id:bigint, references hats table, required
name:string, required, not null unique index on hat_id & name, so its clear who is who with no repeats.

table - name_matches
drawing_id:bigint (references drawing table), required
name_1_id:bigint (references name table), required
name_2_id:bigint (references name table), required unique index withname_1_id and name_2_id and drawing_id (don't want to create bad data)

Lets go from top to bottom and use the Rails CLI again to generate our model files. Models are ruby classes that inherit from active record to give them access to important conventions and methods so our rails app will know how to retrieve data from their corresponding database table, as well as any other custom methods we choose to write to perform tasks related to these objects.

One quick step before we start generating files, at some point in this guide, we will write some tests for our code, and I prefer to use rspec. So lets add that to our gemfile, and then as we use the cli generators, it will generate rspec files instead of mini-test ones. Open up your text editor and open up the Gemfile in the root of the project directory. Under group :development, :test do add
gem "rspec-rails", "~> 6.1.0" gem "factory_bot_rails"
Then in your console within your rails app directory run bundle
That will install those two gems that will help us write tests leter.

For the hat model run this command (you may need a new terminal window or to quit the server you may still have running)

rails generate model Hat name
generate could be replaced with g and invoke a rails generator, there are many including controller, scaffold, migration etc. You can read on the rails docs what each do. Some might want to use scaffold here, as it would generate the model, controller and views. However, I find I end up deleting a lot of what it defaults in all those, so we will stick with model to start and then controller later to give us controllers and views.
After you run that command, you will notice it outputted a bunch of files it created for us, notably the app/models/hat.rb model file and the db/migrate/2024sometimestamp_create_hats.rb (and also our test files under rspec).

Lets check those out. Open the app/models/hat.rb file and it should look pretty empty:

class Hat < ApplicationRecord
end

We have a class Hat that inherits from ApplicationRecord, which is our Rails class that helps our models act like models. For now we don't need much more code in here other than a validation to make sure when a user tries to save a hat record, it has a name. So we will add this line:

class Hat < ApplicationRecord
  validates :name, presence: true
end

With that validates line in place, whenever one of these records tries to be saved (there are ways to skip these validations), Rails will validate that that record has at least something, anything in that field. We could get more strict and say it must have a certain amount of characters or that it matches a certain kind of pattern etc. In this case, this will do exactly what we need it too. We aren't concerned what they save as the name, just that there is a name. That does cause me to think, if a few different people or even just one person is using this app, do we want multiple hats with the exact same name? No, at least for now with no users or anything like that, lets make sure that name is unique as well across the whole app. Later we can scope this to a user or something, but for now lets do this:

class Hat < ApplicationRecord
  validates :name, presence: true
  validates :name, uniqueness: true
end

Cool, that looks good for now. Lets move onto looking at our migration file at db/migrate/2024somedatetimestamp_create_hats.rb

class CreateHats < ActiveRecord::Migration[7.1]
  def change
    create_table :hats do |t|
     t.string :name

     t.timestamps
    end
  end
end

We can see here that will create table named hats, which a name field as a string and then the default rails timestamps, which will add created_at and updated_at to the table as well. It knew to add the field names, because we added it to the generator, and since we didn't specify a field type, it defaulted to string, which is what we want.

At this point I can't think of any additional fields, but we could consider database level validations or constraints. We added a model validation for presence and uniqueness, so we could mirror those for the database as well. This isn't always the right choice, but if its an important validation and you wouldn't want any data that breaks that to ever enter our database, then we can consider it. Since we plan on eventually changing our uniqueness validation, lets not enforce it at the database level, but the name one, we could make sure only non-null values make it in. We can always remove this constraint later if we run into any issues with it, but its actually harder to add it later than to take it away, so lets play it safe and add it.

class CreateHats < ActiveRecord::Migration[7.1]
  def change
    create_table :hats do |t|
     t.string :name, null: false

     t.timestamps
    end
  end
end

Nice, that constraint isn't strictly required to add, and in some situations it may be best not to, but if it isn't, we are saying its ok to add a record to this table with absolutely no data included (since name is the only field), which doesn't make sense. We don't want blank data records.

We have a few more tables to add, but lets run this migration and then play around with it a bit in a rails console.

In your terminal run this command:
rails db:migrate

You should see output saying it created the hats table.

Active Record in Rails Console

Now in the terminal run rails console (this could be shortened to rails c)
Now we can interact with the database through ActiveRecord. Remember how our Hats ruby class inherited from ActiveRecord? We can see that in action in our rails console now. Try running Hat.all This should simply return nothing, it probably will show empty brackets [ ]. That's because we haven't created any Hat records yet. Lets do that with Hat.create(name: "Red") . That should give you some output indicating it INSERTED INTO "hats" and it even showed it returned an active-record object. Had we done something like red_hat = Hat.create(name: "Red") that variable would have been set to an instance of that Hat class with the appropriate active record methods available.

Now lets try that first command again Hat.all which gives an array with just one member, that hat we created! Lets make another, but lets do it slightly different. Lets do blue_hat = Hat.new This didn't save the record to the database, but we do have an instance of the Hat class, with only nil values. Remember the validations we added? They didn't come into play yet, because we haven't saved it yet. Lets try to save it just for fun and see what happens blue_hat.save assuming you saved your files and wrote the validation correctly, it should give you a rollback transaction and false and now if we do Hat.all we can see it still only has one record. Lets see what the errors were with blue_hat.errors.full_messages that should give you an array with "Name can't be blank". Cool right? We could use that eventually in our form to tell our user what went wrong. Let's try to give blue_hat's name a value, but try to guess what will happen with this blue_hat.name = "Red" and then blue_hat.save did you guess right? It shouldn't have saved, and if you show the errors again blue_hat.errors.full_messages you should see "Name has already been taken". Right on, just like we wanted, no empty name fields and no duplicates possible.

We might as well save it correctly now with something like blue_hat.name = "Blue" and blue_hat.save and now Hat.all has two records. If we wanted to find one by its id or name we could do Hat.find(1) or Hat.find_by(name: "Red") those should both return the same record. You can see that active-record gives us a lot of shortcuts to writing SQL, and there's a lot more where that came from.

Now that we had an intro to active-record models, lets fly through the rest of these initial tables we are setting up. You can at any time jump back into that console and play around with them as we go. In fact at the end we can play around with the relationships between them and maybe a few other things.

The Rest of the Models & Relationships

Now the rest of these tables will hinge on hats, meaning they will relate to a hat, whether directly or through another model. Lets refer to our plan above for our tables and go down the list, in your console:

rails g model Drawing hat:references name

hat:references tells rails to set up a belongs_to relationship with the hat model we already set up, which means a drawing will belong to a hat. By default rails considers belongs_to relationships as required. Here is the drawing model file with what rails generated and similar validations for name we have to add ourselves:

class Drawing < ApplicationRecord
  belongs_to :hat # Rails added this for us

  validates :name, presence: true
  validates :name, uniqueness: { scope: :hat_id }
end

Notice how that uniqueness validation can take a hash of options, including that scope one, in this case scoping the uniqueness to those that belong_to the same hat. Its ok for two drawings to have the same name as long as they don't also have the same hat_id.

Lets also alter our drawing migration and add database level constraints reflecting those model validations. So in db/migration/datetimestap_create_drawings.rb:

class CreateDrawings < ActiveRecord::Migration[7.1]
  def change
    create_table :drawings do |t|
      t.references :hat, null: false, foreign_key: true
      t.string :name, null: false # added ", null: false"

      t.timestamps
    end
    # added the below line oustide the create table block
    add_index :drawings, [:hat_id, :name], unique: true 
  end
end

(FYI if we used name:uniq in our generator, it would have setup the unique constraints for us, I just wanted to practice writing them ourselves)

Note the add_index line which creates a unique index on hat_id and name, which constrains the database to that scoped unique validation we wanted, as well as will speed up lookups with a name and hat id, which will happen quite a bit.

Lets move on to the next model and run all these migrations at once later. Or if you want you can run them each time and play with them in the console.

For our Name model, lets just repeat exactly what we did before, but replacing Drawing with Name, so to get you started:

rails g model Name hat:references name

Now you can literally copy and paste our changes to the files to the Name version of the files since these models are so similar, at least at this level.

Now lets create our NameMatch model. This one is what will separate the drawing and name responsibilities apart, as it will put two names together as a child of a drawing, acting like a joins table for names to names and then within a drawing.

However as am thinking through this again, I want John to be able to give to Jane, and Jane to someone else and someone else to John for example, that way odd numbered parties can do drawings, and just so that it isn't just a pairing up. This actually will mostly change our field naming and eventual validations.

rails g model NameMatch drawing:references giver:references receiver:references
This doesn't quite get those name references right, and I'm unsure if we could alter it to work right, but we will just change it in the files it generated.
In our NameMatch.rb model file, we will have to add class_name: "Name" to our name relationships, since Rails will try to assume that there is a giver and receiver models to relate these too, but there isn't.

class NameMatch < ApplicationRecord
  belongs_to :drawing
  belongs_to :giver, class_name: "Name"
  belongs_to :receiver, class_name: "Name"

  validates :giver_id, :receiver_id, uniqueness: { scope: :drawing_id }
end

Then in our migration file for name_matches we will need to tell those references to point to the names table, and then also add a unique index for the giver & receiver to only appear once in the drawing in that role.

Now lets take a look at the migration for the name_matches table:

class CreateNameMatches < ActiveRecord::Migration[7.1]
  def change
    create_table :name_matches do |t|
      t.references :drawing, null: false, foreign_key: true
      t.references :giver_id, null: false, foreign_key: { to_table: :names }
      t.references :receiver_id, null: false, foreign_key: { to_table: :names }

      t.timestamps
    end

    add_index :name_matches, [:drawing_id, :giver_id], unique: true
    add_index :name_matches, [:drawing_id, :receiver_id], unique: true
  end
end

The main thing we had to change above was the giver & receiver ids foreign_key to point to the names table. I also went ahead and added unique indexes to match our rails unique validations we added.

One last thing, we set up belongs_to relationships, but we also can add has_many and has_one relationships to the parent models so that not only can we do drawing.hat, but we can do hat.drawings. So here are our three model files that changed, hat.rb & drawing.rb

class Hat < ApplicationRecord
  has_many :drawings, dependent: :destroy
  has_many :names, dependent: :destroy

  validates :name, presence: true
  validates :name, uniqueness: true
end
class Drawing < ApplicationRecord
  belongs_to :hat
  has_many :name_matches, dependent: :destroy

  validates :name, presence: true
  validates :name, uniqueness: { scope: :hat_id }
end
class Name < ApplicationRecord
  belongs_to :hat
  has_one :receiving_name_match, class_name: "NameMatch", foreign_key: "receiver_id"
  has_one :giving_name_match, class_name: "NameMatch", foreign_key: "giver_id"
  has_one :giving_to, through: :giving_name_match, source: :receiver
  has_one :receiving_from, through: :receiving_name_match, source: :giver

  validates :name, presence: true
  validates :name, uniqueness: { scope: :hat_id }
end

The last one for Name got a bit more complicated, in fact it took me a few minutes to think though it. A name in a drawing will be able to have one giving_to (name) and receiving_from (name), but we find that though the NameMatch they relate to as either the giver or receiver, and then the opposite role.

Phew! I think we did it. That's all our tables & their corresponding models we wanted to add. Lets run migrations to see if we did it right or have any typos.

rails db:migrate

Confession time, I copied and pasted (like I suggested) the drawing migration over to the name migration and forgot to change the index to be for the name table instead of the drawing table, and I got an error when I ran migrations. Maybe you did something similar since I didn't spell out that code and said to just copy it over.

I also misspelled receiver in a few places, so I had to do a rails db:rollback to undo the name_matches migration, so I could then fix the spelling and then run the migration again. These kinds of things from time to time, especially misspellings on words like receiver (its easy to mix the e and i up for me), so its good to know how to undo and redo these cli commands. For example, sometimes have to do something like rails destroy model NameMatch if I wanted to change something in that generator or if I wanted to rename something. Just make sure to rollback any related migrations first! This is all of course before you've deployed this code and people are using it. In that case you may have to handle it differently with change table actions rather than just redoing it.

But anyway, your migrations should now have ran successfully, so lets play around with the database again in a rails console with rails c
If you forget any field names or what while in the console you can just type out the model name and new, at least that's one easy way like Drawing.new which shows it has a hat_id and name among the default fields.
Hopefully you followed along above and still have some Hat records, you can check with Hat.all
After you have one, you can get it easily by doing Hat.first or Hat.last (to get either the first or last record in that table, obviously). Lets set up some data underneath that first hat and set up some convenient variables: (if you have your terminal still running, you may need to do reload! or even quit and then start it again to get new code since you opened it)
hat = Hat.first
luke = hat.names.create(name: "Luke")
leia = hat.names.create(name: "Leia")
han = hat.names.create(name: "Han")
chewie = hat.names.create(name: "Chewie")
drawing = hat.drawings.create(name: "Life Day 1978")

You can see that by using hat.names you can then do new or create or find and it will automatically scope it to that hat. So in this case, it will give a the new name the right hat_id without having to spell it out in the create statement.
Now, eventually, the hat model will be able to automatically create name matches, probably with an after_create callback, since one existing without name matches is pointless with our assumptions and opinions on how this will work. But lets set some up manually for fun. We will have chewie give to leia, leia to han and han to luke and luke to chewie.
drawing.name_matches.create(giver: chewie, receiver: leia) drawing.name_matches.create(giver: leia, receiver: han)
drawing.name_matches.create(giver: han, receiver: luke)
drawing.name_matches.create(giver: luke, receiver: chewie)

There you go! We have a database and models can can support creating hats, those hats having names, drawings and those drawings & names having name matches. We have validations both on the model and matching constraints on our tables so we can't get bad data by accident or when we try to add onto this app. You can keep testing out some of those validations in model, for example try to recreate a name match the same way you already have, or create drawings or names with out names or with duplicate names. None of those should work. You can also see who gives to who and who receives from who easily. For example if you still have the luke variable you can do luke.giving_to and luke.receiving_from to see those relationships. Or see them all with drawing.name_matches.

One last thing: Git

One of the most important tools in a programmers toolbelt is git, a code version control system. It allows you to make 'commits' to a codebase, tracking changes as you go, enabling you to look at past commits and see what changed and when. Also and probably most importantly it makes collaborating with other programmers possible. I think rails new should have initialized a git repo for you, you can check this by running git status and it should give you a list of untracked files if your repo is initialized, if it says there isn't a git repo yet, then you will need to simply run git init. and then if you run git status again you will see that a lot of files are now tracked and ready to commit. So for now we can simply do git add . and then lets make our first commit git commit -m "Initial commit, adds base models" . -m means 'message' and then what follows in quote is the git message. These should be short yet descriptive. You can change the message if you want. We could have wrapped all the stuff generated with rails new in a initial commit and then did a commit for each model we created, but this should suffice for now.

Rails new probably also gave you a good .gitignore file which will ignore things like .env (secrets you don't want to share with the internet), tmp files, log files, assets, etc. There may be other directories and files you might want to ignore later or in different projects, like node_modules (which we don't have because we haven't added any and we probably won't since we will use import maps).

git status should show nothing to commit now and if you do git log it will show you all your commits and info about them, which should just be that one we just did. If you want to push this to a remote repository like github, you can follow a tutorial like this one for github. If you want to see all this code as it should look at the moment, checkout the commit I made for this step on github. That link will show you the entire initial commit, you will want to look at the models and migrations to see the code we specifically wrote today.

Since I pushed mine to a remote repo, it enables me to now easily switch over to a different computer by cloning the repo, which I think I will do. Using windows/linux on my gaming pc has been fun, but my macbook is more portable.

Summary

I think that is enough for this step, but soon we will want to do a couple things before we get to the front end. One is to write that algorithm that will create name matches for us, and another is to write some tests for these models and the code for that algorithm. In fact maybe we should try some TDD (Test Driven Development) for that algorithm, meaning we will write the tests first, then the methods for the algorithm. Next article.