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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphqlDeclarative
4
+ VERSION = "0.1.0"
5
+ 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
@@ -0,0 +1,4 @@
1
+ module GraphqlDeclarative
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ 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: []