Skip to content

Commit 770801b

Browse files
Add GraphQL::Schema::Type helpers needed for query-time __typename filtering (#1161)
* Add GraphQL::Schema::Type helpers needed for query-time __typename filtering This is a companion/pre-requisite PR to add methods supporting __typename filter injection when querying mixed-type indexes via index inheritance. * `source_type`: resolves the underlying document type for derived types (e.g. indexed aggregations) so the filter can look up search indexes on the source rather than the aggregation type * `subtypes`: declared in RBS so Steep-checked callers can use it * `non_subtypes_in_shared_index`: identifies concrete types sharing an index with this type that are not subtypes — the presence of any such types is what triggers the need for a __typename filter * Address PR review feedback on GraphQL::Schema::Type helpers - `source_type`: restore `&.` safe navigation since `@object_runtime_metadata` can be nil for non-object types (scalars, enums); return `nil` instead of `self` for non-derived types; update `search_index_definitions` to branch on `source_type` truthy rather than `indexed_aggregation?`, and update comment to describe the general case with indexed aggregation as a prime example - `subtypes`: return a `Set` instead of `Array`; inline `subtypes` in `non_subtypes_in_shared_index` rather than caching in a local variable - Tests: use `before(:context)` shared schema in `#source_type` group; add scalar and enum nil cases; use `contain_exactly` for precise set assertions; consolidate `not_to include` calls; add coverage for `t == self` guard Generated with Claude Code * Address more PR review feedback on GraphQL::Schema::Type helpers - `source_type`: use `defined?(@source_type)` pattern to correctly memoize nil return values (||= doesn't memoize nil) - Rename `non_subtypes_in_shared_index` → `concrete_non_subtypes_in_shared_index` to make explicit that abstract types are excluded from the result - Tests: add a second sibling type so at least one test returns multiple elements, making it harder for a partial implementation to pass Generated with Claude Code
1 parent 416852c commit 770801b

3 files changed

Lines changed: 138 additions & 13 deletions

File tree

  • elasticgraph-graphql

elasticgraph-graphql/lib/elastic_graph/graphql/schema/type.rb

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,12 @@ def name
6161
# List of index definitions that should be searched for this type.
6262
def search_index_definitions
6363
@search_index_definitions ||=
64-
if indexed_aggregation?
65-
# For an indexed aggregation, we just delegate to its source type. This works better than
66-
# dumping index definitions in the runtime metadata of the indexed aggregation type itself
67-
# because of abstract (interface/union) types. The source document type handles that (since
68-
# there is a supertype/subtype relationship on the document types) but that relationship
69-
# does not exist on the indexed aggregation.
64+
if (st = source_type)
65+
# When a type has a source type (a prime example being indexed aggregations), we delegate
66+
# to the source type. This works better than dumping index definitions in the runtime metadata
67+
# of the derived type itself because of abstract (interface/union) types. The source document
68+
# type handles that (since there is a supertype/subtype relationship on the document types)
69+
# but that relationship does not exist on derived types.
7070
#
7171
# For example, assume we have these indexed document types:
7272
# - type Person {}
@@ -76,7 +76,7 @@ def search_index_definitions
7676
# We can go from `Inventor` to its subtypes to find the search indexes. However, `InventorAggregation`
7777
# is NOT a union of `PersonAggregation` and `CompanyAggregation`, so we can't do the same thing on the
7878
# indexed aggregation types. Delegating to the source type solves this case.
79-
@schema.type_named(@object_runtime_metadata.source_type).search_index_definitions
79+
st.search_index_definitions
8080
else
8181
@index_definitions.union(subtypes.flat_map(&:search_index_definitions))
8282
end
@@ -117,13 +117,37 @@ def unwrap_fully
117117
end
118118
end
119119

120-
# Returns the subtypes of this type, if it has any. This is like `#possible_types` provided by the
120+
# Returns all concrete subtypes, at any depth. This is like `#possible_types` provided by the
121121
# GraphQL gem, but that includes a type itself when you ask for the possible types of a non-abstract type.
122122
def subtypes
123123
@subtypes ||= @schema
124124
.graphql_schema
125125
.possible_types(graphql_type, visibility_profile: :boot)
126-
.map { |t| @schema.type_from(t) } - [self]
126+
.map { |t| @schema.type_from(t) }
127+
.reject { |t| t == self }
128+
.to_set
129+
end
130+
131+
# For derived types (e.g. indexed aggregations), returns the underlying source document type.
132+
# Returns `nil` for non-derived types.
133+
def source_type
134+
return @source_type if defined?(@source_type)
135+
@source_type = @object_runtime_metadata&.source_type&.then { |st| @schema.type_named(st) }
136+
end
137+
138+
# Returns the set of concrete (non-abstract) indexed document types that share any of this
139+
# type's search indexes but are not subtypes of this type. Used to determine whether a
140+
# `__typename` filter is needed when querying an abstract type.
141+
#
142+
# Abstract types are excluded because documents in the datastore are always associated
143+
# with a concrete `__typename`. When filtering by `__typename`, only concrete types are
144+
# relevant.
145+
def concrete_non_subtypes_in_shared_index
146+
@concrete_non_subtypes_in_shared_index ||=
147+
search_index_definitions
148+
.flat_map { |index_def| @schema.document_types_stored_in(index_def.name).to_a }
149+
.reject { |t| t == self || subtypes.include?(t) || t.abstract? }
150+
.to_set
127151
end
128152

129153
def field_named(field_name)

elasticgraph-graphql/sig/elastic_graph/graphql/schema/type.rbs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ module ElasticGraph
99
attr_reader graphql_type: ::GraphQL::Schema::_Type
1010
attr_reader grouping_missing_value_placeholder: ::String? | ::Numeric?
1111
def search_index_definitions: () -> ::Array[DatastoreCore::_IndexDefinition]
12+
def source_type: () -> Type?
13+
def subtypes: () -> ::Set[Type]
14+
def concrete_non_subtypes_in_shared_index: () -> ::Set[Type]
1215
def unwrap_fully: () -> Type
1316
def field_named: (::String) -> Field
1417
def fields_by_name_in_index: () -> ::Hash[::String, ::Array[Field]]

elasticgraph-graphql/spec/unit/elastic_graph/graphql/schema/type_spec.rb

Lines changed: 102 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -604,14 +604,14 @@ def type_for(field_name)
604604
end
605605
end
606606

607-
it "returns [] for object types" do
607+
it "returns an empty set for object types" do
608608
type = schema.type_named("Size")
609-
expect(type.subtypes).to eq []
609+
expect(type.subtypes).to be_empty
610610
end
611611

612-
it "returns [] for scalar types" do
612+
it "returns an empty set for scalar types" do
613613
type = schema.type_named("Int")
614-
expect(type.subtypes).to eq []
614+
expect(type.subtypes).to be_empty
615615
end
616616

617617
it "returns the subtypes of a union" do
@@ -758,6 +758,104 @@ def search_index_definitions_from(type_name: "TheType")
758758
end
759759
end
760760

761+
describe "#source_type" do
762+
attr_reader :schema
763+
764+
before(:context) do
765+
@schema = define_schema(clients_by_name: {}) do |s|
766+
s.enum_type "Status" do |t|
767+
t.value "ACTIVE"
768+
t.value "INACTIVE"
769+
end
770+
771+
s.object_type "Thing" do |t|
772+
t.field "id", "ID!"
773+
t.field "status", "Status"
774+
t.index "things"
775+
end
776+
end
777+
end
778+
779+
it "returns the underlying document type for an indexed aggregation type" do
780+
expect(schema.type_named("ThingAggregation").source_type).to be schema.type_named("Thing")
781+
end
782+
783+
it "returns nil for a non-derived object type" do
784+
expect(schema.type_named("Thing").source_type).to be_nil
785+
end
786+
787+
it "returns nil for a scalar type" do
788+
expect(schema.type_named("Int").source_type).to be_nil
789+
end
790+
791+
it "returns nil for an enum type" do
792+
expect(schema.type_named("Status").source_type).to be_nil
793+
end
794+
end
795+
796+
describe "#concrete_non_subtypes_in_shared_index" do
797+
attr_reader :schema
798+
799+
before(:context) do
800+
@schema = define_schema(clients_by_name: {}) do |s|
801+
# A root interface with an index shared by all sub-hierarchies.
802+
s.interface_type "Channel" do |t|
803+
t.field "id", "ID!"
804+
t.index "channels"
805+
end
806+
807+
# Sibling types — NOT under Store, but share the Channel index.
808+
s.object_type "Wholesaler" do |t|
809+
t.implements "Channel"
810+
t.field "id", "ID!"
811+
end
812+
813+
s.object_type "Distributor" do |t|
814+
t.implements "Channel"
815+
t.field "id", "ID!"
816+
end
817+
818+
# A sub-interface and its concrete subtypes.
819+
s.interface_type "Store" do |t|
820+
t.implements "Channel"
821+
t.field "id", "ID!"
822+
end
823+
824+
s.object_type "OnlineStore" do |t|
825+
t.implements "Store"
826+
t.field "id", "ID!"
827+
end
828+
829+
# PhysicalStore overrides to use a dedicated index.
830+
s.object_type "PhysicalStore" do |t|
831+
t.implements "Store"
832+
t.field "id", "ID!"
833+
t.index "physical_stores"
834+
end
835+
end
836+
end
837+
838+
it "excludes the type itself and its subtypes, returning only concrete sibling types in the shared index" do
839+
expect(schema.type_named("Store").concrete_non_subtypes_in_shared_index).to contain_exactly(
840+
schema.type_named("Wholesaler"),
841+
schema.type_named("Distributor")
842+
)
843+
end
844+
845+
it "excludes the type itself even when the type is a concrete type in a shared index" do
846+
# Wholesaler is a concrete type stored in the "channels" index, so it appears in
847+
# document_types_stored_in("channels"). Without the `t == self` guard it would include itself.
848+
expect(schema.type_named("Wholesaler").concrete_non_subtypes_in_shared_index).to contain_exactly(
849+
schema.type_named("Distributor"),
850+
schema.type_named("OnlineStore")
851+
)
852+
end
853+
854+
it "returns an empty set when all types sharing its indexes are subtypes" do
855+
expect(schema.type_named("Channel").concrete_non_subtypes_in_shared_index).to be_empty
856+
end
857+
end
858+
761859
describe "#hidden_from_queries?" do
762860
it "returns `false` on a type that has no backing indexed types" do
763861
schema = define_schema do |s|

0 commit comments

Comments
 (0)