elastictastic 0.5.0

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.
Files changed (57) hide show
  1. data/LICENSE +19 -0
  2. data/README.md +326 -0
  3. data/lib/elastictastic/association.rb +21 -0
  4. data/lib/elastictastic/bulk_persistence_strategy.rb +70 -0
  5. data/lib/elastictastic/callbacks.rb +30 -0
  6. data/lib/elastictastic/child_collection_proxy.rb +56 -0
  7. data/lib/elastictastic/client.rb +101 -0
  8. data/lib/elastictastic/configuration.rb +35 -0
  9. data/lib/elastictastic/dirty.rb +130 -0
  10. data/lib/elastictastic/discrete_persistence_strategy.rb +52 -0
  11. data/lib/elastictastic/document.rb +98 -0
  12. data/lib/elastictastic/errors.rb +7 -0
  13. data/lib/elastictastic/field.rb +38 -0
  14. data/lib/elastictastic/index.rb +19 -0
  15. data/lib/elastictastic/mass_assignment_security.rb +15 -0
  16. data/lib/elastictastic/middleware.rb +119 -0
  17. data/lib/elastictastic/nested_document.rb +29 -0
  18. data/lib/elastictastic/new_relic_instrumentation.rb +26 -0
  19. data/lib/elastictastic/observer.rb +3 -0
  20. data/lib/elastictastic/observing.rb +21 -0
  21. data/lib/elastictastic/parent_child.rb +115 -0
  22. data/lib/elastictastic/persistence.rb +67 -0
  23. data/lib/elastictastic/properties.rb +236 -0
  24. data/lib/elastictastic/railtie.rb +35 -0
  25. data/lib/elastictastic/resource.rb +4 -0
  26. data/lib/elastictastic/scope.rb +283 -0
  27. data/lib/elastictastic/scope_builder.rb +32 -0
  28. data/lib/elastictastic/scoped.rb +20 -0
  29. data/lib/elastictastic/search.rb +180 -0
  30. data/lib/elastictastic/server_error.rb +15 -0
  31. data/lib/elastictastic/test_helpers.rb +172 -0
  32. data/lib/elastictastic/util.rb +63 -0
  33. data/lib/elastictastic/validations.rb +45 -0
  34. data/lib/elastictastic/version.rb +3 -0
  35. data/lib/elastictastic.rb +82 -0
  36. data/spec/environment.rb +6 -0
  37. data/spec/examples/active_model_lint_spec.rb +20 -0
  38. data/spec/examples/bulk_persistence_strategy_spec.rb +233 -0
  39. data/spec/examples/callbacks_spec.rb +96 -0
  40. data/spec/examples/dirty_spec.rb +238 -0
  41. data/spec/examples/document_spec.rb +600 -0
  42. data/spec/examples/mass_assignment_security_spec.rb +13 -0
  43. data/spec/examples/middleware_spec.rb +92 -0
  44. data/spec/examples/observing_spec.rb +141 -0
  45. data/spec/examples/parent_child_spec.rb +308 -0
  46. data/spec/examples/properties_spec.rb +92 -0
  47. data/spec/examples/scope_spec.rb +491 -0
  48. data/spec/examples/search_spec.rb +382 -0
  49. data/spec/examples/spec_helper.rb +15 -0
  50. data/spec/examples/validation_spec.rb +65 -0
  51. data/spec/models/author.rb +9 -0
  52. data/spec/models/blog.rb +5 -0
  53. data/spec/models/comment.rb +5 -0
  54. data/spec/models/post.rb +41 -0
  55. data/spec/models/post_observer.rb +11 -0
  56. data/spec/support/fakeweb_request_history.rb +13 -0
  57. metadata +227 -0
