albanpeignier-searchapi 0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/MIT-LICENSE +20 -0
- data/Manifest.txt +25 -0
- data/README +98 -0
- data/Rakefile +50 -0
- data/db/migrate/001_create_searchable.rb +33 -0
- data/init.rb +25 -0
- data/install.rb +1 -0
- data/lib/search_api.rb +13 -0
- data/lib/search_api/active_record_bridge.rb +385 -0
- data/lib/search_api/active_record_integration.rb +272 -0
- data/lib/search_api/bridge.rb +75 -0
- data/lib/search_api/callbacks.rb +65 -0
- data/lib/search_api/errors.rb +11 -0
- data/lib/search_api/search.rb +473 -0
- data/lib/search_api/sql_fragment.rb +98 -0
- data/lib/search_api/text_criterion.rb +132 -0
- data/searchapi.gemspec +35 -0
- data/tasks/search_api_tasks.rake +8 -0
- data/test/active_record_bridge_test.rb +488 -0
- data/test/active_record_integration_test.rb +49 -0
- data/test/bridge_test.rb +69 -0
- data/test/callbacks_test.rb +157 -0
- data/test/mock_model.rb +54 -0
- data/test/search_test.rb +340 -0
- data/uninstall.rb +1 -0
- metadata +98 -0
@@ -0,0 +1,98 @@
|
|
1
|
+
module SearchApi
|
2
|
+
|
3
|
+
# Utility class that implements logic for sql fragments (such as ["escaped_sql = ?", dirty_string])
|
4
|
+
|
5
|
+
class SqlFragment < Array
|
6
|
+
# A class that makes it more easy to manipulate SQL fragments : 'a=1' or ['a=?', 1]
|
7
|
+
#
|
8
|
+
# c = SqlFragment() => []
|
9
|
+
# c = SqlFragment('') => []
|
10
|
+
# c = SqlFragment('a=1') => ["a=1"]
|
11
|
+
# c = SqlFragment(['a=?', 1]) => ["a=?", 1]
|
12
|
+
# c.or(SqlFragment()) => ["a=?", 1]
|
13
|
+
# c.or(SqlFragment('b=2')) => ["(a=?) OR (b=2)", 1]
|
14
|
+
# c.or!(['b=?', 2]) => ["(a=?) OR (b=?)", 1, 2]
|
15
|
+
# c.and('c=3') => ["((a=?) OR (b=?)) AND (c=3)", 1, 2]
|
16
|
+
# c << ['c=?', 3] => ["((a=?) OR (b=?)) AND (c=?)", 1, 2, 3]
|
17
|
+
# c => ["((a=?) OR (b=?)) AND (c=?)", 1, 2, 3]
|
18
|
+
|
19
|
+
class << self
|
20
|
+
def sanitize(*args)
|
21
|
+
self.new(*args).sanitize
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
attr_reader :logical_operator
|
26
|
+
|
27
|
+
def initialize(*args)
|
28
|
+
if args.length > 1
|
29
|
+
else
|
30
|
+
args = args.first
|
31
|
+
end
|
32
|
+
|
33
|
+
# default operator is AND
|
34
|
+
self.logical_operator = :and
|
35
|
+
|
36
|
+
return if args.nil? or args.empty?
|
37
|
+
|
38
|
+
if args.is_a? Array
|
39
|
+
replace(args)
|
40
|
+
else
|
41
|
+
self.push(args)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def logical_operator=(logical_operator)
|
46
|
+
@logical_operator = (logical_operator || :and)
|
47
|
+
end
|
48
|
+
|
49
|
+
def and(inSqlFragment)
|
50
|
+
inSqlFragment = SqlFragment.new(inSqlFragment) unless inSqlFragment.is_a?(SqlFragment)
|
51
|
+
return self if inSqlFragment.empty?
|
52
|
+
return inSqlFragment if empty?
|
53
|
+
SqlFragment(["(#{sqlString}) AND (#{inSqlFragment[0]})"] + sqlParameters + inSqlFragment[1..-1])
|
54
|
+
end
|
55
|
+
def and!(inSqlFragment) replace(self.and(inSqlFragment)) end
|
56
|
+
|
57
|
+
def or(inSqlFragment)
|
58
|
+
inSqlFragment = SqlFragment.new(inSqlFragment) unless inSqlFragment.is_a?(SqlFragment)
|
59
|
+
return self if inSqlFragment.empty?
|
60
|
+
return inSqlFragment if empty?
|
61
|
+
SqlFragment(["(#{sqlString}) OR (#{inSqlFragment[0]})"] + sqlParameters + inSqlFragment[1..-1])
|
62
|
+
end
|
63
|
+
def or!(inSqlFragment) replace(self.or(inSqlFragment)) end
|
64
|
+
|
65
|
+
def <<(inSqlFragment)
|
66
|
+
case logical_operator
|
67
|
+
when :and!, :and
|
68
|
+
and!(inSqlFragment)
|
69
|
+
when :or!, :or
|
70
|
+
or!(inSqlFragment)
|
71
|
+
else
|
72
|
+
raise "Unsupported logical_operator #{logical_operator.inspect}"
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def not
|
77
|
+
return self if empty?
|
78
|
+
SqlFragment(["NOT(#{sqlString})"]+sqlParameters)
|
79
|
+
end
|
80
|
+
|
81
|
+
def sqlString
|
82
|
+
self[0]
|
83
|
+
end
|
84
|
+
def sqlParameters
|
85
|
+
self[1..-1]
|
86
|
+
end
|
87
|
+
|
88
|
+
def sanitize
|
89
|
+
fragment = self
|
90
|
+
ActiveRecord::Base.instance_eval do sanitize_sql(fragment) end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
|
96
|
+
def SqlFragment(*args)
|
97
|
+
SearchApi::SqlFragment.new(*args)
|
98
|
+
end
|
@@ -0,0 +1,132 @@
|
|
1
|
+
module SearchApi
|
2
|
+
|
3
|
+
# Utility class that implements fulltext search.
|
4
|
+
#
|
5
|
+
# Includes some Google-like features.
|
6
|
+
|
7
|
+
class TextCriterion
|
8
|
+
# t = TextCriterion.new('bonjour +les +amis -toto -"allons bon" define:"salut poulette"')
|
9
|
+
# t.meta_keywords => {"define"=>["salut poulette"]}
|
10
|
+
# t.mandatory_keywords => ["les", "amis"]
|
11
|
+
# t.optional_keywords => ["bonjour"],
|
12
|
+
# t.negative_keywords => ["toto", "allons bon"],
|
13
|
+
# t.positive_keywords = t.mandatory_keywords+t.optional_keywords
|
14
|
+
|
15
|
+
#
|
16
|
+
attr_accessor :meta_keywords
|
17
|
+
attr_accessor :mandatory_keywords
|
18
|
+
attr_accessor :negative_keywords
|
19
|
+
attr_accessor :optional_keywords
|
20
|
+
|
21
|
+
def initialize(inSearchString='', inOptions= {})
|
22
|
+
# inOptions may contain :
|
23
|
+
# :exclude => string, Regexp, or list of Strings and Regexp to exclude (strings are case insensitive)
|
24
|
+
|
25
|
+
@options = inOptions
|
26
|
+
@options[:exclude] = [@options[:exclude]] unless @options[:exclude].nil? || (@options[:exclude].is_a?(Enumerable) && !@options[:exclude].is_a?(String))
|
27
|
+
@options[:parse_meta?] = true if @options[:parse_meta?].nil?
|
28
|
+
|
29
|
+
@meta_keywords = {}
|
30
|
+
@mandatory_keywords = []
|
31
|
+
@negative_keywords = []
|
32
|
+
@optional_keywords = []
|
33
|
+
|
34
|
+
unless inSearchString.blank?
|
35
|
+
|
36
|
+
currentMeta = nil
|
37
|
+
|
38
|
+
splitter = /
|
39
|
+
(
|
40
|
+
[-+]?\b[^ ":]+\b:?
|
41
|
+
)
|
42
|
+
|
|
43
|
+
(
|
44
|
+
[-+]?"[^"]*"
|
45
|
+
)
|
46
|
+
/x
|
47
|
+
|
48
|
+
inSearchString.gsub(/\s+/, ' ').scan(splitter).each { |keyword|
|
49
|
+
keyword=(keyword[0]||keyword[1]).gsub(/"/, '')
|
50
|
+
|
51
|
+
if currentMeta
|
52
|
+
@meta_keywords[currentMeta] ||= []
|
53
|
+
@meta_keywords[currentMeta] << keyword
|
54
|
+
currentMeta = nil
|
55
|
+
else
|
56
|
+
case keyword
|
57
|
+
when /^-/
|
58
|
+
@negative_keywords << keyword[1..-1] unless exclude_keyword?(keyword[1..-1])
|
59
|
+
when /^\+/
|
60
|
+
@mandatory_keywords << keyword[1..-1] unless exclude_keyword?(keyword[1..-1])
|
61
|
+
when /:$/
|
62
|
+
if @options[:parse_meta?]
|
63
|
+
currentMeta = keyword[0..-2]
|
64
|
+
else
|
65
|
+
@optional_keywords << keyword unless exclude_keyword?(keyword)
|
66
|
+
end
|
67
|
+
else
|
68
|
+
@optional_keywords << keyword unless exclude_keyword?(keyword)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
}
|
72
|
+
|
73
|
+
# if everything is excluded, look for the whole search string
|
74
|
+
@optional_keywords << inSearchString if @meta_keywords.empty? && @mandatory_keywords.empty? && @negative_keywords.empty? && @optional_keywords.empty?
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def to_s
|
79
|
+
chunks = []
|
80
|
+
chunks += @mandatory_keywords.map { |x| (x =~ / /) ? "+\"#{x}\"" : "+#{x}" } unless @mandatory_keywords.blank?
|
81
|
+
chunks += @negative_keywords.map { |x| (x =~ / /) ? "-\"#{x}\"" : "-#{x}" } unless @negative_keywords.blank?
|
82
|
+
chunks += @optional_keywords.map { |x| (x =~ / /) ? "\"#{x}\"" : x.to_s } unless @optional_keywords.blank?
|
83
|
+
chunks += @meta_keywords.inject([]) { |s, key_value|
|
84
|
+
key, value = key_value
|
85
|
+
if value.is_a?(Array)
|
86
|
+
s += value.map { |x| (x =~ / /) ? "#{key}:\"#{x}\"" : "#{key}:#{x}" }
|
87
|
+
else
|
88
|
+
s << ((value =~ / /) ? "#{key}:\"#{value}\"" : "#{key}:#{value}")
|
89
|
+
end
|
90
|
+
s
|
91
|
+
} if @meta_keywords
|
92
|
+
chunks.join(' ')
|
93
|
+
end
|
94
|
+
|
95
|
+
def positive_keywords
|
96
|
+
@mandatory_keywords + @optional_keywords
|
97
|
+
end
|
98
|
+
|
99
|
+
def condition(inFields)
|
100
|
+
conditions = SqlFragment.new
|
101
|
+
|
102
|
+
conditions << (@mandatory_keywords.inject(SqlFragment.new) { |cv, value|
|
103
|
+
value = "%#{value}%"
|
104
|
+
cv.and(inFields.inject(SqlFragment.new) { |cf, field|
|
105
|
+
cf.or(["#{field} like ?", value])
|
106
|
+
})
|
107
|
+
})
|
108
|
+
|
109
|
+
conditions << (@negative_keywords.inject(SqlFragment.new) { |cv, value|
|
110
|
+
value = "%#{value}%"
|
111
|
+
cv.and(inFields.inject(SqlFragment.new) { |cf, field|
|
112
|
+
cf.or(["#{field} is not null AND #{field} like ?", value])
|
113
|
+
})
|
114
|
+
}.not)
|
115
|
+
|
116
|
+
conditions << (@optional_keywords.inject(SqlFragment.new) { |cv, value|
|
117
|
+
value = "%#{value}%"
|
118
|
+
cv.or(inFields.inject(SqlFragment.new) { |cf, field|
|
119
|
+
cf.or(["#{field} like ?", value])
|
120
|
+
})
|
121
|
+
})
|
122
|
+
|
123
|
+
conditions
|
124
|
+
end
|
125
|
+
|
126
|
+
protected
|
127
|
+
def exclude_keyword?(inKeyword)
|
128
|
+
return false unless @options[:exclude]
|
129
|
+
return @options[:exclude].any? { |exclude| inKeyword =~ (exclude.is_a?(Regexp) ? exclude : Regexp.new(Regexp.escape(exclude), 'i')) }
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
data/searchapi.gemspec
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
|
3
|
+
Gem::Specification.new do |s|
|
4
|
+
s.name = %q{searchapi}
|
5
|
+
s.version = "0.1"
|
6
|
+
|
7
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
8
|
+
s.authors = ["Gwendal Rou\303\251"]
|
9
|
+
s.date = %q{2009-03-31}
|
10
|
+
s.email = ["gr@pierlis.com"]
|
11
|
+
s.extra_rdoc_files = ["Manifest.txt"]
|
12
|
+
s.files = ["MIT-LICENSE", "Manifest.txt", "README", "Rakefile", "db/migrate/001_create_searchable.rb", "init.rb", "install.rb", "lib/search_api.rb", "lib/search_api/active_record_bridge.rb", "lib/search_api/active_record_integration.rb", "lib/search_api/bridge.rb", "lib/search_api/callbacks.rb", "lib/search_api/errors.rb", "lib/search_api/search.rb", "lib/search_api/sql_fragment.rb", "lib/search_api/text_criterion.rb", "searchapi.gemspec", "tasks/search_api_tasks.rake", "test/active_record_bridge_test.rb", "test/active_record_integration_test.rb", "test/bridge_test.rb", "test/callbacks_test.rb", "test/mock_model.rb", "test/search_test.rb", "uninstall.rb"]
|
13
|
+
s.has_rdoc = true
|
14
|
+
s.rdoc_options = ["--main", "README.txt"]
|
15
|
+
s.require_paths = ["lib"]
|
16
|
+
s.rubyforge_project = %q{searchapi}
|
17
|
+
s.rubygems_version = %q{1.3.1}
|
18
|
+
s.summary = %q{Ruby on Rails plugin which purpose is to let the developper define Search APIs for ActiveRecord models}
|
19
|
+
|
20
|
+
if s.respond_to? :specification_version then
|
21
|
+
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
|
22
|
+
s.specification_version = 2
|
23
|
+
|
24
|
+
if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
|
25
|
+
s.add_runtime_dependency(%q<activerecord>, [">= 0"])
|
26
|
+
s.add_development_dependency(%q<hoe>, [">= 1.11.0"])
|
27
|
+
else
|
28
|
+
s.add_dependency(%q<activerecord>, [">= 0"])
|
29
|
+
s.add_dependency(%q<hoe>, [">= 1.11.0"])
|
30
|
+
end
|
31
|
+
else
|
32
|
+
s.add_dependency(%q<activerecord>, [">= 0"])
|
33
|
+
s.add_dependency(%q<hoe>, [">= 1.11.0"])
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,8 @@
|
|
1
|
+
namespace :searchapi do
|
2
|
+
task :migrate_test_db => :environment do
|
3
|
+
RAILS_ENV = "test" unless defined?(RAILS_ENV)
|
4
|
+
|
5
|
+
ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations['test'])
|
6
|
+
ActiveRecord::Migrator.migrate(File.join(File.dirname(__FILE__), %w{.. db migrate}))
|
7
|
+
end
|
8
|
+
end
|
@@ -0,0 +1,488 @@
|
|
1
|
+
require 'test/unit'
|
2
|
+
RAILS_ENV = "test" unless defined?(RAILS_ENV)
|
3
|
+
require File.expand_path(File.join(File.dirname(__FILE__), '../../../../config/environment.rb'))
|
4
|
+
require 'search_api'
|
5
|
+
require 'active_record_bridge'
|
6
|
+
require 'test/mock_model'
|
7
|
+
|
8
|
+
class ActiveRecordBridgeTest < Test::Unit::TestCase
|
9
|
+
|
10
|
+
def setup
|
11
|
+
begin
|
12
|
+
Searchable.find(:first)
|
13
|
+
rescue
|
14
|
+
raise "run 'rake searchapi_migrate_test_db' in the application directory before running searchapi tests"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
|
19
|
+
def test_active_record_type_cast
|
20
|
+
# create a search class
|
21
|
+
search_class = Class.new(::SearchApi::Search::Base)
|
22
|
+
|
23
|
+
# define some search_accessors
|
24
|
+
search_class.class_eval do
|
25
|
+
model Searchable, :type_cast => true
|
26
|
+
end
|
27
|
+
|
28
|
+
search = search_class.new
|
29
|
+
|
30
|
+
# test number column
|
31
|
+
search.age = '12'
|
32
|
+
assert_equal 12, search.age
|
33
|
+
|
34
|
+
# test text column
|
35
|
+
search.name = ' thing '
|
36
|
+
assert_equal ' thing ', search.name
|
37
|
+
|
38
|
+
# test boolean column
|
39
|
+
search.funny = true
|
40
|
+
assert_equal true, search.funny
|
41
|
+
|
42
|
+
search.funny = false
|
43
|
+
assert_equal false, search.funny
|
44
|
+
|
45
|
+
search.funny = 1
|
46
|
+
assert_equal true, search.funny
|
47
|
+
|
48
|
+
search.funny = 0
|
49
|
+
assert_equal false, search.funny
|
50
|
+
end
|
51
|
+
|
52
|
+
|
53
|
+
def test_empty_search
|
54
|
+
# create a search class
|
55
|
+
search_class = Class.new(::SearchApi::Search::Base)
|
56
|
+
|
57
|
+
# define some search_accessors
|
58
|
+
search_class.class_eval do
|
59
|
+
model Searchable
|
60
|
+
end
|
61
|
+
|
62
|
+
assert_equivalent_request search_class,
|
63
|
+
nil,
|
64
|
+
nil
|
65
|
+
end
|
66
|
+
|
67
|
+
|
68
|
+
def test_eq_operator
|
69
|
+
# create a search class
|
70
|
+
search_class = Class.new(::SearchApi::Search::Base)
|
71
|
+
|
72
|
+
# define some search_accessors
|
73
|
+
search_class.class_eval do
|
74
|
+
model Searchable
|
75
|
+
search_accessor :years, :column => :age, :operator => :eq
|
76
|
+
end
|
77
|
+
|
78
|
+
|
79
|
+
# nil equality must behaves like ActiveRecord::Base
|
80
|
+
assert_equivalent_request search_class,
|
81
|
+
{:conditions => {:age => nil}},
|
82
|
+
{:years => nil}
|
83
|
+
|
84
|
+
# value equality must behaves like ActiveRecord::Base
|
85
|
+
assert_equivalent_request search_class,
|
86
|
+
{:conditions => {:age => 50}},
|
87
|
+
{:years => 50}
|
88
|
+
|
89
|
+
# array equality must behaves like ActiveRecord::Base
|
90
|
+
assert_equivalent_request search_class,
|
91
|
+
{:conditions => {:age => [11,23,54,92,42,24,25,26]}},
|
92
|
+
{:years => [11,23,54,92,42,24,25,26]}
|
93
|
+
|
94
|
+
# range equality must behaves like ActiveRecord::Base
|
95
|
+
assert_equivalent_request search_class,
|
96
|
+
{:conditions => {:age => 0..20}},
|
97
|
+
{:years => 0..20}
|
98
|
+
end
|
99
|
+
|
100
|
+
|
101
|
+
def test_neq_operator
|
102
|
+
# create a search class
|
103
|
+
search_class = Class.new(::SearchApi::Search::Base)
|
104
|
+
|
105
|
+
# define some search_accessors
|
106
|
+
search_class.class_eval do
|
107
|
+
model Searchable
|
108
|
+
search_accessor :not_funny, :column => :funny, :operator => :neq, :type_cast => true
|
109
|
+
end
|
110
|
+
|
111
|
+
|
112
|
+
# nil negation -> don't search (allow null values)
|
113
|
+
assert_equivalent_request search_class,
|
114
|
+
{:conditions => "funny IS NOT NULL"},
|
115
|
+
{:not_funny => nil}
|
116
|
+
|
117
|
+
# valued negation
|
118
|
+
assert_equivalent_request search_class,
|
119
|
+
{:conditions => ["funny = ? OR funny IS NULL", false]},
|
120
|
+
{:not_funny => true}
|
121
|
+
assert_equivalent_request search_class,
|
122
|
+
{:conditions => ["funny = ? OR funny IS NULL", true]},
|
123
|
+
{:not_funny => false}
|
124
|
+
end
|
125
|
+
|
126
|
+
|
127
|
+
def test_lt_operator
|
128
|
+
# create a search class
|
129
|
+
search_class = Class.new(::SearchApi::Search::Base)
|
130
|
+
|
131
|
+
# define some search_accessors
|
132
|
+
search_class.class_eval do
|
133
|
+
model Searchable
|
134
|
+
search_accessor :limit_age, :column => :age, :operator => :lt
|
135
|
+
end
|
136
|
+
|
137
|
+
|
138
|
+
# nil upper bound -> don't search (allow null values)
|
139
|
+
assert_equivalent_request search_class,
|
140
|
+
nil,
|
141
|
+
{:limit_age => nil}
|
142
|
+
|
143
|
+
# valued upper bound
|
144
|
+
assert_equivalent_request search_class,
|
145
|
+
{:conditions => "age < 50"},
|
146
|
+
{:limit_age => 50}
|
147
|
+
end
|
148
|
+
|
149
|
+
|
150
|
+
def test_lte_operator
|
151
|
+
# create a search class
|
152
|
+
search_class = Class.new(::SearchApi::Search::Base)
|
153
|
+
|
154
|
+
# define some search_accessors
|
155
|
+
search_class.class_eval do
|
156
|
+
model Searchable
|
157
|
+
search_accessor :limit_age, :column => :age, :operator => :lte
|
158
|
+
end
|
159
|
+
|
160
|
+
|
161
|
+
# nil upper bound -> don't search (allow null values)
|
162
|
+
assert_equivalent_request search_class,
|
163
|
+
nil,
|
164
|
+
{:limit_age => nil}
|
165
|
+
|
166
|
+
# valued upper bound
|
167
|
+
assert_equivalent_request search_class,
|
168
|
+
{:conditions => "age <= 50"},
|
169
|
+
{:limit_age => 50}
|
170
|
+
end
|
171
|
+
|
172
|
+
|
173
|
+
def test_gt_operator
|
174
|
+
# create a search class
|
175
|
+
search_class = Class.new(::SearchApi::Search::Base)
|
176
|
+
|
177
|
+
# define some search_accessors
|
178
|
+
search_class.class_eval do
|
179
|
+
model Searchable
|
180
|
+
search_accessor :limit_age, :column => :age, :operator => :gt
|
181
|
+
end
|
182
|
+
|
183
|
+
|
184
|
+
# nil lower bound -> don't search (allow null values)
|
185
|
+
assert_equivalent_request search_class,
|
186
|
+
nil,
|
187
|
+
{:limit_age => nil}
|
188
|
+
|
189
|
+
# valued lower bound
|
190
|
+
assert_equivalent_request search_class,
|
191
|
+
{:conditions => "age > 50"},
|
192
|
+
{:limit_age => 50}
|
193
|
+
end
|
194
|
+
|
195
|
+
|
196
|
+
def test_gte_operator
|
197
|
+
# create a search class
|
198
|
+
search_class = Class.new(::SearchApi::Search::Base)
|
199
|
+
|
200
|
+
# define some search_accessors
|
201
|
+
search_class.class_eval do
|
202
|
+
model Searchable
|
203
|
+
search_accessor :limit_age, :column => :age, :operator => :gte
|
204
|
+
end
|
205
|
+
|
206
|
+
|
207
|
+
# nil upper bound -> don't search (allow null values)
|
208
|
+
assert_equivalent_request search_class,
|
209
|
+
nil,
|
210
|
+
{:limit_age => nil}
|
211
|
+
|
212
|
+
# valued upper bound
|
213
|
+
assert_equivalent_request search_class,
|
214
|
+
{:conditions => "age >= 50"},
|
215
|
+
{:limit_age => 50}
|
216
|
+
end
|
217
|
+
|
218
|
+
|
219
|
+
def test_starts_with_operator
|
220
|
+
# create a search class
|
221
|
+
search_class = Class.new(::SearchApi::Search::Base)
|
222
|
+
|
223
|
+
# define some search_accessors
|
224
|
+
search_class.class_eval do
|
225
|
+
model Searchable
|
226
|
+
search_accessor :name_beginning, :column => :name, :operator => :starts_with, :type_cast => true
|
227
|
+
end
|
228
|
+
|
229
|
+
|
230
|
+
# nil beginning string -> don't search (allow null values)
|
231
|
+
assert_equivalent_request search_class,
|
232
|
+
nil,
|
233
|
+
{:name_beginning => nil}
|
234
|
+
|
235
|
+
# empty beginning string -> don't search (allow null values)
|
236
|
+
assert_equivalent_request search_class,
|
237
|
+
nil,
|
238
|
+
{:name_beginning => ''}
|
239
|
+
|
240
|
+
# valued beginning string
|
241
|
+
assert_equivalent_request search_class,
|
242
|
+
{:conditions => ["name like ?", 'Mary%']},
|
243
|
+
{:name_beginning => 'Mary'}
|
244
|
+
end
|
245
|
+
|
246
|
+
|
247
|
+
def test_ends_with_operator
|
248
|
+
# create a search class
|
249
|
+
search_class = Class.new(::SearchApi::Search::Base)
|
250
|
+
|
251
|
+
# define some search_accessors
|
252
|
+
search_class.class_eval do
|
253
|
+
model Searchable
|
254
|
+
search_accessor :name_ending, :column => :name, :operator => :ends_with, :type_cast => true
|
255
|
+
end
|
256
|
+
|
257
|
+
|
258
|
+
# nil ending string -> don't search (allow null values)
|
259
|
+
assert_equivalent_request search_class,
|
260
|
+
nil,
|
261
|
+
{:name_ending => nil}
|
262
|
+
|
263
|
+
# empty ending string -> don't search (allow null values)
|
264
|
+
assert_equivalent_request search_class,
|
265
|
+
nil,
|
266
|
+
{:name_ending => ''}
|
267
|
+
|
268
|
+
# valued ending string
|
269
|
+
assert_equivalent_request search_class,
|
270
|
+
{:conditions => ["name like ?", '%Ann']},
|
271
|
+
{:name_ending => 'Ann'}
|
272
|
+
end
|
273
|
+
|
274
|
+
|
275
|
+
def test_contains_operator
|
276
|
+
# create a search class
|
277
|
+
search_class = Class.new(::SearchApi::Search::Base)
|
278
|
+
|
279
|
+
# define some search_accessors
|
280
|
+
search_class.class_eval do
|
281
|
+
model Searchable
|
282
|
+
search_accessor :name_partial, :column => :name, :operator => :contains, :type_cast => true
|
283
|
+
end
|
284
|
+
|
285
|
+
|
286
|
+
# nil partial string -> don't search (allow null values)
|
287
|
+
assert_equivalent_request search_class,
|
288
|
+
nil,
|
289
|
+
{:name_partial => nil}
|
290
|
+
|
291
|
+
# empty partial string -> don't search (allow null values)
|
292
|
+
assert_equivalent_request search_class,
|
293
|
+
nil,
|
294
|
+
{:name_partial => ''}
|
295
|
+
|
296
|
+
# valued partial string
|
297
|
+
assert_equivalent_request search_class,
|
298
|
+
{:conditions => ["name like ?", '%ar%']},
|
299
|
+
{:name_partial => 'ar'}
|
300
|
+
end
|
301
|
+
|
302
|
+
|
303
|
+
def test_full_text_operator
|
304
|
+
# create a search class
|
305
|
+
search_class = Class.new(::SearchApi::Search::Base)
|
306
|
+
|
307
|
+
# define some search_accessors
|
308
|
+
search_class.class_eval do
|
309
|
+
model Searchable
|
310
|
+
search_accessor :keyword, :column => [:name, :city], :operator => :full_text
|
311
|
+
end
|
312
|
+
|
313
|
+
|
314
|
+
# nil full text search -> don't search (allow null values)
|
315
|
+
assert_equivalent_request search_class,
|
316
|
+
nil,
|
317
|
+
{:keyword => nil}
|
318
|
+
|
319
|
+
# empty full text search -> don't search (allow null values)
|
320
|
+
assert_equivalent_request search_class,
|
321
|
+
nil,
|
322
|
+
{:keyword => nil}
|
323
|
+
|
324
|
+
# valued full text search
|
325
|
+
assert_equivalent_request search_class,
|
326
|
+
{:conditions => ["((searchables.`name` like ?) OR (searchables.`city` like ?)) OR ((searchables.`name` like ?) OR (searchables.`city` like ?))", "%Mary%", "%Mary%", "%Paris%", "%Paris%"]},
|
327
|
+
{:keyword => 'Mary Paris'}
|
328
|
+
end
|
329
|
+
|
330
|
+
|
331
|
+
def test_multi_column_search
|
332
|
+
# create a search class
|
333
|
+
search_class = Class.new(::SearchApi::Search::Base)
|
334
|
+
|
335
|
+
# define some search_accessors
|
336
|
+
search_class.class_eval do
|
337
|
+
model Searchable
|
338
|
+
end
|
339
|
+
|
340
|
+
|
341
|
+
assert_equivalent_request search_class,
|
342
|
+
{:conditions => {:age=>(10..30).to_a, :city=>['Paris', 'London']}},
|
343
|
+
{:age => (10..30).to_a, :city => ['Paris', 'London']}
|
344
|
+
end
|
345
|
+
|
346
|
+
|
347
|
+
def test_automatic_search_attribute_builders
|
348
|
+
# create a search class
|
349
|
+
search_class = Class.new(::SearchApi::Search::Base)
|
350
|
+
|
351
|
+
# define some search_accessors
|
352
|
+
search_class.class_eval do
|
353
|
+
model Searchable
|
354
|
+
end
|
355
|
+
|
356
|
+
# assert columns names are there
|
357
|
+
assert Searchable.columns.map(&:name).all? { |column_name| search_class.search_attributes.include?(column_name.to_sym)}
|
358
|
+
|
359
|
+
# assert lower and upper bound search attributes are there
|
360
|
+
assert %w(min_age max_age).all? { |column_name| search_class.search_attributes.include?(column_name.to_sym)}
|
361
|
+
end
|
362
|
+
|
363
|
+
|
364
|
+
def test_equality_automatic_attributes
|
365
|
+
# create a search class
|
366
|
+
search_class = Class.new(::SearchApi::Search::Base)
|
367
|
+
|
368
|
+
# define some search_accessors
|
369
|
+
search_class.class_eval do
|
370
|
+
model Searchable
|
371
|
+
end
|
372
|
+
|
373
|
+
|
374
|
+
# nil equality must behaves like ActiveRecord::Base
|
375
|
+
conditions = {:age => nil}
|
376
|
+
assert_equivalent_request search_class,
|
377
|
+
{:conditions => conditions},
|
378
|
+
conditions
|
379
|
+
|
380
|
+
# value equality must behaves like ActiveRecord::Base
|
381
|
+
conditions = {:name => 'Mary'}
|
382
|
+
assert_equivalent_request search_class,
|
383
|
+
{:conditions => conditions},
|
384
|
+
conditions
|
385
|
+
|
386
|
+
# array equality must behaves like ActiveRecord::Base
|
387
|
+
conditions = {:age => [11,23,54,92,42,24,25,26]}
|
388
|
+
assert_equivalent_request search_class,
|
389
|
+
{:conditions => conditions},
|
390
|
+
conditions
|
391
|
+
|
392
|
+
# range equality must behaves like ActiveRecord::Base
|
393
|
+
conditions = {:age => 0..20}
|
394
|
+
assert_equivalent_request search_class,
|
395
|
+
{:conditions => conditions},
|
396
|
+
conditions
|
397
|
+
end
|
398
|
+
|
399
|
+
|
400
|
+
def test_lower_bound_automatic_attributes
|
401
|
+
# create a search class
|
402
|
+
search_class = Class.new(::SearchApi::Search::Base)
|
403
|
+
|
404
|
+
# define some search_accessors
|
405
|
+
search_class.class_eval do
|
406
|
+
model Searchable
|
407
|
+
end
|
408
|
+
|
409
|
+
|
410
|
+
# nil lower bound -> don't search (allow null values)
|
411
|
+
assert_equivalent_request search_class,
|
412
|
+
nil,
|
413
|
+
{:min_age => nil}
|
414
|
+
|
415
|
+
# valued lower bound
|
416
|
+
assert_equivalent_request search_class,
|
417
|
+
{:conditions => "age >= 50"},
|
418
|
+
{:min_age => 50}
|
419
|
+
end
|
420
|
+
|
421
|
+
|
422
|
+
def test_upper_bound_automatic_attributes
|
423
|
+
# create a search class
|
424
|
+
search_class = Class.new(::SearchApi::Search::Base)
|
425
|
+
|
426
|
+
# define some search_accessors
|
427
|
+
search_class.class_eval do
|
428
|
+
model Searchable
|
429
|
+
end
|
430
|
+
|
431
|
+
|
432
|
+
# nil upper bound -> don't search (allow null values)
|
433
|
+
assert_equivalent_request search_class,
|
434
|
+
nil,
|
435
|
+
{:max_age => nil}
|
436
|
+
|
437
|
+
# valued upper bound
|
438
|
+
assert_equivalent_request search_class,
|
439
|
+
{:conditions => "age <= 50"},
|
440
|
+
{:max_age => 50}
|
441
|
+
end
|
442
|
+
|
443
|
+
|
444
|
+
def test_block_defined_search
|
445
|
+
# create a search class
|
446
|
+
search_class = Class.new(::SearchApi::Search::Base)
|
447
|
+
|
448
|
+
# define some search_accessors
|
449
|
+
search_class.class_eval do
|
450
|
+
model Searchable
|
451
|
+
search_accessor :age_limit do |search|
|
452
|
+
{ :conditions => ["age < ?", search.age_limit] }
|
453
|
+
end
|
454
|
+
end
|
455
|
+
|
456
|
+
assert_equivalent_request search_class,
|
457
|
+
{:conditions => "age < 50"},
|
458
|
+
{:age_limit => 50}
|
459
|
+
end
|
460
|
+
|
461
|
+
|
462
|
+
def test_valid_find_options
|
463
|
+
# create a search class
|
464
|
+
search_class = Class.new(::SearchApi::Search::Base)
|
465
|
+
|
466
|
+
# define some search_accessors
|
467
|
+
search_class.class_eval do
|
468
|
+
model Searchable
|
469
|
+
search_accessor :bad_hash do |search|
|
470
|
+
{ :foo => :bar } # invalid key
|
471
|
+
end
|
472
|
+
end
|
473
|
+
|
474
|
+
assert_raise ArgumentError do
|
475
|
+
search_class.new(:bad_hash => true).find_options
|
476
|
+
end
|
477
|
+
end
|
478
|
+
|
479
|
+
|
480
|
+
protected
|
481
|
+
|
482
|
+
def assert_equivalent_request(search_class, traditional_find_options, search_options)
|
483
|
+
traditional_result_ids = Searchable.find(:all, traditional_find_options).map(&:id).sort
|
484
|
+
search_result_ids = Searchable.find(:all, search_class.new(search_options).find_options).map(&:id).sort
|
485
|
+
assert !traditional_result_ids.empty?
|
486
|
+
assert_equal traditional_result_ids, search_result_ids
|
487
|
+
end
|
488
|
+
end
|