diff --git a/lib/graphql/schema/visibility.rb b/lib/graphql/schema/visibility.rb index 8f98802ebf..975ef3781c 100644 --- a/lib/graphql/schema/visibility.rb +++ b/lib/graphql/schema/visibility.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "graphql/schema/visibility/profile" require "graphql/schema/visibility/migration" +require "graphql/schema/visibility/visit" module GraphQL class Schema @@ -13,6 +14,9 @@ class Visibility # @param migration_errors [Boolean] if `true`, raise an error when `Visibility` and `Warden` return different results def self.use(schema, dynamic: false, profiles: EmptyObjects::EMPTY_HASH, preload: (defined?(Rails) ? Rails.env.production? : nil), migration_errors: false) schema.visibility = self.new(schema, dynamic: dynamic, preload: preload, profiles: profiles, migration_errors: migration_errors) + if preload + schema.visibility.preload + end end def initialize(schema, dynamic:, preload:, profiles:, migration_errors:) @@ -26,27 +30,54 @@ def initialize(schema, dynamic:, preload:, profiles:, migration_errors:) @cached_profiles = {} @dynamic = dynamic @migration_errors = migration_errors - if preload - # Traverse the schema now (and in the *_configured hooks below) - # To make sure things are loaded during boot - @preloaded_types = Set.new - types_to_visit = [ - @schema.query, - @schema.mutation, - @schema.subscription, - *@schema.introspection_system.types.values, - *@schema.introspection_system.entry_points.map { |ep| ep.type.unwrap }, - *@schema.orphan_types, - ] - # Root types may have been nil: - types_to_visit.compact! - ensure_all_loaded(types_to_visit) - - profiles.each do |profile_name, example_ctx| - example_ctx[:visibility_profile] = profile_name - prof = profile_for(example_ctx, profile_name) - prof.all_types # force loading - end + # Top-level type caches: + @loaded_all = false + @interface_type_memberships = nil + @directives = nil + @types = nil + @references = nil + end + + def all_directives + load_all + @directives + end + + def all_interface_type_memberships + load_all + @interface_type_memberships + end + + def all_references + load_all + @references + end + + def get_type(type_name) + load_all + @types[type_name] + end + + def preload + # Traverse the schema now (and in the *_configured hooks below) + # To make sure things are loaded during boot + @preloaded_types = Set.new + types_to_visit = [ + @schema.query, + @schema.mutation, + @schema.subscription, + *@schema.introspection_system.types.values, + *@schema.introspection_system.entry_points.map { |ep| ep.type.unwrap }, + *@schema.orphan_types, + ] + # Root types may have been nil: + types_to_visit.compact! + ensure_all_loaded(types_to_visit) + + @profiles.each do |profile_name, example_ctx| + example_ctx[:visibility_profile] = profile_name + prof = profile_for(example_ctx, profile_name) + prof.all_types # force loading end end @@ -132,7 +163,10 @@ def profile_for(context, visibility_profile) end end - private + attr_reader :top_level + + # @api private + attr_reader :unfiltered_interface_type_memberships def top_level_profile(refresh: false) if refresh @@ -141,6 +175,8 @@ def top_level_profile(refresh: false) @top_level_profile ||= @schema.visibility_profile_class.new(context: Query::NullContext.instance, schema: @schema) end + private + def ensure_all_loaded(types_to_visit) while (type = types_to_visit.shift) if type.kind.fields? && @preloaded_types.add?(type) @@ -151,8 +187,80 @@ def ensure_all_loaded(types_to_visit) end end top_level_profile(refresh: true) + load_all(refresh: true) nil end + + def load_all(refresh: false) + if refresh + @loaded_all = nil + end + @loaded_all ||= begin + @interface_type_memberships = Hash.new { |h, interface_type| h[interface_type] = [] }.compare_by_identity + @directives = [] + @types = {} # String => Module + @references = Hash.new { |h, member| h[member] = [] }.compare_by_identity + visit = Visibility::Visit.new(@schema) + + @schema.root_types.each do |t| + @references[t] << true + end + + @schema.introspection_system.types.each_value do |t| + @references[t] << true + end + + unions_for_references = [] + + visit.visit_each do |member| + if member.is_a?(Module) + type_name = member.graphql_name + if (prev_t = @types[type_name]) + if prev_t.is_a?(Array) + prev_t << member + else + @types[type_name] = [member, prev_t] + end + else + @types[member.graphql_name] = member + end + if member < GraphQL::Schema::Directive + @directives << member + elsif member.respond_to?(:interface_type_memberships) + member.interface_type_memberships.each do |itm| + @references[itm.abstract_type] << member + @interface_type_memberships[itm.abstract_type] << itm + end + elsif member < GraphQL::Schema::Union + unions_for_references << member + end + elsif member.is_a?(GraphQL::Schema::Argument) + member.validate_default_value + @references[member.type.unwrap] << member + elsif member.is_a?(GraphQL::Schema::Field) + @references[member.type.unwrap] << member + end + true + end + @interface_type_memberships.each do |int_type, type_memberships| + referers = @references[int_type].select { |r| r.is_a?(GraphQL::Schema::Field) } + if referers.any? + type_memberships.each do |type_membership| + implementor_type = type_membership.object_type + @references[implementor_type].concat(referers) + end + end + end + + unions_for_references.each do |union_type| + refs = @references[union_type] + union_type.all_possible_types.each do |object_type| + @references[object_type].concat(refs) + end + end + true + end + end end end end diff --git a/lib/graphql/schema/visibility/profile.rb b/lib/graphql/schema/visibility/profile.rb index f744bf64c9..537838681c 100644 --- a/lib/graphql/schema/visibility/profile.rb +++ b/lib/graphql/schema/visibility/profile.rb @@ -38,38 +38,17 @@ def initialize(name: nil, context:, schema:) @all_types = {} @all_types_loaded = false @unvisited_types = [] - @referenced_types = Hash.new { |h, type_defn| h[type_defn] = [] }.compare_by_identity - @cached_directives = {} @all_directives = nil - @cached_visible = Hash.new { |h, member| - h[member] = @schema.visible?(member, @context) - }.compare_by_identity + @cached_visible = Hash.new { |h, member| h[member] = @schema.visible?(member, @context) }.compare_by_identity @cached_visible_fields = Hash.new { |h, owner| h[owner] = Hash.new do |h2, field| - h2[field] = if @cached_visible[field] && - (ret_type = field.type.unwrap) && - @cached_visible[ret_type] && - (owner == field.owner || (!owner.kind.object?) || field_on_visible_interface?(field, owner)) - - if !field.introspection? - # The problem is that some introspection fields may have references - # to non-custom introspection types. - # If those were added here, they'd cause a DuplicateNamesError. - # This is basically a bug -- those fields _should_ reference the custom types. - add_type(ret_type, field) - end - true - else - false - end + h2[field] = visible_field_for(owner, field) end.compare_by_identity }.compare_by_identity @cached_visible_arguments = Hash.new do |h, arg| h[arg] = if @cached_visible[arg] && (arg_type = arg.type.unwrap) && @cached_visible[arg_type] - add_type(arg_type, arg) - arg.validate_default_value true else false @@ -88,39 +67,7 @@ def initialize(name: nil, context:, schema:) end end.compare_by_identity - @cached_possible_types = Hash.new do |h, type| - h[type] = case type.kind.name - when "INTERFACE" - load_all_types - pts = [] - @unfiltered_interface_type_memberships[type].each { |itm| - if @cached_visible[itm] && (ot = itm.object_type) && @cached_visible[ot] && referenced?(ot) - pts << ot - end - } - pts - when "UNION" - pts = [] - type.type_memberships.each { |tm| - if @cached_visible[tm] && - (ot = tm.object_type) && - @cached_visible[ot] && - referenced?(ot) - pts << ot - end - } - pts - when "OBJECT" - load_all_types - if @all_types[type.graphql_name] == type - [type] - else - EmptyObjects::EMPTY_ARRAY - end - else - GraphQL::EmptyObjects::EMPTY_ARRAY - end - end.compare_by_identity + @cached_possible_types = Hash.new { |h, type| h[type] = possible_types_for(type) }.compare_by_identity @cached_enum_values = Hash.new do |h, enum_t| values = non_duplicate_items(enum_t.enum_values(@context), @cached_visible) @@ -164,17 +111,12 @@ def field_on_visible_interface?(field, owner) end def type(type_name) - t = if (loaded_t = @all_types[type_name]) - loaded_t - elsif !@all_types_loaded - load_all_types - @all_types[type_name] - end + t = @schema.visibility.get_type(type_name) # rubocop:disable ContextIsPassedCop if t if t.is_a?(Array) vis_t = nil t.each do |t_defn| - if @cached_visible[t_defn] + if @cached_visible[t_defn] && referenced?(t_defn) if vis_t.nil? vis_t = t_defn else @@ -184,7 +126,7 @@ def type(type_name) end vis_t else - if t && @cached_visible[t] + if t && @cached_visible[t] && referenced?(t) t else nil @@ -267,15 +209,15 @@ def interfaces(obj_or_int_type) end def query_root - add_if_visible(@schema.query) + ((t = @schema.query) && @cached_visible[t]) ? t : nil end def mutation_root - add_if_visible(@schema.mutation) + ((t = @schema.mutation) && @cached_visible[t]) ? t : nil end def subscription_root - add_if_visible(@schema.subscription) + ((t = @schema.subscription) && @cached_visible[t]) ? t : nil end def all_types @@ -293,28 +235,15 @@ def enum_values(owner) end def directive_exists?(dir_name) - if (dir = @schema.directives[dir_name]) && @cached_visible[dir] - !!dir - else - load_all_types - !!@cached_directives[dir_name] - end + directives.any? { |d| d.graphql_name == dir_name } end def directives - @all_directives ||= begin - load_all_types - dirs = [] - @schema.directives.each do |name, dir_defn| - if !@cached_directives[name] && @cached_visible[dir_defn] - dirs << dir_defn - end - end - dirs.concat(@cached_directives.values) - end + @all_directives ||= @schema.visibility.all_directives.select { |dir| @cached_visible[dir] } end def loadable?(t, _ctx) + load_all_types !@all_types[t.graphql_name] && @cached_visible[t] end @@ -322,9 +251,9 @@ def loaded_types @all_types.values end - def reachable_type?(name) + def reachable_type?(type_name) load_all_types - !!@all_types[name] + !!@all_types[type_name] end def visible_enum_value?(enum_value, _ctx = nil) @@ -333,29 +262,6 @@ def visible_enum_value?(enum_value, _ctx = nil) private - def add_if_visible(t) - (t && @cached_visible[t]) ? (add_type(t, true); t) : nil - end - - def add_type(t, by_member) - if t && @cached_visible[t] - n = t.graphql_name - if (prev_t = @all_types[n]) - if !prev_t.equal?(t) - raise_duplicate_definition(prev_t, t) - end - false - else - @referenced_types[t] << by_member - @all_types[n] = t - @unvisited_types << t - true - end - else - false - end - end - def non_duplicate_items(definitions, visibility_cache) non_dups = [] definitions.each do |defn| @@ -373,64 +279,24 @@ def raise_duplicate_definition(first_defn, second_defn) raise DuplicateNamesError.new(duplicated_name: first_defn.path, duplicated_definition_1: first_defn.inspect, duplicated_definition_2: second_defn.inspect) end - def referenced?(t) - load_all_types - @referenced_types[t].any? { |reference| (reference == true) || @cached_visible[reference] } - end - def load_all_types return if @all_types_loaded @all_types_loaded = true - entry_point_types = [ - query_root, - mutation_root, - subscription_root, - *@schema.introspection_system.types.values, - ] - - # Don't include any orphan_types whose interfaces aren't visible. - @schema.orphan_types.each do |orphan_type| - if @cached_visible[orphan_type] && - orphan_type.interface_type_memberships.any? { |tm| @cached_visible[tm] && @cached_visible[tm.abstract_type] } - entry_point_types << orphan_type - end - end - - @schema.directives.each do |_dir_name, dir_class| - if @cached_visible[dir_class] - arguments(dir_class).each do |arg| - entry_point_types << arg.type.unwrap - end - end - end - - entry_point_types.compact! # Root types might be nil - entry_point_types.flatten! # handle multiple defns - entry_point_types.each { |t| add_type(t, true) } - - @unfiltered_interface_type_memberships = Hash.new { |h, k| h[k] = [] }.compare_by_identity - @add_possible_types = Set.new - @late_types = [] - - while @unvisited_types.any? || @late_types.any? - while t = @unvisited_types.pop - # These have already been checked for `.visible?` - visit_type(t) - end - @add_possible_types.each do |int_t| - itms = @unfiltered_interface_type_memberships[int_t] - itms.each do |itm| - if @cached_visible[itm] && (obj_type = itm.object_type) && @cached_visible[obj_type] - add_type(obj_type, itm) + visit = Visibility::Visit.new(@schema) + visit.visit_each do |member| + if member.is_a?(Module) && member.respond_to?(:kind) + if @cached_visible[member] + type_name = member.graphql_name + if (prev_t = @all_types[type_name]) && !prev_t.equal?(member) + raise_duplicate_definition(member, prev_t) end + @all_types[type_name] = member + true + else + false end - end - @add_possible_types.clear - - while (union_tm = @late_types.shift) - late_obj_t = union_tm.object_type - obj_t = @all_types[late_obj_t.graphql_name] || raise("Failed to resolve #{late_obj_t.graphql_name.inspect} from #{union_tm.inspect}") - union_tm.abstract_type.assign_type_membership_object_type(obj_t) + else + @cached_visible[member] end end @@ -438,88 +304,47 @@ def load_all_types nil end - def visit_type(type) - visit_directives(type) - case type.kind.name - when "OBJECT", "INTERFACE" - if type.kind.object? - type.interface_type_memberships.each do |itm| - @unfiltered_interface_type_memberships[itm.abstract_type] << itm - end - # recurse into visible implemented interfaces - interfaces(type).each do |interface| - add_type(interface, type) - end - else - type.orphan_types.each { |t| add_type(t, type)} - end - - # recurse into visible fields - t_f = type.all_field_definitions - t_f.each do |field| - field.ensure_loaded - if @cached_visible[field] - visit_directives(field) - field_type = field.type.unwrap - if field_type.kind.interface? - @add_possible_types.add(field_type) - end - add_type(field_type, field) + def referenced?(type_defn) + @schema.visibility.all_references[type_defn].any? { |r| r == true || @cached_visible[r] } + end - # recurse into visible arguments - arguments(field).each do |argument| - visit_directives(argument) - add_type(argument.type.unwrap, argument) - end + def possible_types_for(type) + case type.kind.name + when "INTERFACE" + pts = [] + @schema.visibility.all_interface_type_memberships[type].each do |itm| + if @cached_visible[itm] && (ot = itm.object_type) && @cached_visible[ot] && referenced?(ot) + pts << ot end end - when "INPUT_OBJECT" - # recurse into visible arguments - arguments(type).each do |argument| - visit_directives(argument) - add_type(argument.type.unwrap, argument) - end + pts when "UNION" - # recurse into visible possible types - type.type_memberships.each do |tm| - if @cached_visible[tm] - obj_t = tm.object_type - if obj_t.is_a?(GraphQL::Schema::LateBoundType) - @late_types << tm - else - if obj_t.is_a?(String) - obj_t = Member::BuildType.constantize(obj_t) - tm.object_type = obj_t - end - if @cached_visible[obj_t] - add_type(obj_t, tm) - end - end + pts = [] + type.type_memberships.each { |tm| + if @cached_visible[tm] && + (ot = tm.object_type) && + @cached_visible[ot] && + referenced?(ot) + pts << ot end + } + pts + when "OBJECT" + if @cached_visible[type] + [type] + else + EmptyObjects::EMPTY_ARRAY end - when "ENUM" - enum_values(type).each do |val| - visit_directives(val) - end - when "SCALAR" - # pass + else + GraphQL::EmptyObjects::EMPTY_ARRAY end end - def visit_directives(member) - member.directives.each { |dir| - dir_class = dir.class - if @cached_visible[dir_class] - dir_name = dir_class.graphql_name - if (existing_dir = @cached_directives[dir_name]) - if existing_dir != dir_class - raise ArgumentError, "Two directives for `@#{dir_name}`: #{existing_dir}, #{dir.class}" - end - else - @cached_directives[dir.graphql_name] = dir_class - end - end - } + def visible_field_for(owner, field) + @cached_visible[field] && + (ret_type = field.type.unwrap) && + @cached_visible[ret_type] && + (owner == field.owner || (!owner.kind.object?) || field_on_visible_interface?(field, owner)) end end end diff --git a/lib/graphql/schema/visibility/visit.rb b/lib/graphql/schema/visibility/visit.rb new file mode 100644 index 0000000000..701bc26705 --- /dev/null +++ b/lib/graphql/schema/visibility/visit.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true +module GraphQL + class Schema + class Visibility + class Visit + def initialize(schema) + @schema = schema + @late_bound_types = nil + @unvisited_types = nil + end + + def entry_point_types + ept = [ + @schema.query, + @schema.mutation, + @schema.subscription, + *@schema.introspection_system.types.values, + *@schema.orphan_types, + ] + ept.compact! + ept + end + + def visit_each + @unvisited_types && raise("Can't call #visit_each twice on this Visit object") + @unvisited_types = entry_point_types + @late_bound_types = [] + visited_types = Set.new.compare_by_identity + visited_directives = Set.new.compare_by_identity + + directives_to_visit = [] + + @schema.directives.each_value { |dir_class| + if visited_directives.add?(dir_class) + yield(dir_class) + dir_class.all_argument_definitions.each do |arg_defn| + if yield(arg_defn) + directives_to_visit.concat(arg_defn.directives) + append_unvisited_type(dir_class, arg_defn.type.unwrap) + end + end + end + } + + while @unvisited_types.any? || @late_bound_types.any? + while (type = @unvisited_types.pop) + if visited_types.add?(type) && yield(type) + directives_to_visit.concat(type.directives) + case type.kind.name + when "OBJECT", "INTERFACE" + type.interface_type_memberships.each do |itm| + append_unvisited_type(type, itm.abstract_type) + end + if type.kind.interface? + type.orphan_types.each do |orphan_type| + append_unvisited_type(type, orphan_type) + end + end + + type.all_field_definitions.each do |field| + field.ensure_loaded + if yield(field) + directives_to_visit.concat(field.directives) + append_unvisited_type(type, field.type.unwrap) + field.all_argument_definitions.each do |argument| + if yield(argument) + directives_to_visit.concat(argument.directives) + append_unvisited_type(field, argument.type.unwrap) + end + end + end + end + when "INPUT_OBJECT" + type.all_argument_definitions.each do |argument| + if yield(argument) + directives_to_visit.concat(argument.directives) + append_unvisited_type(type, argument.type.unwrap) + end + end + when "UNION" + type.type_memberships.each do |tm| + append_unvisited_type(type, tm.object_type) + end + when "ENUM" + type.all_enum_value_definitions.each do |val| + if yield(val) + directives_to_visit.concat(val.directives) + end + end + when "SCALAR" + # pass -- nothing else to visit + else + raise "Invariant: unhandled type kind: #{type.kind.inspect}" + end + end + end + + directives_to_visit.each do |dir| + dir_class = dir.class + if visited_directives.add?(dir_class) + yield(dir_class) + end + end + + missed_late_types_streak = 0 + while (owner, late_type = @late_bound_types.shift) + if (late_type.is_a?(String) && (type = Member::BuildType.constantize(type))) || + (late_type.is_a?(LateBoundType) && (type = visited_types.find { |t| t.graphql_name == late_type.graphql_name })) + missed_late_types_streak = 0 # might succeed next round + update_type_owner(owner, type) + append_unvisited_type(owner, type) + else + # Didn't find it -- keep trying + missed_late_types_streak += 1 + @late_bound_types << [owner, late_type] + if missed_late_types_streak == @late_bound_types.size + raise UnresolvedLateBoundTypeError.new(type: late_type) + end + end + end + end + nil + end + + private + + def append_unvisited_type(owner, type) + if type.is_a?(LateBoundType) || type.is_a?(String) + @late_bound_types << [owner, type] + else + @unvisited_types << type + end + end + + def update_type_owner(owner, type) + case owner + when Module + if owner.kind.union? + owner.assign_type_membership_object_type(type) + elsif type.kind.interface? + new_interfaces = [] + owner.interfaces.each do |int_t| + if int_t.is_a?(String) && int_t == type.graphql_name + new_interfaces << type + elsif int_t.is_a?(LateBoundType) && int_t.graphql_name == type.graphql_name + new_interfaces << type + else + # Don't re-add proper interface definitions, + # they were probably already added, maybe with options. + end + end + owner.implements(*new_interfaces) + new_interfaces.each do |int| + pt = @possible_types[int] ||= [] + if !pt.include?(owner) && owner.is_a?(Class) + pt << owner + end + int.interfaces.each do |indirect_int| + if indirect_int.is_a?(LateBoundType) && (indirect_int_type = get_type(indirect_int.graphql_name)) # rubocop:disable ContextIsPassedCop + update_type_owner(owner, indirect_int_type) + end + end + end + end + when GraphQL::Schema::Argument, GraphQL::Schema::Field + orig_type = owner.type + # Apply list/non-null wrapper as needed + if orig_type.respond_to?(:of_type) + transforms = [] + while (orig_type.respond_to?(:of_type)) + if orig_type.kind.non_null? + transforms << :to_non_null_type + elsif orig_type.kind.list? + transforms << :to_list_type + else + raise "Invariant: :of_type isn't non-null or list" + end + orig_type = orig_type.of_type + end + transforms.reverse_each { |t| type = type.public_send(t) } + end + owner.type = type + else + raise "Unexpected update: #{owner.inspect} #{type.inspect}" + end + end + end + end + end +end diff --git a/lib/graphql/static_validation/validation_context.rb b/lib/graphql/static_validation/validation_context.rb index 41e841b9d1..c9fcd1cde7 100644 --- a/lib/graphql/static_validation/validation_context.rb +++ b/lib/graphql/static_validation/validation_context.rb @@ -29,6 +29,7 @@ def initialize(query, visitor_class, max_errors) @visitor = visitor_class.new(document, self) end + # TODO stop using def_delegators because of Array allocations def_delegators :@visitor, :path, :type_definition, :field_definition, :argument_definition, :parent_type_definition, :directive_definition, :object_types, :dependencies diff --git a/lib/graphql/testing/helpers.rb b/lib/graphql/testing/helpers.rb index 573bad1248..9df3c8171a 100644 --- a/lib/graphql/testing/helpers.rb +++ b/lib/graphql/testing/helpers.rb @@ -92,7 +92,7 @@ def run_graphql_field(schema, field_path, object, arguments: {}, context: {}, as end graphql_result else - unfiltered_type = Schema::Visibility::Profile.null_profile(schema: schema, context: context).type(type_name) + unfiltered_type = schema.use_visibility_profile? ? schema.visibility.get_type(type_name) : schema.get_type(type_name) # rubocop:disable ContextIsPassedCop if unfiltered_type raise TypeNotVisibleError.new(type_name: type_name) else diff --git a/spec/graphql/schema/visibility/subset_spec.rb b/spec/graphql/schema/visibility/profile_spec.rb similarity index 58% rename from spec/graphql/schema/visibility/subset_spec.rb rename to spec/graphql/schema/visibility/profile_spec.rb index c54b1ca4af..c36ba6851c 100644 --- a/spec/graphql/schema/visibility/subset_spec.rb +++ b/spec/graphql/schema/visibility/profile_spec.rb @@ -19,9 +19,17 @@ class Query < GraphQL::Schema::Object it "only loads the types it needs" do query = GraphQL::Query.new(ProfileSchema, "{ thing { name } }", use_visibility_profile: true) assert_equal [], query.types.loaded_types - res = query.result + res = query.result assert_equal "Something", res["data"]["thing"]["name"] - assert_equal ["Query", "String", "Thing"], query.types.loaded_types.map(&:graphql_name).sort + assert_equal [], query.types.loaded_types.map(&:graphql_name).sort + + query = GraphQL::Query.new(ProfileSchema, "{ __schema { types { name }} }", use_visibility_profile: true) + assert_equal [], query.types.loaded_types + + res = query.result + assert_equal 12, res["data"]["__schema"]["types"].size + loaded_type_names = query.types.loaded_types.map(&:graphql_name).reject { |n| n.start_with?("__") }.sort + assert_equal ["Boolean", "Query", "String", "Thing"], loaded_type_names end end