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,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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+ gemspec path: ".."
5
+ gem "activerecord", "~> 7.1.0"
6
+ gem "rake", "~> 13.0"
7
+ gem "rspec", "~> 3.13"
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+ gemspec path: ".."
5
+ gem "activerecord", "~> 7.2.0"
6
+ gem "rake", "~> 13.0"
7
+ gem "rspec", "~> 3.13"
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+ gemspec path: ".."
5
+ gem "activerecord", "~> 8.0.0"
6
+ gem "rake", "~> 13.0"
7
+ 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