Squirrell
Squirrell is a kinda magical library intended to make it easier to simplify your relationship with ActiveRecord.
ActiveRecord provides an immense amount of flexibility and power, and it's really easy to let this functionality become more-and-more intense.
Controllers doing arbitrary where
s, other models doing a find_by
, maybe even a hidden finder in the views somewhere.
This level of decoupling makes things difficult to test and obscures the lines in your application.
Squirrell makes it easy to create finders and query objects that respond very well to testing and are easy to mock.
Installation
Add this line to your application's Gemfile:
gem 'squirrell'
Rails
Run the install generator to copy the initializer, add app/queries
to Rails autoload, and provide an example query:
$ rails generate sqrl:install
Usage
Configuring Squirrell
If you want to use any raw SQL or Arel querying, you'll need to provide Squirrell with an executor.
An executor responds to :call
, accepts a single argument, and executes that argument as a SQL query.
It doesn't really have to execute a SQL query, though.
It just probably should.
To configure Squirrell, you can do a block in an initializer somewhere:
Squirrell.configure do |sqrl|
sqrl.executor = -> (sql) { ActiveRecord::Base.connection.execute(sql) }
end
Using a Squirrell query class
Squirrell query classes have a very limited external interface.
They respond to .find
and return the result of the query.
As a result, they're very easy to mock: allow(ComplexQuery).to receive(:find).and_return(your_stubs)
(hah hah, silly squirrell)
Examples:
UserFinder.find(id: 6)
WizardByElementAndPet.find(element: :ice, pet: :penguin)
HeroByName.find(name: "Finn")
Defining a Squirrell
There are currently three queries supported by Squirrell: #finder
, #arel
, and #raw_sql
.
Finders are the simplest.
They just return the result of the #finder
method.
class UserFinder
include Squirrell
requires :id
def finder
User.find(id)
end
def process(result)
result.map { |user| "Happy birthday, #{user.name}!" }
end
end
The requires :id
line indicates what parameters must be passed to find
.
An error will be raised if a required parameter is missing or if an extra parameter is passed.
The symbols in the hash are made into instance variables of the same name.
attr_readers
are provided for them, so you can refer to them without @
.
After the finding method gets called, #process
gets called with the result of the query.
In the previous example, result
would be an array, and it would convert the found users into a string wishing them a happy birthday.
The return value of process
is ultimately what the return value of UserFinder.find
will be.
Arel finders are meant to be used in conjunction with the Arel gem.
In truth, the only requirement is that the return value of the #arel
method respond to :to_sql
.
class WizardByElementAndPet
include Squirrell
requires :element, :pet
def arel
wizards = Wizard.arel_table
wizards.where(wizards[:pet].eq(pet))
.where(wizards[:element].eq(element))
.project(wizards[:id])
end
end
Finally, Squirrell can do raw SQL.
Define #raw_sql
on the Squirrell class and it'll use the executor.
The string returned by raw_sql
is passed directly to the executor.
Currently, it doesn't do anything clever to escape it.
class HeroByName
include Squirrell
requires :name
def raw_sql
"SELECT heroes.id FROM heroes WHERE heroes.name = '#{name}'"
end
end
Sometimes, you just want to return a bunch of models, and the finder has you totally covered.
Other times, the array-of-arrays or array-of-column-hashes are all you need.
When that isn't the case, Squirrell provides a #process
hook that receives the result of the query and can do whatever it wants with it.
class HeroByName
# ...
def process(result)
puts result
# => #<PG::Result:0x981723098 status=PGRES_TUPLES_OK etc....>
puts result.values
# => [["1"],["42"]]
Hero.find(result.values.flatten)
end
end
HeroByName.find(name: "Finn")
# => [#<Hero:0x0987123 name="Finn" weapon="Grass Sword", etc...]
Squirrell allows you to define optional permitted parameters:
class PermissionExample
include Squirrell
requires :user_id
permits :post_id
def raw_sql
<<SQL
SELECT *
FROM users
INNER JOIN posts ON users.id = posts.user_id
WHERE users.id = #{user_id} #{has_post?}
SQL
end
def has_post?
post_id ? "AND posts.id = #{post_id}" : ""
end
end
Generally, this makes for more complex queries. If you're finding that you're customizing with a bunch of permits
, you may want to make a new query object.
Rails Generator
Squirrell has a generator for queries.
$ rails generate sqrl:query QueryName id name --type=raw_sql
-
QueryName
is the name of the query object. -
--type=
can either beraw_sql
,finder
, orarel
. The default isfinder
- The remaining elements are the required parameters for the query. The default is
id
.
Testing Squirrells
Mocking Squirrels is really easy. expect(QueryObject).to receive(:find).and_return(results)
will capture the desired behavior in calling classes.
Testing Squirrells has two components: testing that the interaction with the database works as expected, and testing that the post-processing method works as expected.
For basic ActiveRecord finders, it's not really necessary to test .find
.
ActiveRecord is well-tested and unlikely to be cause problems.
For Arel and raw SQL queries that have a bit more complexity, you'll likely want to actually touch the database in these tests.
describe HeroByName do
before :all do
let(:finn) { create(:hero, name: "Finn") }
let(:jake) { create(:hero, name: "Jake") }
end
it 'finds by name' do
result = HeroByName.find(name: "Finn")
expect(result).to include(finn)
expect(result).to_not include(jake)
end
end
You can gain access to the underlying Squirrell with .new
, which lets you test the process
method and any other methods you choose to define on the class.
class MathQuery
include Squirrell
requires :math
def process(result)
result * 5
end
end
describe MathQuery do
let(:subject) { MathQuery.new }
it 'multiples result by 5' do
expect(subject.process(5)).to eq(25)
end
end
Generally, it'll be easiest to use and test the code if process
is a pure function of it's input.
If you need to refer to those values, you can pass the parameters in to new
: MathQuery.new(math: "so cool")
.
Wishlist
- Test helpers and test generators.
- A test hook that runs queries and caches them, similar to VCR.
Development
After checking out the repo, run bin/setup
to install dependencies. Then, run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
to create a git tag for the version, push git commits and tags, and push the .gem
file to rubygems.org.
Contributing
- Fork it ( https://github.com/[my-github-username]/squirrell/fork )
- Create your feature branch (
git checkout -b my-new-feature
) - Commit your changes (
git commit -am 'Add some feature'
) - Push to the branch (
git push origin my-new-feature
) - Create a new Pull Request