pg_search 0.0.2
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/.autotest +5 -0
- data/.gitignore +7 -0
- data/.rvmrc +1 -0
- data/CHANGELOG +7 -0
- data/Gemfile +5 -0
- data/LICENSE +19 -0
- data/README.rdoc +290 -0
- data/Rakefile +35 -0
- data/TODO +12 -0
- data/gemfiles/Gemfile.common +9 -0
- data/gemfiles/rails2/Gemfile +4 -0
- data/gemfiles/rails3/Gemfile +4 -0
- data/lib/pg_search.rb +32 -0
- data/lib/pg_search/configuration.rb +73 -0
- data/lib/pg_search/configuration/column.rb +43 -0
- data/lib/pg_search/features.rb +7 -0
- data/lib/pg_search/features/dmetaphone.rb +28 -0
- data/lib/pg_search/features/trigram.rb +29 -0
- data/lib/pg_search/features/tsearch.rb +64 -0
- data/lib/pg_search/normalizer.rb +13 -0
- data/lib/pg_search/railtie.rb +11 -0
- data/lib/pg_search/scope.rb +31 -0
- data/lib/pg_search/scope_options.rb +75 -0
- data/lib/pg_search/tasks.rb +37 -0
- data/lib/pg_search/version.rb +3 -0
- data/pg_search.gemspec +19 -0
- data/script/setup-contrib +12 -0
- data/spec/associations_spec.rb +225 -0
- data/spec/pg_search_spec.rb +596 -0
- data/spec/spec_helper.rb +92 -0
- data/sql/dmetaphone.sql +4 -0
- data/sql/uninstall_dmetaphone.sql +1 -0
- metadata +103 -0
@@ -0,0 +1,12 @@
|
|
1
|
+
#!/bin/sh
|
2
|
+
POSTGRESQL_VERSION=`pg_config --version | awk '{print $2}'`
|
3
|
+
|
4
|
+
cd /tmp
|
5
|
+
test -e /tmp/postgresql-$POSTGRESQL_VERSION.tar.bz2 || wget http://ftp9.us.postgresql.org/pub/mirrors/postgresql/source/v$POSTGRESQL_VERSION/postgresql-$POSTGRESQL_VERSION.tar.bz2
|
6
|
+
test -d /tmp/postgresql-$POSTGRESQL_VERSION || tar zxvf postgresql-$POSTGRESQL_VERSION.tar.bz2
|
7
|
+
cd postgresql-$POSTGRESQL_VERSION && eval ./configure `pg_config --configure` && make
|
8
|
+
cd contrib/unaccent && make && make install
|
9
|
+
cd ..
|
10
|
+
cd contrib/pg_trgm && make && make install
|
11
|
+
cd ..
|
12
|
+
cd contrib/fuzzystrmatch && make && make install
|
@@ -0,0 +1,225 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
|
2
|
+
|
3
|
+
describe PgSearch do
|
4
|
+
context "joining to another table" do
|
5
|
+
if defined?(ActiveRecord::Relation)
|
6
|
+
context "with Arel support" do
|
7
|
+
context "through a belongs_to association" do
|
8
|
+
with_model :associated_model do
|
9
|
+
table do |t|
|
10
|
+
t.string 'title'
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
with_model :model_with_belongs_to do
|
15
|
+
table do |t|
|
16
|
+
t.string 'title'
|
17
|
+
t.belongs_to 'another_model'
|
18
|
+
end
|
19
|
+
|
20
|
+
model do
|
21
|
+
include PgSearch
|
22
|
+
belongs_to :another_model, :class_name => 'AssociatedModel'
|
23
|
+
|
24
|
+
pg_search_scope :with_associated, :against => :title, :associated_against => {:another_model => :title}
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
it "returns rows that match the query in either its own columns or the columns of the associated model" do
|
29
|
+
associated = associated_model.create!(:title => 'abcdef')
|
30
|
+
included = [
|
31
|
+
model_with_belongs_to.create!(:title => 'ghijkl', :another_model => associated),
|
32
|
+
model_with_belongs_to.create!(:title => 'abcdef')
|
33
|
+
]
|
34
|
+
excluded = model_with_belongs_to.create!(:title => 'mnopqr',
|
35
|
+
:another_model => associated_model.create!(:title => 'stuvwx'))
|
36
|
+
|
37
|
+
results = model_with_belongs_to.with_associated('abcdef')
|
38
|
+
results.map(&:title).should =~ included.map(&:title)
|
39
|
+
results.should_not include(excluded)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
context "through a has_many association" do
|
44
|
+
with_model :associated_model_with_has_many do
|
45
|
+
table do |t|
|
46
|
+
t.string 'title'
|
47
|
+
t.belongs_to 'model_with_has_many'
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
with_model :model_with_has_many do
|
52
|
+
table do |t|
|
53
|
+
t.string 'title'
|
54
|
+
end
|
55
|
+
|
56
|
+
model do
|
57
|
+
include PgSearch
|
58
|
+
has_many :other_models, :class_name => 'AssociatedModelWithHasMany', :foreign_key => 'model_with_has_many_id'
|
59
|
+
|
60
|
+
pg_search_scope :with_associated, :against => [:title], :associated_against => {:other_models => :title}
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
it "returns rows that match the query in either its own columns or the columns of the associated model" do
|
65
|
+
included = [
|
66
|
+
model_with_has_many.create!(:title => 'abcdef', :other_models => [
|
67
|
+
associated_model_with_has_many.create!(:title => 'foo'),
|
68
|
+
associated_model_with_has_many.create!(:title => 'bar')
|
69
|
+
]),
|
70
|
+
model_with_has_many.create!(:title => 'ghijkl', :other_models => [
|
71
|
+
associated_model_with_has_many.create!(:title => 'foo bar'),
|
72
|
+
associated_model_with_has_many.create!(:title => 'mnopqr')
|
73
|
+
]),
|
74
|
+
model_with_has_many.create!(:title => 'foo bar')
|
75
|
+
]
|
76
|
+
excluded = model_with_has_many.create!(:title => 'stuvwx', :other_models => [
|
77
|
+
associated_model_with_has_many.create!(:title => 'abcdef')
|
78
|
+
])
|
79
|
+
|
80
|
+
results = model_with_has_many.with_associated('foo bar')
|
81
|
+
results.map(&:title).should =~ included.map(&:title)
|
82
|
+
results.should_not include(excluded)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
context "across multiple associations" do
|
87
|
+
context "on different tables" do
|
88
|
+
with_model :first_associated_model do
|
89
|
+
table do |t|
|
90
|
+
t.string 'title'
|
91
|
+
t.belongs_to 'model_with_many_associations'
|
92
|
+
end
|
93
|
+
model {}
|
94
|
+
end
|
95
|
+
|
96
|
+
with_model :second_associated_model do
|
97
|
+
table do |t|
|
98
|
+
t.string 'title'
|
99
|
+
end
|
100
|
+
model {}
|
101
|
+
end
|
102
|
+
|
103
|
+
with_model :model_with_many_associations do
|
104
|
+
table do |t|
|
105
|
+
t.string 'title'
|
106
|
+
t.belongs_to 'model_of_second_type'
|
107
|
+
end
|
108
|
+
|
109
|
+
model do
|
110
|
+
include PgSearch
|
111
|
+
has_many :models_of_first_type, :class_name => 'FirstAssociatedModel', :foreign_key => 'model_with_many_associations_id'
|
112
|
+
belongs_to :model_of_second_type, :class_name => 'SecondAssociatedModel'
|
113
|
+
|
114
|
+
pg_search_scope :with_associated, :against => :title,
|
115
|
+
:associated_against => {:models_of_first_type => :title, :model_of_second_type => :title}
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
it "returns rows that match the query in either its own columns or the columns of the associated model" do
|
120
|
+
matching_second = second_associated_model.create!(:title => "foo bar")
|
121
|
+
unmatching_second = second_associated_model.create!(:title => "uiop")
|
122
|
+
|
123
|
+
included = [
|
124
|
+
ModelWithManyAssociations.create!(:title => 'abcdef', :models_of_first_type => [
|
125
|
+
first_associated_model.create!(:title => 'foo'),
|
126
|
+
first_associated_model.create!(:title => 'bar')
|
127
|
+
]),
|
128
|
+
ModelWithManyAssociations.create!(:title => 'ghijkl', :models_of_first_type => [
|
129
|
+
first_associated_model.create!(:title => 'foo bar'),
|
130
|
+
first_associated_model.create!(:title => 'mnopqr')
|
131
|
+
]),
|
132
|
+
ModelWithManyAssociations.create!(:title => 'foo bar'),
|
133
|
+
ModelWithManyAssociations.create!(:title => 'qwerty', :model_of_second_type => matching_second)
|
134
|
+
]
|
135
|
+
excluded = [
|
136
|
+
ModelWithManyAssociations.create!(:title => 'stuvwx', :models_of_first_type => [
|
137
|
+
first_associated_model.create!(:title => 'abcdef')
|
138
|
+
]),
|
139
|
+
ModelWithManyAssociations.create!(:title => 'qwerty', :model_of_second_type => unmatching_second)
|
140
|
+
]
|
141
|
+
|
142
|
+
results = ModelWithManyAssociations.with_associated('foo bar')
|
143
|
+
results.map(&:title).should =~ included.map(&:title)
|
144
|
+
excluded.each { |object| results.should_not include(object) }
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
context "on the same table" do
|
149
|
+
with_model :doubly_associated_model do
|
150
|
+
table do |t|
|
151
|
+
t.string 'title'
|
152
|
+
t.belongs_to 'model_with_double_association'
|
153
|
+
t.belongs_to 'model_with_double_association_again'
|
154
|
+
end
|
155
|
+
model {}
|
156
|
+
end
|
157
|
+
|
158
|
+
with_model :model_with_double_association do
|
159
|
+
table do |t|
|
160
|
+
t.string 'title'
|
161
|
+
end
|
162
|
+
|
163
|
+
model do
|
164
|
+
include PgSearch
|
165
|
+
has_many :things, :class_name => 'DoublyAssociatedModel', :foreign_key => 'model_with_double_association_id'
|
166
|
+
has_many :thingamabobs, :class_name => 'DoublyAssociatedModel', :foreign_key => 'model_with_double_association_again_id'
|
167
|
+
|
168
|
+
pg_search_scope :with_associated, :against => :title,
|
169
|
+
:associated_against => {:things => :title, :thingamabobs => :title}
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
it "returns rows that match the query in either its own columns or the columns of the associated model" do
|
174
|
+
included = [
|
175
|
+
ModelWithDoubleAssociation.create!(:title => 'abcdef', :things => [
|
176
|
+
DoublyAssociatedModel.create!(:title => 'foo'),
|
177
|
+
DoublyAssociatedModel.create!(:title => 'bar')
|
178
|
+
]),
|
179
|
+
ModelWithDoubleAssociation.create!(:title => 'ghijkl', :things => [
|
180
|
+
DoublyAssociatedModel.create!(:title => 'foo bar'),
|
181
|
+
DoublyAssociatedModel.create!(:title => 'mnopqr')
|
182
|
+
]),
|
183
|
+
ModelWithDoubleAssociation.create!(:title => 'foo bar'),
|
184
|
+
ModelWithDoubleAssociation.create!(:title => 'qwerty', :thingamabobs => [
|
185
|
+
DoublyAssociatedModel.create!(:title => "foo bar")
|
186
|
+
])
|
187
|
+
]
|
188
|
+
excluded = [
|
189
|
+
ModelWithDoubleAssociation.create!(:title => 'stuvwx', :things => [
|
190
|
+
DoublyAssociatedModel.create!(:title => 'abcdef')
|
191
|
+
]),
|
192
|
+
ModelWithDoubleAssociation.create!(:title => 'qwerty', :thingamabobs => [
|
193
|
+
DoublyAssociatedModel.create!(:title => "uiop")
|
194
|
+
])
|
195
|
+
]
|
196
|
+
|
197
|
+
results = ModelWithDoubleAssociation.with_associated('foo bar')
|
198
|
+
results.map(&:title).should =~ included.map(&:title)
|
199
|
+
excluded.each { |object| results.should_not include(object) }
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
203
|
+
end
|
204
|
+
else
|
205
|
+
context "without Arel support" do
|
206
|
+
with_model :model do
|
207
|
+
table do |t|
|
208
|
+
t.string 'title'
|
209
|
+
end
|
210
|
+
|
211
|
+
model do
|
212
|
+
include PgSearch
|
213
|
+
pg_search_scope :with_joins, :against => :title, :joins => :another_model
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
it "should raise an error" do
|
218
|
+
lambda {
|
219
|
+
Model.with_joins('foo')
|
220
|
+
}.should raise_error(ArgumentError, /joins/)
|
221
|
+
end
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
@@ -0,0 +1,596 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
|
2
|
+
|
3
|
+
describe "an ActiveRecord model which includes PgSearch" do
|
4
|
+
|
5
|
+
with_model :model_with_pg_search do
|
6
|
+
table do |t|
|
7
|
+
t.string 'title'
|
8
|
+
t.text 'content'
|
9
|
+
t.integer 'importance'
|
10
|
+
end
|
11
|
+
|
12
|
+
model do
|
13
|
+
include PgSearch
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
describe ".pg_search_scope" do
|
18
|
+
it "builds a scope" do
|
19
|
+
model_with_pg_search.class_eval do
|
20
|
+
pg_search_scope "matching_query", :against => []
|
21
|
+
end
|
22
|
+
|
23
|
+
lambda {
|
24
|
+
model_with_pg_search.scoped({}).matching_query("foo").scoped({})
|
25
|
+
}.should_not raise_error
|
26
|
+
end
|
27
|
+
|
28
|
+
context "when passed a lambda" do
|
29
|
+
it "builds a dynamic scope" do
|
30
|
+
model_with_pg_search.class_eval do
|
31
|
+
pg_search_scope :search_title_or_content, lambda { |query, pick_content|
|
32
|
+
{
|
33
|
+
:query => query.gsub("-remove-", ""),
|
34
|
+
:against => pick_content ? :content : :title
|
35
|
+
}
|
36
|
+
}
|
37
|
+
end
|
38
|
+
|
39
|
+
included = model_with_pg_search.create!(:title => 'foo', :content => 'bar')
|
40
|
+
excluded = model_with_pg_search.create!(:title => 'bar', :content => 'foo')
|
41
|
+
|
42
|
+
model_with_pg_search.search_title_or_content('fo-remove-o', false).should == [included]
|
43
|
+
model_with_pg_search.search_title_or_content('b-remove-ar', true).should == [included]
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
context "when an unknown option is passed in" do
|
48
|
+
it "raises an exception when invoked" do
|
49
|
+
lambda {
|
50
|
+
model_with_pg_search.class_eval do
|
51
|
+
pg_search_scope :with_unknown_option, :against => :content, :foo => :bar
|
52
|
+
end
|
53
|
+
model_with_pg_search.with_unknown_option("foo")
|
54
|
+
}.should raise_error(ArgumentError, /foo/)
|
55
|
+
end
|
56
|
+
|
57
|
+
context "dynamically" do
|
58
|
+
it "raises an exception when invoked" do
|
59
|
+
lambda {
|
60
|
+
model_with_pg_search.class_eval do
|
61
|
+
pg_search_scope :with_unknown_option, lambda { |*| {:against => :content, :foo => :bar} }
|
62
|
+
end
|
63
|
+
model_with_pg_search.with_unknown_option("foo")
|
64
|
+
}.should raise_error(ArgumentError, /foo/)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
context "when an unknown :using is passed" do
|
70
|
+
it "raises an exception when invoked" do
|
71
|
+
lambda {
|
72
|
+
model_with_pg_search.class_eval do
|
73
|
+
pg_search_scope :with_unknown_using, :against => :content, :using => :foo
|
74
|
+
end
|
75
|
+
model_with_pg_search.with_unknown_using("foo")
|
76
|
+
}.should raise_error(ArgumentError, /foo/)
|
77
|
+
end
|
78
|
+
|
79
|
+
context "dynamically" do
|
80
|
+
it "raises an exception when invoked" do
|
81
|
+
lambda {
|
82
|
+
model_with_pg_search.class_eval do
|
83
|
+
pg_search_scope :with_unknown_using, lambda { |*| {:against => :content, :using => :foo} }
|
84
|
+
end
|
85
|
+
model_with_pg_search.with_unknown_using("foo")
|
86
|
+
}.should raise_error(ArgumentError, /foo/)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
context "when an unknown :normalizing is passed" do
|
92
|
+
it "raises an exception when invoked" do
|
93
|
+
lambda {
|
94
|
+
model_with_pg_search.class_eval do
|
95
|
+
pg_search_scope :with_unknown_normalizing, :against => :content, :normalizing => :foo
|
96
|
+
end
|
97
|
+
model_with_pg_search.with_unknown_normalizing("foo")
|
98
|
+
}.should raise_error(ArgumentError, /normalizing.*foo/)
|
99
|
+
end
|
100
|
+
|
101
|
+
context "dynamically" do
|
102
|
+
it "raises an exception when invoked" do
|
103
|
+
lambda {
|
104
|
+
model_with_pg_search.class_eval do
|
105
|
+
pg_search_scope :with_unknown_normalizing, lambda { |*| {:against => :content, :normalizing => :foo} }
|
106
|
+
end
|
107
|
+
model_with_pg_search.with_unknown_normalizing("foo")
|
108
|
+
}.should raise_error(ArgumentError, /normalizing.*foo/)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
context "when :against is not passed in" do
|
113
|
+
it "raises an exception when invoked" do
|
114
|
+
lambda {
|
115
|
+
model_with_pg_search.class_eval do
|
116
|
+
pg_search_scope :with_unknown_normalizing, {}
|
117
|
+
end
|
118
|
+
model_with_pg_search.with_unknown_normalizing("foo")
|
119
|
+
}.should raise_error(ArgumentError, /against/)
|
120
|
+
end
|
121
|
+
context "dynamically" do
|
122
|
+
it "raises an exception when invoked" do
|
123
|
+
lambda {
|
124
|
+
model_with_pg_search.class_eval do
|
125
|
+
pg_search_scope :with_unknown_normalizing, lambda { |*| {} }
|
126
|
+
end
|
127
|
+
model_with_pg_search.with_unknown_normalizing("foo")
|
128
|
+
}.should raise_error(ArgumentError, /against/)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
describe "a search scope" do
|
136
|
+
context "against a single column" do
|
137
|
+
before do
|
138
|
+
model_with_pg_search.class_eval do
|
139
|
+
pg_search_scope :search_content, :against => :content
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
it "returns an empty array when a blank query is passed in" do
|
144
|
+
model_with_pg_search.create!(:content => 'foo')
|
145
|
+
|
146
|
+
results = model_with_pg_search.search_content('')
|
147
|
+
results.should == []
|
148
|
+
end
|
149
|
+
|
150
|
+
it "returns rows where the column contains the term in the query" do
|
151
|
+
included = model_with_pg_search.create!(:content => 'foo')
|
152
|
+
excluded = model_with_pg_search.create!(:content => 'bar')
|
153
|
+
|
154
|
+
results = model_with_pg_search.search_content('foo')
|
155
|
+
results.should include(included)
|
156
|
+
results.should_not include(excluded)
|
157
|
+
end
|
158
|
+
|
159
|
+
it "returns rows where the column contains all the terms in the query in any order" do
|
160
|
+
included = [model_with_pg_search.create!(:content => 'foo bar'),
|
161
|
+
model_with_pg_search.create!(:content => 'bar foo')]
|
162
|
+
excluded = model_with_pg_search.create!(:content => 'foo')
|
163
|
+
|
164
|
+
results = model_with_pg_search.search_content('foo bar')
|
165
|
+
results.should =~ included
|
166
|
+
results.should_not include(excluded)
|
167
|
+
end
|
168
|
+
|
169
|
+
it "returns rows that match the query but not its case" do
|
170
|
+
# \303\241 is a with acute accent
|
171
|
+
# \303\251 is e with acute accent
|
172
|
+
|
173
|
+
included = [model_with_pg_search.create!(:content => "foo"),
|
174
|
+
model_with_pg_search.create!(:content => "FOO")]
|
175
|
+
|
176
|
+
results = model_with_pg_search.search_content("Foo")
|
177
|
+
results.should =~ included
|
178
|
+
end
|
179
|
+
|
180
|
+
it "returns rows that match the query only if their diacritics match" do
|
181
|
+
# \303\241 is a with acute accent
|
182
|
+
# \303\251 is e with acute accent
|
183
|
+
|
184
|
+
included = model_with_pg_search.create!(:content => "abcd\303\251f")
|
185
|
+
excluded = model_with_pg_search.create!(:content => "\303\241bcdef")
|
186
|
+
|
187
|
+
results = model_with_pg_search.search_content("abcd\303\251f")
|
188
|
+
results.should == [included]
|
189
|
+
results.should_not include(excluded)
|
190
|
+
end
|
191
|
+
|
192
|
+
it "returns rows that match the query but not rows that are prefixed by the query" do
|
193
|
+
included = model_with_pg_search.create!(:content => 'pre')
|
194
|
+
excluded = model_with_pg_search.create!(:content => 'prefix')
|
195
|
+
|
196
|
+
results = model_with_pg_search.search_content("pre")
|
197
|
+
results.should == [included]
|
198
|
+
results.should_not include(excluded)
|
199
|
+
end
|
200
|
+
|
201
|
+
it "returns rows that match the query when stemmed by the default dictionary (english)" do
|
202
|
+
included = [model_with_pg_search.create!(:content => "jump"),
|
203
|
+
model_with_pg_search.create!(:content => "jumped"),
|
204
|
+
model_with_pg_search.create!(:content => "jumping")]
|
205
|
+
|
206
|
+
results = model_with_pg_search.search_content("jump")
|
207
|
+
results.should =~ included
|
208
|
+
end
|
209
|
+
|
210
|
+
it "returns rows that match sorted by rank" do
|
211
|
+
loser = model_with_pg_search.create!(:content => 'foo')
|
212
|
+
winner = model_with_pg_search.create!(:content => 'foo foo')
|
213
|
+
|
214
|
+
results = model_with_pg_search.search_content("foo")
|
215
|
+
results[0].rank.should > results[1].rank
|
216
|
+
results.should == [winner, loser]
|
217
|
+
end
|
218
|
+
|
219
|
+
it "returns results that match sorted by primary key for records that rank the same" do
|
220
|
+
sorted_results = [model_with_pg_search.create!(:content => 'foo'),
|
221
|
+
model_with_pg_search.create!(:content => 'foo')].sort_by(&:id)
|
222
|
+
|
223
|
+
results = model_with_pg_search.search_content("foo")
|
224
|
+
results.should == sorted_results
|
225
|
+
end
|
226
|
+
|
227
|
+
it "returns results that match a query with multiple space-separated search terms" do
|
228
|
+
included = [
|
229
|
+
model_with_pg_search.create!(:content => 'foo bar'),
|
230
|
+
model_with_pg_search.create!(:content => 'bar foo'),
|
231
|
+
model_with_pg_search.create!(:content => 'bar foo baz'),
|
232
|
+
]
|
233
|
+
excluded = [
|
234
|
+
model_with_pg_search.create!(:content => 'foo'),
|
235
|
+
model_with_pg_search.create!(:content => 'foo baz')
|
236
|
+
]
|
237
|
+
|
238
|
+
results = model_with_pg_search.search_content('foo bar')
|
239
|
+
results.should =~ included
|
240
|
+
results.should_not include(excluded)
|
241
|
+
end
|
242
|
+
|
243
|
+
it "returns rows that match a query with characters that are invalid in a tsquery expression" do
|
244
|
+
included = model_with_pg_search.create!(:content => "(Foo.) Bar?, \\")
|
245
|
+
|
246
|
+
results = model_with_pg_search.search_content("foo bar .,?() \\")
|
247
|
+
results.should == [included]
|
248
|
+
end
|
249
|
+
it "accepts non-string queries and calls #to_s on them" do
|
250
|
+
foo = model_with_pg_search.create!(:content => "foo")
|
251
|
+
not_a_string = stub(:to_s => "foo")
|
252
|
+
model_with_pg_search.search_content(not_a_string).should == [foo]
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
context "against multiple columns" do
|
257
|
+
before do
|
258
|
+
model_with_pg_search.class_eval do
|
259
|
+
pg_search_scope :search_title_and_content, :against => [:title, :content]
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
it "returns rows whose columns contain all of the terms in the query across columns" do
|
264
|
+
included = [
|
265
|
+
model_with_pg_search.create!(:title => 'foo', :content => 'bar'),
|
266
|
+
model_with_pg_search.create!(:title => 'bar', :content => 'foo')
|
267
|
+
]
|
268
|
+
excluded = [
|
269
|
+
model_with_pg_search.create!(:title => 'foo', :content => 'foo'),
|
270
|
+
model_with_pg_search.create!(:title => 'bar', :content => 'bar')
|
271
|
+
]
|
272
|
+
|
273
|
+
results = model_with_pg_search.search_title_and_content('foo bar')
|
274
|
+
|
275
|
+
results.should =~ included
|
276
|
+
excluded.each do |result|
|
277
|
+
results.should_not include(result)
|
278
|
+
end
|
279
|
+
end
|
280
|
+
|
281
|
+
it "returns rows where at one column contains all of the terms in the query and another does not" do
|
282
|
+
in_title = model_with_pg_search.create!(:title => 'foo', :content => 'bar')
|
283
|
+
in_content = model_with_pg_search.create!(:title => 'bar', :content => 'foo')
|
284
|
+
|
285
|
+
results = model_with_pg_search.search_title_and_content('foo')
|
286
|
+
results.should =~ [in_title, in_content]
|
287
|
+
end
|
288
|
+
|
289
|
+
# Searching with a NULL column will prevent any matches unless we coalesce it.
|
290
|
+
it "returns rows where at one column contains all of the terms in the query and another is NULL" do
|
291
|
+
included = model_with_pg_search.create!(:title => 'foo', :content => nil)
|
292
|
+
results = model_with_pg_search.search_title_and_content('foo')
|
293
|
+
results.should == [included]
|
294
|
+
end
|
295
|
+
end
|
296
|
+
|
297
|
+
context "using trigram" do
|
298
|
+
before do
|
299
|
+
model_with_pg_search.class_eval do
|
300
|
+
pg_search_scope :with_trigrams, :against => [:title, :content], :using => :trigram
|
301
|
+
end
|
302
|
+
end
|
303
|
+
|
304
|
+
it "returns rows where one searchable column and the query share enough trigrams" do
|
305
|
+
included = model_with_pg_search.create!(:title => 'abcdefghijkl', :content => nil)
|
306
|
+
results = model_with_pg_search.with_trigrams('cdefhijkl')
|
307
|
+
results.should == [included]
|
308
|
+
end
|
309
|
+
|
310
|
+
it "returns rows where multiple searchable columns and the query share enough trigrams" do
|
311
|
+
included = model_with_pg_search.create!(:title => 'abcdef', :content => 'ghijkl')
|
312
|
+
results = model_with_pg_search.with_trigrams('cdefhijkl')
|
313
|
+
results.should == [included]
|
314
|
+
end
|
315
|
+
end
|
316
|
+
|
317
|
+
context "using tsearch" do
|
318
|
+
context "with :prefix => true" do
|
319
|
+
before do
|
320
|
+
model_with_pg_search.class_eval do
|
321
|
+
pg_search_scope :search_title_with_prefixes,
|
322
|
+
:against => :title,
|
323
|
+
:using => {
|
324
|
+
:tsearch => {:prefix => true}
|
325
|
+
}
|
326
|
+
end
|
327
|
+
end
|
328
|
+
|
329
|
+
it "returns rows that match the query and that are prefixed by the query" do
|
330
|
+
included = model_with_pg_search.create!(:title => 'prefix')
|
331
|
+
excluded = model_with_pg_search.create!(:title => 'postfix')
|
332
|
+
|
333
|
+
results = model_with_pg_search.search_title_with_prefixes("pre")
|
334
|
+
results.should == [included]
|
335
|
+
results.should_not include(excluded)
|
336
|
+
end
|
337
|
+
|
338
|
+
it "returns rows that match the query when the query has a hyphen" do
|
339
|
+
included = [
|
340
|
+
model_with_pg_search.create!(:title => 'foo bar'),
|
341
|
+
model_with_pg_search.create!(:title => 'foo-bar')
|
342
|
+
]
|
343
|
+
excluded = model_with_pg_search.create!(:title => 'baz quux')
|
344
|
+
|
345
|
+
results = model_with_pg_search.search_title_with_prefixes("foo-bar")
|
346
|
+
results.should =~ included
|
347
|
+
results.should_not include(excluded)
|
348
|
+
end
|
349
|
+
end
|
350
|
+
|
351
|
+
context "with the simple dictionary" do
|
352
|
+
before do
|
353
|
+
model_with_pg_search.class_eval do
|
354
|
+
pg_search_scope :search_title, :against => :title
|
355
|
+
|
356
|
+
pg_search_scope :search_title_with_simple,
|
357
|
+
:against => :title,
|
358
|
+
:using => {
|
359
|
+
:tsearch => {:dictionary => :simple}
|
360
|
+
}
|
361
|
+
end
|
362
|
+
end
|
363
|
+
|
364
|
+
it "returns rows that match the query exactly but not that match the query when stemmed by the default dictionary" do
|
365
|
+
included = model_with_pg_search.create!(:title => "jumped")
|
366
|
+
excluded = [model_with_pg_search.create!(:title => "jump"),
|
367
|
+
model_with_pg_search.create!(:title => "jumping")]
|
368
|
+
|
369
|
+
default_results = model_with_pg_search.search_title("jumped")
|
370
|
+
default_results.should =~ [included] + excluded
|
371
|
+
|
372
|
+
simple_results = model_with_pg_search.search_title_with_simple("jumped")
|
373
|
+
simple_results.should == [included]
|
374
|
+
excluded.each do |result|
|
375
|
+
simple_results.should_not include(result)
|
376
|
+
end
|
377
|
+
end
|
378
|
+
end
|
379
|
+
|
380
|
+
context "against columns ranked with arrays" do
|
381
|
+
before do
|
382
|
+
model_with_pg_search.class_eval do
|
383
|
+
pg_search_scope :search_weighted_by_array_of_arrays, :against => [[:content, 'B'], [:title, 'A']]
|
384
|
+
end
|
385
|
+
end
|
386
|
+
|
387
|
+
it "returns results sorted by weighted rank" do
|
388
|
+
loser = model_with_pg_search.create!(:title => 'bar', :content => 'foo')
|
389
|
+
winner = model_with_pg_search.create!(:title => 'foo', :content => 'bar')
|
390
|
+
|
391
|
+
results = model_with_pg_search.search_weighted_by_array_of_arrays('foo')
|
392
|
+
results[0].rank.should > results[1].rank
|
393
|
+
results.should == [winner, loser]
|
394
|
+
end
|
395
|
+
end
|
396
|
+
|
397
|
+
context "against columns ranked with a hash" do
|
398
|
+
before do
|
399
|
+
model_with_pg_search.class_eval do
|
400
|
+
pg_search_scope :search_weighted_by_hash, :against => {:content => 'B', :title => 'A'}
|
401
|
+
end
|
402
|
+
end
|
403
|
+
|
404
|
+
it "returns results sorted by weighted rank" do
|
405
|
+
loser = model_with_pg_search.create!(:title => 'bar', :content => 'foo')
|
406
|
+
winner = model_with_pg_search.create!(:title => 'foo', :content => 'bar')
|
407
|
+
|
408
|
+
results = model_with_pg_search.search_weighted_by_hash('foo')
|
409
|
+
results[0].rank.should > results[1].rank
|
410
|
+
results.should == [winner, loser]
|
411
|
+
end
|
412
|
+
end
|
413
|
+
|
414
|
+
context "against columns of which only some are ranked" do
|
415
|
+
before do
|
416
|
+
model_with_pg_search.class_eval do
|
417
|
+
pg_search_scope :search_weighted, :against => [:content, [:title, 'A']]
|
418
|
+
end
|
419
|
+
end
|
420
|
+
|
421
|
+
it "returns results sorted by weighted rank using an implied low rank for unranked columns" do
|
422
|
+
loser = model_with_pg_search.create!(:title => 'bar', :content => 'foo')
|
423
|
+
winner = model_with_pg_search.create!(:title => 'foo', :content => 'bar')
|
424
|
+
|
425
|
+
results = model_with_pg_search.search_weighted('foo')
|
426
|
+
results[0].rank.should > results[1].rank
|
427
|
+
results.should == [winner, loser]
|
428
|
+
end
|
429
|
+
end
|
430
|
+
end
|
431
|
+
|
432
|
+
context "using dmetaphone" do
|
433
|
+
before do
|
434
|
+
model_with_pg_search.class_eval do
|
435
|
+
pg_search_scope :with_dmetaphones, :against => [:title, :content], :using => :dmetaphone
|
436
|
+
end
|
437
|
+
end
|
438
|
+
|
439
|
+
it "returns rows where one searchable column and the query share enough dmetaphones" do
|
440
|
+
included = model_with_pg_search.create!(:title => 'Geoff', :content => nil)
|
441
|
+
excluded = model_with_pg_search.create!(:title => 'Bob', :content => nil)
|
442
|
+
results = model_with_pg_search.with_dmetaphones('Jeff')
|
443
|
+
results.should == [included]
|
444
|
+
end
|
445
|
+
|
446
|
+
it "returns rows where multiple searchable columns and the query share enough dmetaphones" do
|
447
|
+
included = model_with_pg_search.create!(:title => 'Geoff', :content => 'George')
|
448
|
+
excluded = model_with_pg_search.create!(:title => 'Bob', :content => 'Jones')
|
449
|
+
results = model_with_pg_search.with_dmetaphones('Jeff Jorge')
|
450
|
+
results.should == [included]
|
451
|
+
end
|
452
|
+
|
453
|
+
it "returns rows that match dmetaphones that are English stopwords" do
|
454
|
+
included = model_with_pg_search.create!(:title => 'White', :content => nil)
|
455
|
+
excluded = model_with_pg_search.create!(:title => 'Black', :content => nil)
|
456
|
+
results = model_with_pg_search.with_dmetaphones('Wight')
|
457
|
+
results.should == [included]
|
458
|
+
end
|
459
|
+
end
|
460
|
+
|
461
|
+
context "using multiple features" do
|
462
|
+
before do
|
463
|
+
model_with_pg_search.class_eval do
|
464
|
+
pg_search_scope :with_tsearch, :against => :title, :using => :tsearch
|
465
|
+
|
466
|
+
pg_search_scope :with_trigram, :against => :title, :using => :trigram
|
467
|
+
|
468
|
+
pg_search_scope :with_tsearch_and_trigram_using_array,
|
469
|
+
:against => :title,
|
470
|
+
:using => [:tsearch, :trigram]
|
471
|
+
|
472
|
+
end
|
473
|
+
end
|
474
|
+
|
475
|
+
it "returns rows that match using any of the features" do
|
476
|
+
record = model_with_pg_search.create!(:title => "tiling is grouty")
|
477
|
+
|
478
|
+
# matches trigram only
|
479
|
+
trigram_query = "ling is grouty"
|
480
|
+
model_with_pg_search.with_trigram(trigram_query).should include(record)
|
481
|
+
model_with_pg_search.with_tsearch(trigram_query).should_not include(record)
|
482
|
+
model_with_pg_search.with_tsearch_and_trigram_using_array(trigram_query).should == [record]
|
483
|
+
|
484
|
+
# matches tsearch only
|
485
|
+
tsearch_query = "tile"
|
486
|
+
model_with_pg_search.with_tsearch(tsearch_query).should include(record)
|
487
|
+
model_with_pg_search.with_trigram(tsearch_query).should_not include(record)
|
488
|
+
model_with_pg_search.with_tsearch_and_trigram_using_array(tsearch_query).should == [record]
|
489
|
+
end
|
490
|
+
|
491
|
+
context "with feature-specific configuration" do
|
492
|
+
before do
|
493
|
+
@tsearch_config = tsearch_config = {:dictionary => 'english'}
|
494
|
+
@trigram_config = trigram_config = {:foo => 'bar'}
|
495
|
+
|
496
|
+
model_with_pg_search.class_eval do
|
497
|
+
pg_search_scope :with_tsearch_and_trigram_using_hash,
|
498
|
+
:against => :title,
|
499
|
+
:using => {
|
500
|
+
:tsearch => tsearch_config,
|
501
|
+
:trigram => trigram_config
|
502
|
+
}
|
503
|
+
end
|
504
|
+
end
|
505
|
+
|
506
|
+
it "should pass the custom configuration down to the specified feature" do
|
507
|
+
stub_feature = stub(:conditions => "1 = 1", :rank => "1.0")
|
508
|
+
PgSearch::Features::TSearch.should_receive(:new).with(anything, @tsearch_config, anything, anything, anything).at_least(:once).and_return(stub_feature)
|
509
|
+
PgSearch::Features::Trigram.should_receive(:new).with(anything, @trigram_config, anything, anything, anything).at_least(:once).and_return(stub_feature)
|
510
|
+
|
511
|
+
model_with_pg_search.with_tsearch_and_trigram_using_hash("foo")
|
512
|
+
end
|
513
|
+
end
|
514
|
+
end
|
515
|
+
|
516
|
+
context "normalizing diacritics" do
|
517
|
+
before do
|
518
|
+
model_with_pg_search.class_eval do
|
519
|
+
pg_search_scope :search_title_without_diacritics, :against => :title, :normalizing => :diacritics
|
520
|
+
end
|
521
|
+
end
|
522
|
+
|
523
|
+
it "returns rows that match the query but not its diacritics" do
|
524
|
+
# \303\241 is a with acute accent
|
525
|
+
# \303\251 is e with acute accent
|
526
|
+
|
527
|
+
included = model_with_pg_search.create!(:title => "\303\241bcdef")
|
528
|
+
|
529
|
+
results = model_with_pg_search.search_title_without_diacritics("abcd\303\251f")
|
530
|
+
results.should == [included]
|
531
|
+
end
|
532
|
+
end
|
533
|
+
|
534
|
+
context "when passed a :ranked_by expression" do
|
535
|
+
before do
|
536
|
+
model_with_pg_search.class_eval do
|
537
|
+
pg_search_scope :search_content_with_default_rank,
|
538
|
+
:against => :content
|
539
|
+
pg_search_scope :search_content_with_importance_as_rank,
|
540
|
+
:against => :content,
|
541
|
+
:ranked_by => "importance"
|
542
|
+
pg_search_scope :search_content_with_importance_as_rank_multiplier,
|
543
|
+
:against => :content,
|
544
|
+
:ranked_by => ":tsearch * importance"
|
545
|
+
end
|
546
|
+
end
|
547
|
+
|
548
|
+
it "should return records with a rank attribute equal to the :ranked_by expression" do
|
549
|
+
model_with_pg_search.create!(:content => 'foo', :importance => 10)
|
550
|
+
results = model_with_pg_search.search_content_with_importance_as_rank("foo")
|
551
|
+
results.first.rank.should == 10
|
552
|
+
end
|
553
|
+
|
554
|
+
it "should substitute :tsearch with the tsearch rank expression in the :ranked_by expression" do
|
555
|
+
model_with_pg_search.create!(:content => 'foo', :importance => 10)
|
556
|
+
|
557
|
+
tsearch_rank = model_with_pg_search.search_content_with_default_rank("foo").first.rank
|
558
|
+
multiplied_rank = model_with_pg_search.search_content_with_importance_as_rank_multiplier("foo").first.rank
|
559
|
+
|
560
|
+
multiplied_rank.should be_within(0.001).of(tsearch_rank * 10)
|
561
|
+
end
|
562
|
+
|
563
|
+
it "should return results in descending order of the value of the rank expression" do
|
564
|
+
records = [
|
565
|
+
model_with_pg_search.create!(:content => 'foo', :importance => 1),
|
566
|
+
model_with_pg_search.create!(:content => 'foo', :importance => 3),
|
567
|
+
model_with_pg_search.create!(:content => 'foo', :importance => 2)
|
568
|
+
]
|
569
|
+
|
570
|
+
results = model_with_pg_search.search_content_with_importance_as_rank("foo")
|
571
|
+
results.should == records.sort_by(&:importance).reverse
|
572
|
+
end
|
573
|
+
|
574
|
+
%w[tsearch trigram dmetaphone].each do |feature|
|
575
|
+
|
576
|
+
context "using the #{feature} ranking algorithm" do
|
577
|
+
before do
|
578
|
+
@scope_name = scope_name = :"search_content_ranked_by_#{feature}"
|
579
|
+
model_with_pg_search.class_eval do
|
580
|
+
pg_search_scope scope_name,
|
581
|
+
:against => :content,
|
582
|
+
:ranked_by => ":#{feature}"
|
583
|
+
end
|
584
|
+
end
|
585
|
+
|
586
|
+
it "should return results with a rank" do
|
587
|
+
model_with_pg_search.create!(:content => 'foo')
|
588
|
+
|
589
|
+
results = model_with_pg_search.send(@scope_name, 'foo')
|
590
|
+
results.first.rank.should_not be_nil
|
591
|
+
end
|
592
|
+
end
|
593
|
+
end
|
594
|
+
end
|
595
|
+
end
|
596
|
+
end
|