yeahNah
yeahNah

Reputation: 21

Rails self referencing association

I have an Item and I want to have a 'Recipe' which is just an Item which references many_of :items.

I have a record in the ingredients table: (id: 1, recipe_id: 1, item_id: 2)

I want to be able to:

item.find(1).items - return all the items for recipe_id: 1

item.find(1).ingredients - return the ingredient records where recipe_id: 1

item.find(2).recipes - return all the recipes for which item_id: 2

I am able to get item.find(1).items working OR item.find(2).recipes working BUT NOT BOTH... :(

MIGRATION

class CreateIngredients < ActiveRecord::Migration[7.0]
  def change
    create_table :ingredients do |t|
      t.references :items, null: false, foreign_key: true
      t.references :recipe, references: :items, foreign_key: { to_table: :items }

      t.timestamps
    end
  end
end

INGREDIENTS model

class Ingredient < ApplicationRecord
  belongs_to :recipe, class_name: 'Item'
  belongs_to :item
end

ITEM model

has_many :ingredients

has_many :items, through: :ingredients
has_many :recipes, through: :ingredients

Upvotes: 1

Views: 101

Answers (1)

max
max

Reputation: 101831

You haven't actually modeled the assocations correctly. This isn't actually a valid use case for a self-referential association.

What you actually want is to use a join table between recipies and ingredients:

class CreateRecipeIngredients < ActiveRecord::Migration[7.0]
  def change
    create_table :recipe_ingredients do |t|
      t.belongs_to :recipe, null: false, foreign_key: true
      t.belongs_to :ingredient, null: false, foreign_key: true
      t.decimal :quantity
      t.string :unit # better would be to use a separate table or enum
      t.timestamps
    end
  end
end

You can name it whatever you want. Naming it a_bs is just a lazy convention. This is also where you are going to want to store the quantity of the ingredients.

You then setup a many to many assocation with has_many through::

class Recipe < Application
  has_many :recipe_ingredients
  has_many :ingredients, through: :recipe_ingredients
end

class RecipeIngredient < Application
  belongs_to :recipe
  belongs_to :ingredient
  delegate :name, to: :ingredient
end

class Ingredient < Application
  has_many :recipe_ingredients
  has_many :recipies, through: :recipe_ingredients
end

The reason you want has_many through: and not has_and_belongs_to_many is that the later is highly limited and doesn't let you access additional columns on the join table like the quantity in this case.

You can then get just use the indirect assocations to get recipies for an ingredient or vice versa:

Recipe.find(1).ingredients
Ingredient.find(5).recipies

You can also use joins to get recipies with certain ingredients:

Recipe.joins(:ingredients)
      .where(ingredients: { name: 'Broccoli' })

# Recipies with at least one of Broccoli, Kale and Squash
Recipe.joins(:ingredients)
      .where(ingredients: { name: ['Broccoli', 'Kale', 'Squash'] })

# Recipies with Broccoli, Kale and Squash
Recipe.left_joins(:ingredients)
      .where(ingredients: { name: ['Broccoli', 'Kale', 'Squash'] })
      .group(:id)
      .having(Ingredient.arel_table[:id].count.gteq(3))

If you want to write out a recipe you want to iterate across the recipe_ingredients assocation and not ingredients:

<table>
  <thead>
    <tr>
      <th>Name</th>
      <th>Quantity</th>
      <th>Unit</th>
    </tr>
  <thead>
  <tbody>
    <% recipe.recipe_ingredients.each do |ri| %>
    <tr>
      <td><%= ri.name %></td>
      <td><%= ri.quantity %></td>
      <td><%= ri.unit %></td>
    </tr>
    <% end %>
  </tbody>
</table>

Addendum

If you want to create a self-referential assocation between a recipe and "sub-recipes" you can do it either as one-to-many:

class AddParentToRecipes < ActiveRecord::Migration[7.0]
  def change
    change_table :recipes do |t|
      t.belongs_to :parent, null: true, 
                            foreign_key: { to_table: :recipes }
    end
  end
end

class Recipe < ApplicationRecord
  # ...
  belongs_to :parent,
    class_name: 'Recipe',
    optional: true
  has_many :children, 
    class_name: 'Recipe'
    foreign_key: :parent_id
end

Or many to many:

class CreateRecipeComponents < ActiveRecord::Migration[7.0]
  def change
    create_table :recipe_components do |t|
      t.belongs_to :parent, null: false, 
        foreign_key: { to_table: :recipes }
      t.belongs_to :child, null: false, 
        foreign_key: { to_table: :recipes } 
    end
  end
end

class RecipeComponent < ApplicationRecord
  belongs_to :parent, class_name: 'Recipe'
  belongs_to :child, class_name: 'Recipe'
end


class Recipe < ApplicationRecord
  # ...
  has_many :recipe_components_as_parent,
    class_name: 'RecipeComponent',
    foreign_key: :parent_id
  has_many :recipe_components_as_child,
    class_name: 'RecipeComponent',
    foreign_key: :child_id
  has_many :sub_recipies,
    through: :recipe_components_as_parent,
    source: :child
  has_many :parent_recipies,
    through: :recipe_components_as_child,
    source: :parent
end

While having an "item" that references either a recipe or ingredient might sound like a good idea from an object oriented POV you need to remember that its not how relational databases actually work. In a relational database tables have references to other tables through foreign keys and the table which that foreign key points to is not dynamic.

While you can use a polymorphic assocation to do this remember that its a hack around how databases are actually envisioned to work.

Upvotes: 2

Related Questions