es-elasticity 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: