Polymorphic habtm relationships with Rails/ActiveRecord - ruby-on-rails

How would I go about creating a polymorphic has_and_belongs_to_many relationship with Rails/ActiveRecord?
Most of the examples I see involve creating a belongs_to relationship which limits my polymorphic-side to being related to only one parent:
Table: Task
Table: Tasks_Targets
Table: CustomerStore
Table: SoftwareSystem
Both CustomerStore and SoftwareSystem would be of type "Targetable" in this circumstance. From what I understand, if I implement the polymorphic relationship as most examples show, I'd only be able to relate a Targetable to a Task once.
Some clarification might help as most searches online still leave some of the theory behind this relationship unexplained...
Thanks!

Given your explanation of your domain, I've whipped up a small test-driven example of how you might solve your problem. If you see any domain inconsistencies, please feel free to clarify further (I'm using my acts_as_fu gem to whip up test models on the fly).
require 'acts_as_fu'
# class Task < ActiveRecord::Base
build_model(:tasks) do
integer :task_target_id
has_many :task_targets
has_many :customer_stores, :through => :task_targets, :source => :targetable, :source_type => 'CustomerStore'
has_many :software_systems, :through => :task_targets, :source => :targetable, :source_type => 'SoftwareSystem'
end
# class TaskTarget < ActiveRecord::Base
build_model(:task_targets) do
string :targetable_type
integer :targetable_id
integer :task_id
belongs_to :targetable, :polymorphic => true
belongs_to :task
end
# class CustomerStore < ActiveRecord::Base
build_model(:customer_stores) do
has_many :task_targets, :as => :targetable
has_many :tasks, :through => :task_targets
end
# class SoftwareSystem < ActiveRecord::Base
build_model(:software_systems) do
has_many :task_targets, :as => :targetable
has_many :tasks, :through => :task_targets
end
require 'test/unit'
class PolymorphicDomainTest < Test::Unit::TestCase
# Test that customer stores can have multiple tasks
def test_customer_store_gets_task
task = Task.create!
customer_store = CustomerStore.create!
customer_store.task_targets.create! :task => task
assert customer_store.tasks.include?(task)
end
def test_many_customer_stores_get_task
task_a = Task.create!
task_b = Task.create!
customer_store = CustomerStore.create! :tasks => [task_a, task_b]
assert customer_store.tasks.include?(task_a)
assert customer_store.tasks.include?(task_b)
end
# Test that software systems can have multiple tasks
def test_software_system_gets_task
task = Task.create!
software_system = SoftwareSystem.create!
software_system.task_targets.create! :task => task
assert software_system.tasks.include?(task)
end
def test_many_software_systems_get_task
task_a = Task.create!
task_b = Task.create!
software_system = SoftwareSystem.create! :tasks => [task_a, task_b]
assert software_system.tasks.include?(task_a)
assert software_system.tasks.include?(task_b)
end
# Test that Tasks can have multiple customer stores
def test_task_has_many_customer_stores
task = Task.create!
customer_store_a = CustomerStore.create!
customer_store_b = CustomerStore.create!
task.customer_stores = [customer_store_a, customer_store_b]
task.save!
task.reload
assert task.customer_stores.include?(customer_store_a)
assert task.customer_stores.include?(customer_store_b)
end
# Test that Tasks can have multiple software systems
def test_task_has_many_software_systems
task = Task.create!
software_system_a = SoftwareSystem.create!
software_system_b = SoftwareSystem.create!
task.software_systems = [software_system_a, software_system_b]
task.save!
task.reload
assert task.software_systems.include?(software_system_a)
assert task.software_systems.include?(software_system_b)
end
end

To complement nakajima's answer with regards to your concern, here's how I would do it:
class Task < ActiveRecord::Base
def targets
# Get Array of all targetables
tt = TaskTarget.select_all("SELECT targetable_type, targetable_id FROM task_targerts WHERE task_id = #{self[:id]}")
# Build Hash of targetable_type => Array of targetable_ids
targetables = Hash.new { |hash, key| hash[key] = [] }
tt.each do |targetable|
targetables[targetable.targetable_type] << targetable.targetable_id
end
# Query each "targetable" table once and merge all results
targetables.keys.map{|key| (eval key).find(targetables[key])}.flatten
end
end
Make sure to index task_id in table task_targets.

