es-elasticity 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 4316eca2c0cf5953a781ed8233e13eb6b6ae90fd
4
+ data.tar.gz: f37edd93770533fc4cb5c05b515438abf481aea9
5
+ SHA512:
6
+ metadata.gz: dfc1d0d24057f8e8a3bb890ff6b2812d3db20dbd0972ba07aa957e454bdc61f261fba3eca868397db40e14c6ae649ffb3314312ae288b005c532a7e855bd14d9
7
+ data.tar.gz: f76f11327b69c509442cda2a6788c5e13252e44a86146d6c5424981508fab1e480592f95a028aee8bf708768418de347255445e6c4d4a7bea3cfedcceb40dc4a
data/.gitignore ADDED
@@ -0,0 +1,15 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ *.bundle
11
+ *.so
12
+ *.o
13
+ *.a
14
+ mkmf.log
15
+ *.log
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ -Ilib -Ispec -rrspec_config --color --format progress --order rand
data/.simplecov ADDED
@@ -0,0 +1,5 @@
1
+ SimpleCov.start do
2
+ add_filter do |src|
3
+ src.filename =~ /^#{Regexp.escape(File.dirname(__FILE__))}\/spec/
4
+ end
5
+ end
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in elasticity.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Rodrigo Kochenburger
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # Elasticity
2
+
3
+ Elasticity provides a higher level abstraction on top of [elasticsearch-ruby](https://github.com/elasticsearch/elasticsearch-ruby) gem.
4
+
5
+ Mainly, it provides a model-oriented approach to ElasticSearch, similar to what [ActiveRecord](https://github.com/rails/rails/tree/master/activerecord) provides to relational databases. It leverages [ActiveModel](https://github.com/rails/rails/tree/master/activemodel) to provide a familiar format for Rails developers.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'elasticity'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ $ bundle
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install elasticity
22
+
23
+ ## Usage overview
24
+
25
+ The first thing to do, is setup a model class for a index and document type that inherits from `Elasticity::Document`.
26
+
27
+ ```ruby
28
+ class Search::User < Elasticity::Document
29
+ # All models automatically have the id attribute but you need to define the others.
30
+ attr_accessor :name, :birthdate
31
+
32
+ # Define the index mapping for the index and document type this model represents.
33
+ self.mappings = {
34
+ properties: {
35
+ name: { type: "string" },
36
+ birthdate: { type: "date" },
37
+ }
38
+ }
39
+
40
+ # Defines a search method.
41
+ def self.adults
42
+ date = Date.today - 21.years
43
+
44
+ # This is the query that will be submited to ES, same format ES would expect,
45
+ # translated to a Ruby hash.
46
+ body = {
47
+ filter: {
48
+ { range: { birthdate: { gte: date.iso8601 }}},
49
+ },
50
+ }
51
+
52
+ # Creates a search object from the body and return it. The returned object is a
53
+ # lazy evaluated search that behaves like a collection, being automatically
54
+ # triggered when data is iterated over.
55
+ self.search(body)
56
+ end
57
+
58
+ # to_document is the only required method that needs to be implemented so an
59
+ # instance of this model can be indexed.
60
+ def to_document
61
+ {
62
+ name: self.name,
63
+ birthdate: self.birthdate.iso8601,
64
+ }
65
+ end
66
+ end
67
+ ```
68
+
69
+ Then instances of that model can be indexed pretty easily by just calling the `update` method.
70
+
71
+ ```ruby
72
+ # Creates a new document on the index
73
+ u = Search::User.new(id: 1, name: "John", birthdate: Date.civil(1985, 10, 31))
74
+ u.update
75
+
76
+ # Updates the same document on the index
77
+ u.name = "Jonh Jon"
78
+ u.update
79
+ ```
80
+
81
+ Getting the results of a search is also pretty straightforward:
82
+
83
+ ```ruby
84
+ # Get the search object, which is an instance of `Elasticity::DocumentSearchProxy`.
85
+ # Search is not performed until data is accessed.
86
+ adults = User.adults
87
+
88
+ # Iterating over the results will trigger the query
89
+ adults.each do |user|
90
+ # do something with user
91
+ end
92
+
93
+ # Or you can also, map the results back to an ActiveRecord relation.
94
+ # In this case, only the ids will be fetched.
95
+ adults.active_recors(Database::User) # => Array of Database::User instances
96
+ ```
97
+
98
+ ## Design Goals
99
+
100
+ - Provide model specific for ElasticSearch documents instead of an ActiveRecord mixin;
101
+ - proper separation of concerns and de-coupling;
102
+ - lazy search evaluation and easy composition of multi-searches;
103
+ - easy of debug;
104
+ - higher level API that resembles ElasticSearch API;
105
+
106
+ ## Roadmap
107
+
108
+ - [ ] Write more detailed documentation section for:
109
+ - [ ] Model definition
110
+ - [ ] Indexing, Bulk Indexing and Delete By Query
111
+ - [ ] Search and Multi Search
112
+ - [ ] ActiveRecord integration
113
+
114
+ ## Contributing
115
+
116
+ 1. Fork it ( https://github.com/[my-github-username]/elasticity/fork )
117
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
118
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
119
+ 4. Push to the branch (`git push origin my-new-feature`)
120
+ 5. Create a new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new
data/bin/rake ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+ #
3
+ # This file was generated by Bundler.
4
+ #
5
+ # The application 'rake' is installed as part of a gem, and
6
+ # this file is here to facilitate running it.
7
+ #
8
+
9
+ require 'pathname'
10
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path("../../Gemfile",
11
+ Pathname.new(__FILE__).realpath)
12
+
13
+ require 'rubygems'
14
+ require 'bundler/setup'
15
+
16
+ load Gem.bin_path('rake', 'rake')
data/bin/rspec ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+ #
3
+ # This file was generated by Bundler.
4
+ #
5
+ # The application 'rspec' is installed as part of a gem, and
6
+ # this file is here to facilitate running it.
7
+ #
8
+
9
+ require 'pathname'
10
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path("../../Gemfile",
11
+ Pathname.new(__FILE__).realpath)
12
+
13
+ require 'rubygems'
14
+ require 'bundler/setup'
15
+
16
+ load Gem.bin_path('rspec-core', 'rspec')
@@ -0,0 +1,32 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'elasticity/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "es-elasticity"
8
+ spec.version = Elasticity::VERSION
9
+ spec.authors = ["Rodrigo Kochenburger"]
10
+ spec.email = ["rodrigo@doximity.com"]
11
+ spec.summary = %q{ActiveModel-based library for working with ElasticSearch}
12
+ spec.description = %q{Elasticity provides a higher level abstraction on top of [elasticsearch-ruby](https://github.com/elasticsearch/elasticsearch-ruby) gem}
13
+ spec.homepage = ""
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ # spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.executables = []
19
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
20
+ spec.require_paths = ["lib"]
21
+
22
+ spec.add_development_dependency "bundler", "~> 1.7"
23
+ spec.add_development_dependency "rake", "~> 10.0"
24
+ spec.add_development_dependency "rspec", "~> 3.1.0"
25
+ spec.add_development_dependency "simplecov", "~> 0.7.1"
26
+ spec.add_development_dependency "oj"
27
+ spec.add_development_dependency "pry"
28
+
29
+ spec.add_dependency "activesupport", "~> 4.0.0"
30
+ spec.add_dependency "activemodel", "~> 4.0.0"
31
+ spec.add_dependency "elasticsearch", "~> 1.0.5"
32
+ end
data/lib/elasticity.rb ADDED
@@ -0,0 +1,5 @@
1
+ require "elasticity_base"
2
+ require "elasticity/index"
3
+ require "elasticity/document"
4
+ require "elasticity/search"
5
+ require "elasticity/multi_search"
@@ -0,0 +1,100 @@
1
+ module Elasticity
2
+ class Document
3
+ include ::ActiveModel::Model
4
+
5
+ # Returns the instance of Elasticity::Index associated with this document.
6
+ def self.index
7
+ return @index if defined?(@index)
8
+
9
+ index_name = self.name.underscore.pluralize
10
+
11
+ if namespace = Elasticity.config.namespace
12
+ index_name = "#{namespace}_#{index_name}"
13
+ end
14
+
15
+ @index = Index.new(Elasticity.config.client, index_name)
16
+ @index.create_if_undefined(settings: Elasticity.config.settings, mappings: @mappings)
17
+ @index
18
+ end
19
+
20
+ # The index name to be used for indexing and storing data for this document model.
21
+ # By default, it's the class name converted to underscore and plural.
22
+ def self.index_name
23
+ self.index.name
24
+ end
25
+
26
+ # The document type to be used, it's inferred by the class name.
27
+ def self.document_type
28
+ self.name.underscore
29
+ end
30
+
31
+ # Sets the mapping for this model, which will be used to create the associated index and
32
+ # generate accessor methods.
33
+ def self.mappings=(mappings)
34
+ raise "Can't re-define mappings in runtime" if defined?(@mappings)
35
+ @mappings = mappings
36
+ end
37
+
38
+ # Searches the index using the parameters provided in the body hash, following the same
39
+ # structure ElasticSearch expects.
40
+ # Returns a DocumentSearch object.
41
+ def self.search(body)
42
+ DocumentSearchProxy.new(Search.new(index, document_type, body), self)
43
+ end
44
+
45
+ # Fetches one specific document from the index by ID.
46
+ def self.get(id)
47
+ if doc = index.get_document(document_type, id)
48
+ new(doc["_source"])
49
+ end
50
+ end
51
+
52
+ # Removes one specific document from the index.
53
+ def self.delete(id)
54
+ index.delete_document(document_type, id)
55
+ end
56
+
57
+ # Removes entries based on a search
58
+ def self.delete_by_search(search)
59
+ index.delete_by_query(document_type, search.body)
60
+ end
61
+
62
+ # Bulk index the provided documents
63
+ def self.bulk_index(documents)
64
+ index.bulk do |b|
65
+ documents.each do |doc|
66
+ b.index(self.document_type, doc.id, doc.to_document)
67
+ end
68
+ end
69
+ end
70
+
71
+ # Define common attributes for all documents
72
+ attr_accessor :id
73
+
74
+ # Creates a new Document instance with the provided attributes.
75
+ def initialize(attributes = {})
76
+ super(attributes)
77
+ end
78
+
79
+ # Defines equality by comparing the ID and values of each instance variable.
80
+ def ==(other)
81
+ return false if id != other.id
82
+
83
+ instance_variables.all? do |ivar|
84
+ instance_variable_get(ivar) == other.instance_variable_get(ivar)
85
+ end
86
+ end
87
+
88
+ # IMPLEMENT
89
+ # Returns a hash with the attributes as they should be stored in the index.
90
+ # This will be stored as _source attributes on ElasticSearch.
91
+ def to_document
92
+ raise NotImplementedError, "to_document needs to be implemented for #{self.class}"
93
+ end
94
+
95
+ # Update this object on the index, creating or updating the document.
96
+ def update
97
+ self.class.index.index_document(self.class.document_type, id, to_document)
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,114 @@
1
+ module Elasticity
2
+ class Index
3
+ attr_reader :name
4
+
5
+ def initialize(client, index_name)
6
+ @client = client
7
+ @name = index_name
8
+ end
9
+
10
+ def create(index_def)
11
+ args = { index: @name, body: index_def }
12
+ instrument("index_create", args) { @client.indices.create(args) }
13
+ end
14
+
15
+ def create_if_undefined(index_def)
16
+ create(index_def) unless @client.indices.exists(index: @name)
17
+ end
18
+
19
+ def delete
20
+ args = { index: @name }
21
+ instrument("index_delete", args) { @client.indices.delete(args) }
22
+ end
23
+
24
+ def delete_if_defined
25
+ delete if @client.indices.exists(index: @name)
26
+ end
27
+
28
+ def recreate(index_def = nil)
29
+ index_def ||= { settings: settings, mappings: mappings }
30
+ delete_if_defined
31
+ create(index_def)
32
+ end
33
+
34
+ def index_document(type, id, attributes)
35
+ args = { index: @name, type: type, id: id, body: attributes }
36
+ instrument("index_document", args) { @client.index(args) }
37
+ end
38
+
39
+ def delete_document(type, id)
40
+ args = { index: @name, type: type, id: id }
41
+ instrument("delete_document", args) { @client.delete(args) }
42
+ end
43
+
44
+ def get_document(type, id)
45
+ args = { index: @name, type: type, id: id }
46
+ instrument("get_document", args) { @client.get(args) }
47
+ end
48
+
49
+ def search(type, body)
50
+ args = { index: @name, type: type, body: body }
51
+ instrument("search", args) { @client.search(args) }
52
+ end
53
+
54
+ def delete_by_query(type, body)
55
+ args = { index: @name, type: type, body: body }
56
+ instrument("delete_by_query", args) { @client.delete_by_query(args) }
57
+ end
58
+
59
+ def bulk
60
+ b = Bulk.new(@client, @name)
61
+ yield b
62
+ b.execute
63
+ end
64
+
65
+ def settings
66
+ args = { index: @name }
67
+ settings = instrument("settings", args) { @client.indices.get_settings(args) }
68
+ settings[@name]["settings"] if settings[@name]
69
+ rescue Elasticsearch::Transport::Transport::Errors::NotFound
70
+ nil
71
+ end
72
+
73
+ def mappings
74
+ args = { index: @name }
75
+ mappings = instrument("mappings", args) { @client.indices.get_mapping(args) }
76
+ mappings[@name]["mappings"] if mappings[@name]
77
+ rescue Elasticsearch::Transport::Transport::Errors::NotFound
78
+ nil
79
+ end
80
+
81
+ def flush
82
+ args = { index: @name }
83
+ instrument("flush", args) { @client.indices.flush(args) }
84
+ end
85
+
86
+ private
87
+
88
+ def instrument(name, extra = {})
89
+ ActiveSupport::Notifications.instrument("elasticity.#{name}", args: extra) do
90
+ yield
91
+ end
92
+ end
93
+
94
+ class Bulk
95
+ def initialize(client, name)
96
+ @client = client
97
+ @name = name
98
+ @operations = []
99
+ end
100
+
101
+ def index(type, id, attributes)
102
+ @operations << { index: { _index: @name, _type: type, _id: id, data: attributes }}
103
+ end
104
+
105
+ def delete(type, id)
106
+ @operations << { delete: { _index: @name, _type: type, _id: id }}
107
+ end
108
+
109
+ def execute
110
+ @client.bulk(body: @operations)
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,46 @@
1
+ module Elasticity
2
+ class MultiSearch
3
+ def initialize
4
+ @searches = []
5
+ yield self if block_given?
6
+ end
7
+
8
+ def add(name, search, documents: nil, active_records: nil)
9
+ mapper = case
10
+ when documents && active_records
11
+ raise ArgumentError, "you can only pass either :documents or :active_records as an option"
12
+ when documents
13
+ Search::DocumentMapper.new(documents)
14
+ when active_records
15
+ Search::ActiveRecordMapper.new(active_records)
16
+ else
17
+ raise ArgumentError, "you need to provide either :documents or :active_records as an option"
18
+ end
19
+
20
+ @searches << [name, search, mapper]
21
+ end
22
+
23
+ def [](name)
24
+ @results ||= fetch
25
+ @results[name]
26
+ end
27
+
28
+ private
29
+
30
+ def fetch
31
+ multi_body = @searches.map do |name, search, _|
32
+ { index: search.index.name, type: search.document_type, body: search.body }
33
+ end
34
+
35
+ results = {}
36
+
37
+ responses = Array(Elasticity.config.client.msearch(body: multi_body)["responses"])
38
+ responses.each_with_index do |resp, idx|
39
+ name, search, mapper = @searches[idx]
40
+ results[name] = Search::Result.new(resp, mapper)
41
+ end
42
+
43
+ results
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,159 @@
1
+ module Elasticity
2
+ # Search provides a simple interface for defining a search against an ElasticSearch
3
+ # index and fetching the results in different ways and mappings.
4
+ #
5
+ # Example:
6
+ # search = Elasticity::Search.new("people", "person", {...})
7
+ # search.documents(Person)
8
+ class Search
9
+ attr_reader :index, :document_type, :body
10
+
11
+ # Creates a new Search definitions for the given index, document_type and criteria. The
12
+ # search is not performend until methods are called, each method represents a different
13
+ # way of fetching and mapping the data.
14
+ #
15
+ # The body parameter is a hash following the exact same syntax as ElasticSearch's JSON
16
+ # query language.
17
+ def initialize(index, document_type, body)
18
+ @index = index
19
+ @document_type = document_type.freeze
20
+ @body = body.freeze
21
+ end
22
+
23
+ # Execute the search, fetching only ids from ElasticSearch and then mapping the results
24
+ # into ActiveRecord models using the provided relation.
25
+ def active_records(relation)
26
+ return @active_record if defined?(@active_record)
27
+ response = @index.search(@document_type, @body.merge(_source: []))
28
+ @active_record = Result.new(response, ActiveRecordMapper.new(relation))
29
+ end
30
+
31
+ # Execute the search, fetching all documents from the index and mapping the stored attributes
32
+ # into instances of the provided class. It will call document_klass.new(attrs), where attrs
33
+ # are the stored attributes.
34
+ def documents(document_klass)
35
+ return @documents if defined?(@documents)
36
+ response = @index.search(@document_type, @body)
37
+ @documents = Result.new(response, DocumentMapper.new(document_klass))
38
+ end
39
+
40
+ # Result is a collection representing the response from a search against an index. It's what gets
41
+ # returned by any of the Elasticity::Search methods and it provides a lazily-evaluated and
42
+ # lazily-mapped – using the provided mapper class.
43
+ #
44
+ # Example:
45
+ #
46
+ # response = {"took"=>0, "timed_out"=>false, "_shards"=>{"total"=>5, "successful"=>5, "failed"=>0}, "hits"=>{"total"=>2, "max_score"=>1.0, "hits"=>[
47
+ # {"_index"=>"my_index", "_type"=>"my_type", "_id"=>"1", "_score"=>1.0, "_source"=> { "id" => 1, "name" => "Foo" },
48
+ # {"_index"=>"my_index", "_type"=>"my_type", "_id"=>"2", "_score"=>1.0, "_source"=> { "id" => 2, "name" => "Bar" },
49
+ # ]}}
50
+ #
51
+ # class AttributesMapper
52
+ # def map(hits)
53
+ # hits.map { |h| h["_source"] }
54
+ # end
55
+ # end
56
+ #
57
+ # r = Result.new(response, AttributesMapper.new)
58
+ # r.total # => 2
59
+ # r[0] # => { "id" => 1, "name" => "Foo" }
60
+ #
61
+ class Result
62
+ include Enumerable
63
+
64
+ def initialize(response, mapper)
65
+ @response = response
66
+ @mapper = mapper
67
+ end
68
+
69
+ def [](idx)
70
+ mapping[idx]
71
+ end
72
+
73
+ def each(&block)
74
+ mapping.each(&block)
75
+ end
76
+
77
+ def to_ary
78
+ mapping.to_ary
79
+ end
80
+
81
+ def size
82
+ mapping.size
83
+ end
84
+
85
+ # The total number of entries as returned by ES
86
+ def total
87
+ @response["hits"]["total"]
88
+ end
89
+
90
+ def empty?
91
+ total == 0
92
+ end
93
+
94
+ def blank?
95
+ empty?
96
+ end
97
+
98
+ def mapping
99
+ return @mapping if defined?(@mapping)
100
+ hits = Array(@response["hits"]["hits"])
101
+ @mapping = @mapper.map(hits)
102
+ end
103
+ end
104
+
105
+ class DocumentMapper
106
+ def initialize(document_klass)
107
+ @document_klass = document_klass
108
+ end
109
+
110
+ def map(hits)
111
+ hits.map do |hit|
112
+ @document_klass.new(hit["_source"])
113
+ end
114
+ end
115
+ end
116
+
117
+ class ActiveRecordMapper
118
+ def initialize(relation)
119
+ @relation = relation
120
+ end
121
+
122
+ def map(hits)
123
+ ids = hits.map { |h| h["_source"]["id"] }
124
+
125
+ if ids.any?
126
+ id_col = "#{quote(@relation.table_name)}.#{quote(@relation.klass.primary_key)}"
127
+ @relation.where(id: ids).order("FIELD(#{id_col},#{ids.join(',')})")
128
+ else
129
+ @relation.none
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ def quote(identifier)
136
+ @relation.connection.quote_column_name(identifier)
137
+ end
138
+ end
139
+ end
140
+
141
+ class DocumentSearchProxy < BasicObject
142
+ def initialize(search, document_klass)
143
+ @search = search
144
+ @document_klass = document_klass
145
+ end
146
+
147
+ def active_records(relation)
148
+ @search.active_records(relation)
149
+ end
150
+
151
+ def documents
152
+ @search.documents(@document_klass)
153
+ end
154
+
155
+ def method_missing(method_name, *args, &block)
156
+ documents.public_send(method_name, *args, &block)
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,3 @@
1
+ module Elasticity
2
+ VERSION = "0.2.1"
3
+ end
@@ -0,0 +1,60 @@
1
+ require "rubygems"
2
+ require "bundler/setup"
3
+ Bundler.setup
4
+
5
+ require "active_support"
6
+ require "active_support/core_ext"
7
+ require "active_model"
8
+ require "elasticsearch"
9
+
10
+ module Elasticity
11
+ class Config
12
+ attr_writer :logger, :client, :settings, :namespace, :pretty_json
13
+
14
+ def logger
15
+ return @logger if defined?(@logger)
16
+ @logger = Logger.new(STDOUT)
17
+ end
18
+
19
+ def client
20
+ return @client if defined?(@client)
21
+ @client = Elasticsearch::Client.new
22
+ end
23
+
24
+ def settings
25
+ return @settings if defined?(@settings)
26
+ @settings = {}
27
+ end
28
+
29
+ def namespace
30
+ @namespace
31
+ end
32
+
33
+ def pretty_json
34
+ @pretty_json || false
35
+ end
36
+ end
37
+
38
+ def self.configure
39
+ @config = Config.new
40
+ yield(@config)
41
+ end
42
+
43
+ def self.config
44
+ return @config if defined?(@config)
45
+ @config = Config.new
46
+ end
47
+ end
48
+
49
+ ActiveSupport::Notifications.subscribe(/^elasticity\./) do |name, start, finish, id, payload|
50
+ time = (finish - start)*1000
51
+
52
+ if logger = Elasticity.config.logger
53
+ logger.debug "#{name} #{"%.2f" % time}ms #{MultiJson.dump(payload[:args], pretty: Elasticity.config.pretty_json)}"
54
+
55
+ exception, message = payload[:exception]
56
+ if exception
57
+ logger.error "#{name} #{exception}: #{message}"
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,35 @@
1
+ require "elasticity_base"
2
+ require "simplecov"
3
+ require "oj"
4
+ require "elasticity"
5
+
6
+ def elastic_search_client
7
+ return @elastic_search_client if defined?(@elastic_search_client)
8
+ @elastic_search_client = Elasticsearch::Client.new host: "http://0.0.0.0:9200"
9
+ end
10
+
11
+ logger = Logger.new("spec/spec.log", File::WRONLY | File::APPEND | File::CREAT)
12
+
13
+ RSpec.configure do |c|
14
+ c.disable_monkey_patching!
15
+
16
+ c.before(:suite) do
17
+ logger.info "rspec.init Starting test suite execution"
18
+ end
19
+
20
+ c.before(:each) do |example|
21
+ logger.info "rspec.spec #{example.full_description}"
22
+
23
+ if example.metadata[:elasticsearch]
24
+ client = elastic_search_client
25
+ else
26
+ client = double(:elasticsearch_client)
27
+ end
28
+
29
+ Elasticity.configure do |e|
30
+ e.logger = logger
31
+ e.client = client
32
+ e.namespace = "elasticity_test"
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,100 @@
1
+ require "elasticity/search"
2
+
3
+ RSpec.describe Elasticity::Document do
4
+ mappings = {
5
+ properties: {
6
+ id: { type: "integer" },
7
+ name: { type: "string" },
8
+
9
+ items: {
10
+ type: "nested",
11
+ properties: {
12
+ name: { type: "string" },
13
+ },
14
+ }
15
+ }
16
+ }
17
+
18
+ let :klass do
19
+ Class.new(described_class) do
20
+ # Override the name since this is an anonymous class
21
+ def self.name
22
+ "ClassName"
23
+ end
24
+
25
+ self.mappings = mappings
26
+
27
+ attr_accessor :name, :items
28
+
29
+ def to_document
30
+ { id: id, name: name, items: items}
31
+ end
32
+ end
33
+ end
34
+
35
+ let :index do
36
+ double(:index, create_if_undefined: nil, name: "elasticity_test_class_names")
37
+ end
38
+
39
+ before :each do
40
+ allow(Elasticity::Index).to receive(:new).and_return(index)
41
+ end
42
+
43
+ it "requires subclasses to define to_document method" do
44
+ expect { Class.new(described_class).new.to_document }.to raise_error(NotImplementedError)
45
+ end
46
+
47
+ context "class" do
48
+ subject { klass }
49
+
50
+ it "extracts index name and document type from the class name" do
51
+ expect(subject.index_name).to eq "elasticity_test_class_names"
52
+ expect(subject.document_type).to eq "class_name"
53
+ end
54
+
55
+ it "have an associated Index instance" do
56
+ client = double(:client)
57
+ settings = double(:settings)
58
+
59
+ Elasticity.config.settings = settings
60
+ Elasticity.config.client = client
61
+
62
+ expect(Elasticity::Index).to receive(:new).with(client, "elasticity_test_class_names").and_return(index)
63
+ expect(index).to receive(:create_if_undefined).with(settings: settings, mappings: mappings)
64
+
65
+ expect(subject.index).to be index
66
+ end
67
+
68
+ it "searches using DocumentSearch" do
69
+ body = double(:body)
70
+ search = double(:search)
71
+ expect(Elasticity::Search).to receive(:new).with(index, "class_name", body).and_return(search)
72
+
73
+ doc_search = double(:doc_search)
74
+ expect(Elasticity::DocumentSearchProxy).to receive(:new).with(search, subject).and_return(doc_search)
75
+
76
+ expect(subject.search(body)).to be doc_search
77
+ end
78
+
79
+ it "gets specific document from the index" do
80
+ doc = { "_source" => { "id" => 1, "name" => "Foo", "items" => [{ "name" => "Item1" }]}}
81
+ expect(index).to receive(:get_document).with("class_name", 1).and_return(doc)
82
+ expect(subject.get(1)).to eq klass.new(id: 1, name: "Foo", items: [{ "name" => "Item1" }])
83
+ end
84
+
85
+ it "deletes specific document from index" do
86
+ index_ret = double(:index_return)
87
+ expect(index).to receive(:delete_document).with("class_name", 1).and_return(index_ret)
88
+ expect(subject.delete(1)).to eq index_ret
89
+ end
90
+ end
91
+
92
+ context "instance" do
93
+ subject { klass.new id: 1, name: "Foo", items: [{ name: "Item1" }] }
94
+
95
+ it "stores the document in the index" do
96
+ expect(index).to receive(:index_document).with("class_name", 1, {id: 1, name: "Foo", items: [{ name: "Item1" }]})
97
+ subject.update
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,97 @@
1
+ require "elasticity/index"
2
+
3
+ RSpec.describe Elasticity::Index, elasticsearch: true do
4
+ subject do
5
+ described_class.new(Elasticity.config.client, "test_index_name")
6
+ end
7
+
8
+ let :index_def do
9
+ {
10
+ mappings: {
11
+ document: {
12
+ properties: {
13
+ name: { type: "string" }
14
+ }
15
+ }
16
+ }
17
+ }
18
+ end
19
+
20
+ after do
21
+ subject.delete_if_defined
22
+ end
23
+
24
+ it "allows creating, recreating and deleting an index" do
25
+ subject.create(index_def)
26
+ expect(subject.mappings).to eq({"document"=>{"properties"=>{"name"=>{"type"=>"string"}}}})
27
+
28
+ subject.recreate
29
+ expect(subject.mappings).to eq({"document"=>{"properties"=>{"name"=>{"type"=>"string"}}}})
30
+
31
+ subject.delete
32
+ expect(subject.mappings).to be nil
33
+ end
34
+
35
+ it "returns nil for mapping and settings when index does not exist" do
36
+ expect(subject.mappings).to be nil
37
+ expect(subject.settings).to be nil
38
+ end
39
+
40
+ context "with existing index" do
41
+ before do
42
+ subject.create_if_undefined(index_def)
43
+ end
44
+
45
+ it "allows adding, getting and removing documents from the index" do
46
+ subject.index_document("document", 1, name: "test")
47
+
48
+ doc = subject.get_document("document", 1)
49
+ expect(doc["_source"]["name"]).to eq("test")
50
+
51
+ subject.delete_document("document", 1)
52
+ expect { subject.get_document("document", 1) }.to raise_error(Elasticsearch::Transport::Transport::Errors::NotFound)
53
+ end
54
+
55
+ it "allows batching index and delete actions" do
56
+ results_a = subject.bulk do |b|
57
+ b.index "document", 1, name: "foo"
58
+ end
59
+ expect(results_a).to include("errors"=>false, "items"=>[{"index"=>{"_index"=>"test_index_name", "_type"=>"document", "_id"=>"1", "_version"=>1, "status"=>201}}])
60
+
61
+ results_b = subject.bulk do |b|
62
+ b.index "document", 2, name: "bar"
63
+ b.delete "document", 1
64
+ end
65
+ expect(results_b).to include("errors"=>false, "items"=>[{"index"=>{"_index"=>"test_index_name", "_type"=>"document", "_id"=>"2", "_version"=>1, "status"=>201}}, {"delete"=>{"_index"=>"test_index_name", "_type"=>"document", "_id"=>"1", "_version"=>2, "status"=>200, "found"=>true}}])
66
+
67
+ subject.flush
68
+
69
+ expect { subject.get_document("document", 1) }.to raise_error(Elasticsearch::Transport::Transport::Errors::NotFound)
70
+ expect(subject.get_document("document", 2)).to eq({"_index"=>"test_index_name", "_type"=>"document", "_id"=>"2", "_version"=>1, "found"=>true, "_source"=>{"name"=>"bar"}})
71
+ end
72
+
73
+ it "allows searching documents" do
74
+ subject.index_document("document", 1, name: "test")
75
+ subject.flush
76
+ results = subject.search("document", filter: { term: { name: "test" }})
77
+
78
+ expect(results["hits"]["total"]).to be 1
79
+
80
+ doc = results["hits"]["hits"][0]
81
+ expect(doc["_id"]).to eq "1"
82
+ expect(doc["_source"]).to eq({ "name" => "test" })
83
+ end
84
+
85
+ it "allows deleting by queryu" do
86
+ subject.index_document("document", 1, name: "foo")
87
+ subject.index_document("document", 2, name: "bar")
88
+
89
+ subject.delete_by_query("document", query: { term: { name: "foo" }})
90
+
91
+ expect { subject.get_document("document", 1) }.to raise_error(Elasticsearch::Transport::Transport::Errors::NotFound)
92
+ expect { subject.get_document("document", 2) }.to_not raise_error
93
+
94
+ subject.flush
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,37 @@
1
+ require "elasticity/search"
2
+ require "elasticity/multi_search"
3
+
4
+ RSpec.describe Elasticity::MultiSearch do
5
+ let :klass do
6
+ Class.new do
7
+ include ActiveModel::Model
8
+ attr_accessor :id, :name
9
+
10
+ def ==(other)
11
+ self.id == other.id && self.name == other.name
12
+ end
13
+ end
14
+ end
15
+
16
+ let :response do
17
+ {
18
+ "responses" => [
19
+ { "hits" => { "total" => 2, "hits" => [{"_source" => { "id" => 1, "name" => "foo" }}, {"_source" => { "id" => 2, "name" => "bar" }}]}},
20
+ { "hits" => { "total" => 1, "hits" => [{"_source" => { "id" => 3, "name" => "baz" }}]}},
21
+ ]
22
+ }
23
+ end
24
+
25
+ it "performs multi search" do
26
+ subject.add(:first, Elasticity::Search.new(double(:index, name: "index_first"), "document_first", { search: :first }), documents: klass)
27
+ subject.add(:second, Elasticity::Search.new(double(:index, name: "index_second"), "document_second", { search: :second }), documents: klass)
28
+
29
+ expect(Elasticity.config.client).to receive(:msearch).with(body: [
30
+ { index: "index_first", type: "document_first", body: { search: :first } },
31
+ { index: "index_second", type: "document_second", body: { search: :second } },
32
+ ]).and_return(response)
33
+
34
+ expect(Array(subject[:first])). to eq [klass.new(id: 1, name: "foo"), klass.new(id: 2, name: "bar")]
35
+ expect(Array(subject[:second])). to eq [klass.new(id: 3, name: "baz")]
36
+ end
37
+ end
@@ -0,0 +1,108 @@
1
+ require "elasticity/search"
2
+
3
+ RSpec.describe "Search" do
4
+ let(:index) { double(:index) }
5
+ let(:document_type) { "document" }
6
+ let(:body) { {} }
7
+
8
+ let :full_response do
9
+ { "hits" => { "total" => 2, "hits" => [
10
+ {"_source" => { "id" => 1, "name" => "foo" }},
11
+ {"_source" => { "id" => 2, "name" => "bar" }},
12
+ ]}}
13
+ end
14
+
15
+ let :ids_response do
16
+ { "hits" => { "total" => 2, "hits" => [
17
+ {"_source" => { "id" => 1, "name" => "foo" }},
18
+ {"_source" => { "id" => 2, "name" => "bar" }},
19
+ ]}}
20
+ end
21
+
22
+ let :empty_response do
23
+ { "hits" => { "total" => 0, "hits" => [] }}
24
+ end
25
+
26
+ let :klass do
27
+ Class.new do
28
+ include ActiveModel::Model
29
+ attr_accessor :id, :name
30
+
31
+ def ==(other)
32
+ self.id == other.id && self.name == other.name
33
+ end
34
+ end
35
+ end
36
+
37
+ describe Elasticity::Search do
38
+ subject do
39
+ described_class.new(index, document_type, body)
40
+ end
41
+
42
+ it "searches the index and return document models" do
43
+ expect(index).to receive(:search).with(document_type, body).and_return(full_response)
44
+
45
+ docs = subject.documents(klass)
46
+ expected = [klass.new(id: 1, name: "foo"), klass.new(id: 2, name: "bar")]
47
+
48
+ expect(docs.total).to eq 2
49
+ expect(docs.size).to eq expected.size
50
+
51
+ expect(docs).to_not be_empty
52
+ expect(docs).to_not be_blank
53
+
54
+ expect(docs[0].name).to eq expected[0].name
55
+ expect(docs[1].name).to eq expected[1].name
56
+
57
+ expect(docs.each.first).to eq expected[0]
58
+ expect(Array(docs)).to eq expected
59
+ end
60
+
61
+ it "searches the index and return active record models" do
62
+ expect(index).to receive(:search).with(document_type, body.merge(_source: ["id"])).and_return(ids_response)
63
+
64
+ relation = double(:relation,
65
+ connection: double(:connection),
66
+ table_name: "table_name",
67
+ klass: double(:klass, primary_key: "id"),
68
+ )
69
+ allow(relation.connection).to receive(:quote_column_name) { |name| name }
70
+
71
+ expect(relation).to receive(:where).with(id: [1,2]).and_return(relation)
72
+ expect(relation).to receive(:order).with("FIELD(table_name.id,1,2)").and_return(relation)
73
+
74
+ expect(subject.active_records(relation).mapping).to be relation
75
+ end
76
+
77
+ it "return relation.none from activerecord relation with no matches" do
78
+ expect(index).to receive(:search).with(document_type, body.merge(_source: ["id"])).and_return(empty_response)
79
+
80
+ relation = double(:relation)
81
+ expect(relation).to receive(:none).and_return(relation)
82
+
83
+ expect(subject.active_records(relation).mapping).to be relation
84
+ end
85
+ end
86
+
87
+ describe Elasticity::DocumentSearchProxy do
88
+ let :search do
89
+ Elasticity::Search.new(index, "document", body)
90
+ end
91
+
92
+ subject do
93
+ described_class.new(search, klass)
94
+ end
95
+
96
+ it "automatically maps the documents into the provided Document class" do
97
+ expect(index).to receive(:search).with(document_type, body).and_return(full_response)
98
+ expect(Array(subject)).to eq [klass.new(id: 1, name: "foo"), klass.new(id: 2, name: "bar")]
99
+ end
100
+
101
+ it "delegates active_records for the underlying search" do
102
+ records = double(:records)
103
+ rel = double(:relation)
104
+ expect(search).to receive(:active_records).with(rel).and_return(records)
105
+ expect(subject.active_records(rel)).to be records
106
+ end
107
+ end
108
+ end
metadata ADDED
@@ -0,0 +1,199 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: es-elasticity
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.1
5
+ platform: ruby
6
+ authors:
7
+ - Rodrigo Kochenburger
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-10-07 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.7'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.7'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 3.1.0
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 3.1.0
55
+ - !ruby/object:Gem::Dependency
56
+ name: simplecov
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 0.7.1
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 0.7.1
69
+ - !ruby/object:Gem::Dependency
70
+ name: oj
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: pry
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: activesupport
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: 4.0.0
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: 4.0.0
111
+ - !ruby/object:Gem::Dependency
112
+ name: activemodel
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: 4.0.0
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: 4.0.0
125
+ - !ruby/object:Gem::Dependency
126
+ name: elasticsearch
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: 1.0.5
132
+ type: :runtime
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: 1.0.5
139
+ description: Elasticity provides a higher level abstraction on top of [elasticsearch-ruby](https://github.com/elasticsearch/elasticsearch-ruby)
140
+ gem
141
+ email:
142
+ - rodrigo@doximity.com
143
+ executables: []
144
+ extensions: []
145
+ extra_rdoc_files: []
146
+ files:
147
+ - ".gitignore"
148
+ - ".rspec"
149
+ - ".simplecov"
150
+ - Gemfile
151
+ - LICENSE.txt
152
+ - README.md
153
+ - Rakefile
154
+ - bin/rake
155
+ - bin/rspec
156
+ - elasticity.gemspec
157
+ - lib/elasticity.rb
158
+ - lib/elasticity/document.rb
159
+ - lib/elasticity/index.rb
160
+ - lib/elasticity/multi_search.rb
161
+ - lib/elasticity/search.rb
162
+ - lib/elasticity/version.rb
163
+ - lib/elasticity_base.rb
164
+ - spec/rspec_config.rb
165
+ - spec/units/document_spec.rb
166
+ - spec/units/index_spec.rb
167
+ - spec/units/multi_search_spec.rb
168
+ - spec/units/search_spec.rb
169
+ homepage: ''
170
+ licenses:
171
+ - MIT
172
+ metadata: {}
173
+ post_install_message:
174
+ rdoc_options: []
175
+ require_paths:
176
+ - lib
177
+ required_ruby_version: !ruby/object:Gem::Requirement
178
+ requirements:
179
+ - - ">="
180
+ - !ruby/object:Gem::Version
181
+ version: '0'
182
+ required_rubygems_version: !ruby/object:Gem::Requirement
183
+ requirements:
184
+ - - ">="
185
+ - !ruby/object:Gem::Version
186
+ version: '0'
187
+ requirements: []
188
+ rubyforge_project:
189
+ rubygems_version: 2.2.2
190
+ signing_key:
191
+ specification_version: 4
192
+ summary: ActiveModel-based library for working with ElasticSearch
193
+ test_files:
194
+ - spec/rspec_config.rb
195
+ - spec/units/document_spec.rb
196
+ - spec/units/index_spec.rb
197
+ - spec/units/multi_search_spec.rb
198
+ - spec/units/search_spec.rb
199
+ has_rdoc: