diff --git a/elasticgraph-graphql/lib/elastic_graph/graphql/schema/type.rb b/elasticgraph-graphql/lib/elastic_graph/graphql/schema/type.rb index d35e73e88..f502dc850 100644 --- a/elasticgraph-graphql/lib/elastic_graph/graphql/schema/type.rb +++ b/elasticgraph-graphql/lib/elastic_graph/graphql/schema/type.rb @@ -61,12 +61,12 @@ def name # List of index definitions that should be searched for this type. def search_index_definitions @search_index_definitions ||= - if indexed_aggregation? - # For an indexed aggregation, we just delegate to its source type. This works better than - # dumping index definitions in the runtime metadata of the indexed aggregation type itself - # because of abstract (interface/union) types. The source document type handles that (since - # there is a supertype/subtype relationship on the document types) but that relationship - # does not exist on the indexed aggregation. + if (st = source_type) + # When a type has a source type (a prime example being indexed aggregations), we delegate + # to the source type. This works better than dumping index definitions in the runtime metadata + # of the derived type itself because of abstract (interface/union) types. The source document + # type handles that (since there is a supertype/subtype relationship on the document types) + # but that relationship does not exist on derived types. # # For example, assume we have these indexed document types: # - type Person {} @@ -76,7 +76,7 @@ def search_index_definitions # We can go from `Inventor` to its subtypes to find the search indexes. However, `InventorAggregation` # is NOT a union of `PersonAggregation` and `CompanyAggregation`, so we can't do the same thing on the # indexed aggregation types. Delegating to the source type solves this case. - @schema.type_named(@object_runtime_metadata.source_type).search_index_definitions + st.search_index_definitions else @index_definitions.union(subtypes.flat_map(&:search_index_definitions)) end @@ -117,13 +117,37 @@ def unwrap_fully end end - # Returns the subtypes of this type, if it has any. This is like `#possible_types` provided by the + # Returns all concrete subtypes, at any depth. This is like `#possible_types` provided by the # GraphQL gem, but that includes a type itself when you ask for the possible types of a non-abstract type. def subtypes @subtypes ||= @schema .graphql_schema .possible_types(graphql_type, visibility_profile: :boot) - .map { |t| @schema.type_from(t) } - [self] + .map { |t| @schema.type_from(t) } + .reject { |t| t == self } + .to_set + end + + # For derived types (e.g. indexed aggregations), returns the underlying source document type. + # Returns `nil` for non-derived types. + def source_type + return @source_type if defined?(@source_type) + @source_type = @object_runtime_metadata&.source_type&.then { |st| @schema.type_named(st) } + end + + # Returns the set of concrete (non-abstract) indexed document types that share any of this + # type's search indexes but are not subtypes of this type. Used to determine whether a + # `__typename` filter is needed when querying an abstract type. + # + # Abstract types are excluded because documents in the datastore are always associated + # with a concrete `__typename`. When filtering by `__typename`, only concrete types are + # relevant. + def concrete_non_subtypes_in_shared_index + @concrete_non_subtypes_in_shared_index ||= + search_index_definitions + .flat_map { |index_def| @schema.document_types_stored_in(index_def.name).to_a } + .reject { |t| t == self || subtypes.include?(t) || t.abstract? } + .to_set end def field_named(field_name) diff --git a/elasticgraph-graphql/sig/elastic_graph/graphql/schema/type.rbs b/elasticgraph-graphql/sig/elastic_graph/graphql/schema/type.rbs index a3d840c2f..1436d393d 100644 --- a/elasticgraph-graphql/sig/elastic_graph/graphql/schema/type.rbs +++ b/elasticgraph-graphql/sig/elastic_graph/graphql/schema/type.rbs @@ -9,6 +9,9 @@ module ElasticGraph attr_reader graphql_type: ::GraphQL::Schema::_Type attr_reader grouping_missing_value_placeholder: ::String? | ::Numeric? def search_index_definitions: () -> ::Array[DatastoreCore::_IndexDefinition] + def source_type: () -> Type? + def subtypes: () -> ::Set[Type] + def concrete_non_subtypes_in_shared_index: () -> ::Set[Type] def unwrap_fully: () -> Type def field_named: (::String) -> Field def fields_by_name_in_index: () -> ::Hash[::String, ::Array[Field]] diff --git a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/schema/type_spec.rb b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/schema/type_spec.rb index 872826847..4a1209326 100644 --- a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/schema/type_spec.rb +++ b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/schema/type_spec.rb @@ -604,14 +604,14 @@ def type_for(field_name) end end - it "returns [] for object types" do + it "returns an empty set for object types" do type = schema.type_named("Size") - expect(type.subtypes).to eq [] + expect(type.subtypes).to be_empty end - it "returns [] for scalar types" do + it "returns an empty set for scalar types" do type = schema.type_named("Int") - expect(type.subtypes).to eq [] + expect(type.subtypes).to be_empty end it "returns the subtypes of a union" do @@ -758,6 +758,104 @@ def search_index_definitions_from(type_name: "TheType") end end + describe "#source_type" do + attr_reader :schema + + before(:context) do + @schema = define_schema(clients_by_name: {}) do |s| + s.enum_type "Status" do |t| + t.value "ACTIVE" + t.value "INACTIVE" + end + + s.object_type "Thing" do |t| + t.field "id", "ID!" + t.field "status", "Status" + t.index "things" + end + end + end + + it "returns the underlying document type for an indexed aggregation type" do + expect(schema.type_named("ThingAggregation").source_type).to be schema.type_named("Thing") + end + + it "returns nil for a non-derived object type" do + expect(schema.type_named("Thing").source_type).to be_nil + end + + it "returns nil for a scalar type" do + expect(schema.type_named("Int").source_type).to be_nil + end + + it "returns nil for an enum type" do + expect(schema.type_named("Status").source_type).to be_nil + end + end + + describe "#concrete_non_subtypes_in_shared_index" do + attr_reader :schema + + before(:context) do + @schema = define_schema(clients_by_name: {}) do |s| + # A root interface with an index shared by all sub-hierarchies. + s.interface_type "Channel" do |t| + t.field "id", "ID!" + t.index "channels" + end + + # Sibling types — NOT under Store, but share the Channel index. + s.object_type "Wholesaler" do |t| + t.implements "Channel" + t.field "id", "ID!" + end + + s.object_type "Distributor" do |t| + t.implements "Channel" + t.field "id", "ID!" + end + + # A sub-interface and its concrete subtypes. + s.interface_type "Store" do |t| + t.implements "Channel" + t.field "id", "ID!" + end + + s.object_type "OnlineStore" do |t| + t.implements "Store" + t.field "id", "ID!" + end + + # PhysicalStore overrides to use a dedicated index. + s.object_type "PhysicalStore" do |t| + t.implements "Store" + t.field "id", "ID!" + t.index "physical_stores" + end + end + end + + it "excludes the type itself and its subtypes, returning only concrete sibling types in the shared index" do + expect(schema.type_named("Store").concrete_non_subtypes_in_shared_index).to contain_exactly( + schema.type_named("Wholesaler"), + schema.type_named("Distributor") + ) + end + + it "excludes the type itself even when the type is a concrete type in a shared index" do + # Wholesaler is a concrete type stored in the "channels" index, so it appears in + # document_types_stored_in("channels"). Without the `t == self` guard it would include itself. + expect(schema.type_named("Wholesaler").concrete_non_subtypes_in_shared_index).to contain_exactly( + schema.type_named("Distributor"), + schema.type_named("OnlineStore") + ) + end + + it "returns an empty set when all types sharing its indexes are subtypes" do + expect(schema.type_named("Channel").concrete_non_subtypes_in_shared_index).to be_empty + end + end + describe "#hidden_from_queries?" do it "returns `false` on a type that has no backing indexed types" do schema = define_schema do |s|