summaryrefslogtreecommitdiff
path: root/app/graphql/resolvers/concerns/caching_array_resolver.rb
blob: e7555dcf42c5edad00578cf4ea74ddcde3d250e0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# 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:
# - 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)
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)
            .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

  # Override to apply filters on a per-item basis
  def allowed?(item)
    true
  end

  # Override to specify preloads for each query
  def preload
    nil
  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)
    return unless allowed?(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