Searchlight
Status
I consider searchlight "done". It has no production dependencies, so there's no reason it shouldn't work indefinitely. I've also moved on to other things.
If you find a bug, feel free to open an issue so others can find it and discuss, but I'm unlikely to respond personally. If Searchlight doesn't meet your needs anymore, fork away! :)
Description
Searchlight is a low-magic way to build database searches using an ORM.
Searchlight can work with any ORM or object that can build a query using chained method calls (eg, ActiveRecord's .where(...).where(...).limit(...)
, or similar chains with Sequel, Mongoid, etc).
Getting Started
A demo app and the code for that app are available to help you get started.
Overview
Searchlight's main use is to support search forms in web applications.
Searchlight doesn't write queries for you. What it does do is:
- Give you an object with which you can build a search form (eg, using
form_for
in Rails) - Give you a sensible place to put your query logic
- Decide which parts of the search to run based on what the user submitted (eg, if they didn't fill in a "first name", don't do the
WHERE first_name =
part)
For example, if you have a Searchlight search class called YetiSearch
, and you instantiate it like this:
search = YetiSearch.new(
# or params[:yeti_search]
"active" => true, "name" => "Jimmy", "location_in" => %w[NY LA]
)
... calling search.results
will build a search by calling the methods search_active
, search_name
, and search_location_in
on your YetiSearch
, assuming that you've defined them. (If you do it again but omit "name"
, it won't call search_name
.)
The results
method will then return the return value of the last search method. If you're using ActiveRecord, this would be an ActiveRecord::Relation
, and you can then call each
to loop through the results, to_sql
to get the generated query, etc.
Usage
Search class
A search class has two main parts: a base_query
and some search_
methods. For example:
class PersonSearch < Searchlight::Search
# This is the starting point for any chaining we do, and it's what
# will be returned if no search options are passed.
# In this case, it's an ActiveRecord model.
def base_query
Person.all # or `.scoped` for ActiveRecord 3
end
# A search method.
def search_first_name
# If `"first_name"` was the first key in the options_hash,
# `query` here will be the base query, namely, `Person.all`.
query.where(first_name: options[:first_name])
end
# Another search method.
def search_last_name
# If `"last_name"` was the second key in the options_hash,
# `query` here will be whatever `search_first_name` returned.
query.where(last_name: last_name)
end
end
Calling PersonSearch.new("first_name" => "Gregor", "last_name" => "Mendel").results
would run Person.all.where(first_name: "Gregor").where(last_name: "Mendel")
and return the resulting ActiveRecord::Relation
. If you omitted the last_name
option, or provided "last_name" => ""
, the second where
would not be added.
Here's a fuller example search class. Note that because Searchlight doesn't write queries for you, you're free to do anything your ORM supports. (See spec/support/book_search.rb
for even more fanciness.)
# app/searches/city_search.rb
class CitySearch < Searchlight::Search
# `City` here is an ActiveRecord model
def base_query
City.includes(:country)
end
# Reach into other tables
def search_continent
query.where('`countries`.`continent` = ?', continent)
end
# Other kinds of queries
def search_country_name_like
query.where("`countries`.`name` LIKE ?", "%#{country_name_like}%")
end
# .checked? considers "false", 0 and "0" to be false
def search_is_megacity
query.where("`cities`.`population` #{checked?(is_megacity) ? '>=' : '<'} ?", 10_000_000)
end
end
Here are some example searches.
CitySearch.new.results.to_sql
# => "SELECT `cities`.* FROM `cities` "
CitySearch.new("name" => "Nairobi").results.to_sql
# => "SELECT `cities`.* FROM `cities` WHERE `cities`.`name` = 'Nairobi'"
CitySearch.new("country_name_like" => "aust", "continent" => "Europe").results.count # => 6
non_megas = CitySearch.new("is_megacity" => "false")
non_megas.results.to_sql
# => "SELECT `cities`.* FROM `cities` WHERE (`cities`.`population` < 10000000"
non_megas.results.each do |city|
# ...
end
Option Readers
For each search method you define, Searchlight will define a corresponding option reader method. Eg, if you add def search_first_name
, your search class will get a .first_name
method that returns options["first_name"]
or, if that key doesn't exist, options[:first_name]
. This is useful mainly when building forms.
Since it considers the keys "first_name"
and :first_name
to be interchangeable, Searchlight will raise an error if you supply both.
Examining Options
Searchlight provides some methods for examining the options provided to your search.
-
raw_options
contains exactly what it was instantiated with -
options
contains allraw_options
that weren'tempty?
. Eg, ifraw_options
iscategories: nil, tags: ["a", ""]
, options will betags: ["a"]
. -
empty?(value)
returns true fornil
, whitespace-only strings, or anything else that returns true fromvalue.empty?
(eg, empty arrays) -
checked?(value)
returns a boolean, which mostly works like!!value
but considers0
,"0"
, and"false"
to befalse
Finally, explain
will tell you how Searchlight interpreted your options. Eg, book_search.explain
might output:
Initialized with `raw_options`: ["title_like", "author_name_like", "category_in",
"tags", "book_thickness", "parts_about_lolcats"]
Of those, the non-blank ones are available as `options`: ["title_like",
"author_name_like", "tags", "book_thickness", "in_print"]
Of those, the following have corresponding `search_` methods: ["title_like",
"author_name_like", "in_print"]. These would be used to build the query.
Blank options are: ["category_in", "parts_about_lolcats"]
Non-blank options with no corresponding `search_` method are: ["tags",
"book_thickness"]
Defining Defaults
Sometimes it's useful to have default search options - eg, "orders that haven't been fulfilled" or "houses listed in the last month".
This can be done by overriding options
. Eg:
class BookSearch < SearchlightSearch
# def base_query...
def options
super.tap { |opts|
opts["in_print"] ||= "either"
}
end
def search_in_print
return query if options["in_print"].to_s == "either"
query.where(in_print: checked?(options["in_print"]))
end
end
Subclassing
You can subclass an existing search class and support all the same options with a different base query. This may be useful for single table inheritance, for example.
class VillageSearch < CitySearch
def base_query
Village.all
end
end
Or you can use super
to get the superclass's base_query
value and modify it:
class SmallTownSearch < CitySearch
def base_query
super.where("`cities`.`population` < ?", 1_000)
end
end
Custom Options
You can provide a Searchlight search any options you like; only those with a matching search_
method will determine what methods are run. Eg, if you want to do AccountSearch.new("super_user" => true)
to find restricted results, just ensure that you check options["super_user"]
when building your query.
Usage in Rails
ActionView adapter
Searchlight plays nicely with Rails forms - just include the ActionView
adapter as follows:
require "searchlight/adapters/action_view"
class MySearch < Searchlight::Search
include Searchlight::Adapters::ActionView
# ...etc
end
This will enable using a Searchlight::Search
with form_for
:
# app/views/cities/index.html.haml
...
= form_for(@search, url: search_cities_path) do |f|
%fieldset
= f.label :name, "Name"
= f.text_field :name
%fieldset
= f.label :country_name_like, "Country Name Like"
= f.text_field :country_name_like
%fieldset
= f.label :is_megacity, "Megacity?"
= f.select :is_megacity, [['Yes', true], ['No', false], ['Either', '']]
%fieldset
= f.label :continent, "Continent"
= f.select :continent, ['Africa', 'Asia', 'Europe'], include_blank: true
= f.submit "Search"
- @results.each do |city|
= render partial: 'city', locals: {city: city}
Controllers
As long as your form submits options your search understands, you can easily hook it up in your controller:
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
def index
@search = OrderSearch.new(search_params) # For use in a form
@results = @search.results # For display along with form
end
protected
def search_params
# Ensure the user can only browse or search their own orders
(params[:order_search] || {}).merge(user_id: current_user.id)
end
end
Compatibility
For any given version, check .travis.yml
to see what Ruby versions we're testing for compatibility.
Installation
Add this line to your application's Gemfile:
gem 'searchlight'
And then execute:
$ bundle
Or install it yourself as:
$ gem install searchlight
Contributing
rake
runs the tests; rake mutant
runs mutation tests using mutant.
- Fork it
- 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 new Pull Request
Shout Outs
- The excellent Mr. Adam Hunter, co-creator of Searchlight.
- TMA for supporting the initial development of Searchlight.