redis-textsearch 0.1.2 → 0.1.3
Sign up to get free protection for your applications and to get access to all the features.
- data/README.rdoc +2 -4
- data/lib/redis/text_search/collection.rb +13 -0
- data/lib/redis/text_search.rb +123 -34
- data/spec/{redis_text_search_sqlite_spec.rb → redis_text_search_activerecord_spec.rb} +56 -4
- data/spec/redis_text_search_core_spec.rb +48 -6
- data/spec/redis_text_search_sequel_spec.rb +266 -0
- metadata +4 -3
data/README.rdoc
CHANGED
@@ -17,13 +17,11 @@ that just returns a string, or an MD5 of a filename, or something else unique.
|
|
17
17
|
|
18
18
|
== Installation
|
19
19
|
|
20
|
-
gem install gemcutter
|
21
|
-
gem tumble
|
22
20
|
gem install redis-textsearch
|
23
21
|
|
24
22
|
== Initialization
|
25
23
|
|
26
|
-
If
|
24
|
+
If you're using Rails, config/initializers/redis.rb is a good place for this:
|
27
25
|
|
28
26
|
require 'redis'
|
29
27
|
require 'redis/text_search'
|
@@ -89,5 +87,5 @@ hash must be in brackets:
|
|
89
87
|
|
90
88
|
== Author
|
91
89
|
|
92
|
-
Copyright (c) 2009 {Nate Wiger}[http://nate.wiger.org]. All Rights Reserved.
|
90
|
+
Copyright (c) 2009-2010 {Nate Wiger}[http://nate.wiger.org]. All Rights Reserved.
|
93
91
|
Released under the {Artistic License}[http://www.opensource.org/licenses/artistic-license-2.0.php].
|
@@ -118,6 +118,19 @@ module Redis::TextSearch
|
|
118
118
|
@total_entries = number.to_i
|
119
119
|
@total_pages = (@total_entries / per_page.to_f).ceil
|
120
120
|
end
|
121
|
+
|
122
|
+
# returns the current start ordinal of the paginated collection
|
123
|
+
def row_start
|
124
|
+
@total_entries > 0 ? offset + 1 : 0
|
125
|
+
end
|
126
|
+
|
127
|
+
# returns the current end ordinal of the paginated collection
|
128
|
+
def row_end
|
129
|
+
end_ordinal = offset + per_page
|
130
|
+
# bounds check row_end
|
131
|
+
end_ordinal = @total_entries if end_ordinal > @total_entries
|
132
|
+
end_ordinal
|
133
|
+
end
|
121
134
|
|
122
135
|
# This is a magic wrapper for the original Array#replace method. It serves
|
123
136
|
# for populating the paginated collection after initialization.
|
data/lib/redis/text_search.rb
CHANGED
@@ -7,6 +7,7 @@ class Redis
|
|
7
7
|
module TextSearch
|
8
8
|
class NoFinderMethod < StandardError; end
|
9
9
|
class BadTextIndex < StandardError; end
|
10
|
+
class BadConditions < StandardError; end
|
10
11
|
|
11
12
|
DEFAULT_EXCLUDE_LIST = %w(a an and as at but by for in into of on onto to the)
|
12
13
|
|
@@ -26,7 +27,7 @@ class Redis
|
|
26
27
|
klass.instance_variable_set('@text_index_exclude_list', DEFAULT_EXCLUDE_LIST)
|
27
28
|
klass.send :include, InstanceMethods
|
28
29
|
klass.extend ClassMethods
|
29
|
-
klass.
|
30
|
+
klass.extend WpHelpers unless respond_to?(:wp_count)
|
30
31
|
class << klass
|
31
32
|
define_method(:per_page) { 30 } unless respond_to?(:per_page)
|
32
33
|
end
|
@@ -61,31 +62,40 @@ class Redis
|
|
61
62
|
# This is called when the class is imported, and uses reflection to guess
|
62
63
|
# how to retrieve records. You can override it by explicitly defining a
|
63
64
|
# +text_search_find+ class method that takes an array of IDs as an argument.
|
64
|
-
def
|
65
|
-
if defined?(
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
elsif defined?(MongoRecord::Base) and ancestors.include?(MongoRecord::Base)
|
72
|
-
instance_eval <<-EndMethod
|
73
|
-
def text_search_find(ids, options)
|
74
|
-
all(options.merge(:conditions => {:#{primary_key} => ids}))
|
75
|
-
end
|
76
|
-
EndMethod
|
65
|
+
def text_search_find(ids, options)
|
66
|
+
if defined?(ActiveModel)
|
67
|
+
# guess that we're on Rails 3
|
68
|
+
raise "text_search_find not implemented for Rails 3 (yet) - patches welcome"
|
69
|
+
elsif defined?(ActiveRecord::Base) and ancestors.include?(ActiveRecord::Base)
|
70
|
+
merge_text_search_conditions!(ids, options)
|
71
|
+
all(options)
|
77
72
|
elsif defined?(Sequel::Model) and ancestors.include?(Sequel::Model)
|
78
|
-
|
79
|
-
def text_search_find(ids, options)
|
80
|
-
all(options.merge(:conditions => {:#{primary_key} => ids}))
|
81
|
-
end
|
82
|
-
EndMethod
|
73
|
+
self[primary_key.to_sym => ids].filter(options)
|
83
74
|
elsif defined?(DataMapper::Resource) and included_modules.include?(DataMapper::Resource)
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
75
|
+
get(options.merge(primary_key.to_sym => ids))
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def merge_text_search_conditions!(ids, options)
|
80
|
+
pk = "#{table_name}.#{primary_key}"
|
81
|
+
case options[:conditions]
|
82
|
+
when Array
|
83
|
+
if options[:conditions][1].is_a?(Hash)
|
84
|
+
options[:conditions][0] = "(#{options[:conditions][0]}) AND #{pk} IN (:text_search_ids)"
|
85
|
+
options[:conditions][1][:text_search_ids] = ids
|
86
|
+
else
|
87
|
+
options[:conditions][0] = "(#{options[:conditions][0]}) AND #{pk} IN (?)"
|
88
|
+
options[:conditions] << ids
|
89
|
+
end
|
90
|
+
when Hash
|
91
|
+
if options[:conditions].has_key?(primary_key.to_sym)
|
92
|
+
raise BadConditions, "Cannot specify primary key (#{pk}) in :conditions to #{self.name}.text_search"
|
93
|
+
end
|
94
|
+
options[:conditions][primary_key.to_sym] = ids
|
95
|
+
when String
|
96
|
+
options[:conditions] = ["(#{options[:conditions]}) AND #{pk} IN (?)", ids]
|
97
|
+
else
|
98
|
+
options.merge!(:conditions => {primary_key => ids})
|
89
99
|
end
|
90
100
|
end
|
91
101
|
|
@@ -93,7 +103,7 @@ class Redis
|
|
93
103
|
# update_text_indexes after record save or at the appropriate point.
|
94
104
|
def text_index(*args)
|
95
105
|
options = args.last.is_a?(Hash) ? args.pop : {}
|
96
|
-
options[:minlength] ||=
|
106
|
+
options[:minlength] ||= 2
|
97
107
|
options[:split] ||= /\s+/
|
98
108
|
raise ArgumentError, "Must specify fields to index to #{self.name}.text_index" unless args.length > 0
|
99
109
|
args.each do |name|
|
@@ -141,21 +151,29 @@ class Redis
|
|
141
151
|
end
|
142
152
|
end
|
143
153
|
|
154
|
+
# Assemble our options for our finder conditions (destructive for speed)
|
155
|
+
recalculate_count = options.has_key?(:conditions)
|
156
|
+
|
144
157
|
# Calculate pagination if applicable. Presence of :page indicates we want pagination.
|
145
158
|
# Adapted from will_paginate/finder.rb
|
146
159
|
if options.has_key?(:page)
|
147
160
|
page = options.delete(:page) || 1
|
148
161
|
per_page = options.delete(:per_page) || self.per_page
|
149
|
-
total = ids.length
|
150
162
|
|
151
|
-
Redis::TextSearch::Collection.create(page, per_page,
|
163
|
+
Redis::TextSearch::Collection.create(page, per_page, nil) do |pager|
|
152
164
|
# Convert page/per_page to limit/offset
|
153
165
|
options.merge!(:offset => pager.offset, :limit => pager.per_page)
|
154
|
-
|
166
|
+
if ids.empty?
|
167
|
+
pager.replace([])
|
168
|
+
pager.total_entries = 0
|
169
|
+
else
|
170
|
+
pager.replace(send(finder, ids, options){ |*a| yield(*a) if block_given? })
|
171
|
+
pager.total_entries = recalculate_count ? wp_count(options, [], finder.to_s) : ids.length # hacked into will_paginate for compat
|
172
|
+
end
|
155
173
|
end
|
156
174
|
else
|
157
175
|
# Execute finder directly
|
158
|
-
send(finder, ids, options)
|
176
|
+
ids.empty? ? [] : send(finder, ids, options)
|
159
177
|
end
|
160
178
|
end
|
161
179
|
|
@@ -221,7 +239,7 @@ class Redis
|
|
221
239
|
fields = self.class.text_indexes.keys if fields.empty?
|
222
240
|
fields.each do |field|
|
223
241
|
options = self.class.text_indexes[field]
|
224
|
-
value = self.send(field)
|
242
|
+
value = self.send(field).to_s
|
225
243
|
return false if value.length < options[:minlength] # too short to index
|
226
244
|
indexes = []
|
227
245
|
|
@@ -238,13 +256,24 @@ class Redis
|
|
238
256
|
indexes << "#{options[:key]}:#{str}"
|
239
257
|
else
|
240
258
|
len = options[:minlength]
|
241
|
-
while len
|
242
|
-
str = val[0
|
259
|
+
while len <= val.length
|
260
|
+
str = val[0,len].gsub(/\s+/, '.') # can't have " " in Redis cmd string
|
243
261
|
indexes << "#{options[:key]}:#{str}"
|
244
262
|
len += 1
|
245
263
|
end
|
246
264
|
end
|
247
265
|
end
|
266
|
+
|
267
|
+
# Also left-anchor the cropped string if "full" is specified
|
268
|
+
if options[:full]
|
269
|
+
val = values.join('.')
|
270
|
+
len = options[:minlength]
|
271
|
+
while len <= val.length
|
272
|
+
str = val[0,len].gsub(/\s+/, '.') # can't have " " in Redis cmd string
|
273
|
+
indexes << "#{options[:key]}:#{str}"
|
274
|
+
len += 1
|
275
|
+
end
|
276
|
+
end
|
248
277
|
|
249
278
|
# Determine what, if anything, needs to be done. If the indexes are unchanged,
|
250
279
|
# don't make any trips to Redis. Saves tons of useless network calls.
|
@@ -288,7 +317,67 @@ class Redis
|
|
288
317
|
end
|
289
318
|
end
|
290
319
|
end
|
291
|
-
|
292
|
-
end
|
320
|
+
|
321
|
+
end # InstanceMethods
|
322
|
+
|
323
|
+
module WpHelpers
|
324
|
+
# Does the not-so-trivial job of finding out the total number of entries
|
325
|
+
# in the database. It relies on the ActiveRecord +count+ method.
|
326
|
+
def wp_count(options, args, finder)
|
327
|
+
excludees = [:count, :order, :limit, :offset, :readonly]
|
328
|
+
if defined?(ActiveRecord::Calculations)
|
329
|
+
excludees << :from unless ActiveRecord::Calculations::CALCULATIONS_OPTIONS.include?(:from)
|
330
|
+
end
|
331
|
+
|
332
|
+
# we may be in a model or an association proxy
|
333
|
+
klass = (@owner and @reflection) ? @reflection.klass : self
|
334
|
+
|
335
|
+
# Use :select from scope if it isn't already present.
|
336
|
+
options[:select] = scope(:find, :select) unless options[:select]
|
337
|
+
|
338
|
+
if options[:select] and options[:select] =~ /^\s*DISTINCT\b/i
|
339
|
+
# Remove quoting and check for table_name.*-like statement.
|
340
|
+
if options[:select].gsub('`', '') =~ /\w+\.\*/
|
341
|
+
options[:select] = "DISTINCT #{klass.table_name}.#{klass.primary_key}"
|
342
|
+
end
|
343
|
+
else
|
344
|
+
excludees << :select # only exclude the select param if it doesn't begin with DISTINCT
|
345
|
+
end
|
346
|
+
|
347
|
+
# count expects (almost) the same options as find
|
348
|
+
count_options = options.except *excludees
|
349
|
+
|
350
|
+
# merge the hash found in :count
|
351
|
+
# this allows you to specify :select, :order, or anything else just for the count query
|
352
|
+
count_options.update options[:count] if options[:count]
|
353
|
+
|
354
|
+
# forget about includes if they are irrelevant (Rails 2.1)
|
355
|
+
# if count_options[:include] and
|
356
|
+
# klass.private_methods.include_method?(:references_eager_loaded_tables?) and
|
357
|
+
# !klass.send(:references_eager_loaded_tables?, count_options)
|
358
|
+
# count_options.delete :include
|
359
|
+
# end
|
360
|
+
|
361
|
+
# we may have to scope ...
|
362
|
+
counter = Proc.new { count(count_options) }
|
363
|
+
|
364
|
+
count = if finder.index('find_') == 0 and klass.respond_to?(scoper = finder.sub('find', 'with'))
|
365
|
+
# scope_out adds a 'with_finder' method which acts like with_scope, if it's present
|
366
|
+
# then execute the count with the scoping provided by the with_finder
|
367
|
+
send(scoper, &counter)
|
368
|
+
elsif finder =~ /^find_(all_by|by)_([_a-zA-Z]\w*)$/
|
369
|
+
# extract conditions from calls like "paginate_by_foo_and_bar"
|
370
|
+
attribute_names = $2.split('_and_')
|
371
|
+
conditions = construct_attributes_from_arguments(attribute_names, args)
|
372
|
+
with_scope(:find => { :conditions => conditions }, &counter)
|
373
|
+
else
|
374
|
+
counter.call
|
375
|
+
end
|
376
|
+
|
377
|
+
count.respond_to?(:length) ? count.length : count
|
378
|
+
end
|
379
|
+
|
380
|
+
end # WpHelpers
|
381
|
+
|
293
382
|
end
|
294
383
|
end
|
@@ -1,8 +1,13 @@
|
|
1
1
|
|
2
2
|
require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
|
3
3
|
|
4
|
+
DBFILE = File.dirname(__FILE__) + '/test.db'
|
5
|
+
|
6
|
+
require 'logger'
|
4
7
|
require 'active_record'
|
5
|
-
|
8
|
+
require 'fileutils'
|
9
|
+
|
10
|
+
ActiveRecord::Base.establish_connection(:adapter => 'sqlite3', :database => DBFILE)
|
6
11
|
ActiveRecord::Base.logger = Logger.new(STDOUT)
|
7
12
|
|
8
13
|
def check_result_ids(results, ids, sort=true)
|
@@ -20,9 +25,14 @@ end
|
|
20
25
|
class Post < ActiveRecord::Base
|
21
26
|
include Redis::TextSearch
|
22
27
|
include Marshal
|
28
|
+
|
29
|
+
BLOG = 101
|
30
|
+
INFO = 102
|
31
|
+
CAL = 103
|
23
32
|
|
24
33
|
text_index :title
|
25
34
|
text_index :tags, :exact => true
|
35
|
+
text_index :type_id, :exact => true
|
26
36
|
end
|
27
37
|
|
28
38
|
class CreatePosts < ActiveRecord::Migration
|
@@ -30,6 +40,7 @@ class CreatePosts < ActiveRecord::Migration
|
|
30
40
|
create_table :posts do |t|
|
31
41
|
t.string :title
|
32
42
|
t.string :tags
|
43
|
+
t.integer :type_id
|
33
44
|
t.timestamps
|
34
45
|
end
|
35
46
|
end
|
@@ -52,20 +63,25 @@ TAGS = [
|
|
52
63
|
['gaming','technical']
|
53
64
|
]
|
54
65
|
|
66
|
+
TYPES = [
|
67
|
+
Post::BLOG,
|
68
|
+
Post::BLOG,
|
69
|
+
Post::INFO
|
70
|
+
]
|
55
71
|
|
56
72
|
describe Redis::TextSearch do
|
57
73
|
before :all do
|
58
74
|
CreatePosts.up
|
59
75
|
|
60
|
-
@post = Post.new(:title => TITLES[0], :tags => TAGS[0] * ' ')
|
76
|
+
@post = Post.new(:title => TITLES[0], :tags => TAGS[0] * ' ', :type_id => TYPES[0])
|
61
77
|
# @post.id = 1
|
62
78
|
@post.save!
|
63
79
|
# sleep 1 # sqlite timestamps
|
64
|
-
@post2 = Post.new(:title => TITLES[1], :tags => TAGS[1] * ' ')
|
80
|
+
@post2 = Post.new(:title => TITLES[1], :tags => TAGS[1] * ' ', :type_id => TYPES[1])
|
65
81
|
# @post2.id = 2
|
66
82
|
@post2.save!
|
67
83
|
# sleep 1 # sqlite timestamps
|
68
|
-
@post3 = Post.new(:title => TITLES[2], :tags => TAGS[2] * ' ')
|
84
|
+
@post3 = Post.new(:title => TITLES[2], :tags => TAGS[2] * ' ', :type_id => TYPES[2])
|
69
85
|
# @post3.id = 3
|
70
86
|
@post3.save!
|
71
87
|
# sleep 1 # sqlite timestamps
|
@@ -77,6 +93,7 @@ describe Redis::TextSearch do
|
|
77
93
|
|
78
94
|
after :all do
|
79
95
|
CreatePosts.down
|
96
|
+
FileUtils.rm_f DBFILE
|
80
97
|
end
|
81
98
|
|
82
99
|
it "should define text indexes in the class" do
|
@@ -153,6 +170,7 @@ describe Redis::TextSearch do
|
|
153
170
|
res.first.tags
|
154
171
|
rescue => error
|
155
172
|
end
|
173
|
+
error.should_not be_nil
|
156
174
|
error.should be_kind_of ActiveRecord::MissingAttributeError
|
157
175
|
|
158
176
|
error = nil
|
@@ -160,6 +178,7 @@ describe Redis::TextSearch do
|
|
160
178
|
res.first.updated_at
|
161
179
|
rescue => error
|
162
180
|
end
|
181
|
+
error.should_not be_nil
|
163
182
|
error.should be_kind_of ActiveRecord::MissingAttributeError
|
164
183
|
|
165
184
|
error = nil
|
@@ -167,7 +186,25 @@ describe Redis::TextSearch do
|
|
167
186
|
res.first.created_at
|
168
187
|
rescue => error
|
169
188
|
end
|
189
|
+
error.should_not be_nil
|
170
190
|
error.should be_kind_of ActiveRecord::MissingAttributeError
|
191
|
+
|
192
|
+
# Merging conditions for SQL
|
193
|
+
check_result_ids Post.text_search('some', :conditions => 'id < 2'), [1]
|
194
|
+
check_result_ids Post.text_search('some', :conditions => ['id < ?', 2]), [1]
|
195
|
+
check_result_ids Post.text_search('some', :conditions => ['id = ?', 1]), [1]
|
196
|
+
check_result_ids Post.text_search('some', :conditions => ['id = ? and title = ?', 1, TITLES[0]]), [1]
|
197
|
+
check_result_ids Post.text_search('some', :conditions => ['title = :title and id = :id', {:id => 1, :title => TITLES[0]}]), [1]
|
198
|
+
check_result_ids Post.text_search('some', :conditions => {:title => TITLES[0]}), [1]
|
199
|
+
check_result_ids Post.text_search('some', :conditions => {:title => TITLES[0], :tags => TAGS[0] * ' '}), [1]
|
200
|
+
|
201
|
+
error = nil
|
202
|
+
begin
|
203
|
+
check_result_ids Post.text_search('some', :conditions => {:id => 1}), [1]
|
204
|
+
rescue => error
|
205
|
+
end
|
206
|
+
error.should_not be_nil
|
207
|
+
error.should be_kind_of Redis::TextSearch::BadConditions
|
171
208
|
end
|
172
209
|
|
173
210
|
it "should handle pagination" do
|
@@ -191,6 +228,21 @@ describe Redis::TextSearch do
|
|
191
228
|
res.total_pages.should == 1
|
192
229
|
res.per_page.should == 5
|
193
230
|
res.current_page.should == 2
|
231
|
+
|
232
|
+
res = Post.text_search('some', :page => 1, :per_page => 1, :conditions => ['id > ?', 2], :order => 'id')
|
233
|
+
check_result_ids res, [3]
|
234
|
+
res.total_entries.should == 1
|
235
|
+
res.total_pages.should == 1
|
236
|
+
res.per_page.should == 1
|
237
|
+
res.current_page.should == 1
|
238
|
+
|
239
|
+
res = Post.text_search('some', :page => 1, :per_page => 1, :order => 'id',
|
240
|
+
:conditions => ['title = :title', {:title => TITLES[0]}])
|
241
|
+
check_result_ids res, [1]
|
242
|
+
res.total_entries.should == 1
|
243
|
+
res.total_pages.should == 1
|
244
|
+
res.per_page.should == 1
|
245
|
+
res.current_page.should == 1
|
194
246
|
end
|
195
247
|
|
196
248
|
it "should support a hash to the text_search method" do
|
@@ -6,6 +6,10 @@ class Post
|
|
6
6
|
|
7
7
|
text_index :title
|
8
8
|
text_index :tags, :exact => true
|
9
|
+
text_index :description, :full => true
|
10
|
+
|
11
|
+
def self.table_name; 'post'; end
|
12
|
+
def self.primary_key; 'id'; end
|
9
13
|
|
10
14
|
def self.text_search_find(ids, options)
|
11
15
|
options.empty? ? ids : [ids, options]
|
@@ -21,7 +25,7 @@ class Post
|
|
21
25
|
end
|
22
26
|
def id; @id; end
|
23
27
|
def method_missing(name, *args)
|
24
|
-
@attrib[name]
|
28
|
+
@attrib.has_key?(name) ? @attrib[name] : super
|
25
29
|
end
|
26
30
|
end
|
27
31
|
|
@@ -35,15 +39,15 @@ TITLES = [
|
|
35
39
|
TAGS = [
|
36
40
|
['personal', 'nontechnical'],
|
37
41
|
['mysql', 'technical'],
|
38
|
-
['gaming','technical']
|
42
|
+
['gaming','technical'],
|
43
|
+
['character', 'halloween']
|
39
44
|
]
|
40
45
|
|
41
|
-
|
42
46
|
describe Redis::TextSearch do
|
43
47
|
before :all do
|
44
|
-
@post = Post.new(:title => TITLES[0], :tags => TAGS[0] * ' ', :id => 1)
|
45
|
-
@post2 = Post.new(:title => TITLES[1], :tags => TAGS[1], :id => 2)
|
46
|
-
@post3 = Post.new(:title => TITLES[2], :tags => TAGS[2] * ' ', :id => 3)
|
48
|
+
@post = Post.new(:title => TITLES[0], :tags => TAGS[0] * ' ', :id => 1, :description => nil)
|
49
|
+
@post2 = Post.new(:title => TITLES[1], :tags => TAGS[1], :id => 2, :description => nil)
|
50
|
+
@post3 = Post.new(:title => TITLES[2], :tags => TAGS[2] * ' ', :id => 3, :description => nil)
|
47
51
|
|
48
52
|
@post.delete_text_indexes
|
49
53
|
@post2.delete_text_indexes
|
@@ -59,6 +63,7 @@ describe Redis::TextSearch do
|
|
59
63
|
@post.update_text_indexes
|
60
64
|
@post2.update_text_indexes
|
61
65
|
|
66
|
+
Post.redis.set_members('post:text_index:title:s').should == []
|
62
67
|
Post.redis.set_members('post:text_index:title:so').should == ['1']
|
63
68
|
Post.redis.set_members('post:text_index:title:som').should == ['1']
|
64
69
|
Post.redis.set_members('post:text_index:title:some').should == ['1']
|
@@ -66,6 +71,7 @@ describe Redis::TextSearch do
|
|
66
71
|
Post.redis.set_members('post:text_index:title:pla').sort.should == ['1','2']
|
67
72
|
Post.redis.set_members('post:text_index:title:plai').sort.should == ['1','2']
|
68
73
|
Post.redis.set_members('post:text_index:title:plain').sort.should == ['1','2']
|
74
|
+
Post.redis.set_members('post:text_index:title:t').should == []
|
69
75
|
Post.redis.set_members('post:text_index:title:te').sort.should == ['1','2']
|
70
76
|
Post.redis.set_members('post:text_index:title:tex').sort.should == ['1','2']
|
71
77
|
Post.redis.set_members('post:text_index:title:text').sort.should == ['1','2']
|
@@ -75,6 +81,7 @@ describe Redis::TextSearch do
|
|
75
81
|
Post.redis.set_members('post:text_index:title:textstri').should == ['2']
|
76
82
|
Post.redis.set_members('post:text_index:title:textstrin').should == ['2']
|
77
83
|
Post.redis.set_members('post:text_index:title:textstring').should == ['2']
|
84
|
+
Post.redis.set_members('post:text_index:tags:p').should == []
|
78
85
|
Post.redis.set_members('post:text_index:tags:pe').should == []
|
79
86
|
Post.redis.set_members('post:text_index:tags:per').should == []
|
80
87
|
Post.redis.set_members('post:text_index:tags:pers').should == []
|
@@ -82,6 +89,7 @@ describe Redis::TextSearch do
|
|
82
89
|
Post.redis.set_members('post:text_index:tags:person').should == []
|
83
90
|
Post.redis.set_members('post:text_index:tags:persona').should == []
|
84
91
|
Post.redis.set_members('post:text_index:tags:personal').should == ['1']
|
92
|
+
Post.redis.set_members('post:text_index:tags:n').should == []
|
85
93
|
Post.redis.set_members('post:text_index:tags:no').should == []
|
86
94
|
Post.redis.set_members('post:text_index:tags:non').should == []
|
87
95
|
Post.redis.set_members('post:text_index:tags:nont').should == []
|
@@ -130,6 +138,39 @@ describe Redis::TextSearch do
|
|
130
138
|
Post.text_search(:tags => ['technical','MYsql'], :title => 'some').should == []
|
131
139
|
Post.text_search(:tags => 'technical', :title => 'comments').sort.should == ['2','3']
|
132
140
|
end
|
141
|
+
|
142
|
+
it "should support full-phrase and sub-phrase simultaneously" do
|
143
|
+
@post4 = Post.new(:title => 'Dude', :description => 'Red flame dude', :tags => TAGS[3], :id => 4)
|
144
|
+
@post4.delete_text_indexes
|
145
|
+
@post4.update_text_indexes
|
146
|
+
Post.text_search(:description => 'Red flame dude').should == ['4']
|
147
|
+
Post.text_search(:description => 'Red flame dude', :tags => 'character').should == ['4']
|
148
|
+
Post.text_search(:description => 'Red').should == ['4']
|
149
|
+
Post.text_search(:description => 'Red', :tags => 'character').should == ['4']
|
150
|
+
Post.text_search(:description => 'Red', :tags => 'luigi').should == []
|
151
|
+
Post.text_search(:description => 'Red flame').should == ['4']
|
152
|
+
Post.text_search(:description => 'Red flame', :tags => 'halloween').should == ['4']
|
153
|
+
Post.text_search(:description => 'Red flame', :tags => 'hallowee').should == []
|
154
|
+
Post.text_search(:description => 'red FLame').should == ['4']
|
155
|
+
Post.text_search(:description => 'Red fla').should == ['4']
|
156
|
+
Post.text_search(:description => 'flame').should == ['4']
|
157
|
+
Post.text_search(:description => 'flame dude').should == [] # NOT SUPPORTED (must left-anchor)
|
158
|
+
|
159
|
+
Post.redis.set_members('post:text_index:description:red.flame.dude').should == ['4']
|
160
|
+
Post.redis.set_members('post:text_index:description:red.flame.dud').should == ['4']
|
161
|
+
Post.redis.set_members('post:text_index:description:red.flame.du').should == ['4']
|
162
|
+
Post.redis.set_members('post:text_index:description:red.flame.d').should == ['4']
|
163
|
+
Post.redis.set_members('post:text_index:description:red.flame.').should == ['4']
|
164
|
+
Post.redis.set_members('post:text_index:description:red.flame').should == ['4']
|
165
|
+
Post.redis.set_members('post:text_index:description:red.flam').should == ['4']
|
166
|
+
Post.redis.set_members('post:text_index:description:red.fla').should == ['4']
|
167
|
+
Post.redis.set_members('post:text_index:description:red.fl').should == ['4']
|
168
|
+
Post.redis.set_members('post:text_index:description:red.f').should == ['4']
|
169
|
+
Post.redis.set_members('post:text_index:description:red.').should == ['4']
|
170
|
+
Post.redis.set_members('post:text_index:description:red').should == ['4']
|
171
|
+
Post.redis.set_members('post:text_index:description:re').should == ['4']
|
172
|
+
Post.redis.set_members('post:text_index:description:r').should == []
|
173
|
+
end
|
133
174
|
|
134
175
|
# MUST BE LAST!!!!!!
|
135
176
|
it "should delete text indexes" do
|
@@ -139,5 +180,6 @@ describe Redis::TextSearch do
|
|
139
180
|
@post.text_indexes.should == []
|
140
181
|
@post2.text_indexes.should == []
|
141
182
|
@post3.text_indexes.should == []
|
183
|
+
Post.delete_text_indexes(4)
|
142
184
|
end
|
143
185
|
end
|
@@ -0,0 +1,266 @@
|
|
1
|
+
|
2
|
+
require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
|
3
|
+
|
4
|
+
DBFILE = File.dirname(__FILE__) + '/test.db'
|
5
|
+
|
6
|
+
require 'logger'
|
7
|
+
require 'sequel'
|
8
|
+
require 'fileutils'
|
9
|
+
|
10
|
+
Sequel.extension :migration
|
11
|
+
DB = Sequel.connect(:adapter => 'sqlite', :database => DBFILE, :loggers => [Logger.new(STDOUT)])
|
12
|
+
|
13
|
+
def check_result_ids(results, ids, sort=true)
|
14
|
+
results.length.should == ids.length
|
15
|
+
if results.length > 0
|
16
|
+
results.first.should be_kind_of(Sequel::Model)
|
17
|
+
end
|
18
|
+
if sort
|
19
|
+
results.collect{|m| m.id}.sort.should == ids.sort
|
20
|
+
else
|
21
|
+
results.collect{|m| m.id}.should == ids
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
class Post < Sequel::Model
|
26
|
+
include Redis::TextSearch
|
27
|
+
include Marshal
|
28
|
+
|
29
|
+
BLOG = 101
|
30
|
+
INFO = 102
|
31
|
+
CAL = 103
|
32
|
+
|
33
|
+
text_index :title
|
34
|
+
text_index :tags, :exact => true
|
35
|
+
text_index :type_id, :exact => true
|
36
|
+
end
|
37
|
+
|
38
|
+
class CreatePosts < Sequel::Migration
|
39
|
+
def up
|
40
|
+
create_table :posts do
|
41
|
+
primary_key :id
|
42
|
+
String :title
|
43
|
+
String :tags
|
44
|
+
Integer :type_id
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def down
|
49
|
+
drop_table :posts
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
TITLES = [
|
54
|
+
'Some plain text',
|
55
|
+
'More plain textstring comments',
|
56
|
+
'Come get somebody personal comments',
|
57
|
+
'*Welcome to Nate\'s new BLOG!!',
|
58
|
+
]
|
59
|
+
|
60
|
+
TAGS = [
|
61
|
+
['personal', 'nontechnical'],
|
62
|
+
['mysql', 'technical'],
|
63
|
+
['gaming','technical']
|
64
|
+
]
|
65
|
+
|
66
|
+
TYPES = [
|
67
|
+
Post::BLOG,
|
68
|
+
Post::BLOG,
|
69
|
+
Post::INFO
|
70
|
+
]
|
71
|
+
|
72
|
+
describe Redis::TextSearch do
|
73
|
+
before :all do
|
74
|
+
CreatePosts.apply(DB, :up)
|
75
|
+
|
76
|
+
@post = Post.new(:title => TITLES[0], :tags => TAGS[0] * ' ', :type_id => TYPES[0])
|
77
|
+
# @post.id = 1
|
78
|
+
@post.save!
|
79
|
+
# sleep 1 # sqlite timestamps
|
80
|
+
@post2 = Post.new(:title => TITLES[1], :tags => TAGS[1] * ' ', :type_id => TYPES[1])
|
81
|
+
# @post2.id = 2
|
82
|
+
@post2.save!
|
83
|
+
# sleep 1 # sqlite timestamps
|
84
|
+
@post3 = Post.new(:title => TITLES[2], :tags => TAGS[2] * ' ', :type_id => TYPES[2])
|
85
|
+
# @post3.id = 3
|
86
|
+
@post3.save!
|
87
|
+
# sleep 1 # sqlite timestamps
|
88
|
+
|
89
|
+
@post.delete_text_indexes
|
90
|
+
@post2.delete_text_indexes
|
91
|
+
Post.delete_text_indexes(3)
|
92
|
+
end
|
93
|
+
|
94
|
+
after :all do
|
95
|
+
CreatePosts.apply(DB, :down)
|
96
|
+
FileUtils.rm_f DBFILE
|
97
|
+
end
|
98
|
+
|
99
|
+
it "should define text indexes in the class" do
|
100
|
+
Post.text_indexes[:title][:key].should == 'post:text_index:title'
|
101
|
+
Post.text_indexes[:tags][:key].should == 'post:text_index:tags'
|
102
|
+
end
|
103
|
+
|
104
|
+
it "should update text indexes correctly" do
|
105
|
+
@post.update_text_indexes
|
106
|
+
@post2.update_text_indexes
|
107
|
+
|
108
|
+
Post.redis.set_members('post:text_index:title:so').should == ['1']
|
109
|
+
Post.redis.set_members('post:text_index:title:som').should == ['1']
|
110
|
+
Post.redis.set_members('post:text_index:title:some').should == ['1']
|
111
|
+
Post.redis.set_members('post:text_index:title:pl').sort.should == ['1','2']
|
112
|
+
Post.redis.set_members('post:text_index:title:pla').sort.should == ['1','2']
|
113
|
+
Post.redis.set_members('post:text_index:title:plai').sort.should == ['1','2']
|
114
|
+
Post.redis.set_members('post:text_index:title:plain').sort.should == ['1','2']
|
115
|
+
Post.redis.set_members('post:text_index:title:te').sort.should == ['1','2']
|
116
|
+
Post.redis.set_members('post:text_index:title:tex').sort.should == ['1','2']
|
117
|
+
Post.redis.set_members('post:text_index:title:text').sort.should == ['1','2']
|
118
|
+
Post.redis.set_members('post:text_index:title:texts').should == ['2']
|
119
|
+
Post.redis.set_members('post:text_index:title:textst').should == ['2']
|
120
|
+
Post.redis.set_members('post:text_index:title:textstr').should == ['2']
|
121
|
+
Post.redis.set_members('post:text_index:title:textstri').should == ['2']
|
122
|
+
Post.redis.set_members('post:text_index:title:textstrin').should == ['2']
|
123
|
+
Post.redis.set_members('post:text_index:title:textstring').should == ['2']
|
124
|
+
Post.redis.set_members('post:text_index:tags:pe').should == []
|
125
|
+
Post.redis.set_members('post:text_index:tags:per').should == []
|
126
|
+
Post.redis.set_members('post:text_index:tags:pers').should == []
|
127
|
+
Post.redis.set_members('post:text_index:tags:perso').should == []
|
128
|
+
Post.redis.set_members('post:text_index:tags:person').should == []
|
129
|
+
Post.redis.set_members('post:text_index:tags:persona').should == []
|
130
|
+
Post.redis.set_members('post:text_index:tags:personal').should == ['1']
|
131
|
+
Post.redis.set_members('post:text_index:tags:no').should == []
|
132
|
+
Post.redis.set_members('post:text_index:tags:non').should == []
|
133
|
+
Post.redis.set_members('post:text_index:tags:nont').should == []
|
134
|
+
Post.redis.set_members('post:text_index:tags:nonte').should == []
|
135
|
+
Post.redis.set_members('post:text_index:tags:nontec').should == []
|
136
|
+
Post.redis.set_members('post:text_index:tags:nontech').should == []
|
137
|
+
Post.redis.set_members('post:text_index:tags:nontechn').should == []
|
138
|
+
Post.redis.set_members('post:text_index:tags:nontechni').should == []
|
139
|
+
Post.redis.set_members('post:text_index:tags:nontechnic').should == []
|
140
|
+
Post.redis.set_members('post:text_index:tags:nontechnica').should == []
|
141
|
+
Post.redis.set_members('post:text_index:tags:nontechnical').should == ['1']
|
142
|
+
end
|
143
|
+
|
144
|
+
it "should search text indexes and return records" do
|
145
|
+
check_result_ids Post.text_search('some'), [1]
|
146
|
+
@post3.update_text_indexes
|
147
|
+
check_result_ids Post.text_search('some'), [1,3]
|
148
|
+
|
149
|
+
check_result_ids Post.text_search('plain'), [1,2]
|
150
|
+
check_result_ids Post.text_search('plain','text'), [1,2]
|
151
|
+
check_result_ids Post.text_search('plain','textstr'), [2]
|
152
|
+
check_result_ids Post.text_search('some','TExt'), [1]
|
153
|
+
check_result_ids Post.text_search('techNIcal'), [2,3]
|
154
|
+
check_result_ids Post.text_search('nontechnical'), [1]
|
155
|
+
check_result_ids Post.text_search('personal'), [1,3]
|
156
|
+
check_result_ids Post.text_search('personAL', :fields => :tags), [1]
|
157
|
+
check_result_ids Post.text_search('PERsonal', :fields => [:tags]), [1]
|
158
|
+
check_result_ids Post.text_search('nontechnical', :fields => [:title]), []
|
159
|
+
end
|
160
|
+
|
161
|
+
it "should pass options thru to find" do
|
162
|
+
check_result_ids Post.text_search('some', :order => 'id desc'), [3,1], false
|
163
|
+
res = Post.text_search('some', :select => 'id,title', :order => 'tags desc')
|
164
|
+
check_result_ids res, [1,3]
|
165
|
+
res.first.title.should == TITLES[0]
|
166
|
+
res.last.title.should == TITLES[2]
|
167
|
+
|
168
|
+
error = nil
|
169
|
+
begin
|
170
|
+
res.first.tags
|
171
|
+
rescue => error
|
172
|
+
end
|
173
|
+
error.should_not be_nil
|
174
|
+
error.should be_kind_of ActiveRecord::MissingAttributeError
|
175
|
+
|
176
|
+
error = nil
|
177
|
+
begin
|
178
|
+
res.first.updated_at
|
179
|
+
rescue => error
|
180
|
+
end
|
181
|
+
error.should_not be_nil
|
182
|
+
error.should be_kind_of ActiveRecord::MissingAttributeError
|
183
|
+
|
184
|
+
error = nil
|
185
|
+
begin
|
186
|
+
res.first.created_at
|
187
|
+
rescue => error
|
188
|
+
end
|
189
|
+
error.should_not be_nil
|
190
|
+
error.should be_kind_of ActiveRecord::MissingAttributeError
|
191
|
+
|
192
|
+
# Merging conditions for SQL
|
193
|
+
check_result_ids Post.text_search('some', :conditions => 'id < 2'), [1]
|
194
|
+
check_result_ids Post.text_search('some', :conditions => ['id < ?', 2]), [1]
|
195
|
+
check_result_ids Post.text_search('some', :conditions => ['id = ?', 1]), [1]
|
196
|
+
check_result_ids Post.text_search('some', :conditions => ['id = ? and title = ?', 1, TITLES[0]]), [1]
|
197
|
+
check_result_ids Post.text_search('some', :conditions => ['title = :title and id = :id', {:id => 1, :title => TITLES[0]}]), [1]
|
198
|
+
check_result_ids Post.text_search('some', :conditions => {:title => TITLES[0]}), [1]
|
199
|
+
check_result_ids Post.text_search('some', :conditions => {:title => TITLES[0], :tags => TAGS[0] * ' '}), [1]
|
200
|
+
|
201
|
+
error = nil
|
202
|
+
begin
|
203
|
+
check_result_ids Post.text_search('some', :conditions => {:id => 1}), [1]
|
204
|
+
rescue => error
|
205
|
+
end
|
206
|
+
error.should_not be_nil
|
207
|
+
error.should be_kind_of Redis::TextSearch::BadConditions
|
208
|
+
end
|
209
|
+
|
210
|
+
it "should handle pagination" do
|
211
|
+
res = Post.text_search('some', :page => 1, :per_page => 1, :order => 'id desc')
|
212
|
+
check_result_ids res, [3]
|
213
|
+
res.total_entries.should == 2
|
214
|
+
res.total_pages.should == 2
|
215
|
+
res.per_page.should == 1
|
216
|
+
res.current_page.should == 1
|
217
|
+
|
218
|
+
res = Post.text_search('some', :page => 2, :per_page => 1, :order => 'id desc')
|
219
|
+
check_result_ids res, [1]
|
220
|
+
res.total_entries.should == 2
|
221
|
+
res.total_pages.should == 2
|
222
|
+
res.per_page.should == 1
|
223
|
+
res.current_page.should == 2
|
224
|
+
|
225
|
+
res = Post.text_search('some', :page => 2, :per_page => 5)
|
226
|
+
check_result_ids res, []
|
227
|
+
res.total_entries.should == 2
|
228
|
+
res.total_pages.should == 1
|
229
|
+
res.per_page.should == 5
|
230
|
+
res.current_page.should == 2
|
231
|
+
|
232
|
+
res = Post.text_search('some', :page => 1, :per_page => 1, :conditions => ['id > ?', 2], :order => 'id')
|
233
|
+
check_result_ids res, [3]
|
234
|
+
res.total_entries.should == 1
|
235
|
+
res.total_pages.should == 1
|
236
|
+
res.per_page.should == 1
|
237
|
+
res.current_page.should == 1
|
238
|
+
|
239
|
+
res = Post.text_search('some', :page => 1, :per_page => 1, :order => 'id',
|
240
|
+
:conditions => ['title = :title', {:title => TITLES[0]}])
|
241
|
+
check_result_ids res, [1]
|
242
|
+
res.total_entries.should == 1
|
243
|
+
res.total_pages.should == 1
|
244
|
+
res.per_page.should == 1
|
245
|
+
res.current_page.should == 1
|
246
|
+
end
|
247
|
+
|
248
|
+
it "should support a hash to the text_search method" do
|
249
|
+
check_result_ids Post.text_search(:tags => 'technical'), [2,3]
|
250
|
+
check_result_ids Post.text_search(:tags => 'nontechnical'), [1]
|
251
|
+
check_result_ids Post.text_search(:tags => 'technical', :title => 'plain'), [2]
|
252
|
+
check_result_ids Post.text_search(:tags => ['technical','MYsql'], :title => 'Mo'), [2]
|
253
|
+
check_result_ids Post.text_search(:tags => ['technical','MYsql'], :title => 'some'), []
|
254
|
+
check_result_ids Post.text_search(:tags => 'technical', :title => 'comments'), [2,3]
|
255
|
+
end
|
256
|
+
|
257
|
+
# MUST BE LAST!!!!!!
|
258
|
+
it "should delete text indexes" do
|
259
|
+
@post.delete_text_indexes
|
260
|
+
@post2.delete_text_indexes
|
261
|
+
Post.delete_text_indexes(3)
|
262
|
+
@post.text_indexes.should == []
|
263
|
+
@post2.text_indexes.should == []
|
264
|
+
@post3.text_indexes.should == []
|
265
|
+
end
|
266
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: redis-textsearch
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Nate Wiger
|
@@ -9,7 +9,7 @@ autorequire:
|
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
11
|
|
12
|
-
date:
|
12
|
+
date: 2010-03-05 00:00:00 -08:00
|
13
13
|
default_executable:
|
14
14
|
dependencies:
|
15
15
|
- !ruby/object:Gem::Dependency
|
@@ -33,8 +33,9 @@ extra_rdoc_files:
|
|
33
33
|
files:
|
34
34
|
- lib/redis/text_search/collection.rb
|
35
35
|
- lib/redis/text_search.rb
|
36
|
+
- spec/redis_text_search_activerecord_spec.rb
|
36
37
|
- spec/redis_text_search_core_spec.rb
|
37
|
-
- spec/
|
38
|
+
- spec/redis_text_search_sequel_spec.rb
|
38
39
|
- spec/spec_helper.rb
|
39
40
|
- README.rdoc
|
40
41
|
has_rdoc: true
|