Learning Rails – Modeling Meal Plans (Part 5)

Thu Dec 8, 2016 - 1500 Words

Today we’re going to start developing our main feature: meal planning. We’ll use test driven development to build out how a meal plan should work.

Goal for this Tutorial:

  • Test Drive the behavior of a meal plan.
  • Database modeling a meal plan.

Meal planning is the bread and butter of our application so we’re going to spend quite a bit of time thinking through how we’re going to model this so that it makes sense.

We need to find a way to structure a meal plan in our database and create a good interface for us to use a meal plan to build out a sane list of recipes to cook.

Renaming our Application

Before we get too far we’re going to need to make a change to our application config. When we generated the rails application we used the name of meal_plan which created a module for us to house our application configuration. This module is named MealPlan and because of that we can’t create a model named MealPlan. We’re going to change this module name to be MealPlanner. Here’s the new application.rb:

config/application.rb

require_relative 'boot'

require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_view/railtie"
require "action_cable/engine"
require "sprockets/railtie"

# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)

module MealPlanner
  class Application < Rails::Application
    # Settings in config/environments/* take precedence over those specified here.
    # Application configuration should go into files in config/initializers
    # -- all .rb files in that directory are automatically loaded.

    config.generators do |g|
      g.test_framework :minitest, spec: true
    end
  end
end

Modeling a Meal Plan

Up until now we haven’t really thought about what a meal plan consisted of, but in order for us to model it in our database we need to lay out what it means to be a meal plan. Here are the requirements that I can think of off hand:

  • Start Date
  • End Date
  • List of Recipes
  • User

That seems pretty simple, but it’s going to require us to create a few different database tables. The meal_plans table isn’t actually going to have much on it besides the start_date and end_date. The more involve model will actually be the Meal model which will act to join together a Recipe and a MealPlan. Our Meal model will need to have the following:

  • date
  • recipe_id
  • meal_plan_id

We need the date so that we know when we’re planning to cook the recipe. The Meal will hold onto both the meal_plan_id and recipe_id because recipes will be on numerous meal plans. We could get around this by having the MealPlan model have seven different recipe columns (monday_recipe, tuesday_recipe, etc), but this is less than ideal because what do we do when we also want to plan out lunches instead of just dinner? By having a separate model to associate recipes and meal plans we can be more explicit about what a meal is and that will allow us to be flexible moving forward.

Let’s create these models now:

$ rails g migration create_meal_plans
      invoke  active_record
      create    db/migrate/20161126154052_create_meal_plans.rb

Modifying the migration that was created we’ll add our date fields.

db/migrate/20161126154052_create_meal_plans.rb

class CreateMealPlans < ActiveRecord::Migration[5.0]
  def change
    create_table :meal_plans do |t|
      t.date :start_date, null: false
      t.date :end_date, null: false
      t.references :user, null: false, foreign_key: true
      t.timestamps null: false
    end
  end
end

Now we can generate our meals table:

$ rails g migration create_meals
      invoke  active_record
      create    db/migrate/20161126154355_create_meals.rb

db/migrate/20161126154355_create_meals.rb

class CreateMeals < ActiveRecord::Migration[5.0]
  def change
    create_table :meals do |t|
      t.date :date, null: false
      t.references :meal_plan, null: false, foreign_key: true
      t.references :recipe, null: false, foreign_key: true
      t.timestamps null: false
    end
  end
end

Since we have two new models we will go ahead and also create our factories now, leaving the implementations empty for a second:

$ rails g factory_girl:model MealPlan
      create  test/factories/meal_plans.rb
$ rails g factory_girl:model Meal
      create  test/factories/meals.rb

Finally, we’ll run our migrations and get ready to test drive these new models:

rake db:migrate

Validating the MealPlan and Meal Models

The initial set up of our MealPlan model will be pretty simple so we’re going to quickly write the tests for that.

test/model/meal_plan_test.rb

require "test_helper"

describe MealPlan do
  describe "validity" do
    let(:meal_plan) { MealPlan.new }

    before do
      meal_plan.valid?
    end

    it "requires a start_date" do
      meal_plan.errors[:start_date].must_include "can't be blank"
    end

    it "requires an end_date" do
      meal_plan.errors[:end_date].must_include "can't be blank"
    end

    it "requires a user" do
      meal_plan.errors[:user].must_include "can't be blank"
    end
  end
end

We can implement this right away based on what we’ve learned in a previous tutorial.

app/model/meal_plan.rb

class MealPlan < ApplicationRecord
  belongs_to :user

  validates :start_date, presence: true
  validates :end_date, presence: true
  validates :user, presence: true
end

Moving onto Meal we’ll have similarly simple tests to write:

test/models/meal_test.rb

