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/README.md
ADDED
@@ -0,0 +1,280 @@
|
|
1
|
+
Ion
|
2
|
+
===
|
3
|
+
|
4
|
+
A search engine written in Ruby and uses Redis.
|
5
|
+
|
6
|
+
Ion is under a state merciless refactoring until it reaches a
|
7
|
+
useable feature set--use at your own risk :)
|
8
|
+
|
9
|
+
Testing
|
10
|
+
-------
|
11
|
+
|
12
|
+
rvm 1.9.2-p136@ion --rvmrc --create
|
13
|
+
redis-server
|
14
|
+
rvm gemset import # or install gems in .gems
|
15
|
+
|
16
|
+
export REDIS_URL=redis://127.0.0.1:6379/0 # optional, this is the default
|
17
|
+
rake test
|
18
|
+
|
19
|
+
Usage
|
20
|
+
-----
|
21
|
+
|
22
|
+
Ion needs Redis.
|
23
|
+
|
24
|
+
require 'ion'
|
25
|
+
Ion.connect url: 'redis://127.0.0.1:6379/0'
|
26
|
+
|
27
|
+
Any ORM will do. As long as you can hook it to update Ion's indices, you'll be fine.
|
28
|
+
|
29
|
+
class Album < Ohm::Model
|
30
|
+
include Ion::Entity
|
31
|
+
include Ohm::Callbacks # for `after` and `before`
|
32
|
+
|
33
|
+
# Say you have these fields
|
34
|
+
attribute :name
|
35
|
+
attribute :artist
|
36
|
+
|
37
|
+
# Set it up to be indexed
|
38
|
+
ion {
|
39
|
+
text :name
|
40
|
+
metaphone :artist
|
41
|
+
}
|
42
|
+
|
43
|
+
# Just call these after saving/deleting
|
44
|
+
after :save, :update_ion_indices
|
45
|
+
before :delete, :delete_ion_indices
|
46
|
+
end
|
47
|
+
|
48
|
+
Searching is easy:
|
49
|
+
|
50
|
+
results = Album.ion.search {
|
51
|
+
text :name, "Dancing Galaxy"
|
52
|
+
}
|
53
|
+
|
54
|
+
results = Album.ion.search {
|
55
|
+
metaphone :artist, "Astral Projection"
|
56
|
+
}
|
57
|
+
|
58
|
+
The results will be an `Enumerable` object. Go ahead and iterate as you normally would.
|
59
|
+
|
60
|
+
results.each do |album|
|
61
|
+
puts "Album '#{album.name}' (by #{album.artist})"
|
62
|
+
end
|
63
|
+
|
64
|
+
You can also get the raw results easily.
|
65
|
+
|
66
|
+
results.to_a #=> [<#Album>, <#Album>, ... ]
|
67
|
+
results.ids #=> ["1", "2", "10", ... ]
|
68
|
+
|
69
|
+
Features
|
70
|
+
--------
|
71
|
+
|
72
|
+
### Custom indexing functions
|
73
|
+
|
74
|
+
class Book < Ohm::Model
|
75
|
+
attribute :name
|
76
|
+
attribute :synopsis
|
77
|
+
reference :author, Person
|
78
|
+
|
79
|
+
ion {
|
80
|
+
text(:author) { author.name } # Supply your own indexing function
|
81
|
+
}
|
82
|
+
end
|
83
|
+
|
84
|
+
Book.ion.search { text :author, "Patrick Suskind" }
|
85
|
+
|
86
|
+
### Nested conditions
|
87
|
+
|
88
|
+
By default, doing a `.search { ... }` does an `all_of` search (that is,
|
89
|
+
it must match all the given rules). You can use `any_of` and `all_of`, and
|
90
|
+
you may even nest them.
|
91
|
+
|
92
|
+
Book.ion.search {
|
93
|
+
all_of {
|
94
|
+
text :name, "perfume the story of a murderer"
|
95
|
+
text :synopsis, "base note"
|
96
|
+
any_of {
|
97
|
+
text :tags, "fiction"
|
98
|
+
text :tags, "thriller"
|
99
|
+
}
|
100
|
+
}
|
101
|
+
}
|
102
|
+
|
103
|
+
### Important rules
|
104
|
+
|
105
|
+
You can make certain rules score higher than the rest. In this example,
|
106
|
+
if the search string is found in the name, it'll rank higher than if it
|
107
|
+
was found in the synopsis.
|
108
|
+
|
109
|
+
Book.ion.search {
|
110
|
+
any_of {
|
111
|
+
score(5.0) { text :name, "Darkly Dreaming Dexter" }
|
112
|
+
score(1.0) { text :synopsis, "Darkly Dreaming Dexter" }
|
113
|
+
}
|
114
|
+
}
|
115
|
+
|
116
|
+
### Boosting
|
117
|
+
|
118
|
+
You can define rules on what will rank higher.
|
119
|
+
|
120
|
+
This is different from `score` (above) in such that it only boosts current
|
121
|
+
results, and doesn't add any. For instance, below, it will not show all
|
122
|
+
"sale" items, but will make any sale items in the current result set
|
123
|
+
rank higher.
|
124
|
+
|
125
|
+
Book.ion.search {
|
126
|
+
text :name, "The Taking of Sleeping Beauty"
|
127
|
+
boost(2.0) { text :tags, "sale" }
|
128
|
+
}
|
129
|
+
|
130
|
+
(Note: it will add +2.0, not multiply by 2.0. Also, the number is optional. This behavior may change in the future)
|
131
|
+
|
132
|
+
### Metaphones
|
133
|
+
|
134
|
+
Indexing via metaphones allows you to search by how something sounds like,
|
135
|
+
rather than with exact spellings.
|
136
|
+
|
137
|
+
class Person < Ohm::Model
|
138
|
+
attribute :name
|
139
|
+
|
140
|
+
ion {
|
141
|
+
metaphone :name
|
142
|
+
}
|
143
|
+
end
|
144
|
+
|
145
|
+
Person.create name: "Stephane Michael Cook"
|
146
|
+
|
147
|
+
# Any of these will work
|
148
|
+
Person.ion.search { metaphone :name, 'stiefen michel cooke' }
|
149
|
+
Person.ion.search { metaphone :name, 'steven quoc' }
|
150
|
+
|
151
|
+
### Ranges
|
152
|
+
|
153
|
+
Limit your searches like so:
|
154
|
+
|
155
|
+
results = Book.ion.search {
|
156
|
+
text :author, "Anne Rice"
|
157
|
+
}
|
158
|
+
|
159
|
+
# Any of these will work.
|
160
|
+
results.range from: 54, limit: 10
|
161
|
+
results.range from: 3
|
162
|
+
results.range page: 1, limit: 30
|
163
|
+
results.range (0..3)
|
164
|
+
results.range (0..-1)
|
165
|
+
results.range from: 3, to: 9
|
166
|
+
|
167
|
+
results.size # This will not change even if you change the range...
|
168
|
+
results.ids.size # However, this will.
|
169
|
+
|
170
|
+
# Reset
|
171
|
+
results.range :all
|
172
|
+
|
173
|
+
### Number indices
|
174
|
+
|
175
|
+
class Recipe < Ohm::Model
|
176
|
+
attribute :serving_size
|
177
|
+
|
178
|
+
ion {
|
179
|
+
number :serving_size # Define a number index
|
180
|
+
}
|
181
|
+
end
|
182
|
+
|
183
|
+
Recipe.ion.search { number :serving_size, 1 } # n == 1
|
184
|
+
Recipe.ion.search { number :serving_size, gt:1 } # n > 1
|
185
|
+
Recipe.ion.search { number :serving_size, gt:2, lt:5 } # 2 < n < 5
|
186
|
+
Recipe.ion.search { number :serving_size, min: 4 } # n >= 4
|
187
|
+
Recipe.ion.search { number :serving_size, max: 10 } # n <= 10
|
188
|
+
|
189
|
+
### Sorting
|
190
|
+
|
191
|
+
First, define a sort index in your model.
|
192
|
+
|
193
|
+
class Element < Ohm::Model
|
194
|
+
attribute :name
|
195
|
+
attribute :protons
|
196
|
+
attribute :electrons
|
197
|
+
|
198
|
+
ion {
|
199
|
+
sort :name # <-- like this
|
200
|
+
number :protons
|
201
|
+
}
|
202
|
+
end
|
203
|
+
|
204
|
+
Now sort it like so. This will not take the search relevancy scores
|
205
|
+
into account.
|
206
|
+
|
207
|
+
results = Element.ion.search { number :protons, gt: 3.5 }
|
208
|
+
results.sort_by :name
|
209
|
+
|
210
|
+
Note that this sorting (unlike in Ohm, et al) is case insensitive,
|
211
|
+
and takes English articles into account (eg, "The Beatles" will
|
212
|
+
come before "Rolling Stones").
|
213
|
+
|
214
|
+
Extending Ion
|
215
|
+
-------------
|
216
|
+
|
217
|
+
Override it with some fancy stuff.
|
218
|
+
|
219
|
+
class Ion::Search
|
220
|
+
def to_ohm
|
221
|
+
set_key = model.key['~']['mysearch']
|
222
|
+
ids.each { |id| set_key.sadd id }
|
223
|
+
Ohm::Set.new(set_key, model)
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
set = Album.ion.search { ... }.to_ohm
|
228
|
+
|
229
|
+
Or extend the DSL
|
230
|
+
|
231
|
+
class Ion::Scope
|
232
|
+
def keywords(what)
|
233
|
+
any_of {
|
234
|
+
text :title, what
|
235
|
+
metaphone :artist, what
|
236
|
+
}
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
Album.ion.search { keywords "Foo" }
|
241
|
+
|
242
|
+
Features in the works
|
243
|
+
---------------------
|
244
|
+
|
245
|
+
Stuff that's not implemented yet, but will be.
|
246
|
+
|
247
|
+
# TODO: search keyword blacklist
|
248
|
+
Ion.config.ignored_words += %w(at it the)
|
249
|
+
|
250
|
+
Item.ion.search { # TODO: Quoted searching
|
251
|
+
text :title, 'apple "MacBook Pro"'
|
252
|
+
}
|
253
|
+
|
254
|
+
results = Item.ion.search {
|
255
|
+
text :title, "Macbook"
|
256
|
+
exclude { # TODO: exclusions
|
257
|
+
text :title, "Case"
|
258
|
+
}
|
259
|
+
}
|
260
|
+
|
261
|
+
results.sort_by :name, order: :desc # TODO: descending sort
|
262
|
+
|
263
|
+
results.facet_counts #=> { :name => { "Ape" => 2, "Banana" => 3 } } ??
|
264
|
+
|
265
|
+
Quirks
|
266
|
+
------
|
267
|
+
|
268
|
+
### Searching with arity
|
269
|
+
|
270
|
+
The search DSL may leave some things in accessible since the block will
|
271
|
+
be ran through `instance_eval` in another context. You can get around it
|
272
|
+
via:
|
273
|
+
|
274
|
+
Book.ion.search { text :name, @name } # fail
|
275
|
+
Book.ion.search { |q| q.text :name, @name } # good
|
276
|
+
|
277
|
+
Or you may also take advantage of Ruby closures:
|
278
|
+
|
279
|
+
name = @name
|
280
|
+
Book.ion.search { text :name, name } # good
|
data/Rakefile
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
task :test do
|
2
|
+
$:.unshift File.expand_path(File.join(File.dirname(__FILE__), 'test'))
|
3
|
+
|
4
|
+
Dir['test/**/*_test.rb'].each do |file|
|
5
|
+
load file unless file =~ /^-/
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
task :irb do
|
10
|
+
system 'irb -r./lib/ion.rb -r./test/irb_helpers'
|
11
|
+
end
|
12
|
+
|
13
|
+
task :default => :test
|
data/lib/ion.rb
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
require 'redis'
|
2
|
+
require 'nest'
|
3
|
+
require 'text'
|
4
|
+
require 'ostruct'
|
5
|
+
|
6
|
+
module Ion
|
7
|
+
VERSION = "0.0.1"
|
8
|
+
|
9
|
+
PREFIX = File.join(File.dirname(__FILE__), 'ion')
|
10
|
+
|
11
|
+
# How long until search keys expire.
|
12
|
+
DEFAULT_TTL = 30
|
13
|
+
|
14
|
+
autoload :Stringer, "#{PREFIX}/stringer"
|
15
|
+
autoload :Config, "#{PREFIX}/config"
|
16
|
+
autoload :Options, "#{PREFIX}/options"
|
17
|
+
autoload :Search, "#{PREFIX}/search"
|
18
|
+
autoload :Entity, "#{PREFIX}/entity"
|
19
|
+
autoload :Index, "#{PREFIX}/index"
|
20
|
+
autoload :Indices, "#{PREFIX}/indices"
|
21
|
+
autoload :Scope, "#{PREFIX}/scope"
|
22
|
+
autoload :Helpers, "#{PREFIX}/helpers"
|
23
|
+
|
24
|
+
InvalidIndexType = Class.new(StandardError)
|
25
|
+
Error = Class.new(StandardError)
|
26
|
+
|
27
|
+
# Returns the Redis instance that is being used by Ion.
|
28
|
+
def self.config
|
29
|
+
@config ||= Ion::Config.new
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.version
|
33
|
+
VERSION
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.redis
|
37
|
+
@redis || key.redis
|
38
|
+
end
|
39
|
+
|
40
|
+
# Connects to a certain Redis server.
|
41
|
+
def self.connect(to)
|
42
|
+
@redis = Redis.connect(to)
|
43
|
+
end
|
44
|
+
|
45
|
+
# Returns the root key.
|
46
|
+
def self.key
|
47
|
+
@key ||= if @redis
|
48
|
+
Nest.new('Ion', @redis)
|
49
|
+
else
|
50
|
+
Nest.new('Ion')
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Returns a new temporary key.
|
55
|
+
def self.volatile_key
|
56
|
+
key['~'][rand.to_s]
|
57
|
+
end
|
58
|
+
|
59
|
+
# Makes a certain volatile key expire.
|
60
|
+
def self.expire(*keys)
|
61
|
+
keys.each { |k| redis.expire(k, DEFAULT_TTL) if k.include?('~') }
|
62
|
+
end
|
63
|
+
|
64
|
+
# Redis helper stuff
|
65
|
+
# Probably best to move this somewhere
|
66
|
+
|
67
|
+
# Combines multiple set keys.
|
68
|
+
def self.union(keys, options={})
|
69
|
+
return keys.first if keys.size == 1
|
70
|
+
|
71
|
+
results = Ion.volatile_key
|
72
|
+
results.zunionstore keys, options
|
73
|
+
results
|
74
|
+
end
|
75
|
+
|
76
|
+
# Finds the intersection in multiple set keys.
|
77
|
+
def self.intersect(keys, options={})
|
78
|
+
return keys.first if keys.size == 1
|
79
|
+
|
80
|
+
results = Ion.volatile_key
|
81
|
+
results.zinterstore keys, options
|
82
|
+
results
|
83
|
+
end
|
84
|
+
end
|
data/lib/ion/config.rb
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
class Ion::Config < OpenStruct
|
2
|
+
def initialize(args={})
|
3
|
+
super defaults.merge(args)
|
4
|
+
end
|
5
|
+
|
6
|
+
def defaults
|
7
|
+
@defaults ||= {
|
8
|
+
:ignored_words => %w(a it at the)
|
9
|
+
}
|
10
|
+
end
|
11
|
+
|
12
|
+
def method_missing(meth, *args, &blk)
|
13
|
+
return @table.keys.include?(meth[0...-1].to_sym) if meth.to_s[-1] == '?'
|
14
|
+
super
|
15
|
+
end
|
16
|
+
end
|
data/lib/ion/entity.rb
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
module Ion::Entity
|
2
|
+
def self.included(to)
|
3
|
+
to.extend ClassMethods
|
4
|
+
end
|
5
|
+
|
6
|
+
# Call me after saving
|
7
|
+
def update_ion_indices
|
8
|
+
ion = self.class.ion
|
9
|
+
|
10
|
+
# Clear out previous indexes...
|
11
|
+
ion.index_types.each { |i_type| i_type.deindex(self) }
|
12
|
+
|
13
|
+
# And add new ones
|
14
|
+
ion.indices.each { |index| index.index(self) }
|
15
|
+
end
|
16
|
+
|
17
|
+
# Call me before deletion
|
18
|
+
def delete_ion_indices
|
19
|
+
ion = self.class.ion
|
20
|
+
ion.index_types.each { |i_type| i_type.del(self) }
|
21
|
+
end
|
22
|
+
|
23
|
+
module ClassMethods
|
24
|
+
# Sets up Ion indexing for a model.
|
25
|
+
#
|
26
|
+
# When no block is given, it returns the Ion::Options
|
27
|
+
# for the model.
|
28
|
+
#
|
29
|
+
# @example
|
30
|
+
#
|
31
|
+
# class Artist < Model
|
32
|
+
# include Ion::Entity
|
33
|
+
# ion {
|
34
|
+
# text :name
|
35
|
+
# text :real_name
|
36
|
+
# }
|
37
|
+
# end
|
38
|
+
#
|
39
|
+
# Artist.ion.indices
|
40
|
+
# Artist.ion.search { ... }
|
41
|
+
#
|
42
|
+
def ion(&blk)
|
43
|
+
@ion_options ||= Ion::Options.new(self)
|
44
|
+
@ion_options.instance_eval(&blk) if block_given?
|
45
|
+
@ion_options
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
data/lib/ion/helpers.rb
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
module Ion::Helpers
|
2
|
+
# Replacement for instance_eval for DSL stuff
|
3
|
+
# @example
|
4
|
+
#
|
5
|
+
# yieldie(search) { |q| q.text :title, "hi" }
|
6
|
+
# yieldie(search) { text :title, "hi" }
|
7
|
+
#
|
8
|
+
def yieldie(to=self, &blk)
|
9
|
+
(blk.arity > 0) ? yield(to) : to.instance_eval(&blk)
|
10
|
+
end
|
11
|
+
end
|