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,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphqlDeclarative
|
|
4
|
+
# Whitelisted ordering. Never interpolate a client-supplied column name.
|
|
5
|
+
# Always append the model's primary key as the final tiebreaker so ordering
|
|
6
|
+
# is total — a non-total order makes keyset pagination non-deterministic.
|
|
7
|
+
#
|
|
8
|
+
# Sort.apply(Course.all, allowed: [:title, :created_at], field: "title", direction: :desc)
|
|
9
|
+
# # => ORDER BY "courses"."title" DESC, "courses"."id" DESC
|
|
10
|
+
#
|
|
11
|
+
# Two rows with the same `title` would otherwise come back in whatever order
|
|
12
|
+
# the adapter felt like this time. A cursor built from (title, id) cannot tell
|
|
13
|
+
# them apart if the primary key is not part of the ORDER BY, so page 2 either
|
|
14
|
+
# repeats or skips the tied rows. The tiebreaker goes in the *same* direction
|
|
15
|
+
# as the sort column, because Cursor.seek compares both halves in that
|
|
16
|
+
# direction.
|
|
17
|
+
#
|
|
18
|
+
# The tiebreaker is always `model.primary_key`, not a hardcoded `:id`. Sort
|
|
19
|
+
# and Cursor derive it from the same source (`ActiveRecord::Base#primary_key`)
|
|
20
|
+
# for exactly this reason: if Sort ordered by one column and Cursor seeked on
|
|
21
|
+
# another, the total order the keyset depends on would not exist. A model
|
|
22
|
+
# whose primary key is not "id" (e.g. a `uuid` column) would then get an
|
|
23
|
+
# ORDER BY and a WHERE clause built from different columns — on Postgres/MySQL
|
|
24
|
+
# a hard UndefinedColumn error, on SQLite a silent, wrong fallback to rowid.
|
|
25
|
+
#
|
|
26
|
+
# Identifier safety (SPEC.md §7): `field` is only ever used after it has been
|
|
27
|
+
# matched against `allowed` — the value that reaches ActiveRecord is the
|
|
28
|
+
# canonical name from the whitelist, never the caller's string. Ordering is
|
|
29
|
+
# expressed as a Hash (`order(title: :asc)`) so ActiveRecord quotes the
|
|
30
|
+
# identifier itself; there is no Arel.sql on an interpolated string here.
|
|
31
|
+
class Sort
|
|
32
|
+
DIRECTIONS = %i[asc desc].freeze
|
|
33
|
+
|
|
34
|
+
# @param scope [ActiveRecord::Relation, Class] relation (or model) to order
|
|
35
|
+
# @param allowed [Array<Symbol, String, #name>] the `sortable_by` whitelist.
|
|
36
|
+
# Entries may be plain names or FilterInput::Definition structs; a
|
|
37
|
+
# definition carrying `through:` is rejected (see below).
|
|
38
|
+
# @param field [Symbol, String, nil] requested sort field. `nil` means "no
|
|
39
|
+
# sort declared" and orders by the primary key, which SPEC.md §6.7 makes
|
|
40
|
+
# the default.
|
|
41
|
+
# @param direction [Symbol, String] :asc or :desc
|
|
42
|
+
# @return [ActiveRecord::Relation]
|
|
43
|
+
def self.apply(scope, allowed:, field: nil, direction: :asc)
|
|
44
|
+
direction = normalize_direction(direction)
|
|
45
|
+
model = model_for(scope)
|
|
46
|
+
tiebreaker = primary_key_for(model)
|
|
47
|
+
field = resolve_field(model, allowed, field, tiebreaker)
|
|
48
|
+
|
|
49
|
+
# reorder, not order: the declared sort is authoritative. Appending to a
|
|
50
|
+
# default_scope's ORDER BY would leave some other column as the leading
|
|
51
|
+
# term, and the seek predicate is built from *our* leading term.
|
|
52
|
+
return scope.reorder(tiebreaker => direction) if field == tiebreaker
|
|
53
|
+
|
|
54
|
+
scope.reorder(field => direction).order(tiebreaker => direction)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Symbol] the column that was actually ordered by — the caller
|
|
58
|
+
# needs it to build cursors, and it may differ from `field` (nil => the
|
|
59
|
+
# model's primary key).
|
|
60
|
+
def self.column_for(scope, allowed:, field: nil)
|
|
61
|
+
model = model_for(scope)
|
|
62
|
+
resolve_field(model, allowed, field, primary_key_for(model))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.normalize_direction(direction)
|
|
66
|
+
normalized = direction.to_s.downcase.to_sym
|
|
67
|
+
unless DIRECTIONS.include?(normalized)
|
|
68
|
+
raise Error, "sort direction must be one of #{DIRECTIONS.inspect}, got #{direction.inspect}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
normalized
|
|
72
|
+
end
|
|
73
|
+
private_class_method :normalize_direction
|
|
74
|
+
|
|
75
|
+
# The single source of truth for the tiebreaker column, shared with
|
|
76
|
+
# Cursor.seek (which calls `model.primary_key` directly). Both must derive
|
|
77
|
+
# it the same way or the ORDER BY and the seek predicate reference
|
|
78
|
+
# different columns and the keyset's total order stops existing.
|
|
79
|
+
def self.primary_key_for(model)
|
|
80
|
+
pk = model.primary_key
|
|
81
|
+
if pk.nil?
|
|
82
|
+
raise Error,
|
|
83
|
+
"#{model.name} has no primary key declared. Keyset pagination requires a single-column " \
|
|
84
|
+
"primary key to use as the sort tiebreaker."
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Rails 7.1+ composite primary keys return an Array. A keyset needs one
|
|
88
|
+
# totally-ordered tiebreaker column, so this is a clear refusal rather
|
|
89
|
+
# than a NoMethodError on Array#to_sym.
|
|
90
|
+
if pk.is_a?(Array)
|
|
91
|
+
raise Error,
|
|
92
|
+
"#{model.name} has a composite primary key (#{pk.join(", ")}). Keyset pagination " \
|
|
93
|
+
"requires a single-column primary key to use as the sort tiebreaker; composite keys " \
|
|
94
|
+
"are not supported in v0.1.0."
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
pk.to_sym
|
|
98
|
+
end
|
|
99
|
+
private_class_method :primary_key_for
|
|
100
|
+
|
|
101
|
+
# Resolves the requested field against the whitelist and returns the
|
|
102
|
+
# canonical, declaration-supplied name. Everything that can go wrong is a
|
|
103
|
+
# raise: an unsortable field must not silently fall back to the tiebreaker,
|
|
104
|
+
# or a client gets a page ordered differently from the one its cursor was
|
|
105
|
+
# issued under.
|
|
106
|
+
def self.resolve_field(model, allowed, field, tiebreaker)
|
|
107
|
+
return tiebreaker if field.nil? || field.to_s.empty?
|
|
108
|
+
|
|
109
|
+
requested = field.to_s
|
|
110
|
+
# The primary key is implicitly sortable — it is the tiebreaker on
|
|
111
|
+
# every other sort.
|
|
112
|
+
return tiebreaker if requested == tiebreaker.to_s
|
|
113
|
+
|
|
114
|
+
name, entry = match(allowed, requested)
|
|
115
|
+
if name.nil?
|
|
116
|
+
raise Error,
|
|
117
|
+
"#{requested.inspect} is not sortable. Declared sortable fields: " \
|
|
118
|
+
"#{whitelist(allowed).map(&:first).inspect}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# A `through:` filter names a column on another table. Ordering by it
|
|
122
|
+
# would mean joining, and a join multiplies rows — exactly the pagination
|
|
123
|
+
# corruption this gem exists to avoid (SPEC.md §1a). Out of scope for
|
|
124
|
+
# v0.1.0, so say so instead of silently joining.
|
|
125
|
+
if entry.respond_to?(:through) && entry.through
|
|
126
|
+
raise Error,
|
|
127
|
+
"cannot sort by #{name.inspect}: it is declared `through: #{entry.through.inspect}`. " \
|
|
128
|
+
"Sorting on an association column is not supported in v0.1.0 — a join multiplies rows " \
|
|
129
|
+
"and breaks keyset pagination."
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
unless model.column_names.include?(name)
|
|
133
|
+
raise Error,
|
|
134
|
+
"cannot sort by #{name.inspect}: #{model.name} has no such column. " \
|
|
135
|
+
"Sorting is limited to columns on the model's own table in v0.1.0."
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# SPEC.md §6.4: keyset pagination requires sortable columns to be
|
|
139
|
+
# NOT NULL and raises otherwise. NULL never compares true, so a row with
|
|
140
|
+
# a null sort value can never be reached by the seek predicate — it just
|
|
141
|
+
# vanishes, and (depending on direction and adapter) either drops rows
|
|
142
|
+
# while reporting has_next_page: false, or wedges the client behind rows
|
|
143
|
+
# it can never seek past. This is where that requirement stops being
|
|
144
|
+
# silent: as early as this gem can know the column's nullability (the
|
|
145
|
+
# first time it is asked to sort by it), not buried in a per-request
|
|
146
|
+
# NULL that only shows up once real data has one.
|
|
147
|
+
# Deliberately not `column&.null`: safe navigation would fail OPEN here.
|
|
148
|
+
# column_names and columns_hash are not guaranteed to agree (view-backed
|
|
149
|
+
# models, attributes declared with `attribute`), and a missing entry must
|
|
150
|
+
# not mean "no NOT NULL check" — that is how the silent-row-loss bug this
|
|
151
|
+
# guard exists to prevent gets back in.
|
|
152
|
+
column = model.columns_hash[name]
|
|
153
|
+
if column.nil?
|
|
154
|
+
raise Error,
|
|
155
|
+
"cannot sort by #{name.inspect}: #{model.name} reports no column metadata for it, " \
|
|
156
|
+
"so its nullability cannot be verified. Keyset pagination requires a NOT NULL " \
|
|
157
|
+
"sortable column."
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
if column.null
|
|
161
|
+
raise Error,
|
|
162
|
+
"cannot sort by #{name.inspect}: #{model.name}##{name} (#{model.table_name}.#{name}) " \
|
|
163
|
+
"allows NULL. Keyset pagination requires sortable columns to be NOT NULL — a NULL sort " \
|
|
164
|
+
"value never compares true, so rows carrying it are silently dropped mid-pagination while " \
|
|
165
|
+
"has_next_page reports false, or (ascending) leave later rows permanently unreachable. " \
|
|
166
|
+
"Add a NOT NULL constraint to #{model.table_name}.#{name}, or remove it from `sortable_by`."
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
name.to_sym
|
|
170
|
+
end
|
|
171
|
+
private_class_method :resolve_field
|
|
172
|
+
|
|
173
|
+
# Exact match first, then case-insensitive — a GraphQL enum may arrive as
|
|
174
|
+
# "CREATED_AT". Either way the name that gets used is the whitelist's own.
|
|
175
|
+
def self.match(allowed, requested)
|
|
176
|
+
list = whitelist(allowed)
|
|
177
|
+
list.find { |name, _| name == requested } ||
|
|
178
|
+
list.find { |name, _| name.casecmp?(requested) } ||
|
|
179
|
+
[nil, nil]
|
|
180
|
+
end
|
|
181
|
+
private_class_method :match
|
|
182
|
+
|
|
183
|
+
def self.whitelist(allowed)
|
|
184
|
+
Array(allowed).map do |entry|
|
|
185
|
+
name = (entry.is_a?(Symbol) || entry.is_a?(String)) ? entry : entry.name
|
|
186
|
+
[name.to_s, entry]
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
private_class_method :whitelist
|
|
190
|
+
|
|
191
|
+
def self.model_for(scope)
|
|
192
|
+
scope.respond_to?(:klass) ? scope.klass : scope
|
|
193
|
+
end
|
|
194
|
+
private_class_method :model_for
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "graphql"
|
|
4
|
+
require_relative "graphql_declarative/version"
|
|
5
|
+
|
|
6
|
+
module GraphqlDeclarative
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
autoload :FilterInput, "graphql_declarative/filter_input"
|
|
10
|
+
autoload :Filter, "graphql_declarative/filter"
|
|
11
|
+
autoload :Sort, "graphql_declarative/sort"
|
|
12
|
+
autoload :Cursor, "graphql_declarative/cursor"
|
|
13
|
+
autoload :KeysetConnection, "graphql_declarative/keyset_connection"
|
|
14
|
+
autoload :Preloader, "graphql_declarative/preloader"
|
|
15
|
+
autoload :Resolver, "graphql_declarative/resolver"
|
|
16
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: graphql_declarative
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Raja Rajan
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-22 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: graphql
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '2.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: activerecord
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '7.0'
|
|
34
|
+
- - "<"
|
|
35
|
+
- !ruby/object:Gem::Version
|
|
36
|
+
version: '9.0'
|
|
37
|
+
type: :runtime
|
|
38
|
+
prerelease: false
|
|
39
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '7.0'
|
|
44
|
+
- - "<"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '9.0'
|
|
47
|
+
- !ruby/object:Gem::Dependency
|
|
48
|
+
name: sqlite3
|
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '2.0'
|
|
54
|
+
type: :development
|
|
55
|
+
prerelease: false
|
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '2.0'
|
|
61
|
+
description: Declare filtering, sorting, cursor pagination, and preloading on a graphql-ruby
|
|
62
|
+
resolver instead of hand-writing them per endpoint. Pagination stays correct through
|
|
63
|
+
association filters, and preloading is derived from the query's selection set.
|
|
64
|
+
email:
|
|
65
|
+
- rajuart678@gmail.com
|
|
66
|
+
executables: []
|
|
67
|
+
extensions: []
|
|
68
|
+
extra_rdoc_files: []
|
|
69
|
+
files:
|
|
70
|
+
- ".rspec"
|
|
71
|
+
- ".standard.yml"
|
|
72
|
+
- LICENSE.txt
|
|
73
|
+
- README.md
|
|
74
|
+
- Rakefile
|
|
75
|
+
- SPEC.md
|
|
76
|
+
- bench/query_count.rb
|
|
77
|
+
- gemfiles/ar_7.0.gemfile
|
|
78
|
+
- gemfiles/ar_7.1.gemfile
|
|
79
|
+
- gemfiles/ar_7.2.gemfile
|
|
80
|
+
- gemfiles/ar_8.0.gemfile
|
|
81
|
+
- lib/graphql_declarative.rb
|
|
82
|
+
- lib/graphql_declarative/cursor.rb
|
|
83
|
+
- lib/graphql_declarative/filter.rb
|
|
84
|
+
- lib/graphql_declarative/filter_input.rb
|
|
85
|
+
- lib/graphql_declarative/keyset_connection.rb
|
|
86
|
+
- lib/graphql_declarative/preloader.rb
|
|
87
|
+
- lib/graphql_declarative/resolver.rb
|
|
88
|
+
- lib/graphql_declarative/sort.rb
|
|
89
|
+
- lib/graphql_declarative/version.rb
|
|
90
|
+
- sig/graphql_declarative.rbs
|
|
91
|
+
homepage: https://github.com/RajuRajan/graphql_declarative
|
|
92
|
+
licenses:
|
|
93
|
+
- MIT
|
|
94
|
+
metadata:
|
|
95
|
+
homepage_uri: https://github.com/RajuRajan/graphql_declarative
|
|
96
|
+
post_install_message:
|
|
97
|
+
rdoc_options: []
|
|
98
|
+
require_paths:
|
|
99
|
+
- lib
|
|
100
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
101
|
+
requirements:
|
|
102
|
+
- - ">="
|
|
103
|
+
- !ruby/object:Gem::Version
|
|
104
|
+
version: 3.2.0
|
|
105
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - ">="
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '0'
|
|
110
|
+
requirements: []
|
|
111
|
+
rubygems_version: 3.5.22
|
|
112
|
+
signing_key:
|
|
113
|
+
specification_version: 4
|
|
114
|
+
summary: Declarative filtering, sorting, cursor pagination, and preloading for graphql-ruby
|
|
115
|
+
resolvers.
|
|
116
|
+
test_files: []
|