Combine Model Scopes the Rails Way - ruby-on-rails

I accidentally noticed that two of my models have some resemblance. Their names are GameItem and OwnedItem. A GameItem is a just an item of the game, while an OwnedItem represents if a player has that item, if it's on his/her inventory or warehouse and more. My models are now like ( i removed validations and some irrelevant code for simplicity) :
class OwnedItem < ActiveRecord::Base
belongs_to :user
belongs_to :game_item
belongs_to :ownable, :polymorphic => true # [warehouse|inventory]
scope :equipped, where(:is_equipped => 1).includes(:game_item)
scope :item, lambda { |item_type|
joins(:game_item).
where("game_items.item_type = ?", item_type ).
limit(1)
}
scope :inventory, where(:ownable_type => 'Inventory')
scope :warehouse, where(:ownable_type => 'Warehouse')
end
class GameItem < ActiveRecord::Base
scope :can_be_sold, where(:is_sold => 1)
scope :item_type, lambda { |item_type|
where("game_items.item_type = ?", item_type )
}
scope :item_types, lambda { |item_types|
where("game_items.item_type IN (?)", item_types )
}
scope :class_type, lambda { |class_type|
where("game_items.class_type = ?", class_type )
}
scope :grade, lambda { |grade|
where("game_items.grade = ?", grade )
}
end
Notice the issue with game_item.item_type. I reference it in owned_item model, thus breaking encapsulation and repeating myself. How can i actually be able to do something like :
user.inventory.owned_items.item_type('Weapon').equipped
that is, without actually adding repeated code in my OwnedItem model, but getting that information out of the GameItem model ?

I think you've defined the relationships here in a way that's going to cause you trouble. You may find it's better off to use a simple user to item join model, something like this:
class User < ActiveRecord::Base
has_many :owned_items
has_many :game_items, :through => :owned_items
end
class OwnedItem < ActiveRecord::Base
belongs_to :user
belongs_to :game_item
# Has 'location' field: 'warehouse' or 'inventory'
end
class GameItem < ActiveRecord::Base
has_many :owned_items
has_many :users, :through => :owned_items
end
This is a common pattern where you have users and some kind of thing which they will own an instance of. The relationship table in the middle, OwnedItem, is used to establish, among other things, any unique characteristics of this particular instance of GameItem as well as the location of it relative to the user.
Generally this sort of structure avoids using polymorphic associations which can be trouble if used too casually. Whenever possible, try and avoid polymorphic associations unless they are on the very edge of your relationships. Putting them in the middle massively complicates joins and makes indexes a lot harder to tune.
As a note about the original, you can roll up a lot of that into a simple scope that uses the hash method for where:
scope :with_item_type, lambda { |types|
where('game_items.item_type' => types)
}
This will take either an array or string argument and will use IN or = accordingly. It's actually quite handy to do it this way because you won't need to remember which one to use.

Related

How do I write a Rails finder method through a chain of belongs_to associations?

