elasticsearch-model-extensions 0.0.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: 91592da0d915c072cdaa65f2a805d94edab055e9
4
+ data.tar.gz: c9b8c32829eb32ca15d9971fe5dc967b2947b740
5
+ SHA512:
6
+ metadata.gz: 0900f09019a5451ba338c7b619a3ad23cda124624f9aa902865289f27164c6f7245d2235b77b9ffce6beda2a927cc02e7a26426d2235339bd6ddefb3a0fa44bb
7
+ data.tar.gz: 523697b1b2836f2033941899db7077edc6ea2c7302b8d317f54745d3339e86bf3286e96065ae9155f1522e1c95ca4d5e095997954b94b7c86e337def1686a54b
data/.gitignore ADDED
@@ -0,0 +1,14 @@
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
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in elasticsearch-model-extensions.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Yusuke KUOKA
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,31 @@
1
+ # Elasticsearch::Model::Extensions
2
+
3
+ TODO: Write a gem description
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'elasticsearch-model-extensions'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install elasticsearch-model-extensions
20
+
21
+ ## Usage
22
+
23
+ TODO: Write usage instructions here
24
+
25
+ ## Contributing
26
+
27
+ 1. Fork it ( https://github.com/[my-github-username]/elasticsearch-model-extensions/fork )
28
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
29
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
30
+ 4. Push to the branch (`git push origin my-new-feature`)
31
+ 5. Create a new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,2 @@
1
+ require "bundler/gem_tasks"
2
+
@@ -0,0 +1,23 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'elasticsearch/model/extensions/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "elasticsearch-model-extensions"
8
+ spec.version = Elasticsearch::Model::Extensions::VERSION
9
+ spec.authors = ["Yusuke KUOKA"]
10
+ spec.email = ["yusuke.kuoka@crowdworks.co.jp"]
11
+ spec.summary = %q{A set of extensions for elasticsearch-model which aims to ease the burden of things like re-indexing, verbose/complex mapping that you may face once you started using elasticsearch seriously.}
12
+ spec.description = %q{A set of extensions for elasticsearch-model which aims to ease the burden of things like re-indexing, verbose/complex mapping.}
13
+ spec.homepage = "https://github.com/crowdworks/elasticsearch-model-extensions"
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.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_development_dependency "bundler", "~> 1.7"
22
+ spec.add_development_dependency "rake", "~> 10.0"
23
+ end
@@ -0,0 +1,139 @@
1
+ require_relative 'mapping_reflection'
2
+
3
+ module Elasticsearch
4
+ module Model
5
+ module Extensions
6
+ module BatchUpdating
7
+ DEFAULT_BATCH_SIZE = 100
8
+
9
+ def self.included(klass)
10
+ klass.extend ClassMethods
11
+
12
+ unless klass.respond_to? :with_indexed_tables_included
13
+ class << klass
14
+ def with_indexed_tables_included
15
+ raise "#{self}.with_indexed_tables_included is not implemented."
16
+ end
17
+ end
18
+ end
19
+
20
+ unless klass.respond_to? :elasticsearch_hosts
21
+ class << klass
22
+ def elasticsearch_hosts
23
+ raise "#{self}.elasticsearch_hosts is not implemented."
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ module ClassMethods
30
+ def split_ids_into(chunk_num, min:nil, max:nil)
31
+ min ||= minimum(:id)
32
+ max ||= maximum(:id)
33
+ chunk_num.times.inject([]) do |r,i|
34
+ chunk_size = ((max-min+1)/chunk_num.to_f).ceil
35
+ first = chunk_size * i
36
+
37
+ last = if i == chunk_num - 1
38
+ max
39
+ else
40
+ chunk_size * (i + 1) - 1
41
+ end
42
+
43
+ r << (first..last)
44
+ end
45
+ end
46
+
47
+ def update_index_in_parallel(parallelism:, index: nil, type: nil, min: nil, max: nil, batch_size:DEFAULT_BATCH_SIZE)
48
+ klass = self
49
+
50
+ Parallel.each(klass.split_ids_into(parallelism, min: min, max: max), in_processes: parallelism) do |id_range|
51
+ @rdb_reconnected ||= klass.connection.reconnect! || true
52
+ @elasticsearch_reconnected ||= klass.__elasticsearch__.client = Elasticsearch::Client.new(host: klass.elasticsearch_hosts)
53
+ klass.for_indexing.update_index_for_ids_in_range id_range, index: index, type: type, batch_size: batch_size
54
+ end
55
+
56
+ klass.connection.reconnect!
57
+ end
58
+
59
+ def for_indexing
60
+ for_batch_indexing
61
+ end
62
+
63
+ def for_batch_indexing
64
+ with_indexed_tables_included.extending(::Elasticsearch::Model::Extensions::BatchUpdating::Association::Extension)
65
+ end
66
+
67
+ # @param [Fixnum] batch_size
68
+ def update_index_in_batches(batch_size: DEFAULT_BATCH_SIZE, where: nil, index: nil, type: nil)
69
+ records_in_scope = if where.nil?
70
+ for_batch_indexing
71
+ else
72
+ for_batch_indexing.where(where)
73
+ end
74
+
75
+ records_in_scope.update_index_in_batches(batch_size: batch_size, index: index, type: type)
76
+ end
77
+
78
+ # @param [Array] records
79
+ def update_index_in_batch(records, index: nil, type: nil, client: nil)
80
+ klass = self
81
+
82
+ client ||= klass.__elasticsearch__.client
83
+ index ||= klass.index_name
84
+ type ||= klass.document_type
85
+
86
+ if records.size > 1
87
+ response = client.bulk \
88
+ index: index,
89
+ type: type,
90
+ body: records.map { |r| { index: { _id: r.id, data: r.as_indexed_json } } }
91
+
92
+ one_or_more_errors_occurred = response["errors"]
93
+
94
+ if one_or_more_errors_occurred
95
+ Rails.logger.warn "One or more error(s) occurred while updating the index #{records} for the type #{type}\n#{JSON.pretty_generate(response)}"
96
+ end
97
+ else
98
+ records.each do |r|
99
+ client.index index: index, type: type, id: r.id, body: r.as_indexed_json
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ module Association
106
+ module Extension
107
+ def update_index_in_chunks(num, index: index)
108
+ klass.split_ids_into(num).map do |r|
109
+ if block_given?
110
+ yield -> { update_index_for_ids_in_range(r, index: index) }
111
+ else
112
+ update_index_for_ids_in_range(r, index: index)
113
+ end
114
+ end
115
+ end
116
+
117
+ def update_index_for_ids_from(from, to:, index: nil, type: nil, batch_size: DEFAULT_BATCH_SIZE)
118
+ record_id = arel_table[:id]
119
+
120
+ conditions = record_id.gteq(from).and(record_id.lteq(to))
121
+
122
+ update_index_in_batches(batch_size: batch_size, index: index, type: type, conditions: conditions)
123
+ end
124
+
125
+ def update_index_for_ids_in_range(range, index: nil, type: nil, batch_size: DEFAULT_BATCH_SIZE)
126
+ update_index_for_ids_from(range.first, to: range.last, type: type, index: index, batch_size: batch_size)
127
+ end
128
+
129
+ def update_index_in_batches(batch_size: DEFAULT_BATCH_SIZE, conditions:nil, index: nil, type: nil)
130
+ find_in_batches(batch_size: batch_size, conditions: conditions) do |records|
131
+ klass.update_index_in_batch(records, index: index, type: type)
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,18 @@
1
+ require_relative 'configuration'
2
+
3
+ module Elasticsearch
4
+ module Model
5
+ module Extensions
6
+ class Callback
7
+ # @param [Configuration] config
8
+ def initialize(config)
9
+ @config = config
10
+ end
11
+
12
+ def config
13
+ @config
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,72 @@
1
+ module Elasticsearch
2
+ module Model
3
+ module Extensions
4
+ class Configuration
5
+ attr_reader :delayed
6
+
7
+ def initialize(active_record_class, parent_class: parent_class, delayed:, if: -> r { true }, records_to_update_documents: nil)
8
+ @delayed = @delayed
9
+
10
+ @active_record_class = active_record_class
11
+ @parent_class = parent_class
12
+ @if = binding.local_variable_get(:if)
13
+ @records_to_update_documents = records_to_update_documents
14
+ end
15
+
16
+ def to_hash
17
+ @cached_hash ||= build_hash
18
+ end
19
+
20
+ def field_to_update
21
+ to_hash[:field_to_update]
22
+ end
23
+
24
+ def only_if
25
+ to_hash[:only_if]
26
+ end
27
+
28
+ def records_to_update_documents
29
+ to_hash[:records_to_update_documents]
30
+ end
31
+
32
+ def optionally_delayed
33
+ -> t { delayed ? t.delay : t }
34
+ end
35
+
36
+ private
37
+
38
+ def build_hash
39
+ child_class = @active_record_class
40
+
41
+ path = child_class.path_from(@parent_class)
42
+ parent_to_child_path = path.map(&:name)
43
+
44
+ # a has_a b has_a cという関係のとき、cが更新されたらaのフィールドbをupdateする必要がある。
45
+ # そのとき、
46
+ # 親aから子cへのパスが[:b, :c]だったら、bだけをupdateすればよいので
47
+ field_to_update = parent_to_child_path.first
48
+
49
+ puts "#{child_class.name} updates #{@parent_class.name}'s #{field_to_update}"
50
+
51
+ # TODO 勝手にインスタンスの状態を書き換えていて、相当いまいち。インスタンス外に出す。
52
+ child_class.instance_variable_set :@nested_object_fields, @parent_class.nested_object_fields_for(parent_to_child_path).map(&:to_s)
53
+ child_class.instance_variable_set :@has_dependent_fields, @parent_class.has_dependent_fields?(field_to_update) ||
54
+ (path.first.destination.through_class == child_class && @parent_class.has_association_named?(field_to_update) && @parent_class.has_document_field_named?(field_to_update))
55
+
56
+ custom_if = @if
57
+
58
+ update_strategy_class = Elasticsearch::Model::Extensions::OuterDocumentUpdating.strategy_for child_class
59
+ update_strategy = update_strategy_class.new(from: @parent_class, to: child_class)
60
+
61
+ only_if, records_to_update_documents = update_strategy.apply
62
+
63
+ {
64
+ field_to_update: field_to_update,
65
+ records_to_update_documents: @records_to_update_documents || records_to_update_documents,
66
+ only_if: -> r { custom_if.call(r) && only_if.call(r) }
67
+ }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,45 @@
1
+ module Elasticsearch
2
+ module Model
3
+ module Extensions
4
+ module DependencyTracking
5
+ def self.included(base)
6
+ base.extend ClassMethods
7
+
8
+ base.class_eval do
9
+ before_validation do
10
+ self.class.each_dependent_attribute_for(changes) do |a|
11
+ attribute_will_change! a
12
+ end
13
+ end
14
+ end
15
+ end
16
+
17
+ module ClassMethods
18
+ def tracks_attributes_dependencies(dependencies)
19
+ const_set 'DEPENDENT_CUSTOM_ATTRIBUTES', dependencies.dup.freeze
20
+ end
21
+
22
+ def each_dependent_attribute_for(changed_attributes)
23
+ const_get('DEPENDENT_CUSTOM_ATTRIBUTES').each do |attributes, dependent_attributes|
24
+ dependent_attributes.each do |dependent_attribute|
25
+ attributes.each do |a|
26
+ yield dependent_attribute if changed_attributes.include? a
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ def has_dependent_fields?(field)
33
+ const_get('DEPENDENT_CUSTOM_ATTRIBUTES').any? do |from, to|
34
+ from.include? field.to_s
35
+ end
36
+ end
37
+
38
+ def has_association_named?(table_name)
39
+ reflect_on_all_associations.any? { |a| a.name == table_name }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,30 @@
1
+ require_relative 'callback'
2
+
3
+ module Elasticsearch
4
+ module Model
5
+ module Extensions
6
+ class DestroyCallback < Callback
7
+ def after_commit(record)
8
+ field_to_update = config.field_to_update
9
+ records_to_update_documents = config.records_to_update_documents
10
+ optionally_delayed = config.optionally_delayed
11
+ only_if = config.only_if
12
+
13
+ record.instance_eval do
14
+ return unless only_if.call(self)
15
+
16
+ target = records_to_update_documents.call(self)
17
+
18
+ if target.respond_to? :each
19
+ target.map(&:reload).map(&optionally_delayed).each do |t|
20
+ t.partially_update_document(field_to_update)
21
+ end
22
+ else
23
+ optionally_delayed.call(target.reload).partially_update_document(field_to_update)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,86 @@
1
+ module Elasticsearch
2
+ module Model
3
+ module Extensions
4
+ module IndexOperations
5
+ def self.included(klass)
6
+ klass.extend ClassMethods
7
+ end
8
+
9
+ module ClassMethods
10
+ def create_index(name:, force:true)
11
+ klass = self
12
+
13
+ client = __elasticsearch__.client
14
+
15
+ indices = client.indices
16
+
17
+ if indices.exists(index: name) && force
18
+ indices.delete index: name
19
+ end
20
+
21
+ indices.create index: name, body: { settings: klass.settings.to_hash, mappings: klass.mappings.to_hash }
22
+ end
23
+
24
+ def delete_index(name:)
25
+ client = __elasticsearch__.client
26
+
27
+ indices = client.indices
28
+
29
+ indices.delete index: name
30
+ end
31
+
32
+ def delete_alias(name: nil)
33
+ name ||= index_name
34
+
35
+ client = __elasticsearch__.client
36
+
37
+ indices = client.indices
38
+
39
+ indices_aliased = indices.get_alias(name: name).keys
40
+
41
+ indices_aliased.each do |index|
42
+ indices.delete name: name, index: index
43
+ end
44
+ end
45
+
46
+ def prepare_alias(name:, force: true)
47
+ client = __elasticsearch__.client
48
+
49
+ indices = client.indices
50
+
51
+ if indices.exists(index: name) && force
52
+ indices.delete index: name
53
+ end
54
+
55
+ unless indices.exists_alias(name: name)
56
+ aliased_index_name = "#{index_name}_#{Time.now.to_i}"
57
+
58
+ create_index(name: aliased_index_name, force: force)
59
+
60
+ indices.put_alias index: aliased_index_name, name: name
61
+ end
62
+ end
63
+
64
+ def replace_index_for_alias(name:, to:)
65
+ client = __elasticsearch__.client
66
+
67
+ indices = client.indices
68
+
69
+ if indices.exists_alias name: name
70
+ old_index_name = indices.get_alias(name: name).keys.first
71
+
72
+ indices.update_aliases body: {
73
+ actions: [
74
+ { remove: { index: old_index_name, alias: name } },
75
+ { add: { index: to, alias: name } }
76
+ ]
77
+ }
78
+ else
79
+ indices.put_alias index: to, name: name
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,65 @@
1
+ require 'shortest_path'
2
+
3
+ module Elasticsearch
4
+ module Model
5
+ module Extensions
6
+ class MappingNode < ShortestPath::Node
7
+ def self.from_class(klass)
8
+ name = klass.document_type.intern
9
+
10
+ new(klass: klass, name: name, mapping: klass.mapping.to_hash[name])
11
+ end
12
+
13
+ def initialize(klass:, name:, mapping:, through_class:nil)
14
+ @klass = klass
15
+ @name = name
16
+ @mapping = mapping
17
+ @through_class = through_class
18
+ end
19
+
20
+ def name
21
+ @name
22
+ end
23
+
24
+ def relates_to_class?(klass)
25
+ @klass == klass || @through_class == klass
26
+ end
27
+
28
+ def klass
29
+ @klass
30
+ end
31
+
32
+ def through_class
33
+ @through_class
34
+ end
35
+
36
+ def each(&block)
37
+ associations = @klass.reflect_on_all_associations
38
+
39
+ props = @mapping[:properties]
40
+ fields = props.keys
41
+
42
+ edges = fields.map { |f|
43
+ a = associations.find { |a| a.name == f }
44
+
45
+ if a && a.options[:polymorphic] != true
46
+ through_class = if a.options[:through]
47
+ a.options[:through].to_s.classify.constantize
48
+ end
49
+
50
+ dest = MappingNode.new(klass: a.class_name.constantize, name: f.to_s.pluralize.intern, mapping: props[f], through_class: through_class)
51
+
52
+ edge_class.new(name: f, destination: dest)
53
+ end
54
+ }.reject(&:nil?)
55
+
56
+ if block.nil?
57
+ edges
58
+ else
59
+ edges.each(&block)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,95 @@
1
+ module Elasticsearch
2
+ module Model
3
+ module Extensions
4
+ module MappingReflection
5
+ def self.included(base)
6
+ base.extend ClassMethods
7
+ end
8
+
9
+ module ClassMethods
10
+ # @param [Class] destination_class
11
+ def path_in_mapping_to_class(destination_class, current_properties: nil, current_class: nil, visited_classes: nil)
12
+ current_properties ||= mappings.to_hash[:"#{self.document_type}"][:properties]
13
+ visited_classes ||= []
14
+ current_class ||= self
15
+
16
+ # Recurse only on associations
17
+ current_properties.keys.each do |key|
18
+ association_found = current_class.reflect_on_all_associations.find { |a| a.name == key }
19
+
20
+ next unless association_found
21
+ next if visited_classes.include? association_found.klass
22
+
23
+ if association_found.klass == destination_class
24
+ return [key]
25
+ else
26
+ suffix_found = path_in_mapping_to_class(
27
+ destination_class,
28
+ current_properties: current_properties[key][:properties],
29
+ current_class: association_found.klass,
30
+ visited_classes: visited_classes.dup.append(association_found.klass)
31
+ )
32
+
33
+ if suffix_found
34
+ return [key] + suffix_found
35
+ end
36
+ end
37
+ end
38
+
39
+ nil
40
+ end
41
+
42
+ # @param [Symbol] nested_object_name
43
+ # @return [Array<Symbol>]
44
+ def path_in_mapping_to(nested_object_name, root_properties: nil)
45
+ root_properties ||= mappings.to_hash[:"#{self.document_type}"][:properties]
46
+
47
+ keys = root_properties.keys
48
+
49
+ keys.each do |key|
50
+ if key == nested_object_name
51
+ return [key]
52
+ end
53
+
54
+ next if root_properties[key][:type] != 'object'
55
+
56
+ suffix = path_in_mapping_to(nested_object_name, root_properties: root_properties[key][:properties])
57
+
58
+ if suffix.include? nested_object_name
59
+ return [key] + suffix
60
+ end
61
+ end
62
+ []
63
+ end
64
+
65
+ def document_field_named(field_name)
66
+ root_properties ||= mappings.to_hash[:"#{self.document_type}"][:properties]
67
+ root_properties[field_name]
68
+ end
69
+
70
+ def has_document_field_named?(field_name)
71
+ !! document_field_named(field_name)
72
+ end
73
+
74
+ # @param [Array<Symbol>] path
75
+ def nested_object_fields_for(path, root_properties: nil)
76
+ root_properties ||= mappings.to_hash[:"#{self.document_type}"][:properties]
77
+
78
+ keys = root_properties.keys
79
+
80
+ suffix, *postfix = path
81
+
82
+ return root_properties.keys if suffix.nil?
83
+
84
+ keys.each do |key|
85
+ if key == suffix
86
+ result = nested_object_fields_for(postfix, root_properties: root_properties[key][:properties])
87
+ return result if result
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,175 @@
1
+ require_relative 'mapping_node'
2
+ require_relative 'update_callback'
3
+ require_relative 'destroy_callback'
4
+
5
+ module Elasticsearch
6
+ module Model
7
+ module Extensions
8
+ module OuterDocumentUpdating
9
+ def self.included(klass)
10
+ klass.extend ClassMethods
11
+ end
12
+
13
+ def index_update_required?
14
+ (previous_changes.keys & self.class.nested_object_fields).size > 0 ||
15
+ (previous_changes.size > 0 && self.class.has_dependent_fields?)
16
+ end
17
+
18
+ class Update
19
+ def initialize(from:, to:)
20
+ @parent_class = from
21
+ @child_class = to
22
+ end
23
+
24
+ class Default < Update
25
+ def self.applicable_to?(klass)
26
+ true
27
+ end
28
+
29
+ def apply
30
+ parent_class = @parent_class
31
+ child_class = @child_class
32
+
33
+ only_if = -> r { true }
34
+
35
+ puts "Parent: #{@parent_class.name}"
36
+ puts "Child: #{@child_class.name}"
37
+
38
+ # 子cから親aへのパスが[:b, :a]のようなパスだったら、c.b.aのようにaを辿れるはずなので
39
+ records_to_update_documents = begin
40
+ child_to_parent_path = Elasticsearch::Model::Extensions::OuterDocumentUpdating::ClassMethods::AssociationTraversal.shortest_path(from: child_class, to: parent_class)
41
+
42
+ -> updated_record { child_to_parent_path.inject(updated_record) { |d, parent_association| d.send parent_association } }
43
+ end
44
+
45
+ [only_if, records_to_update_documents]
46
+ end
47
+ end
48
+
49
+ # Configures callbacks to update the index of the model associated through a polymorphic association
50
+ # @see http://guides.rubyonrails.org/association_basics.html#polymorphic-associations
51
+ class ThroughPolymorphicAssociation < Update
52
+ def self.applicable_to?(klass)
53
+ !! polymorphic_assoc_for(klass)
54
+ end
55
+
56
+ def self.polymorphic_assoc_for(klass)
57
+ klass.reflect_on_all_associations.find { |a|
58
+ a.macro == :belongs_to && a.options[:polymorphic]
59
+ }
60
+ end
61
+
62
+ def apply
63
+ parent_class = @parent_class
64
+ child_class = @child_class
65
+
66
+ polymorphic_assoc = self.class.polymorphic_assoc_for(child_class)
67
+ polymorphic_assoc_name = polymorphic_assoc.name
68
+
69
+ parent_type_attribute_name = :"#{polymorphic_assoc_name}_type"
70
+ parent_id_attribute_name = :"#{polymorphic_assoc_name}_id"
71
+
72
+ only_if = -> updated_record {
73
+ updated_record.send(parent_type_attribute_name) == parent_class.name
74
+ }
75
+
76
+ records_to_update_documents = -> updated_record {
77
+ parent_class.where(id: updated_record.send(parent_id_attribute_name))
78
+ }
79
+
80
+ [only_if, records_to_update_documents]
81
+ end
82
+ end
83
+ end
84
+
85
+ STRATEGIES = [Update::ThroughPolymorphicAssociation, Update::Default]
86
+
87
+ def self.strategy_for(klass)
88
+ STRATEGIES.find { |s| s.applicable_to? klass }
89
+ end
90
+
91
+ module ClassMethods
92
+ def nested_object_fields
93
+ @nested_object_fields
94
+ end
95
+
96
+ def has_dependent_fields?
97
+ @has_dependent_fields
98
+ end
99
+
100
+ def path_from(from)
101
+ Elasticsearch::Model::Extensions::MappingNode.
102
+ from_class(from).
103
+ breadth_first_search { |e| e.destination.relates_to_class?(self) }.
104
+ first
105
+ end
106
+
107
+ def initialize_active_record!(active_record_class, parent_class: parent_class, delayed:, if: -> r { true }, records_to_update_documents: nil)
108
+ config = Elasticsearch::Model::Extensions::Configuration.new(active_record_class, parent_class: parent_class, delayed: delayed, if: binding.local_variable_get(:if), records_to_update_documents: records_to_update_documents)
109
+
110
+ active_record_class.after_commit Elasticsearch::Model::Extensions::UpdateCallback.new(config)
111
+ active_record_class.after_commit Elasticsearch::Model::Extensions::DestroyCallback.new(config), on: :destroy
112
+ end
113
+
114
+ def partially_updates_document_of(parent_class, delayed:, if: -> r { true }, records_to_update_documents: nil)
115
+ initialize_active_record!(
116
+ self,
117
+ parent_class: parent_class,
118
+ delayed: delayed,
119
+ if: binding.local_variable_get(:if),
120
+ records_to_update_documents: records_to_update_documents
121
+ )
122
+ end
123
+
124
+ module AssociationTraversal
125
+ class << self
126
+ def shortest_path(from:, to:, visited_classes: nil)
127
+ visited_classes ||= []
128
+ current_class = from
129
+ destination_class = to
130
+
131
+ paths = []
132
+
133
+ current_class.reflect_on_all_associations.each do |association_found|
134
+
135
+ next if association_found.options[:polymorphic]
136
+
137
+ key = association_found.name
138
+
139
+ begin
140
+ klass = association_found.class_name.constantize
141
+ rescue => e
142
+ warn "#{e.message} while reflecting #{current_class.name}\##{key}\n#{e.backtrace[0...1].join("\n")}"
143
+ next
144
+ end
145
+
146
+ next if visited_classes.include? association_found.class_name
147
+
148
+ if klass == destination_class
149
+ return [key]
150
+ else
151
+ suffix_found = shortest_path(
152
+ from: klass,
153
+ to: destination_class,
154
+ visited_classes: visited_classes.append(association_found.class_name)
155
+ )
156
+
157
+ if suffix_found
158
+ paths << [key] + suffix_found
159
+ end
160
+ end
161
+ end
162
+
163
+ if paths.empty?
164
+ nil
165
+ else
166
+ paths.min_by { |path| path.size }
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,148 @@
1
+ module Elasticsearch
2
+ module Model
3
+ module Extensions
4
+ module PartialUpdating
5
+ def self.included(klass)
6
+ klass.extend ClassMethods
7
+ end
8
+
9
+ def self.build_as_json_options(klass:, props: )
10
+ indexed_attributes = props.keys
11
+ associations = klass.reflect_on_all_associations.select { |a| %i| has_one has_many belongs_to |.include? a.macro }
12
+ association_names = associations.map(&:name)
13
+ persisted_attributes = klass.attribute_names.map(&:intern)
14
+
15
+ nested_attributes = indexed_attributes & association_names
16
+ method_attributes = indexed_attributes - persisted_attributes - nested_attributes
17
+ only_attributes = indexed_attributes - nested_attributes
18
+
19
+ options = {}
20
+
21
+ if only_attributes.size > 1
22
+ options[:only] = only_attributes
23
+ elsif only_attributes.size == 1
24
+ options[:only] = only_attributes.first
25
+ end
26
+
27
+ if method_attributes.size > 1
28
+ options[:methods] = method_attributes
29
+ elsif method_attributes.size == 1
30
+ options[:methods] = method_attributes.first
31
+ end
32
+
33
+ nested_attributes.each do |n|
34
+ a = associations.find { |a| a.name == n.intern }
35
+ nested_klass = a.class_name.constantize
36
+ nested_prop = props[n]
37
+ if nested_prop.present?
38
+ options[:include] ||= {}
39
+ options[:include][n] = build_as_json_options(
40
+ klass: nested_klass,
41
+ props: nested_prop[:properties]
42
+ )
43
+ end
44
+ end
45
+
46
+ options
47
+ end
48
+
49
+ def as_indexed_json(options={})
50
+ as_json(options.merge(self.class.as_json_options))
51
+ end
52
+
53
+ # @param [Array<Symbol>] changed_attributes
54
+ # @param [Proc<Symbol, Hash>] json_options
55
+ def build_partial_document_for_update(*changed_attributes, json_options: nil)
56
+ changes = {}
57
+
58
+ json_options ||= -> field { self.class.partial_as_json_options(field) || {} }
59
+
60
+ self.class.each_field_to_update_according_to_changed_fields(changed_attributes) do |field_to_update|
61
+ options = json_options.call field_to_update
62
+
63
+ json = __send__(:"#{field_to_update}").as_json(options)
64
+
65
+ changes[field_to_update] = json
66
+ end
67
+
68
+ changes
69
+ end
70
+
71
+ def partially_update_document(*changed_attributes)
72
+ if changed_attributes.empty?
73
+ __elasticsearch__.index_document
74
+ else
75
+ begin
76
+ partial_document = build_partial_document_for_update(*changed_attributes)
77
+ rescue => e
78
+ Rails.logger.error "Error in #partially_update_document: #{e.message}\n#{e.backtrace.join("\n")}"
79
+ end
80
+
81
+ update_document(partial_document)
82
+ end
83
+ end
84
+
85
+ # @param [Hash] partial_document
86
+ def update_document(partial_document)
87
+ klass = self.class
88
+
89
+ __elasticsearch__.client.update(
90
+ { index: klass.index_name,
91
+ type: klass.document_type,
92
+ id: self.id,
93
+ body: { doc: partial_document } }
94
+ ) if partial_document.size > 0
95
+ end
96
+
97
+ module ClassMethods
98
+ def as_json_options
99
+ @as_json_options ||= Elasticsearch::Model::Extensions::PartialUpdating.build_as_json_options(
100
+ klass: self,
101
+ props: self.mappings.to_hash[self.document_type.intern][:properties]
102
+ )
103
+ end
104
+
105
+ def partial_as_json_options(field)
106
+ as_json_options[:include][field]
107
+ end
108
+
109
+ def each_field_to_update_according_to_changed_fields(changed_fields)
110
+ root_mapping_properties = mappings.to_hash[:"#{document_type}"][:properties]
111
+
112
+ changed_fields.each do |changed_field|
113
+ field_mapping = root_mapping_properties[:"#{changed_field}"]
114
+
115
+ next unless field_mapping
116
+
117
+ yield changed_field
118
+ end
119
+
120
+ each_dependent_attribute_for(changed_fields.map(&:to_s)) do |a|
121
+ a_sym = a.intern
122
+
123
+ yield a_sym
124
+ end
125
+ end
126
+
127
+ def fields_to_update_according_to_changed_fields(changed_fields)
128
+ fields = []
129
+ each_field_to_update_according_to_changed_fields changed_fields do |field_to_update|
130
+ fields << field_to_update
131
+ end
132
+ fields
133
+ end
134
+ end
135
+
136
+ module Callbacks
137
+ def self.included(base)
138
+ base.class_eval do
139
+ after_commit lambda { __elasticsearch__.index_document }, on: :create
140
+ after_commit lambda { partially_update_document(*previous_changes.keys.map(&:intern)) }, on: :update, if: -> { previous_changes.size != 0 }
141
+ after_commit lambda { __elasticsearch__.delete_document }, on: :destroy
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,43 @@
1
+ require_relative 'callback'
2
+
3
+ module Elasticsearch
4
+ module Model
5
+ module Extensions
6
+ class UpdateCallback < Callback
7
+ def after_commit(record)
8
+ field_to_update = config.field_to_update
9
+ records_to_update_documents = config.records_to_update_documents
10
+ optionally_delayed = config.optionally_delayed
11
+ only_if = config.only_if
12
+
13
+ record.instance_eval do
14
+ return unless only_if.call(self) && index_update_required?
15
+
16
+ target = records_to_update_documents.call(self)
17
+
18
+ if target.respond_to? :each
19
+ # `reload` required to ensure that the outer record is up-to-date with changes
20
+ # when `self` is an instance of a `through` model.
21
+ #
22
+ # Imagine the case where we have an association containing:
23
+ #
24
+ # `article has_many comments through article_comments`
25
+ #
26
+ # and:
27
+ #
28
+ # `article_comments belongs_to article`
29
+ #
30
+ # Here, `article_comment.article` may contain outdated `comments` because `article_comment.article`
31
+ # won't be notified with changes in `article_comments` thus won't reload `comments` automatically.
32
+ target.map(&:reload).map(&optionally_delayed).each do |t|
33
+ t.partially_update_document(field_to_update)
34
+ end
35
+ else
36
+ optionally_delayed.call(target.reload).partially_update_document(field_to_update)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,7 @@
1
+ module Elasticsearch
2
+ module Model
3
+ module Extensions
4
+ VERSION = "0.0.1"
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ require "elasticsearch/model/extensions/version"
2
+
3
+ module Elasticsearch
4
+ module Model
5
+ module Extensions
6
+ # Your code goes here...
7
+ end
8
+ end
9
+ end
metadata ADDED
@@ -0,0 +1,94 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: elasticsearch-model-extensions
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Yusuke KUOKA
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-10-14 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
+ description: A set of extensions for elasticsearch-model which aims to ease the burden
42
+ of things like re-indexing, verbose/complex mapping.
43
+ email:
44
+ - yusuke.kuoka@crowdworks.co.jp
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - ".gitignore"
50
+ - Gemfile
51
+ - LICENSE.txt
52
+ - README.md
53
+ - Rakefile
54
+ - elasticsearch-model-extensions.gemspec
55
+ - lib/elasticsearch/model/extensions.rb
56
+ - lib/elasticsearch/model/extensions/batch_updating.rb
57
+ - lib/elasticsearch/model/extensions/callback.rb
58
+ - lib/elasticsearch/model/extensions/configuration.rb
59
+ - lib/elasticsearch/model/extensions/dependency_tracking.rb
60
+ - lib/elasticsearch/model/extensions/destroy_callback.rb
61
+ - lib/elasticsearch/model/extensions/index_operations.rb
62
+ - lib/elasticsearch/model/extensions/mapping_node.rb
63
+ - lib/elasticsearch/model/extensions/mapping_reflection.rb
64
+ - lib/elasticsearch/model/extensions/outer_document_updating.rb
65
+ - lib/elasticsearch/model/extensions/partial_updating.rb
66
+ - lib/elasticsearch/model/extensions/update_callback.rb
67
+ - lib/elasticsearch/model/extensions/version.rb
68
+ homepage: https://github.com/crowdworks/elasticsearch-model-extensions
69
+ licenses:
70
+ - MIT
71
+ metadata: {}
72
+ post_install_message:
73
+ rdoc_options: []
74
+ require_paths:
75
+ - lib
76
+ required_ruby_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ requirements: []
87
+ rubyforge_project:
88
+ rubygems_version: 2.2.2
89
+ signing_key:
90
+ specification_version: 4
91
+ summary: A set of extensions for elasticsearch-model which aims to ease the burden
92
+ of things like re-indexing, verbose/complex mapping that you may face once you started
93
+ using elasticsearch seriously.
94
+ test_files: []