Skip to content

Commit

Permalink
Add v2 files
Browse files Browse the repository at this point in the history
  • Loading branch information
binarylogic committed Jun 9, 2009
1 parent 87754bc commit a9cbc43
Show file tree
Hide file tree
Showing 16 changed files with 990 additions and 0 deletions.
20 changes: 20 additions & 0 deletions LICENSE
@@ -0,0 +1,20 @@
Copyright (c) 2009 Binary Logic

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
127 changes: 127 additions & 0 deletions README.rdoc
@@ -0,0 +1,127 @@
= Searchlogic

<b>Searchlogic has been <em>completely</em> rewritten for v2. It is much simpler and has taken an entirely new approach. To give you an idea, v1 had ~2300 lines of code, v2 has ~350 lines of code.</b>

Searchlogic is a library that leverages named scopes to make searching in your application simple.

Instead of explaining what Searchlogic can do, let me show you. Let's start at the top:

# We have the following model
User(id: integer, created_at: datetime, username: string, age: integer)

# Searchlogic gives you a bunch of named scopes for free:
User.username_equals("bjohnson")
User.username_does_not_equal("bjohnson")
User.username_begins_with("bjohnson")
User.username_like("bjohnson")
User.username_ends_with("bjohnson")
User.age_greater_than(20)
User.age_greater_than_or_equal_to(20)
User.age_less_than(20)
User.age_less_than_or_equal_to(20)
User.username_null
User.username_blank

# You can also order by columns
User.ascend_by_username
User.descend_by_username
User.order("ascend_by_username")

Keep in mind, these are just named scopes, you can chain them, call methods off of them, etc:

scope = User.username_like("bjohnson").age_greater_than(20).ascend_by_username
scope.all
scope.first
scope.count
# etc...

== Named scopes for associations

You also get named scopes for any of your associations:

# We have the following relationships
User.has_many :orders
Order.has_many :line_items
LineItem

# Set conditions on association columns
User.orders_total_greater_than(20)
User.orders_line_items_price_greater_than(20)

# Order by association columns
User.ascend_by_order_total
User.descend_by_orders_line_items_price

Again these are just named scopes. You can chain them together, call methods off of them, etc. What's great about these named scopes is that they do NOT use the :include option, making them much faster. Instead they create a LEFT OUTER JOIN and pass it to the :joins option, which is great for performance. If you want to use the :include option, just specify it:

User.orders_line_items_price_greater_than(20).all(:include => {:orders => :line_items})

Now all of Searchlogic's goodness fits nicely into your app and you can use it with your custom named scopes. Nice and clean.

== Make searching in your application trivial

The above is great, but what about tying all of this in with a search form in your application? Just do this...

User.search(:username_like => "bjohnson", :age_less_than => 20)

The above is equivalent to:

User.username_like("bjohnson").age_less_than(20)

All that the search method does is chain named scopes together for you. What's so great about that? It keeps your controllers extremely simple:

class UsersController < ApplicationController
def index
@search = User.search(params[:search])
@users = @search.all
end
end

It doesn't get any simpler than that. Adding a search condition is as simple as adding a condition to your form. Remember all of those named scopes above? Just create fields with the same names:

- form_for @search do |f|
= f.text_field :username_like
= f.select :age_greater_than, (0..100)
= f.text_field :orders_total_greater_than
= f.submit

== Use your existing named scopes

This is one of the big differences between Searchlogic v1 and v2. What about your existing named scopes? Let's say you have this:

User.named_scope :four_year_olds, :conditions => {:age => 4}

Again, these are all just named scopes, use it in the same way:

User.search(:four_year_olds => true, :username_like => "bjohnson")

Notice we pass true as the value. If a named scope does not accept any parameters (arity == 0) you can simply pass it true or false. If you pass false, the named scope will be ignored. If your named scope accepts a parameter, the value will be passed right to the named scope regardless of the value.

Now just throw it in your form:

- form_for @search do |f|
= f.text_field :username_like
= f.check_box :four_year_olds
= f.submit

What's great about this is that you can do just about anything you want. If Searchlogic doesn't provide a named scope for that crazy searching edge case that you need, just create your own named scope. The sky is the limit.

== Pagination (leverage will_paginate)

Instead of recreating the wheel with pagination, Searchlogic works great with will_paginate. All that Searchlogic is doing is creating named scopes, and will_paginate works great with named scopes:

User.username_like("bjohnson").age_less_than(20).paginate(:page => params[:page])

If you don't like will_paginate, use another solution, or roll your own. Pagination really has nothing to do with searching, and the main goal for Searchlogic v2 was to keep it lean and simple. No reason to recreate the wheel and bloat the library.

== Under the hood

Before I use a library in my application I like to glance at the source and try to at least understand the basics of how it works. If you are like me, a nice little explanation from the author is always helpful:

Searchlogic utilizes method_missing to create all of these named scopes. When it hits method_missing it creates a named scope to ensure it will never hit method missing for that named scope again. Sort of a caching mechanism. It works in the same fashion as ActiveRecord's "find_by_*" methods. This way only the named scopes you need are created and nothing more.

That's about it, the named scope options are pretty bare bones and created just like you would manually.

== Copyright

