binarylogic-searchlogic 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,6 @@
1
+ .DS_Store
2
+ *.log
3
+ pkg/*
4
+ coverage/*
5
+ doc/*
6
+ benchmarks/*
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Ben Johnson of Binary Logic
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,191 @@
1
+ = Searchlogic
2
+
3
+ <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>
4
+
5
+ <b>Attention v1 users. v2 breaks backwards compatibility. Please make sure your gem declaration for searchlogic looks like this so that you app does not break when the gem is updated: config.gem "searchlogic", :version => "~> 1.6.6"</b>
6
+
7
+ Searchlogic provides common named scopes and object based searching.
8
+
9
+ == Helpful links
10
+
11
+ * <b>Documentation:</b> http://rdoc.info/projects/binarylogic/searchlogic
12
+ * <b>Repository:</b> http://github.com/binarylogic/searchlogic/tree/master
13
+ * <b>Bugs / feature suggestions:</b> http://binarylogic.lighthouseapp.com/projects/16601-searchlogic
14
+ * <b>Google group:</b> http://groups.google.com/group/searchlogic
15
+
16
+ <b>Before contacting me directly, please read:</b>
17
+
18
+ If you find a bug or a problem please post it on lighthouse. If you need help with something, please use google groups. I check both regularly and get emails when anything happens, so that is the best place to get help. This also benefits other people in the future with the same questions / problems. Thank you.
19
+
20
+ == Install & use
21
+
22
+ In your rails project:
23
+
24
+ # config/environment.rb
25
+ config.gem "binarylogic-searchlogic",
26
+ :lib => 'searchlogic',
27
+ :source => 'http://gems.github.com',
28
+ :version => '0.6.6'
29
+
30
+ Then install the gem:
31
+
32
+ rake gems:install
33
+
34
+ That's it, you are ready to go. See below for usage examples.
35
+
36
+ == Search using conditions on columns
37
+
38
+ Instead of explaining what Searchlogic can do, let me show you. Let's start at the top:
39
+
40
+ # We have the following model
41
+ User(id: integer, created_at: datetime, username: string, age: integer)
42
+
43
+ # Searchlogic gives you a bunch of named scopes for free:
44
+ User.username_equals("bjohnson")
45
+ User.username_does_not_equal("bjohnson")
46
+ User.username_begins_with("bjohnson")
47
+ User.username_like("bjohnson")
48
+ User.username_ends_with("bjohnson")
49
+ User.age_greater_than(20)
50
+ User.age_greater_than_or_equal_to(20)
51
+ User.age_less_than(20)
52
+ User.age_less_than_or_equal_to(20)
53
+ User.username_null
54
+ User.username_blank
55
+
56
+ # You can also order by columns
57
+ User.ascend_by_username
58
+ User.descend_by_username
59
+ User.order("ascend_by_username")
60
+
61
+ Any named scope Searchlogic creates is dynamic and created via method_missing. Meaning it will only create what you need. Also, keep in mind, these are just named scopes, you can chain them, call methods off of them, etc:
62
+
63
+ scope = User.username_like("bjohnson").age_greater_than(20).ascend_by_username
64
+ scope.all
65
+ scope.first
66
+ scope.count
67
+ # etc...
68
+
69
+ That's all pretty standard, but here's where Searchlogic starts to get interesting...
70
+
71
+ == Search using conditions on association columns
72
+
73
+ You also get named scopes for any of your associations:
74
+
75
+ # We have the following relationships
76
+ User.has_many :orders
77
+ Order.has_many :line_items
78
+ LineItem
79
+
80
+ # Set conditions on association columns
81
+ User.orders_total_greater_than(20)
82
+ User.orders_line_items_price_greater_than(20)
83
+
84
+ # Order by association columns
85
+ User.ascend_by_order_total
86
+ User.descend_by_orders_line_items_price
87
+
88
+ 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 <em>much</em> faster. Instead they create a LEFT OUTER JOIN and pass it to the :joins option, which is great for performance. To prove my point here is a quick benchmark from an application I am working on:
89
+
90
+ Benchmark.bm do |x|
91
+ x.report { 10.times { Event.tickets_id_gt(10).all(:include => :tickets) } }
92
+ x.report { 10.times { Event.tickets_id_gt(10).all } }
93
+ end
94
+ user system total real
95
+ 10.120000 0.170000 10.290000 ( 12.625521)
96
+ 2.630000 0.050000 2.680000 ( 3.313754)
97
+
98
+ If you want to use the :include option, just specify it:
99
+
100
+ User.orders_line_items_price_greater_than(20).all(:include => {:orders => :line_items})
101
+
102
+ Obviously, only do this if you want to actually use the included objects.
103
+
104
+ == Make searching and ordering data in your application trivial
105
+
106
+ The above is great, but what about tying all of this in with a search form in your application? Just do this...
107
+
108
+ User.search(:username_like => "bjohnson", :age_less_than => 20)
109
+
110
+ The above is equivalent to:
111
+
112
+ User.username_like("bjohnson").age_less_than(20)
113
+
114
+ 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:
115
+
116
+ class UsersController < ApplicationController
117
+ def index
118
+ @search = User.search(params[:search])
119
+ @users = @search.all
120
+ end
121
+ end
122
+
123
+ 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:
124
+
125
+ - form_for @search do |f|
126
+ = f.text_field :username_like
127
+ = f.select :age_greater_than, (0..100)
128
+ = f.text_field :orders_total_greater_than
129
+ = f.submit
130
+
131
+ When a Searchlogic::Search object is passed to form_for it will add a hidden field for the "order" condition, to preserve the order of the data. If you want to order your search with a link, just specify the name of the column. Ex:
132
+
133
+ = order @search, :by => :age
134
+
135
+ This will create a link that alternates between calling "ascend_by_age" and "descend_by_age". If you wanted to order your data by more than just a column, create your own named scopes: "ascend_by_*" and "descend_by_*". The "order" helper is a very straight forward helper, checkout the docs for some of the options.
136
+
137
+ == Use your existing named scopes
138
+
139
+ This is one of the big differences between Searchlogic v1 and v2. What about your existing named scopes? Let's say you have this:
140
+
141
+ User.named_scope :four_year_olds, :conditions => {:age => 4}
142
+
143
+ Again, these are all just named scopes, use it in the same way:
144
+
145
+ User.search(:four_year_olds => true, :username_like => "bjohnson")
146
+
147
+ 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.
148
+
149
+ Now just throw it in your form:
150
+
151
+ - form_for @search do |f|
152
+ = f.text_field :username_like
153
+ = f.check_box :four_year_olds
154
+ = f.submit
155
+
156
+ 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 edge case that you need, just create your own named scope. The sky is the limit.
157
+
158
+ == Use any or all
159
+
160
+ Every condition you've seen in this readme also has 2 related conditions that you can use. Example:
161
+
162
+ User.username_like_any("bjohnson", "thunt") # will return any users that have either of those strings in their username
163
+ User.username_like_all("bjohnson", "thunt") # will return any users that have all of those string in their username
164
+ User.username_like_any(["bjohnson", "thunt"]) # also accept an array
165
+
166
+ This is great for checkbox filters, etc. Where you can pass an array right from your form to this condition.
167
+
168
+ == Pagination (leverage will_paginate)
169
+
170
+ 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:
171
+
172
+ User.username_like("bjohnson").age_less_than(20).paginate(:page => params[:page])
173
+ User.search(:username_like => "bjohnson", :age_less_than => 20).paginate(:page => params[:page])
174
+
175
+ 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.
176
+
177
+ == Under the hood
178
+
179
+ 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:
180
+
181
+ 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.
182
+
183
+ That's about it, the named scope options are pretty bare bones and created just like you would manually.
184
+
185
+ == Credit
186
+
187
+ Thanks a lot to {Tyler Hunt}[http://github.com/tylerhunt] for helping plan, design, and start the project. He was a big help.
188
+
189
+ == Copyright
190
+
191
+ Copyright (c) 2009 {Ben Johnson of Binary Logic}[http://www.binarylogic.com], released under the MIT license
data/Rakefile ADDED
@@ -0,0 +1,48 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "searchlogic"
8
+ gem.summary = "Searchlogic provides common named scopes and object based searching."
9
+ gem.email = "bjohnson@binarylogic.com"
10
+ gem.homepage = "http://github.com/binarylogic/search"
11
+ gem.authors = ["Ben Johnson of Binary Logic"]
12
+ # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
13
+ end
14
+
15
+ rescue LoadError
16
+ puts "Jeweler (or a dependency) not available. Install it with: sudo gem install jeweler"
17
+ end
18
+
19
+ require 'spec/rake/spectask'
20
+ Spec::Rake::SpecTask.new(:spec) do |spec|
21
+ spec.libs << 'lib' << 'spec'
22
+ spec.spec_files = FileList['spec/**/*_spec.rb']
23
+ end
24
+
25
+ Spec::Rake::SpecTask.new(:rcov) do |spec|
26
+ spec.libs << 'lib' << 'spec'
27
+ spec.pattern = 'spec/**/*_spec.rb'
28
+ spec.rcov = true
29
+ end
30
+
31
+
32
+ task :default => :spec
33
+
34
+ require 'rake/rdoctask'
35
+ Rake::RDocTask.new do |rdoc|
36
+ if File.exist?('VERSION.yml')
37
+ config = YAML.load(File.read('VERSION.yml'))
38
+ version = "#{config[:major]}.#{config[:minor]}.#{config[:patch]}"
39
+ else
40
+ version = ""
41
+ end
42
+
43
+ rdoc.rdoc_dir = 'rdoc'
44
+ rdoc.title = "search #{version}"
45
+ rdoc.rdoc_files.include('README*')
46
+ rdoc.rdoc_files.include('lib/**/*.rb')
47
+ end
48
+
data/VERSION.yml ADDED
@@ -0,0 +1,4 @@
1
+ ---
2
+ :major: 2
3
+ :minor: 0
4
+ :patch: 0
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require "searchlogic"
@@ -0,0 +1,39 @@
1
+ module Searchlogic
2
+ module CoreExt
3
+ # Contains extensions for the Object class that Searchlogic uses.
4
+ module Object
5
+ # Searchlogic needs to know the expected type of the condition value so that it can properly cast
6
+ # the value in the Searchlogic::Search object. For example:
7
+ #
8
+ # search = User.search(:id_gt => "1")
9
+ #
10
+ # You would expect this:
11
+ #
12
+ # search.id_gt => 1
13
+ #
14
+ # Not this:
15
+ #
16
+ # search.id_gt => "1"
17
+ #
18
+ # Parameter values from forms are ALWAYS strings, so we have to cast them. Just like ActiveRecord
19
+ # does when you instantiate a new User object.
20
+ #
21
+ # The problem is that ruby has no variable types, so Searchlogic needs to know what type you are expecting
22
+ # for your named scope. So instead of this:
23
+ #
24
+ # named_scope :id_gt, lambda { |value| {:conditions => ["id > ?", value]} }
25
+ #
26
+ # You need to do this:
27
+ #
28
+ # named_scope :id_gt, searchlogic_lambda(:integer) { |value| {:conditions => ["id > ?", value]} }
29
+ #
30
+ # If you are wanting a string, you don't have to do anything, because Searchlogic assumes you are want a string.
31
+ # If you want something else, you need to specify it as I did in the above example.
32
+ def searchlogic_lambda(type = :string, &block)
33
+ proc = lambda(&block)
34
+ proc.searchlogic_arg_type = type
35
+ proc
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,11 @@
1
+ module Searchlogic
2
+ module CoreExt
3
+ module Proc # :nodoc:
4
+ def self.included(klass)
5
+ klass.class_eval do
6
+ attr_accessor :searchlogic_arg_type
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,144 @@
1
+ module Searchlogic
2
+ module NamedScopes
3
+ # Handles dynamically creating named scopes for associations.
4
+ module Associations
5
+ def condition?(name) # :nodoc:
6
+ super || association_condition?(name) || association_alias_condition?(name)
7
+ end
8
+
9
+ def primary_condition_name(name) # :nodoc:
10
+ if result = super
11
+ result
12
+ elsif association_condition?(name)
13
+ name.to_sym
14
+ elsif details = association_alias_condition_details(name)
15
+ "#{details[:association]}_#{details[:column]}_#{primary_condition(details[:condition])}".to_sym
16
+ else
17
+ nil
18
+ end
19
+ end
20
+
21
+ # Is the name of the method a valid name for an association condition?
22
+ def association_condition?(name)
23
+ !association_condition_details(name).nil?
24
+ end
25
+
26
+ # Is the ane of the method a valie name for an association alias condition?
27
+ # An alias being "gt" for "greater_than", etc.
28
+ def association_alias_condition?(name)
29
+ !association_alias_condition_details(name).nil?
30
+ end
31
+
32
+ private
33
+ def method_missing(name, *args, &block)
34
+ if details = association_condition_details(name)
35
+ create_association_condition(details[:association], details[:column], details[:condition], args)
36
+ send(name, *args)
37
+ elsif details = association_alias_condition_details(name)
38
+ create_association_alias_condition(details[:association], details[:column], details[:condition], args)
39
+ send(name, *args)
40
+ elsif details = association_ordering_condition_details(name)
41
+ create_association_ordering_condition(details[:association], details[:order_as], details[:column], args)
42
+ send(name, *args)
43
+ else
44
+ super
45
+ end
46
+ end
47
+
48
+ def association_condition_details(name)
49
+ associations = reflect_on_all_associations.collect { |assoc| assoc.name }
50
+ if name.to_s =~ /^(#{associations.join("|")})_(\w+)_(#{Conditions::PRIMARY_CONDITIONS.join("|")})$/
51
+ {:association => $1, :column => $2, :condition => $3}
52
+ end
53
+ end
54
+
55
+ def create_association_condition(association_name, column, condition, args)
56
+ named_scope("#{association_name}_#{column}_#{condition}", association_condition_options(association_name, "#{column}_#{condition}", args))
57
+ end
58
+
59
+ def association_alias_condition_details(name)
60
+ associations = reflect_on_all_associations.collect { |assoc| assoc.name }
61
+ if name.to_s =~ /^(#{associations.join("|")})_(\w+)_(#{Conditions::ALIAS_CONDITIONS.join("|")})$/
62
+ {:association => $1, :column => $2, :condition => $3}
63
+ end
64
+ end
65
+
66
+ def create_association_alias_condition(association, column, condition, args)
67
+ primary_condition = primary_condition(condition)
68
+ alias_name = "#{association}_#{column}_#{condition}"
69
+ primary_name = "#{association}_#{column}_#{primary_condition}"
70
+ send(primary_name, *args) # go back to method_missing and make sure we create the method
71
+ (class << self; self; end).class_eval { alias_method alias_name, primary_name }
72
+ end
73
+
74
+ def association_ordering_condition_details(name)
75
+ associations = reflect_on_all_associations.collect { |assoc| assoc.name }
76
+ if name.to_s =~ /^(ascend|descend)_by_(#{associations.join("|")})_(\w+)$/
77
+ {:order_as => $1, :association => $2, :column => $3}
78
+ end
79
+ end
80
+
81
+ def create_association_ordering_condition(association_name, order_as, column, args)
82
+ named_scope("#{order_as}_by_#{association_name}_#{column}", association_condition_options(association_name, "#{order_as}_by_#{column}", args))
83
+ end
84
+
85
+ def association_condition_options(association_name, association_condition, args)
86
+ association = reflect_on_association(association_name.to_sym)
87
+ scope = association.klass.send(association_condition, *args)
88
+ scope_options = association.klass.named_scope_options(association_condition)
89
+ arity = association.klass.named_scope_arity(association_condition)
90
+
91
+ if !arity || arity == 0
92
+ # The underlying condition doesn't require any parameters, so let's just create a simple
93
+ # named scope that is based on a hash.
94
+ options = scope.proxy_options
95
+ add_left_outer_joins(options, association)
96
+ options
97
+ else
98
+ # The underlying condition requires parameters, let's match the parameters it requires
99
+ # and pass those onto the named scope. We can't use proxy_options because that returns the
100
+ # result after a value has been passed.
101
+ proc_args = []
102
+ if arity > 0
103
+ arity.times { |i| proc_args << "arg#{i}"}
104
+ else
105
+ positive_arity = arity * -1
106
+ positive_arity.times do |i|
107
+ if i == (positive_arity - 1)
108
+ proc_args << "*arg#{i}"
109
+ else
110
+ proc_args << "arg#{i}"
111
+ end
112
+ end
113
+ end
114
+ eval <<-"end_eval"
115
+ searchlogic_lambda(:#{scope_options.searchlogic_arg_type}) { |#{proc_args.join(",")}|
116
+ options = association.klass.named_scope_options(association_condition).call(#{proc_args.join(",")})
117
+ add_left_outer_joins(options, association)
118
+ options
119
+ }
120
+ end_eval
121
+ end
122
+ end
123
+
124
+ # In a named scope you have 2 options for adding joins: :include and :joins.
125
+ #
126
+ # :include will execute multiple queries for each association and instantiate objects for each association.
127
+ # This is not what we want when we are searching. The only other option left is :joins. We can pass the
128
+ # name of the association directly, but AR creates an INNER JOIN. If we are ordering by an association's
129
+ # attribute, and that association is optional, the records without an association will be omitted. Again,
130
+ # not what we want.
131
+ #
132
+ # So the only option left is to use :joins with manually written SQL. We can still have AR generate this SQL
133
+ # for us by leveraging it's join dependency classes. Instead of using the InnerJoinDependency we use the regular
134
+ # JoinDependency which creates a LEFT OUTER JOIN, which is what we want.
135
+ #
136
+ # The code below was extracted out of AR's add_joins! method and then modified.
137
+ def add_left_outer_joins(options, association)
138
+ join = ActiveRecord::Associations::ClassMethods::JoinDependency.new(self, association.name, nil).join_associations.collect { |assoc| assoc.association_join }.join.strip
139
+ options[:joins] ||= []
140
+ options[:joins].unshift(join)
141
+ end
142
+ end
143
+ end
144
+ end