Related

Querying in the controller- ROR

I am creating an application in Ruby and I have two model classes
class Restaurant < ActiveRecord::Base
attr_accessible :title, :cuisine, :price_range, :environment
self.primary_key = 'title'
has_many :environments
end
end
class Environment < ActiveRecord::Base
attr_accessible :title, :type
belongs_to :restaurants,foreign_key: 'title'
end
I am trying to query two tables in the controller- to return restaurants with specific environments.
#restaurants = Restaurant.joins('INNER JOIN environments ON restaurants.title=environments.title').where('environments.env_type'=> #selected_environment)
NOTE: #selected_environment is a hash contained list of environments. Environments table has a list of environment and restaurant pairs(there can be more than one restaurant).
I know the query above is not correct to achieve my goal. Is there a way i can get this done?
controller method:
def index
# can later be moved to the Restaurant model
ordering = {:title => :asc}
#all_environments = Restaurant.all_environments
#selected_environments = params[:environments] || session[:environments] || {}
if #selected_environments == {}
#selected_environments = Hash[#all_environments.map {|environment| [environment, environment]}]
end
if params[:environments] != session[:environments]
session[:environments] = #selected_environments
redirect_to :environments => #selected_environments and return
end
#restaurants = Restaurant.joins(:environments).where(environments:{env_type: #selected_environments.keys })
end
For your models you want to do this:
class Restaurant < ActiveRecord::Base
attr_accessible :title, :cuisine, :price_range, :environment
self.primary_key = 'title'
has_many :environments, :foreign_key => 'title', :primary_key => 'title'
end
class Environment < ActiveRecord::Base
attr_accessible :title, :type
belongs_to :restaurants, foreign_key: 'title'
end
Try structuring your query like this:
Restaurant.joins(:environments).where(environments: { env_type: #selected_environments.values })

How to apply conditions when accessing records using a has_many through relationship in Rails?

I have the following models:
class Campaign < ActiveRecord::Base
has_many :campaign_keywords
has_many :leads, :through => :campaign_keywords
end
class CampaignKeyword < ActiveRecord::Base
belongs_to :campaign
has_many :leads
end
class Lead < ActiveRecord::Base
belongs_to :campaign_keyword
end
I am trying to build a function in the "Campaign" model that will only return leads which belong to a given campaign_keyword.
My attempt is:
def leads?(campaign_keyword_id = -1)
self.leads :conditions => ['campaign_keyword_id = #{campaign_keyword_id}']
end
but this does not work, the conditions are ignored.
Can you see a solution to this?
Create a named_scope for your Lead model, like so:
class Lead < ActiveRecord::Base
belongs_to :campaign_keyword
named_scope :with_keyword, lambda { |keyword| { :conditions => { :campaign_keyword => keyword } } }
end
Now, when you want to get leads for a particular campaign keyword, you would do so like this:
def leads_for_campaign(keyword)
self.leads.with_keyword(keyword)
end
This is much nicer and more re-usable, because the Lead model itself now knows how to find leads for a specific campaign.
For more of an idea of what you can do with named_scopes, check out http://apidock.com/rails/ActiveRecord/NamedScope/ClassMethods/named_scope
Try this:
def leads?(campaign_keyword_id = -1)
self.leads.all :conditions => ['campaign_keyword_id = #{campaign_keyword_id}']
end
I would rewrite your query as follows:
def leads?(campaign_keyword_id = -1)
self.leads.all :conditions => ['campaign_keyword_id = ?', campaign_keyword_id]
end
OR
self.leads.find_all_by_compaign_keyword_id(campaign_keyword_id)

validate uniqueness amongst multiple subclasses with Single Table Inheritance

I have a Card model that has many CardSets and a CardSet model that has many Cards through a Membership model:
class Card < ActiveRecord::Base
has_many :memberships
has_many :card_sets, :through => :memberships
end
class Membership < ActiveRecord::Base
belongs_to :card
belongs_to :card_set
validates_uniqueness_of :card_id, :scope => :card_set_id
end
class CardSet < ActiveRecord::Base
has_many :memberships
has_many :cards, :through => :memberships
validates_presence_of :cards
end
I also have some sub-classes of the above using Single Table Inheritance:
class FooCard < Card
end
class BarCard < Card
end
and
class Expansion < CardSet
end
class GameSet < CardSet
validates_size_of :cards, :is => 10
end
All of the above is working as I intend. What I'm trying to figure out is how to validate that a Card can only belong to a single Expansion. I want the following to be invalid:
some_cards = FooCard.all( :limit => 25 )
first_expansion = Expansion.new
second_expansion = Expansion.new
first_expansion.cards = some_cards
second_expansion.cards = some_cards
first_expansion.save # Valid
second_expansion.save # **Should be invalid**
However, GameSets should allow this behavior:
other_cards = FooCard.all( :limit => 10 )
first_set = GameSet.new
second_set = GameSet.new
first_set.cards = other_cards # Valid
second_set.cards = other_cards # Also valid
I'm guessing that a validates_uniqueness_of call is needed somewhere, but I'm not sure where to put it. Any suggestions?
UPDATE 1
I modified the Expansion class as sugested:
class Expansion < CardSet
validate :validates_uniqueness_of_cards
def validates_uniqueness_of_cards
membership = Membership.find(
:first,
:include => :card_set,
:conditions => [
"card_id IN (?) AND card_sets.type = ?",
self.cards.map(&:id), "Expansion"
]
)
errors.add_to_base("a Card can only belong to a single Expansion") unless membership.nil?
end
end
This works! Thanks J.!
Update 2
I spoke a little too soon. The above solution was working great until I went to update an Expansion with a new card. It was incorrectly identifying subsequent #valid? checks as false because it was finding itself in the database. I fixed this by adding a check for #new_record? in the validation method:
class Expansion < CardSet
validate :validates_uniqueness_of_cards
def validates_uniqueness_of_cards
sql_string = "card_id IN (?) AND card_sets.type = ?"
sql_params = [self.cards.map(&:id), "Expansion"]
unless new_record?
sql_string << " AND card_set_id <> ?"
sql_params << self.id
end
membership = Membership.find(
:first,
:include => :card_set,
:conditions => [sql_string, *sql_params]
)
errors.add_to_base("a Card can only belong to a single Expansion") unless membership.nil?
end
I'm really not sure about that, I'm just trying because I'm not able to test it here... but maybe something like the following works for you. Let me know if it does :]
class Expansion < Set
validate :validates_uniqueness_of_cards
def validates_uniqueness_of_cards
membership = Membership.find(:first, :include => :set,
:conditions => ["card_id IN (?) AND set.type = ?",
self.cards.map(&:id), "Expansion"])
errors.add_to_base("Error message") unless membership.nil?
end
end
Very late to the party here, but assuming you've set up STI, then you can now validate uniqueness of an attribute scoped to the sti type,
e.g
validates_uniqueness_of :your_attribute_id, scope: :type

Rails model relations depending on count of nested relations

I am putting together a messaging system for a rails app I am working on.
I am building it in a similar fashion to facebook's system, so messages are grouped into threads, etc.
My related models are:
MsgThread - main container of a thread
Message - each message/reply in thread
Recipience - ties to user to define which users should subscribe to this thread
Read - determines whether or not a user has read a specific message
My relationships look like
class User < ActiveRecord::Base
#stuff...
has_many :msg_threads, :foreign_key => 'originator_id' #threads the user has started
has_many :recipiences
has_many :subscribed_threads, :through => :recipiences, :source => :msg_thread #threads the user is subscribed to
end
class MsgThread < ActiveRecord::Base
has_many :messages
has_many :recipiences
belongs_to :originator, :class_name => "User", :foreign_key => "originator_id"
end
class Recipience < ActiveRecord::Base
belongs_to :user
belongs_to :msg_thread
end
class Message < ActiveRecord::Base
belongs_to :msg_thread
belongs_to :author, :class_name => "User", :foreign_key => "author_id"
end
class Read < ActiveRecord::Base
belongs_to :user
belongs_to :message
end
I'd like to create a new selector in the user sort of like:
has_many :updated_threads, :through => :recipiencies, :source => :msg_thread, :conditions => {THREAD CONTAINS MESSAGES WHICH ARE UNREAD (have no 'read' models tying a user to a message)}
I was thinking of either writing a long condition with multiple joins, or possibly writing giving the model an updated_threads method to return this, but I'd like to see if there is an easier way first. Am I able to pass some kind of nested hash into the conditions instead of a string?
Any ideas? Also, if there is something fundamentally wrong with my structure for this functionality let me know! Thanks!!
UPDATE:
While I would still appreciate input on better possibilities if they exist, this is what I have gotten working now:
class User < ActiveRecord::Base
# stuff...
def updated_threads
MsgThread.find_by_sql("
SELECT msg_threads.* FROM msg_threads
INNER JOIN messages ON messages.msg_thread_id = msg_threads.id
INNER JOIN recipiences ON recipiences.msg_thread_id = msg_threads.id
WHERE (SELECT COUNT(*) FROM `reads` WHERE reads.message_id = messages.id AND reads.user_id = #{self.id}) = 0
AND (SELECT COUNT(*) FROM recipiences WHERE recipiences.user_id = #{self.id} AND recipiences.msg_thread_id = msg_threads.id) > 0
")
end
end
Seems to be working fine!
Also to check if a specific thread (and message) are read:
class Message < ActiveRecord::Base
# stuff...
def read?(user_id)
Read.exists?(:user_id => user_id, :message_id => self.id)
end
end
class MsgThread < ActiveRecord::Base
# stuff...
def updated?(user_id)
updated = false
self.messages.each { |m| updated = true if !m.read?(user_id) }
updated
end
end
Any suggestions to improve this?
Add a named_scope to the MsgThread model:
class MsgThread < ActiveRecord::Base
named_scope :unread_threads, lambda { |user|
{
:include => [{:messages=>[:reads]}, recipiencies],
:conditions => ["recipiences.user_id = ? AND reads.message_id IS NULL",
user.id],
:group => "msg_threads.id"
}}
end
Note: Rails uses LEFT OUTER JOIN for :include. Hence the IS NULL check works.
Now you can do the following:
MsgThread.unread_threads(current_user)
Second part can be written as:
class Message
has_many :reads
def read?(usr)
reads.exists?(:user_id => usr.id)
end
end
class MsgThread < ActiveRecord::Base
def updated?(usr)
messages.first(:joins => :reads,
:conditions => ["reads.user_id = ? ", usr.id]
) != nil
end
end
You might want to take a look at Arel, which can help with complex SQL queries. I believe (don't quote me) this is already baked into Rails3.

Model Relationship Problem

I am trying to calculate the average (mean) rating for all entries within a category based on the following model associations ...
class Entry < ActiveRecord::Base
acts_as_rateable
belongs_to :category
...
end
class Category < ActiveRecord::Base
has_many :entry
...
end
class Rating < ActiveRecord::Base
belongs_to :rateable, :polymorphic => true
...
end
The rating model is handled by the acts as rateable plugin, so the rateable model looks like this ...
module Rateable #:nodoc:
...
module ClassMethods
def acts_as_rateable
has_many :ratings, :as => :rateable, :dependent => :destroy
...
end
end
...
end
How can I perform the average calculation? Can this be accomplished through the rails model associations or do I have to resort to a SQL query?
The average method is probably what you're looking for. Here's how to use it in your situation:
#category.entries.average('ratings.rating', :joins => :ratings)
Could you use a named_scope or custom method on the model. Either way it would still require some SQL since, if I understand the question, your are calculating a value.
In a traditional database application this would be a view on the data tables.
So in this context you might do something like... (note not tested or sure it is 100% complete)
class Category
has_many :entry do
def avg_rating()
#entries = find :all
#entres.each do |en|
#value += en.rating
end
return #value / entries.count
end
end
Edit - Check out EmFi's revised answer.
I make no promises but try this
class Category
def average_rating
Rating.average :rating,
:conditions => [ "type = ? AND entries.category_id = ?", "Entry", id ],
:join => "JOIN entries ON rateable_id = entries.id"
end
end

Resources