clekstro/numbered_relationships

View on GitHub
README.md

Summary

Maintainability
Test Coverage
# 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:

```ruby
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:
```ruby
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
```ruby
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.
```ruby
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:
```ruby
# Class-based filters
Joker.with_at_least(2, :music_instruments)
Joker.with_at_least(200, :jokes)
```

```ruby
# 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:
```ruby
@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:

```ruby
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:
```ruby
Joker.with_at_least(1, [:dirty], :joke)

```
As long as the class method or scope :dirty is defined on Joker:
```ruby
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:
```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:
```ruby
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:
```ruby
[]
```

## TODO:
1. Improve exception handling.

## Contributing

1. Fork it
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Added some feature'`)
4. Push to the branch (`git push origin my-new-feature`)
5. Create new Pull Request

[![Code Climate](https://codeclimate.com/github/clekstro/numbered_relationships.png)](https://codeclimate.com/github/clekstro/numbered_relationships)
![Travis CI Status](https://secure.travis-ci.org/clekstro/numbered_relationships.png)