graphql_declarative 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/.rspec +3 -0
- data/.standard.yml +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +225 -0
- data/Rakefile +10 -0
- data/SPEC.md +336 -0
- data/bench/query_count.rb +77 -0
- data/gemfiles/ar_7.0.gemfile +10 -0
- data/gemfiles/ar_7.1.gemfile +7 -0
- data/gemfiles/ar_7.2.gemfile +7 -0
- data/gemfiles/ar_8.0.gemfile +7 -0
- data/lib/graphql_declarative/cursor.rb +174 -0
- data/lib/graphql_declarative/filter.rb +151 -0
- data/lib/graphql_declarative/filter_input.rb +120 -0
- data/lib/graphql_declarative/keyset_connection.rb +182 -0
- data/lib/graphql_declarative/preloader.rb +158 -0
- data/lib/graphql_declarative/resolver.rb +426 -0
- data/lib/graphql_declarative/sort.rb +196 -0
- data/lib/graphql_declarative/version.rb +5 -0
- data/lib/graphql_declarative.rb +16 -0
- data/sig/graphql_declarative.rbs +4 -0
- metadata +116 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# AR <= 7.0 requires sqlite3 ~> 1.4, so this file declares dependencies directly
|
|
4
|
+
# instead of using `gemspec`, whose dev dependency pins sqlite3 ~> 2.0.
|
|
5
|
+
source "https://rubygems.org"
|
|
6
|
+
gem "graphql_declarative", path: ".."
|
|
7
|
+
gem "activerecord", "~> 7.0.0"
|
|
8
|
+
gem "sqlite3", "~> 1.4"
|
|
9
|
+
gem "rake", "~> 13.0"
|
|
10
|
+
gem "rspec", "~> 3.13"
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "bigdecimal"
|
|
5
|
+
require "date"
|
|
6
|
+
require "json"
|
|
7
|
+
require "time"
|
|
8
|
+
|
|
9
|
+
module GraphqlDeclarative
|
|
10
|
+
# Opaque keyset cursors. Encode the tuple (sort_value, id) — never the offset,
|
|
11
|
+
# and never the sort value alone, or ties silently drop records.
|
|
12
|
+
#
|
|
13
|
+
# Cursor.encode(sort_value: "Ruby 101", id: 42)
|
|
14
|
+
# Cursor.decode("eyJzIjoiUnVieSAxMDEiLCJpZCI6NDJ9")
|
|
15
|
+
#
|
|
16
|
+
# Seek predicate for ASC: (sort_col, id) > (sort_value, id)
|
|
17
|
+
# Emulate row-value comparison where the adapter lacks it:
|
|
18
|
+
# sort_col > :s OR (sort_col = :s AND id > :id)
|
|
19
|
+
#
|
|
20
|
+
# The payload is JSON `{"v" => sort_value, "i" => id}` in urlsafe, unpadded
|
|
21
|
+
# Base64. That encoding is an implementation detail: it is opaque to clients
|
|
22
|
+
# by contract and is not documented as stable.
|
|
23
|
+
class Cursor
|
|
24
|
+
KEY_VALUE = "v"
|
|
25
|
+
KEY_ID = "i"
|
|
26
|
+
|
|
27
|
+
# Fractional-second digits kept when a Time is serialised. A datetime that
|
|
28
|
+
# round-trips through `to_s` loses everything after the second, and two rows
|
|
29
|
+
# created in the same second then compare equal to the cursor: the seek
|
|
30
|
+
# `created_at > :v` drops the second one, or `>=` would repeat the first.
|
|
31
|
+
# Nine digits is more than any supported adapter stores.
|
|
32
|
+
TIME_PRECISION = 9
|
|
33
|
+
|
|
34
|
+
# @param sort_value [Object] the value of the sort column for this row
|
|
35
|
+
# @param id [Object] the row's primary key
|
|
36
|
+
# @return [String] urlsafe, unpadded Base64
|
|
37
|
+
def self.encode(sort_value:, id:)
|
|
38
|
+
payload = {KEY_VALUE => serialize(sort_value), KEY_ID => id}
|
|
39
|
+
Base64.urlsafe_encode64(JSON.generate(payload), padding: false)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param str [String] a cursor produced by .encode
|
|
43
|
+
# @param type [ActiveModel::Type::Value, Symbol, nil] the type of the sort
|
|
44
|
+
# column, used to cast the decoded value back. Pass
|
|
45
|
+
# `model.type_for_attribute(sort_column)` — without it a datetime comes
|
|
46
|
+
# back as the ISO8601 String it was encoded as, and comparing a String
|
|
47
|
+
# against a datetime column is adapter-dependent nonsense.
|
|
48
|
+
# @return [Hash] {sort_value:, id:}
|
|
49
|
+
# @raise [Error] on anything malformed. A bad cursor is never silently
|
|
50
|
+
# treated as "start from the beginning": the client would be handed page 1
|
|
51
|
+
# while believing it was on page 7, and would never notice.
|
|
52
|
+
def self.decode(str, type: nil)
|
|
53
|
+
raise Error, "cursor is missing" if str.nil? || str.to_s.empty?
|
|
54
|
+
|
|
55
|
+
payload = parse(str)
|
|
56
|
+
unless payload.is_a?(Hash) && payload.key?(KEY_VALUE) && payload.key?(KEY_ID)
|
|
57
|
+
raise Error, "malformed cursor: expected keys #{KEY_VALUE.inspect} and #{KEY_ID.inspect}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
id = payload[KEY_ID]
|
|
61
|
+
raise Error, "malformed cursor: missing id" if id.nil?
|
|
62
|
+
|
|
63
|
+
{sort_value: cast(payload[KEY_VALUE], type), id: id}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Builds the seek predicate. Portable form, because SQLite's row-value
|
|
67
|
+
# support is version-dependent and MySQL's optimiser treats row-value
|
|
68
|
+
# comparisons differently again:
|
|
69
|
+
#
|
|
70
|
+
# ASC: sort_col > :v OR (sort_col = :v AND id > :i)
|
|
71
|
+
# DESC: sort_col < :v OR (sort_col = :v AND id < :i)
|
|
72
|
+
#
|
|
73
|
+
# Both halves flip together for DESC — the tiebreaker has to run the same
|
|
74
|
+
# way as the sort column or the tied rows come back in the wrong order and
|
|
75
|
+
# the page walks backwards through them.
|
|
76
|
+
#
|
|
77
|
+
# @param scope [ActiveRecord::Relation, Class]
|
|
78
|
+
# @param column [Symbol, String] sort column, from the `sortable_by`
|
|
79
|
+
# whitelist only — never from user input (SPEC.md §7).
|
|
80
|
+
# @param direction [Symbol] :asc or :desc
|
|
81
|
+
# @param sort_value [Object] value half of the cursor
|
|
82
|
+
# @param id [Object] id half of the cursor
|
|
83
|
+
# @return [ActiveRecord::Relation]
|
|
84
|
+
def self.seek(scope, column:, direction:, sort_value:, id:)
|
|
85
|
+
direction = direction.to_s.downcase.to_sym
|
|
86
|
+
unless %i[asc desc].include?(direction)
|
|
87
|
+
raise Error, "seek direction must be :asc or :desc, got #{direction.inspect}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
model = scope.respond_to?(:klass) ? scope.klass : scope
|
|
91
|
+
column = column.to_sym
|
|
92
|
+
unless model.column_names.include?(column.to_s)
|
|
93
|
+
raise Error, "cannot seek on #{column.inspect}: #{model.name} has no such column"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if sort_value.nil?
|
|
97
|
+
# NULL never compares true, so every row after this one would be lost.
|
|
98
|
+
# v0.1.0 requires sortable columns to be NOT NULL (SPEC.md §6.4); this
|
|
99
|
+
# is where that requirement stops being silent.
|
|
100
|
+
raise Error,
|
|
101
|
+
"cannot seek on a NULL #{column} value: keyset pagination requires the sort column " \
|
|
102
|
+
"to be NOT NULL in v0.1.0 (a NULL never compares true, so the remaining rows vanish)."
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
table = model.arel_table
|
|
106
|
+
sort_col = table[column]
|
|
107
|
+
id_col = table[model.primary_key]
|
|
108
|
+
|
|
109
|
+
value_bind = bind(model, column, sort_value)
|
|
110
|
+
id_bind = bind(model, model.primary_key, id)
|
|
111
|
+
|
|
112
|
+
tie = ->(node) { Arel::Nodes::Grouping.new(sort_col.eq(value_bind).and(node)) }
|
|
113
|
+
|
|
114
|
+
predicate =
|
|
115
|
+
if direction == :asc
|
|
116
|
+
sort_col.gt(value_bind).or(tie.call(id_col.gt(id_bind)))
|
|
117
|
+
else
|
|
118
|
+
sort_col.lt(value_bind).or(tie.call(id_col.lt(id_bind)))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
scope.where(predicate)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Values are bind parameters, always — the column half of the predicate is
|
|
125
|
+
# an Arel attribute built from a whitelisted name, and the value half never
|
|
126
|
+
# reaches the SQL string. The QueryAttribute also type-casts, so an ISO8601
|
|
127
|
+
# String from a cursor is written to the wire exactly the way the column's
|
|
128
|
+
# own value was.
|
|
129
|
+
def self.bind(model, column, value)
|
|
130
|
+
type = model.type_for_attribute(column.to_s)
|
|
131
|
+
attribute = ActiveRecord::Relation::QueryAttribute.new(column.to_s, value, type)
|
|
132
|
+
Arel::Nodes::BindParam.new(attribute)
|
|
133
|
+
end
|
|
134
|
+
private_class_method :bind
|
|
135
|
+
|
|
136
|
+
def self.parse(str)
|
|
137
|
+
json = Base64.urlsafe_decode64(str.to_s)
|
|
138
|
+
JSON.parse(json)
|
|
139
|
+
rescue ArgumentError, JSON::ParserError => e
|
|
140
|
+
raise Error, "malformed cursor: #{e.message}"
|
|
141
|
+
end
|
|
142
|
+
private_class_method :parse
|
|
143
|
+
|
|
144
|
+
# Times are serialised at full precision rather than left to JSON, which
|
|
145
|
+
# would call #to_s and truncate to the second. BigDecimal likewise: JSON
|
|
146
|
+
# renders it as a String already, but "0.1e2" is not what comes back out of
|
|
147
|
+
# a decimal column.
|
|
148
|
+
def self.serialize(value)
|
|
149
|
+
case value
|
|
150
|
+
when Time, DateTime
|
|
151
|
+
value.to_time.iso8601(TIME_PRECISION)
|
|
152
|
+
when Date
|
|
153
|
+
value.iso8601
|
|
154
|
+
when BigDecimal
|
|
155
|
+
value.to_s("F")
|
|
156
|
+
else
|
|
157
|
+
value
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
private_class_method :serialize
|
|
161
|
+
|
|
162
|
+
# `type` may be an ActiveModel type object (anything responding to #cast) or
|
|
163
|
+
# a symbol looked up in ActiveRecord's type registry.
|
|
164
|
+
def self.cast(value, type)
|
|
165
|
+
return value if value.nil? || type.nil?
|
|
166
|
+
return type.cast(value) if type.respond_to?(:cast)
|
|
167
|
+
|
|
168
|
+
ActiveRecord::Type.lookup(type.to_sym).cast(value)
|
|
169
|
+
rescue ArgumentError, TypeError => e
|
|
170
|
+
raise Error, "malformed cursor: cannot cast #{value.inspect} (#{e.message})"
|
|
171
|
+
end
|
|
172
|
+
private_class_method :cast
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphqlDeclarative
|
|
4
|
+
# Applies a FilterInput's arguments to an ActiveRecord scope.
|
|
5
|
+
#
|
|
6
|
+
# THE IMPORTANT PART — see spec/pagination_through_associations_spec.rb.
|
|
7
|
+
# Filters declared with `through:` MUST NOT be applied as a join on the scope
|
|
8
|
+
# being paginated. Resolve them to an id subquery instead:
|
|
9
|
+
#
|
|
10
|
+
# ids = model.joins(through).where(assoc_table => {column => value}).select(:id)
|
|
11
|
+
# scope.where(id: ids)
|
|
12
|
+
#
|
|
13
|
+
# The base relation then stays un-joined, so LIMIT, ORDER BY and cursors all
|
|
14
|
+
# remain correct. Two `through:` filters on the same association must
|
|
15
|
+
# intersect within ONE subquery, not chain two (that changes the meaning from
|
|
16
|
+
# "one child matching both" to "children matching each").
|
|
17
|
+
#
|
|
18
|
+
# Chosen semantic, stated once so it is not rediscovered by accident:
|
|
19
|
+
# ONE CHILD ROW MUST SATISFY ALL PREDICATES DECLARED FOR THAT ASSOCIATION.
|
|
20
|
+
class Filter
|
|
21
|
+
# Ops that compare with a LIKE pattern. The pattern is built here, from a
|
|
22
|
+
# sanitized value, and passed as a bound/quoted node — never spliced into
|
|
23
|
+
# SQL text.
|
|
24
|
+
LIKE_PATTERNS = {
|
|
25
|
+
contains: ->(v) { "%#{v}%" },
|
|
26
|
+
starts_with: ->(v) { "#{v}%" },
|
|
27
|
+
ends_with: ->(v) { "%#{v}" }
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# LIKE's own metacharacters are escaped so a user-supplied "100%" means the
|
|
31
|
+
# literal string, not "100 followed by anything". `ESCAPE '\'` is emitted
|
|
32
|
+
# explicitly because only MySQL assumes backslash by default.
|
|
33
|
+
LIKE_ESCAPE = "\\"
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# scope - ActiveRecord::Relation (or a model class)
|
|
37
|
+
# filter_class - a GraphqlDeclarative::FilterInput subclass
|
|
38
|
+
# args - the `filter:` input, as a Hash with symbol keys, a
|
|
39
|
+
# GraphQL::Schema::InputObject, or nil
|
|
40
|
+
def apply(scope, filter_class, args)
|
|
41
|
+
args = normalize_args(args)
|
|
42
|
+
relation = scope.respond_to?(:all) ? scope.all : scope
|
|
43
|
+
return relation if args.empty?
|
|
44
|
+
|
|
45
|
+
model = relation.klass
|
|
46
|
+
index = argument_index(filter_class)
|
|
47
|
+
|
|
48
|
+
direct = []
|
|
49
|
+
# Keyed by association name so that every predicate on one association
|
|
50
|
+
# ends up in the SAME subquery. This grouping IS the semantic.
|
|
51
|
+
through = Hash.new { |h, k| h[k] = [] }
|
|
52
|
+
|
|
53
|
+
args.each do |key, value|
|
|
54
|
+
definition, op = index[key.to_sym]
|
|
55
|
+
unless definition
|
|
56
|
+
raise Error, "unknown filter argument #{key.inspect} for #{filter_class}; " \
|
|
57
|
+
"known arguments: #{index.keys.sort.inspect}"
|
|
58
|
+
end
|
|
59
|
+
# A key that was not supplied is absent from the hash; an explicit nil
|
|
60
|
+
# is treated as "not filtering on this", not as `IS NULL`.
|
|
61
|
+
next if value.nil?
|
|
62
|
+
|
|
63
|
+
(definition.through ? through[definition.through] : direct) << [definition, op, value]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
relation = apply_direct(relation, model, direct)
|
|
67
|
+
apply_through(relation, model, through)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def normalize_args(args)
|
|
73
|
+
return {} if args.nil?
|
|
74
|
+
return args if args.is_a?(Hash)
|
|
75
|
+
return args.to_h if args.respond_to?(:to_h)
|
|
76
|
+
|
|
77
|
+
raise Error, "filter args must be a Hash or nil, got #{args.class}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# {argument_name => [Definition, op]} — the reverse of the naming table in
|
|
81
|
+
# SPEC.md section 4. Built from `definitions` only, so every column and
|
|
82
|
+
# association identifier below is declaration-derived.
|
|
83
|
+
def argument_index(filter_class)
|
|
84
|
+
unless filter_class.respond_to?(:definitions)
|
|
85
|
+
raise Error, "#{filter_class} is not a GraphqlDeclarative::FilterInput"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
filter_class.definitions.each_with_object({}) do |(name, definition), index|
|
|
89
|
+
definition.ops.each do |op|
|
|
90
|
+
index[FilterInput.argument_name_for(name, op)] = [definition, op]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def apply_direct(relation, model, entries)
|
|
96
|
+
entries.reduce(relation) do |rel, (definition, op, value)|
|
|
97
|
+
rel.where(predicate(model.arel_table, definition.column, op, value))
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# One subquery per association, never a join on `relation` itself.
|
|
102
|
+
def apply_through(relation, model, grouped)
|
|
103
|
+
grouped.reduce(relation) do |rel, (association, entries)|
|
|
104
|
+
reflection = model.reflect_on_association(association)
|
|
105
|
+
unless reflection
|
|
106
|
+
raise Error, "#{model} has no association #{association.inspect} " \
|
|
107
|
+
"(declared as `through:` on filter #{entries.first[0].name.inspect})"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
assoc_table = reflection.klass.arel_table
|
|
111
|
+
|
|
112
|
+
# Build the subquery on the bare model. It may be joined freely: it
|
|
113
|
+
# projects ids, so duplicate rows collapse inside `IN (...)` and never
|
|
114
|
+
# reach the paginated relation.
|
|
115
|
+
subquery = entries.reduce(model.joins(association)) do |sub, (definition, op, value)|
|
|
116
|
+
sub.where(predicate(assoc_table, definition.column, op, value))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
key = model.primary_key
|
|
120
|
+
rel.where(key => subquery.select(model.arel_table[key]))
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Column identifiers come from the Definition; values go through Arel's
|
|
125
|
+
# quoted/typecast nodes. There is no string interpolation of SQL here.
|
|
126
|
+
def predicate(table, column, op, value)
|
|
127
|
+
attribute = table[column]
|
|
128
|
+
|
|
129
|
+
case op
|
|
130
|
+
when :eq then attribute.eq(value)
|
|
131
|
+
when :in then attribute.in(Array(value))
|
|
132
|
+
when :gt then attribute.gt(value)
|
|
133
|
+
when :gte then attribute.gteq(value)
|
|
134
|
+
when :lt then attribute.lt(value)
|
|
135
|
+
when :lte then attribute.lteq(value)
|
|
136
|
+
when :contains, :starts_with, :ends_with
|
|
137
|
+
pattern = LIKE_PATTERNS.fetch(op).call(sanitize_like(value))
|
|
138
|
+
# case_sensitive: true keeps this a plain LIKE on PostgreSQL too,
|
|
139
|
+
# instead of Arel's default ILIKE.
|
|
140
|
+
attribute.matches(pattern, Arel::Nodes.build_quoted(LIKE_ESCAPE), true)
|
|
141
|
+
else
|
|
142
|
+
raise Error, "unsupported filter op #{op.inspect}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def sanitize_like(value)
|
|
147
|
+
ActiveRecord::Base.sanitize_sql_like(value.to_s, LIKE_ESCAPE)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphqlDeclarative
|
|
4
|
+
# A GraphQL::Schema::InputObject built from `filter` declarations.
|
|
5
|
+
#
|
|
6
|
+
# class Types::CourseFilter < GraphqlDeclarative::FilterInput
|
|
7
|
+
# filter :title, :string, ops: [:eq, :contains, :starts_with]
|
|
8
|
+
# filter :published, :boolean
|
|
9
|
+
# filter :created_at, :datetime, ops: [:gte, :lte]
|
|
10
|
+
# filter :author_name, :string, through: :author, column: :name
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# Each (name, op) pair generates one argument: :title_contains, :created_at_gte.
|
|
14
|
+
# `:eq` generates the bare name (:title), not :title_eq.
|
|
15
|
+
#
|
|
16
|
+
# The registry (`definitions`) is what `Filter.apply` reads back at request
|
|
17
|
+
# time. It is the ONLY source of column and association identifiers — nothing
|
|
18
|
+
# in the SQL layer is ever derived from user input. See SPEC.md section 7.
|
|
19
|
+
class FilterInput < GraphQL::Schema::InputObject
|
|
20
|
+
DEFAULT_OPS = {
|
|
21
|
+
string: %i[eq contains starts_with ends_with in],
|
|
22
|
+
integer: %i[eq gt gte lt lte in],
|
|
23
|
+
float: %i[eq gt gte lt lte],
|
|
24
|
+
boolean: %i[eq],
|
|
25
|
+
datetime: %i[eq gt gte lt lte]
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# The GraphQL scalar each declared type maps to. `:in` wraps this in a list
|
|
29
|
+
# and the LIKE ops (contains/starts_with/ends_with) override it to String.
|
|
30
|
+
GRAPHQL_TYPES = {
|
|
31
|
+
string: GraphQL::Types::String,
|
|
32
|
+
integer: GraphQL::Types::Int,
|
|
33
|
+
float: GraphQL::Types::Float,
|
|
34
|
+
boolean: GraphQL::Types::Boolean,
|
|
35
|
+
datetime: GraphQL::Types::ISO8601DateTime
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
Definition = Struct.new(:name, :type, :ops, :through, :column, keyword_init: true)
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# Declare one filterable attribute and generate one argument per op.
|
|
42
|
+
#
|
|
43
|
+
# Everything here fails loudly at class-definition time (SPEC.md 6.1):
|
|
44
|
+
# an unknown type or an op that makes no sense for the type is a boot
|
|
45
|
+
# error, never a per-request surprise.
|
|
46
|
+
def filter(name, type, ops: nil, through: nil, column: nil)
|
|
47
|
+
name = name.to_sym
|
|
48
|
+
type = type.to_sym
|
|
49
|
+
|
|
50
|
+
valid_ops = DEFAULT_OPS[type]
|
|
51
|
+
unless valid_ops
|
|
52
|
+
raise Error, "unknown filter type #{type.inspect} for filter #{name.inspect}; " \
|
|
53
|
+
"expected one of #{DEFAULT_OPS.keys.inspect}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
ops = Array(ops || valid_ops).map(&:to_sym)
|
|
57
|
+
raise Error, "filter #{name.inspect} declares an empty ops list" if ops.empty?
|
|
58
|
+
|
|
59
|
+
invalid = ops - valid_ops
|
|
60
|
+
unless invalid.empty?
|
|
61
|
+
raise Error, "invalid op(s) #{invalid.inspect} for #{type} filter #{name.inspect}; " \
|
|
62
|
+
"valid ops for #{type} are #{valid_ops.inspect}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
definition = Definition.new(
|
|
66
|
+
name: name,
|
|
67
|
+
type: type,
|
|
68
|
+
ops: ops.freeze,
|
|
69
|
+
through: through&.to_sym,
|
|
70
|
+
# `column:` defaults to the filter name; for a `through:` filter it is
|
|
71
|
+
# the column on the ASSOCIATION's table, not on the base model.
|
|
72
|
+
column: (column || name).to_sym
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
own_definitions[name] = definition
|
|
76
|
+
|
|
77
|
+
ops.each do |op|
|
|
78
|
+
argument(argument_name_for(name, op), graphql_type_for(type, op), required: false)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
definition
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# {Symbol => Definition}. Inheritance-safe: walking `superclass` on every
|
|
85
|
+
# read means a subclass sees its parent's declarations without ever
|
|
86
|
+
# holding (or mutating) the parent's hash. Adding a filter to the parent
|
|
87
|
+
# after the subclass exists is picked up too.
|
|
88
|
+
def definitions
|
|
89
|
+
if superclass.respond_to?(:definitions)
|
|
90
|
+
superclass.definitions.merge(own_definitions)
|
|
91
|
+
else
|
|
92
|
+
own_definitions.dup
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Declarations made directly on this class, excluding inherited ones.
|
|
97
|
+
def own_definitions
|
|
98
|
+
@own_definitions ||= {}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The argument naming table from SPEC.md section 4. `:eq` is the bare
|
|
102
|
+
# name — `title`, never `title_eq`.
|
|
103
|
+
def argument_name_for(name, op)
|
|
104
|
+
(op.to_sym == :eq) ? name.to_sym : :"#{name}_#{op}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def graphql_type_for(type, op)
|
|
110
|
+
base =
|
|
111
|
+
case op
|
|
112
|
+
when :contains, :starts_with, :ends_with then GraphQL::Types::String
|
|
113
|
+
else GRAPHQL_TYPES.fetch(type)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
(op == :in) ? [base] : base
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphqlDeclarative
|
|
4
|
+
# Returned directly from Resolver#resolve — graphql-ruby uses a Connection
|
|
5
|
+
# instance as-is instead of re-wrapping it.
|
|
6
|
+
#
|
|
7
|
+
# Unlike GraphQL::Pagination::RelationConnection, whose #cursor_for encodes an
|
|
8
|
+
# OFFSET (relation_connection.rb:47), this encodes the key tuple
|
|
9
|
+
# (sort_value, id). Offset cursors shift whenever a row is inserted or deleted
|
|
10
|
+
# before the current page, so pages repeat or skip records. See SPEC.md §6.5
|
|
11
|
+
# and spec/stability_spec.rb.
|
|
12
|
+
#
|
|
13
|
+
# has_next_page comes from fetching page_size + 1 rows, never from COUNT.
|
|
14
|
+
# has_previous_page is always false in v0.1.0 (forward-only). Documented, not hidden.
|
|
15
|
+
#
|
|
16
|
+
# Construction (see SPEC.md §5 for where this sits in the pipeline):
|
|
17
|
+
#
|
|
18
|
+
# KeysetConnection.new(
|
|
19
|
+
# scope, # filtered + sorted + seeked, NOT limited
|
|
20
|
+
# sort_column: :created_at, # the column Sort ordered by; :id by default
|
|
21
|
+
# sort_direction: :asc, # recorded for callers; not used to query here
|
|
22
|
+
# preloader: ->(records) { GraphqlDeclarative::Preloader... },
|
|
23
|
+
# first: args[:first], after: args[:after], context: context,
|
|
24
|
+
# default_page_size: 25, max_page_size: 100
|
|
25
|
+
# )
|
|
26
|
+
#
|
|
27
|
+
# The `after:` seek is applied by the caller *before* the relation gets here:
|
|
28
|
+
# the seek predicate belongs with Sort (SPEC.md §5, invariant 2). This class
|
|
29
|
+
# never re-applies it.
|
|
30
|
+
#
|
|
31
|
+
# `items` may be either:
|
|
32
|
+
# * an ActiveRecord::Relation — this class applies LIMIT page_size + 1,
|
|
33
|
+
# loads it, trims to page_size, and only then runs `preloader`. That order
|
|
34
|
+
# is invariant 1 in SPEC.md §5: preloading an unbounded relation preloads
|
|
35
|
+
# the whole filtered set.
|
|
36
|
+
# * an Array the caller already bounded to page_size + 1 rows (use
|
|
37
|
+
# .page_size_for to compute the same limit) — it is trimmed here, and
|
|
38
|
+
# `preloader` is still applied if given.
|
|
39
|
+
class KeysetConnection < GraphQL::Pagination::Connection
|
|
40
|
+
# Fallbacks used only when neither an explicit override nor a schema-level
|
|
41
|
+
# setting is available (e.g. a connection built outside a query).
|
|
42
|
+
DEFAULT_PAGE_SIZE = 25
|
|
43
|
+
MAX_PAGE_SIZE = 100
|
|
44
|
+
|
|
45
|
+
# @return [Symbol] the column the relation is ordered by; also the value
|
|
46
|
+
# half of every cursor this connection issues.
|
|
47
|
+
attr_reader :sort_column
|
|
48
|
+
|
|
49
|
+
# @return [Symbol] :asc or :desc — the direction the relation was sorted in.
|
|
50
|
+
# Carried so a caller can round-trip it; the seek itself happens upstream.
|
|
51
|
+
attr_reader :sort_direction
|
|
52
|
+
|
|
53
|
+
def initialize(items, sort_column: :id, sort_direction: :asc, preloader: nil, **kwargs)
|
|
54
|
+
@sort_column = sort_column.to_sym
|
|
55
|
+
@sort_direction = sort_direction.to_sym
|
|
56
|
+
@preloader = preloader
|
|
57
|
+
super(items, **kwargs)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The limit a caller must use if it wants to fetch the page itself:
|
|
61
|
+
# page_size + 1 rows, where the +1 is what makes has_next_page free.
|
|
62
|
+
#
|
|
63
|
+
# `first: 0` returns an empty page (Relay-conformant); only a negative
|
|
64
|
+
# `first:` is an error. The subtlety this class must not repeat: clamping a
|
|
65
|
+
# negative to 0 would let `load_page` fetch 1 row, report `has_next_page:
|
|
66
|
+
# true` off it, and trim `nodes` to `[]` — a page with no rows and no
|
|
67
|
+
# cursor, since `endCursor` is nil when `nodes` is empty. graphql-ruby's own
|
|
68
|
+
# RelationConnection avoids the whole question because `Connection#first`
|
|
69
|
+
# clamps through `limit_pagination_argument`; this class bypasses that by
|
|
70
|
+
# reading `first_value` (the raw, unclamped value) directly, so it validates
|
|
71
|
+
# the bound itself.
|
|
72
|
+
def self.page_size_for(first: nil, default_page_size: nil, max_page_size: nil)
|
|
73
|
+
# The Relay Cursor Connections spec treats first: 0 as valid — an empty
|
|
74
|
+
# page — and only a negative value as an error, so this returns 0 rather
|
|
75
|
+
# than raising. Note what that means: load_page fetches page_size + 1 == 1
|
|
76
|
+
# row, so hasNextPage is true whenever any row matches, with no endCursor
|
|
77
|
+
# to advance from. That is Relay-conformant, and a client that loops on
|
|
78
|
+
# hasNextPage while asking for first: 0 will not progress — but that is
|
|
79
|
+
# the client asking for no rows, not the connection misreporting.
|
|
80
|
+
if first&.negative?
|
|
81
|
+
raise GraphQL::ExecutionError,
|
|
82
|
+
"first: must not be negative (got #{first.inspect}). Omit first: to use the default " \
|
|
83
|
+
"page size."
|
|
84
|
+
end
|
|
85
|
+
return 0 if first == 0
|
|
86
|
+
|
|
87
|
+
requested = first || default_page_size || DEFAULT_PAGE_SIZE
|
|
88
|
+
max = max_page_size || MAX_PAGE_SIZE
|
|
89
|
+
[requested, max].min
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# [first || default_page_size, max_page_size].min. A `first:` above
|
|
93
|
+
# max_page_size is clamped, not an error (SPEC.md §6.5).
|
|
94
|
+
def page_size
|
|
95
|
+
@page_size ||= self.class.page_size_for(
|
|
96
|
+
first: first_value,
|
|
97
|
+
default_page_size: configured_default_page_size,
|
|
98
|
+
max_page_size: configured_max_page_size
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def nodes
|
|
103
|
+
load_page
|
|
104
|
+
@nodes
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# True iff the LIMIT page_size + 1 query came back with the extra row.
|
|
108
|
+
# No COUNT — an unbounded count on a filtered set is the thing this avoids.
|
|
109
|
+
def has_next_page # rubocop:disable Naming/PredicateName
|
|
110
|
+
load_page
|
|
111
|
+
@has_next_page
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Forward-only in v0.1.0 (SPEC.md §3). Stated, not hidden: a client that
|
|
115
|
+
# walks forward never needs it, and honouring it would double the cursor
|
|
116
|
+
# logic for `last:`/`before:`.
|
|
117
|
+
def has_previous_page # rubocop:disable Naming/PredicateName
|
|
118
|
+
false
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The whole point of the class. RelationConnection encodes an offset here;
|
|
122
|
+
# this encodes the key tuple, so the cursor keeps meaning the same row even
|
|
123
|
+
# when rows are inserted or deleted before the current page.
|
|
124
|
+
def cursor_for(item)
|
|
125
|
+
Cursor.encode(sort_value: sort_value_for(item), id: item.id)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def load_page
|
|
131
|
+
return if defined?(@nodes)
|
|
132
|
+
|
|
133
|
+
fetched = fetch(page_size + 1)
|
|
134
|
+
@has_next_page = fetched.size > page_size
|
|
135
|
+
# first(page_size) also copes with a caller that handed us a longer array.
|
|
136
|
+
@nodes = @has_next_page ? fetched.first(page_size) : fetched
|
|
137
|
+
apply_preloader(@nodes)
|
|
138
|
+
@nodes
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# LIMIT page_size + 1. The extra row is never returned; its existence is the
|
|
142
|
+
# has_next_page answer.
|
|
143
|
+
def fetch(limit)
|
|
144
|
+
if items.is_a?(Array)
|
|
145
|
+
items.first(limit)
|
|
146
|
+
elsif items.respond_to?(:limit)
|
|
147
|
+
items.limit(limit).to_a
|
|
148
|
+
else
|
|
149
|
+
items.first(limit).to_a
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Preloading runs here — after the page is bounded — never on `items`.
|
|
154
|
+
def apply_preloader(records)
|
|
155
|
+
return records if @preloader.nil? || records.empty?
|
|
156
|
+
|
|
157
|
+
@preloader.call(records)
|
|
158
|
+
records
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def sort_value_for(item)
|
|
162
|
+
if item.respond_to?(:[])
|
|
163
|
+
item[sort_column]
|
|
164
|
+
else
|
|
165
|
+
item.public_send(sort_column)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The base class reads default/max page size off `context.schema` when no
|
|
170
|
+
# override was given; `context` is nil for a connection built outside a
|
|
171
|
+
# query, so fall back to the documented defaults instead of blowing up.
|
|
172
|
+
def configured_default_page_size
|
|
173
|
+
value = default_page_size if has_default_page_size_override? || context
|
|
174
|
+
value || DEFAULT_PAGE_SIZE
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def configured_max_page_size
|
|
178
|
+
value = max_page_size if has_max_page_size_override? || context
|
|
179
|
+
value || MAX_PAGE_SIZE
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|