soulmate_rails 0.2.0.alpha
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/.document +5 -0
- data/.gitignore +19 -0
- data/.rvmrc +1 -0
- data/Gemfile +2 -0
- data/LICENSE.txt +20 -0
- data/README.markdown +93 -0
- data/Rakefile +24 -0
- data/lib/soulmate/base.rb +23 -0
- data/lib/soulmate/helpers.rb +21 -0
- data/lib/soulmate/loader.rb +63 -0
- data/lib/soulmate/matcher.rb +31 -0
- data/lib/soulmate_rails.rb +54 -0
- data/lib/soulmate_rails/model_additions.rb +61 -0
- data/lib/soulmate_rails/railtie.rb +15 -0
- data/lib/soulmate_rails/version.rb +8 -0
- data/soulmate_rails.gemspec +33 -0
- data/spec/config/test.conf +8 -0
- data/spec/samples/stop-words.txt +4 -0
- data/spec/samples/venues.json +7 -0
- data/spec/soulmate/loader_spec.rb +84 -0
- data/spec/soulmate/matcher_spec.rb +37 -0
- data/spec/soulmate_rails/model_additions_spec.rb +45 -0
- data/spec/spec_helper.rb +17 -0
- metadata +186 -0
data/.document
ADDED
data/.gitignore
ADDED
data/.rvmrc
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
rvm 1.9.3@soulmate_rails --create
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2011 Eric Waller
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.markdown
ADDED
@@ -0,0 +1,93 @@
|
|
1
|
+
# Soulmate Rails
|
2
|
+
|
3
|
+
Soulmate Rails is a rails plugin that helps to solve the common problem of
|
4
|
+
auto-completion in rails intuitively. It extends the soulmate gem <a
|
5
|
+
href="http://github.com/seatgeek/soulmate">Soulmate</a> to make it easily
|
6
|
+
pluggable into a rails project.
|
7
|
+
|
8
|
+
## Getting Started
|
9
|
+
|
10
|
+
### Installation :
|
11
|
+
|
12
|
+
```sh
|
13
|
+
$ gem install soulmate_rails
|
14
|
+
```
|
15
|
+
|
16
|
+
### Usage :
|
17
|
+
|
18
|
+
Following is an example of how one can use Soulmate Rails for enabling backend
|
19
|
+
autocompletion using redis.
|
20
|
+
|
21
|
+
```ruby
|
22
|
+
class User < ActiveRecord::Base
|
23
|
+
autocomplete :first_name, :score => :calculate_score
|
24
|
+
autocomplete :last_name, :score => :id
|
25
|
+
|
26
|
+
def calculate_score
|
27
|
+
# Some magic calculation returning a number.
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
1.9.3p385 :001 > User.create(:first_name => 'First1', :last_name => 'Last1')
|
32
|
+
1.9.3p385 :002 > User.create(:first_name => 'First2', :last_name => 'Last2')
|
33
|
+
1.9.3p385 :003 > User.create(:first_name => 'First3', :last_name => 'Last3')
|
34
|
+
1.9.3p385 :004 > User.search_by_first_name('firs')
|
35
|
+
=> [#<User:0x000000014bb1e8 @new_record=false,
|
36
|
+
@attributes={"first_name"=>"First3", "last_name"=>"Last3" "id"=>3},
|
37
|
+
@changed_attributes={}>, #<User:0x000000014bb1e9 @new_record=false,
|
38
|
+
@attributes={"first_name"=>"First2", "last_name"=>"Last2" "id"=>2},
|
39
|
+
@changed_attributes={}>, #<User:0x000000014bb1ea @new_record=false,
|
40
|
+
@attributes={"first_name"=>"First1", "last_name"=>"Last1" "id"=>1},
|
41
|
+
@changed_attributes={}>]
|
42
|
+
1.9.3p385 :005 > User.search_by_last_name('last1')
|
43
|
+
=> [#<User:0x000000014bb1e8 @new_record=false,
|
44
|
+
@attributes={"first_name"=>"First3", "last_name"=>"Last3" "id"=>3},
|
45
|
+
@changed_attributes={}>]
|
46
|
+
```
|
47
|
+
|
48
|
+
The `autocomplete` method takes 2 arguments :
|
49
|
+
|
50
|
+
* attribute name to use for autocompletion.
|
51
|
+
* options that determine how autocompletion works for indexing.
|
52
|
+
|
53
|
+
Methods added by autocomplete :
|
54
|
+
|
55
|
+
* Class Methods
|
56
|
+
* `search_by(attribute, term, options={})` - Generic method to search by
|
57
|
+
an attribute for which an autocomplete was defined.
|
58
|
+
* `search_by_#{attribute}(term, options={})` - Specific methods for each
|
59
|
+
attribute autocomplete was defined for.
|
60
|
+
* Instance Methods
|
61
|
+
* `update_index_for(attribute, options={})`
|
62
|
+
* `update_index_for_#{attribute}` - used in an `after_save` callback to
|
63
|
+
update index for searching.
|
64
|
+
* `remove_index_for(attribute)`
|
65
|
+
* `remove_index_for_#{attribute}` - used in a `before_destroy` callback to
|
66
|
+
remove index for searching. Hence you should use `destroy` as opposed to
|
67
|
+
`delete` to ensure the callbacks are invoked appropriately by rails and
|
68
|
+
soulmate updates the index.
|
69
|
+
|
70
|
+
Options you can provide to `autocomplete` :
|
71
|
+
|
72
|
+
* `:score` : This is required. Soulmate uses it for sorting the results (in
|
73
|
+
reverse order, i.e. higher score first). This can be the name of a function
|
74
|
+
or can also be the name of another attribute with integer values.
|
75
|
+
* `:aliases` : This is optional. Soulmate uses this as aliases for the term
|
76
|
+
field and uses it for searching as well. This can be an array of values or
|
77
|
+
a method name which returns an array of values.
|
78
|
+
|
79
|
+
## Contributing
|
80
|
+
### Reporting an Issue :
|
81
|
+
* Use <a href="http://github.com/dhruvasagar/soulmate_rails/issues">Github
|
82
|
+
Issue Tracker</a> to report issues.
|
83
|
+
|
84
|
+
### Contributing to code :
|
85
|
+
* Fork it.
|
86
|
+
* Commit your changes ( git commit ).
|
87
|
+
* Push to github ( git push ).
|
88
|
+
* Open a Pull Request.
|
89
|
+
|
90
|
+
## License
|
91
|
+
Soulmate Rails is released under the MIT License
|
92
|
+
|
93
|
+
<!-- vim: set tw=80 colorcolumn=80 -->
|
data/Rakefile
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'bundler/gem_tasks'
|
2
|
+
require 'rspec/core/rake_task'
|
3
|
+
|
4
|
+
require File.join(File.expand_path('../lib', __FILE__), 'soulmate_rails', 'version')
|
5
|
+
|
6
|
+
RSpec::Core::RakeTask.new
|
7
|
+
task :default => :spec
|
8
|
+
|
9
|
+
desc 'Builds the gem'
|
10
|
+
task :build do
|
11
|
+
sh 'gem build soulmate_rails.gemspec'
|
12
|
+
Dir.mkdir('pkg') unless File.directory?('pkg')
|
13
|
+
sh "mv soulmate_rails-#{SoulmateRails::VERSION}.gem pkg/"
|
14
|
+
end
|
15
|
+
|
16
|
+
desc 'Builds and Installs the gem'
|
17
|
+
task :install => :build do
|
18
|
+
sh "gem install pkg/soulmate_rails-#{SoulmateRails::VERSION}.gem"
|
19
|
+
end
|
20
|
+
|
21
|
+
desc 'Open an irb session preloaded with this library'
|
22
|
+
task :console do
|
23
|
+
sh 'irb -rubygems -I lib -r soulmate_rails.rb'
|
24
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Soulmate
|
2
|
+
class Base
|
3
|
+
include Helpers
|
4
|
+
|
5
|
+
attr_accessor :type
|
6
|
+
|
7
|
+
def initialize(type)
|
8
|
+
@type = normalize(type)
|
9
|
+
end
|
10
|
+
|
11
|
+
def base
|
12
|
+
"soulmate-index:#{type}"
|
13
|
+
end
|
14
|
+
|
15
|
+
def database
|
16
|
+
"soulmate-data:#{type}"
|
17
|
+
end
|
18
|
+
|
19
|
+
def cachebase
|
20
|
+
"soulmate-cache:#{type}"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
module Soulmate
|
4
|
+
module Helpers
|
5
|
+
|
6
|
+
def prefixes_for_phrase(phrase)
|
7
|
+
words = normalize(phrase).split(' ').reject do |w|
|
8
|
+
Soulmate.stop_words.include?(w)
|
9
|
+
end
|
10
|
+
words.map do |w|
|
11
|
+
(MIN_COMPLETE-1..(w.length-1)).map{ |l| w[0..l] }
|
12
|
+
end.flatten.uniq
|
13
|
+
end
|
14
|
+
|
15
|
+
def normalize(str)
|
16
|
+
# Letter, Mark, Number, Connector_Punctuation (Chinese, Japanese, etc.)
|
17
|
+
str.downcase.gsub(/[^\p{Word}\ ]/i, '').strip
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
module Soulmate
|
2
|
+
class Loader < Base
|
3
|
+
|
4
|
+
def load(items)
|
5
|
+
# delete the sorted sets for this type
|
6
|
+
phrases = Soulmate.redis.smembers(base)
|
7
|
+
Soulmate.redis.pipelined do
|
8
|
+
phrases.each do |p|
|
9
|
+
Soulmate.redis.del("#{base}:#{p}")
|
10
|
+
end
|
11
|
+
Soulmate.redis.del(base)
|
12
|
+
end
|
13
|
+
|
14
|
+
# Redis can continue serving cached requests for this type while the reload is
|
15
|
+
# occuring. Some requests may be cached incorrectly as empty set (for requests
|
16
|
+
# which come in after the above delete, but before the loading completes). But
|
17
|
+
# everything will work itself out as soon as the cache expires again.
|
18
|
+
|
19
|
+
# delete the data stored for this type
|
20
|
+
Soulmate.redis.del(database)
|
21
|
+
|
22
|
+
items.each_with_index do |item, i|
|
23
|
+
add(item, :skip_duplicate_check => true)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# "id", "term", "score", "aliases", "data"
|
28
|
+
def add(item, opts = {})
|
29
|
+
opts = { :skip_duplicate_check => false }.merge(opts)
|
30
|
+
raise ArgumentError, 'Item must have an id AND a term' unless item["id"] && item["term"]
|
31
|
+
|
32
|
+
# kill any old items with this id
|
33
|
+
remove("id" => item["id"]) unless opts[:skip_duplicate_check]
|
34
|
+
|
35
|
+
Soulmate.redis.pipelined do
|
36
|
+
# store the raw data in a separate key to reduce memory usage
|
37
|
+
Soulmate.redis.hset(database, item["id"], MultiJson.encode(item))
|
38
|
+
phrase = ([item["term"]] + (item["aliases"] || [])).join(' ')
|
39
|
+
prefixes_for_phrase(phrase).each do |p|
|
40
|
+
Soulmate.redis.sadd(base, p) # remember this prefix in a master set
|
41
|
+
Soulmate.redis.zadd("#{base}:#{p}", item["score"], item["id"]) # store the id of this term in the index
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# remove only cares about an item's id, but for consistency takes an object
|
47
|
+
def remove(item)
|
48
|
+
prev_item = Soulmate.redis.hget(database, item["id"])
|
49
|
+
if prev_item
|
50
|
+
prev_item = MultiJson.decode(prev_item)
|
51
|
+
# undo the operations done in add
|
52
|
+
Soulmate.redis.pipelined do
|
53
|
+
Soulmate.redis.hdel(database, prev_item["id"])
|
54
|
+
phrase = ([prev_item["term"]] + (prev_item["aliases"] || [])).join(' ')
|
55
|
+
prefixes_for_phrase(phrase).each do |p|
|
56
|
+
Soulmate.redis.srem(base, p)
|
57
|
+
Soulmate.redis.zrem("#{base}:#{p}", prev_item["id"])
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Soulmate
|
2
|
+
class Matcher < Base
|
3
|
+
|
4
|
+
def matches_for_term(term, options = {})
|
5
|
+
options = { :limit => 5, :cache => true }.merge(options)
|
6
|
+
|
7
|
+
words = normalize(term).split(' ').reject do |w|
|
8
|
+
w.size < MIN_COMPLETE or Soulmate.stop_words.include?(w)
|
9
|
+
end.sort
|
10
|
+
|
11
|
+
return [] if words.empty?
|
12
|
+
|
13
|
+
cachekey = "#{cachebase}:" + words.join('|')
|
14
|
+
|
15
|
+
if !options[:cache] || !Soulmate.redis.exists(cachekey)
|
16
|
+
interkeys = words.map { |w| "#{base}:#{w}" }
|
17
|
+
Soulmate.redis.zinterstore(cachekey, interkeys)
|
18
|
+
Soulmate.redis.expire(cachekey, 10 * 60) # expire after 10 minutes
|
19
|
+
end
|
20
|
+
|
21
|
+
ids = Soulmate.redis.zrevrange(cachekey, 0, options[:limit] - 1)
|
22
|
+
if ids.size > 0
|
23
|
+
results = Soulmate.redis.hmget(database, *ids)
|
24
|
+
results = results.reject{ |r| r.nil? } # handle cached results for ids which have since been deleted
|
25
|
+
results.map { |r| MultiJson.decode(r) }
|
26
|
+
else
|
27
|
+
[]
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
require 'uri'
|
2
|
+
require 'redis'
|
3
|
+
require 'multi_json'
|
4
|
+
|
5
|
+
require 'active_support/concern'
|
6
|
+
|
7
|
+
require 'soulmate/helpers'
|
8
|
+
require 'soulmate/base'
|
9
|
+
require 'soulmate/matcher'
|
10
|
+
require 'soulmate/loader'
|
11
|
+
|
12
|
+
require 'soulmate_rails/model_additions'
|
13
|
+
require 'soulmate_rails/railtie' if defined? Rails
|
14
|
+
|
15
|
+
module Soulmate
|
16
|
+
|
17
|
+
extend self
|
18
|
+
|
19
|
+
MIN_COMPLETE = 2
|
20
|
+
DEFAULT_STOP_WORDS = ["vs", "at", "the"]
|
21
|
+
|
22
|
+
def redis=(server)
|
23
|
+
if server.is_a?(String)
|
24
|
+
@redis = nil
|
25
|
+
@redis_url = server
|
26
|
+
else
|
27
|
+
@redis = server
|
28
|
+
end
|
29
|
+
|
30
|
+
redis
|
31
|
+
end
|
32
|
+
|
33
|
+
def redis
|
34
|
+
@redis ||= (
|
35
|
+
url = URI(@redis_url || ENV["REDIS_URL"] || "redis://127.0.0.1:6379/0")
|
36
|
+
|
37
|
+
::Redis.new({
|
38
|
+
:host => url.host,
|
39
|
+
:port => url.port,
|
40
|
+
:db => url.path[1..-1],
|
41
|
+
:password => url.password
|
42
|
+
})
|
43
|
+
)
|
44
|
+
end
|
45
|
+
|
46
|
+
def stop_words
|
47
|
+
@stop_words ||= DEFAULT_STOP_WORDS
|
48
|
+
end
|
49
|
+
|
50
|
+
def stop_words=(arr)
|
51
|
+
@stop_words = Array(arr).flatten
|
52
|
+
end
|
53
|
+
|
54
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module SoulmateRails
|
2
|
+
module ModelAdditions
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
def update_index_for(attribute, options={})
|
6
|
+
loader = instance_variable_get("@#{loader_for(attribute)}") || instance_variable_set("@#{loader_for(attribute)}", Soulmate::Loader.new(loader_for(attribute)))
|
7
|
+
item = {
|
8
|
+
'id' => "#{attribute}_#{self.id}",
|
9
|
+
'term' => send(attribute),
|
10
|
+
'score' => ( respond_to?(options[:score]) ? send(options[:score]) : options[:score] )
|
11
|
+
}.merge( options[:aliases] ? ( respond_to?(options[:aliases]) ? send(options[:aliases]) : options[:aliases] ) : {} )
|
12
|
+
# NOTE: Not supporting :data for now, will find a better way to use this later.
|
13
|
+
# .merge( options[:data] ? ( respond_to?(options[:data]) ? send(options[:data]) : options[:data] ) : {} )
|
14
|
+
loader.add(item, options)
|
15
|
+
end
|
16
|
+
|
17
|
+
def remove_index_for(attribute)
|
18
|
+
loader = instance_variable_get("@#{loader_for(attribute)}") || instance_variable_set("@#{loader_for(attribute)}", Soulmate::Loader.new(loader_for(attribute)))
|
19
|
+
loader.remove('id' => "#{attribute}_#{self.id}")
|
20
|
+
end
|
21
|
+
|
22
|
+
def loader_for(attribute)
|
23
|
+
"#{self.class.normalized_class_name}_#{attribute}"
|
24
|
+
end
|
25
|
+
|
26
|
+
module ClassMethods
|
27
|
+
def autocomplete(attribute, options={})
|
28
|
+
define_method "update_index_for_#{attribute}" do
|
29
|
+
update_index_for(attribute, options)
|
30
|
+
end
|
31
|
+
after_save "update_index_for_#{attribute}"
|
32
|
+
|
33
|
+
define_method "remove_index_for_#{attribute}" do
|
34
|
+
remove_index_for(attribute)
|
35
|
+
end
|
36
|
+
before_destroy "remove_index_for_#{attribute}"
|
37
|
+
|
38
|
+
define_singleton_method "search_by_#{attribute}" do |term, *opts|
|
39
|
+
opts = opts.empty? ? {} : opts.first
|
40
|
+
search_by(attribute, term, opts)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def search_by(attribute, term, options={})
|
45
|
+
matcher = instance_variable_get("@#{matcher_for(attribute)}") || instance_variable_set("@#{matcher_for(attribute)}", Soulmate::Matcher.new(matcher_for(attribute)))
|
46
|
+
matches = matcher.matches_for_term(term, options)
|
47
|
+
matches = matches.map do |match|
|
48
|
+
find(match['id'].split('_')[-1].to_i) rescue nil
|
49
|
+
end.compact
|
50
|
+
end
|
51
|
+
|
52
|
+
def matcher_for(attribute)
|
53
|
+
"#{normalized_class_name}_#{attribute}"
|
54
|
+
end
|
55
|
+
|
56
|
+
def normalized_class_name
|
57
|
+
self.name.gsub('::', '_').downcase
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module SoulmateRails
|
2
|
+
class Railtie < Rails::Railtie
|
3
|
+
initializer 'soulmate_rails.model_additions' do
|
4
|
+
ActiveSupport.on_load :active_record do
|
5
|
+
include ModelAdditions
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
initializer 'soulmate_rails.set_configs' do |app|
|
10
|
+
options = app.config.soulmate_rails
|
11
|
+
|
12
|
+
Soulmate.redis = options[:redis] if options[:redis]
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# Generated by jeweler
|
2
|
+
# DO NOT EDIT THIS FILE DIRECTLY
|
3
|
+
# Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
|
4
|
+
# -*- encoding: utf-8 -*-
|
5
|
+
|
6
|
+
$:.push File.expand_path("../lib", __FILE__)
|
7
|
+
require "soulmate_rails/version"
|
8
|
+
|
9
|
+
Gem::Specification.new do |s|
|
10
|
+
s.name = "soulmate_rails"
|
11
|
+
s.email = ["dhruva.sagar@gmail.com"]
|
12
|
+
s.version = SoulmateRails::VERSION
|
13
|
+
s.authors = ["Dhruva Sagar"]
|
14
|
+
s.homepage = "http://github.com/dhruvasagar/soulmate_rails"
|
15
|
+
s.summary = "Redis-backed autocompletion engine for Rails"
|
16
|
+
s.description = "Soulmate Rails is a tool to help solve the common problem of developing a fast autocomplete feature for Rails. It uses Redis's sorted sets to build an index of partial words and corresponding top matches."
|
17
|
+
|
18
|
+
s.files = `git ls-files`.split("\n")
|
19
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
20
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map {|f| File.basename(f)}
|
21
|
+
|
22
|
+
s.licenses = ["MIT"]
|
23
|
+
s.require_paths = ["lib"]
|
24
|
+
|
25
|
+
s.add_runtime_dependency 'redis', '>= 2.0'
|
26
|
+
s.add_runtime_dependency 'multi_json', '>= 1.0'
|
27
|
+
s.add_runtime_dependency 'activesupport', '>= 0'
|
28
|
+
|
29
|
+
s.add_development_dependency 'rake', '>= 0'
|
30
|
+
s.add_development_dependency 'rspec', '>= 0'
|
31
|
+
s.add_development_dependency 'supermodel', '>= 0'
|
32
|
+
s.add_development_dependency 'mock_redis', '>= 0'
|
33
|
+
end
|
@@ -0,0 +1,7 @@
|
|
1
|
+
{"id":1,"term":"Dodger Stadium","score":84,"data":{"url":"\/dodger-stadium-tickets\/","subtitle":"Los Angeles, CA"},"aliases":["Chavez Ravine"]}
|
2
|
+
{"id":28,"term":"Angel Stadium","score":90,"data":{"url":"\/angel-stadium-tickets\/","subtitle":"Anaheim, CA"},"aliases":["Edison International Field of Anaheim"]}
|
3
|
+
{"id":30,"term":"Chase Field ","score":80,"data":{"url":"\/chase-field-tickets\/","subtitle":"Phoenix, AZ"},"aliases":["Bank One Ballpark", "Bank One Stadium"]}
|
4
|
+
{"id":29,"term":"Sun Life Stadium","score":75,"data":{"url":"\/sun-life-stadium-tickets\/","subtitle":"Miami, FL"},"aliases":["Dolphins Stadium","Land Shark Stadium"]}
|
5
|
+
{"id":2,"term":"Turner Field","score":50,"data":{"url":"\/turner-field-tickets\/","subtitle":"Atlanta, GA"}}
|
6
|
+
{"id":3,"term":"Citi Field","score":92,"data":{"url":"\/citi-field-tickets\/","subtitle":"Atlanta, GA"},"aliases":["Shea Stadium"]}
|
7
|
+
{"id":8,"term":"中国佛山 李小龙","score":94,"data":{"url":"\/Bruce Lee\/","subtitle":"Chinese Foshan"},"aliases":["Li XiaoLong"]}
|
@@ -0,0 +1,84 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module Soulmate
|
4
|
+
describe Loader do
|
5
|
+
before :each do
|
6
|
+
@loader = Loader.new('venues')
|
7
|
+
end
|
8
|
+
|
9
|
+
context 'add' do
|
10
|
+
it 'should successfully add acceptable value' do
|
11
|
+
expect {
|
12
|
+
@loader.add({"id" => 11,"term" => "Dodger Stadium","score" => 84,"data" => {"url" => "\/dodger-stadium-tickets\/","subtitle" => "Los Angeles, CA"},"aliases" => ["Chavez Ravine"]})
|
13
|
+
}.to_not raise_error
|
14
|
+
end
|
15
|
+
|
16
|
+
context 'invalid item' do
|
17
|
+
it 'should raise ArgumentError if id is missing' do
|
18
|
+
expect {
|
19
|
+
@loader.add({"term" => "Dodger Stadium","score" => 84,"data" => {"url" => "\/dodger-stadium-tickets\/","subtitle" => "Los Angeles, CA"},"aliases" => ["Chavez Ravine"]})
|
20
|
+
}.to raise_error
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'should raise ArgumentError if term is missing' do
|
24
|
+
expect {
|
25
|
+
@loader.add({"id" => 11, "score" => 84,"data" => {"url" => "\/dodger-stadium-tickets\/","subtitle" => "Los Angeles, CA"},"aliases" => ["Chavez Ravine"]})
|
26
|
+
}.to raise_error(ArgumentError)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
context 'load' do
|
32
|
+
before :each do
|
33
|
+
@items = []
|
34
|
+
venues = File.open(TestRoot + '/samples/venues.json', 'r')
|
35
|
+
venues.each_line do |venue|
|
36
|
+
@items << MultiJson.decode(venue)
|
37
|
+
end
|
38
|
+
@items_loaded = @loader.load(@items)
|
39
|
+
end
|
40
|
+
|
41
|
+
it 'should load values' do
|
42
|
+
@items_loaded.size.should eq(7)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
context 'integration' do
|
47
|
+
before :each do
|
48
|
+
@matcher = Matcher.new('venues')
|
49
|
+
end
|
50
|
+
|
51
|
+
it 'should successfully remove the item' do
|
52
|
+
@loader.load([])
|
53
|
+
results = @matcher.matches_for_term('te', :cache => false)
|
54
|
+
results.size.should eq(0)
|
55
|
+
|
56
|
+
@loader.add('id' => 1, 'term' => 'Testing this', 'score' => 10)
|
57
|
+
results = @matcher.matches_for_term('te', :cache => false)
|
58
|
+
results.size.should eq(1)
|
59
|
+
|
60
|
+
@loader.remove('id' => 1)
|
61
|
+
results = @matcher.matches_for_term('te', :cache => false)
|
62
|
+
results.size.should eq(0)
|
63
|
+
end
|
64
|
+
|
65
|
+
it 'should successfully update items' do
|
66
|
+
@loader.load([])
|
67
|
+
@loader.add("id" => 1, "term" => "Testing this", "score" => 10)
|
68
|
+
@loader.add("id" => 2, "term" => "Another Term", "score" => 9)
|
69
|
+
@loader.add("id" => 3, "term" => "Something different", "score" => 5)
|
70
|
+
|
71
|
+
results = @matcher.matches_for_term('te', :cache => false)
|
72
|
+
results.size.should eq(2)
|
73
|
+
results.first['term'].should eq('Testing this')
|
74
|
+
results.first['score'].should eq(10)
|
75
|
+
|
76
|
+
@loader.add("id" => 1, "term" => "Updated", "score" => 5)
|
77
|
+
results = @matcher.matches_for_term('te', :cache => false)
|
78
|
+
results.size.should eq(1)
|
79
|
+
results.first['term'].should eq('Another Term')
|
80
|
+
results.first['score'].should eq(9)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
module Soulmate
|
6
|
+
describe Matcher do
|
7
|
+
before :each do
|
8
|
+
items = []
|
9
|
+
loader = Loader.new('venues')
|
10
|
+
venues = File.open(TestRoot + '/samples/venues.json', 'r')
|
11
|
+
venues.each_line do |venue|
|
12
|
+
items << MultiJson.decode(venue)
|
13
|
+
end
|
14
|
+
loader.load(items)
|
15
|
+
|
16
|
+
@matcher = Matcher.new('venues')
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'should successfully return matches for given term' do
|
20
|
+
results = @matcher.matches_for_term('stad')
|
21
|
+
results.size.should eq(5)
|
22
|
+
results.first['term'].should eq('Citi Field')
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'should successfully return matches with aliases' do
|
26
|
+
results = @matcher.matches_for_term('land shark stadium')
|
27
|
+
results.size.should eq(1)
|
28
|
+
results.first['term'].should eq('Sun Life Stadium')
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'should successfully return matches with chinese' do
|
32
|
+
results = @matcher.matches_for_term('中国')
|
33
|
+
results.size.should eq(1)
|
34
|
+
results.first['term'].should eq('中国佛山 李小龙')
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module SoulmateRails
|
4
|
+
class User < SuperModel::Base
|
5
|
+
include ModelAdditions
|
6
|
+
|
7
|
+
autocomplete :name, :score => :id
|
8
|
+
end
|
9
|
+
|
10
|
+
describe ModelAdditions do
|
11
|
+
context 'single autocomplete' do
|
12
|
+
before :each do
|
13
|
+
@user = User.create(:name => 'Dhruva Sagar')
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'should successfully search by name' do
|
17
|
+
users = User.search_by_name('dhruv')
|
18
|
+
user = users.first
|
19
|
+
user.should eq(@user)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
context 'multiple autocompletes' do
|
24
|
+
before :each do
|
25
|
+
# Define another autocomplete for country
|
26
|
+
User.autocomplete(:country, :score => :id)
|
27
|
+
@user = User.create(:name => 'Dhruva Sagar', :country => 'India')
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'should successfully search by name as well as country' do
|
31
|
+
users = User.search_by_name('dhr')
|
32
|
+
user = users.first
|
33
|
+
user.should eq(@user)
|
34
|
+
|
35
|
+
users = User.search_by_country('ind')
|
36
|
+
user = users.first
|
37
|
+
user.should eq(@user)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
after :each do
|
42
|
+
User.destroy_all
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
require 'supermodel'
|
2
|
+
require 'mock_redis'
|
3
|
+
require 'soulmate_rails'
|
4
|
+
|
5
|
+
TestRoot = File.expand_path(File.dirname(__FILE__))
|
6
|
+
|
7
|
+
RSpec.configure do |config|
|
8
|
+
config.order = 'random'
|
9
|
+
config.color_enabled = true
|
10
|
+
|
11
|
+
config.before(:each) do
|
12
|
+
redis = MockRedis.new
|
13
|
+
redis.flushdb
|
14
|
+
Soulmate.redis = redis
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
metadata
ADDED
@@ -0,0 +1,186 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: soulmate_rails
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.2.0.alpha
|
5
|
+
prerelease: 6
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Dhruva Sagar
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2013-03-04 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: redis
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '2.0'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ! '>='
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '2.0'
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: multi_json
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ! '>='
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: '1.0'
|
38
|
+
type: :runtime
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ! '>='
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: '1.0'
|
46
|
+
- !ruby/object:Gem::Dependency
|
47
|
+
name: activesupport
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
49
|
+
none: false
|
50
|
+
requirements:
|
51
|
+
- - ! '>='
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '0'
|
54
|
+
type: :runtime
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ! '>='
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
- !ruby/object:Gem::Dependency
|
63
|
+
name: rake
|
64
|
+
requirement: !ruby/object:Gem::Requirement
|
65
|
+
none: false
|
66
|
+
requirements:
|
67
|
+
- - ! '>='
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: '0'
|
70
|
+
type: :development
|
71
|
+
prerelease: false
|
72
|
+
version_requirements: !ruby/object:Gem::Requirement
|
73
|
+
none: false
|
74
|
+
requirements:
|
75
|
+
- - ! '>='
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: '0'
|
78
|
+
- !ruby/object:Gem::Dependency
|
79
|
+
name: rspec
|
80
|
+
requirement: !ruby/object:Gem::Requirement
|
81
|
+
none: false
|
82
|
+
requirements:
|
83
|
+
- - ! '>='
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: '0'
|
86
|
+
type: :development
|
87
|
+
prerelease: false
|
88
|
+
version_requirements: !ruby/object:Gem::Requirement
|
89
|
+
none: false
|
90
|
+
requirements:
|
91
|
+
- - ! '>='
|
92
|
+
- !ruby/object:Gem::Version
|
93
|
+
version: '0'
|
94
|
+
- !ruby/object:Gem::Dependency
|
95
|
+
name: supermodel
|
96
|
+
requirement: !ruby/object:Gem::Requirement
|
97
|
+
none: false
|
98
|
+
requirements:
|
99
|
+
- - ! '>='
|
100
|
+
- !ruby/object:Gem::Version
|
101
|
+
version: '0'
|
102
|
+
type: :development
|
103
|
+
prerelease: false
|
104
|
+
version_requirements: !ruby/object:Gem::Requirement
|
105
|
+
none: false
|
106
|
+
requirements:
|
107
|
+
- - ! '>='
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
version: '0'
|
110
|
+
- !ruby/object:Gem::Dependency
|
111
|
+
name: mock_redis
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
113
|
+
none: false
|
114
|
+
requirements:
|
115
|
+
- - ! '>='
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
none: false
|
122
|
+
requirements:
|
123
|
+
- - ! '>='
|
124
|
+
- !ruby/object:Gem::Version
|
125
|
+
version: '0'
|
126
|
+
description: Soulmate Rails is a tool to help solve the common problem of developing
|
127
|
+
a fast autocomplete feature for Rails. It uses Redis's sorted sets to build an index
|
128
|
+
of partial words and corresponding top matches.
|
129
|
+
email:
|
130
|
+
- dhruva.sagar@gmail.com
|
131
|
+
executables: []
|
132
|
+
extensions: []
|
133
|
+
extra_rdoc_files: []
|
134
|
+
files:
|
135
|
+
- .document
|
136
|
+
- .gitignore
|
137
|
+
- .rvmrc
|
138
|
+
- Gemfile
|
139
|
+
- LICENSE.txt
|
140
|
+
- README.markdown
|
141
|
+
- Rakefile
|
142
|
+
- lib/soulmate/base.rb
|
143
|
+
- lib/soulmate/helpers.rb
|
144
|
+
- lib/soulmate/loader.rb
|
145
|
+
- lib/soulmate/matcher.rb
|
146
|
+
- lib/soulmate_rails.rb
|
147
|
+
- lib/soulmate_rails/model_additions.rb
|
148
|
+
- lib/soulmate_rails/railtie.rb
|
149
|
+
- lib/soulmate_rails/version.rb
|
150
|
+
- soulmate_rails.gemspec
|
151
|
+
- spec/config/test.conf
|
152
|
+
- spec/samples/stop-words.txt
|
153
|
+
- spec/samples/venues.json
|
154
|
+
- spec/soulmate/loader_spec.rb
|
155
|
+
- spec/soulmate/matcher_spec.rb
|
156
|
+
- spec/soulmate_rails/model_additions_spec.rb
|
157
|
+
- spec/spec_helper.rb
|
158
|
+
homepage: http://github.com/dhruvasagar/soulmate_rails
|
159
|
+
licenses:
|
160
|
+
- MIT
|
161
|
+
post_install_message:
|
162
|
+
rdoc_options: []
|
163
|
+
require_paths:
|
164
|
+
- lib
|
165
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
166
|
+
none: false
|
167
|
+
requirements:
|
168
|
+
- - ! '>='
|
169
|
+
- !ruby/object:Gem::Version
|
170
|
+
version: '0'
|
171
|
+
segments:
|
172
|
+
- 0
|
173
|
+
hash: -2339712200945415298
|
174
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
175
|
+
none: false
|
176
|
+
requirements:
|
177
|
+
- - ! '>'
|
178
|
+
- !ruby/object:Gem::Version
|
179
|
+
version: 1.3.1
|
180
|
+
requirements: []
|
181
|
+
rubyforge_project:
|
182
|
+
rubygems_version: 1.8.25
|
183
|
+
signing_key:
|
184
|
+
specification_version: 3
|
185
|
+
summary: Redis-backed autocompletion engine for Rails
|
186
|
+
test_files: []
|