ion 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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