redis-textsearch 0.1.2 → 0.1.3
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/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
|