require "test_helper"

describe Meal do
  describe "validity" do
    let(:meal) { Meal.new }

    before do
      meal.valid?
    end

    it "requires a date" do
      meal.errors[:date].must_include "can't be blank"
    end

    it "requires a meal_plan" do
      meal.errors[:meal_plan].must_include "can't be blank"
    end

    it "requires a recipe" do
      meal.errors[:recipe].must_include "can't be blank"
    end
  end
end

The initial implementation for Meal is also simple:

app/model/meal.rb

class Meal < ApplicationRecord
  belongs_to :meal_plan
  belongs_to :recipe

  validates :date, presence: true
  validates :meal_plan, presence: true
  validates :recipe, presence: true
end

Generating a Meal Plan

While we will definitely allow users to create meal plans customizing all of the recipes that are used, but we’re really wanting meal planning to take little thought so we’ll generate the initial list of recipes ourselves. Thankfully we can write tests to ensure that this works. Let’s open the test file for MealPlan back up and go through what we would like it to look like to generate a meal plan.

test/models/meal_plan_test.rb

require "test_helper"

describe MealPlan do
  # Validation tests ...

  describe "generating a weekly plan" do
    let(:meal_plan) { build(:meal_plan) }

    before do
      7.times do
        create(:recipe, user: meal_plan.user)
      end
    end

    it "populates a meal for each date between start and end" do
      meal_plan.meals.size.must_equal 0

      meal_plan.build_meals

      meal_plan.meals.size.must_equal 7
    end
  end
end

Running the test right now will show you that we haven’t implemented our factory yet, so let’s do that now:

test/factories/meal_plans.rb

FactoryGirl.define do
  factory :meal_plan do
    start_date { Date.today }
    end_date { 6.days.from_now }

    association(:user)
  end
end

Running the tests now (rake tests), we get the error that build_meals doesn’t exist, which is just what we needed. Let’s follow strict TDD and add an empty method.

app/models/meal_plan.rb

  def build_meals
  end

With that we get an expected error saying that we expected 7 but got 0. Now we get to actually implement this method. We need to create a meal for each date so we can use a ruby range for that.

app/models/meal_plan.rb

  def build_meals
    user_recipe_ids = user.recipes.pluck(:id)

    (start_date..end_date).each do |date|
      available_recipe_ids = user_recipe_ids - meals.map(&:recipe_id)

      meals.build(date: date, recipe_id: available_recipe_ids.sample)
    end
  end

This will get our test passing, but just looking at this it won’t handle the case where a user doesn’t have enough recipes to fill out a full week. Let’s write some tests to handle that and tweak this to work.

test/models/meal_plan_test.rb

  describe "generating a weekly plan" do
    let(:meal_plan) { build(:meal_plan) }

    before do
      7.times do
        create(:recipe, user: meal_plan.user)
      end
    end

    it "populates a meal for each date between start and end" do
      meal_plan.meals.size.must_equal 0

      meal_plan.build_meals

      meal_plan.meals.size.must_equal 7
    end

    it "builds valid meals" do
      meal_plan.build_meals

      meal_plan.meals.all?(&:valid?).must_equal true
    end

    it "uses unique recipes" do
      meal_plan.build_meals

      uniq_ids = meal_plan.meals.map(&:recipe_id).uniq
      uniq_ids.size.must_equal 7
    end

    describe "with more days than recipes" do
      let(:meal_plan) { build(:meal_plan, end_date: 8.days.from_now) }

      it "build valid meals" do
        meal_plan.build_meals

        meal_plan.meals.all?(&:valid?).must_equal true
      end

      it "reuses recipes where necessary" do
        meal_plan.build_meals

        uniq_ids = meal_plan.meals.map(&:recipe_id).uniq
        uniq_ids.size.must_equal 7
      end
    end
  end

Our extra test cases make this work by extending the length of the meal plan to have more days than recipes we created. These two tests will fail because we will attempt to build meals with nil for the recipe_id. Here’s how we can fix this:

app/models/meal_plan.rb

  def build_meals
    user_recipe_ids = user.recipes.pluck(:id)

    (start_date..end_date).each do |date|
      unused_recipes = user_recipe_ids - meals.map(&:recipe_id)

      available_recipes = unused_recipes.empty? ? user_recipe_ids : unused_recipes
      meals.build(date: date, recipe_id: available_recipes.sample)
    end
  end

This change will change the recipe ids we pull from after we’ve used all of the unique ones, and will get our tests passing.

Recap

We’ve created a few more models that we’ll use to create a useful meal planning application and we wrote some logic to actually randomize a list of meals. In the next tutorial we’ll use this logic to build out a meal plan and pre-populate the user interface while still allowing the user to customize the recipes that are being used.