├── .gitignore ├── install.rb ├── uninstall.rb ├── init.rb ├── test ├── fixtures │ ├── example_model.rb │ ├── schema.rb │ └── example_models.yml ├── test_helper.rb └── dystopian_index_test.rb ├── tasks └── dystopian_index_tasks.rake ├── Rakefile ├── MIT-LICENSE ├── benchmarks └── indexing.rb ├── README.textile └── lib └── dystopian_index.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.swo 3 | -------------------------------------------------------------------------------- /install.rb: -------------------------------------------------------------------------------- 1 | # Install hook code here 2 | -------------------------------------------------------------------------------- /uninstall.rb: -------------------------------------------------------------------------------- 1 | # Uninstall hook code here 2 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | $:.unshift "#{File.dirname(__FILE__)}/lib" 2 | require 'rufus/tokyo/dystopia' 3 | require 'dystopian_index' 4 | 5 | DystopianIndex.enable 6 | -------------------------------------------------------------------------------- /test/fixtures/example_model.rb: -------------------------------------------------------------------------------- 1 | class ExampleModel < ActiveRecord::Base 2 | dystopian_index do 3 | indexes :content 4 | indexes :name 5 | order_by :created_at 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/fixtures/schema.rb: -------------------------------------------------------------------------------- 1 | unless ExampleModel.table_exists? 2 | ActiveRecord::Schema.define do 3 | create_table 'example_models', :force => true do |t| 4 | t.column 'name', :text 5 | t.column 'content', :text 6 | t.timestamps 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /tasks/dystopian_index_tasks.rake: -------------------------------------------------------------------------------- 1 | require File.join(RAILS_ROOT, 'config', 'environment') 2 | 3 | namespace :dystopia do 4 | desc "Indexes all models" 5 | task :index do 6 | DystopianIndex.index_all 7 | end 8 | 9 | desc "Runs benchmarks" 10 | task :benchmarks do 11 | Dir["#{File.dirname(__FILE__)}/../benchmarks/*.rb"].each do |file| 12 | load file 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'rake/testtask' 3 | require 'rake/rdoctask' 4 | 5 | desc 'Default: run unit tests.' 6 | task :default => :test 7 | 8 | desc 'Test the dystopian_index plugin.' 9 | Rake::TestTask.new(:test) do |t| 10 | t.libs << 'lib' 11 | t.libs << 'test' 12 | t.pattern = 'test/**/*_test.rb' 13 | t.verbose = true 14 | end 15 | 16 | desc 'Generate documentation for the dystopian_index plugin.' 17 | Rake::RDocTask.new(:rdoc) do |rdoc| 18 | rdoc.rdoc_dir = 'rdoc' 19 | rdoc.title = 'DystopianIndex' 20 | rdoc.options << '--line-numbers' << '--inline-source' 21 | rdoc.rdoc_files.include('README') 22 | rdoc.rdoc_files.include('lib/**/*.rb') 23 | end 24 | -------------------------------------------------------------------------------- /test/fixtures/example_models.yml: -------------------------------------------------------------------------------- 1 | alex: 2 | name: Alex Young 3 | content: Software engineer based in London, England. 4 | created_at: 2009-07-01 18:33:50 5 | yuka: 6 | name: Yuka Young 7 | content: Bilingual videogames enthusiast based in London. 8 | created_at: 2009-07-02 18:33:50 9 | rowland: 10 | name: Rowland Watkins 11 | content: Programming and business consultant based in Hong Kong. 12 | created_at: 2009-06-06 18:33:50 13 | kev: 14 | name: Kevin Ford 15 | content: Systems administrator currently based in Swindon, Wilts. 16 | created_at: 2009-07-20 18:33:50 17 | simon: 18 | name: Simon Starr 19 | content: Rails developer based in Yorkshire. 20 | created_at: 2009-07-30 18:33:50 21 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'test/unit' 2 | require 'rubygems' 3 | 4 | # Include the application's test_helper 5 | unless defined?(ActiveRecord) 6 | begin 7 | require '../../../test/test_helper' 8 | rescue LoadError 9 | require 'test_helper' 10 | end 11 | end 12 | 13 | DYSTOPIA_PLUGIN_PATH = File.join(RAILS_ROOT, 'vendor', 'plugins', 'dystopian_index') 14 | fixtures_path = File.join(DYSTOPIA_PLUGIN_PATH, 'test', 'fixtures') 15 | 16 | DystopianIndex.config.db_path = File.join(DYSTOPIA_PLUGIN_PATH, 'test', 'fixtures', 'indexes') 17 | DystopianIndex.config.model_directories = [File.join(DYSTOPIA_PLUGIN_PATH, 'test', 'fixtures')] 18 | 19 | require File.join(fixtures_path, 'example_model') 20 | require File.join(fixtures_path, 'schema') 21 | 22 | Fixtures.create_fixtures(fixtures_path, ActiveRecord::Base.connection.tables) 23 | -------------------------------------------------------------------------------- /test/dystopian_index_test.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'test_helper') 2 | 3 | class DystopianIndexTest < ActiveSupport::TestCase 4 | def setup 5 | ExampleModel.clear_dystopian_index! 6 | ExampleModel.index_all 7 | end 8 | 9 | test "search" do 10 | assert_equal 1, ExampleModel.search('alex').size 11 | end 12 | 13 | test "pagination" do 14 | assert_equal 2, ExampleModel.search('based', :per_page => 2, :page => 1).size 15 | end 16 | 17 | # The example model has order_by enabled 18 | test "sorting" do 19 | results = ExampleModel.search('based') 20 | assert results.first.created_at < results.last.created_at 21 | end 22 | 23 | test "deletion removes records from index" do 24 | assert !ExampleModel.search('alex').empty? 25 | ExampleModel.search('alex').each { |m| m.destroy } 26 | assert ExampleModel.search('alex').empty? 27 | end 28 | 29 | test "searching for nil should return nil" do 30 | assert_equal ExampleModel.search(nil), nil 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Alex R. Young, for Helicoid Limited 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /benchmarks/indexing.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require File.join(File.dirname(__FILE__), '..', 'test', 'test_helper') 3 | require 'benchmark' 4 | require 'faker' 5 | 6 | def clear! 7 | ExampleModel.clear_dystopian_index! 8 | ExampleModel.delete_all 9 | DystopianIndex.config.disabled = false 10 | end 11 | 12 | def fake_record 13 | ExampleModel.create :name => Faker::Name.name, :content => Faker::Lorem.paragraph 14 | end 15 | 16 | count = 500 17 | 18 | puts "Running benchmarks. Please make a cup of tea or coffee." 19 | clear! 20 | 21 | # 22 | # Benchmark #1 23 | # 24 | 25 | bm = Benchmark.measure do 26 | count.times do 27 | fake_record 28 | end 29 | end 30 | 31 | puts "[with indexing enabled] #{count} records created in: #{bm}" 32 | clear! 33 | 34 | # 35 | # Benchmark #2 36 | # 37 | 38 | DystopianIndex.config.disabled = true 39 | 40 | bm = Benchmark.measure do 41 | count.times do 42 | fake_record 43 | end 44 | end 45 | 46 | puts "[with indexing disabled] #{count} records created in: #{bm}" 47 | clear! 48 | 49 | # 50 | # Benchmark #3 51 | # 52 | 53 | DystopianIndex.config.disabled = true 54 | 55 | db = Rufus::Tokyo::Dystopia::Core.new ExampleModel.dystopian_config[:db] 56 | 57 | bm = Benchmark.measure do 58 | count.times do 59 | model = fake_record 60 | db.store model.id, model.dystopian_payload 61 | end 62 | end 63 | 64 | db.close 65 | 66 | puts "[with a single dystopia connection] #{count} records created in: #{bm}" 67 | -------------------------------------------------------------------------------- /README.textile: -------------------------------------------------------------------------------- 1 | h2. DystopianIndex 2 | 3 | This is a Rails plugin that uses Tokyo Dystopia to index models. It's ideal for small projects that need a fast indexer because it's easy to setup and understand, and doesn't use too many resources or daemon processes. 4 | 5 | h3. Requirements 6 | 7 | Gems: 8 | 9 | * faker 10 | * rufus-tokyo 11 | 12 | h3. Installation 13 | 14 | Build and compile Tokyo Cabinet and Tokyo Dystopia from "tokyocabinet.sourceforge.net":http://tokyocabinet.sourceforge.net/ -- they're clean and simple C projects and should build on your Mac or Linux machine. 15 | 16 | Then install the "rufus-tokyo":http://github.com/jmettraux/rufus-tokyo gem. 17 | 18 | h3. Usage 19 | 20 | To use the plugin with a model, define an index like this: 21 | 22 |
23 |   dystopian_index do
24 |     indexes :content, :name
25 |     order_by :created_at
26 |   end
27 | 
28 | 29 | h3. Rake Tasks 30 | 31 |
32 | rake dystopia:benchmarks                  # Runs benchmarks
33 | rake dystopia:index                       # Indexes all models
34 | 
35 | 36 | Database files are stored in db/indexes. You can change the path where the database is stored by setting DystopianIndex.config.db_path. 37 | 38 | h3. Plugin Design 39 | 40 | * Each model has its own Dystopia index 41 | * Fields are concatenated into one big index record -- this means you can't currently search according to field 42 | * When an update is performed, the index is opened and closed on demand to help prevent data loss. I've indexed this scheme (see the benchmarks) and it's not too slow 43 | * Data information is stored in the first few bytes of each index record to help sort values 44 | * Indexes are stored in db/indexes/ -- the plugin will make this directory if required 45 | 46 | h3. TODO 47 | 48 | * Tests only run from within a Rails app, and by executing each test file with ruby 49 | * It might be better to create separate indexes for each field. 50 | * order_by needs desc 51 | * Sorting fetches every matching ID from dystopia and sorts according to date integers. There might be a better way to do this, but it's still pretty fast 52 | 53 | h3. Acknowledgements 54 | 55 | The Rake tasks need to load all models. This code was based on "ThinkingSphinx's":http://freelancing-god.github.com/ts/en/ code that does the same thing (which is a nice piece of work by the way). I hope to refactor this out later. 56 | 57 | Copyright (c) 2009 Alex R. Young, released under the MIT license 58 | -------------------------------------------------------------------------------- /lib/dystopian_index.rb: -------------------------------------------------------------------------------- 1 | require 'singleton' 2 | 3 | module DystopianIndex 4 | def self.debug ; true ; end 5 | 6 | def self.included(base) 7 | base.extend ActiveRecordHook 8 | end 9 | 10 | def self.enable 11 | config.reset 12 | 13 | if Object.const_defined? 'ActiveRecord' 14 | ActiveRecord::Base.class_eval { include DystopianIndex } 15 | end 16 | end 17 | 18 | def self.load_models 19 | config.model_directories.each do |base| 20 | Dir["#{base}**/*.rb"].each do |file| 21 | model_name = file.gsub(/^#{base}([\w_\/\\]+)\.rb/, '\1') 22 | 23 | next if model_name.nil? 24 | next if ::ActiveRecord::Base.send(:subclasses).detect { |model| 25 | model.name == model_name 26 | } 27 | 28 | begin 29 | model_name.camelize.constantize 30 | rescue LoadError 31 | model_name.gsub!(/.*[\/\\]/, '').nil? ? next : retry 32 | rescue NameError 33 | next 34 | end 35 | end 36 | end 37 | end 38 | 39 | def self.index_all 40 | load_models 41 | DystopianIndex.config.models.each do |table_name, settings| 42 | settings[:klass].clear_dystopian_index! 43 | settings[:klass].index_all 44 | end 45 | end 46 | 47 | def self.config 48 | DystopianIndex::Configuration.instance 49 | end 50 | 51 | class Builder 52 | def initialize(model) 53 | @model = model 54 | @fields = [] 55 | @order_by = nil 56 | 57 | Dir.mkdir(db_path) unless File.exists?(db_path) 58 | @db_name = File.join db_path, "#{model.table_name}.dys" 59 | end 60 | 61 | def db_path 62 | DystopianIndex.config.db_path 63 | end 64 | 65 | def order_by(field) 66 | @order_by = field 67 | end 68 | 69 | def indexes(*fields) 70 | @fields += fields 71 | end 72 | 73 | def apply! 74 | apply_methods! 75 | add_settings 76 | end 77 | 78 | private 79 | 80 | def apply_methods! 81 | @model.class_eval <<-RUBY 82 | include DystopianIndex::ModelMethods 83 | after_save :update_dystopian_index 84 | before_destroy :remove_from_index 85 | RUBY 86 | 87 | @model.extend DystopianIndex::ModelClassMethods 88 | end 89 | 90 | def add_settings 91 | DystopianIndex.config.models[@model.table_name] = { 92 | :fields => @fields, 93 | :db => @db_name, 94 | :klass => @model, 95 | :order_by => @order_by 96 | } 97 | end 98 | end 99 | 100 | class Configuration 101 | include Singleton 102 | 103 | attr_accessor :models, :app_root, :model_directories, :disabled, :db_path 104 | 105 | def reset 106 | self.app_root = RAILS_ROOT 107 | self.model_directories = ["#{app_root}/app/models/"] 108 | self.disabled = false 109 | self.db_path = File.join(self.app_root, 'db', 'indexes') 110 | end 111 | end 112 | 113 | module ModelClassMethods 114 | def dystopian_config 115 | DystopianIndex.config.models[table_name] 116 | end 117 | 118 | # == Examples 119 | # 120 | # Simple text search: 121 | # 122 | # User.search "name" 123 | # 124 | # With pagination: 125 | # 126 | # User.search "name", :page => (params[:page]), :per_page => 10 127 | # 128 | def search(*args) 129 | query = args.first 130 | return if query.nil? 131 | 132 | args = args.last.is_a?(Hash) ? args.last : {} 133 | args[:order] = dystopian_config[:order_by] 134 | paginate_results search_ids(query, args), args 135 | end 136 | 137 | def search_ids(query, args = {}) 138 | sort_results(with_dystopian_db { |db| db.search(query) }) 139 | end 140 | 141 | def clear_dystopian_index! 142 | with_dystopian_db do |db| 143 | db.clear 144 | end 145 | end 146 | 147 | def index_all 148 | find(:all).each do |model| 149 | model.update_dystopian_index 150 | end 151 | end 152 | 153 | def indexer_uses_timestamps? 154 | return false unless dystopian_config[:order_by] 155 | order_by = dystopian_config[:order_by].to_s 156 | if columns_hash[order_by] 157 | columns_hash[order_by].type == :datetime 158 | end 159 | end 160 | 161 | # This will optionally use the date integer (specified by order_by) to sort results 162 | def sort_results(ids, args = {}) 163 | if indexer_uses_timestamps? 164 | records = [] 165 | with_dystopian_db do |db| 166 | records = ids.collect { |id| [id, db.fetch(id)[0..12].to_i] } 167 | end 168 | records.sort! { |a, b| a[1] <=> b[1] } 169 | records.collect { |a| a[0] } 170 | else 171 | ids 172 | end 173 | end 174 | 175 | def paginate_results(ids, args) 176 | if args[:page] and defined?(WillPaginate) 177 | args[:page] = args[:page].to_i 178 | args[:per_page] = args[:per_page].to_i 179 | 180 | WillPaginate::Collection.create(args[:page], args[:per_page], ids.size) do |pager| 181 | start = (args[:page] - 1) * args[:per_page] 182 | pager.replace(find ids[start, args[:per_page]], :order => dystopian_config[:order_by]) 183 | end 184 | else 185 | find ids, args 186 | end 187 | end 188 | 189 | def with_dystopian_db 190 | db = Rufus::Tokyo::Dystopia::Core.new dystopian_config[:db] 191 | results = yield(db) 192 | ensure 193 | db.close if db.respond_to?(:close) 194 | results 195 | end 196 | end 197 | 198 | module ModelMethods 199 | def dystopian_fields 200 | self.class.dystopian_config[:fields] 201 | end 202 | 203 | def with_dystopian_db(&block) 204 | self.class.with_dystopian_db &block 205 | end 206 | 207 | def update_dystopian_index 208 | return if DystopianIndex.config.disabled 209 | with_dystopian_db do |db| 210 | db.store id, dystopian_payload 211 | end 212 | end 213 | 214 | def remove_from_index 215 | return if DystopianIndex.config.disabled 216 | with_dystopian_db do |db| 217 | db.delete id 218 | end 219 | end 220 | 221 | def dystopian_data 222 | dystopian_fields.collect { |field| read_attribute field }.join("\n") 223 | end 224 | 225 | def dystopian_timestamps 226 | if self.class.indexer_uses_timestamps? 227 | # to_f.to_i in case the value is DateTime 228 | read_attribute(self.class.dystopian_config[:order_by]).to_f.to_i.to_s.ljust(13) 229 | else 230 | '' 231 | end 232 | end 233 | 234 | def dystopian_payload 235 | "#{dystopian_timestamps}#{dystopian_data}" 236 | end 237 | end 238 | 239 | module ActiveRecordHook 240 | def dystopian_index(&block) 241 | DystopianIndex.config.models ||= {} 242 | 243 | builder = DystopianIndex::Builder.new(self) 244 | builder.instance_eval(&block) 245 | builder.apply! 246 | end 247 | end 248 | end 249 | --------------------------------------------------------------------------------