├── .document ├── .gitignore ├── CONTRIBUTORS.txt ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── elastic_searchable.gemspec ├── lib ├── elastic_searchable.rb └── elastic_searchable │ ├── active_record_extensions.rb │ ├── callbacks.rb │ ├── index.rb │ ├── queries.rb │ └── version.rb └── test ├── database.yml ├── helper.rb ├── setup_database.rb └── test_elastic_searchable.rb /.document: -------------------------------------------------------------------------------- 1 | lib/**/*.rb 2 | bin/* 3 | - 4 | features/**/*.feature 5 | LICENSE.txt 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # rcov generated 2 | coverage 3 | 4 | # rdoc generated 5 | rdoc 6 | 7 | # yard generated 8 | doc 9 | .yardoc 10 | 11 | # bundler 12 | .bundle 13 | Gemfile.lock 14 | 15 | # jeweler generated 16 | pkg 17 | 18 | # test files 19 | test/*.log 20 | test/*.sqlite3 21 | 22 | # For vim: 23 | *.swp 24 | 25 | # For MacOS: 26 | .DS_Store 27 | 28 | # git files 29 | *.orig 30 | 31 | # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore: 32 | # 33 | # * Create a file at ~/.gitignore 34 | # * Include files you want ignored 35 | # * Run: git config --global core.excludesfile ~/.gitignore 36 | # 37 | # After doing this, these files will be ignored in all your git projects, 38 | # saving you from having to 'pollute' every project you touch with them 39 | # 40 | # Not sure what to needs to be ignored for particular editors/OSes? Here's some ideas to get you started. (Remember, remove the leading # of the line) 41 | # 42 | # For TextMate 43 | #*.tmproj 44 | #tmtags 45 | # 46 | # For emacs: 47 | #*~ 48 | #\#* 49 | #.\#* 50 | 51 | -------------------------------------------------------------------------------- /CONTRIBUTORS.txt: -------------------------------------------------------------------------------- 1 | Ryan Sonnek - Original Author 2 | 3 | 4 | Complete list of contributors: 5 | https://github.com/socialcast/elastic_searchable/contributors 6 | 7 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gemspec 4 | 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2011 Socialcast, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # elastic_searchable 2 | 3 | Integrate the elasticsearch library into Rails. 4 | 5 | ## Usage 6 | 7 | ```ruby 8 | class Blog < ActiveRecord::Base 9 | elastic_searchable 10 | end 11 | 12 | results = Blog.search 'foo' 13 | ``` 14 | 15 | ## Features 16 | 17 | * fast. fast! FAST! 30% faster than rubberband on average. 18 | * active record callbacks automatically keep search index up to date as your data changes 19 | * out of the box background indexing of data using backgrounded. Don't lock up a foreground process waiting on a background job! 20 | * integrates with will_paginate library for easy pagination of search results 21 | 22 | ## Installation 23 | 24 | ```ruby 25 | # Bundler Gemfile 26 | gem 'elastic_searchable' 27 | ``` 28 | 29 | ## Configuration 30 | 31 | ```ruby 32 | # config/initializers/elastic_searchable.rb 33 | # (optional) customize elasticsearch host 34 | # default is localhost:9200 35 | ElasticSearchable.base_uri = 'server:9200' 36 | ``` 37 | 38 | ## Contributing 39 | 40 | * Fork the project 41 | * Fix the issue 42 | * Add unit tests 43 | * Submit pull request on github 44 | 45 | See CONTRIBUTORS.txt for list of project contributors 46 | 47 | ## Copyright 48 | 49 | Copyright (c) 2011 Socialcast, Inc. 50 | See LICENSE.txt for further details. 51 | 52 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler::GemHelper.install_tasks 3 | 4 | require 'rake' 5 | 6 | require 'rake/testtask' 7 | Rake::TestTask.new(:test) do |test| 8 | test.libs << 'lib' << 'test' 9 | test.pattern = 'test/**/test_*.rb' 10 | test.verbose = true 11 | end 12 | task :default => :test 13 | 14 | -------------------------------------------------------------------------------- /elastic_searchable.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "elastic_searchable/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = %q{elastic_searchable} 7 | s.version = ElasticSearchable::VERSION 8 | s.platform = Gem::Platform::RUBY 9 | s.authors = ["Ryan Sonnek"] 10 | s.email = ["ryan@codecrate.com"] 11 | s.homepage = %q{http://github.com/wireframe/elastic_searchable} 12 | s.summary = %q{elastic search for activerecord} 13 | s.description = %q{integrate the elastic search engine with rails} 14 | 15 | s.rubyforge_project = "elastic_searchable" 16 | 17 | s.add_runtime_dependency(%q, ["~> 2.3.5"]) 18 | s.add_runtime_dependency(%q, ["~> 0.6.0"]) 19 | s.add_runtime_dependency(%q, ["~> 0.7.0"]) 20 | s.add_runtime_dependency(%q, ["~> 2.3.15"]) 21 | s.add_runtime_dependency(%q, ["~> 1.0.5"]) 22 | s.add_development_dependency(%q, [">= 0"]) 23 | s.add_development_dependency(%q, [">= 0"]) 24 | s.add_development_dependency(%q, ["~> 1.5.2"]) 25 | s.add_development_dependency(%q, [">= 0"]) 26 | s.add_development_dependency(%q, ["~> 1.3.2"]) 27 | s.add_development_dependency(%q, [">= 0"]) 28 | 29 | s.files = `git ls-files`.split("\n") 30 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 31 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 32 | s.require_paths = ["lib"] 33 | end 34 | 35 | -------------------------------------------------------------------------------- /lib/elastic_searchable.rb: -------------------------------------------------------------------------------- 1 | require 'httparty' 2 | require 'logger' 3 | require 'elastic_searchable/active_record_extensions' 4 | 5 | module ElasticSearchable 6 | DEFAULT_INDEX = 'elastic_searchable' 7 | include HTTParty 8 | format :json 9 | base_uri 'localhost:9200' 10 | 11 | class ElasticError < StandardError; end 12 | class << self 13 | attr_accessor :logger, :default_index, :offline 14 | 15 | # execute a block of work without reindexing objects 16 | def offline(&block) 17 | @offline = true 18 | yield 19 | ensure 20 | @offline = false 21 | end 22 | def offline? 23 | !!@offline 24 | end 25 | # encapsulate encoding hash into json string 26 | # support Yajl encoder if installed 27 | def encode_json(options = {}) 28 | defined?(Yajl) ? Yajl::Encoder.encode(options) : ActiveSupport::JSON.encode(options) 29 | end 30 | # perform a request to the elasticsearch server 31 | # configuration: 32 | # ElasticSearchable.base_uri 'host:port' controls where to send request to 33 | # ElasticSearchable.debug_output outputs all http traffic to console 34 | def request(method, url, options = {}) 35 | options.merge! :headers => {'Content-Type' => 'application/json'} 36 | options.merge! :body => ElasticSearchable.encode_json(options[:json_body]) if options[:json_body] 37 | 38 | response = self.send(method, url, options) 39 | logger.debug "elasticsearch request: #{method} #{url} #{"took #{response['took']}ms" if response['took']}" 40 | validate_response response 41 | response 42 | end 43 | 44 | private 45 | # all elasticsearch rest calls return a json response when an error occurs. ex: 46 | # {error: 'an error occurred' } 47 | def validate_response(response) 48 | error = response['error'] || "Error executing request: #{response.inspect}" 49 | raise ElasticSearchable::ElasticError.new(error) if response['error'] || ![Net::HTTPOK, Net::HTTPCreated].include?(response.response.class) 50 | end 51 | end 52 | end 53 | 54 | # configure default logger to standard out with info log level 55 | ElasticSearchable.logger = Logger.new STDOUT 56 | ElasticSearchable.logger.level = Logger::INFO 57 | 58 | # configure default index to be elastic_searchable 59 | # one index can hold many object 'types' 60 | ElasticSearchable.default_index = ElasticSearchable::DEFAULT_INDEX 61 | 62 | -------------------------------------------------------------------------------- /lib/elastic_searchable/active_record_extensions.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | require 'after_commit' 3 | require 'backgrounded' 4 | require 'elastic_searchable/queries' 5 | require 'elastic_searchable/callbacks' 6 | require 'elastic_searchable/index' 7 | 8 | module ElasticSearchable 9 | module ActiveRecordExtensions 10 | # Valid options: 11 | # :index (optional) configure index to store data in. default to ElasticSearchable.default_index 12 | # :type (optional) configue type to store data in. default to model table name 13 | # :index_options (optional) configure index properties (ex: tokenizer) 14 | # :mapping (optional) configure field properties for this model (ex: skip analyzer for field) 15 | # :if (optional) reference symbol/proc condition to only index when condition is true 16 | # :unless (optional) reference symbol/proc condition to skip indexing when condition is true 17 | # :json (optional) configure the json document to be indexed (see http://api.rubyonrails.org/classes/ActiveModel/Serializers/JSON.html#method-i-as_json for available options) 18 | def elastic_searchable(options = {}) 19 | cattr_accessor :elastic_options 20 | self.elastic_options = options.symbolize_keys.merge(:unless => Array.wrap(options[:unless]).push(:elasticsearch_offline?)) 21 | 22 | extend ElasticSearchable::Indexing::ClassMethods 23 | extend ElasticSearchable::Queries 24 | 25 | include ElasticSearchable::Indexing::InstanceMethods 26 | include ElasticSearchable::Callbacks::InstanceMethods 27 | 28 | backgrounded :update_index_on_create => ElasticSearchable::Callbacks.backgrounded_options, :update_index_on_update => ElasticSearchable::Callbacks.backgrounded_options 29 | class << self 30 | backgrounded :delete_id_from_index => ElasticSearchable::Callbacks.backgrounded_options 31 | end 32 | 33 | define_callbacks :after_index_on_create, :after_index_on_update, :after_index 34 | after_commit_on_create :update_index_on_create_backgrounded, :if => :should_index? 35 | after_commit_on_update :update_index_on_update_backgrounded, :if => :should_index? 36 | after_commit_on_destroy :delete_from_index 37 | end 38 | end 39 | end 40 | 41 | ActiveRecord::Base.send(:extend, ElasticSearchable::ActiveRecordExtensions) 42 | -------------------------------------------------------------------------------- /lib/elastic_searchable/callbacks.rb: -------------------------------------------------------------------------------- 1 | module ElasticSearchable 2 | module Callbacks 3 | class << self 4 | def backgrounded_options 5 | {:queue => 'elasticsearch'} 6 | end 7 | end 8 | 9 | module InstanceMethods 10 | private 11 | def delete_from_index 12 | self.class.delete_id_from_index_backgrounded self.id 13 | end 14 | def update_index_on_create 15 | reindex :create 16 | end 17 | def update_index_on_update 18 | reindex :update 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/elastic_searchable/index.rb: -------------------------------------------------------------------------------- 1 | require 'will_paginate' 2 | 3 | module ElasticSearchable 4 | module Indexing 5 | module ClassMethods 6 | # delete all documents of this type in the index 7 | # http://www.elasticsearch.com/docs/elasticsearch/rest_api/admin/indices/delete_mapping/ 8 | def clean_index 9 | ElasticSearchable.request :delete, index_type_path 10 | end 11 | 12 | # configure the index for this type 13 | # http://www.elasticsearch.com/docs/elasticsearch/rest_api/admin/indices/put_mapping/ 14 | def update_index_mapping 15 | if mapping = self.elastic_options[:mapping] 16 | ElasticSearchable.request :put, index_type_path('_mapping'), :json_body => {index_type => mapping} 17 | end 18 | end 19 | 20 | # create the index 21 | # http://www.elasticsearch.org/guide/reference/api/admin-indices-create-index.html 22 | def create_index 23 | options = {} 24 | options.merge! :settings => self.elastic_options[:index_options] if self.elastic_options[:index_options] 25 | options.merge! :mappings => {index_type => self.elastic_options[:mapping]} if self.elastic_options[:mapping] 26 | ElasticSearchable.request :put, index_path, :json_body => options 27 | end 28 | 29 | # explicitly refresh the index, making all operations performed since the last refresh 30 | # available for search 31 | # 32 | # http://www.elasticsearch.com/docs/elasticsearch/rest_api/admin/indices/refresh/ 33 | def refresh_index 34 | ElasticSearchable.request :post, index_path('_refresh') 35 | end 36 | 37 | # deletes the entire index 38 | # http://www.elasticsearch.com/docs/elasticsearch/rest_api/admin/indices/delete_index/ 39 | def delete_index 40 | ElasticSearchable.request :delete, index_path 41 | end 42 | 43 | # delete one record from the index 44 | # http://www.elasticsearch.com/docs/elasticsearch/rest_api/delete/ 45 | def delete_id_from_index(id) 46 | ElasticSearchable.request :delete, index_type_path(id) 47 | rescue ElasticSearchable::ElasticError => e 48 | ElasticSearchable.logger.warn e 49 | end 50 | 51 | # helper method to generate elasticsearch url for this object type 52 | def index_type_path(action = nil) 53 | index_path [index_type, action].compact.join('/') 54 | end 55 | 56 | # helper method to generate elasticsearch url for this index 57 | def index_path(action = nil) 58 | ['', index_name, action].compact.join('/') 59 | end 60 | 61 | # reindex all records using bulk api 62 | # see http://www.elasticsearch.org/guide/reference/api/bulk.html 63 | # options: 64 | # :scope - scope to use for looking up records to reindex. defaults to self (all) 65 | # :page - page/batch to begin indexing at. defaults to 1 66 | # :per_page - number of records to index per batch. defaults to 1000 67 | def reindex(options = {}) 68 | self.update_index_mapping 69 | options.reverse_merge! :page => 1, :per_page => 1000, :total_entries => 1 70 | scope = options.delete(:scope) || self 71 | 72 | records = scope.paginate(options) 73 | while records.any? do 74 | ElasticSearchable.logger.debug "reindexing batch ##{records.current_page}..." 75 | 76 | actions = [] 77 | records.each do |record| 78 | next unless record.should_index? 79 | begin 80 | doc = ElasticSearchable.encode_json(record.as_json_for_index) 81 | actions << ElasticSearchable.encode_json({:index => {'_index' => index_name, '_type' => index_type, '_id' => record.id}}) 82 | actions << doc 83 | rescue => e 84 | ElasticSearchable.logger.warn "Unable to bulk index record: #{record.inspect} [#{e.message}]" 85 | end 86 | end 87 | begin 88 | ElasticSearchable.request(:put, '/_bulk', :body => "\n#{actions.join("\n")}\n") if actions.any? 89 | rescue ElasticError => e 90 | ElasticSearchable.logger.warn "Error indexing batch ##{options[:page]}: #{e.message}" 91 | ElasticSearchable.logger.warn e 92 | end 93 | 94 | options.merge! :page => (options[:page] + 1) 95 | records = scope.paginate(options) 96 | end 97 | end 98 | 99 | private 100 | def index_name 101 | self.elastic_options[:index] || ElasticSearchable.default_index 102 | end 103 | def index_type 104 | self.elastic_options[:type] || self.table_name 105 | end 106 | end 107 | 108 | module InstanceMethods 109 | # reindex the object in elasticsearch 110 | # fires after_index callbacks after operation is complete 111 | # see http://www.elasticsearch.org/guide/reference/api/index_.html 112 | def reindex(lifecycle = nil) 113 | query = {} 114 | query.merge! :percolate => "*" if self.class.elastic_options[:percolate] 115 | response = ElasticSearchable.request :put, self.class.index_type_path(self.id), :query => query, :json_body => self.as_json_for_index 116 | 117 | self.run_callbacks("after_index_on_#{lifecycle}".to_sym) if lifecycle 118 | self.run_callbacks(:after_index) 119 | 120 | if percolate_callback = self.class.elastic_options[:percolate] 121 | matches = response['matches'] 122 | self.send percolate_callback, matches if matches.any? 123 | end 124 | end 125 | # document to index in elasticsearch 126 | def as_json_for_index 127 | self.as_json self.class.elastic_options[:json] 128 | end 129 | def should_index? 130 | [self.class.elastic_options[:if]].flatten.compact.all? { |m| evaluate_elastic_condition(m) } && 131 | ![self.class.elastic_options[:unless]].flatten.compact.any? { |m| evaluate_elastic_condition(m) } 132 | end 133 | # percolate this object to see what registered searches match 134 | # can be done on transient/non-persisted objects! 135 | # can be done automatically when indexing using :percolate => true config option 136 | # http://www.elasticsearch.org/blog/2011/02/08/percolator.html 137 | def percolate 138 | response = ElasticSearchable.request :get, self.class.index_type_path('_percolate'), :json_body => {:doc => self.as_json_for_index} 139 | response['matches'] 140 | end 141 | 142 | private 143 | def elasticsearch_offline? 144 | ElasticSearchable.offline? 145 | end 146 | # ripped from activesupport 147 | def evaluate_elastic_condition(method) 148 | case method 149 | when Symbol 150 | self.send method 151 | when String 152 | eval(method, self.instance_eval { binding }) 153 | when Proc, Method 154 | method.call 155 | else 156 | if method.respond_to?(kind) 157 | method.send kind 158 | else 159 | raise ArgumentError, 160 | "Callbacks must be a symbol denoting the method to call, a string to be evaluated, " + 161 | "a block to be invoked, or an object responding to the callback method." 162 | end 163 | end 164 | end 165 | end 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /lib/elastic_searchable/queries.rb: -------------------------------------------------------------------------------- 1 | require 'will_paginate/collection' 2 | 3 | module ElasticSearchable 4 | module Queries 5 | PER_PAGE_DEFAULT = 20 6 | 7 | # search returns a will_paginate collection of ActiveRecord objects for the search results 8 | # supported options: 9 | # :page - page of results to search for 10 | # :per_page - number of results per page 11 | # 12 | # http://www.elasticsearch.com/docs/elasticsearch/rest_api/search/ 13 | def search(query, options = {}) 14 | page = (options.delete(:page) || 1).to_i 15 | options[:fields] ||= '_id' 16 | options[:size] ||= per_page_for_search(options) 17 | options[:from] ||= options[:size] * (page - 1) 18 | if query.is_a?(Hash) 19 | options[:query] = query 20 | else 21 | options[:query] = { 22 | :query_string => { 23 | :query => query, 24 | :default_operator => options.delete(:default_operator) 25 | } 26 | } 27 | end 28 | query = {} 29 | case sort = options.delete(:sort) 30 | when Array,Hash 31 | options[:sort] = sort 32 | when String 33 | query[:sort] = sort 34 | end 35 | 36 | response = ElasticSearchable.request :get, index_type_path('_search'), :query => query, :json_body => options 37 | hits = response['hits'] 38 | ids = hits['hits'].collect {|h| h['_id'].to_i } 39 | results = self.find(ids).sort_by {|result| ids.index(result.id) } 40 | 41 | page = WillPaginate::Collection.new(page, options[:size], hits['total']) 42 | page.replace results 43 | page 44 | end 45 | 46 | private 47 | # determine the number of search results per page 48 | # supports will_paginate configuration by using: 49 | # Model.per_page 50 | # Model.max_per_page 51 | def per_page_for_search(options = {}) 52 | per_page = options.delete(:per_page) || (self.respond_to?(:per_page) ? self.per_page : nil) || ElasticSearchable::Queries::PER_PAGE_DEFAULT 53 | per_page = [per_page.to_i, self.max_per_page].min if self.respond_to?(:max_per_page) 54 | per_page 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/elastic_searchable/version.rb: -------------------------------------------------------------------------------- 1 | module ElasticSearchable 2 | VERSION = '0.7.3' 3 | end 4 | 5 | -------------------------------------------------------------------------------- /test/database.yml: -------------------------------------------------------------------------------- 1 | sqlite: 2 | adapter: sqlite3 3 | database: test/elastic_searchable.sqlite3 4 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler' 3 | begin 4 | Bundler.setup(:default, :development) 5 | rescue Bundler::BundlerError => e 6 | $stderr.puts e.message 7 | $stderr.puts "Run `bundle install` to install missing gems" 8 | exit e.status_code 9 | end 10 | require 'test/unit' 11 | require 'shoulda' 12 | require 'mocha' 13 | require 'ruby-debug' 14 | 15 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 16 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 17 | require 'elastic_searchable' 18 | require 'setup_database' 19 | 20 | class Test::Unit::TestCase 21 | def delete_index 22 | ElasticSearchable.delete '/elastic_searchable' rescue nil 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/setup_database.rb: -------------------------------------------------------------------------------- 1 | config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml')) 2 | ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + "/debug.log") 3 | ActiveRecord::Base.establish_connection(config[ENV['DB'] || 'sqlite']) 4 | 5 | ActiveRecord::Schema.define(:version => 1) do 6 | create_table :posts, :force => true do |t| 7 | t.column :title, :string 8 | t.column :body, :string 9 | t.column :name, :string 10 | end 11 | create_table :blogs, :force => true do |t| 12 | t.column :title, :string 13 | t.column :body, :string 14 | end 15 | create_table :users, :force => true do |t| 16 | t.column :name, :string 17 | end 18 | create_table :friends, :force => true do |t| 19 | t.column :name, :string 20 | t.column :favorite_color, :string 21 | t.belongs_to :book 22 | end 23 | create_table :books, :force => true do |t| 24 | t.column :title, :string 25 | t.column :isbn, :string 26 | end 27 | create_table :max_page_size_classes, :force => true do |t| 28 | t.column :name, :string 29 | end 30 | end 31 | 32 | WillPaginate.enable_activerecord 33 | 34 | -------------------------------------------------------------------------------- /test/test_elastic_searchable.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'helper') 2 | 3 | class TestElasticSearchable < Test::Unit::TestCase 4 | def setup 5 | delete_index 6 | end 7 | ElasticSearchable.debug_output 8 | 9 | context 'non elastic activerecord class' do 10 | class Cat < ActiveRecord::Base 11 | end 12 | should 'not respond to elastic_options' do 13 | assert !Cat.respond_to?(:elastic_options) 14 | end 15 | end 16 | 17 | class Post < ActiveRecord::Base 18 | elastic_searchable :index_options => {'number_of_replicas' => 0, 'number_of_shards' => 1} 19 | after_index :indexed 20 | after_index_on_create :indexed_on_create 21 | after_index_on_update :indexed_on_update 22 | def indexed 23 | @indexed = true 24 | end 25 | def indexed? 26 | @indexed 27 | end 28 | def indexed_on_create 29 | @indexed_on_create = true 30 | end 31 | def indexed_on_create? 32 | @indexed_on_create 33 | end 34 | def indexed_on_update 35 | @indexed_on_update = true 36 | end 37 | def indexed_on_update? 38 | @indexed_on_update 39 | end 40 | end 41 | context 'activerecord class with default elastic_searchable config' do 42 | setup do 43 | @clazz = Post 44 | end 45 | should 'respond to :search' do 46 | assert @clazz.respond_to?(:search) 47 | end 48 | should 'define elastic_options' do 49 | assert @clazz.elastic_options 50 | end 51 | end 52 | 53 | context 'Model.request with invalid url' do 54 | should 'raise error' do 55 | assert_raises ElasticSearchable::ElasticError do 56 | ElasticSearchable.request :get, '/elastic_searchable/foobar/notfound' 57 | end 58 | end 59 | end 60 | 61 | context 'Model.create_index' do 62 | setup do 63 | Post.create_index 64 | Post.refresh_index 65 | @status = ElasticSearchable.request :get, '/elastic_searchable/_status' 66 | end 67 | should 'have created index' do 68 | assert @status['ok'] 69 | end 70 | end 71 | 72 | context 'Model.create' do 73 | setup do 74 | @post = Post.create :title => 'foo', :body => "bar" 75 | end 76 | should 'have fired after_index callback' do 77 | assert @post.indexed? 78 | end 79 | should 'have fired after_index_on_create callback' do 80 | assert @post.indexed_on_create? 81 | end 82 | should 'not have fired after_index_on_update callback' do 83 | assert !@post.indexed_on_update? 84 | end 85 | end 86 | 87 | context 'Model.update' do 88 | setup do 89 | Post.create :title => 'foo', :body => 'bar' 90 | @post = Post.last 91 | @post.title = 'baz' 92 | @post.save 93 | end 94 | should 'have fired after_index callback' do 95 | assert @post.indexed? 96 | end 97 | should 'not have fired after_index_on_create callback' do 98 | assert !@post.indexed_on_create? 99 | end 100 | should 'have fired after_index_on_update callback' do 101 | assert @post.indexed_on_update? 102 | end 103 | end 104 | 105 | context 'Model.create within ElasticSearchable.offline block' do 106 | setup do 107 | ElasticSearchable.offline do 108 | @post = Post.create :title => 'foo', :body => "bar" 109 | end 110 | end 111 | should 'not have fired after_index callback' do 112 | assert !@post.indexed? 113 | end 114 | should 'not have fired after_index_on_create callback' do 115 | assert !@post.indexed_on_create? 116 | end 117 | end 118 | 119 | context 'with empty index when multiple database records' do 120 | setup do 121 | Post.delete_all 122 | Post.create_index 123 | @first_post = Post.create :title => 'foo', :body => "first bar" 124 | @second_post = Post.create :title => 'foo', :body => "second bar" 125 | Post.delete_index 126 | Post.create_index 127 | end 128 | should 'not raise error if error occurs reindexing model' do 129 | ElasticSearchable.expects(:request).raises(ElasticSearchable::ElasticError.new('faux error')) 130 | assert_nothing_raised do 131 | Post.reindex 132 | end 133 | end 134 | should 'not raise error if destroying one instance' do 135 | Logger.any_instance.expects(:warn) 136 | assert_nothing_raised do 137 | @first_post.destroy 138 | end 139 | end 140 | context 'Model.reindex' do 141 | setup do 142 | Post.reindex :per_page => 1, :scope => Post.scoped(:order => 'body desc') 143 | Post.refresh_index 144 | end 145 | should 'have reindexed both records' do 146 | assert_nothing_raised do 147 | ElasticSearchable.request :get, "/elastic_searchable/posts/#{@first_post.id}" 148 | ElasticSearchable.request :get, "/elastic_searchable/posts/#{@second_post.id}" 149 | end 150 | end 151 | end 152 | end 153 | 154 | context 'with index containing multiple results' do 155 | setup do 156 | Post.create_index 157 | @first_post = Post.create :title => 'foo', :body => "first bar" 158 | @second_post = Post.create :title => 'foo', :body => "second bar" 159 | Post.refresh_index 160 | end 161 | 162 | context 'searching for results' do 163 | setup do 164 | @results = Post.search 'first' 165 | end 166 | should 'find created object' do 167 | assert_contains @results, @first_post 168 | end 169 | should 'be paginated' do 170 | assert_equal 1, @results.current_page 171 | assert_equal Post.per_page, @results.per_page 172 | assert_nil @results.previous_page 173 | assert_nil @results.next_page 174 | end 175 | end 176 | 177 | context 'searching for results using a query Hash' do 178 | setup do 179 | @results = Post.search({ 180 | :filtered => { 181 | :query => { 182 | :term => {:title => 'foo'}, 183 | }, 184 | :filter => { 185 | :or => [ 186 | {:term => {:body => 'second'}}, 187 | {:term => {:body => 'third'}} 188 | ] 189 | } 190 | } 191 | }) 192 | end 193 | should 'find only the object which ' do 194 | assert_does_not_contain @results, @first_post 195 | assert_contains @results, @second_post 196 | end 197 | end 198 | 199 | context 'searching for second page using will_paginate params' do 200 | setup do 201 | @results = Post.search 'foo', :page => 2, :per_page => 1, :sort => 'id' 202 | end 203 | should 'not find objects from first page' do 204 | assert_does_not_contain @results, @first_post 205 | end 206 | should 'find second object' do 207 | assert_contains @results, @second_post 208 | end 209 | should 'be paginated' do 210 | assert_equal 2, @results.current_page 211 | assert_equal 1, @results.per_page 212 | assert_equal 1, @results.previous_page 213 | assert_nil @results.next_page 214 | end 215 | end 216 | 217 | context 'sorting search results' do 218 | setup do 219 | @results = Post.search 'foo', :sort => 'id:desc' 220 | end 221 | should 'sort results correctly' do 222 | assert_equal @second_post, @results.first 223 | assert_equal @first_post, @results.last 224 | end 225 | end 226 | 227 | context 'advanced sort options' do 228 | setup do 229 | @results = Post.search 'foo', :sort => [{:id => 'desc'}] 230 | end 231 | should 'sort results correctly' do 232 | assert_equal @second_post, @results.first 233 | assert_equal @first_post, @results.last 234 | end 235 | end 236 | 237 | context 'destroying one object' do 238 | setup do 239 | @first_post.destroy 240 | Post.refresh_index 241 | end 242 | should 'be removed from the index' do 243 | @request = ElasticSearchable.get "/elastic_searchable/posts/#{@first_post.id}" 244 | assert @request.response.is_a?(Net::HTTPNotFound), @request.inspect 245 | end 246 | end 247 | end 248 | 249 | 250 | class Blog < ActiveRecord::Base 251 | elastic_searchable :if => proc {|b| b.should_index? }, :index_options => {'number_of_replicas' => 0, 'number_of_shards' => 1} 252 | def should_index? 253 | false 254 | end 255 | end 256 | context 'activerecord class with optional :if=>proc configuration' do 257 | context 'when creating new instance' do 258 | setup do 259 | Blog.any_instance.expects(:reindex).never 260 | @blog = Blog.create! :title => 'foo' 261 | end 262 | should 'not index record' do end #see expectations 263 | should 'not be found in elasticsearch' do 264 | @request = ElasticSearchable.get "/elastic_searchable/blogs/#{@blog.id}" 265 | assert @request.response.is_a?(Net::HTTPNotFound), @request.inspect 266 | end 267 | end 268 | end 269 | 270 | class User < ActiveRecord::Base 271 | elastic_searchable :index_options => { 272 | 'number_of_replicas' => 0, 273 | 'number_of_shards' => 1, 274 | "analysis.analyzer.default.tokenizer" => 'standard', 275 | "analysis.analyzer.default.filter" => ["standard", "lowercase", 'porterStem']}, 276 | :mapping => {:properties => {:name => {:type => :string, :index => :not_analyzed}}} 277 | end 278 | context 'activerecord class with :index_options and :mapping' do 279 | context 'creating index' do 280 | setup do 281 | User.create_index 282 | end 283 | should 'have used custom index_options' do 284 | @status = ElasticSearchable.request :get, '/elastic_searchable/_status' 285 | expected = { 286 | "index.number_of_replicas" => "0", 287 | "index.number_of_shards" => "1", 288 | "index.analysis.analyzer.default.tokenizer" => "standard", 289 | "index.analysis.analyzer.default.filter.0" => "standard", 290 | "index.analysis.analyzer.default.filter.1" => "lowercase", 291 | "index.analysis.analyzer.default.filter.2" => "porterStem" 292 | } 293 | assert_equal expected, @status['indices']['elastic_searchable']['settings'], @status.inspect 294 | end 295 | should 'have set mapping' do 296 | @status = ElasticSearchable.request :get, '/elastic_searchable/users/_mapping' 297 | expected = { 298 | "users"=> { 299 | "properties"=> { 300 | "name"=> {"type"=>"string", "index"=>"not_analyzed"} 301 | } 302 | } 303 | } 304 | assert_equal expected, @status['elastic_searchable'], @status.inspect 305 | end 306 | end 307 | end 308 | 309 | class Friend < ActiveRecord::Base 310 | belongs_to :book 311 | elastic_searchable :json => {:include => {:book => {:only => :title}}, :only => :name}, :index_options => {'number_of_replicas' => 0, 'number_of_shards' => 1} 312 | end 313 | context 'activerecord class with optional :json config' do 314 | context 'creating index' do 315 | setup do 316 | Friend.create_index 317 | @book = Book.create! :isbn => '123abc', :title => 'another world' 318 | @friend = Friend.new :name => 'bob', :favorite_color => 'red' 319 | @friend.book = @book 320 | @friend.save! 321 | Friend.refresh_index 322 | end 323 | should 'index json with configuration' do 324 | @response = ElasticSearchable.request :get, "/elastic_searchable/friends/#{@friend.id}" 325 | # should not index: 326 | # friend.favorite_color 327 | # book.isbn 328 | expected = { 329 | "name" => 'bob', 330 | 'book' => {'title' => 'another world'} 331 | } 332 | assert_equal expected, @response['_source'], @response.inspect 333 | end 334 | end 335 | end 336 | 337 | context 'updating ElasticSearchable.default_index' do 338 | setup do 339 | ElasticSearchable.default_index = 'my_new_index' 340 | end 341 | teardown do 342 | ElasticSearchable.default_index = ElasticSearchable::DEFAULT_INDEX 343 | end 344 | should 'change default index' do 345 | assert_equal 'my_new_index', ElasticSearchable.default_index 346 | end 347 | end 348 | 349 | class Book < ActiveRecord::Base 350 | elastic_searchable :percolate => :on_percolated 351 | def on_percolated(percolated) 352 | @percolated = percolated 353 | end 354 | def percolated 355 | @percolated 356 | end 357 | end 358 | context 'Book class with percolate=true' do 359 | context 'with created index' do 360 | setup do 361 | Book.create_index 362 | end 363 | context "when index has configured percolation" do 364 | setup do 365 | ElasticSearchable.request :put, '/_percolator/elastic_searchable/myfilter', :json_body => {:query => {:query_string => {:query => 'foo' }}} 366 | ElasticSearchable.request :post, '/_percolator/_refresh' 367 | end 368 | context 'creating an object that matches the percolation' do 369 | setup do 370 | @book = Book.create :title => "foo" 371 | end 372 | should 'return percolated matches in the callback' do 373 | assert_equal ['myfilter'], @book.percolated 374 | end 375 | end 376 | context 'percolating a non-persisted object' do 377 | setup do 378 | @matches = Book.new(:title => 'foo').percolate 379 | end 380 | should 'return percolated matches' do 381 | assert_equal ['myfilter'], @matches 382 | end 383 | end 384 | end 385 | end 386 | end 387 | 388 | class MaxPageSizeClass < ActiveRecord::Base 389 | elastic_searchable :index_options => {'number_of_replicas' => 0, 'number_of_shards' => 1} 390 | def self.max_per_page 391 | 1 392 | end 393 | end 394 | context 'with 2 MaxPageSizeClass instances' do 395 | setup do 396 | MaxPageSizeClass.create_index 397 | @first = MaxPageSizeClass.create! :name => 'foo one' 398 | @second = MaxPageSizeClass.create! :name => 'foo two' 399 | MaxPageSizeClass.refresh_index 400 | end 401 | context 'MaxPageSizeClass.search with default options' do 402 | setup do 403 | @results = MaxPageSizeClass.search 'foo' 404 | end 405 | should 'have one per page' do 406 | assert_equal 1, @results.per_page 407 | end 408 | should 'return one instance' do 409 | assert_equal 1, @results.length 410 | end 411 | should 'have second page' do 412 | assert_equal 2, @results.total_entries 413 | end 414 | end 415 | end 416 | end 417 | 418 | --------------------------------------------------------------------------------