Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add cached, named Visibility profiles #5074

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions lib/graphql/query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ def selected_operation_name
# @param root_value [Object] the object used to resolve fields on the root type
# @param max_depth [Numeric] the maximum number of nested selections allowed for this query (falls back to schema-level value)
# @param max_complexity [Numeric] the maximum field complexity for this query (falls back to schema-level value)
def initialize(schema, query_string = nil, query: nil, document: nil, context: nil, variables: nil, validate: true, static_validator: nil, subscription_topic: nil, operation_name: nil, root_value: nil, max_depth: schema.max_depth, max_complexity: schema.max_complexity, warden: nil, use_schema_subset: nil)
# @param visibility_profile [Symbol]
def initialize(schema, query_string = nil, query: nil, document: nil, context: nil, variables: nil, validate: true, static_validator: nil, visibility_profile: nil, subscription_topic: nil, operation_name: nil, root_value: nil, max_depth: schema.max_depth, max_complexity: schema.max_complexity, warden: nil, use_schema_subset: nil)
# Even if `variables: nil` is passed, use an empty hash for simpler logic
variables ||= {}
@schema = schema
Expand All @@ -105,8 +106,10 @@ def initialize(schema, query_string = nil, query: nil, document: nil, context: n
use_schema_subset = warden ? false : schema.use_schema_visibility?
end

@visibility_profile = visibility_profile

if use_schema_subset
@schema_subset = @schema.subset_class.new(context: @context, schema: @schema)
@schema_subset = @schema.visibility.profile_for(@context, visibility_profile)
@warden = Schema::Warden::NullWarden.new(context: @context, schema: @schema)
else
@schema_subset = nil
Expand Down Expand Up @@ -187,6 +190,9 @@ def query_string
@query_string ||= (document ? document.to_query_string : nil)
end

# @return [Symbol, nil]
attr_reader :visibility_profile

attr_accessor :multiplex

# @return [GraphQL::Tracing::Trace]
Expand Down
30 changes: 22 additions & 8 deletions lib/graphql/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,9 @@ def static_validator
GraphQL::StaticValidation::Validator.new(schema: self)
end

# Add `plugin` to this schema
# @param plugin [#use] A Schema plugin
# @return void
def use(plugin, **kwargs)
if kwargs.any?
plugin.use(self, **kwargs)
Expand All @@ -334,7 +337,8 @@ def plugins
# @see get_type Which is more efficient for finding _one type_ by name, because it doesn't merge hashes.
def types(context = GraphQL::Query::NullContext.instance)
if use_schema_visibility?
return Visibility::Subset.from_context(context, self).all_types_h
types = Visibility::Subset.from_context(context, self)
return types.all_types_h
end
all_types = non_introspection_types.merge(introspection_system.types)
visible_types = {}
Expand All @@ -361,9 +365,11 @@ def types(context = GraphQL::Query::NullContext.instance)
end

# @param type_name [String]
# @param context [GraphQL::Query::Context] Used for filtering definitions at query-time
# @param use_schema_visibility Private, for migration to {Schema::Visibility}
# @return [Module, nil] A type, or nil if there's no type called `type_name`
def get_type(type_name, context = GraphQL::Query::NullContext.instance)
if use_schema_visibility?
def get_type(type_name, context = GraphQL::Query::NullContext.instance, use_schema_visibility = use_schema_visibility?)
if use_schema_visibility
return Visibility::Subset.from_context(context, self).type(type_name)
end
local_entry = own_types[type_name]
Expand Down Expand Up @@ -397,7 +403,7 @@ def get_type(type_name, context = GraphQL::Query::NullContext.instance)

type_defn ||
introspection_system.types[type_name] || # todo context-specific introspection?
(superclass.respond_to?(:get_type) ? superclass.get_type(type_name, context) : nil)
(superclass.respond_to?(:get_type) ? superclass.get_type(type_name, context, use_schema_visibility) : nil)
end

# @return [Boolean] Does this schema have _any_ definition for a type named `type_name`, regardless of visibility?
Expand Down Expand Up @@ -530,7 +536,8 @@ def subset_class
end
end

attr_writer :subset_class, :use_schema_visibility, :visibility
attr_writer :subset_class, :use_schema_visibility
attr_accessor :visibility

def use_schema_visibility?
if defined?(@use_schema_visibility)
Expand All @@ -543,10 +550,12 @@ def use_schema_visibility?
end

# @param type [Module] The type definition whose possible types you want to see
# @param context [GraphQL::Query::Context] used for filtering visible possible types at runtime
# @param use_schema_visibility Private, for migration to {Schema::Visibility}
# @return [Hash<String, Module>] All possible types, if no `type` is given.
# @return [Array<Module>] Possible types for `type`, if it's given.
def possible_types(type = nil, context = GraphQL::Query::NullContext.instance)
if use_schema_visibility?
def possible_types(type = nil, context = GraphQL::Query::NullContext.instance, use_schema_visibility = use_schema_visibility?)
if use_schema_visibility
if type
return Visibility::Subset.from_context(context, self).possible_types(type)
else
Expand All @@ -570,7 +579,7 @@ def possible_types(type = nil, context = GraphQL::Query::NullContext.instance)
introspection_system.possible_types[type] ||
(
superclass.respond_to?(:possible_types) ?
superclass.possible_types(type, context) :
superclass.possible_types(type, context, use_schema_visibility) :
EMPTY_ARRAY
)
end
Expand Down Expand Up @@ -1068,6 +1077,11 @@ def inherited(child_class)
child_class.own_trace_modes[name] = child_class.build_trace_mode(name)
end
child_class.singleton_class.prepend(ResolveTypeWithType)

if use_schema_visibility?
vis = self.visibility
child_class.visibility = vis.dup_for(child_class)
end
super
end

Expand Down
1 change: 1 addition & 0 deletions lib/graphql/schema/argument.rb
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ def load_and_authorize_value(load_method_owner, coerced_value, context)

# @api private
def validate_default_value
return unless default_value?
coerced_default_value = begin
# This is weird, but we should accept single-item default values for list-type arguments.
# If we used `coerce_isolated_input` below, it would do this for us, but it's not really
Expand Down
1 change: 1 addition & 0 deletions lib/graphql/schema/build_from_definition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ def definition_default_resolve

def self.inherited(child_class)
child_class.definition_default_resolve = self.definition_default_resolve
super
end
end

Expand Down
117 changes: 109 additions & 8 deletions lib/graphql/schema/visibility.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,126 @@

module GraphQL
class Schema
# Use this plugin to make some parts of your schema hidden from some viewers.
#
class Visibility
def self.use(schema, preload: nil, migration_errors: false)
schema.visibility = self.new(schema, preload: preload)
# @param schema [Class<GraphQL::Schema>]
# @param profiles [Hash<Symbol => Hash>] A hash of `name => context` pairs for preloading visibility profiles
# @param preload [Boolean] if `true`, load the default schema profile and all named profiles immediately (defaults to `true` for `Rails.env.production?`)
# @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_ARRAY, 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)
end

def initialize(schema, dynamic:, preload:, profiles:, migration_errors:)
@schema = schema
schema.use_schema_visibility = true
if migration_errors
schema.subset_class = Migration
end
@profiles = profiles
@cached_profiles = {}
@dynamic = dynamic
@migration_errors = migration_errors
if preload
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
end

def initialize(schema, preload:)
@schema = schema
@cached_subsets = {}
# Make another Visibility for `schema` based on this one
# @return [Visibility]
# @api private
def dup_for(other_schema)
self.class.new(
other_schema,
dynamic: @dynamic,
preload: @preload,
profiles: @profiles,
migration_errors: @migration_errors
)
end

def migration_errors?
@migration_errors
end

attr_reader :cached_profiles

def profile_for(context, visibility_profile)
if @profiles.any?
if visibility_profile.nil?
if @dynamic
@schema.subset_class.new(context: context, schema: @schema)
elsif @profiles.any?
raise ArgumentError, "#{@schema} expects a visibility profile, but `visibility_profile:` wasn't passed. Provide a `visibility_profile:` value or add `dynamic: true` to your visibility configuration."
end
elsif !@profiles.include?(visibility_profile)
raise ArgumentError, "`#{visibility_profile.inspect}` isn't allowed for `visibility_profile:` (must be one of #{@profiles.keys.map(&:inspect).join(", ")}). Or, add `#{visibility_profile.inspect}` to the list of profiles in the schema definition."
else
@cached_profiles[visibility_profile] ||= @schema.subset_class.new(name: visibility_profile, context: context, schema: @schema)
end
else
@schema.subset_class.new(context: context, schema: @schema)
end
end

if preload.nil? && defined?(Rails) && Rails.env.production?
preload = true
module TypeIntegration
def self.included(child_cls)
child_cls.extend(ClassMethods)
end

if preload
module ClassMethods
def visible_in(profiles = NOT_CONFIGURED)
if NOT_CONFIGURED.equal?(profiles)
@visible_in
else
@visible_in = Array(profiles)
end
end

# TODO visible?

def inherited(child_cls)
super
if visible_in
child_cls.visible_in(visible_in)
else
child_cls.visible_in(nil)
end
end
end
end
module FieldIntegration
def self.included(child_cls)
child_cls.extend(ClassMethods)
end

module ClassMethods
def visible_in(visible_in = NOT_CONFIGURED)
if NOT_CONFIGURED.equal?(visible_in)
@visible_in
else
@visible_in = Array(visible_in)
end
end
end
def initialize(*args, visible_in: nil, **kwargs, &block)
@visible_in = visible_in ? Array(visible_in) : nil
super(*args, **kwargs, &block)
end

def visible?(context)
v_i = @visible_in || self.class.visible_in
if v_i
v_p = context.respond_to?(:query) ? context.query.visibility_profile : context[:visibility_profile]
super && v_i.include?(v_p)
else
super
end
end
end
end
Expand Down
8 changes: 5 additions & 3 deletions lib/graphql/schema/visibility/migration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,11 @@ def humanize(val)
end
end

def initialize(context:, schema:)
@skip_error = context[:skip_visibility_migration_error]
context[:visibility_migration_running] = true
def initialize(context:, schema:, name: nil)
@skip_error = context[:skip_visibility_migration_error] || context.is_a?(Query::NullContext) || context.is_a?(Hash)
@subset_types = GraphQL::Schema::Visibility::Subset.new(context: context, schema: schema)
if !@skip_error
context[:visibility_migration_running] = true
warden_ctx_vals = context.to_h.dup
warden_ctx_vals[:visibility_migration_warden_running] = true
if defined?(schema::WardenCompatSchema)
Expand All @@ -95,6 +95,7 @@ def initialize(context:, schema:)
# TODO public API
warden_schema.send(:add_type_and_traverse, [warden_schema.query, warden_schema.mutation, warden_schema.subscription].compact, root: true)
warden_schema.send(:add_type_and_traverse, warden_schema.directives.values + warden_schema.orphan_types, root: false)
schema.const_set(:WardenCompatSchema, warden_schema)
end
warden_ctx = GraphQL::Query::Context.new(query: context.query, values: warden_ctx_vals)
example_warden = GraphQL::Schema::Warden.new(schema: warden_schema, context: warden_ctx)
Expand All @@ -112,6 +113,7 @@ def loaded_types
:enum_values,
:interfaces,
:all_types,
:all_types_h,
:fields,
:loadable?,
:type,
Expand Down
36 changes: 26 additions & 10 deletions lib/graphql/schema/visibility/subset.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ class Visibility
# - It checks `.visible?` on root introspection types
#
# In the future, {Subset} will support lazy-loading types as needed during execution and multi-request caching of subsets.
# TODO rename to Profile?
class Subset
# @return [Schema::Visibility::Subset]
def self.from_context(ctx, schema)
if ctx.respond_to?(:types) && (types = ctx.types).is_a?(self)
types
else
# TODO use a cached instance from the schema
self.new(context: ctx, schema: schema)
schema.visibility.profile_for(ctx, nil)
end
end

Expand All @@ -30,7 +30,11 @@ def self.pass_thru(context:, schema:)
subset
end

def initialize(context:, schema:)
# @return [Symbol, nil]
attr_reader :name

def initialize(name: nil, context:, schema:)
@name = name
@context = context
@schema = schema
@all_types = {}
Expand Down Expand Up @@ -67,6 +71,7 @@ def initialize(context:, schema:)
@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
Expand Down Expand Up @@ -403,8 +408,9 @@ def load_all_types

@unfiltered_interface_type_memberships = Hash.new { |h, k| h[k] = [] }.compare_by_identity
@add_possible_types = Set.new
@late_types = []

while @unvisited_types.any?
while @unvisited_types.any? || @late_types.any?
while t = @unvisited_types.pop
# These have already been checked for `.visible?`
visit_type(t)
Expand All @@ -418,6 +424,12 @@ def load_all_types
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)
end
end

@all_types.delete_if { |type_name, type_defn| !referenced?(type_defn) }
Expand Down Expand Up @@ -470,12 +482,16 @@ def visit_type(type)
type.type_memberships.each do |tm|
if @cached_visible[tm]
obj_t = tm.object_type
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)
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
end
end
Expand Down
Loading
Loading