graphql-fancy_loader 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.editorconfig +15 -0
- data/.github/workflows/release.yml +38 -0
- data/.github/workflows/test.yml +62 -0
- data/.gitignore +14 -0
- data/.rspec +3 -0
- data/.travis.yml +19 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +230 -0
- data/LICENSE +201 -0
- data/README.md +215 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/docs/sample-schema.png +0 -0
- data/docs/sample-schema.svg +91 -0
- data/graphql-fancy_loader.gemspec +50 -0
- data/lib/graphql/fancy_connection.rb +103 -0
- data/lib/graphql/fancy_loader/dsl.rb +31 -0
- data/lib/graphql/fancy_loader/pagination_filter.rb +64 -0
- data/lib/graphql/fancy_loader/pundit_middleware.rb +13 -0
- data/lib/graphql/fancy_loader/query_generator.rb +125 -0
- data/lib/graphql/fancy_loader/rank_query_generator.rb +40 -0
- data/lib/graphql/fancy_loader/type_generator.rb +42 -0
- data/lib/graphql/fancy_loader/version.rb +5 -0
- data/lib/graphql/fancy_loader.rb +103 -0
- data/lib/graphql/sort_direction.rb +4 -0
- metadata +315 -0
@@ -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,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
|