Skip to content

Commit 6814370

Browse files
Add parent_relationship API and RelationshipChainResolver with validation (#1238)
## Summary ElasticGraph's `sourced_from` feature allows fields on an indexed type to be populated from events published by a different source type. Today, this only works for top-level indexed types. This PR is an incremental step toward supporting `sourced_from` on embedded (non-indexed) types — types that live nested inside a root document. To enable this, we introduce a `parent_relationship` API that lets users declare how an embedded type connects back up to the root indexed type. This PR adds the API and validates the chain of `parent_relationship` declarations — producing clear error messages for misconfigured schemas. Valid configurations are artifact no-ops for now; later PRs will use the validated chain to produce runtime metadata that tells the indexer how to navigate into nested documents and update the correct fields. ## Example API Usage ```ruby ElasticGraph.define_schema do |schema| schema.object_type "Team" do |t| t.field "id", "ID!" t.field "name", "String" t.field "players", "[Player!]!" do |f| f.mapping type: "nested" end t.relates_to_many "statLines", "StatLine", via: "teamId", dir: :in, indexing_only: true t.index "teams" do |i| i.has_had_multiple_sources! end end schema.object_type "Player" do |t| t.field "id", "ID!" t.field "name", "String" t.field "goalsScored", "Int" do |f| f.sourced_from "statLine", "goals" end t.relates_to_one "statLine", "StatLine", via: "playerId", dir: :in, indexing_only: true do |r| r.parent_relationship "Team", "statLines" # <--- NEW! end end schema.object_type "StatLine" do |t| t.field "id", "ID!" t.field "teamId", "ID" t.field "playerId", "ID" t.field "goals", "Int" t.index "stat_lines" end end ``` ## Validations - Relationship must be `indexing_only: true` - No circular parent chains - Parent type must exist - Parent relationship must exist on parent type - Source types must agree across the chain - Chain must terminate at an indexed type - Embedding field must exist on parent type (auto-discovered or explicit via `parent_field_name:`) - No ambiguous embedding (multiple fields of the same type without `parent_field_name:`)
1 parent 55bbdc5 commit 6814370

10 files changed

Lines changed: 696 additions & 8 deletions

File tree

elasticgraph-schema_definition/lib/elastic_graph/schema_definition/factory.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,13 +304,14 @@ def new_field_source(relationship_name:, field_path:)
304304
end
305305
@@field_source_new = prevent_non_factory_instantiation_of(SchemaElements::FieldSource)
306306

307-
def new_relationship(field, cardinality:, related_type:, foreign_key:, direction:)
307+
def new_relationship(field, cardinality:, related_type:, foreign_key:, direction:, indexing_only:)
308308
@@relationship_new.call(
309309
field,
310310
cardinality: cardinality,
311311
related_type: related_type,
312312
foreign_key: foreign_key,
313-
direction: direction
313+
direction: direction,
314+
indexing_only: indexing_only
314315
)
315316
end
316317
@@relationship_new = prevent_non_factory_instantiation_of(SchemaElements::Relationship)
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
# Copyright 2024 - 2026 Block, Inc.
2+
#
3+
# Use of this source code is governed by an MIT-style
4+
# license that can be found in the LICENSE file or at
5+
# https://opensource.org/licenses/MIT.
6+
#
7+
# frozen_string_literal: true
8+
9+
require "elastic_graph/errors"
10+
11+
module ElasticGraph
12+
module SchemaDefinition
13+
module Indexing
14+
# The result of resolving a relationship chain.
15+
#
16+
# @private
17+
ResolvedRelationshipChain = ::Data.define(
18+
:root_relationship, # Relationship - the root relationship (no parent_relationship)
19+
:path_segments # Array<PathSegment> - ordered root-to-leaf
20+
)
21+
22+
# Describes how to navigate from a parent type into a nested child element.
23+
# For list fields, `source_field_name` identifies which element to update: the element
24+
# whose `id` matches `event[source_field_name]`. We implicitly match on the `id` field
25+
# because ElasticGraph relationships always join on `id` via foreign keys; this could be
26+
# made configurable in the future to support non-`id` primary keys.
27+
# For non-list (object) fields, `source_field_name` is nil since there's no ambiguity.
28+
#
29+
# @private
30+
PathSegment = ::Data.define(
31+
:field, # Field - the field to navigate into at this level
32+
:source_field_name # String? - field name on the source event providing the match value (nil for object fields)
33+
)
34+
35+
# Resolves a chain of `parent_relationship` links from a leaf embedded type up to the
36+
# root indexed type. Produces a `ResolvedRelationshipChain` on success, or errors
37+
# describing what's invalid.
38+
#
39+
# @private
40+
class RelationshipChainResolver
41+
def initialize(schema_def_state:)
42+
@schema_def_state = schema_def_state
43+
44+
# Lazily groups each parent type's indexing fields by their fully-unwrapped field type name,
45+
# so `find_field_by_type` can look up candidate embedding fields without re-scanning per chain.
46+
@indexing_fields_by_field_type_name_by_parent_type = ::Hash.new do |hash, parent_type|
47+
hash[parent_type] = parent_type.indexing_fields_by_name_in_index.values.group_by do |field|
48+
field.type.fully_unwrapped.name
49+
end
50+
end
51+
end
52+
53+
# Resolves the chain starting from `starting_relationship` (which must have a `parent_ref`).
54+
#
55+
# Returns a tuple of [resolved_chain, errors].
56+
# If errors is non-empty, resolved_chain will be nil.
57+
def resolve(starting_relationship)
58+
errors = [] # : ::Array[::String]
59+
path_segments = [] # : ::Array[PathSegment]
60+
visited_relationships = Set[starting_relationship]
61+
62+
# resolve_chain returns the chain's root relationship (the one with no parent_ref), or nil
63+
# if it hit an error walking the chain (in which case the error is already recorded).
64+
root_relationship = resolve_chain(starting_relationship, path_segments, errors, visited_relationships)
65+
return [nil, errors] unless root_relationship
66+
67+
# A valid chain must terminate at a relationship defined on an indexed type.
68+
root_type = root_relationship.parent_type
69+
unless root_type.root_document_type?
70+
errors << "The `parent_relationship` chain from #{rel_description(starting_relationship)} " \
71+
"terminates at `#{root_type.name}`, but `#{root_type.name}` is not an indexed type. " \
72+
"The chain must terminate at an indexed type."
73+
return [nil, errors]
74+
end
75+
76+
resolved_chain = ResolvedRelationshipChain.new(
77+
root_relationship: root_relationship,
78+
path_segments: path_segments.reverse # reverse so root-to-leaf order
79+
)
80+
81+
[resolved_chain, errors]
82+
end
83+
84+
private
85+
86+
# Recursively walks from leaf to root, building path segments in reverse. Returns the root
87+
# relationship (the one with no parent_ref) on success, or nil if an error was encountered.
88+
def resolve_chain(current_rel, path_segments, errors, visited_relationships)
89+
parent_ref = current_rel.parent_ref
90+
return current_rel unless parent_ref
91+
92+
parent_rel = resolve_parent_ref(current_rel, parent_ref, errors, visited_relationships)
93+
return nil unless parent_rel
94+
95+
build_path_segment(current_rel, parent_rel.parent_type, path_segments, errors)
96+
return nil if errors.any?
97+
98+
visited_relationships.add(parent_rel)
99+
resolve_chain(parent_rel, path_segments, errors, visited_relationships)
100+
end
101+
102+
# Resolves a parent_ref into the concrete parent relationship.
103+
# Returns the parent relationship on success, or appends to errors and returns nil.
104+
def resolve_parent_ref(current_rel, ref, errors, visited_relationships)
105+
unless current_rel.indexing_only
106+
errors << "#{rel_description(current_rel)} uses `parent_relationship` but is not declared with " \
107+
"`indexing_only: true`. Relationships with `parent_relationship` must be indexing-only."
108+
return nil
109+
end
110+
111+
parent_type = ref.type_ref.as_object_type # : SchemaElements::ObjectType?
112+
unless parent_type
113+
errors << "#{rel_description(current_rel)} references parent type " \
114+
"`#{ref.type_ref.name}` via `parent_relationship`, but that type does not exist. Is it misspelled?"
115+
return nil
116+
end
117+
118+
parent_rel = parent_type.relationships_by_name[ref.relationship_name]
119+
unless parent_rel
120+
errors << "#{rel_description(current_rel)} references parent relationship " \
121+
"`#{parent_type.name}.#{ref.relationship_name}` via `parent_relationship`, " \
122+
"but that relationship does not exist. Is it misspelled?"
123+
return nil
124+
end
125+
126+
if visited_relationships.include?(parent_rel)
127+
errors << "#{rel_description(current_rel)} creates a circular `parent_relationship` chain " \
128+
"— `#{parent_type.name}.#{ref.relationship_name}` was already visited. The chain must terminate at a root indexed type."
129+
return nil
130+
end
131+
132+
current_source_type_name = current_rel.related_type.name
133+
parent_source_type_name = parent_rel.related_type.name
134+
unless current_source_type_name == parent_source_type_name
135+
errors << "#{rel_description(current_rel)} relates to `#{current_source_type_name}`, " \
136+
"but its parent relationship `#{parent_type.name}.#{ref.relationship_name}` relates to " \
137+
"`#{parent_source_type_name}`. All relationships in a `parent_relationship` chain must relate to the same source type."
138+
return nil
139+
end
140+
141+
parent_rel
142+
end
143+
144+
# Builds a PathSegment for the current level and appends it to path_segments.
145+
# Uses the explicitly specified field name if provided, otherwise auto-discovers it.
146+
def build_path_segment(current_rel, parent_type, path_segments, errors)
147+
parent_ref = current_rel.parent_ref # : SchemaElements::Relationship::ParentRef
148+
field = resolve_field(parent_ref, parent_type, current_rel, errors)
149+
return unless field
150+
151+
# For list fields, `source_field_name` identifies which element to update: the one whose
152+
# `id` matches `event[source_field_name]`. We implicitly match on `id` because ElasticGraph
153+
# relationships always join on `id` via foreign keys. For non-list fields, it's nil since
154+
# there's no ambiguity.
155+
path_segments << if field.type.list?
156+
PathSegment.new(
157+
field: field,
158+
source_field_name: current_rel.foreign_key
159+
)
160+
else
161+
PathSegment.new(
162+
field: field,
163+
source_field_name: nil
164+
)
165+
end
166+
end
167+
168+
def resolve_field(parent_ref, parent_type, current_rel, errors)
169+
if parent_ref.field_name
170+
field = parent_type.indexing_fields_by_name_in_index[parent_ref.field_name]
171+
unless field
172+
errors << "#{rel_description(current_rel)} references field `#{parent_type.name}.#{parent_ref.field_name}` " \
173+
"via `parent_relationship`, but that field does not exist."
174+
end
175+
field
176+
else
177+
find_field_by_type(parent_type, current_rel, errors)
178+
end
179+
end
180+
181+
def find_field_by_type(parent_type, current_rel, errors)
182+
child_type = current_rel.parent_type
183+
matches = @indexing_fields_by_field_type_name_by_parent_type.dig(parent_type, child_type.name) || []
184+
185+
if matches.size > 1
186+
field_names = matches.map(&:name).join(", ")
187+
parent_ref = current_rel.parent_ref # : SchemaElements::Relationship::ParentRef
188+
errors << "#{rel_description(current_rel)} has an ambiguous `parent_relationship` — " \
189+
"`#{parent_type.name}` has multiple fields of type `#{child_type.name}` (#{field_names}). " \
190+
"Specify which field using the `parent_field_name:` option: " \
191+
"`r.parent_relationship \"#{parent_type.name}\", \"#{parent_ref.relationship_name}\", parent_field_name: \"<field_name>\"`"
192+
nil
193+
elsif matches.empty?
194+
errors << "#{rel_description(current_rel)} declares `#{parent_type.name}` as its parent type " \
195+
"via `parent_relationship`, but `#{parent_type.name}` has no field of type `#{child_type.name}`."
196+
nil
197+
else
198+
matches.first
199+
end
200+
end
201+
202+
def rel_description(relationship)
203+
"`#{relationship.parent_type.name}.#{relationship.name}`"
204+
end
205+
end
206+
end
207+
end
208+
end

elasticgraph-schema_definition/lib/elastic_graph/schema_definition/indexing/sourced_from_update_targets_resolver.rb

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
# frozen_string_literal: true
88

99
require "elastic_graph/errors"
10+
require "elastic_graph/schema_definition/indexing/relationship_chain_resolver"
1011
require "elastic_graph/schema_definition/indexing/relationship_resolver"
1112
require "elastic_graph/schema_definition/indexing/update_target_resolver"
1213

@@ -48,7 +49,7 @@ def resolve_for_type(object_type, &error_reporter)
4849
fields_with_sources_by_relationship_name = sourced_fields_by_relationship_name(object_type)
4950
defined_relationships = object_type.relationships_by_name.keys
5051

51-
(defined_relationships | fields_with_sources_by_relationship_name.keys).filter_map do |relationship_name|
52+
results = (defined_relationships | fields_with_sources_by_relationship_name.keys).filter_map do |relationship_name|
5253
empty_fields = [] # : ::Array[SchemaElements::Field]
5354
sourced_fields = fields_with_sources_by_relationship_name.fetch(relationship_name) { empty_fields }
5455
relationship_resolver = RelationshipResolver.new(
@@ -65,6 +66,12 @@ def resolve_for_type(object_type, &error_reporter)
6566
resolve_update_target(object_type, resolved_relationship, sourced_fields, &error_reporter)
6667
end
6768
end
69+
70+
# Resolve any `parent_relationship` chains on this type. For now this only surfaces
71+
# configuration errors; later PRs will use the resolved chains to build nested update targets.
72+
resolve_relationship_chains(object_type, &error_reporter)
73+
74+
results
6875
end
6976

7077
def resolve_update_target(object_type, resolved_relationship, sourced_fields)
@@ -91,6 +98,19 @@ def resolve_update_target(object_type, resolved_relationship, sourced_fields)
9198
[resolved_relationship.related_type.name, update_target] if update_target
9299
end
93100

101+
def resolve_relationship_chains(object_type)
102+
relationships_with_parent_ref = object_type.relationships_by_name.each_value.select(&:parent_ref)
103+
return if relationships_with_parent_ref.empty?
104+
105+
chain_resolver = RelationshipChainResolver.new(schema_def_state: @schema_def_state)
106+
107+
relationships_with_parent_ref.each do |relationship|
108+
# TODO: use resolved_chain to build nested update targets once that logic is implemented.
109+
_resolved_chain, chain_errors = chain_resolver.resolve(relationship)
110+
chain_errors.each { |error| yield :sourced_field, error }
111+
end
112+
end
113+
94114
def sourced_fields_by_relationship_name(object_type)
95115
if object_type.own_index_def.nil?
96116
# For now, only indexed types can have `sourced_from` fields, and resolving `fields_with_sources` on an unindexed union type

elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/relationship.rb

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,24 +37,40 @@ module SchemaElements
3737
# end
3838
# end
3939
class Relationship < DelegateClass(Field)
40-
# @dynamic related_type, hide_relationship_runtime_metadata, hide_relationship_runtime_metadata=
40+
# @dynamic related_type, foreign_key, hide_relationship_runtime_metadata, hide_relationship_runtime_metadata=, parent_ref, indexing_only
41+
42+
# @private
43+
ParentRef = ::Data.define(:type_ref, :relationship_name, :field_name)
4144

4245
# @return [ObjectType, InterfaceType, UnionType] the type this relationship relates to
4346
attr_reader :related_type
4447

48+
# @return [String] the foreign key field name (the `via` parameter)
49+
# @private
50+
attr_reader :foreign_key
51+
4552
# @private
4653
attr_accessor :hide_relationship_runtime_metadata
4754

4855
# @private
49-
def initialize(field, cardinality:, related_type:, foreign_key:, direction:)
56+
attr_reader :parent_ref
57+
58+
# @return [Boolean] true if this relationship is for indexing only (not exposed in GraphQL)
59+
# @private
60+
attr_reader :indexing_only
61+
62+
# @private
63+
def initialize(field, cardinality:, related_type:, foreign_key:, direction:, indexing_only: false)
5064
super(field)
5165
self.hide_relationship_runtime_metadata = false
5266
@cardinality = cardinality
5367
@related_type = related_type
5468
@foreign_key = foreign_key
5569
@direction = direction
70+
@indexing_only = indexing_only
5671
@equivalent_field_paths_by_local_path = {}
5772
@additional_filter = {}
73+
@parent_ref = nil
5874
end
5975

6076
# Adds additional filter conditions to a relationship beyond the foreign key.
@@ -136,6 +152,65 @@ def equivalent_field(path, locally_named: path)
136152
end
137153
end
138154

155+
# Indicates that this relationship chains through a parent relationship to reach the root indexed type.
156+
#
157+
# Use this API when defining relationships on embedded (non-indexed) types that need to use `sourced_from`
158+
# on their fields. By chaining relationships through parent types, ElasticGraph can resolve the path from
159+
# the nested type up to the root indexed type and properly update nested fields when source events arrive.
160+
#
161+
# @param parent_type_name [String] name of the parent type in the nesting hierarchy
162+
# @param parent_relationship_name [String] name of the relationship on the parent type
163+
# @param parent_field_name [String, nil] name of the field on the parent type that embeds this type.
164+
# When omitted, auto-discovered by finding the field on the parent type whose type matches this type.
165+
# Required when the parent type has multiple fields of this type.
166+
# @return [void]
167+
#
168+
# @example Define a nested sourced_from relationship chain
169+
# ElasticGraph.define_schema do |schema|
170+
# schema.object_type "Team" do |t|
171+
# t.field "id", "ID!"
172+
# t.field "name", "String"
173+
# t.field "players", "[Player!]!" do |f|
174+
# f.mapping type: "nested"
175+
# end
176+
# t.relates_to_many "statLines", "StatLine", via: "teamId", dir: :in, indexing_only: true
177+
# t.index "teams" do |i|
178+
# i.has_had_multiple_sources!
179+
# end
180+
# end
181+
#
182+
# schema.object_type "Player" do |t|
183+
# t.field "id", "ID!"
184+
# t.field "name", "String"
185+
# t.field "goalsScored", "Int" do |f|
186+
# f.sourced_from "statLine", "goals"
187+
# end
188+
# t.relates_to_one "statLine", "StatLine", via: "playerId", dir: :in, indexing_only: true do |r|
189+
# r.parent_relationship "Team", "statLines"
190+
# end
191+
# end
192+
#
193+
# schema.object_type "StatLine" do |t|
194+
# t.field "id", "ID!"
195+
# t.field "teamId", "ID"
196+
# t.field "playerId", "ID"
197+
# t.field "goals", "Int"
198+
# t.index "stat_lines"
199+
# end
200+
# end
201+
def parent_relationship(parent_type_name, parent_relationship_name, parent_field_name: nil)
202+
if @parent_ref
203+
raise Errors::SchemaError, "`parent_relationship` has been called multiple times on `#{parent_type.name}.#{name}`, " \
204+
"but each relationship can have only one `parent_relationship`."
205+
end
206+
207+
@parent_ref = ParentRef.new(
208+
type_ref: schema_def_state.type_ref(parent_type_name),
209+
relationship_name: parent_relationship_name,
210+
field_name: parent_field_name
211+
)
212+
end
213+
139214
# Gets the `routing_value_source` from this relationship for the given `index`, based on the configured
140215
# routing used by `index` and the configured equivalent fields.
141216
#

elasticgraph-schema_definition/lib/elastic_graph/schema_definition/schema_elements/type_with_subfields.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,8 @@ def relates_to(field_name, type, via:, dir:, foreign_key_type:, cardinality:, re
573573
cardinality: cardinality,
574574
related_type: schema_def_state.type_ref(related_type).to_final_form,
575575
foreign_key: via,
576-
direction: dir
576+
direction: dir,
577+
indexing_only: indexing_only
577578
)
578579

579580
field.relationship = relationship

0 commit comments

Comments
 (0)