elasticsearch-model-extensions 0.0.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: 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: []