├── .gems ├── .gitignore ├── Gemfile ├── HISTORY.md ├── README.md ├── Rakefile ├── TODO.md ├── ion.gemspec ├── lib ├── ion.rb └── ion │ ├── client.rb │ ├── config.rb │ ├── entity.rb │ ├── extras │ ├── activerecord.rb │ └── sequel.rb │ ├── helpers.rb │ ├── index.rb │ ├── indices.rb │ ├── indices │ ├── boolean.rb │ ├── metaphone.rb │ ├── number.rb │ ├── sort.rb │ └── text.rb │ ├── options.rb │ ├── scope.rb │ ├── scope │ └── hash.rb │ ├── search.rb │ ├── stringer.rb │ ├── version.rb │ └── wrapper.rb └── test ├── benchmark ├── index.rb ├── search.rb └── spawn.rb ├── benchmark_helper.rb ├── benchmark_notes.txt ├── common_helper.rb ├── irb_helpers.rb ├── p_helper.rb ├── redis_debug.rb ├── test_helper.rb └── unit ├── boolean_test.rb ├── boost_test.rb ├── config_test.rb ├── hash_test.rb ├── ion_test.rb ├── metaphone_test.rb ├── number_test.rb ├── options_test.rb ├── range_test.rb ├── score_test.rb ├── sort_test.rb ├── stopwords_test.rb ├── subscope_test.rb ├── ttl_test.rb ├── update_test.rb └── wrapper_test.rb /.gems: -------------------------------------------------------------------------------- 1 | # App 2 | redis -v2.1.1 3 | text -v0.2.0 4 | nest -v1.1.0 5 | rest-client -v1.6.1 6 | 7 | # Test 8 | contest -v0.1.2 9 | ohm -v0.1.3 10 | ohm-contrib -v0.1.1 11 | batch -v0.0.3 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | .rvmrc 3 | pkg/ 4 | db/ 5 | vendor/ 6 | /.bundle/ 7 | /.yardoc 8 | /Gemfile.lock 9 | /_yardoc/ 10 | /coverage/ 11 | /doc/ 12 | /pkg/ 13 | /spec/reports/ 14 | /tmp/ 15 | *.bundle 16 | *.so 17 | *.o 18 | *.a 19 | mkmf.log 20 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in *.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | v0.0.2 2 | ------ 3 | 4 | Most of these changes are in preparation for a client/server architecture. 5 | 6 | * Make text and metaphone indices handle Unicode strings better. 7 | 8 | * Add speed benchmarks. 9 | 10 | * Defer actual searching until results are needed. 11 | 12 | * Implement (de)serialization of Searches and Options to hashes. 13 | 14 | * Implement `Search#to_hash`, (eg, `Album.search { .. }.to_hash`) 15 | and `#inspect`. 16 | 17 | * Implement `Options#to_hash`. (eg, `Album.ion.to_hash`) 18 | 19 | * Implement searching by a serialized hash. (eg, `Album.ion.search(hash)`) 20 | 21 | * Implement setting options by hash. (eg, `Album.ion(hash)` or `class Album; ion hash; end`) 22 | 23 | * Implement an `Ion::Wrapper` class so that your app does not need to be 24 | loaded to perform searches. 25 | 26 | * Implement numeric indices. (`ion { number :tracks_count }`) 27 | 28 | * Numeric indices support arrays. (`def tracks_count() [1, 3, 5] end`) 29 | 30 | * Implement boolean indices. (`ion { boolean :available }`) 31 | 32 | * Add preliminary support for Rails. (`require 'ion/extras/activerecord'` + `acts_as_ion_indexable`) 33 | 34 | * Add preliminary support for Sequel. (`require 'ion/extras/sequel'` + `Model.plugin :ion_indexable`) 35 | 36 | v0.0.1 -- Feb 13, 2011 37 | ---------------------- 38 | 39 | * Initial release. 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Gem Version](https://img.shields.io/gem/v/ion.svg?style=flat)](https://rubygems.org/gems/ion) 2 | 3 | Ion 4 | === 5 | 6 | #### A search engine written in Ruby and uses Redis. 7 | 8 | Ion is under a state merciless refactoring until it reaches a 9 | useable feature set--use at your own risk :) 10 | 11 | Usage 12 | ----- 13 | 14 | Ion needs Redis. 15 | 16 | ```ruby 17 | require 'ion' 18 | Ion.connect url: 'redis://127.0.0.1:6379/0' 19 | ``` 20 | 21 | Any ORM will do. As long as you can hook it to update Ion's indices, you'll be fine. 22 | 23 | ```ruby 24 | require 'ohm/contrib' 25 | 26 | class Album < Ohm::Model 27 | include Ion::Entity 28 | include Ohm::Callbacks # for `after` and `before`, part of gem 'ohm-contrib' 29 | 30 | # Say you have these fields 31 | attribute :name 32 | attribute :artist 33 | 34 | # Set it up to be indexed 35 | ion { 36 | text :name 37 | metaphone :artist 38 | } 39 | 40 | # Just call these after saving/deleting 41 | def after_save 42 | update_ion_indices 43 | end 44 | 45 | def after_delete 46 | delete_ion_indices 47 | end 48 | end 49 | ``` 50 | 51 | Searching is easy: 52 | 53 | ```ruby 54 | results = Album.ion.search { 55 | text :name, "Dancing Galaxy" 56 | } 57 | 58 | results = Album.ion.search { 59 | metaphone :artist, "Astral Projection" 60 | } 61 | ``` 62 | 63 | The results will be an `Enumerable` object. Go ahead and iterate as you normally would. 64 | 65 | ```ruby 66 | results.each do |album| 67 | puts "Album '#{album.name}' (by #{album.artist})" 68 | end 69 | ``` 70 | 71 | You can also get the raw results easily. 72 | 73 | ```ruby 74 | results.to_a #=> [<#Album>, <#Album>, ... ] 75 | results.ids #=> ["1", "2", "10", ... ] 76 | ``` 77 | 78 | Features 79 | -------- 80 | 81 | ### Custom indexing functions 82 | 83 | ```ruby 84 | class Book < Ohm::Model 85 | attribute :name 86 | attribute :synopsis 87 | reference :author, Person 88 | 89 | ion { 90 | text(:author) { author.name } # Supply your own indexing function 91 | } 92 | end 93 | 94 | Book.ion.search { text :author, "Patrick Suskind" } 95 | ``` 96 | 97 | ### Nested conditions 98 | 99 | By default, doing a `.search { ... }` does an `all_of` search (that is, 100 | it must match all the given rules). You can use `any_of` and `all_of`, and 101 | you may even nest them. 102 | 103 | ```ruby 104 | Book.ion.search { 105 | all_of { 106 | text :name, "perfume the story of a murderer" 107 | text :synopsis, "base note" 108 | any_of { 109 | text :tags, "fiction" 110 | text :tags, "thriller" 111 | } 112 | } 113 | } 114 | ``` 115 | 116 | ### Important rules 117 | 118 | You can make certain rules score higher than the rest. In this example, 119 | if the search string is found in the name, it'll rank higher than if it 120 | was found in the synopsis. 121 | 122 | ```ruby 123 | Book.ion.search { 124 | any_of { 125 | score(5.0) { text :name, "Darkly Dreaming Dexter" } 126 | score(1.0) { text :synopsis, "Darkly Dreaming Dexter" } 127 | } 128 | } 129 | ``` 130 | 131 | ### Boosting 132 | 133 | You can define rules on what will rank higher. 134 | 135 | This is different from `score` (above) in such that it only boosts current 136 | results, and doesn't add any. For instance, below, it will not show all 137 | "sale" items, but will make any sale items in the current result set 138 | rank higher. 139 | 140 | This example will boost the score of sale items by x2.0. 141 | 142 | ```ruby 143 | Book.ion.search { 144 | text :name, "The Taking of Sleeping Beauty" 145 | boost(2.0) { text :tags, "sale" } 146 | } 147 | ``` 148 | 149 | ### Metaphones 150 | 151 | Indexing via metaphones allows you to search by how something sounds like, 152 | rather than with exact spellings. 153 | 154 | ```ruby 155 | class Person < Ohm::Model 156 | attribute :name 157 | 158 | ion { 159 | metaphone :name 160 | } 161 | end 162 | 163 | Person.create name: "Stephane Michael Cook" 164 | 165 | # Any of these will work 166 | Person.ion.search { metaphone :name, 'stiefen michel cooke' } 167 | Person.ion.search { metaphone :name, 'steven quoc' } 168 | ``` 169 | 170 | ### Ranges 171 | 172 | Limit your searches like so: 173 | 174 | ```ruby 175 | results = Book.ion.search { 176 | text :author, "Anne Rice" 177 | } 178 | 179 | # Any of these will work. 180 | results.range from: 54, limit: 10 181 | results.range from: 3 182 | results.range page: 1, limit: 30 183 | results.range (0..3) 184 | results.range (0..-1) 185 | results.range from: 3, to: 9 186 | 187 | results.size # This will not change even if you change the range... 188 | results.ids.size # However, this will. 189 | 190 | # Reset 191 | results.range :all 192 | ``` 193 | 194 | ### Numeric and boolean indices 195 | 196 | ```ruby 197 | class Recipe < Ohm::Model 198 | attribute :serving_size 199 | attribute :kosher 200 | attribute :name 201 | 202 | ion { 203 | number :serving_size # Define a number index 204 | boolean :kosher 205 | } 206 | end 207 | 208 | Recipe.ion.search { boolean :kosher, true } 209 | 210 | Recipe.ion.search { number :serving_size, 1 } # n == 1 211 | Recipe.ion.search { number :serving_size, gt:1 } # n > 1 212 | Recipe.ion.search { number :serving_size, gt:2, lt:5 } # 2 < n < 5 213 | Recipe.ion.search { number :serving_size, min: 4 } # n >= 4 214 | Recipe.ion.search { number :serving_size, max: 10 } # n <= 10 215 | ``` 216 | 217 | Boolean indexing is a bit forgiving. You can pass it a string 218 | and it will try to guess what it means. 219 | 220 | ```ruby 221 | a = Recipe.create kosher: true 222 | b = Recipe.create kosher: 'false' 223 | c = Recipe.create kosher: false 224 | d = Recipe.create kosher: 1 225 | e = Recipe.create kosher: 0 226 | 227 | Recipe.ion.search { boolean :kosher, true } # Returns a and d 228 | ``` 229 | 230 | ### Sorting 231 | 232 | First, define a sort index in your model. 233 | 234 | ```ruby 235 | class Element < Ohm::Model 236 | attribute :name 237 | attribute :protons 238 | attribute :electrons 239 | 240 | ion { 241 | sort :name # <-- like this 242 | number :protons 243 | } 244 | end 245 | ``` 246 | 247 | Now sort it like so. This will not take the search relevancy scores 248 | into account. 249 | 250 | ```ruby 251 | results = Element.ion.search { number :protons, gt: 3.5 } 252 | results.sort_by :name 253 | ``` 254 | 255 | Note that this sorting (unlike in Ohm, et al) is case insensitive, 256 | and takes English articles into account (eg, "The Beatles" will 257 | come before "Rolling Stones"). 258 | 259 | Stopwords 260 | --------- 261 | 262 | Anything in `Ion.config.stopwords` will be ignored. It currently 263 | has a bunch of default English stopwords (a, it, the, etc) 264 | 265 | ```ruby 266 | # Configure it to use Polish stopwords 267 | Ion.config.stopwords = %w(a aby ach acz aczkolwiek aj tej z) # and so on 268 | 269 | # Same as searching for 'slow' 270 | Album.ion.search { text :title, "slow z tej" } 271 | ``` 272 | 273 | Extending Ion 274 | ------------- 275 | 276 | Override it with some fancy stuff. 277 | 278 | ```ruby 279 | class Ion::Search 280 | def to_ohm 281 | set_key = model.key['~']['mysearch'] 282 | ids.each { |id| set_key.sadd id } 283 | Ohm::Set.new(set_key, model) 284 | end 285 | end 286 | 287 | set = Album.ion.search { ... }.to_ohm 288 | ``` 289 | 290 | Or extend the DSL 291 | 292 | ```ruby 293 | class Ion::Scope 294 | def keywords(what) 295 | any_of { 296 | text :title, what 297 | metaphone :artist, what 298 | } 299 | end 300 | end 301 | 302 | Album.ion.search { keywords "Foo" } 303 | ``` 304 | 305 | Features in the works 306 | --------------------- 307 | 308 | A RESTful [ion-server](http://github.com/rstacruz/ion-server) is under heavy development. 309 | 310 | ```ruby 311 | # An Ion server instance 312 | Ion.connect ion: 'http://127.0.0.1:8082' 313 | 314 | # This will be done on the server 315 | Album.ion.search { ... } 316 | ``` 317 | 318 | Better support for European languages by transforming special characters. (`ü` => `ue`) 319 | 320 | Other stuff that's not implemented yet, but will be: 321 | 322 | ```ruby 323 | Item.ion.search { # TODO: Quoted searching 324 | text :title, 'apple "MacBook Pro"' 325 | } 326 | 327 | results = Item.ion.search { 328 | text :title, "Macbook" 329 | exclude { # TODO: exclusions 330 | text :title, "Case" 331 | } 332 | } 333 | 334 | results.sort_by :name, order: :desc # TODO: descending sort 335 | 336 | results.facet_counts #=> { :name => { "Ape" => 2, "Banana" => 3 } } ?? 337 | ``` 338 | 339 | Quirks 340 | ------ 341 | 342 | ### Searching with arity 343 | 344 | The search DSL may leave some things in accessible since the block will 345 | be ran through `instance_eval` in another context. You can get around it 346 | via: 347 | 348 | ```ruby 349 | Book.ion.search { text :name, @name } # fail 350 | Book.ion.search { |q| q.text :name, @name } # good 351 | ``` 352 | 353 | Or you may also take advantage of Ruby closures: 354 | 355 | ```ruby 356 | name = @name 357 | Book.ion.search { text :name, name } # good 358 | ``` 359 | 360 | ### Using with Sequel 361 | 362 | Ion comes with an optional plugin for [Sequel](http://sequel.rubyforge.org) models. 363 | 364 | ```ruby 365 | require 'ion/extras/sequel' 366 | 367 | class Author < Sequel::Model 368 | plugin :ion_indexable 369 | 370 | # Define indices 371 | ion { text :title } 372 | end 373 | 374 | Author.ion.search { .. } 375 | ``` 376 | 377 | ### Using with Rails 378 | 379 | For Rails 3/Bundler, add it to your Gemfile. 380 | 381 | ```ruby 382 | # Gemfile 383 | gem 'ion', :require_as => 'ion/extras/activerecord' 384 | ``` 385 | 386 | Create an Ion config file. 387 | 388 | ```yaml 389 | # config/ion.yml 390 | development: 391 | :url: redis://127.0.0.1:6579/0 392 | test: 393 | :url: redis://127.0.0.1:6579/1 394 | production: 395 | :url: redis://127.0.0.1:6579/1 396 | ``` 397 | 398 | Have it connect to Ion on startup. 399 | 400 | ```ruby 401 | # config/initializers/ion.rb 402 | spec = YAML.load_file("#{Rails.root.to_s}/config/ion.yml")[Rails.env] 403 | Ion.connect spec if spec 404 | ``` 405 | 406 | In your models: 407 | 408 | ```ruby 409 | class Author < ActiveRecord::Base 410 | acts_as_ion_indexable 411 | 412 | # Define indices 413 | ion { text :title } 414 | end 415 | ``` 416 | 417 | (To do: maybe an `ion-rails` gem with generators et al) 418 | 419 | Testing 420 | ------- 421 | 422 | Install the needed gems. 423 | 424 | rvm 1.9.2-p136@ion --rvmrc --create 425 | rvm gemset import # or install gems in .gems 426 | 427 | Run the tests. This will automatically spawn Redis. 428 | 429 | rake test 430 | 431 | Running benchmarks 432 | ------------------ 433 | 434 | First, populate the database. You need this to run the other benchmarks. 435 | This will automatically spawn Redis. 436 | 437 | rake bm:spawn 438 | BM_SIZE=20000 rake bm:spawn # If you want a bigger DB size 439 | 440 | Then run the indexing benchmark. You will need this to run the other 441 | benchmarks as well. 442 | 443 | rake bm:index 444 | 445 | The other available benchmarks are: 446 | 447 | rake bm:search 448 | 449 | Authors 450 | ======= 451 | 452 | Ion is authored by Rico Sta. Cruz of Sinefunc, Inc. 453 | See more of our work on [www.sinefunc.com](http://www.sinefunc.com)! 454 | 455 | License 456 | ------- 457 | 458 | Copyright (c) 2011 Rico Sta. Cruz. 459 | 460 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 461 | 462 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 463 | 464 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 465 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'fileutils' 3 | $:.push File.expand_path('../test', __FILE__) 4 | $:.push *Dir[File.expand_path('../vendor/*/lib', __FILE__)] 5 | 6 | 7 | module Util 8 | def redis_port 9 | 6385 10 | end 11 | 12 | def redis_url(db=0) 13 | "redis://127.0.0.1:#{redis_port}/#{db}" 14 | end 15 | 16 | def redis? 17 | ! redis_pid.nil? 18 | end 19 | 20 | def redis_pid 21 | begin 22 | id = File.read(redis_pidfile).strip.to_i 23 | return id if Process.getpgid(id) 24 | rescue => e 25 | end 26 | end 27 | 28 | def redis_path(*a) 29 | File.join(File.expand_path('../db', __FILE__), *a) 30 | end 31 | 32 | def redis_pidfile 33 | redis_path 'redis.pid' 34 | end 35 | 36 | def redis_start 37 | FileUtils.mkdir_p redis_path 38 | system "( echo port #{redis_port}; echo pidfile #{redis_path}/redis.pid; echo dir #{redis_path}; echo daemonize yes ) | redis-server -" 39 | end 40 | 41 | def system(cmd) 42 | puts "\033[0;33m$\033[0;m #{cmd}" 43 | super 44 | end 45 | 46 | def info(cmd) 47 | puts "\033[0;33m*\033[0;32m #{cmd}\033[0;m" 48 | end 49 | end 50 | 51 | Object.send :include, Util 52 | 53 | desc "Starts redis." 54 | task :'redis:start' do 55 | if redis? 56 | info "Redis is running at #{redis_pid} (port #{redis_port}) -- `rake redis:stop` to stop." 57 | else 58 | info "Starting redis server at #{redis_port}." 59 | redis_start 60 | end 61 | end 62 | 63 | desc "Stops redis." 64 | task :'redis:stop' do 65 | info "Stopping redis..." 66 | system "redis-cli -p #{redis_port} shutdown" 67 | end 68 | 69 | desc "Runs tests." 70 | task :'test' => :'redis:start' do 71 | ENV['REDIS_URL'] = redis_url(0) 72 | Dir['test/**/*_test.rb'].each { |f| load f } 73 | end 74 | 75 | Dir['test/benchmark/*.rb'].each do |f| 76 | base = File.basename(f, '.*') 77 | desc "Run the '#{base}' benchmark." 78 | task :"bm:#{base}" => :'redis:start' do 79 | ENV['REDIS_URL'] = redis_url(1) 80 | load f 81 | end 82 | end 83 | 84 | task :redis => :'redis:start' 85 | task :default => :test 86 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | To do list 2 | ---------- 3 | 4 | - Server: implement indexing 5 | 6 | - Server: implement purge 7 | 8 | - Server: implement async indexing 9 | 10 | - Server tests: make the server purge, instead of doing it by itself 11 | 12 | - Client: implement `Ion.connect ion:` 13 | 14 | - Client: implement remote searching 15 | 16 | - Client: implement remote indexing 17 | 18 | - Fuzzy indexing for autocompletes 19 | 20 | - Test the Rails `acts_as_ion_indexable` 21 | 22 | - Test the Sequel `plugin :ion_indexable` 23 | 24 | - Number array indexing? (`ion { number :employee_ids }` + `employee_ids().is_a?(Array)`) 25 | 26 | - Text array indexing (`ion { list :tags }`) 27 | 28 | - Boolean indexing should be more forgiving 29 | -------------------------------------------------------------------------------- /ion.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'ion/version' 5 | 6 | Gem::Specification.new do |s| 7 | s.name = "ion" 8 | s.version = Ion::VERSION 9 | s.summary = %{Simple search engine powered by Redis.} 10 | s.description = %Q{Ion is a library that lets you index your records and search them with simple or complex queries.} 11 | s.authors = ["Rico Sta. Cruz"] 12 | s.email = ["rico@sinefunc.com"] 13 | s.homepage = "http://github.com/rstacruz/ion" 14 | s.files = `git ls-files -z`.split("\x0") 15 | s.test_files = s.files.grep(%r{^(test|spec|features)/}) 16 | s.require_paths = ["lib"] 17 | 18 | s.add_dependency "nest", "~> 1.0" 19 | s.add_dependency "redis", "~> 3.2.1" 20 | s.add_dependency "text", "~> 0.2.0" 21 | s.add_development_dependency 'rake', '~> 10.0' 22 | end 23 | -------------------------------------------------------------------------------- /lib/ion.rb: -------------------------------------------------------------------------------- 1 | require 'redis' 2 | require 'nest' 3 | require 'text' 4 | require 'ostruct' 5 | require 'ion/version' 6 | 7 | 8 | module Ion 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 :Wrapper, "#{PREFIX}/wrapper" 23 | autoload :Helpers, "#{PREFIX}/helpers" 24 | autoload :Client, "#{PREFIX}/client" 25 | 26 | InvalidIndexType = Class.new(StandardError) 27 | Error = Class.new(StandardError) 28 | 29 | # Returns the Redis instance that is being used by Ion. 30 | def self.config 31 | @config ||= Ion::Config.new 32 | end 33 | 34 | def self.version 35 | VERSION 36 | end 37 | 38 | def self.redis 39 | @redis || key.redis 40 | end 41 | 42 | # Connects to a certain Redis server. 43 | def self.connect(to) 44 | @redis = Redis.connect(to) 45 | end 46 | 47 | # Returns the root key. 48 | def self.key 49 | @key ||= if @redis 50 | Nest.new('Ion', @redis) 51 | else 52 | Nest.new('Ion') 53 | end 54 | end 55 | 56 | # Returns a new temporary key. 57 | def self.volatile_key 58 | key['~'][rand.to_s] 59 | end 60 | 61 | # Makes a certain volatile key expire. 62 | def self.expire(*keys) 63 | keys.each { |k| redis.expire(k, DEFAULT_TTL) if k.include?('~') } 64 | end 65 | 66 | # Redis helper stuff 67 | # Probably best to move this somewhere 68 | 69 | # Combines multiple set keys. 70 | def self.union(keys, options={}) 71 | return keys.first if keys.size == 1 72 | 73 | results = Ion.volatile_key 74 | results.zunionstore keys, options 75 | results 76 | end 77 | 78 | # Finds the intersection in multiple set keys. 79 | def self.intersect(keys, options={}) 80 | return keys.first if keys.size == 1 81 | 82 | results = Ion.volatile_key 83 | results.zinterstore keys, options 84 | results 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/ion/client.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'restclient' 3 | 4 | # Note: this class aims to be decoupled from the rest of Ion, 5 | # so don't go making things like "model[id].ion" or something 6 | # like that. 7 | class Ion::Client 8 | Error = Class.new(StandardError) 9 | 10 | attr_reader :error 11 | 12 | def initialize(options={}) 13 | @base_uri = options.delete(:url) || ENV['ION_URL'] || 'http://127.0.0.1:8082' 14 | end 15 | 16 | def ok? 17 | @ok.nil? || @ok 18 | end 19 | 20 | # Returns version info. 21 | # 22 | # @example 23 | # a = client.about 24 | # assert a.app == "Ion" 25 | # assert a.version >= "1.0" 26 | # 27 | def about 28 | get '/about' 29 | end 30 | 31 | # Defines the indices for a given model. 32 | # 33 | # @example 34 | # client.define Album, Album.ion.to_hash # { 'indices' => [ ... ] } 35 | # 36 | def define(model, options) 37 | post "/index/#{model}", :body => options.to_hash.to_json 38 | end 39 | 40 | # Searches. 41 | # Define must be ran first. 42 | # 43 | # @example 44 | # search = Album.ion.search { ... } 45 | # client.search Album, search.to_hash 46 | # 47 | def search(model, search) 48 | get "/search/#{model}", :body => search.to_hash.to_json 49 | end 50 | 51 | # Deindexes a model. 52 | def del(model, id) 53 | request :delete, "/index/#{model}/#{id}" 54 | end 55 | 56 | # Indexes a model. 57 | # 58 | # @example 59 | # item = Album[1] 60 | # hash = Album.ion.index_hash(item) # Under debate 61 | # client.index Album, item.id, hash 62 | # 63 | def index(model, id, options) 64 | request :post, "/index/#{model}/#{id}", :body => options.to_hash.to_json 65 | end 66 | 67 | protected 68 | def get(url, params={}) 69 | request :get, url, :params => params, :accept => :json 70 | end 71 | 72 | def post(url, params={}) 73 | request :post, url, params#, :content_type => :json, :accept => :json 74 | end 75 | 76 | # Sends a request 77 | def request(meth, url, *a) 78 | RestClient.send(meth, "#{@base_uri}#{url}", *a, &method(:handle)) 79 | end 80 | 81 | # Response handler 82 | def handle(response, request, result, &blk) 83 | hash = JSON.parse(response) 84 | os = OpenStruct.new(hash) 85 | @ok = false 86 | 87 | case response.code 88 | when 200 89 | @ok = true; @error = nil 90 | return os 91 | when 400 # Error 92 | @error = os 93 | when 500 # Internal server error 94 | @error = OpenStruct.new(:message => "Internal server error") 95 | end 96 | rescue JSON::ParserError 97 | @error = OpenStruct.new(:message => "JSON parse error") 98 | end 99 | 100 | end 101 | 102 | -------------------------------------------------------------------------------- /lib/ion/config.rb: -------------------------------------------------------------------------------- 1 | class Ion::Config < OpenStruct 2 | def initialize(args={}) 3 | super defaults.merge(args) 4 | end 5 | 6 | def defaults 7 | @defaults ||= { 8 | :stopwords => %w(a an and are as at be but by for if in into is) + 9 | %w(it no not of on or s such t that the their then) + 10 | %w(there these they this to was will with) 11 | } 12 | end 13 | 14 | def method_missing(meth, *args, &blk) 15 | return @table.keys.include?(meth[0...-1].to_sym) if meth.to_s[-1] == '?' 16 | super 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/ion/entity.rb: -------------------------------------------------------------------------------- 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 | 11 | # Clear out previous indexes... 12 | ion.index_types.each { |i_type| i_type.deindex(self) } 13 | 14 | Ion.redis.multi 15 | # And add new ones 16 | ion.indices.each { |index| index.index(self) } 17 | Ion.redis.exec 18 | end 19 | 20 | # Call me before deletion 21 | def delete_ion_indices 22 | ion = self.class.ion 23 | ion.index_types.each { |i_type| i_type.del(self) } 24 | end 25 | 26 | module ClassMethods 27 | # Sets up Ion indexing for a model. 28 | # 29 | # When no block is given, it returns the Ion::Options 30 | # for the model. 31 | # 32 | # @example 33 | # 34 | # class Artist < Model 35 | # include Ion::Entity 36 | # ion { 37 | # text :name 38 | # text :real_name 39 | # } 40 | # end 41 | # 42 | # Artist.ion.indices 43 | # Artist.ion.search { ... } 44 | # 45 | def ion(options={}, &blk) 46 | @ion_options ||= Ion::Options.new(self, options) 47 | @ion_options.instance_eval(&blk) if block_given? 48 | @ion_options 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/ion/extras/activerecord.rb: -------------------------------------------------------------------------------- 1 | raise Error, "ActiveRecord not found" unless Object.const_defined?(:ActiveRecord) 2 | 3 | require 'ion' 4 | 5 | # Okay, this is probably wrong 6 | class ActiveRecord::Base 7 | def self.acts_as_ion_indexable 8 | self.send :include, Ion::Entity 9 | self.after_save :update_ion_indices 10 | self.before_destroy :delete_ion_indices 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/ion/extras/sequel.rb: -------------------------------------------------------------------------------- 1 | require 'ion' 2 | require 'sequel' 3 | 4 | module Sequel::Plugins::IonIndexable 5 | def self.configure(model, options={}, &blk) 6 | model.send :include, Ion::Entity 7 | model.ion &blk if block_given? 8 | end 9 | 10 | module InstanceMethods 11 | def after_save 12 | super; update_ion_indices 13 | end 14 | 15 | def before_destroy 16 | super; delete_ion_indices 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/ion/helpers.rb: -------------------------------------------------------------------------------- 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 12 | -------------------------------------------------------------------------------- /lib/ion/index.rb: -------------------------------------------------------------------------------- 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 | def to_hash 17 | { 'type' => type.to_s, 18 | 'name' => @name.to_s 19 | } 20 | end 21 | 22 | def type 23 | self.class.name.split(':').last.downcase 24 | end 25 | 26 | # Indexes a record 27 | def index(record) 28 | end 29 | 30 | def self.deindex(record) 31 | end 32 | 33 | # Completely obliterates traces of a record from the indices 34 | def self.del(record) 35 | self.deindex record 36 | references_key(record).del 37 | end 38 | 39 | # Returns a key (set) of results 40 | def search(what, args={}) 41 | end 42 | 43 | protected 44 | 45 | # Returns the value for a certain record 46 | def value_for(record) 47 | record.instance_eval &@lambda 48 | end 49 | 50 | # Returns the index key. Usually a zset. 51 | # @example Ion:Album:text:title 52 | def index_key 53 | @type ||= self.class.name.split(':').last.downcase 54 | @options.key[@type][self.name] 55 | end 56 | 57 | # Returns the key that holds the list of other keys that is used by a 58 | # given record. This is a set. 59 | # @example Ion:Album:references:1:text 60 | def self.references_key(record) 61 | @type ||= self.name.split(':').last.downcase 62 | 63 | # Some caching of sorts 64 | id = record.id 65 | @ref_keys ||= Hash.new 66 | @ref_keys[id] ||= record.class.ion.key[:references][id][@type] 67 | end 68 | 69 | def references_key(record) 70 | self.class.references_key record 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/ion/indices.rb: -------------------------------------------------------------------------------- 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 | autoload :Boolean, "#{Ion::PREFIX}/indices/boolean" 8 | 9 | def self.names 10 | [ :text, :number, :metaphone, :sort, :boolean ] 11 | end 12 | 13 | def self.get(name) 14 | name = Stringer.classify(name).to_sym 15 | raise InvalidIndexType unless const_defined?(name) 16 | const_get(name) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/ion/indices/boolean.rb: -------------------------------------------------------------------------------- 1 | module Ion 2 | class Indices::Boolean < Indices::Text 3 | def index(record) 4 | value = value_for(record) 5 | value = bool_to_str(value) 6 | refs = references_key(record) 7 | 8 | index_key[value].zadd 1, record.id 9 | refs.sadd index_key[value] 10 | end 11 | 12 | def search(what, args={}) 13 | what = bool_to_str(what) 14 | index_key[what] 15 | end 16 | 17 | protected 18 | def bool_to_str(value) 19 | str_to_bool(value) ? "1" : "0" 20 | end 21 | 22 | def str_to_bool(value) 23 | value = value.downcase if value.is_a?(String) 24 | case value 25 | when 'false', false, '0', 0 then false 26 | else true 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/ion/indices/metaphone.rb: -------------------------------------------------------------------------------- 1 | module Ion 2 | class Indices::Metaphone < Indices::Text 3 | def index_words(str) 4 | words = ::Text::Metaphone.metaphone(str).strip.split(' ') 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/ion/indices/number.rb: -------------------------------------------------------------------------------- 1 | module Ion 2 | class Indices::Number < Indices::Text 3 | MARGIN = 0.0001 4 | 5 | def index(record) 6 | value = value_for(record) 7 | [*value].each { |v| index_value record, v.to_f } 8 | end 9 | 10 | def index_value(record, value) 11 | value = value 12 | refs = references_key(record) 13 | 14 | index_key.zadd value, record.id 15 | refs.sadd index_key 16 | end 17 | 18 | def search(what, args={}) 19 | key = Ion.volatile_key 20 | key.zunionstore [index_key] #copy 21 | 22 | if what.is_a?(Hash) 23 | # Strip away the upper/lower limits. 24 | key.zremrangebyscore '-inf', what[:gt] if what[:gt] 25 | key.zremrangebyscore what[:lt], '+inf' if what[:lt] 26 | key.zremrangebyscore '-inf', (what[:min].to_f-MARGIN) if what[:min] 27 | key.zremrangebyscore (what[:max].to_f+MARGIN), '+inf' if what[:max] 28 | else 29 | key.zremrangebyscore '-inf', what.to_f-MARGIN 30 | key.zremrangebyscore what.to_f+MARGIN, '+inf' 31 | end 32 | 33 | key 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/ion/indices/sort.rb: -------------------------------------------------------------------------------- 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 42 | -------------------------------------------------------------------------------- /lib/ion/indices/text.rb: -------------------------------------------------------------------------------- 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 = self.class.references_key(record) 15 | 16 | words.uniq.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.zcard(key) == 0 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 42 | -------------------------------------------------------------------------------- /lib/ion/options.rb: -------------------------------------------------------------------------------- 1 | class Ion::Options 2 | attr_reader :model 3 | 4 | def initialize(model, options={}) 5 | @model = model 6 | @indices = Hash.new { |h, k| h[k] = Hash.new } 7 | 8 | # deserialize 9 | if options['indices'] 10 | options['indices'].each { |h| field h['type'], h['name'] } 11 | end 12 | end 13 | 14 | def search(spec=nil, &blk) 15 | Ion::Search.new(self, spec, &blk) 16 | end 17 | 18 | def key 19 | @key ||= Ion.key[model.name] #=> 'Ion:Person' 20 | end 21 | 22 | # Returns a certain index. 23 | # @example 24 | # @options.index(:text, :title) #=> <#Ion::Indices::Text> 25 | def index(type, name) 26 | @indices[type.to_sym][name.to_sym] 27 | end 28 | 29 | # Returns all indices. 30 | def indices 31 | @indices.values.map(&:values).flatten 32 | end 33 | 34 | def index_types 35 | indices.map(&:class).uniq 36 | end 37 | 38 | def to_hash 39 | { 'indices' => indices.map { |ix| ix.to_hash } } 40 | end 41 | 42 | protected 43 | # Creates the shortcuts `text :foo` => `field :text, :foo` 44 | Ion::Indices.names.each do |type| 45 | define_method(type) do |id, options={}, &blk| 46 | field type, id, options, &blk 47 | end 48 | end 49 | 50 | def field(type, id, options={}, &blk) 51 | index_type = Ion::Indices.get(type) 52 | @indices[type.to_sym][id.to_sym] = index_type.new(id.to_sym, self, options, &blk) 53 | end 54 | end 55 | 56 | -------------------------------------------------------------------------------- /lib/ion/scope.rb: -------------------------------------------------------------------------------- 1 | class Ion::Scope 2 | require File.expand_path('../scope/hash', __FILE__) 3 | 4 | include Ion::Helpers 5 | include Ion::Scope::Hash 6 | 7 | def initialize(search, args={}, &blk) 8 | @search = search 9 | @gate = (args.delete('gate') || :all).to_sym 10 | @score = (args.delete('score') || 1.0).to_f 11 | 12 | raise Ion::Error unless [:all, :any].include?(@gate) 13 | yieldie(&blk) if block_given? 14 | 15 | deserialize args if args.any? 16 | end 17 | 18 | def any_of(&blk) 19 | scopes << subscope('gate' => :any, &blk) 20 | end 21 | 22 | def all_of(&blk) 23 | scopes << subscope('gate' => :all, &blk) 24 | end 25 | 26 | def boost(amount=1.0, options={}, &blk) 27 | options['gate'] = :all 28 | boosts << [Ion::Scope.new(@search, options, &blk), amount] 29 | end 30 | 31 | def score(score, &blk) 32 | scopes << subscope('score' => score, &blk) 33 | end 34 | 35 | def key 36 | @key ||= get_key 37 | end 38 | 39 | def sort_by(what) 40 | key 41 | index = @search.options.index(:sort, what) 42 | key.sort by: index.spec, order: "ASC ALPHA", store: key 43 | end 44 | 45 | # Only when done 46 | def count 47 | key 48 | return key.zcard if key.type == "zset" 49 | return key.llen if key.type == "list" 50 | 0 51 | end 52 | 53 | # Defines the shortcuts `text :foo 'xyz'` => `search :text, :foo, 'xyz'` 54 | Ion::Indices.names.each do |type| 55 | define_method(type) do |field, what, args={}| 56 | search type, field, what, args 57 | end 58 | end 59 | 60 | # Searches a given field. 61 | # @example 62 | # class Album 63 | # ion { text :name } 64 | # end 65 | # 66 | # Album.ion.search { 67 | # search :text, :name, "Emotional Technology" 68 | # text :name, "Emotional Technology" # same 69 | # } 70 | def search(type, field, what, args={}) 71 | index = options.index(type, field) 72 | raise Ion::Error, "No such index: #{type}/#{field}" if index.nil? 73 | searches << [ index, [what, args] ] 74 | end 75 | 76 | def options 77 | @search.options 78 | end 79 | 80 | def ids(range) 81 | key 82 | 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 (like search keys) to be cleaned up after. 103 | def temp_keys() @temp_keys ||= Array.new end 104 | 105 | # List of boost scopes -- [Scope, amount] tuples 106 | def boosts() @boosts ||= Array.new end 107 | 108 | # List of searches to do (tuple of [index, arguments]) 109 | def searches() @searches ||= Array.new end 110 | 111 | def get_key 112 | # Wrap up it's subscopes 113 | scope_keys = scopes.map(&:key) 114 | 115 | # Wrap up the searches 116 | search_keys = searches.map { |(index, args)| index.search(*args) } 117 | @temp_keys = search_keys 118 | 119 | # Intersect or union all the subkeys 120 | key = combine(scope_keys + search_keys) 121 | 122 | # Adjust scores accordingly 123 | key = rescore(key, @score) if @score != 1.0 && !key.nil? 124 | 125 | # Don't proceed if there are no results anyway 126 | return Ion.volatile_key if key.nil? 127 | 128 | # Process boosts 129 | boosts.each do |(scope, amount)| 130 | inter = Ion.volatile_key 131 | inter.zinterstore [key, scope.key], :weights => [amount, 0] 132 | key.zunionstore [key, inter], :aggregate => (amount > 1 ? :max : :min) 133 | @temp_keys << inter 134 | end 135 | 136 | key 137 | end 138 | 139 | def rescore(key, score) 140 | dest = key.include?('~') ? key : Ion.volatile_key 141 | dest.zunionstore([key], weights: score) 142 | dest 143 | end 144 | 145 | # Sets the TTL for the temp keys. 146 | def expire 147 | scopes.each { |s| s.send :expire } 148 | Ion.expire key, *temp_keys 149 | end 150 | 151 | def combine(subkeys) 152 | return nil if subkeys.empty? 153 | return subkeys.first if subkeys.size == 1 154 | 155 | _key = Ion.volatile_key 156 | if @gate == :all 157 | _key.zinterstore subkeys 158 | elsif @gate == :any 159 | _key.zunionstore subkeys 160 | end 161 | 162 | _key 163 | end 164 | 165 | # Used by all_of and any_of 166 | def subscope(args={}, &blk) 167 | opts = { 'gate' => @gate, 'score' => @score } 168 | Ion::Scope.new(@search, opts.merge(args), &blk) 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /lib/ion/scope/hash.rb: -------------------------------------------------------------------------------- 1 | module Ion::Scope::Hash 2 | def to_hash 3 | h = ::Hash.new 4 | h['gate'] = @gate.to_s unless @gate == :all 5 | h['score'] = @score.to_f unless @score == 1.0 6 | h['scopes'] = scopes.map(&:to_hash) if scopes.any? 7 | h['searches'] = searches.map { |(index, (what, args))| 8 | hh = ::Hash.new 9 | hh['index'] = index.to_hash 10 | hh['value'] = what 11 | hh['args'] = args.to_hash unless args.empty? 12 | hh 13 | } if searches.any? 14 | h['boosts'] = boosts.map { |(scope, amount)| { 'scope' => scope.to_hash, 'amount' => amount.to_f } } if boosts.any? 15 | h 16 | end 17 | 18 | def to_s 19 | a, b = Array.new, Array.new 20 | sep = (@gate == :any) ? ' | ' : ' & ' 21 | 22 | a += searches.map { |(ix, (what, _))| "#{ix.name}.#{ix.type}:\"#{what.gsub('"', '\"')}\"" } 23 | a += scopes.map { |scope| "(#{scope})" } 24 | 25 | b << a.join(sep) if a.any? 26 | b << "*#{@score}" if @score != 1.0 27 | b += boosts.map { |(scope, amount)| "(#{scope} +*#{amount})" } 28 | 29 | b.join(' ') 30 | end 31 | 32 | # Unpack the given hash. 33 | def deserialize(args) 34 | if args['searches'] 35 | args['searches'].each do |h| 36 | index = h['index'] 37 | search index['type'].to_sym, index['name'].to_sym, h['value'] 38 | end 39 | end 40 | 41 | if args['scopes'] 42 | args['scopes'].each { |h| scopes << subscope(h) } 43 | end 44 | 45 | if args['boosts'] 46 | args['boosts'].each do |h| 47 | boost h['amount'], h['scope'] 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/ion/search.rb: -------------------------------------------------------------------------------- 1 | class Ion::Search 2 | include Enumerable 3 | 4 | attr_reader :options 5 | attr_reader :scope 6 | 7 | def initialize(options, spec, &blk) 8 | @options = options 9 | @scope = if block_given? 10 | Ion::Scope.new(self, &blk) 11 | elsif spec.is_a?(Hash) 12 | Ion::Scope.new(self, spec) 13 | else 14 | raise Ion::Error, "Invalid search" 15 | end 16 | end 17 | 18 | # Returns the model. 19 | # @example 20 | # 21 | # search = Album.ion.search { ... } 22 | # assert search.model == Album 23 | # 24 | def model 25 | @options.model 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 | def to_hash 77 | @scope.to_hash 78 | end 79 | 80 | def inspect 81 | "#<#{self.class.name}: [#{@scope.to_s}]>" 82 | end 83 | 84 | # Searching 85 | 86 | protected 87 | # Interal: called when the `Model.ion.search { }` block is done 88 | def done 89 | @scope.send :done 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/ion/stringer.rb: -------------------------------------------------------------------------------- 1 | module Ion::Stringer 2 | def self.sanitize(str) 3 | return '' unless str.is_a?(String) 4 | 5 | # Also remove special characters ("I.B.M. can't" => "ibm cant") 6 | str.downcase.gsub(/[\.'"]/,'').force_encoding('UTF-8') 7 | end 8 | 9 | # "Hey, yes you." => %w(hey yes you) 10 | def self.keywords(str) 11 | return Array.new unless str.is_a?(String) 12 | split = split_words(sanitize(str)) 13 | split.reject { |s| Ion.config.stopwords.include?(s) } 14 | end 15 | 16 | def self.split_words(str) 17 | if RUBY_VERSION >= "1.9" 18 | str.scan(/[[:word:]]+/) 19 | else 20 | # \w doesn't work in Unicode, but it's all you've got for Ruby <=1.8 21 | str.scan(/\w+/) 22 | end 23 | end 24 | 25 | # "one_sign" => "OneSign" 26 | def self.classify(name) 27 | str = name.to_s 28 | str.scan(/\b(\w)(\w*)/).map { |(w, ord)| w.upcase + ord.downcase }.join('') 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/ion/version.rb: -------------------------------------------------------------------------------- 1 | module Ion 2 | VERSION = '0.0.2' 3 | end 4 | -------------------------------------------------------------------------------- /lib/ion/wrapper.rb: -------------------------------------------------------------------------------- 1 | # Returns a fake model class. 2 | class Ion::Wrapper 3 | attr_reader :name 4 | 5 | def initialize(name) 6 | @name = name 7 | extend Ion::Entity::ClassMethods 8 | end 9 | 10 | def ion 11 | end 12 | 13 | def [](id) 14 | { :id => id } 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/benchmark/index.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../benchmark_helper', __FILE__) 2 | 3 | class IonMark < BM 4 | setup 5 | size = Album.all.size 6 | measure "Indexing", size do 7 | Album.all.each { |a| a.update_ion_indices } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/benchmark/search.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../benchmark_helper', __FILE__) 2 | 3 | class IonMark < BM 4 | setup 5 | size = 5000 6 | 7 | measure "Searching - one word", size do 8 | size.times { 9 | phrase = 'lorem' 10 | Album.ion.search { 11 | text :title, phrase 12 | }.ids 13 | } 14 | end 15 | 16 | measure "Searching - three words", size do 17 | size.times { 18 | phrase = 'lorem ipsum dolor' 19 | Album.ion.search { 20 | text :title, phrase 21 | }.ids 22 | } 23 | end 24 | 25 | measure "Searching - 2 fields", size do 26 | size.times { 27 | phrase = 'lorem' 28 | Album.ion.search { 29 | text :title, phrase 30 | text :body, phrase 31 | }.ids 32 | } 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/benchmark/spawn.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../benchmark_helper', __FILE__) 2 | require 'batch' 3 | 4 | class << BM 5 | def spawn(size=5000) 6 | re = Ion.redis 7 | 8 | keys = re.keys("IonBenchmark:*") 9 | re.del(*keys) if keys.any? 10 | 11 | k = Album.send :key 12 | Batch.each((1..size).to_a) do |i| 13 | #Album.create title: lorem, body: lorem 14 | k[:all].sadd i 15 | k[i].hmset :title, lorem, :body, lorem 16 | end 17 | end 18 | end 19 | 20 | BM.spawn (ENV['BM_SIZE'] || 5000).to_i 21 | -------------------------------------------------------------------------------- /test/benchmark_helper.rb: -------------------------------------------------------------------------------- 1 | $:.unshift File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib')) 2 | 3 | require 'ohm' 4 | require 'ohm/contrib' 5 | require 'ion' 6 | require 'benchmark' 7 | require_relative './p_helper' 8 | require_relative './common_helper' 9 | 10 | class BM 11 | class << self 12 | include LoremHelper 13 | 14 | def setup 15 | re = Redis.current 16 | keys = re.keys("Ion:*") 17 | re.del(*keys) if keys.any? 18 | 19 | if Album.all.empty? 20 | puts "Did you spawn? Do `rake bm:spawn` first." 21 | exit 22 | end 23 | 24 | puts "-"*62 25 | puts "%-40s%10s%12s" % [ Time.now.strftime('%A, %b %d -- %l:%M %p'), 'Elapsed', 'Rate' ] 26 | puts "-"*62 27 | end 28 | 29 | def time_for(&blk) 30 | GC.disable 31 | elapsed = Benchmark.realtime &blk 32 | GC.enable 33 | elapsed 34 | end 35 | 36 | def measure(test, size, &blk) 37 | start = Time.now 38 | print "%-40s" % [ "#{test} (x#{size})" ] 39 | 40 | elapsed = time_for(&blk) * 1000 #ms 41 | per = elapsed / size 42 | rate = size / (elapsed / 1000) 43 | 44 | puts "%10s%12s" % [ "#{elapsed.to_i} ms", "#{rate.to_i} /sec" ] 45 | end 46 | end 47 | end 48 | 49 | module IonBenchmark 50 | class Album < Ohm::Model 51 | include Ion::Entity 52 | include Ohm::Callbacks 53 | 54 | attribute :title 55 | attribute :body 56 | 57 | ion { 58 | text :title 59 | text :body 60 | } 61 | 62 | # after :save, :update_ion_indices 63 | # before :delete, :delete_ion_indices 64 | end 65 | end 66 | 67 | Album = IonBenchmark::Album 68 | 69 | -------------------------------------------------------------------------------- /test/benchmark_notes.txt: -------------------------------------------------------------------------------- 1 | ********************************************************************* 2 | 3 | 5000 records 4 | 5 | --------------------------------------------------------------- 6 | Wednesday, Feb 16 Elapsed Records 7 | 2:16 PM time per sec 8 | --------------------------------------------------------------- 9 | Indexing (x5000) 21114 ms 236 /sec 10 | Searching - one word (x5000) 912 ms 5477 /sec 11 | Searching - three words (x5000) 3223 ms 1551 /sec 12 | Searching - 2 fields (x5000) 2446 ms 2043 /sec 13 | 14 | 15 | ********************************************************************* 16 | 17 | 10000 records 18 | 19 | --------------------------------------------------------------- 20 | Wednesday, Feb 16 Elapsed Records 21 | 2:23 PM time per sec 22 | --------------------------------------------------------------- 23 | Indexing (x10000) 46449 ms 215 /sec 24 | Searching - one word (x5000) 920 ms 5430 /sec 25 | Searching - three words (x5000) 3737 ms 1337 /sec 26 | Searching - 2 fields (x5000) 3460 ms 1444 /sec 27 | 28 | ********************************************************************* 29 | 30 | 20000 records 31 | 32 | --------------------------------------------------------------- 33 | Wednesday, Feb 16 Elapsed Records 34 | 2:25 PM time per sec 35 | --------------------------------------------------------------- 36 | Indexing (x20000) 103154 ms 193 /sec 37 | Searching - one word (x5000) 1034 ms 4831 /sec 38 | Searching - three words (x5000) 3848 ms 1299 /sec 39 | Searching - 2 fields (x5000) 2868 ms 1743 /sec 40 | 41 | -------------------------------------------------------------------------------- /test/common_helper.rb: -------------------------------------------------------------------------------- 1 | module LoremHelper 2 | def lorem 3 | (0..5).map { lorem_words[(lorem_words.length * rand).to_i] }.join(' ') 4 | end 5 | 6 | def lorem_words 7 | @w ||= 8 | %w(lorem ipsum dolor sit amet consecteteur adicicising elit sed do eiusmod) + 9 | %w(tempor incidudunt nam posture magna aliqua ut labore et dolore) + 10 | %w(cum sociis nostrud aequitas verificium) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/irb_helpers.rb: -------------------------------------------------------------------------------- 1 | class Nest 2 | def zlist 3 | Hash[*zrange(0, -1, with_scores: true)] 4 | end 5 | end 6 | 7 | def k 8 | Ion.key 9 | end 10 | -------------------------------------------------------------------------------- /test/p_helper.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | 3 | # Overrides `p` to be pretty -- useful for tests. 4 | module Kernel 5 | def p(*args) 6 | # Catch source 7 | begin 8 | raise Error 9 | rescue => e 10 | file, line, _ = e.backtrace[2].split(':') 11 | source = "%s:%s" % [ File.basename(file), line ] 12 | end 13 | 14 | # Source format 15 | pre = "\033[0;33m%20s |\033[0;m " 16 | lines = Array.new 17 | 18 | # YAMLify the last arg if it's YAMLable 19 | if args.last.is_a?(Hash) || args.last.is_a?(Array) 20 | lines = args.pop.to_yaml.split("\n")[1..-1].map { |s| " #{s}" } 21 | end 22 | 23 | # Inspect everything else 24 | lines.unshift(args.map { |a| a.is_a?(String) ? a : a.inspect }.join(' ')) if args.any? 25 | 26 | # Print 27 | print "\n" 28 | puts pre % [source] + (lines.shift || "nil") 29 | puts lines.map { |s| pre % [''] + s }.join("\n") if lines.any? 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/redis_debug.rb: -------------------------------------------------------------------------------- 1 | # Redis debug 2 | class Redis::Client 3 | def call(*args) 4 | puts "REDIS:" + args.inspect.join(' ') 5 | process(args) { read } 6 | end 7 | end 8 | 9 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $:.unshift File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib')) 2 | 3 | require 'ohm' 4 | require 'ohm/contrib' 5 | require 'ion' 6 | require 'contest' 7 | require_relative './p_helper' 8 | require_relative './common_helper' 9 | #require_relative './redis_debug' 10 | 11 | Ion.connect url: (ENV['REDIS_URL'] || 'redis://127.0.0.1:6379/0') 12 | 13 | class Test::Unit::TestCase 14 | include LoremHelper 15 | 16 | def setup 17 | re = Redis.current 18 | keys = re.keys("Ion:*") + re.keys("IT::*") 19 | re.del(*keys) if keys.any? 20 | end 21 | 22 | def redis 23 | Ion.redis 24 | end 25 | 26 | def scores_for(search) 27 | search.ids 28 | Hash[*search.key.zrange(0, -1, with_scores: true)] 29 | end 30 | 31 | def ids(keys) 32 | keys.map { |k| @items[k.to_sym].id } 33 | end 34 | 35 | def assert_ids(keys, args={}) 36 | if args[:ordered] 37 | assert_equal ids(keys), args[:for].ids 38 | else 39 | assert_equal ids(keys).sort, args[:for].ids.sort 40 | end 41 | end 42 | 43 | def assert_score(hash) 44 | hash.each do |key, score| 45 | assert_equal score.to_f, @scores[@items[key.to_sym].id].to_f 46 | end 47 | end 48 | end 49 | 50 | module IT 51 | end 52 | 53 | class IT::Album < Ohm::Model 54 | include Ion::Entity 55 | include Ohm::Callbacks 56 | 57 | attribute :title 58 | attribute :body 59 | attribute :play_count 60 | attribute :available 61 | 62 | ion { 63 | text :title 64 | text :body 65 | text(:also_title) { self.title } 66 | number :play_count 67 | boolean :available 68 | sort :title 69 | } 70 | 71 | after :save, :update_ion_indices 72 | before :delete, :delete_ion_indices 73 | end 74 | 75 | Album = IT::Album 76 | 77 | -------------------------------------------------------------------------------- /test/unit/boolean_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class BooleanTest < Test::Unit::TestCase 4 | setup do 5 | end 6 | 7 | test "boolean search" do 8 | @items = { 9 | :a => Album.create(available: true, title: "Morning Scifi"), 10 | :b => Album.create(available: true, title: "Intensify", body: 'Special Edition'), 11 | :c => Album.create(available: false, title: "BT Emotional Technology", body: 'Special Edition'), 12 | :d => Album.create(available: false, title: "BT Movement in Still Life") 13 | } 14 | search = Album.ion.search { boolean :available, true } 15 | assert_ids %w(a b), for: search 16 | end 17 | 18 | test "forgiving boolean indexing" do 19 | @items = { 20 | :a => Album.create(available: true, title: "Morning Scifi"), 21 | :b => Album.create(available: 'true', title: "Intensify", body: 'Special Edition'), 22 | :c => Album.create(available: false, title: "BT Emotional Technology", body: 'Special Edition'), 23 | :d => Album.create(available: 'false', title: "BT Movement in Still Life"), 24 | :g => Album.create(available: 0, title: "Morning Scifi"), 25 | :h => Album.create(available: '0', title: "Intensify", body: 'Special Edition'), 26 | :i => Album.create(available: true, title: "BT Emotional Technology", body: 'Special Edition'), 27 | :j => Album.create(available: 'monkey', title: "BT Movement in Still Life") 28 | } 29 | 30 | search = Album.ion.search { boolean :available, true } 31 | assert_ids %w(a b i j), for: search 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/unit/boost_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class BoostTest < Test::Unit::TestCase 4 | setup do 5 | # Fake entries that should NOT be returned 6 | 5.times { Album.create title: lorem, body: '' } 7 | 8 | @items = { 9 | :a => Album.create(title: "Morning Scifi"), 10 | :b => Album.create(title: "Intensify", body: 'Special Edition'), 11 | :c => Album.create(title: "BT Emotional Technology", body: 'Special Edition'), 12 | :d => Album.create(title: "BT Movement in Still Life") 13 | } 14 | end 15 | 16 | test "scores" do 17 | search = Album.ion.search { 18 | text :title, "bt" 19 | boost(2.5) { text :body, "special edition" } 20 | } 21 | 22 | assert_ids %w(c d), for: search, ordered: true 23 | 24 | @scores = scores_for search 25 | assert_score c: 2.5 26 | assert_score d: 1.0 27 | end 28 | 29 | test "scores 2" do 30 | search = Album.ion.search { 31 | text :title, "bt" 32 | boost(0.5) { text :body, "special edition" } 33 | } 34 | 35 | assert_ids %w(d c), for: search, ordered: true 36 | 37 | @scores = scores_for search 38 | assert_score c: 0.5 39 | assert_score d: 1.0 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/unit/config_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class ConfigTest < Test::Unit::TestCase 4 | setup do 5 | # Mock erasing the config 6 | Ion.instance_variable_set :@config, nil 7 | @config = Ion.config 8 | end 9 | 10 | test "config" do 11 | assert @config.is_a?(Ion::Config) 12 | end 13 | 14 | test "stopwords" do 15 | assert @config.stopwords.include?('a') 16 | end 17 | 18 | test "question mark" do 19 | assert @config.stopwords? 20 | 21 | assert ! @config.foobar? 22 | @config.foobar = 2 23 | assert @config.foobar? 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/unit/hash_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class HashTest < Test::Unit::TestCase 4 | setup do 5 | @search1 = Album.ion.search { 6 | text :title, "Vloop" 7 | text :body, "Secher betrib" 8 | any_of { 9 | text :body, "betrib" 10 | text :body, "betrib" 11 | any_of { 12 | text :body, "betrib" 13 | text :body, "betrib" 14 | } 15 | } 16 | } 17 | 18 | @search2 = Album.ion.search { 19 | text :title, "Vloop" 20 | text :body, "Secher betrib" 21 | any_of { 22 | text :body, "betrib" 23 | text :body, "betrib" 24 | any_of { 25 | text :body, "betrib" 26 | text :body, "betrib" 27 | } 28 | } 29 | } 30 | 31 | @search3 = Album.ion.search { 32 | text :title, "Vloop" 33 | text :body, "Secher betrib" 34 | any_of { 35 | text :body, "betrib" 36 | text :body, "betrib" 37 | score(2.5) { 38 | text :body, "betrib" 39 | } 40 | } 41 | boost(2.0) { text :title, "x" } 42 | } 43 | end 44 | 45 | test "hash test" do 46 | assert_equal @search1.to_hash, @search2.to_hash 47 | assert @search3.to_hash != @search2.to_hash 48 | end 49 | 50 | test "load from hash" do 51 | hash = {"scopes"=>[{"gate"=>"any", "scopes"=>[{"gate"=>"any", "score"=>2.5, "searches"=>[{"index"=>{"type"=>"text", "name"=>"body"}, "value"=>"betrib"}]}], "searches"=>[{"index"=>{"type"=>"text", "name"=>"body"}, "value"=>"betrib"}, {"index"=>{"type"=>"text", "name"=>"body"}, "value"=>"betrib"}]}], "searches"=>[{"index"=>{"type"=>"text", "name"=>"title"}, "value"=>"Vloop"}, {"index"=>{"type"=>"text", "name"=>"body"}, "value"=>"Secher betrib"}], "boosts"=>[{"scope"=>{"searches"=>[{"index"=>{"type"=>"text", "name"=>"title"}, "value"=>"x"}]}, "amount"=>2.0}]} 52 | h = Album.ion.search hash 53 | assert @search3.to_hash == h.to_hash 54 | end 55 | 56 | test "load from hash 2" do 57 | hash = @search1.to_hash 58 | h = Album.ion.search hash 59 | assert @search2.to_hash == h.to_hash 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/unit/ion_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class IonTest < Test::Unit::TestCase 4 | setup do 5 | # Fake entries that should NOT be returned 6 | 5.times { Album.create title: lorem, body: '' } 7 | end 8 | 9 | test "single result" do 10 | item = Album.create title: "First Lady of Song", body: "Ella Fitzgerald" 11 | search = Album.ion.search { text :title, "lady" } 12 | 13 | assert_equal [item.id], search.ids 14 | end 15 | 16 | test "lambda index" do 17 | item = Album.create title: "First Lady of Song", body: "Ella Fitzgerald" 18 | search = Album.ion.search { text :also_title, "lady" } 19 | 20 | assert_equal [item.id], search.ids 21 | end 22 | 23 | test "stripping punctuations" do 24 | item = Album.create title: "Leapin-and-Lopin'" 25 | search = Album.ion.search { text :title, "leapin lopin" } 26 | 27 | assert_equal [item.id], search.ids 28 | end 29 | 30 | test "many results" do 31 | albums = (0..10).map { 32 | Album.create title: "Hi #{lorem}" 33 | } 34 | 35 | search = Album.ion.search { text :title, "Hi" } 36 | 37 | assert_equal albums.size, search.size 38 | assert_equal albums.map(&:id).sort, search.ids.sort 39 | end 40 | 41 | test "multi keywords" do 42 | album = Album.create title: "Mingus at the Bohemia" 43 | search = Album.ion.search { text :title, "mingus bohemia" } 44 | 45 | assert_equal [album.id], search.ids.sort 46 | end 47 | 48 | test "multi keywords fail" do 49 | album = Album.create title: "Mingus at the Bohemia" 50 | search = Album.ion.search { text :title, "bohemia helicopter" } 51 | 52 | assert_equal [], search.ids.sort 53 | end 54 | 55 | test "search with arity" do 56 | item = Album.create title: "Maiden Voyage" 57 | search = Album.ion.search { |q| q.text :title, "maiden voyage" } 58 | 59 | assert_equal [item.id], search.ids.sort 60 | end 61 | 62 | test "search within one index only" do 63 | album1 = Album.create title: "Future 2 Future", body: "Herbie Hancock" 64 | album2 = Album.create title: "Best of Herbie", body: "VA" 65 | 66 | search = Album.ion.search { text :title, "herbie" } 67 | 68 | assert_equal [album2.id], search.ids.sort 69 | end 70 | 71 | test "count" do 72 | 5.times { Album.create(title: "Bebel Gilberto #{lorem}") } 73 | 74 | search = Album.ion.search { text :title, "Bebel Gilberto" } 75 | assert_equal 5, search.count 76 | end 77 | 78 | test "scores" do 79 | @items = { 80 | a: Album.create(title: "Future 2 Future", body: "Herbie Hancock"), 81 | b: Album.create(title: "Best of Herbie", body: "Herbie Hancock") 82 | } 83 | 84 | search = Album.ion.search { 85 | any_of { 86 | text :title, "herbie" 87 | text :body, "herbie" 88 | } 89 | } 90 | 91 | # Album2 will go first because it matches both 92 | assert_ids %w(a b), for: search 93 | 94 | # Check if the scores are right 95 | @scores = scores_for search 96 | assert_score b: 2.0 97 | assert_score a: 1.0 98 | end 99 | 100 | test "right scoring" do 101 | @items = { 102 | a: Album.create(title: "Best of Herbie", body: "Herbie Hancock") 103 | } 104 | 105 | search = Album.ion.search { 106 | text :body, "Herbie Hancock" 107 | } 108 | 109 | @scores = scores_for search 110 | assert_score a: 1.0 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /test/unit/metaphone_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class IT::Song < Ohm::Model 4 | include Ion::Entity 5 | include Ohm::Callbacks 6 | 7 | attribute :title 8 | attribute :body 9 | 10 | ion { 11 | text :title 12 | metaphone :body 13 | } 14 | 15 | after :save, :update_ion_indices 16 | end 17 | 18 | Song = IT::Song 19 | 20 | class MetaphoneTest < Test::Unit::TestCase 21 | setup do 22 | # Fake entries that should NOT be returned 23 | 5.times { Song.create title: lorem, body: '' } 24 | 25 | @items = { 26 | a: Song.create(body: "Stephanie"), 27 | b: Song.create(body: "Ztephanno..!") 28 | } 29 | end 30 | 31 | test "metaphone" do 32 | search = Song.ion.search { metaphone :body, "Stefan" } 33 | 34 | assert_ids %w(a b), for: search 35 | end 36 | 37 | end 38 | -------------------------------------------------------------------------------- /test/unit/number_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class Number < Test::Unit::TestCase 4 | setup do 5 | @items = { 6 | :a => Album.create(play_count: 1), 7 | :b => Album.create(play_count: 2), 8 | :c => Album.create(play_count: 3), 9 | :d => Album.create(play_count: 4), 10 | :e => Album.create(play_count: 4), 11 | :f => Album.create(play_count: 4), 12 | :g => Album.create(play_count: 5), 13 | :h => Album.create(play_count: 5) 14 | } 15 | end 16 | 17 | test "numb3rs" do 18 | search = Album.ion.search { number :play_count, 4 } 19 | assert_ids %w(d e f), for: search 20 | end 21 | 22 | test "greater than" do 23 | search = Album.ion.search { number :play_count, gt: 3 } 24 | assert_ids %w(d e f g h), for: search 25 | end 26 | 27 | test "greater than or equal" do 28 | search = Album.ion.search { number :play_count, min: 3 } 29 | assert_ids %w(c d e f g h), for: search 30 | end 31 | 32 | test "less than or equal" do 33 | search = Album.ion.search { number :play_count, max: 3 } 34 | assert_ids %w(a b c), for: search 35 | end 36 | 37 | test "greater than and less than" do 38 | search = Album.ion.search { number :play_count, lt: 5, gt: 1 } 39 | assert_ids %w(b c d e f), for: search 40 | end 41 | 42 | test "greater than equal and less than equal" do 43 | search = Album.ion.search { number :play_count, min: 1, max: 3 } 44 | assert_ids %w(a b c), for: search 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/unit/options_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class IT::Options < Ohm::Model 4 | include Ion::Entity 5 | include Ohm::Callbacks 6 | 7 | attribute :text 8 | end 9 | 10 | class OptionsTest < Test::Unit::TestCase 11 | test "foo" do 12 | assert_raise(Ion::InvalidIndexType) do 13 | IT::Options.ion { 14 | field :footype, :text 15 | } 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/unit/range_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class RangeTest < Test::Unit::TestCase 4 | setup do 5 | # Fake entries that should NOT be returned 6 | 5.times { Album.create title: lorem, body: '' } 7 | 8 | @items = { 9 | :a => Album.create(title: "X Morning Scifi"), 10 | :b => Album.create(title: "X Intensify", body: 'Special Edition'), 11 | :c => Album.create(title: "X BT Emotional Technology", body: 'Special Edition'), 12 | :d => Album.create(title: "X BT Movement in Still Life"), 13 | :e => Album.create(title: "X Involver"), 14 | :f => Album.create(title: "X Bullet"), 15 | :g => Album.create(title: "X Fear of a Silver Planet"), 16 | :h => Album.create(title: "X Renaissance: Everybody") 17 | } 18 | end 19 | 20 | test "ranges" do 21 | search = Album.ion.search { text :title, 'x' } 22 | ids = search.ids 23 | 24 | search.range from: 2, limit: 1 25 | 26 | assert_equal (1..1), search.range 27 | assert_equal ids[1..1], search.ids 28 | 29 | search.range from: 4, to: 4 30 | assert_equal (3..3), search.range 31 | assert_equal ids[3..3], search.ids 32 | 33 | search.range from: 1, to: 3 34 | assert_equal (0..2), search.range 35 | assert_equal ids[0..2], search.ids 36 | 37 | search.range (3..4) 38 | assert_equal (3..4), search.range 39 | assert_equal ids[3..4], search.ids 40 | 41 | search.range (3..-1) 42 | assert_equal (3..-1), search.range 43 | assert_equal ids[3..-1], search.ids 44 | 45 | search.range (3..-3) 46 | assert_equal (3..-3), search.range 47 | assert_equal ids[3..-3], search.ids 48 | 49 | search.range (3...4) 50 | assert_equal (3...4), search.range 51 | assert_equal ids[3...4], search.ids 52 | 53 | search.range (3...-1) 54 | assert_equal (3...-1), search.range 55 | assert_equal ids[3...-1], search.ids 56 | 57 | search.range :all 58 | assert_equal (0..-1), search.range 59 | assert_equal ids, search.ids 60 | end 61 | 62 | test "iterating through ranges" do 63 | search = Album.ion.search { text :title, 'x' } 64 | ids = search.ids 65 | search.range from: 2, limit: 4 66 | 67 | assert_equal 4, search.ids.size 68 | assert_equal 8, search.size 69 | 70 | injected = search.inject(0) { |i, _| i += 1 } 71 | assert_equal 4, injected 72 | 73 | mapped = search.map { 1 } 74 | assert_equal [1,1,1,1], mapped 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/unit/score_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class ScoreTest < Test::Unit::TestCase 4 | setup do 5 | # Fake entries that should NOT be returned 6 | 5.times { Album.create title: lorem, body: '' } 7 | 8 | @items = { 9 | :a => Album.create(title: "Secher glite"), 10 | :b => Album.create(title: "Shebboleth mordor"), 11 | :c => Album.create(title: "Rexan ruffush"), 12 | :d => Album.create(title: "Parctris leroux") 13 | } 14 | end 15 | 16 | test "scores" do 17 | search = Album.ion.search { 18 | score(2.5) { 19 | text :title, "secher" 20 | } 21 | } 22 | 23 | assert_ids %w(a), for: search 24 | 25 | @scores = scores_for search 26 | assert_score a: 2.5 27 | end 28 | 29 | test "nested scores" do 30 | search = Album.ion.search { 31 | score(2.5) { 32 | score(2.0) { 33 | text :title, "secher" 34 | } 35 | } 36 | } 37 | 38 | assert_ids %w(a), for: search 39 | 40 | @scores = scores_for search 41 | assert_score a: 5.0 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/unit/sort_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class SortTest < Test::Unit::TestCase 4 | setup do 5 | # Fake entries that should NOT be returned 6 | 5.times { Album.create title: lorem, body: '' } 7 | 8 | @items = { 9 | :r1 => Album.create(title: "Eurostile x"), 10 | :r2 => Album.create(title: "Garamond x"), 11 | :r3 => Album.create(title: "Didot x"), 12 | :r4 => Album.create(title: "Caslon x"), 13 | :r5 => Album.create(title: "Avenir x"), 14 | :r6 => Album.create(title: "The Bodoni x"), 15 | :r7 => Album.create(title: "Helvetica x"), 16 | :r8 => Album.create(title: "Futura x") 17 | } 18 | 19 | @search = Album.ion.search { text :title, 'x' } 20 | @search.sort_by :title 21 | end 22 | 23 | test "sort" do 24 | assert_ids %w(r5 r6 r4 r3 r1 r8 r2 r7), for: @search, ordered: true 25 | end 26 | 27 | test "range 1" do 28 | @search.range from: 1, limit: 4 29 | assert_ids %w(r5 r6 r4 r3), for: @search, ordered: true 30 | end 31 | 32 | test "range 2" do 33 | @search.range from: 2, limit: 4 34 | assert_ids %w(r6 r4 r3 r1), for: @search, ordered: true 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/unit/stopwords_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class Stopwords < Test::Unit::TestCase 4 | setup do 5 | # Fake entries that should NOT be returned 6 | 5.times { Album.create title: lorem, body: '' } 7 | 8 | @items = { 9 | :a => Album.create(title: "Morning Scifi"), 10 | :b => Album.create(title: "Intensify", body: 'Special Edition'), 11 | :c => Album.create(title: "Can't Emotional Technology", body: 'Special Edition'), 12 | :d => Album.create(title: "UNKLE Where Didn't The Night Fall") 13 | } 14 | end 15 | 16 | test "stopwords" do 17 | search = Album.ion.search { text :title, "morning is the scifi" } 18 | assert_ids %w(a), for: search 19 | end 20 | 21 | test "" do 22 | search = Album.ion.search { text :title, "u.n.k.l.e." } 23 | assert_ids %w(d), for: search 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/unit/subscope_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class SubscopeTest < Test::Unit::TestCase 4 | setup do 5 | # Fake entries that should NOT be returned 6 | 5.times { Album.create title: lorem, body: '' } 7 | 8 | @items = { 9 | :a => Album.create(title: "Secher glite"), 10 | :b => Album.create(title: "Shebboleth mordor"), 11 | :c => Album.create(title: "Rexan ruffush"), 12 | :d => Album.create(title: "Parctris leroux") 13 | } 14 | end 15 | 16 | test "nested 1" do 17 | search = Album.ion.search { 18 | any_of { 19 | text :title, "secher" 20 | text :title, "mordor" 21 | all_of { 22 | text :title, "rexan" 23 | text :title, "ruffush" 24 | } 25 | } 26 | } 27 | 28 | assert_ids %w(a b c), for: search 29 | end 30 | 31 | test "nested 2" do 32 | search = Album.ion.search { 33 | any_of { 34 | text :title, "shebboleth" 35 | all_of { 36 | text :title, "rexan" 37 | text :title, "ruffush" 38 | } 39 | } 40 | } 41 | 42 | assert_ids %w(b c), for: search 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/unit/ttl_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class TtlTest < Test::Unit::TestCase 4 | test "key TTL test" do 5 | Album.create title: "Vloop", body: "Secher Betrib" 6 | 7 | old_keys = redis.keys('Ion:*').reject { |s| s['~'] } 8 | 9 | # This should make a bunch of temp keys 10 | search = Album.ion.search { 11 | text :title, "Vloop" 12 | text :body, "Secher betrib" 13 | any_of { 14 | text :body, "betrib" 15 | text :body, "betrib" 16 | any_of { 17 | text :body, "betrib" 18 | text :body, "betrib" 19 | } 20 | } 21 | } 22 | 23 | search.ids 24 | 25 | # Ensure all temp keys will die eventually 26 | keys = redis.keys('Ion:~:*') 27 | keys.each { |key| 28 | ttl = redis.ttl(key) 29 | assert ttl >= 0 30 | } 31 | 32 | new_keys = redis.keys('Ion:*').reject { |s| s['~'] } 33 | 34 | # Ensure that no keys died 35 | assert_equal old_keys.sort, new_keys.sort 36 | 37 | # Ensure they're all alive 38 | new_keys.each { |key| assert_equal -1, redis.ttl(key) } 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/unit/update_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class UpdateTest < Test::Unit::TestCase 4 | setup do 5 | # Fake entries that should NOT be returned 6 | 5.times { Album.create title: lorem, body: '' } 7 | end 8 | 9 | test "Deleting records" do 10 | item = Album.create title: "Shobeh" 11 | search = Album.ion.search { text :title, "Shobeh" } 12 | id = item.id 13 | 14 | # Search should see it 15 | assert_equal [id], search.ids 16 | 17 | item.delete 18 | assert Album[id].nil? 19 | 20 | search = Album.ion.search { text :title, "Shobeh" } 21 | assert_equal [], search.ids 22 | end 23 | 24 | test "Editing records" do 25 | item = Album.create title: "Heshela" 26 | search = Album.ion.search { text :title, "Heshela" } 27 | 28 | # Search should see it 29 | assert_equal [item.id], search.ids 30 | 31 | # Edit 32 | item.title = "Mathroux" 33 | item.save 34 | 35 | # Now search should not see it 36 | search = Album.ion.search { text :title, "Heshela" } 37 | assert_equal [], search.ids 38 | 39 | search = Album.ion.search { text :title, "mathroux" } 40 | assert_equal [item.id], search.ids 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/unit/wrapper_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class WrapperTest < Test::Unit::TestCase 4 | setup do 5 | # Falses 6 | 5.times { Album.create title: lorem, body: '' } 7 | end 8 | 9 | test "wrapper" do 10 | 10.times { Album.create :title => "Foo #{lorem}" } 11 | 12 | person = Ion::Wrapper.new('IT::Album') 13 | 14 | person.ion { text :title } 15 | 16 | search = person.ion.search { text :title, "Foo" } 17 | 18 | search2 = Album.ion.search { text :title, "Foo" } 19 | 20 | assert search.to_hash == search2.to_hash 21 | assert search.ids == search2.ids 22 | assert search.ids.any? 23 | end 24 | end 25 | --------------------------------------------------------------------------------