@@ -0,0 +1,130 @@
1
+ module Elastictastic
2
+ module Dirty
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ include ActiveModel::Dirty
7
+ end
8
+
9
+ module ClassMethods
10
+ def define_field(field_name, options, &block)
11
+ super
12
+ define_dirty_accessors(field_name)
13
+ end
14
+
15
+ def define_embed(embed_name, options)
16
+ super
17
+ define_dirty_accessors(embed_name)
18
+ end
19
+
20
+ private
21
+
22
+ #
23
+ # We have to rewrite ActiveModel functionality here because in Rails 3.0,
24
+ # #define_attribute_methods has to be called exactly one time, and there's
25
+ # no place for us to do that. This appears to be fixed in ActiveModel 3.1
26
+ #
27
+ def define_dirty_accessors(attribute)
28
+ attribute = attribute.to_s
29
+ module_eval <<-RUBY, __FILE__, __LINE__+1
30
+ def #{attribute}_changed?
31
+ attribute_changed?(#{attribute.inspect})
32
+ end
33
+
34
+ def #{attribute}_change
35
+ attribute_change(#{attribute.inspect})
36
+ end
37
+
38
+ def #{attribute}_will_change!
39
+ attribute_will_change!(#{attribute.inspect})
40
+ end
41
+
42
+ def #{attribute}_was
43
+ attribute_was(#{attribute.inspect})
44
+ end
45
+
46
+ def reset_#{attribute}!
47
+ reset_attribute!(#{attribute})
48
+ end
49
+ RUBY
50
+ end
51
+ end
52
+
53
+ module InstanceMethods
54
+ def write_attribute(field, value)
55
+ attribute_will_change!(field)
56
+ super
57
+ end
58
+
59
+ def write_embed(field, value)
60
+ attribute_will_change!(field)
61
+ if Array === value
62
+ value.each do |el|
63
+ el.nesting_document = self
64
+ el.nesting_association = field
65
+ end
66
+ super(field, NestedCollectionProxy.new(self, field, value))
67
+ else
68
+ value.nesting_document = self
69
+ value.nesting_association = field
70
+ super
71
+ end
72
+ end
73
+
74
+ def save
75
+ super
76
+ clean_attributes!
77
+ end
78
+
79
+ def elasticsearch_doc=(doc)
80
+ super
81
+ clean_attributes!
82
+ end
83
+
84
+ protected
85
+
86
+ def clean_attributes!
87
+ changed_attributes.clear
88
+ @embeds.each_pair do |name, embedded|
89
+ Util.call_or_map(embedded) { |doc| doc.clean_attributes! }
90
+ end
91
+ end
92
+ end
93
+
94
+ module NestedDocumentMethods
95
+ attr_writer :nesting_document, :nesting_association
96
+
97
+ def attribute_will_change!(field)
98
+ super
99
+ if @nesting_document
100
+ @nesting_document.__send__("attribute_will_change!", @nesting_association)
101
+ end
102
+ end
103
+ end
104
+
105
+ class NestedCollectionProxy < Array
106
+ def initialize(owner, embed_name, collection = [])
107
+ @owner, @embed_name = owner, embed_name
108
+ super(collection)
109
+ end
110
+
111
+ [
112
+ :<<, :[]=, :collect!, :compact!, :delete, :delete_at, :delete_if,
113
+ :flatten!, :insert, :keep_if, :map!, :push, :reject!, :replace,
114
+ :reverse!, :rotate!, :select!, :shuffle!, :slice!, :sort!, :sort_by!,
115
+ :uniq!
116
+ ].each do |destructive_method|
117
+ module_eval <<-RUBY, __FILE__, __LINE__+1
118
+ def #{destructive_method}(*args)
119
+ @owner.__send__("\#{@embed_name}_will_change!")
120
+ super
121
+ end
122
+ RUBY
123
+ end
124
+
125
+ def clone
126
+ NestedCollectionProxy.new(@owner, @embed_name, map { |el| el.clone })
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,52 @@
1
+ require 'singleton'
2
+
3
+ module Elastictastic
4
+ class DiscretePersistenceStrategy
5
+ include Singleton
6
+
7
+ attr_accessor :auto_refresh
8
+
9
+ def create(doc)
10
+ response = Elastictastic.client.create(
11
+ doc.index,
12
+ doc.class.type,
13
+ doc.id,
14
+ doc.elasticsearch_doc,
15
+ params_for(doc)
16
+ )
17
+ doc.id = response['_id']
18
+ doc.persisted!
19
+ end
20
+
21
+ def update(doc)
22
+ Elastictastic.client.update(
23
+ doc.index,
24
+ doc.class.type,
25
+ doc.id,
26
+ doc.elasticsearch_doc,
27
+ params_for(doc)
28
+ )
29
+ doc.persisted!
30
+ end
31
+
32
+ def destroy(doc)
33
+ response = Elastictastic.client.delete(
34
+ doc.index.name,
35
+ doc.class.type,
36
+ doc.id,
37
+ params_for(doc)
38
+ )
39
+ doc.transient!
40
+ response['found']
41
+ end
42
+
43
+ private
44
+
45
+ def params_for(doc)
46
+ {}.tap do |params|
47
+ params[:refresh] = true if Elastictastic.config.auto_refresh
48
+ params[:parent] = doc._parent_id if doc._parent_id
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,98 @@
1
+ module Elastictastic
2
+ module Document
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ extend Scoped
7
+ include Properties
8
+ include Persistence
9
+ include ParentChild
10
+ include Callbacks
11
+ include Observing
12
+ include Dirty
13
+ include MassAssignmentSecurity
14
+ include Validations
15
+
16
+ extend ActiveModel::Naming
17
+ include ActiveModel::Conversion
18
+ end
19
+
20
+ module ClassMethods
21
+ delegate :find, :destroy_all, :sync_mapping, :inspect, :find_each,
22
+ :find_in_batches, :first, :count, :empty?, :any?, :all,
23
+ :query, :filter, :from, :size, :sort, :highlight, :fields,
24
+ :script_fields, :preference, :facets, :to => :current_scope
25
+
26
+ def mapping
27
+ { type => { 'properties' => properties }}
28
+ end
29
+
30
+ def type
31
+ name.underscore
32
+ end
33
+
34
+ def in_index(name_or_index)
35
+ Scope.new(Elastictastic::Index(name_or_index), self)
36
+ end
37
+
38
+ def scoped(params)
39
+ current_scope.scoped(params)
40
+ end
41
+
42
+ private
43
+
44
+ def default_scope
45
+ in_index(Index.default)
46
+ end
47
+ end
48
+
49
+ module InstanceMethods
50
+ attr_reader :id
51
+
52
+ def initialize(attributes = {})
53
+ self.class.current_scope.initialize_instance(self)
54
+ end
55
+
56
+ def elasticsearch_hit=(hit) #:nodoc:
57
+ @id = hit['_id']
58
+ @index = Index.new(hit['_index'])
59
+ persisted!
60
+
61
+ doc = {}
62
+ doc.merge!(hit['_source']) if hit['_source']
63
+ fields = hit['fields']
64
+ if fields
65
+ unflattened_fields =
66
+ Util.unflatten_hash(fields.reject { |k, v| v.nil? })
67
+ if unflattened_fields.has_key?('_source')
68
+ doc.merge!(unflattened_fields.delete('_source'))
69
+ end
70
+ doc.merge!(unflattened_fields)
71
+ end
72
+ self.elasticsearch_doc=(doc)
73
+ end
74
+
75
+ def id=(id)
76
+ assert_transient!
77
+ @id = id
78
+ end
79
+
80
+ def index
81
+ return @index if defined? @index
82
+ @index = Index.default
83
+ end
84
+
85
+ def ==(other)
86
+ index == other.index && id == other.id
87
+ end
88
+
89
+ def inspect
90
+ inspected = "#<#{self.class.name} id: #{id}, index: #{index.name}"
91
+ attributes.each_pair do |attr, value|
92
+ inspected << ", #{attr}: #{value.inspect}"
93
+ end
94
+ inspected << ">"
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,7 @@
1
+ module Elastictastic
2
+ CancelBulkOperation = Class.new(StandardError)
3
+ IllegalModificationError = Class.new(StandardError)
4
+ OperationNotAllowed = Class.new(StandardError)
5
+ NoServerAvailable = Class.new(StandardError)
6
+ RecordInvalid = Class.new(StandardError)
7
+ end
@@ -0,0 +1,38 @@
1
+ module Elastictastic
2
+ class Field < BasicObject
3
+ private_class_method :new
4
+
5
+ def self.process(field_name, default_options, &block)
6
+ {}.tap do |properties|
7
+ new(field_name, default_options, properties, &block)
8
+ end
9
+ end
10
+
11
+ def self.with_defaults(options)
12
+ options = Util.deep_stringify(options)
13
+ { 'type' => 'string' }.merge(options).tap do |field_properties|
14
+ if field_properties['type'].to_s == 'date'
15
+ field_properties['format'] = 'date_time_no_millis'
16
+ end
17
+ end
18
+ end
19
+
20
+ def initialize(field_name, default_options, properties, &block)
21
+ @field_name = field_name
22
+ @properties = properties
23
+ if block
24
+ @properties['type'] = 'multi_field'
25
+ @properties['fields'] =
26
+ { field_name.to_s => Field.with_defaults(default_options) }
27
+ instance_eval(&block)
28
+ else
29
+ @properties.merge!(Field.with_defaults(default_options))
30
+ end
31
+ end
32
+
33
+ def field(field_name, options = {})
34
+ @properties['fields'][field_name.to_s] =
35
+ Field.with_defaults(options)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,19 @@
1
+ module Elastictastic
2
+ class Index
3
+ class <<self
4
+ def default
5
+ new(Elastictastic.config.default_index)
6
+ end
7
+ end
8
+
9
+ attr_reader :name
10
+
11
+ def initialize(name)
12
+ @name = name
13
+ end
14
+
15
+ def to_s
16
+ @name
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ module Elastictastic
2
+ module MassAssignmentSecurity
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ include ActiveModel::MassAssignmentSecurity
7
+ end
8
+
9
+ module InstanceMethods
10
+ def attributes=(attributes)
11
+ super(sanitize_for_mass_assignment(attributes))
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,119 @@
1
+ require 'faraday'
2
+
3
+ module Elastictastic
4
+ module Middleware
5
+ class JsonEncodeBody < Faraday::Middleware
6
+ def call(env)
7
+ case env[:body]
8
+ when String, nil
9
+ # nothing
10
+ else env[:body] = env[:body].to_json
11
+ end
12
+ @app.call(env)
13
+ end
14
+ end
15
+
16
+ class JsonDecodeResponse < Faraday::Middleware
17
+ def call(env)
18
+ @app.call(env).on_complete do
19
+ env[:body] &&= JSON.parse(env[:body])
20
+ end
21
+ end
22
+ end
23
+
24
+ class RaiseServerErrors < Faraday::Middleware
25
+ ERROR_PATTERN = /^([A-Z][A-Za-z]*)(?::\s*)?(.*)$/
26
+
27
+ def call(env)
28
+ @app.call(env).on_complete do
29
+ body = env[:body]
30
+ if body['error']
31
+ raise_error(body['error'], body['status'])
32
+ elsif body['_shards'] && body['_shards']['failures']
33
+ raise_error(
34
+ body['_shards']['failures'].first['reason'], body['status'])
35
+ end
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def raise_error(server_message, status)
42
+ match = ERROR_PATTERN.match(server_message)
43
+ if match
44
+ clazz = Elastictastic::ServerError.const_get(match[1])
45
+ error = clazz.new(match[2])
46
+ error.status = status
47
+ Kernel.raise error
48
+ else
49
+ Kernel.raise Elastictastic::ServerError::ServerError, server_message
50
+ end
51
+ end
52
+ end
53
+
54
+ class LogRequests < Faraday::Middleware
55
+ def initialize(app, logger)
56
+ super(app)
57
+ @logger = logger
58
+ end
59
+
60
+ def call(env)
61
+ now = Time.now
62
+ body = env[:body]
63
+ @app.call(env).on_complete do
64
+ method = env[:method].to_s.upcase
65
+ time = ((Time.now - now) * 1000).to_i
66
+ message = "ElasticSearch #{method} (#{time}ms) #{env[:url].path}"
67
+ message << ' ' << body if body
68
+ @logger.debug(message)
69
+ end
70
+ end
71
+ end
72
+
73
+ class Rotor < Faraday::Middleware
74
+ def initialize(app, *hosts)
75
+ first = nil
76
+ hosts.each do |host|
77
+ node = Node.new(app, host)
78
+ first ||= node
79
+ @head.next = node if @head
80
+ @head = node
81
+ end
82
+ @head.next = first
83
+ end
84
+
85
+ def call(env)
86
+ last = @head
87
+ begin
88
+ @head = @head.next
89
+ @head.call(env)
90
+ rescue Faraday::Error::ConnectionFailed => e
91
+ raise NoServerAvailable if @head == last
92
+ retry
93
+ end
94
+ end
95
+
96
+ class Node < Faraday::Middleware
97
+ attr_accessor :next
98
+
99
+ def initialize(app, url)
100
+ super(app)
101
+ # Create a connection instance so we can use its #build_url method.
102
+ # Kinda lame -- seems like it would make more sense for Faraday to
103
+ # just implement a middleware for injecting a host/path prefix.
104
+ @connection = Faraday::Connection.new(:url => url)
105
+ end
106
+
107
+ def call(env)
108
+ original_url = env[:url]
109
+ begin
110
+ env[:url] = @connection.build_url(original_url)
111
+ @app.call(env)
112
+ ensure
113
+ env[:url] = original_url
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,29 @@
1
+ module Elastictastic
2
+ module NestedDocument
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ include Properties
7
+ include Dirty
8
+ include Dirty::NestedDocumentMethods
9
+ include MassAssignmentSecurity
10
+ include Validations
11
+ end
12
+
13
+ module InstanceMethods
14
+ def initialize_copy(original)
15
+ self.write_attributes(original.read_attributes.dup)
16
+ end
17
+
18
+ def inspect
19
+ inspected = "#<#{self.class.name}"
20
+ if attributes.any?
21
+ inspected << ' ' << attributes.each_pair.map do |attr, value|
22
+ "#{attr}: #{value.inspect}"
23
+ end.join(', ')
24
+ end
25
+ inspected << '>'
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,26 @@
1
+ begin
2
+ require 'new_relic/agent/method_tracer'
3
+ rescue LoadError => e
4
+ raise LoadError, "Can't use NewRelic instrumentation without NewRelic gem"
5
+ end
6
+
7
+ module Elastictastic
8
+ module NewRelicInstrumentation
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ include NewRelic::Agent::MethodTracer
13
+
14
+ add_method_tracer :create, 'ElasticSearch/#{args[1].classify}#create'
15
+ add_method_tracer :delete, 'ElasticSearch/#{args[1].classify + "#" if args[1]}/delete'
16
+ add_method_tracer :get, 'ElasticSearch/#{args[1].classify}#get'
17
+ add_method_tracer :mget, 'ElasticSearch/mget'
18
+ add_method_tracer :put_mapping, 'ElasticSearch/#{args[1].classify}#put_mapping'
19
+ add_method_tracer :scroll, 'ElasticSearch/scroll'
20
+ add_method_tracer :search, 'ElasticSearch/#{args[1].classify}#search'
21
+ add_method_tracer :update, 'ElasticSearch/#{args[1].classify}#update'
22
+ end
23
+ end
24
+ end
25
+
26
+ Elastictastic::Client.module_eval { include Elastictastic::NewRelicInstrumentation }
@@ -0,0 +1,3 @@
1
+ module Elastictastic
2
+ Observer = Class.new(ActiveModel::Observer)
3
+ end
@@ -0,0 +1,21 @@
1
+ module Elastictastic
2
+ module Observing
3
+ extend ActiveSupport::Concern
4
+ extend ActiveModel::Observing::ClassMethods
5
+
6
+ included do
7
+ include ActiveModel::Observing
8
+ end
9
+
10
+ module InstanceMethods
11
+ Callbacks::HOOKS.each do |method|
12
+ module_eval <<-RUBY, __FILE__, __LINE__ + 1
13
+ def #{method}(*args)
14
+ notify_observers(:before_#{method})
15
+ super.tap { notify_observers(:after_#{method}) }
16
+ end
17
+ RUBY
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,115 @@
1
+ module Elastictastic
2
+ module ParentChild
3
+ extend ActiveSupport::Concern
4
+
5
+ module ClassMethods
6
+ attr_reader :parent_association
7
+
8
+ def belongs_to(parent_name, options = {})
9
+ @parent_association = Association.new(parent_name, options)
10
+
11
+ module_eval(<<-RUBY, __FILE__, __LINE__+1)
12
+ def #{parent_name}
13
+ _parent
14
+ end
15
+ RUBY
16
+ end
17
+
18
+ def has_many(children_name, options = {})
19
+ children_name = children_name.to_s
20
+ child_associations[children_name] = Association.new(children_name, options)
21
+
22
+ module_eval(<<-RUBY, __FILE__, __LINE__ + 1)
23
+ def #{children_name}
24
+ read_child(#{children_name.inspect})
25
+ end
26
+ RUBY
27
+ end
28
+
29
+ def child_association(name)
30
+ child_associations[name.to_s]
31
+ end
32
+
33
+ def child_associations
34
+ @child_associations ||= {}
35
+ end
36
+
37
+ def mapping
38
+ super.tap do |mapping|
39
+ mapping[type]['_parent'] = { 'type' => @parent_association.clazz.type } if @parent_association
40
+ end
41
+ end
42
+ end
43
+
44
+ module InstanceMethods
45
+
46
+ def initialize(attributes = {})
47
+ super
48
+ @children = Hash.new do |hash, child_association_name|
49
+ hash[child_association_name] = Elastictastic::ChildCollectionProxy.new(
50
+ self.class.child_association(child_association_name.to_s),
51
+ self
52
+ )
53
+ end
54
+ end
55
+
56
+ def elasticsearch_doc=(doc)
57
+ @parent_id = doc.delete('_parent')
58
+ super
59
+ end
60
+
61
+ def _parent #:nodoc:
62
+ return @parent if defined? @parent
63
+ @parent =
64
+ if @parent_id
65
+ self.class.parent_association.clazz.find(@parent_id)
66
+ end
67
+ end
68
+
69
+ def _parent_id #:nodoc:
70
+ if @parent
71
+ @parent_id = @parent.id
72
+ elsif @parent_id
73
+ @parent_id
74
+ end
75
+ end
76
+
77
+ def parent_collection=(parent_collection)
78
+ if @parent_collection
79
+ raise Elastictastic::IllegalModificationError,
80
+ "Document is already a child of #{_parent}"
81
+ end
82
+ if persisted?
83
+ raise Elastictastic::IllegalModificationError,
84
+ "Can't change parent of persisted object"
85
+ end
86
+ @parent_collection = parent_collection
87
+ @parent = parent_collection.parent
88
+ end
89
+
90
+ def save
91
+ super
92
+ self.class.child_associations.each_pair do |name, association|
93
+ association.extract(self).transient_children.each do |child|
94
+ child.save unless child.pending_save?
95
+ end
96
+ end
97
+ end
98
+
99
+ def persisted!
100
+ was_persisted = @persisted
101
+ super
102
+ if @parent_collection && !was_persisted
103
+ @parent_collection.persisted!(self)
104
+ end
105
+ end
106
+
107
+ protected
108
+
109
+ def read_child(field_name)
110
+ @children[field_name.to_s]
111
+ end
112
+
113
+ end
114
+ end
115
+ end