Skip to content

Commit 10ff43b

Browse files
authored
Replace parent-child with a join field. (#760)
Implement hierarchical structures with Elastic join field, in place of an obsolete (and removed) parent-child relationships.
1 parent 7d56754 commit 10ff43b

File tree

16 files changed

+746
-83
lines changed

16 files changed

+746
-83
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
### New Features
66

7+
* [#760](https://github.com/toptal/chewy/pull/760): Replace parent-child mapping with a [join field](https://www.elastic.co/guide/en/elasticsearch/reference/current/removal-of-types.html#parent-child-mapping-types) ([@mrzasa][])
8+
79
### Changes
810

911
### Bugs Fixed
@@ -723,4 +725,3 @@
723725
[@Vitalina-Vakulchyk]: https://github.com/Vitalina-Vakulchyk
724726
[@webgago]: https://github.com/webgago
725727
[@yahooguntu]: https://github.com/yahooguntu
726-

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,23 @@ end
446446

447447
See the section on *Script fields* for details on calculating distance in a search.
448448

449+
### Join fields
450+
451+
You can use a [join field](https://www.elastic.co/guide/en/elasticsearch/reference/current/parent-join.html)
452+
to implement parent-child relationships between documents.
453+
It [replaces the old `parent_id` based parent-child mapping](https://www.elastic.co/guide/en/elasticsearch/reference/current/removal-of-types.html#parent-child-mapping-types)
454+
455+
To use it, you need to pass `relations` and `join` (with `type` and `id`) options:
456+
```ruby
457+
field :hierarchy_link, type: :join, relations: {question: %i[answer comment], answer: :vote, vote: :subvote}, join: {type: :comment_type, id: :commented_id}
458+
```
459+
assuming you have `comment_type` and `commented_id` fields in your model.
460+
461+
Note that when you reindex a parent, it's children and grandchildren will be reindexed as well.
462+
This may require additional queries to the primary database and to elastisearch.
463+
464+
Also note that the join field doesn't support crutches (it should be a field directly defined on the model).
465+
449466
### Crutches™ technology
450467

451468
Assume you are defining your index like this (product has_many categories through product_categories):

lib/chewy/errors.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,10 @@ def initialize(type, import_errors)
3030
super message
3131
end
3232
end
33+
34+
class InvalidJoinFieldType < Error
35+
def initialize(join_field_type, join_field_name, relations)
36+
super("`#{join_field_type}` set for the join field `#{join_field_name}` is not on the :relations list (#{relations})")
37+
end
38+
end
3339
end

lib/chewy/fields/base.rb

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
module Chewy
22
module Fields
33
class Base
4-
attr_reader :name, :options, :value, :children
5-
attr_accessor :parent
4+
attr_reader :name, :join_options, :options, :children
5+
attr_accessor :parent # used by Chewy::Index::Mapping to expand nested fields
66

77
def initialize(name, value: nil, **options)
88
@name = name.to_sym
99
@options = {}
1010
update_options!(**options)
1111
@value = value
1212
@children = []
13+
@allowed_relations = find_allowed_relations(options[:relations]) # for join fields
1314
end
1415

1516
def update_options!(**options)
17+
@join_options = options.delete(:join) || {}
1618
@options = options
1719
end
1820

@@ -53,30 +55,70 @@ def compose(*objects)
5355
{name => result}
5456
end
5557

58+
def value
59+
if join_field?
60+
join_type = join_options[:type]
61+
join_id = join_options[:id]
62+
# memoize
63+
@value ||= proc do |object|
64+
validate_join_type!(value_by_name_proc(join_type).call(object))
65+
# If it's a join field and it has join_id, the value is compound and contains
66+
# both name (type) and id of the parent object
67+
if value_by_name_proc(join_id).call(object).present?
68+
{
69+
name: value_by_name_proc(join_type).call(object), # parent type
70+
parent: value_by_name_proc(join_id).call(object) # parent id
71+
}
72+
else
73+
value_by_name_proc(join_type).call(object)
74+
end
75+
end
76+
else
77+
@value
78+
end
79+
end
80+
5681
private
5782

5883
def geo_point?
5984
@options[:type].to_s == 'geo_point'
6085
end
6186

87+
def join_field?
88+
@options[:type].to_s == 'join'
89+
end
90+
6291
def ignore_blank?
6392
@options.fetch(:ignore_blank) { geo_point? }
6493
end
6594

6695
def evaluate(objects)
67-
object = objects.first
68-
6996
if value.is_a?(Proc)
70-
if value.arity.zero?
71-
object.instance_exec(&value)
72-
elsif value.arity.negative?
73-
value.call(*object)
74-
else
75-
value.call(*objects.first(value.arity))
76-
end
97+
value_by_proc(objects, value)
7798
else
78-
message = value.is_a?(Symbol) || value.is_a?(String) ? value.to_sym : name
99+
value_by_name(objects, value)
100+
end
101+
end
79102

103+
def value_by_proc(objects, value)
104+
object = objects.first
105+
if value.arity.zero?
106+
object.instance_exec(&value)
107+
elsif value.arity.negative?
108+
value.call(*object)
109+
else
110+
value.call(*objects.first(value.arity))
111+
end
112+
end
113+
114+
def value_by_name(objects, value)
115+
object = objects.first
116+
message = value.is_a?(Symbol) || value.is_a?(String) ? value.to_sym : name
117+
value_by_name_proc(message).call(object)
118+
end
119+
120+
def value_by_name_proc(message)
121+
proc do |object|
80122
if object.is_a?(Hash)
81123
if object.key?(message)
82124
object[message]
@@ -89,6 +131,20 @@ def evaluate(objects)
89131
end
90132
end
91133

134+
def validate_join_type!(type)
135+
return unless type
136+
return if @allowed_relations.include?(type.to_sym)
137+
138+
raise Chewy::InvalidJoinFieldType.new(type, @name, options[:relations])
139+
end
140+
141+
def find_allowed_relations(relations)
142+
return [] unless relations
143+
return relations unless relations.is_a?(Hash)
144+
145+
(relations.keys + relations.values).flatten.uniq
146+
end
147+
92148
def compose_children(value, *parent_objects)
93149
return unless value
94150

lib/chewy/fields/root.rb

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
module Chewy
22
module Fields
33
class Root < Chewy::Fields::Base
4-
attr_reader :dynamic_templates, :id, :parent, :parent_id
4+
attr_reader :dynamic_templates, :id
55

66
def initialize(name, **options)
77
super(name, **options)
@@ -12,9 +12,7 @@ def initialize(name, **options)
1212

1313
def update_options!(**options)
1414
@id = options.fetch(:id, options.fetch(:_id, @id))
15-
@parent = options.fetch(:parent, options.fetch(:_parent, @parent))
16-
@parent_id = options.fetch(:parent_id, @parent_id)
17-
@options.merge!(options.except(:id, :_id, :parent, :_parent, :parent_id, :type))
15+
@options.merge!(options.except(:id, :_id, :type))
1816
end
1917

2018
def mappings_hash
@@ -50,12 +48,6 @@ def dynamic_template(*args)
5048
end
5149
end
5250

53-
def compose_parent(object)
54-
return unless parent_id
55-
56-
parent_id.arity.zero? ? object.instance_exec(&parent_id) : parent_id.call(object)
57-
end
58-
5951
def compose_id(object)
6052
return unless id
6153

lib/chewy/index/adapter/active_record.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ def raw_default_scope_where_ids_in(ids, converter)
9494
object_class.connection.execute(sql).map(&converter)
9595
end
9696

97+
def raw(scope, converter)
98+
sql = scope.to_sql
99+
object_class.connection.execute(sql).map(&converter)
100+
end
101+
97102
def relation_class
98103
::ActiveRecord::Relation
99104
end

lib/chewy/index/adapter/orm.rb

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,13 @@ def load(ids, **options)
101101
additional_scope = options[options[:_index].to_sym].try(:[], :scope) || options[:scope]
102102

103103
loaded_objects = load_scope_objects(scope, additional_scope)
104-
.index_by do |object|
105-
object.public_send(primary_key).to_s
106-
end
104+
loaded_objects = raw(loaded_objects, options[:raw_import]) if options[:raw_import]
105+
106+
indexed_objects = loaded_objects.index_by do |object|
107+
object.public_send(primary_key).to_s
108+
end
107109

108-
ids.map { |id| loaded_objects[id.to_s] }
110+
ids.map { |id| indexed_objects[id.to_s] }
109111
end
110112

111113
private

lib/chewy/index/import.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,7 @@ module ClassMethods
3636
# passed objects from the index if they are not in the default scope
3737
# or marked for destruction.
3838
#
39-
# It handles parent-child relationships: if the object parent_id has been
40-
# changed it destroys the object and recreates it from scratch.
39+
# It handles parent-child relationships with a join field reindexing children when the parent is reindexed.
4140
#
4241
# Performs journaling if enabled: it stores all the ids of the imported
4342
# objects to a specialized index. It is possible to replay particular import

0 commit comments

Comments
 (0)