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.
@@ -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