Rails 3 merging scopes with joins - ruby-on-rails

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.

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!

Complex has_many :through with block conditions

I'm having a bit of difficulty figuring out how to do this in the "Rails" way, if it is even possible at all.
Background: I have a model Client, which has a has_many relationship called :users_and_managers, which is defined like so:
has_many :users_and_managers, -> do
Spree::User.joins(:roles).where( {spree_roles: {name: ["manager", "client"]}})
end, class_name: "Spree::User"
The model Users have a has_many relationship called credit_cards which is merely a simple has_many - belongs_to relationship (it is defined in the framework).
So in short, clients ---has many---> users ---has many---> credit_cards
The Goal: I would like to get all the credit cards created by users (as defined in the above relationship) that belong to this client.
The Problem: I thought I could achieve this using a has_many ... :through, which I defined like this:
has_many :credit_cards, through: :users_and_managers
Unfortunately, this generated an error in relation to the join with the roles table:
SQLite3::SQLException: no such column: spree_roles.name:
SELECT "spree_credit_cards".*
FROM "spree_credit_cards"
INNER JOIN "spree_users" ON "spree_credit_cards"."user_id" = "spree_users"."id"
WHERE "spree_users"."client_id" = 9 AND "spree_roles"."name" IN ('manager', 'client')
(Emphasis and formatting mine)
As you can see in the generated query, Rails seems to be ignoring the join(:roles) portion of the query I defined in the block of :users_and_managers, while still maintaining the where clause portion.
Current Solution: I can, of course, solve the problem by defining a plain 'ol method like so:
def credit_cards
Spree::CreditCard.where(user_id: self.users_and_managers.joins(:credit_cards))
end
But I feel there must be a more concise way of doing this, and I am rather confused about the source of the error message.
The Question: Does anyone know why the AR / Rails seems to be "selective" about which AR methods it will include in the query, and how can I get a collection of credit cards for all users and managers of this client using a has_many relationship, assuming it is possible at all?
The joins(:roles) is being ignored because that can't be appended to the ActiveRecord::Relation. You need to use direct AR methods in the block. Also, let's clean things up a bit:
class Spree::Role < ActiveRecord::Base
scope :clients_and_managers, -> { where(name: %w{client manager}) }
# a better scope name would be nice :)
end
class Client < ActiveRecord::Base
has_many :users,
class_name: "Spree::User",
foreign_key: :client_id
has_many :clients_and_managers_roles,
-> { merge(Spree::Role.clients_and_managers) },
through: :users,
source: :roles
has_many :clients_and_managers_credit_cards,
-> { joins(:clients_and_managers_roles) },
through: :users,
source: :credit_cards
end
With that setup, you should be able to do the following:
client = # find client according to your criteria
credit_card_ids = Client.
clients_and_managers_credit_cards.
where(clients: {id: client.id}).
pluck("DISTINCT spree_credit_cards.id")
credit_cards = Spree::CreditCard.where(id: credit_card_ids)
As you can see, that'll query the database twice. For querying it once, check out the following:
class Spree::CreditCard < ActiveRecord::Base
belongs_to :user # with Spree::User conditions, if necessary
end
credit_cards = Spree::CreditCard.
where(spree_users: {id: client.id}).
joins(user: :roles).
merge(Spree::Role.clients_and_managers)

Specifying the default ordering for a has_many association using a join (Rails)?

I have a simple Customer model with a has many relationship with a Purchase model.
class Customer < ActiveRecord::Base
has_many :purchases
end
I am repeatedly finding that I need to order Customer.purchases in my views in the following way:
#customer.purchases.joins(:shop).order("shops.position").order(:position) #yes, two orders chained
In the interest of keeping things DRY, I'd like to put this somewhere centralized so I don't have to repeatedly do it. Ideally, I'd like to make it the default ordering for Customer.purchases. For example:
class Customer < ActiveRecord::Base
has_many :purchases, :order => joins(:shop).order("shops.position").order(:position)
end
Obviously the above doesn't work. How should I do this?
In your customer model you specified joins(:shop) is the value for the key :order. I think here is the problem, So you can use the joins as a key instead of order like below,
class Customer < ActiveRecord::Base
has_many :purchases, :joins => [:shop], :order => "shops.position"
end
I think it may work.
In your purchases model, you can create a class method:
Purchase.rb:
def self.order_by_position
joins(:shop).order("shops.position").order(:position)
end
Then you can say things like:
#customer.purchases.order_by_position
Purchase.order_by_position
You could create a method on Customer that returns ordered purchases:
class Customer < ActiveRecord::Base
has_many :purchases
def ordered_purchases
purchases.joins(:shop).order("shops.position").order(:position)
end
end
and call #customer.ordered_purchases from your views.

How do I do multiple has_and_belongs_to_many associations between the same two classes?

I have the following setup:
class Publication < ActiveRecord::Base
has_and_belongs_to_many :authors, :class_name=>'Person', :join_table => 'authors_publications'
has_and_belongs_to_many :editors, :class_name=>'Person', :join_table => 'editors_publications'
end
class Person < ActiveRecord::Base
has_and_belongs_to_many :publications
end
With this setup I can do stuff like Publication.first.authors. But if I want to list all publications in which a person is involved Person.first.publications, an error about a missing join table people_publications it thrown. How could I fix that?
Should I maybe switch to separate models for authors and editors? It would however introduce some redundancy to the database, since a person can be an author of one publication and an editor of another.
The other end of your associations should probably be called something like authored_publications and edited_publications with an extra read-only publications accessor that returns the union of the two.
Otherwise, you'll run in to sticky situations if you try to do stuff like
person.publications << Publication.new
because you'll never know whether the person was an author or an editor. Not that this couldn't be solved differently by slightly changing your object model.
There's also hacks you can do in ActiveRecord to change the SQL queries or change the behavior of the association, but maybe just keep it simple?
I believe you should have another association on person model
class Person < ActiveRecord::Base
# I'm assuming you're using this names for your foreign keys
has_and_belongs_to_many :author_publications, :foreign_key => :author_id
has_and_belongs_to_many :editor_publications, :foreign_key => :editor_id
end

How do I use ActiveRecord to find unrelated records?

I have a many-to-many relationship set up through a join model. Essentially, I allow people to express interests in activities.
class Activity < ActiveRecord::Base
has_many :personal_interests
has_many :people, :through => :personal_interests
end
class Person < ActiveRecord::Base
has_many :personal_interests
has_many :activities, :through => :personal_interests
end
class PersonalInterest < ActiveRecord::Base
belongs_to :person
belongs_to :activity
end
I now want to find out: in which activities has a particular user not expressed interest? This must include activities that have other people interested as well as activities with exactly zero people interested.
A successful (but inefficent) method were two separate queries:
(Activity.all - this_person.interests).first
How can I neatly express this query in ActiveRecord? Is there a (reliable, well-kept) plugin that abstracts the queries?
I think the easiest way will be to just use an SQL where clause fragment via the :conditions parameter.
For example:
Activity.all(:conditions => ['not exists (select 1 from personal_interests where person_id = ? and activity_id = activities.id)', this_person.id])
Totally untested, and probably doesn't work exactly right, but you get the idea.

Resources