Copyright (c) 2009 {Ben Johnson of Binary Logic}[http://www.binarylogic.com], released under the MIT license
48 changes: 48 additions & 0 deletions Rakefile
@@ -0,0 +1,48 @@
require 'rubygems'
require 'rake'

begin
require 'jeweler'
Jeweler::Tasks.new do |gem|
gem.name = "search"
gem.summary = %Q{TODO}
gem.email = "bjohnson@binarylogic.com"
gem.homepage = "http://github.com/binarylogic/search"
gem.authors = ["binarylogic"]
# gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
end

rescue LoadError
puts "Jeweler (or a dependency) not available. Install it with: sudo gem install jeweler"
end

require 'spec/rake/spectask'
Spec::Rake::SpecTask.new(:spec) do |spec|
spec.libs << 'lib' << 'spec'
spec.spec_files = FileList['spec/**/*_spec.rb']
end

Spec::Rake::SpecTask.new(:rcov) do |spec|
spec.libs << 'lib' << 'spec'
spec.pattern = 'spec/**/*_spec.rb'
spec.rcov = true
end


task :default => :spec

require 'rake/rdoctask'
Rake::RDocTask.new do |rdoc|
if File.exist?('VERSION.yml')
config = YAML.load(File.read('VERSION.yml'))
version = "#{config[:major]}.#{config[:minor]}.#{config[:patch]}"
else
version = ""
end

rdoc.rdoc_dir = 'rdoc'
rdoc.title = "search #{version}"
rdoc.rdoc_files.include('README*')
rdoc.rdoc_files.include('lib/**/*.rb')
end

9 changes: 9 additions & 0 deletions lib/searchlogic.rb
@@ -0,0 +1,9 @@
require "searchlogic/named_scopes/conditions"
require "searchlogic/named_scopes/ordering"
require "searchlogic/named_scopes/associations"
require "searchlogic/search"
require "searchlogic/search_proxy"

ActiveRecord::Base.extend(Searchlogic::NamedScopes::Conditions)
ActiveRecord::Base.extend(Searchlogic::NamedScopes::Ordering)
ActiveRecord::Base.extend(Searchlogic::NamedScopes::Associations)
105 changes: 105 additions & 0 deletions lib/searchlogic/named_scopes/associations.rb
@@ -0,0 +1,105 @@
module Searchlogic
module NamedScopes
module Associations
private
def method_missing(name, *args, &block)
if details = association_condition_details(name)
create_association_condition(details[:association], details[:column], details[:condition], args)
send(name, *args)
elsif details = association_alias_condition_details(name)
create_association_alias_condition(details[:association], details[:column], details[:condition], args)
send(name, *args)
elsif details = association_ordering_condition_details(name)
create_association_ordering_condition(details[:association], details[:order_as], details[:column], args)
send(name, *args)
else
super
end
end

def association_condition_details(name)
associations = reflect_on_all_associations.collect { |assoc| assoc.name }
if name.to_s =~ /^(#{associations.join("|")})_(\w+)_(#{Conditions::PRIMARY_CONDITIONS.join("|")})$/
{:association => $1, :column => $2, :condition => $3}
end
end

def create_association_condition(association_name, column, condition, args)
named_scope("#{association_name}_#{column}_#{condition}", association_condition_options(association_name, "#{column}_#{condition}", args))
end

def association_alias_condition_details(name)
associations = reflect_on_all_associations.collect { |assoc| assoc.name }
if name.to_s =~ /^(#{associations.join("|")})_(\w+)_(#{Conditions::ALIAS_CONDITIONS.join("|")})$/
{:association => $1, :column => $2, :condition => $3}
end
end

def create_association_alias_condition(association, column, condition, args)
primary_condition = primary_condition(condition)
alias_name = "#{association}_#{column}_#{condition}"
primary_name = "#{association}_#{column}_#{primary_condition}"
send(primary_name, *args) # go back to method_missing and make sure we create the method
(class << self; self; end).class_eval { alias_method alias_name, primary_name }
end

def association_ordering_condition_details(name)
associations = reflect_on_all_associations.collect { |assoc| assoc.name }
if name.to_s =~ /^(ascend|descend)_by_(#{associations.join("|")})_(\w+)$/
{:order_as => $1, :association => $2, :column => $3}
end
end

def create_association_ordering_condition(association_name, order_as, column, args)
named_scope("#{order_as}_by_#{association_name}_#{column}", association_condition_options(association_name, "#{order_as}_by_#{column}", args))
end

def association_condition_options(association_name, association_condition, args)
association = reflect_on_association(association_name.to_sym)
scope = association.klass.send(association_condition, *args)
arity = association.klass.named_scope_arity(association_condition)

if !arity || arity == 0
# The underlying condition doesn't require any parameters, so let's just create a simple
# named scope that is based on a hash.
options = scope.proxy_options
add_left_outer_join(options, association)
options
else
# The underlying condition requires parameters, let's match the parameters it requires
# and pass those onto the named scope. We can't use proxy_options because that returns the
# result after a value has been passed.
proc_args = []
arity.times { |i| proc_args << "arg#{i}"}
eval <<-"end_eval"
lambda { |#{proc_args.join(",")}|
options = association.klass.named_scope_options(association_condition).call(#{proc_args.join(",")})
add_left_outer_join(options, association)
options
}
end_eval
end
end

# In a named scope you have 2 options for adding joins: :include and :joins.
#
# :include will execute multiple queries for each association and instantiate objects for each association.
# This is not what we want when we are searching. The only other option left is :joins. We can pass the
# name of the association directly, but AR creates an INNER JOIN. If we are ordering by an association's
# attribute, and that association is optional, the records without an association will be omitted. Again,
# not what we want.
#
# So the only option left is to use :joins with manually written SQL. We can still have AR generate this SQL
# for us by leveraging it's join dependency classes. Instead of using the InnerJoinDependency we use the regular
# JoinDependency which creates a LEFT OUTER JOIN, which is what we want.
#
# The code below was extracted out of AR's add_joins! method and then modified.
def add_left_outer_join(options, association)
join = ActiveRecord::Associations::ClassMethods::JoinDependency.new(self, association.name, nil).join_associations.collect { |assoc| assoc.association_join }.join
options[:joins] ||= ""
next if options[:joins].include?(join)
options[:joins] = join.strip + (options[:joins].blank? ? "" : " #{options[:joins]}")
end
end
end
end

0 comments on commit a9cbc43

Please sign in to comment.