diff options
Diffstat (limited to 'app/graphql/resolvers/concerns')
6 files changed, 144 insertions, 13 deletions
diff --git a/app/graphql/resolvers/concerns/caching_array_resolver.rb b/app/graphql/resolvers/concerns/caching_array_resolver.rb new file mode 100644 index 00000000000..4f2c8b98928 --- /dev/null +++ b/app/graphql/resolvers/concerns/caching_array_resolver.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +# Concern that will eliminate N+1 queries for size-constrained +# collections of items. +# +# **note**: The resolver will never load more items than +# `@field.max_page_size` if defined, falling back to +# `context.schema.default_max_page_size`. +# +# provided that: +# +# - the query can be uniquely determined by the object and the arguments +# - the model class includes FromUnion +# - the model class defines a scalar primary key +# +# This comes at the cost of returning arrays, not relations, so we don't get +# any keyset pagination goodness. Consequently, this is only suitable for small-ish +# result sets, as the full result set will be loaded into memory. +# +# To enforce this, the resolver limits the size of result sets to +# `@field.max_page_size || context.schema.default_max_page_size`. +# +# **important**: If the cardinality of your collection is likely to be greater than 100, +# then you will want to pass `max_page_size:` as part of the field definition +# or (ideally) as part of the resolver `field_options`. +# +# How to implement: +# -------------------- +# +# Each including class operates on two generic parameters, A and R: +# - A is any Object that can be used as a Hash key. Instances of A +# are returned by `query_input` and then passed to `query_for`. +# - R is any subclass of ApplicationRecord that includes FromUnion. +# R must have a single scalar primary_key +# +# Classes must implement: +# - #model_class -> Class[R]. (Must respond to :primary_key, and :from_union) +# - #query_input(**kwargs) -> A (Must be hashable) +# - #query_for(A) -> ActiveRecord::Relation[R] +# +# Note the relationship between query_input and query_for, one of which +# consumes the input of the other +# (i.e. `resolve(**args).sync == query_for(query_input(**args)).to_a`). +# +# Classes may implement: +# - #item_found(A, R) (return value is ignored) +# - max_union_size Integer (the maximum number of queries to run in any one union) +module CachingArrayResolver + MAX_UNION_SIZE = 50 + + def resolve(**args) + key = query_input(**args) + + BatchLoader::GraphQL.for(key).batch(**batch) do |keys, loader| + if keys.size == 1 + # We can avoid the union entirely. + k = keys.first + limit(query_for(k)).each { |item| found(loader, k, item) } + else + queries = keys.map { |key| query_for(key) } + + queries.in_groups_of(max_union_size, false).each do |group| + by_id = model_class + .from_union(tag(group), remove_duplicates: false) + .group_by { |r| r[primary_key] } + + by_id.values.each do |item_group| + item = item_group.first + item_group.map(&:union_member_idx).each do |i| + found(loader, keys[i], item) + end + end + end + end + end + end + + # Override this to intercept the items once they are found + def item_found(query_input, item) + end + + def max_union_size + MAX_UNION_SIZE + end + + private + + def primary_key + @primary_key ||= (model_class.primary_key || raise("No primary key for #{model_class}")) + end + + def batch + { key: self.class, default_value: [] } + end + + def found(loader, key, value) + loader.call(key) do |vs| + item_found(key, value) + vs << value + end + end + + # Tag each row returned from each query with a the index of which query in + # the union it comes from. This lets us map the results back to the cache key. + def tag(queries) + queries.each_with_index.map do |q, i| + limit(q.select(all_fields, member_idx(i))) + end + end + + def limit(query) + query.limit(query_limit) # rubocop: disable CodeReuse/ActiveRecord + end + + def all_fields + model_class.arel_table[Arel.star] + end + + # rubocop: disable Graphql/Descriptions (false positive!) + def query_limit + field&.max_page_size.presence || context.schema.default_max_page_size + end + # rubocop: enable Graphql/Descriptions + + def member_idx(idx) + ::Arel::Nodes::SqlLiteral.new(idx.to_s).as('union_member_idx') + end +end diff --git a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb index fe6fa0bb262..4715b867ecb 100644 --- a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb +++ b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb @@ -29,7 +29,7 @@ module IssueResolverArguments description: 'Usernames of users assigned to the issue' argument :assignee_id, GraphQL::STRING_TYPE, required: false, - description: 'ID of a user assigned to the issues, "none" and "any" values supported' + description: 'ID of a user assigned to the issues, "none" and "any" values are supported' argument :created_before, Types::TimeType, required: false, description: 'Issues created before this date' diff --git a/app/graphql/resolvers/concerns/looks_ahead.rb b/app/graphql/resolvers/concerns/looks_ahead.rb index 61f23920ebb..d468047b539 100644 --- a/app/graphql/resolvers/concerns/looks_ahead.rb +++ b/app/graphql/resolvers/concerns/looks_ahead.rb @@ -4,6 +4,7 @@ module LooksAhead extend ActiveSupport::Concern included do + extras [:lookahead] attr_accessor :lookahead end diff --git a/app/graphql/resolvers/concerns/resolves_pipelines.rb b/app/graphql/resolvers/concerns/resolves_pipelines.rb index 46d9e174deb..f061f5f1606 100644 --- a/app/graphql/resolvers/concerns/resolves_pipelines.rb +++ b/app/graphql/resolvers/concerns/resolves_pipelines.rb @@ -4,7 +4,7 @@ module ResolvesPipelines extend ActiveSupport::Concern included do - type [Types::Ci::PipelineType], null: false + type Types::Ci::PipelineType.connection_type, null: false argument :status, Types::Ci::PipelineStatusEnum, required: false, diff --git a/app/graphql/resolvers/concerns/resolves_project.rb b/app/graphql/resolvers/concerns/resolves_project.rb index 3c5ce3dab01..b2ee7d7e850 100644 --- a/app/graphql/resolvers/concerns/resolves_project.rb +++ b/app/graphql/resolvers/concerns/resolves_project.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true module ResolvesProject + # Accepts EITHER one of + # - full_path: String (see Project#full_path) + # - project_id: GlobalID. Arguments should be typed as: `::Types::GlobalIDType[Project]` def resolve_project(full_path: nil, project_id: nil) unless full_path.present? ^ project_id.present? raise ::Gitlab::Graphql::Errors::ArgumentError, 'Incompatible arguments: projectId, projectPath.' diff --git a/app/graphql/resolvers/concerns/resolves_snippets.rb b/app/graphql/resolvers/concerns/resolves_snippets.rb index 483372bbf63..790ff4f774f 100644 --- a/app/graphql/resolvers/concerns/resolves_snippets.rb +++ b/app/graphql/resolvers/concerns/resolves_snippets.rb @@ -4,9 +4,9 @@ module ResolvesSnippets extend ActiveSupport::Concern included do - type Types::SnippetType, null: false + type Types::SnippetType.connection_type, null: false - argument :ids, [GraphQL::ID_TYPE], + argument :ids, [::Types::GlobalIDType[::Snippet]], required: false, description: 'Array of global snippet ids, e.g., "gid://gitlab/ProjectSnippet/1"' @@ -32,16 +32,15 @@ module ResolvesSnippets }.merge(options_by_type(args[:type])) end - def resolve_ids(ids) - Array.wrap(ids).map { |id| resolve_gid(id, :id) } - end - - def resolve_gid(gid, argument) - return unless gid.present? + def resolve_ids(ids, type = ::Types::GlobalIDType[::Snippet]) + Array.wrap(ids).map do |id| + next unless id.present? - GlobalID.parse(gid)&.model_id.tap do |id| - raise Gitlab::Graphql::Errors::ArgumentError, "Invalid global id format for param #{argument}" if id.nil? - end + # TODO: remove this line when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = type.coerce_isolated_input(id) + id.model_id + end.compact end def options_by_type(type) |