I'm using Rails 5.1. How do I write a finder method when there is a chain of "belongs_to" associations? I have the following models ...
class Plan < ApplicationRecord
...
has_many :plan_items, :dependent => :destroy
class PlanItem < ApplicationRecord
...
belongs_to :offer, :optional => false
class Offer < ApplicationRecord
belongs_to :package, :optional => false
class Package < ApplicationRecord
has_and_belongs_to_many :items
I want to write a finder that gets all Plans with an Item with id = "blah". But the below is failing ...
[19] pry(main)> Plan.joins(plan_items: :offer).joins(packages: :item).where(:item => {:id => "abac"}).count
ActiveRecord::ConfigurationError: Can't join 'Plan' to association named 'packages'; perhaps you misspelled it?
from /Users/davea/.rvm/gems/ruby-2.5.1/gems/activerecord-5.2.2.1/lib/active_record/associations/join_dependency.rb:188:in `find_reflection'
How do I write a finder when there is a chain of belongs_to associations?
First, maybe your table name is wrong. Second, to pass method between belong_to association, you can use delegate
I'm assuming PlanItem is a join table between Plan and Item (that would be inline with the Rails naming convention). This could be done neatly with through associations and scopes. I would do it like this...
class Plan < ApplicationRecord
has_many :plan_items, dependent: :destroy
has_many :items, through: :plan_items
scope :blah_items { items.id_of_blah }
class PlanItem < ApplicationRecord
belongs_to :offer, optional: false
belongs_to :item
class Item < ApplicationRecord
scope :id_of_blah { where(id: 'blah') }
Then you can call it like so... Plan.with_blah_items or if you had an active record collection of plans you could use the scope to narrow it down plans.with_blah_items.
Since ActiveRecord associations will return ActiveRecord relations, you can chain them with any other active record methods (e.g. Plan.first.items.where(item: { id: 'blah' }) Scopes just make it nice and neat. : )
If PlanItem is not a join table between Plan and Item, first thing you should do is rename it. This is not just a best practice, rails spends a lot of time assuming what things are named, and it could cause a bug. After you rename it you should create a join table between Plan and Item called PlanItem. If a join between these tables doesn't make sense with your application architecture, you could always string through associations together, but that would be a code smell.
If you didn't want to mess with scopes, you could always just do a query like this plan.items.where(items: { id: 'blah' }).
Hope that helps!

Rails 3 merging scopes with joins

Setup
For this question, I'll use the following three classes:
class SolarSystem < ActiveRecord::Base
has_many :planets
scope :has_earthlike_planet, joins(:planets).merge(Planet.like_earth)
end
class Planet < ActiveRecord::Base
belongs_to :solar_system
belongs_to :planet_type
scope :like_earth, joins(:planet_type).where(:planet_types => {:life => true, :gravity => 9.8})
end
class PlanetType < ActiveRecord::Base
has_many :planets
attr_accessible :gravity, :life
end
Problem
The scope has_earthlike_planet does not work. It gives me the following error:
ActiveRecord::ConfigurationError: Association named 'planet_type' was
not found; perhaps you misspelled it?
Question
I have found out that this is because it is equivalent to the following:
joins(:planets, :planet_type)...
and SolarSystem does not have a planet_type association. I'd like to use the like_earth scope on Planet, the has_earthlike_planet on SolarSystem, and would like to avoid duplicating code and conditions. Is there a way to merge these scopes like I'm attempting to do but am missing a piece? If not, what other techniques can I use to accomplish these goals?
Apparently, at this time you can only merge simple constructs that don't involve joins. Here is a possible workaround if you modify your models to look like this:
class SolarSystem < ActiveRecord::Base
has_many :planets
has_many :planet_types, :through => :planets
scope :has_earthlike_planet, joins(:planet_types).merge(PlanetType.like_earth)
end
class Planet < ActiveRecord::Base
belongs_to :solar_system
belongs_to :planet_type
scope :like_earth, joins(:planet_type).merge(PlanetType.like_earth)
end
class PlanetType < ActiveRecord::Base
has_many :planets
attr_accessible :gravity, :life
scope :like_earth, where(:life => true, :gravity => 9.8)
end
** UPDATE **
For the record, a bug was filed about this behavior - hopefully will be fixed soon...
You are reusing the conditions from the scope Planet.like_earth, which joins planet_type. When these conditions are merged, the planet_type association is being called on SolarSystem, which doesn't exist.
A SolarSystem has many planet_types through planets, but this is still not the right association name, since it is pluralized. You can add the following to the SolarSystem class to setup the planet_type association, which is just an alias for planet_types. You can't use the Ruby alias however since AREL reflects on the association macros, and doesn't query on whether the model responds to a method by that name:
class SolarSystem < ActiveRecord::Base
has_many :planets
has_many :planet_types, :through => :planets
has_many :planet_type, :through => :planets, :class_name => 'PlanetType'
scope :has_earthlike_planet, joins(:planets).merge(Planet.like_earth)
end
SolarSystem.has_earthlike_planet.to_sql # => SELECT "solar_systems".* FROM "solar_systems" INNER JOIN "planets" ON "planets"."solar_system_id" = "solar_systems"."id" INNER JOIN "planets" "planet_types_solar_systems_join" ON "solar_systems"."id" = "planet_types_solar_systems_join"."solar_system_id" INNER JOIN "planet_types" ON "planet_types"."id" = "planet_types_solar_systems_join"."planet_type_id" WHERE "planet_types"."life" = 't' AND "planet_types"."gravity" = 9.8
An easy solution that I found is that you can change your joins in your Planet class to
joins(Planet.joins(:planet_type).join_sql)
This will create an SQL string for the joins which will always include the correct table names and therefore should always be working no matter if you call the scope directly or use it in a merge. It's not that nice looking and may be a bit of a hack, but it's only a little more code and there's no need to change your associations.

Has_many: through in Rails. Can I have two foreign keys?

I am a rails newbie and I am trying to create a database schema that looks like the following:
There are many matches. Each match has 2 teams.
A team has many matches.
The team model and match model are joined together through a competition table.
I have that competition model with a match_id and a team1_id and a team2_id.
But I don't know how to make this work or if it's even the best way to go about it. I don't know how to make certain teams team1 and others team2.... two foreign keys? Is that possible?
The match table also needs to hold additional data like team1_points and team2_points, winner and loser, etc.
You can have as many foreign keys as you want in a table. I wrote an application that involved scheduling teams playing in games.
The way that I handled this in the Game class with the following:
class Game < ActiveRecord::Base
belongs_to :home_team, :class_name => 'Team', :foreign_key => 'team1_id'
belongs_to :visitor_team, :class_name => 'Team', :foreign_key => 'team2_id'
You can add appropriate fields for team1_points, team2_points, etc. You'll need to set up your Team model with something like:
class Team < ActiveRecord::Base
has_many :home_games, :class_name => 'Game', :foreign_key => 'team1_id'
has_many :visitor_games, :class_name => 'Game', :foreign_key => 'team2_id'
def games
home_games + visitor_games
end
#important other logic missing
end
Note that some of my naming conventions were the result of having to work with a legacy database.
I faced a similar problem, and extending the previous answer, what I did was:
class Game < ActiveRecord::Base
def self.played_by(team)
where('team1_id = ? OR team2_id = ?', team.id, team.id)
end
end
class Team < ActiveRecord::Base
def games
#games ||= Game.played_by(self)
end
end
This way, Team#games returns an ActiveRecord::Relation instead of an Array, so you can keep chaining other scopes.

has_many :through with instance specific conditions

Is it possible to set an instance-level constraint on a has_many, :through relationship in rails 3.1?
http://guides.rubyonrails.org/association_basics.html#the-has_many-association
Something like:
Class A
has_many :c, :through => :b, :conditions => { "'c'.something_id" => #a.something_id }
The documentation gives me hope with this, but it doesn't work for me:
If you need to evaluate conditions dynamically at runtime, you could
use string interpolation in single quotes:
class Customer < ActiveRecord::Base
has_many :latest_orders, :class_name => "Order",
:conditions => 'orders.created_at > #{10.hours.ago.to_s(:db).inspect}'
end
That gives me "unrecognized token '#'" on rails 3.1. Wondering if this functionality doesn't work anymore?
EDIT
Want to clarify why I don't think scopes are the solution. I want to be able to get from an instance of A all of the Cs that have a condition (which is based on an attribute of that instance of A). These are the only Cs that should EVER be associated with that A. To do this with scopes, I would put a scope on C that takes an argument, and then have to call it from #a with some value? I don't get why that's better than incorporating it into my has_many query directly.
Use a scope on the orders model:
class Order < ActiveRecord::Base
belongs_to :customer
scope :latest, lambda { where('created_at > ?', 10.hours.ago) }
end
And then call it with:
#customer.orders.latest
And if you really want to use latest_orders, you can instead add this to the Customer model:
def latest_orders
orders.where('created_at > ?', 10.hours.ago)
end

Can a nested set have duplicate child objects or multiple parent_id/root/nodes?

Can a nested set have duplicate child objects or multiple parent_id/root/nodes?
For instance, I want to create an application that can manage parts and equipment. However, a specific equipment can have the same parts from other equipment as well.
Any thoughts on the best approach for this?
Thank you!!!
I think what you need here is an association class to help model the many-to-many relationship. In rails, this might look something like this:
class Equipment < ActiveRecord::Base
has_many :part_relationships
has_many :parts, :through => :part_relationships
end
class Part < ActiveRecord::Base
has_many :part_relationships
has_many :equipment, :through => :part_relationships
end
class PartRelationship < ActiveRecord::Base
belongs_to :equipment
belongs_to :part
end
There are other ways of modelling this (e.g. using a tree type structure), but if a 'set' is what you want, then this is the way I'd go.
Once this is done, you can do things like:
e = Equipment.find(:first)
e.parts # Returns all the parts on this equipment, including shared
p = Part.find(:first)
p.equipment # Returns all equipment this part features in.
# Create a new relationship between e and p
PartRelationship.create(:equipment => e, :part => p)

Resources