Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

core functionality working on create

  • Loading branch information...
commit b5ffda3cd3db3712cd2b94ad3f7c8cab02f1a551 1 parent ce32ae4
Nate Wiger authored
View
28 README.rdoc
@@ -33,17 +33,31 @@ Model class:
class Post < ActiveRecord::Base
include Redis::TextSearch
-
- text_index :title, :tags, :minlength => 2
- def self.search(string)
- find_all_by_ids text_search(strings)
+ text_index :title, :minlength => 2
+ text_index :tags, :exact => true
+
+ after_save do |r|
+ r.update_text_indexes
end
end
Then in your controller or other app code:
- @posts = Post.search('bacon')
- @posts = Post.search('bacon')
-
+ Post.create(:title => "All About Bacon", :tags => "chef nontechnical")
+ Post.create(:title => "All About Bacon - Part 2", :tags => "chef nontechnical")
+ Post.create(:title => "Homemade Belgian Waffles", :tags => "chef nontechnical")
+ Post.create(:title => "Using Redis with Ruby", :tags => "technical ruby redis")
+ Post.create(:title => "Installing Redis on Linux", :tags => "technical redis linux")
+ Post.create(:title => "Chef Deployment Recipes", :tags => "ruby howto")
+
+ @posts = Post.text_search('bacon') # 2 results
+ @posts = Post.text_search('chef') # 4 results (tags and title)
+ @posts = Post.text_search('chef', :fields => :tags) # 3 results (only search tags)
+ @posts = Post.text_search('technical', 'ruby') # OR search (2 results)
+
+
+== Author
+Copyright (c) 2009 {Nate Wiger}[http://nate.wiger.org]. All Rights Reserved.
+Released under the {Artistic License}[http://www.opensource.org/licenses/artistic-license-2.0.php].
View
157 lib/redis/text_search.rb
@@ -1,4 +1,161 @@
+# Redis::TextSearch - Use Redis to add text search to your app.
class Redis
+ #
+ # Redis::TextSearch enables high-performance text search in your app.
+ #
module TextSearch
+ class NoFinderMethod < StandardError; end
+ class BadTextIndex < StandardError; end
+
+ class << self
+ def redis=(conn) @redis = conn end
+ def redis
+ @redis ||= $redis || raise(NotConnected, "Redis::TextSearch.redis not set to a Redis.new connection")
+ end
+
+ def included(klass)
+ klass.instance_variable_set('@redis', @redis)
+ klass.instance_variable_set('@text_indexes', {})
+ klass.instance_variable_set('@finder_method', nil)
+ klass.send :include, InstanceMethods
+ klass.extend ClassMethods
+ klass.guess_finder_method
+ end
+ end
+
+ # These class methods are added to the class when you include Redis::TextSearch.
+ module ClassMethods
+ attr_accessor :redis
+ attr_reader :text_indexes
+
+ # Set the Redis prefix to use. Defaults to model_name
+ def prefix=(prefix) @prefix = prefix end
+ def prefix #:nodoc:
+ @prefix ||= self.name.to_s.
+ sub(%r{(.*::)}, '').
+ gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
+ gsub(/([a-z\d])([A-Z])/,'\1_\2').
+ downcase
+ end
+
+ def field_key(name, id) #:nodoc:
+ "#{prefix}:#{id}:#{name}"
+ end
+
+ # This is called when the class is imported, and uses reflection to guess
+ # how to retrieve records. You can override it by explicitly defining a
+ # +finder_method+ class method that takes an array of IDs as an argument.
+ def guess_finder_method
+ if defined?(ActiveRecord::Base) and is_a?(ActiveRecord::Base)
+ instance_eval <<-EndMethod
+ def finder_method(ids, options)
+ all(options.merge(:conditions => {:#{primary_key} => ids}))
+ end
+ EndMethod
+ elsif defined?(MongoRecord::Base) and is_a?(MongoRecord::Base)
+ instance_eval <<-EndMethod
+ def finder_method(ids, options)
+ all(options.merge(:conditions => {:#{primary_key} => ids}))
+ end
+ EndMethod
+ elsif respond_to?(:get)
+ # DataMapper::Resource is an include, so is_a? won't work
+ instance_eval <<-EndMethod
+ def finder_method(ids, options)
+ get(ids, options)
+ end
+ EndMethod
+ end
+ end
+
+ # Define fields to be indexed for text search. To update the index, you must call
+ # update_text_indexes after record save or at the appropriate point.
+ def text_index(*args)
+ options = args.last.is_a?(Hash) ? args.pop : {}
+ options[:minlength] ||= 1
+ options[:split] ||= /\s+/
+ raise ArgumentError, "Must specify fields to index to #{self.name}.text_index" unless args.length > 0
+ args.each do |name|
+ @text_indexes[name.to_sym] = options.merge(:key => field_key(name, 'text_index'))
+ end
+ end
+
+ # Perform text search and return results from database. Options:
+ #
+ # 'string', 'string2'
+ # :fields
+ # :page
+ # :per_page
+ def text_search(*args)
+ options = args.last.is_a?(Hash) ? args.pop : {}
+ fields = Array(options[:fields] || @text_indexes.keys)
+ unless defined?(:finder_method)
+ raise NoFinderMethod, "Could not detect how to find records; you must def finder_method()"
+ end
+
+ # Assemble set names for our intersection
+ # Must loop for each field, since it's an "or"
+ raise ArgumentError, "Must specify search string(s) to #{self.name}.text_search" unless args.length > 0
+ ids = []
+ fields.each do |name|
+ opts = @text_indexes[name].merge(options)
+ keys = args.collect do |val|
+ str = val.downcase.gsub(/[^\w\s]+/,'').gsub(/\s+/, '.') # can't have " " in Redis cmd string
+ "#{opts[:key]}:#{str}"
+ end
+
+ # Execute intersection
+ if keys.length > 1
+ ids += redis.set_intersect(*keys)
+ else
+ ids += redis.set_members(keys.first)
+ end
+ end
+
+ # Calculate pagination if applicable
+ if options[:page]
+ per_page = options[:per_page] || self.respond_to?(:per_page) ? per_page : 10
+ end
+
+ # Execute finder
+ finder_method(ids, options)
+ end
+ end
+
+ module InstanceMethods #:nodoc:
+ def redis() self.class.redis end
+
+ # Update all text indexes for the given object. Should be used in an +after_save+ hook
+ # or other applicable area, for example r.update_text_indexes. Can pass an array of
+ # field names to restrict updates just to those fields.
+ def update_text_indexes(*fields)
+ fields = self.class.text_indexes.keys if fields.empty?
+ fields.each do |name|
+ options = self.class.text_indexes[name]
+ value = self.send(name)
+ return false if value.length < options[:minlength]
+ values = value.is_a?(Array) ? value : options[:split] ? value.split(options[:split]) : value
+ values.each do |val|
+ val.gsub!(/[^\w\s]+/,'')
+ val.downcase!
+ next if value.length < options[:minlength]
+ if options[:exact]
+ str = val.gsub(/\s+/, '.') # can't have " " in Redis cmd string
+ redis.sadd("#{options[:key]}:#{str}", id)
+ else
+ len = options[:minlength]
+ redis.pipelined do |cmd|
+ while len < val.length
+ str = val[0..len].gsub(/\s+/, '.') # can't have " " in Redis cmd string
+ # puts "Post.redis.set_members('#{options[:key]}:#{str}').should == ['1']"
+ cmd.sadd("#{options[:key]}:#{str}", id)
+ len += 1
+ end
+ end
+ end
+ end
+ end
+ end
+ end
end
end
View
122 spec/redis_text_search_core_spec.rb
@@ -0,0 +1,122 @@
+
+require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
+
+class Post
+ include Redis::TextSearch
+
+ text_index :title
+ text_index :tags, :exact => true
+
+ def self.finder_method(ids, options)
+ ids
+ end
+
+ def initialize(attrib)
+ @attrib = attrib
+ @id = attrib[:id] || 1
+ end
+ def id; @id; end
+ def method_missing(name, *args)
+ @attrib[name] || super
+ end
+end
+
+TITLES = [
+ 'Some plain text',
+ 'More plain textstring',
+ 'Come get somebody personal',
+ '*Welcome to Nate\'s new BLOG!!',
+]
+
+TAGS = [
+ ['personal', 'nontechnical'],
+ ['mysql', 'technical'],
+ ['gaming','technical']
+]
+
+
+describe Redis::TextSearch do
+ before :all do
+ Post.redis.del('post:text_index:title:so')
+ Post.redis.del('post:text_index:title:som')
+ Post.redis.del('post:text_index:title:some')
+ Post.redis.del('post:text_index:title:pl')
+ Post.redis.del('post:text_index:title:pla')
+ Post.redis.del('post:text_index:title:plai')
+ Post.redis.del('post:text_index:title:plain')
+ Post.redis.del('post:text_index:title:te')
+ Post.redis.del('post:text_index:title:tex')
+ Post.redis.del('post:text_index:title:text')
+ Post.redis.del('post:text_index:title:texts')
+ Post.redis.del('post:text_index:title:textst')
+ Post.redis.del('post:text_index:title:textstr')
+ Post.redis.del('post:text_index:title:textstri')
+ Post.redis.del('post:text_index:title:textstrin')
+ Post.redis.del('post:text_index:title:textstring')
+ Post.redis.del('post:text_index:tags:personal')
+ Post.redis.del('post:text_index:tags:nontechnical')
+ end
+
+ it "should define text indexes in the class" do
+ Post.text_indexes[:title][:key].should == 'post:text_index:title'
+ Post.text_indexes[:tags][:key].should == 'post:text_index:tags'
+ end
+
+ it "should update text indexes correctly" do
+ post = Post.new(:title => TITLES[0], :tags => TAGS[0], :id => 1)
+ post2 = Post.new(:title => TITLES[1], :tags => TAGS[1], :id => 2)
+ post.update_text_indexes
+ post2.update_text_indexes
+
+ Post.redis.set_members('post:text_index:title:so').should == ['1']
+ Post.redis.set_members('post:text_index:title:som').should == ['1']
+ Post.redis.set_members('post:text_index:title:some').should == ['1']
+ Post.redis.set_members('post:text_index:title:pl').sort.should == ['1','2']
+ Post.redis.set_members('post:text_index:title:pla').sort.should == ['1','2']
+ Post.redis.set_members('post:text_index:title:plai').sort.should == ['1','2']
+ Post.redis.set_members('post:text_index:title:plain').sort.should == ['1','2']
+ Post.redis.set_members('post:text_index:title:te').sort.should == ['1','2']
+ Post.redis.set_members('post:text_index:title:tex').sort.should == ['1','2']
+ Post.redis.set_members('post:text_index:title:text').sort.should == ['1','2']
+ Post.redis.set_members('post:text_index:title:texts').should == ['2']
+ Post.redis.set_members('post:text_index:title:textst').should == ['2']
+ Post.redis.set_members('post:text_index:title:textstr').should == ['2']
+ Post.redis.set_members('post:text_index:title:textstri').should == ['2']
+ Post.redis.set_members('post:text_index:title:textstrin').should == ['2']
+ Post.redis.set_members('post:text_index:title:textstring').should == ['2']
+ Post.redis.set_members('post:text_index:tags:pe').should == []
+ Post.redis.set_members('post:text_index:tags:per').should == []
+ Post.redis.set_members('post:text_index:tags:pers').should == []
+ Post.redis.set_members('post:text_index:tags:perso').should == []
+ Post.redis.set_members('post:text_index:tags:person').should == []
+ Post.redis.set_members('post:text_index:tags:persona').should == []
+ Post.redis.set_members('post:text_index:tags:personal').should == ['1']
+ Post.redis.set_members('post:text_index:tags:no').should == []
+ Post.redis.set_members('post:text_index:tags:non').should == []
+ Post.redis.set_members('post:text_index:tags:nont').should == []
+ Post.redis.set_members('post:text_index:tags:nonte').should == []
+ Post.redis.set_members('post:text_index:tags:nontec').should == []
+ Post.redis.set_members('post:text_index:tags:nontech').should == []
+ Post.redis.set_members('post:text_index:tags:nontechn').should == []
+ Post.redis.set_members('post:text_index:tags:nontechni').should == []
+ Post.redis.set_members('post:text_index:tags:nontechnic').should == []
+ Post.redis.set_members('post:text_index:tags:nontechnica').should == []
+ Post.redis.set_members('post:text_index:tags:nontechnical').should == ['1']
+ end
+
+ it "should search text indexes and return records" do
+ Post.text_search('some').should == ['1']
+ Post.new(:title => TITLES[2], :tags => TAGS[2], :id => 3).update_text_indexes
+ Post.text_search('some').sort.should == ['1','3']
+ Post.text_search('plain').sort.should == ['1','2']
+ Post.text_search('plain','text').sort.should == ['1','2']
+ Post.text_search('plain','textstr').sort.should == ['2']
+ Post.text_search('some','text').sort.should == ['1']
+ Post.text_search('nontechnical').sort.should == ['1']
+ Post.text_search('personal').sort.should == ['1','3']
+ Post.text_search('personal', :fields => :tags).sort.should == ['1']
+ Post.text_search('personal', :fields => [:tags]).sort.should == ['1']
+ Post.text_search('nontechnical', :fields => [:title]).sort.should == []
+ end
+
+end
View
5 spec/spec_helper.rb
@@ -0,0 +1,5 @@
+$LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__) + '/../lib')
+require 'redis'
+require 'redis/text_search'
+
+Redis::TextSearch.redis = Redis.new(:host => ENV['REDIS_HOST'], :port => ENV['REDIS_PORT'])
Please sign in to comment.
Something went wrong with that request. Please try again.