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.
- 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
|