NumberedRelationships
This gem implements basic filtering of ActiveRecord models on the class and instance level based on the amount of relationships belonging to the object or its associations.
Benefits/ Raison d'Etre
Defining a scope or model method that implements such basic having/count functionality on an ActiveRecord model is trivial:
class Joker << ActiveRecord::Base
has_many :jokes
def self.with_at_least_n_jokes(n)
self.joins(:jokes).group("jokes.id HAVING count(joker_id) >= n")
end
end
But what if we want to filter a jester's jokes as well? ActiveSupport defines concerns to make module inclusion really simple:
module Humor
extend ActiveSupport::Concern
included do
has_many :jokes
def with_at_least_n_jokes(n)
self.joins(:jokes).group("jokes.id HAVING count(joker_id) >= n")
end
end
end
class Joker < ActiveRecord::Base
include Humor
end
Great! We can find the really humurous
Joker.with_at_least_n_jokes(200)
But the following might break, as the hard-coded foreign key "joker_id" might not exist in the jesters table.
class Jester < ActiveRecord::Base
include Humor
end
The problem is quite clear: the scopes define queries using specific implementation details, making reuse nearly impossible.
But there's also a larger issue here: jokes are only one possible association. If we want the Jester model to include, say, music_instruments -- and to be able to filter on their amount -- we're back at square one, writing another module for each association.
This gem provides relief. Instead of defining modules with hard-coded method names and queries, it uses ActiveRecord's reflection capabilities to enable amount-based filtering on all models and their associations:
# Class-based filters
Joker.with_at_least(2, :music_instruments)
Joker.with_at_least(200, :jokes)
# Instance-based association filters
@first_joker = Joker.find(1)
@first_joker.jokes.with_at_least(10, :tomatoes)
These also call scoped() on all queries and return an instance of ActiveRecord::Relation, meaning that these methods are chainable:
@first_joker.performances.with_at_least(10, :dramatic_moments).where(:duration > 10)
Installation
Add this line to your application's Gemfile:
gem 'numbered_relationships'
And then execute:
$ bundle
Or install it yourself as:
$ gem install numbered_relationships
Usage
This gem extends ActiveRecord, meaning the installation immediately offers the following methods:
Joker.with_at_least(1, :joke)
Joker.with_at_most(2, :jokes)
Joker.with_exactly(2, :jokes)
Joker.without(2, :jokes)
Joker.with_more_than(2, :jokes)
Joker.with_less_than(2, :jokes)
j = Joker.find(123)
j.jokes.with_at_least(1, :laugh)
j.jokes.with_at_most(2, :laughs)
j.jokes.with_exactly(2, :laughs)
j.jokes.without(11, :laughs)
j.jokes.with_more_than(18, :laughs)
j.jokes.with_less_than(2, :laughs)
It's also possible to use class methods or scopes defined on the association class:
Joker.with_at_least(1, [:dirty], :joke)
As long as the class method or scope :dirty is defined on Joker:
class Joke
def self.dirty
where(funny: true)
end
end
this code will properly filter the results before attempting to group them. It outputs the following SQL:
SELECT jesters.* FROM jesters
INNER JOIN jokes ON jokes.jester_id = jesters.id
WHERE jokes.funny = 't'
GROUP BY jesters.id
HAVING count(jokes.id) >= 1
These methods, like all other class methods, are chainable, meaning the following would also work:
Joker.with_at_least(1, [:dirty, :insulting, :crying_on_the_floor], :joke)
A call to a non-existent association will -- at least for the moment -- simply return:
[]
TODO:
- Improve exception handling.
Contributing
- Fork it
- Create your feature branch (
git checkout -b my-new-feature
) - Commit your changes (
git commit -am 'Added some feature'
) - Push to the branch (
git push origin my-new-feature
) - Create new Pull Request