Module: CachingArrayResolver

Included in:
Resolvers::MergeRequestPipelinesResolver
Defined in:
app/graphql/resolvers/concerns/caching_array_resolver.rb

Overview

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. (Must respond to :primary_key, and :from_union)

  • #query_input(**kwargs) -> A (Must be hashable)

  • #query_for(A) -> ActiveRecord::Relation

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:

  • max_union_size Integer (the maximum number of queries to run in any one union)

  • preload -> Preloads|NilClass (a set of preloads to apply to each query)

  • #item_found(A, R) (return value is ignored)

  • allowed?(R) -> Boolean (if this method returns false, the value is not resolved)

Constant Summary collapse

MAX_UNION_SIZE =
50

Instance Method Summary collapse

Instance Method Details

#allowed?(item) ⇒ Boolean

Override to apply filters on a per-item basis

Returns:

  • (Boolean)

82
83
84
# File 'app/graphql/resolvers/concerns/caching_array_resolver.rb', line 82

def allowed?(item)
  true
end

#item_found(query_input, item) ⇒ Object

Override this to intercept the items once they are found


92
93
# File 'app/graphql/resolvers/concerns/caching_array_resolver.rb', line 92

def item_found(query_input, item)
end

#max_union_sizeObject


95
96
97
# File 'app/graphql/resolvers/concerns/caching_array_resolver.rb', line 95

def max_union_size
  MAX_UNION_SIZE
end

#preloadObject

Override to specify preloads for each query


87
88
89
# File 'app/graphql/resolvers/concerns/caching_array_resolver.rb', line 87

def preload
  nil
end

#resolve(**args) ⇒ Object


53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'app/graphql/resolvers/concerns/caching_array_resolver.rb', line 53

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)
          .preload(preload) # rubocop: disable CodeReuse/ActiveRecord
          .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