albanpeignier-searchapi 0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|