ion 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/README.md +280 -0
- data/Rakefile +13 -0
- data/lib/ion.rb +84 -0
- data/lib/ion/config.rb +16 -0
- data/lib/ion/entity.rb +48 -0
- data/lib/ion/helpers.rb +11 -0
- data/lib/ion/index.rb +56 -0
- data/lib/ion/indices.rb +18 -0
- data/lib/ion/indices/metaphone.rb +7 -0
- data/lib/ion/indices/number.rb +31 -0
- data/lib/ion/indices/sort.rb +41 -0
- data/lib/ion/indices/text.rb +41 -0
- data/lib/ion/options.rb +46 -0
- data/lib/ion/scope.rb +163 -0
- data/lib/ion/search.rb +83 -0
- data/lib/ion/stringer.rb +17 -0
- data/test/irb_helpers.rb +9 -0
- data/test/p_helper.rb +31 -0
- data/test/redis_debug.rb +8 -0
- data/test/test_helper.rb +81 -0
- data/test/unit/boost_test.rb +28 -0
- data/test/unit/config_test.rb +25 -0
- data/test/unit/hash_test.rb +48 -0
- data/test/unit/ion_test.rb +112 -0
- data/test/unit/metaphone_test.rb +37 -0
- data/test/unit/number_test.rb +46 -0
- data/test/unit/options_test.rb +18 -0
- data/test/unit/range_test.rb +76 -0
- data/test/unit/score_test.rb +43 -0
- data/test/unit/sort_test.rb +36 -0
- data/test/unit/subscope_test.rb +44 -0
- data/test/unit/ttl_test.rb +40 -0
- data/test/unit/update_test.rb +42 -0
- metadata +121 -0
data/lib/ion/index.rb
ADDED
@@ -0,0 +1,56 @@
|
|
1
|
+
# An index
|
2
|
+
#
|
3
|
+
# You can subclass me by reimplementing #index, #deindex and #search.
|
4
|
+
#
|
5
|
+
class Ion::Index
|
6
|
+
attr_reader :name
|
7
|
+
attr_reader :options
|
8
|
+
|
9
|
+
def initialize(name, options, args={}, &blk)
|
10
|
+
@name = name
|
11
|
+
@options = options
|
12
|
+
@lambda = blk if block_given?
|
13
|
+
@lambda ||= Proc.new { self.send(name) }
|
14
|
+
end
|
15
|
+
|
16
|
+
# Indexes a record
|
17
|
+
def index(record)
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.deindex(record)
|
21
|
+
end
|
22
|
+
|
23
|
+
# Completely obliterates traces of a record from the indices
|
24
|
+
def self.del(record)
|
25
|
+
self.deindex record
|
26
|
+
references_key(record).del
|
27
|
+
end
|
28
|
+
|
29
|
+
# Returns a key (set) of results
|
30
|
+
def search(what, args={})
|
31
|
+
end
|
32
|
+
|
33
|
+
protected
|
34
|
+
|
35
|
+
# Returns the value for a certain record
|
36
|
+
def value_for(record)
|
37
|
+
record.instance_eval &@lambda
|
38
|
+
end
|
39
|
+
|
40
|
+
# Returns the index key
|
41
|
+
# Example: Ion:Album:text:title
|
42
|
+
def index_key
|
43
|
+
@type ||= self.class.name.split(':').last.downcase
|
44
|
+
@options.key[@type][self.name]
|
45
|
+
end
|
46
|
+
|
47
|
+
# Ion:Album:references:1:text
|
48
|
+
def self.references_key(record)
|
49
|
+
type = self.name.split(':').last.downcase
|
50
|
+
record.class.ion.key[:references][record.id][type]
|
51
|
+
end
|
52
|
+
|
53
|
+
def references_key(record)
|
54
|
+
self.class.references_key record
|
55
|
+
end
|
56
|
+
end
|
data/lib/ion/indices.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
module Ion
|
2
|
+
module Indices
|
3
|
+
autoload :Text, "#{Ion::PREFIX}/indices/text"
|
4
|
+
autoload :Number, "#{Ion::PREFIX}/indices/number"
|
5
|
+
autoload :Metaphone, "#{Ion::PREFIX}/indices/metaphone"
|
6
|
+
autoload :Sort, "#{Ion::PREFIX}/indices/sort"
|
7
|
+
|
8
|
+
def self.names
|
9
|
+
[ :text, :number, :metaphone, :sort ]
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.get(name)
|
13
|
+
name = Stringer.classify(name).to_sym
|
14
|
+
raise InvalidIndexType unless const_defined?(name)
|
15
|
+
const_get(name)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Ion
|
2
|
+
class Indices::Number < Indices::Text
|
3
|
+
MARGIN = 0.0001
|
4
|
+
|
5
|
+
def index(record)
|
6
|
+
value = value_for(record).to_f
|
7
|
+
refs = references_key(record)
|
8
|
+
|
9
|
+
index_key.zadd value, record.id
|
10
|
+
refs.sadd index_key
|
11
|
+
end
|
12
|
+
|
13
|
+
def search(what, args={})
|
14
|
+
key = Ion.volatile_key
|
15
|
+
key.zunionstore [index_key] #copy
|
16
|
+
|
17
|
+
if what.is_a?(Hash)
|
18
|
+
# Strip away the upper/lower limits.
|
19
|
+
key.zremrangebyscore '-inf', what[:gt] if what[:gt]
|
20
|
+
key.zremrangebyscore what[:lt], '+inf' if what[:lt]
|
21
|
+
key.zremrangebyscore '-inf', (what[:min].to_f-MARGIN) if what[:min]
|
22
|
+
key.zremrangebyscore (what[:max].to_f+MARGIN), '+inf' if what[:max]
|
23
|
+
else
|
24
|
+
key.zremrangebyscore '-inf', what.to_f-MARGIN
|
25
|
+
key.zremrangebyscore what.to_f+MARGIN, '+inf'
|
26
|
+
end
|
27
|
+
|
28
|
+
key
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module Ion
|
2
|
+
class Indices::Sort < Ion::Index
|
3
|
+
def index(record)
|
4
|
+
value = value_for(record)
|
5
|
+
value = transform(value)
|
6
|
+
|
7
|
+
key_for(record).hset name, value
|
8
|
+
end
|
9
|
+
|
10
|
+
# The function that the string passes thru before going to the db.
|
11
|
+
def transform(value)
|
12
|
+
str = value.to_s.downcase.strip
|
13
|
+
str = str[4..-1] if str[0..3] == "the " # Remove articles from sorting
|
14
|
+
str
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.deindex(record)
|
18
|
+
key_for(record).del
|
19
|
+
end
|
20
|
+
|
21
|
+
def spec
|
22
|
+
# Ion:sort:Album:*->title
|
23
|
+
@spec ||= self.class.key[@options.model.name]["*->#{name}"]
|
24
|
+
end
|
25
|
+
|
26
|
+
protected
|
27
|
+
def self.key_for(record)
|
28
|
+
# Ion:sort:Album:1
|
29
|
+
key[record.class.name][record.id]
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.key
|
33
|
+
# Ion:sort
|
34
|
+
Ion.key[:sort]
|
35
|
+
end
|
36
|
+
|
37
|
+
def key_for(record)
|
38
|
+
self.class.key_for(record)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module Ion
|
2
|
+
class Indices::Text < Index
|
3
|
+
def index_words(str)
|
4
|
+
Stringer.keywords str
|
5
|
+
end
|
6
|
+
|
7
|
+
def search_words(str)
|
8
|
+
index_words str
|
9
|
+
end
|
10
|
+
|
11
|
+
def index(record)
|
12
|
+
super
|
13
|
+
words = index_words(value_for(record).to_s)
|
14
|
+
refs = references_key(record)
|
15
|
+
|
16
|
+
words.each do |word|
|
17
|
+
k = index_key[word]
|
18
|
+
refs.sadd k
|
19
|
+
k.zadd 1, record.id
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.deindex(record)
|
24
|
+
super
|
25
|
+
refs = references_key(record)
|
26
|
+
refs.smembers.each do |key|
|
27
|
+
Ion.redis.zrem(key, record.id)
|
28
|
+
Ion.redis.del(key) if Ion.redis.zrange(key,0,0).nil?
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def search(what, args={})
|
33
|
+
super
|
34
|
+
words = search_words(what)
|
35
|
+
keys = words.map { |word| index_key[word] }
|
36
|
+
|
37
|
+
w = keys.map { 0 }; w[0] = 1
|
38
|
+
Ion.intersect keys, weights: w
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
data/lib/ion/options.rb
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
class Ion::Options
|
2
|
+
attr_reader :model
|
3
|
+
|
4
|
+
def initialize(model)
|
5
|
+
@model = model
|
6
|
+
@indices = Hash.new { |h, k| h[k] = Hash.new }
|
7
|
+
end
|
8
|
+
|
9
|
+
def search(&blk)
|
10
|
+
Ion::Search.new(self, &blk)
|
11
|
+
end
|
12
|
+
|
13
|
+
def key
|
14
|
+
@key ||= Ion.key[model.name] #=> 'Ion:Person'
|
15
|
+
end
|
16
|
+
|
17
|
+
# Returns a certain index.
|
18
|
+
# @example
|
19
|
+
# @options.index(:text, :title) #=> <#Ion::Indices::Text>
|
20
|
+
def index(type, name)
|
21
|
+
@indices[type][name]
|
22
|
+
end
|
23
|
+
|
24
|
+
# Returns all indices.
|
25
|
+
def indices
|
26
|
+
@indices.values.map(&:values).flatten
|
27
|
+
end
|
28
|
+
|
29
|
+
def index_types
|
30
|
+
indices.map(&:class).uniq
|
31
|
+
end
|
32
|
+
|
33
|
+
protected
|
34
|
+
# Creates the shortcuts `text :foo` => `field :text, :foo`
|
35
|
+
Ion::Indices.names.each do |type|
|
36
|
+
define_method(type) do |id, options={}, &blk|
|
37
|
+
field type, id, options, &blk
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def field(type, id, options={}, &blk)
|
42
|
+
index_type = Ion::Indices.get(type)
|
43
|
+
@indices[type][id.to_sym] = index_type.new(id, self, options, &blk)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
data/lib/ion/scope.rb
ADDED
@@ -0,0 +1,163 @@
|
|
1
|
+
class Ion::Scope
|
2
|
+
include Ion::Helpers
|
3
|
+
|
4
|
+
attr_writer :key
|
5
|
+
|
6
|
+
def initialize(search, args={}, &blk)
|
7
|
+
@search = search
|
8
|
+
@gate = args[:gate] || :all
|
9
|
+
@score = args[:score] || 1.0
|
10
|
+
@type = :z # or :l
|
11
|
+
|
12
|
+
yieldie(&blk) and done if block_given?
|
13
|
+
|
14
|
+
raise Ion::Error unless [:all, :any].include?(@gate)
|
15
|
+
end
|
16
|
+
|
17
|
+
# Returns a unique hash of what the scope contains.
|
18
|
+
def search_hash
|
19
|
+
@search_hash ||= [[@gate, @score]]
|
20
|
+
end
|
21
|
+
|
22
|
+
def any_of(&blk)
|
23
|
+
scopes << subscope(:gate => :any, &blk)
|
24
|
+
end
|
25
|
+
|
26
|
+
def all_of(&blk)
|
27
|
+
scopes << subscope(:gate => :all, &blk)
|
28
|
+
end
|
29
|
+
|
30
|
+
def boost(amount=1.0, &blk)
|
31
|
+
boosts << [Ion::Scope.new(@search, :gate => :all, &blk), amount]
|
32
|
+
end
|
33
|
+
|
34
|
+
def score(score, &blk)
|
35
|
+
scopes << subscope(:score => score, &blk)
|
36
|
+
end
|
37
|
+
|
38
|
+
def key
|
39
|
+
@key ||= Ion.volatile_key
|
40
|
+
end
|
41
|
+
|
42
|
+
def sort_by(what)
|
43
|
+
index = @search.options.index(:sort, what)
|
44
|
+
key.sort by: index.spec, order: "ASC ALPHA", store: key
|
45
|
+
end
|
46
|
+
|
47
|
+
# Only when done
|
48
|
+
def count
|
49
|
+
return key.zcard if key.type == "zset"
|
50
|
+
return key.llen if key.type == "list"
|
51
|
+
0
|
52
|
+
end
|
53
|
+
|
54
|
+
# Defines the shortcuts `text :foo 'xyz'` => `search :text, :foo, 'xyz'`
|
55
|
+
Ion::Indices.names.each do |type|
|
56
|
+
define_method(type) do |field, what, args={}|
|
57
|
+
search type, field, what, args
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# Searches a given field.
|
62
|
+
# @example
|
63
|
+
# class Album
|
64
|
+
# ion { text :name }
|
65
|
+
# end
|
66
|
+
#
|
67
|
+
# Album.ion.search {
|
68
|
+
# search :text, :name, "Emotional Technology"
|
69
|
+
# text :name, "Emotional Technology" # same
|
70
|
+
# }
|
71
|
+
def search(type, field, what, args={})
|
72
|
+
subkey = options.index(type, field).search(what, args)
|
73
|
+
temp_keys << subkey
|
74
|
+
subkeys << subkey
|
75
|
+
search_hash << [type,field,what,args]
|
76
|
+
end
|
77
|
+
|
78
|
+
def options
|
79
|
+
@search.options
|
80
|
+
end
|
81
|
+
|
82
|
+
def ids(range)
|
83
|
+
from, to = range.first, range.last
|
84
|
+
to -= 1 if range.exclude_end?
|
85
|
+
|
86
|
+
type = key.type
|
87
|
+
results = if type == "zset"
|
88
|
+
key.zrevrange(from, to)
|
89
|
+
elsif type == "list"
|
90
|
+
key.lrange(from, to)
|
91
|
+
else
|
92
|
+
Array.new
|
93
|
+
end
|
94
|
+
|
95
|
+
expire and results
|
96
|
+
end
|
97
|
+
|
98
|
+
protected
|
99
|
+
# List of scopes to be cleaned up after. (Array of Scope instances)
|
100
|
+
def scopes() @scopes ||= Array.new end
|
101
|
+
|
102
|
+
# List of keys that contain results to be combined.
|
103
|
+
def subkeys() @subkeys ||= Array.new end
|
104
|
+
|
105
|
+
# List of keys (like search keys) to be cleaned up after.
|
106
|
+
def temp_keys() @temp_keys ||= Array.new end
|
107
|
+
|
108
|
+
# List of boost scopes -- [Scope, amount] tuples
|
109
|
+
def boosts() @boosts ||= Array.new end
|
110
|
+
|
111
|
+
# Called by #run after doing an instance_eval of DSL stuff.
|
112
|
+
# This consolidates the keys into self.key.
|
113
|
+
def done
|
114
|
+
if subkeys.size == 1
|
115
|
+
self.key = subkeys.first
|
116
|
+
elsif subkeys.size > 1
|
117
|
+
combine subkeys
|
118
|
+
end
|
119
|
+
|
120
|
+
# Adjust scores accordingly
|
121
|
+
self.key = rescore(key, @score)
|
122
|
+
|
123
|
+
boost_keys = boosts.map do |(scope, amount)|
|
124
|
+
inter = Ion.volatile_key
|
125
|
+
inter.zinterstore([key, scope.key], :weights => [0, amount])
|
126
|
+
temp_keys << inter
|
127
|
+
inter
|
128
|
+
end
|
129
|
+
|
130
|
+
key.zunionstore [key, *boost_keys]
|
131
|
+
end
|
132
|
+
|
133
|
+
def rescore(key, score)
|
134
|
+
return key if score == 1.0
|
135
|
+
dest = key.include?('~') ? key : Ion.volatile_key
|
136
|
+
dest.zunionstore([key], weights: score)
|
137
|
+
dest
|
138
|
+
end
|
139
|
+
|
140
|
+
# Sets the TTL for the temp keys.
|
141
|
+
def expire
|
142
|
+
scopes.each { |s| s.send :expire }
|
143
|
+
Ion.expire key, *temp_keys
|
144
|
+
end
|
145
|
+
|
146
|
+
def combine(subkeys)
|
147
|
+
if @gate == :all
|
148
|
+
key.zinterstore subkeys
|
149
|
+
elsif @gate == :any
|
150
|
+
key.zunionstore subkeys
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
# Used by all_of and any_of
|
155
|
+
def subscope(args={}, &blk)
|
156
|
+
opts = { :gate => @gate, :score => @score }
|
157
|
+
scope = Ion::Scope.new(@search, opts.merge(args), &blk)
|
158
|
+
|
159
|
+
subkeys << scope.key
|
160
|
+
search_hash << scope.search_hash
|
161
|
+
scope
|
162
|
+
end
|
163
|
+
end
|
data/lib/ion/search.rb
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
class Ion::Search
|
2
|
+
include Enumerable
|
3
|
+
|
4
|
+
attr_reader :options
|
5
|
+
attr_reader :scope
|
6
|
+
|
7
|
+
def initialize(options, &blk)
|
8
|
+
@options = options
|
9
|
+
@scope = Ion::Scope.new(self, &blk)
|
10
|
+
end
|
11
|
+
|
12
|
+
# Returns the model.
|
13
|
+
# @example
|
14
|
+
#
|
15
|
+
# search = Album.ion.search { ... }
|
16
|
+
# assert search.model == Album
|
17
|
+
#
|
18
|
+
def model
|
19
|
+
@options.model
|
20
|
+
end
|
21
|
+
|
22
|
+
# Returns a unique hash for the search.
|
23
|
+
def search_hash
|
24
|
+
require 'digest'
|
25
|
+
Digest::MD5.hexdigest @scope.search_hash.inspect
|
26
|
+
end
|
27
|
+
|
28
|
+
def range(args=nil)
|
29
|
+
@range = if args == :all
|
30
|
+
nil
|
31
|
+
elsif args.is_a?(Range)
|
32
|
+
args
|
33
|
+
elsif !args.is_a?(Hash)
|
34
|
+
@range
|
35
|
+
elsif args[:from] && args[:limit]
|
36
|
+
((args[:from]-1)..(args[:from]-1 + args[:limit]-1))
|
37
|
+
elsif args[:page] && args[:limit]
|
38
|
+
(((args[:page]-1)*args[:limit])..((args[:page])*args[:limit]))
|
39
|
+
elsif args[:from] && args[:to]
|
40
|
+
((args[:from]-1)..(args[:to]-1))
|
41
|
+
elsif args[:from]
|
42
|
+
((args[:from]-1)..-1)
|
43
|
+
else
|
44
|
+
@range
|
45
|
+
end || (0..-1)
|
46
|
+
end
|
47
|
+
|
48
|
+
def to_a
|
49
|
+
ids.map &model
|
50
|
+
end
|
51
|
+
|
52
|
+
def each(&blk)
|
53
|
+
to_a.each &blk
|
54
|
+
end
|
55
|
+
|
56
|
+
def size
|
57
|
+
@scope.count
|
58
|
+
end
|
59
|
+
|
60
|
+
def yieldie(&blk)
|
61
|
+
@scope.yieldie &blk
|
62
|
+
end
|
63
|
+
|
64
|
+
def sort_by(what)
|
65
|
+
@scope.sort_by what
|
66
|
+
end
|
67
|
+
|
68
|
+
def ids
|
69
|
+
@scope.ids range
|
70
|
+
end
|
71
|
+
|
72
|
+
def key
|
73
|
+
@scope.key
|
74
|
+
end
|
75
|
+
|
76
|
+
# Searching
|
77
|
+
|
78
|
+
protected
|
79
|
+
# Interal: called when the `Model.ion.search { }` block is done
|
80
|
+
def done
|
81
|
+
@scope.send :done
|
82
|
+
end
|
83
|
+
end
|