graphql-fancy_loader 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,103 @@
1
+ class GraphQL::FancyConnection < GraphQL::Pagination::RelationConnection
2
+ def initialize(loader, args, key, **super_args)
3
+ @loader = loader
4
+ @args = args
5
+ @key = key
6
+ @then = nil
7
+
8
+ super(nil, **super_args)
9
+ end
10
+
11
+ # @return [Promise<Array<ApplicationRecord>>]
12
+ def nodes
13
+ if @then
14
+ base_nodes.then(@then)
15
+ else
16
+ base_nodes
17
+ end
18
+ end
19
+
20
+ def edges
21
+ @edges ||= nodes.then do |nodes|
22
+ nodes.map { |n| @edge_class.new(n, self) }
23
+ end
24
+ end
25
+
26
+ # @return [Promise<Integer>]
27
+ def total_count
28
+ base_nodes.then do |results|
29
+ if results.first
30
+ results.first.attributes['total_count']
31
+ else
32
+ 0
33
+ end
34
+ end
35
+ end
36
+
37
+ # @return [Promise<Boolean>]
38
+ def has_next_page # rubocop:disable Naming/PredicateName
39
+ base_nodes.then do |results|
40
+ if results.last
41
+ results.last.attributes['row_number'] < results.last.attributes['total_count']
42
+ else
43
+ false
44
+ end
45
+ end
46
+ end
47
+
48
+ # @return [Promise<Boolean>]
49
+ def has_previous_page # rubocop:disable Naming/PredicateName
50
+ base_nodes.then do |results|
51
+ if results.first
52
+ results.first.attributes['row_number'] > 1
53
+ else
54
+ false
55
+ end
56
+ end
57
+ end
58
+
59
+ def start_cursor
60
+ base_nodes.then do |results|
61
+ cursor_for(results.first)
62
+ end
63
+ end
64
+
65
+ def end_cursor
66
+ base_nodes.then do |results|
67
+ cursor_for(results.last)
68
+ end
69
+ end
70
+
71
+ def cursor_for(item)
72
+ item && encode(item.attributes['row_number'].to_s)
73
+ end
74
+
75
+ def then(&block)
76
+ @then = block
77
+ self
78
+ end
79
+
80
+ private
81
+
82
+ def base_nodes
83
+ @base_nodes ||= @loader.for(**loader_args).load(@key)
84
+ end
85
+
86
+ def after_offset
87
+ @after_offset ||= after && decode(after).to_i
88
+ end
89
+
90
+ def before_offset
91
+ @before_offset ||= before && decode(before).to_i
92
+ end
93
+
94
+ def loader_args
95
+ @args.merge(
96
+ before: before_offset,
97
+ after: after_offset,
98
+ first: first,
99
+ last: last,
100
+ context: context
101
+ )
102
+ end
103
+ end
@@ -0,0 +1,31 @@
1
+ module GraphQL
2
+ class FancyLoader
3
+ module DSL
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ class_attribute :model
8
+ class_attribute :sorts
9
+ class_attribute :modify_query_lambda
10
+ end
11
+
12
+ class_methods do
13
+ def from(model)
14
+ self.model = model
15
+ end
16
+
17
+ def sort(name, transform: nil, on: -> { model.arel_table[name] })
18
+ self.sorts ||= {}
19
+ sorts[name] = {
20
+ transform: transform,
21
+ column: on
22
+ }
23
+ end
24
+
25
+ def modify_query(lambda)
26
+ self.modify_query_lambda = lambda
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,64 @@
1
+ module GraphQL
2
+ class FancyLoader
3
+ class PaginationFilter
4
+ def initialize(query, before: nil, after: nil, first: nil, last: nil)
5
+ @query = query
6
+ @before = before
7
+ @after = after
8
+ @first = first
9
+ @last = last
10
+ end
11
+
12
+ def arel
13
+ [
14
+ after_filter,
15
+ before_filter,
16
+ first_filter,
17
+ last_filter
18
+ ].compact.inject(&:and)
19
+ end
20
+
21
+ private
22
+
23
+ def row
24
+ @row ||= @query[:row_number]
25
+ end
26
+
27
+ def count
28
+ @count ||= @query[:total_count]
29
+ end
30
+
31
+ def after_filter
32
+ return unless @after
33
+
34
+ row.gt(@after)
35
+ end
36
+
37
+ def before_filter
38
+ return unless @before
39
+
40
+ row.lt(@before)
41
+ end
42
+
43
+ def first_filter
44
+ return unless @first
45
+
46
+ if @after
47
+ row.lteq(@after + @first)
48
+ else
49
+ row.lteq(@first)
50
+ end
51
+ end
52
+
53
+ def last_filter
54
+ return unless @last
55
+
56
+ if @before
57
+ row.gteq(@before - @last)
58
+ else
59
+ row.gt(Arel::Nodes::Subtraction.new(count, @last))
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,13 @@
1
+ module GraphQL
2
+ class FancyLoader::PunditMiddleware
3
+ def initialize(key:)
4
+ @key = key
5
+ end
6
+
7
+ def call(model:, query:, context:)
8
+ scope = ::Pundit::PolicyFinder.new(model).scope!
9
+ user = context[@key]
10
+ scope.new(user, query).resolve
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,125 @@
1
+ # @private
2
+ module GraphQL
3
+ class FancyLoader
4
+ class QueryGenerator
5
+ # @param model [ActiveRecord::Model] the model to load from
6
+ # @param find_by [Symbol, String, Array<Symbol, String>] the key or keys to find by
7
+ # @param sort [Array<{:column, :transform, :direction => Object}>] The sorts to apply
8
+ # @param keys [Array] an array of values to find by
9
+ # @param before [Integer] Filter by rows less than this (one-indexed)
10
+ # @param after [Integer] Filter by rows greater than this (one-indexed)
11
+ # @param first [Integer] Filter for first N rows
12
+ # @param last [Integer] Filter for last N rows
13
+ # @param where [Hash] a filter to use when querying
14
+ # @param context [Context] The context of the graphql query. Can be used inside of modify_query.
15
+ # @param modify_query [Lambda] An escape hatch to FancyLoader to allow modifying
16
+ # the base_query before it generates the rest of the query
17
+ def initialize(
18
+ model:, find_by:, sort:, keys:,
19
+ before: nil, after: 0, first: nil, last: nil,
20
+ where: nil, context: {}, modify_query: nil
21
+ )
22
+ @model = model
23
+ @find_by = find_by
24
+ @sort = sort
25
+ @keys = keys
26
+ @before = before
27
+ @after = after
28
+ @first = first
29
+ @last = last
30
+ @where = where
31
+ @context = context
32
+ @modify_query = modify_query
33
+ end
34
+
35
+ def query
36
+ # Finally, go *back* to the ActiveRecord model, and do the final select
37
+ @model.unscoped
38
+ .select(Arel.star)
39
+ .from(subquery)
40
+ .where(pagination_filter(subquery))
41
+ .order(subquery[:row_number].asc)
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :context
47
+
48
+ # The underlying Arel table for the model
49
+ def table
50
+ @table ||= @model.arel_table
51
+ end
52
+
53
+ # A window function partition clause to apply the sort within each window
54
+ #
55
+ # PARTITION BY #{find_by} ORDER BY #{orders}
56
+ def partition
57
+ @partition ||= begin
58
+ # Every sort has a column and a direction, apply them
59
+ orders = @sort.map do |sort|
60
+ sort[:column].call.public_send(sort[:direction])
61
+ end
62
+
63
+ Arel::Nodes::Window.new.partition(table[@find_by]).order(orders)
64
+ end
65
+ end
66
+
67
+ # Our actual window function.
68
+ #
69
+ # ROW_NUMBER() OVER (#{partition})
70
+ def row_number
71
+ Arel::Nodes::NamedFunction.new('ROW_NUMBER', []).over(partition).as('row_number')
72
+ end
73
+
74
+ # A count window function. Omits sort from the partition to get the total count.
75
+ #
76
+ # COUNT(*) OVER (#{partition})
77
+ def count
78
+ count_partition = Arel::Nodes::Window.new.partition(table[@find_by])
79
+ Arel::Nodes::NamedFunction.new('COUNT', [Arel.star]).over(count_partition).as('total_count')
80
+ end
81
+
82
+ def pagination_filter(query)
83
+ @pagination_filter ||= GraphQL::FancyLoader::PaginationFilter.new(
84
+ query,
85
+ before: @before,
86
+ after: @after,
87
+ first: @first,
88
+ last: @last
89
+ ).arel
90
+ end
91
+
92
+ # The "base" query. This is the query that would load everything without pagination or sorting,
93
+ # just auth scoping.
94
+ def base_query
95
+ query = @model.where(@find_by => @keys)
96
+ query = query.where(@where) unless @where.nil?
97
+ query = middleware(query: query)
98
+ query.arel
99
+ end
100
+
101
+ def subquery
102
+ @subquery ||= begin
103
+ # Apply the sort transforms and add the window function to our projection
104
+ subquery = @sort.inject(base_query) do |arel, sort|
105
+ sort[:transform] ? sort[:transform].call(arel, context) : arel
106
+ end
107
+
108
+ subquery = subquery.project(row_number).project(count)
109
+ subquery = instance_exec(subquery, &@modify_query) unless @modify_query.nil?
110
+ subquery.as('subquery')
111
+ end
112
+ end
113
+
114
+ def middleware(query:)
115
+ return query if GraphQL::FancyLoader.middleware.blank?
116
+
117
+ GraphQL::FancyLoader.middleware.each do |klass|
118
+ query = klass.call(model: @model, query: query, context: context)
119
+ end
120
+
121
+ query
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,40 @@
1
+ # @private
2
+ module GraphQL
3
+ class FancyLoader
4
+ class RankQueryGenerator
5
+ # @param column [Symbol] The table column to rank by
6
+ # @param partition_by [Symbol] The find_by key for the table
7
+ # @param table [Arel::Table]
8
+ # @param name_suffix [String] The suffix the be used for the column name
9
+ def initialize(column:, partition_by:, table:, name_suffix: '_rank')
10
+ @column = column
11
+ @partition_by = partition_by
12
+ @table = table
13
+ @name_suffix = name_suffix
14
+ end
15
+
16
+ # Our actual window function.
17
+ #
18
+ # ROW_NUMBER() OVER (#{partition})
19
+ def arel
20
+ Arel::Nodes::NamedFunction.new('ROW_NUMBER', []).over(partition).as(name)
21
+ end
22
+
23
+ private
24
+
25
+ def name
26
+ return @column if @name_suffix.blank?
27
+
28
+ "#{@column}#{@name_suffix}"
29
+ end
30
+
31
+ def partition
32
+ @partition ||= Arel::Nodes::Window.new.partition(@table[@partition_by]).order(order)
33
+ end
34
+
35
+ def order
36
+ @table[@column].asc
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,42 @@
1
+ ##
2
+ # Generate parameter types for sorting
3
+ module GraphQL
4
+ class FancyLoader
5
+ class TypeGenerator
6
+ def initialize(loader, name: loader.model.name)
7
+ @loader = loader
8
+ @name = name
9
+ end
10
+
11
+ def sorts_enum
12
+ @sorts_enum ||= begin
13
+ sorts = @loader.sorts
14
+ name = "#{@name}SortEnum"
15
+
16
+ Class.new(GraphQL::Schema::Enum) do
17
+ graphql_name name
18
+ sorts.each_key do |sort_name|
19
+ value(sort_name.to_s.underscore.upcase, value: sort_name)
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ def sorts_option
26
+ @sorts_option ||= begin
27
+ enum = sorts_enum
28
+ name = "#{@name}SortOption"
29
+ Class.new(GraphQL::Schema::InputObject) do
30
+ graphql_name name
31
+ argument :on, enum, required: true
32
+ argument :direction, GraphQL::SortDirection, required: true
33
+ end
34
+ end
35
+ end
36
+
37
+ def sorts_list
38
+ @sorts_list ||= GraphQL::Schema::List.new(sorts_option)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,5 @@
1
+ module GraphQL
2
+ class FancyLoader
3
+ VERSION = '0.1.0'.freeze
4
+ end
5
+ end
@@ -0,0 +1,103 @@
1
+ # +FancyLoader+ allows for easily batching complex custom sorts and pagination. It does so through
2
+ # heavy use of complex Arel and Postgres window functions, but has performance attributes that make
3
+ # it worthwhile.
4
+ #
5
+ # To use +FancyLoader+, you'll make a subclass to define your sorts and source model. You can then
6
+ # create a field which uses your subclass to load data.
7
+
8
+ require 'graphql/batch'
9
+ require 'active_support'
10
+ require 'active_support/concern'
11
+ require 'active_support/configurable'
12
+ require 'active_support/core_ext/class/attribute'
13
+
14
+ require 'graphql/sort_direction'
15
+ require 'graphql/fancy_connection'
16
+ # FancyLoader
17
+ require 'graphql/fancy_loader/dsl'
18
+ require 'graphql/fancy_loader/pagination_filter'
19
+ require 'graphql/fancy_loader/query_generator'
20
+ require 'graphql/fancy_loader/rank_query_generator'
21
+ require 'graphql/fancy_loader/type_generator'
22
+ # Middleware
23
+ require 'graphql/fancy_loader/pundit_middleware'
24
+
25
+ module GraphQL
26
+ class FancyLoader < GraphQL::Batch::Loader
27
+ include ActiveSupport::Configurable
28
+ include GraphQL::FancyLoader::DSL
29
+
30
+ config_accessor :middleware
31
+
32
+ # Get an autogenerated GraphQL type for an order input
33
+ def self.sort_argument
34
+ @sort_argument ||= GraphQL::FancyLoader::TypeGenerator.new(self).sorts_list
35
+ end
36
+
37
+ # Get a FancyConnection wrapping this Loader
38
+ def self.connection_for(args, key)
39
+ GraphQL::FancyConnection.new(self, args.except(:context), key, **args.slice(:context))
40
+ end
41
+
42
+ # Initialize a FancyLoader. This takes all the keys which are used to batch, which is a *lot* of
43
+ # them. Thanks to the design of GraphQL, however, the frequently-called fields also tend to have
44
+ # the same parameters each time. This means that we can get away with this less-than-ideal
45
+ # batching and still have significant performance gains.
46
+ #
47
+ # The pagination parameters have some odd interactions to be aware of! They are *intersected*, so
48
+ # if you pass before and after, you're specifying after < row < before. That's pretty logical,
49
+ # but first+last are weirder, because when combined they will return the *middle*, due to that
50
+ # intersection-driven logic. That is, given a set of 10 rows, first=6 & last=6 will return rows
51
+ # 4, 5, and 6 because they are the only ones in both sets. This isn't a particularly useful
52
+ # behavior, but the Relay spec is pretty clear that you shouldn't expect good results if you pass
53
+ # both first and last to the same field.
54
+ #
55
+ # @param find_by [Symbol, String] the key to find by
56
+ # @param before [Integer] Filter by rows less than this
57
+ # @param after [Integer] Filter by rows greater than this
58
+ # @param first [Integer] Filter for first N rows
59
+ # @param last [Integer] Filter for last N rows
60
+ # @param sort [Array<{:on, :direction => Symbol}>] The sorts to apply while loading
61
+ def initialize(find_by:, sort:, before: nil, after: 0, first: nil, last: nil, where: nil, context: {})
62
+ @find_by = find_by
63
+ @sort = sort.map(&:to_h)
64
+ @before = before
65
+ @after = after
66
+ @first = first
67
+ @last = last
68
+ @where = where
69
+ @context = context
70
+ end
71
+
72
+ # Perform the loading. Uses {GraphQL::FancyLoader::QueryGenerator} to build a query, then groups
73
+ # the results by the @find_by column, then fulfills all the Promises.
74
+ def perform(keys)
75
+ query = QueryGenerator.new(
76
+ model: model,
77
+ find_by: @find_by,
78
+ before: @before,
79
+ after: @after,
80
+ first: @first,
81
+ last: @last,
82
+ sort: sort,
83
+ keys: keys,
84
+ where: @where,
85
+ context: @context,
86
+ modify_query: modify_query_lambda
87
+ ).query
88
+
89
+ results = query.to_a.group_by { |rec| rec[@find_by] }
90
+ keys.each do |key|
91
+ fulfill(key, results[key] || [])
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ def sort
98
+ @sort.map do |sort|
99
+ sorts[sort[:on]].merge(direction: sort[:direction])
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,4 @@
1
+ class GraphQL::SortDirection < GraphQL::Schema::Enum
2
+ value :ASCENDING, value: :asc
3
+ value :DESCENDING, value: :desc
4
+ end