ion 0.0.1
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.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
|