graphql-fancy_loader 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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